Repository: gruntwork-io/terratest Branch: main Commit: 68667d560018 Files: 1130 Total size: 3.0 MB Directory structure: gitextract_g6sf3yo0/ ├── .circleci/ │ └── config.yml ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ ├── no-response.yml │ ├── pull_request_template.md │ └── workflows/ │ ├── ci.yml │ ├── lint.yml │ └── update-lint-config.yml ├── .gitignore ├── .golangci.yml ├── .pre-commit-config.yaml ├── CODEOWNERS ├── LICENSE ├── Makefile ├── NOTICE ├── README.md ├── REFACTOR.md ├── SECURITY.md ├── cmd/ │ ├── pick-instance-type/ │ │ └── main.go │ └── terratest_log_parser/ │ └── main.go ├── docs/ │ ├── .gitignore │ ├── CNAME │ ├── Dockerfile │ ├── Gemfile │ ├── README.md │ ├── _config.yml │ ├── _data/ │ │ ├── examples.yml │ │ └── prism_extends.yml │ ├── _docs/ │ │ ├── 01_getting-started/ │ │ │ ├── examples.md │ │ │ ├── godoc.md │ │ │ ├── introduction.md │ │ │ ├── packages-overview.md │ │ │ ├── quick-start.md │ │ │ └── testing-terragrunt.md │ │ ├── 02_testing-best-practices/ │ │ │ ├── alternative-testing-tools.md │ │ │ ├── avoid-test-caching.md │ │ │ ├── cleanup.md │ │ │ ├── debugging-interleaved-test-output.md │ │ │ ├── error-handling.md │ │ │ ├── idempotent.md │ │ │ ├── iterating-locally-using-docker.md │ │ │ ├── iterating-locally-using-test-stages.md │ │ │ ├── namespacing.md │ │ │ ├── picking-instance-types.md │ │ │ ├── testing-environment.md │ │ │ ├── timeouts-and-logging.md │ │ │ ├── tools-and-plugins.md │ │ │ └── unit-integration-end-to-end-test.md │ │ └── 04_community/ │ │ ├── contributing.md │ │ ├── license.md │ │ └── support.md │ ├── _includes/ │ │ ├── built-by.html │ │ ├── canonical-url.html │ │ ├── collection_browser/ │ │ │ ├── _cta-section.html │ │ │ ├── _doc-header.html │ │ │ ├── _doc-page.html │ │ │ ├── _doc-thumb.html │ │ │ ├── _doc-thumb__excerpt.html │ │ │ ├── _docs-list.html │ │ │ ├── _no-search-results.html │ │ │ ├── _search.html │ │ │ ├── _sidebar.html │ │ │ ├── browser.html │ │ │ └── navigation/ │ │ │ └── _collection_toc.html │ │ ├── examples/ │ │ │ ├── example.html │ │ │ └── explorer.html │ │ ├── favicon.html │ │ ├── footer.html │ │ ├── get-access.html │ │ ├── head.html │ │ ├── header-min.html │ │ ├── header.html │ │ ├── links-n-built-by.html │ │ ├── links-n-get-access.html │ │ ├── links-section.html │ │ ├── logo.html │ │ ├── navbar.html │ │ ├── scripts.html │ │ ├── share-meta.html │ │ ├── styles.html │ │ ├── switch.html │ │ └── video-player.html │ ├── _layouts/ │ │ ├── collection-browser-doc.html │ │ ├── collection-browser.html │ │ ├── contact.html │ │ ├── default.html │ │ ├── post.html │ │ └── subpage.html │ ├── _pages/ │ │ ├── 404/ │ │ │ └── 404.md │ │ ├── commercial-support/ │ │ │ └── index.html │ │ ├── contact/ │ │ │ ├── _contact-form.html │ │ │ └── index.html │ │ ├── cookie-policy/ │ │ │ └── index.md │ │ ├── docs/ │ │ │ └── index.html │ │ ├── examples/ │ │ │ └── index.html │ │ ├── index/ │ │ │ ├── _built_by.html │ │ │ ├── _cta_section.html │ │ │ ├── _header.html │ │ │ ├── _terratest-in-4-steps.html │ │ │ ├── _test-with-terratest.html │ │ │ ├── _watch.html │ │ │ └── index.html │ │ └── thanks/ │ │ └── index.html │ ├── _posts/ │ │ └── .keep │ ├── assets/ │ │ ├── css/ │ │ │ ├── _variables.scss │ │ │ ├── bootstrap/ │ │ │ │ └── scss/ │ │ │ │ └── bootstrap.scss │ │ │ ├── collection_browser.scss │ │ │ ├── components.scss │ │ │ ├── examples.scss │ │ │ ├── global.scss │ │ │ ├── pages/ │ │ │ │ ├── contact.scss │ │ │ │ ├── cookie-policy.scss │ │ │ │ ├── home.scss │ │ │ │ └── support.scss │ │ │ ├── prism.css │ │ │ ├── prism_custom.scss │ │ │ ├── styles.scss │ │ │ ├── subpage.scss │ │ │ └── utilities.scss │ │ ├── img/ │ │ │ └── favicon/ │ │ │ ├── browserconfig.xml │ │ │ └── manifest.json │ │ └── js/ │ │ ├── collection-browser_scroll.js │ │ ├── collection-browser_search.js │ │ ├── collection-browser_toc.js │ │ ├── contact-form.js │ │ ├── cookie.js │ │ ├── examples.js │ │ ├── main.js │ │ ├── prism.js │ │ └── video-player.js │ ├── docker-compose.yml │ ├── jekyll-serve.sh │ └── scripts/ │ ├── convert_adoc_to_md.sh │ └── convert_md_to_adoc.sh ├── examples/ │ ├── azure/ │ │ ├── README.md │ │ ├── terraform-azure-aci-example/ │ │ │ ├── README.md │ │ │ ├── main.tf │ │ │ ├── output.tf │ │ │ └── variables.tf │ │ ├── terraform-azure-acr-example/ │ │ │ ├── README.md │ │ │ ├── main.tf │ │ │ ├── output.tf │ │ │ └── variables.tf │ │ ├── terraform-azure-actiongroup-example/ │ │ │ ├── README.md │ │ │ ├── main.tf │ │ │ ├── output.tf │ │ │ └── variables.tf │ │ ├── terraform-azure-aks-example/ │ │ │ ├── README.md │ │ │ ├── main.tf │ │ │ ├── nginx-deployment.yml │ │ │ ├── output.tf │ │ │ └── variables.tf │ │ ├── terraform-azure-availabilityset-example/ │ │ │ ├── README.md │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ ├── terraform-azure-container-apps-example/ │ │ │ ├── README.md │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ ├── terraform-azure-cosmosdb-example/ │ │ │ ├── README.md │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ ├── terraform-azure-datafactory-example/ │ │ │ ├── README.md │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ ├── terraform-azure-disk-example/ │ │ │ ├── README.md │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ ├── terraform-azure-example/ │ │ │ ├── README.md │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ ├── terraform-azure-frontdoor-example/ │ │ │ ├── README.md │ │ │ ├── main.tf │ │ │ ├── output.tf │ │ │ └── variables.tf │ │ ├── terraform-azure-functionapp-example/ │ │ │ ├── README.md │ │ │ ├── main.tf │ │ │ ├── output.tf │ │ │ └── variables.tf │ │ ├── terraform-azure-keyvault-example/ │ │ │ ├── README.md │ │ │ ├── example.pfx │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ ├── terraform-azure-loadbalancer-example/ │ │ │ ├── README.md │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ ├── terraform-azure-loganalytics-example/ │ │ │ ├── README.md │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ ├── terraform-azure-monitor-example/ │ │ │ ├── README.md │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ ├── terraform-azure-mysqldb-example/ │ │ │ ├── README.md │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ ├── terraform-azure-network-example/ │ │ │ ├── README.md │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ ├── terraform-azure-nsg-example/ │ │ │ ├── README.md │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ ├── terraform-azure-postgresql-example/ │ │ │ ├── README.md │ │ │ ├── main.tf │ │ │ ├── output.tf │ │ │ └── variables.tf │ │ ├── terraform-azure-recoveryservices-example/ │ │ │ ├── README.md │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ ├── terraform-azure-resourcegroup-example/ │ │ │ ├── README.md │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ ├── terraform-azure-servicebus-example/ │ │ │ ├── README.md │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ ├── terraform-azure-sqldb-example/ │ │ │ ├── README.md │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ ├── terraform-azure-sqlmanagedinstance-example/ │ │ │ ├── README.md │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ ├── terraform-azure-storage-example/ │ │ │ ├── README.md │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ ├── terraform-azure-synapse-example/ │ │ │ ├── README.md │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ └── terraform-azure-vm-example/ │ │ ├── README.md │ │ ├── main.tf │ │ ├── outputs.tf │ │ └── variables.tf │ ├── docker-compose-stdout-example/ │ │ ├── Dockerfile │ │ ├── bash_script.sh │ │ └── docker-compose.yml │ ├── docker-hello-world-example/ │ │ ├── Dockerfile │ │ └── README.md │ ├── helm-basic-example/ │ │ ├── Chart.yaml │ │ ├── README.md │ │ ├── templates/ │ │ │ ├── _helpers.tpl │ │ │ ├── deployment.yaml │ │ │ └── service.yaml │ │ └── values.yaml │ ├── helm-dependency-example/ │ │ ├── .gitignore │ │ ├── Chart.yaml │ │ ├── README.md │ │ ├── templates/ │ │ │ ├── _helpers.tpl │ │ │ ├── deployment.yaml │ │ │ └── service.yaml │ │ └── values.yaml │ ├── kubernetes-basic-example/ │ │ ├── README.md │ │ ├── nginx-deployment.yml │ │ └── podinfo-daemonset.yml │ ├── kubernetes-hello-world-example/ │ │ ├── README.md │ │ └── hello-world-deployment.yml │ ├── kubernetes-kustomize-example/ │ │ ├── README.md │ │ ├── deployment.yaml │ │ ├── kustomization.yaml │ │ └── service.yaml │ ├── kubernetes-rbac-example/ │ │ ├── README.md │ │ └── namespace-service-account.yml │ ├── packer-basic-example/ │ │ ├── README.md │ │ ├── build-gcp.pkr.hcl │ │ └── build.pkr.hcl │ ├── packer-docker-example/ │ │ ├── README.md │ │ ├── app.rb │ │ ├── build.json │ │ ├── build.pkr.hcl │ │ ├── configure-sinatra-app.sh │ │ └── docker-compose.yml │ ├── packer-hello-world-example/ │ │ ├── README.md │ │ └── build.pkr.hcl │ ├── terraform-asg-scp-example/ │ │ ├── README.md │ │ ├── main.tf │ │ ├── outputs.tf │ │ └── variables.tf │ ├── terraform-aws-dynamodb-example/ │ │ ├── README.md │ │ ├── main.tf │ │ └── variables.tf │ ├── terraform-aws-ec2-windows-example/ │ │ ├── README.md │ │ ├── main.tf │ │ ├── outputs.tf │ │ ├── packer/ │ │ │ ├── build.pkr.hcl │ │ │ └── scripts/ │ │ │ ├── bootstrap_windows.txt │ │ │ ├── install_chocolatey.ps1 │ │ │ └── install_packages.ps1 │ │ └── variables.tf │ ├── terraform-aws-ecs-example/ │ │ ├── README.md │ │ ├── main.tf │ │ ├── outputs.tf │ │ └── variables.tf │ ├── terraform-aws-example/ │ │ ├── README.md │ │ ├── main.tf │ │ ├── outputs.tf │ │ └── variables.tf │ ├── terraform-aws-hello-world-example/ │ │ ├── README.md │ │ └── main.tf │ ├── terraform-aws-lambda-example/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── main.tf │ │ ├── src/ │ │ │ ├── README.md │ │ │ └── bootstrap.go │ │ └── variables.tf │ ├── terraform-aws-network-example/ │ │ ├── README.md │ │ ├── main.tf │ │ ├── output.tf │ │ └── variables.tf │ ├── terraform-aws-rds-example/ │ │ ├── README.md │ │ ├── main.tf │ │ ├── outputs.tf │ │ └── variables.tf │ ├── terraform-aws-s3-example/ │ │ ├── README.md │ │ ├── main.tf │ │ ├── outputs.tf │ │ └── variables.tf │ ├── terraform-aws-ssm-example/ │ │ ├── README.md │ │ ├── main.tf │ │ ├── outputs.tf │ │ └── variables.tf │ ├── terraform-backend-example/ │ │ ├── README.md │ │ └── main.tf │ ├── terraform-basic-example/ │ │ ├── README.md │ │ ├── main.tf │ │ ├── outputs.tf │ │ ├── varfile.tfvars │ │ └── variables.tf │ ├── terraform-database-example/ │ │ ├── REAME.md │ │ ├── main.tf │ │ ├── outputs.tf │ │ └── variables.tf │ ├── terraform-gcp-example/ │ │ ├── README.md │ │ ├── main.tf │ │ ├── outputs.tf │ │ └── variables.tf │ ├── terraform-gcp-hello-world-example/ │ │ ├── README.md │ │ └── main.tf │ ├── terraform-gcp-ig-example/ │ │ ├── README.md │ │ ├── main.tf │ │ ├── outputs.tf │ │ └── variables.tf │ ├── terraform-hello-world-example/ │ │ ├── README.md │ │ └── main.tf │ ├── terraform-http-example/ │ │ ├── README.md │ │ ├── main.tf │ │ ├── outputs.tf │ │ ├── user-data/ │ │ │ └── user-data.sh │ │ └── variables.tf │ ├── terraform-opa-example/ │ │ ├── README.md │ │ ├── fail/ │ │ │ ├── main_fail.tf │ │ │ ├── output.tf │ │ │ └── variables.tf │ │ ├── pass/ │ │ │ ├── main_pass.tf │ │ │ ├── output.tf │ │ │ └── variables.tf │ │ └── policy/ │ │ ├── enforce_source.rego │ │ └── enforce_source_v0.rego │ ├── terraform-packer-example/ │ │ ├── README.md │ │ ├── main.tf │ │ ├── outputs.tf │ │ ├── user-data/ │ │ │ └── user-data.sh │ │ └── variables.tf │ ├── terraform-redeploy-example/ │ │ ├── README.md │ │ ├── main.tf │ │ ├── outputs.tf │ │ ├── user-data/ │ │ │ └── user-data.sh │ │ └── variables.tf │ ├── terraform-remote-exec-example/ │ │ ├── README.md │ │ ├── files/ │ │ │ └── get-public-ip.sh │ │ ├── main.tf │ │ ├── outputs.tf │ │ └── variables.tf │ ├── terraform-ssh-certificate-example/ │ │ ├── README.md │ │ ├── main.tf │ │ ├── outputs.tf │ │ ├── user_data.sh │ │ └── variables.tf │ ├── terraform-ssh-example/ │ │ ├── README.md │ │ ├── main.tf │ │ ├── outputs.tf │ │ └── variables.tf │ ├── terraform-ssh-password-example/ │ │ ├── README.md │ │ ├── main.tf │ │ ├── outputs.tf │ │ ├── user_data.sh │ │ └── variables.tf │ ├── terragrunt-example/ │ │ ├── README.md │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── terragrunt-multi-module-example/ │ │ ├── README.md │ │ ├── live/ │ │ │ ├── app/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── database/ │ │ │ │ └── terragrunt.hcl │ │ │ └── vpc/ │ │ │ └── terragrunt.hcl │ │ └── modules/ │ │ ├── app/ │ │ │ └── main.tf │ │ ├── database/ │ │ │ └── main.tf │ │ └── vpc/ │ │ └── main.tf │ └── terragrunt-second-example/ │ ├── main.tf │ └── terragrunt.hcl ├── go.mod ├── go.sum ├── internal/ │ └── lib/ │ └── formatting/ │ ├── format.go │ └── format_test.go ├── mise.toml ├── modules/ │ ├── aws/ │ │ ├── account.go │ │ ├── account_test.go │ │ ├── acm.go │ │ ├── ami.go │ │ ├── ami_test.go │ │ ├── asg.go │ │ ├── asg_test.go │ │ ├── auth.go │ │ ├── aws.go │ │ ├── cloudwatch.go │ │ ├── dynamodb.go │ │ ├── ebs.go │ │ ├── ec2-files.go │ │ ├── ec2-syslog.go │ │ ├── ec2.go │ │ ├── ec2_test.go │ │ ├── ecr.go │ │ ├── ecr_test.go │ │ ├── ecs.go │ │ ├── ecs_test.go │ │ ├── errors.go │ │ ├── iam.go │ │ ├── iam_test.go │ │ ├── keypair.go │ │ ├── keypair_test.go │ │ ├── kms.go │ │ ├── lambda.go │ │ ├── lambda_test.go │ │ ├── rds.go │ │ ├── rds_test.go │ │ ├── region.go │ │ ├── region_test.go │ │ ├── route53.go │ │ ├── route53_test.go │ │ ├── s3.go │ │ ├── s3_test.go │ │ ├── secretsmanager.go │ │ ├── secretsmanager_test.go │ │ ├── sns.go │ │ ├── sns_test.go │ │ ├── sqs.go │ │ ├── sqs_test.go │ │ ├── ssm.go │ │ ├── ssm_test.go │ │ ├── vpc.go │ │ └── vpc_test.go │ ├── azure/ │ │ ├── actiongroup.go │ │ ├── actiongroup_test.go │ │ ├── aks.go │ │ ├── appService.go │ │ ├── appService_test.go │ │ ├── authorizer.go │ │ ├── availabilityset.go │ │ ├── availabilityset_test.go │ │ ├── azure.go │ │ ├── client_factory.go │ │ ├── client_factory_test.go │ │ ├── common.go │ │ ├── common_test.go │ │ ├── compute.go │ │ ├── compute_test.go │ │ ├── container_apps.go │ │ ├── container_apps_test.go │ │ ├── containers.go │ │ ├── containers_test.go │ │ ├── cosmosdb.go │ │ ├── datafactory.go │ │ ├── datafactory_test.go │ │ ├── disk.go │ │ ├── disk_test.go │ │ ├── enums.go │ │ ├── errors.go │ │ ├── frontdoor.go │ │ ├── frontdoor_test.go │ │ ├── keyvault.go │ │ ├── keyvault_test.go │ │ ├── loadbalancer.go │ │ ├── loadbalancer_test.go │ │ ├── loganalytics.go │ │ ├── loganalytics_test.go │ │ ├── monitor.go │ │ ├── monitor_test.go │ │ ├── mysql.go │ │ ├── mysql_test.go │ │ ├── networkinterface.go │ │ ├── networkinterface_test.go │ │ ├── nsg.go │ │ ├── nsg_test.go │ │ ├── postgresql.go │ │ ├── postgresql_test.go │ │ ├── privatednszone.go │ │ ├── privatednszone_test.go │ │ ├── publicaddress.go │ │ ├── publicaddress_test.go │ │ ├── recoveryservices.go │ │ ├── recoveryservices_test.go │ │ ├── region.go │ │ ├── region_test.go │ │ ├── resourcegroup.go │ │ ├── resourcegroup_test.go │ │ ├── resourcegroupv2.go │ │ ├── resourcegroupv2_test.go │ │ ├── resourceid.go │ │ ├── resourceid_test.go │ │ ├── servicebus.go │ │ ├── servicebus_test.go │ │ ├── sql.go │ │ ├── sql_managedinstance.go │ │ ├── sql_managedinstance_test.go │ │ ├── sql_test.go │ │ ├── storage.go │ │ ├── storage_test.go │ │ ├── subscription.go │ │ ├── synapse.go │ │ ├── synapse_test.go │ │ ├── virtualnetwork.go │ │ └── virtualnetwork_test.go │ ├── collections/ │ │ ├── collections.go │ │ ├── errors.go │ │ ├── lists.go │ │ ├── lists_test.go │ │ ├── stringslicevalue.go │ │ └── stringslicevalue_test.go │ ├── database/ │ │ └── database.go │ ├── dns-helper/ │ │ ├── dns_helper.go │ │ ├── dns_helper_test.go │ │ ├── dns_local_server.go │ │ └── errors.go │ ├── docker/ │ │ ├── build.go │ │ ├── build_test.go │ │ ├── docker.go │ │ ├── docker_compose.go │ │ ├── docker_compose_test.go │ │ ├── host.go │ │ ├── host_test.go │ │ ├── images.go │ │ ├── images_test.go │ │ ├── inspect.go │ │ ├── inspect_test.go │ │ ├── push.go │ │ ├── run.go │ │ ├── run_test.go │ │ ├── stop.go │ │ └── stop_test.go │ ├── environment/ │ │ ├── environment.go │ │ ├── envvar.go │ │ └── envvar_test.go │ ├── files/ │ │ ├── errors.go │ │ ├── files.go │ │ └── files_test.go │ ├── gcp/ │ │ ├── cloudbuild.go │ │ ├── cloudbuild_test.go │ │ ├── compute.go │ │ ├── compute_test.go │ │ ├── compute_unit_test.go │ │ ├── gcp.go │ │ ├── gcr.go │ │ ├── oslogin.go │ │ ├── oslogin_test.go │ │ ├── provider.go │ │ ├── region.go │ │ ├── region_test.go │ │ ├── static_token.go │ │ ├── static_token_test.go │ │ ├── storage.go │ │ └── storage_test.go │ ├── git/ │ │ ├── git.go │ │ └── git_test.go │ ├── helm/ │ │ ├── cmd.go │ │ ├── cmd_test.go │ │ ├── delete.go │ │ ├── errors.go │ │ ├── format.go │ │ ├── format_test.go │ │ ├── helm.go │ │ ├── install.go │ │ ├── install_test.go │ │ ├── options.go │ │ ├── repo.go │ │ ├── rollback.go │ │ ├── template.go │ │ ├── template_test.go │ │ ├── testdata/ │ │ │ ├── configmap-literalblock.yaml │ │ │ ├── deployment.yaml │ │ │ ├── deployments-array.yaml │ │ │ ├── deployments.yaml │ │ │ ├── deprecated-chart/ │ │ │ │ ├── Chart.yaml │ │ │ │ └── templates/ │ │ │ │ └── deployment.yaml │ │ │ ├── invalid-duplicate.yaml │ │ │ └── multiple-manifests/ │ │ │ ├── Chart.yaml │ │ │ └── templates/ │ │ │ ├── configmap.yaml │ │ │ └── deployment.yaml │ │ ├── upgrade.go │ │ └── upgrade_test.go │ ├── http-helper/ │ │ ├── continuous.go │ │ ├── dummy_server.go │ │ ├── dummy_server_test.go │ │ ├── errors.go │ │ ├── http_helper.go │ │ └── http_helper_test.go │ ├── k8s/ │ │ ├── client.go │ │ ├── cluster_role.go │ │ ├── cluster_role_test.go │ │ ├── config.go │ │ ├── config_test.go │ │ ├── configmap.go │ │ ├── configmap_test.go │ │ ├── cronjob.go │ │ ├── cronjob_test.go │ │ ├── daemonset.go │ │ ├── daemonset_test.go │ │ ├── deployment.go │ │ ├── deployment_test.go │ │ ├── errors.go │ │ ├── errors_test.go │ │ ├── event.go │ │ ├── event_test.go │ │ ├── ingress.go │ │ ├── ingress_test.go │ │ ├── job.go │ │ ├── job_test.go │ │ ├── jsonpath.go │ │ ├── jsonpath_test.go │ │ ├── k8s.go │ │ ├── kubectl.go │ │ ├── kubectl_options.go │ │ ├── kubectl_test.go │ │ ├── minikube.go │ │ ├── minikube_test.go │ │ ├── namespace.go │ │ ├── namespace_test.go │ │ ├── networkpolicy.go │ │ ├── networkpolicy_test.go │ │ ├── node.go │ │ ├── node_test.go │ │ ├── persistent_volume.go │ │ ├── persistent_volume_claim.go │ │ ├── persistent_volume_claim_test.go │ │ ├── persistent_volume_test.go │ │ ├── pod.go │ │ ├── pod_test.go │ │ ├── replicaset.go │ │ ├── replicaset_test.go │ │ ├── role.go │ │ ├── role_test.go │ │ ├── secret.go │ │ ├── secret_test.go │ │ ├── self_subject_access_review.go │ │ ├── self_subject_access_review_test.go │ │ ├── service.go │ │ ├── service_account.go │ │ ├── service_account_test.go │ │ ├── service_test.go │ │ ├── tunnel.go │ │ ├── tunnel_test.go │ │ ├── version.go │ │ └── version_test.go │ ├── logger/ │ │ ├── logger.go │ │ ├── logger_test.go │ │ └── parser/ │ │ ├── failed_test_marker.go │ │ ├── failed_test_marker_test.go │ │ ├── fixtures/ │ │ │ ├── basic_example.log │ │ │ ├── basic_example_expected/ │ │ │ │ ├── TestCloseChannelsClosesAll.log │ │ │ │ ├── TestEnsureDirectoryExistsCreatesDirectory.log │ │ │ │ ├── TestEnsureDirectoryExistsHandlesExistingDirectory.log │ │ │ │ ├── TestGetIndent/ │ │ │ │ │ ├── BaseCase.log │ │ │ │ │ ├── EmptyString.log │ │ │ │ │ ├── MixTabSpace.log │ │ │ │ │ ├── NoIndent.log │ │ │ │ │ └── Tabs.log │ │ │ │ ├── TestGetIndent.log │ │ │ │ ├── TestGetOrCreateChannelCreatesNewChannel.log │ │ │ │ ├── TestGetOrCreateChannelReturnsExistingChannel.log │ │ │ │ ├── TestGetOrCreateChannelSpawnsLogCollectorOnCreate.log │ │ │ │ ├── TestGetTestNameFromResultLine/ │ │ │ │ │ ├── BaseCase.log │ │ │ │ │ ├── Indented.log │ │ │ │ │ ├── SpecialChars.log │ │ │ │ │ └── WhenFailed.log │ │ │ │ ├── TestGetTestNameFromResultLine.log │ │ │ │ ├── TestGetTestNameFromStatusLine/ │ │ │ │ │ ├── BaseCase.log │ │ │ │ │ ├── Indented.log │ │ │ │ │ ├── SpecialChars.log │ │ │ │ │ ├── WhenCont.log │ │ │ │ │ └── WhenPaused.log │ │ │ │ ├── TestGetTestNameFromStatusLine.log │ │ │ │ ├── TestIsEmpty.log │ │ │ │ ├── TestIsPanicLine/ │ │ │ │ │ ├── BaseCase.log │ │ │ │ │ └── NotPanic.log │ │ │ │ ├── TestIsPanicLine.log │ │ │ │ ├── TestIsResultLine/ │ │ │ │ │ ├── BaseCase.log │ │ │ │ │ ├── Indented.log │ │ │ │ │ ├── NonResultLine.log │ │ │ │ │ ├── SpecialChars.log │ │ │ │ │ └── WhenFailed.log │ │ │ │ ├── TestIsResultLine.log │ │ │ │ ├── TestIsStatusLine/ │ │ │ │ │ ├── BaseCase.log │ │ │ │ │ ├── Indented.log │ │ │ │ │ ├── NonStatusLine.log │ │ │ │ │ ├── SpecialChars.log │ │ │ │ │ ├── WhenCont.log │ │ │ │ │ └── WhenPaused.log │ │ │ │ ├── TestIsStatusLine.log │ │ │ │ ├── TestIsSummaryLine/ │ │ │ │ │ ├── BaseCase.log │ │ │ │ │ └── NotSummary.log │ │ │ │ ├── TestIsSummaryLine.log │ │ │ │ ├── TestLogCollectorCreatesAndWritesToFile.log │ │ │ │ ├── TestPeek.log │ │ │ │ ├── TestPeekEmpty.log │ │ │ │ ├── TestRemoveDedentedTestResultMarkers.log │ │ │ │ ├── TestRemoveDedentedTestResultMarkersAll.log │ │ │ │ ├── TestRemoveDedentedTestResultMarkersEmpty.log │ │ │ │ ├── TestStackPop.log │ │ │ │ ├── TestStackPopEmpty.log │ │ │ │ ├── TestStackPush.log │ │ │ │ ├── report.xml │ │ │ │ └── summary.log │ │ │ ├── failing_example.log │ │ │ ├── failing_example_expected/ │ │ │ │ ├── TestBasicExample.log │ │ │ │ ├── TestCloseChannelsClosesAll.log │ │ │ │ ├── TestEnsureDirectoryExistsCreatesDirectory.log │ │ │ │ ├── TestEnsureDirectoryExistsHandlesExistingDirectory.log │ │ │ │ ├── TestGetIndent/ │ │ │ │ │ ├── BaseCase.log │ │ │ │ │ ├── EmptyString.log │ │ │ │ │ ├── MixTabSpace.log │ │ │ │ │ ├── NoIndent.log │ │ │ │ │ └── Tabs.log │ │ │ │ ├── TestGetIndent.log │ │ │ │ ├── TestGetOrCreateChannelCreatesNewChannel.log │ │ │ │ ├── TestGetOrCreateChannelReturnsExistingChannel.log │ │ │ │ ├── TestGetOrCreateChannelSpawnsLogCollectorOnCreate.log │ │ │ │ ├── TestGetTestNameFromResultLine/ │ │ │ │ │ ├── BaseCase.log │ │ │ │ │ ├── Indented.log │ │ │ │ │ ├── SpecialChars.log │ │ │ │ │ └── WhenFailed.log │ │ │ │ ├── TestGetTestNameFromResultLine.log │ │ │ │ ├── TestGetTestNameFromStatusLine/ │ │ │ │ │ ├── BaseCase.log │ │ │ │ │ ├── Indented.log │ │ │ │ │ ├── SpecialChars.log │ │ │ │ │ ├── WhenCont.log │ │ │ │ │ └── WhenPaused.log │ │ │ │ ├── TestGetTestNameFromStatusLine.log │ │ │ │ ├── TestIsEmpty.log │ │ │ │ ├── TestIsPanicLine/ │ │ │ │ │ ├── BaseCase.log │ │ │ │ │ └── NotPanic.log │ │ │ │ ├── TestIsPanicLine.log │ │ │ │ ├── TestIsResultLine/ │ │ │ │ │ ├── BaseCase.log │ │ │ │ │ ├── Indented.log │ │ │ │ │ ├── NonResultLine.log │ │ │ │ │ ├── SpecialChars.log │ │ │ │ │ └── WhenFailed.log │ │ │ │ ├── TestIsResultLine.log │ │ │ │ ├── TestIsStatusLine/ │ │ │ │ │ ├── BaseCase.log │ │ │ │ │ ├── Indented.log │ │ │ │ │ ├── NonStatusLine.log │ │ │ │ │ ├── SpecialChars.log │ │ │ │ │ ├── WhenCont.log │ │ │ │ │ └── WhenPaused.log │ │ │ │ ├── TestIsStatusLine.log │ │ │ │ ├── TestIsSummaryLine/ │ │ │ │ │ ├── BaseCase.log │ │ │ │ │ └── NotSummary.log │ │ │ │ ├── TestIsSummaryLine.log │ │ │ │ ├── TestLogCollectorCreatesAndWritesToFile.log │ │ │ │ ├── TestPanicExample.log │ │ │ │ ├── TestPeek.log │ │ │ │ ├── TestPeekEmpty.log │ │ │ │ ├── TestRealWorldExample.log │ │ │ │ ├── TestRemoveDedentedTestResultMarkers.log │ │ │ │ ├── TestRemoveDedentedTestResultMarkersAll.log │ │ │ │ ├── TestRemoveDedentedTestResultMarkersEmpty.log │ │ │ │ ├── TestStackPop.log │ │ │ │ ├── TestStackPopEmpty.log │ │ │ │ ├── TestStackPush.log │ │ │ │ ├── report.xml │ │ │ │ └── summary.log │ │ │ ├── new_go_failing_example.log │ │ │ ├── new_go_failing_example_expected/ │ │ │ │ ├── TestIntegrationBasicExample.log │ │ │ │ ├── TestIntegrationFailingExample.log │ │ │ │ ├── TestIntegrationPanicExample.log │ │ │ │ ├── report.xml │ │ │ │ └── summary.log │ │ │ ├── panic_example.log │ │ │ └── panic_example_expected/ │ │ │ ├── TestCloseChannelsClosesAll.log │ │ │ ├── TestEnsureDirectoryExistsCreatesDirectory.log │ │ │ ├── TestEnsureDirectoryExistsHandlesExistingDirectory.log │ │ │ ├── TestGetIndent/ │ │ │ │ ├── BaseCase.log │ │ │ │ ├── EmptyString.log │ │ │ │ ├── MixTabSpace.log │ │ │ │ ├── NoIndent.log │ │ │ │ └── Tabs.log │ │ │ ├── TestGetIndent.log │ │ │ ├── TestGetOrCreateChannelCreatesNewChannel.log │ │ │ ├── TestGetOrCreateChannelReturnsExistingChannel.log │ │ │ ├── TestGetOrCreateChannelSpawnsLogCollectorOnCreate.log │ │ │ ├── TestGetTestNameFromResultLine/ │ │ │ │ ├── BaseCase.log │ │ │ │ ├── Indented.log │ │ │ │ ├── SpecialChars.log │ │ │ │ └── WhenFailed.log │ │ │ ├── TestGetTestNameFromResultLine.log │ │ │ ├── TestGetTestNameFromStatusLine/ │ │ │ │ ├── BaseCase.log │ │ │ │ ├── Indented.log │ │ │ │ ├── SpecialChars.log │ │ │ │ ├── WhenCont.log │ │ │ │ └── WhenPaused.log │ │ │ ├── TestGetTestNameFromStatusLine.log │ │ │ ├── TestIsEmpty.log │ │ │ ├── TestIsPanicLine/ │ │ │ │ ├── BaseCase.log │ │ │ │ └── NotPanic.log │ │ │ ├── TestIsPanicLine.log │ │ │ ├── TestIsResultLine/ │ │ │ │ ├── BaseCase.log │ │ │ │ ├── Indented.log │ │ │ │ ├── NonResultLine.log │ │ │ │ ├── SpecialChars.log │ │ │ │ └── WhenFailed.log │ │ │ ├── TestIsResultLine.log │ │ │ ├── TestIsStatusLine/ │ │ │ │ ├── BaseCase.log │ │ │ │ ├── Indented.log │ │ │ │ ├── NonStatusLine.log │ │ │ │ ├── SpecialChars.log │ │ │ │ ├── WhenCont.log │ │ │ │ └── WhenPaused.log │ │ │ ├── TestIsStatusLine.log │ │ │ ├── TestIsSummaryLine/ │ │ │ │ ├── BaseCase.log │ │ │ │ └── NotSummary.log │ │ │ ├── TestIsSummaryLine.log │ │ │ ├── TestLogCollectorCreatesAndWritesToFile.log │ │ │ ├── TestPeek.log │ │ │ ├── TestPeekEmpty.log │ │ │ ├── TestRemoveDedentedTestResultMarkers.log │ │ │ ├── TestRemoveDedentedTestResultMarkersAll.log │ │ │ ├── TestRemoveDedentedTestResultMarkersEmpty.log │ │ │ ├── TestStackPop.log │ │ │ ├── TestStackPopEmpty.log │ │ │ ├── TestStackPush.log │ │ │ ├── report.xml │ │ │ └── summary.log │ │ ├── helpers_for_test.go │ │ ├── integration_test.go │ │ ├── parser.go │ │ ├── parser_test.go │ │ ├── store.go │ │ └── store_test.go │ ├── oci/ │ │ ├── compute.go │ │ ├── identity.go │ │ ├── network.go │ │ └── provider.go │ ├── opa/ │ │ ├── download_policy.go │ │ ├── download_policy_test.go │ │ ├── eval.go │ │ └── eval_test.go │ ├── packer/ │ │ ├── packer.go │ │ └── packer_test.go │ ├── random/ │ │ ├── random.go │ │ └── random_test.go │ ├── retry/ │ │ ├── retry.go │ │ └── retry_test.go │ ├── shell/ │ │ ├── command.go │ │ ├── command_test.go │ │ ├── output.go │ │ └── shell.go │ ├── slack/ │ │ ├── doc.go │ │ ├── validate.go │ │ └── validate_test.go │ ├── ssh/ │ │ ├── agent.go │ │ ├── agent_test.go │ │ ├── key_pair.go │ │ ├── key_pair_test.go │ │ ├── session.go │ │ ├── session_test.go │ │ ├── ssh.go │ │ └── ssh_test.go │ ├── terraform/ │ │ ├── apply.go │ │ ├── apply_test.go │ │ ├── cmd.go │ │ ├── cmd_test.go │ │ ├── count.go │ │ ├── count_test.go │ │ ├── destroy.go │ │ ├── errors.go │ │ ├── format.go │ │ ├── format_test.go │ │ ├── get.go │ │ ├── init.go │ │ ├── init_test.go │ │ ├── opa_check.go │ │ ├── options.go │ │ ├── options_test.go │ │ ├── output.go │ │ ├── output_test.go │ │ ├── plan.go │ │ ├── plan_struct.go │ │ ├── plan_struct_test.go │ │ ├── plan_test.go │ │ ├── show.go │ │ ├── show_test.go │ │ ├── terraform.go │ │ ├── validate.go │ │ ├── validate_test.go │ │ ├── var-file.go │ │ ├── var-file_test.go │ │ ├── var.go │ │ ├── workspace.go │ │ └── workspace_test.go │ ├── terragrunt/ │ │ ├── README.md │ │ ├── apply.go │ │ ├── apply_test.go │ │ ├── cmd.go │ │ ├── cmd_args_test.go │ │ ├── destroy.go │ │ ├── destroy_test.go │ │ ├── format.go │ │ ├── format_test.go │ │ ├── graph.go │ │ ├── graph_test.go │ │ ├── hcl_validate.go │ │ ├── hcl_validate_test.go │ │ ├── init.go │ │ ├── init_test.go │ │ ├── json_helpers.go │ │ ├── json_helpers_test.go │ │ ├── options.go │ │ ├── output.go │ │ ├── output_test.go │ │ ├── plan.go │ │ ├── plan_test.go │ │ ├── render.go │ │ ├── render_test.go │ │ ├── run.go │ │ ├── run_all.go │ │ ├── run_all_test.go │ │ ├── run_test.go │ │ ├── stack_clean.go │ │ ├── stack_clean_test.go │ │ ├── stack_generate.go │ │ ├── stack_generate_test.go │ │ ├── stack_output.go │ │ ├── stack_output_test.go │ │ ├── stack_run.go │ │ ├── stack_run_test.go │ │ ├── terragrunt_e2e_test.go │ │ ├── terragrunt_example_test.go │ │ ├── testdata/ │ │ │ ├── terragrunt-multi-plan/ │ │ │ │ ├── bar/ │ │ │ │ │ ├── main.tf │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── foo/ │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── terragrunt-no-error/ │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── terragrunt-output/ │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── terragrunt-stack-init/ │ │ │ │ ├── live/ │ │ │ │ │ ├── placeholder.tf │ │ │ │ │ ├── terragrunt.hcl │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ └── units/ │ │ │ │ ├── chick/ │ │ │ │ │ ├── main.tf │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── chicken/ │ │ │ │ │ ├── main.tf │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── father/ │ │ │ │ │ ├── main.tf │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── mother/ │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── terragrunt-stack-init-error/ │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── terragrunt-with-plan-error/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── validate.go │ │ └── validate_test.go │ ├── test-structure/ │ │ ├── save_test_data.go │ │ ├── save_test_data_test.go │ │ ├── test_structure.go │ │ ├── test_structure_test.go │ │ └── validate_struct.go │ ├── testing/ │ │ └── types.go │ └── version-checker/ │ ├── errors.go │ ├── version_checker.go │ └── version_checker_test.go ├── test/ │ ├── azure/ │ │ ├── terraform_azure_aci_example_test.go │ │ ├── terraform_azure_acr_example_test.go │ │ ├── terraform_azure_actiongroup_example_test.go │ │ ├── terraform_azure_aks_example_test.go │ │ ├── terraform_azure_availabilityset_example_test.go │ │ ├── terraform_azure_container_apps_example_test.go │ │ ├── terraform_azure_cosmosdb_example_test.go │ │ ├── terraform_azure_datafactory_example_test.go │ │ ├── terraform_azure_disk_example_test.go │ │ ├── terraform_azure_example_test.go │ │ ├── terraform_azure_frontdoor_example_test.go │ │ ├── terraform_azure_functionapp_example_test.go │ │ ├── terraform_azure_keyvault_example_test.go │ │ ├── terraform_azure_loadbalancer_example_test.go │ │ ├── terraform_azure_loganalytics_example_test.go │ │ ├── terraform_azure_monitor_example_test.go │ │ ├── terraform_azure_mysqldb_example_test.go │ │ ├── terraform_azure_network_example_test.go │ │ ├── terraform_azure_nsg_example_test.go │ │ ├── terraform_azure_postgresql_example_test.go │ │ ├── terraform_azure_recoveryservices_example_test.go │ │ ├── terraform_azure_resourcegroup_example_test.go │ │ ├── terraform_azure_servicebus_example_test.go │ │ ├── terraform_azure_sqldb_example_test.go │ │ ├── terraform_azure_sqlmanagedinstance_example_test.go │ │ ├── terraform_azure_storage_example_test.go │ │ ├── terraform_azure_synapse_example_test.go │ │ └── terraform_azure_vm_example_test.go │ ├── docker_hello_world_example_test.go │ ├── docker_stdout_example_test.go │ ├── fixtures/ │ │ ├── copy-folder-contents/ │ │ │ ├── full-copy/ │ │ │ │ ├── .hidden-file.txt │ │ │ │ ├── .terraform-version │ │ │ │ ├── foo.txt │ │ │ │ └── subfolder/ │ │ │ │ ├── .hidden-folder/ │ │ │ │ │ └── baz.txt │ │ │ │ └── bar.txt │ │ │ ├── no-hidden-files/ │ │ │ │ ├── foo.txt │ │ │ │ └── subfolder/ │ │ │ │ └── bar.txt │ │ │ ├── no-hidden-files-no-terraform-files/ │ │ │ │ ├── .terraform-version │ │ │ │ ├── foo.txt │ │ │ │ └── subfolder/ │ │ │ │ └── bar.txt │ │ │ ├── no-state-files/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── original/ │ │ │ │ ├── .hidden-file.txt │ │ │ │ ├── .terraform-version │ │ │ │ ├── foo.txt │ │ │ │ └── subfolder/ │ │ │ │ ├── .hidden-folder/ │ │ │ │ │ └── baz.txt │ │ │ │ └── bar.txt │ │ │ ├── symlinks/ │ │ │ │ ├── foo.txt │ │ │ │ └── subfolder/ │ │ │ │ └── bar.txt │ │ │ ├── symlinks-broken/ │ │ │ │ ├── foo.txt │ │ │ │ └── subfolder/ │ │ │ │ └── bar.txt │ │ │ └── terragrunt-files/ │ │ │ └── terragrunt.hcl │ │ ├── docker/ │ │ │ └── Dockerfile │ │ ├── docker-compose-with-buildkit/ │ │ │ ├── Dockerfile │ │ │ ├── bash_script.sh │ │ │ └── docker-compose.yml │ │ ├── docker-compose-with-custom-project-name/ │ │ │ └── docker-compose.yml │ │ ├── docker-with-buildkit/ │ │ │ └── Dockerfile │ │ ├── helm/ │ │ │ └── keda-values.yaml │ │ ├── terraform-backend/ │ │ │ ├── backend.hcl │ │ │ └── main.tf │ │ ├── terraform-basic-configuration/ │ │ │ └── main.tf │ │ ├── terraform-no-error/ │ │ │ └── main.tf │ │ ├── terraform-not-idempotent/ │ │ │ └── main.tf │ │ ├── terraform-null/ │ │ │ └── main.tf │ │ ├── terraform-output/ │ │ │ └── output.tf │ │ ├── terraform-output-all/ │ │ │ └── output.tf │ │ ├── terraform-output-list/ │ │ │ └── output.tf │ │ ├── terraform-output-listofobjects/ │ │ │ └── output.tf │ │ ├── terraform-output-map/ │ │ │ └── output.tf │ │ ├── terraform-output-mapofobjects/ │ │ │ └── output.tf │ │ ├── terraform-output-struct/ │ │ │ └── output.tf │ │ ├── terraform-parallelism/ │ │ │ └── main.tf │ │ ├── terraform-validation-valid/ │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── vars.tf │ │ ├── terraform-with-error/ │ │ │ └── main.tf │ │ ├── terraform-with-plan-error/ │ │ │ └── main.tf │ │ ├── terraform-with-warning/ │ │ │ └── main.tf │ │ └── terraform-workspace/ │ │ └── main.tf │ ├── gcp/ │ │ ├── packer_gcp_basic_example_test.go │ │ ├── terraform_gcp_example_test.go │ │ ├── terraform_gcp_hello_world_example_test.go │ │ └── terraform_gcp_ig_example_test.go │ ├── helm_basic_example_integration_test.go │ ├── helm_basic_example_template_test.go │ ├── helm_dependency_example_template_test.go │ ├── helm_keda_remote_example_template_snapshot_test.go │ ├── helm_keda_remote_example_template_test.go │ ├── helm_log_redirect_integration_test.go │ ├── kubernetes_basic_example_logs_test.go │ ├── kubernetes_basic_example_service_check_test.go │ ├── kubernetes_basic_example_test.go │ ├── kubernetes_hello_world_example_test.go │ ├── kubernetes_kustomize_example_test.go │ ├── kubernetes_rbac_example_test.go │ ├── kubernetes_rest_config_example_test.go │ ├── packer_basic_example_test.go │ ├── packer_docker_example_test.go │ ├── packer_hello_world_example_test.go │ ├── packer_oci_example_test.go │ ├── terraform_aws_dynamodb_example_test.go │ ├── terraform_aws_ec2_windows_test.go │ ├── terraform_aws_ecs_example_test.go │ ├── terraform_aws_example_plan_test.go │ ├── terraform_aws_example_test.go │ ├── terraform_aws_hello_world_example_test.go │ ├── terraform_aws_lambda_example_test.go │ ├── terraform_aws_network_example_test.go │ ├── terraform_aws_rds_example_test.go │ ├── terraform_aws_s3_example_test.go │ ├── terraform_aws_ssm_example_test.go │ ├── terraform_backend_example_test.go │ ├── terraform_basic_example_regression_test.go │ ├── terraform_basic_example_test.go │ ├── terraform_database_example_test.go │ ├── terraform_hello_world_example_test.go │ ├── terraform_http_example_test.go │ ├── terraform_opa_example_extra_args_test.go │ ├── terraform_opa_example_test.go │ ├── terraform_packer_example_test.go │ ├── terraform_redeploy_example_test.go │ ├── terraform_remote_exec_example_test.go │ ├── terraform_scp_example_test.go │ ├── terraform_ssh_certificate_example_test.go │ ├── terraform_ssh_example_test.go │ ├── terraform_ssh_password_example_test.go │ └── terraform_unit_null_test.go └── test-docker-images/ ├── README.md ├── gruntwork-amazon-linux-test/ │ ├── Dockerfile │ └── README.md ├── gruntwork-centos-test/ │ ├── Dockerfile │ └── README.md ├── gruntwork-ubuntu-test/ │ ├── Dockerfile │ └── README.md └── moto/ ├── Dockerfile └── README.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: .circleci/config.yml ================================================ env: &env environment: GRUNTWORK_INSTALLER_VERSION: v0.0.36 MODULE_CI_VERSION: v0.46.0 MODULE_GCP_CI_VERSION: v0.1.1 MODULE_CI_CIRCLECI_HELPER_VERSION: v0.56.0 TERRAFORM_VERSION: 1.5.7 TOFU_VERSION: 1.8.0 PACKER_VERSION: 1.10.0 TERRAGRUNT_VERSION: v0.80.4 TERRAGRUNT_TEST_VERSION: v0.93.10 # Version used for terragrunt module tests OPA_VERSION: v1.1.0 GO_VERSION: 1.26.0 GO111MODULE: auto K8S_VERSION: v1.28.0 # Same as EKS MINIKUBE_VERSION: v1.35.0 CRI_DOCKERD_VERSION: v0.3.16 CNI_PLUGINS_VERSION: v1.6.2 HELM_VERSION: v3.13.1 KUBECONFIG: /home/circleci/.kube/config BIN_BUILD_PARALLELISM: 3 MISE_VERSION: v2024.4.0 # Mise ASDF defaults to using main.tf to determine the terraform version to use, so we need to # override this to use the .terraform-version file instead. ASDF_HASHICORP_TERRAFORM_VERSION_FILE: .terraform-version defaults: &defaults machine: enabled: true image: ubuntu-2004:2022.10.1 <<: *env setup_minikube: &setup_minikube command: | sudo apt update -y sudo apt install -y conntrack # Install cri-dockerd (required for minikube none driver with K8s v1.24+) CRI_DOCKERD_VERSION_NUM="${CRI_DOCKERD_VERSION#v}" curl -sLO "https://github.com/Mirantis/cri-dockerd/releases/download/${CRI_DOCKERD_VERSION}/cri-dockerd-${CRI_DOCKERD_VERSION_NUM}.amd64.tgz" tar xzf "cri-dockerd-${CRI_DOCKERD_VERSION_NUM}.amd64.tgz" sudo install -m 0755 cri-dockerd/cri-dockerd /usr/local/bin/cri-dockerd # Set up cri-dockerd systemd service curl -sLo /tmp/cri-docker.service "https://raw.githubusercontent.com/Mirantis/cri-dockerd/${CRI_DOCKERD_VERSION}/packaging/systemd/cri-docker.service" curl -sLo /tmp/cri-docker.socket "https://raw.githubusercontent.com/Mirantis/cri-dockerd/${CRI_DOCKERD_VERSION}/packaging/systemd/cri-docker.socket" sudo mv /tmp/cri-docker.service /tmp/cri-docker.socket /etc/systemd/system/ sudo sed -i -e 's,/usr/bin/cri-dockerd,/usr/local/bin/cri-dockerd,' /etc/systemd/system/cri-docker.service sudo systemctl daemon-reload sudo systemctl enable --now cri-docker.socket # Install CNI plugins (required for minikube none driver with K8s v1.24+) curl -sLO "https://github.com/containernetworking/plugins/releases/download/${CNI_PLUGINS_VERSION}/cni-plugins-linux-amd64-${CNI_PLUGINS_VERSION}.tgz" sudo mkdir -p /opt/cni/bin sudo tar -C /opt/cni/bin -xzf "cni-plugins-linux-amd64-${CNI_PLUGINS_VERSION}.tgz" # Retry setup-minikube to reduce flakes from transient GPG key retrieval timeouts for i in 1 2 3; do echo "Attempt $i: setting up minikube..." if setup-minikube --k8s-version "$K8S_VERSION" --minikube-version "$MINIKUBE_VERSION"; then break fi echo "setup-minikube failed on attempt $i" if [ "$i" -eq 3 ]; then echo "setup-minikube failed after 3 attempts" exit 1 fi sleep 10 done install_helm: &install_helm name: install helm command: | # install helm curl -Lo helm.tar.gz https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz tar -xvf helm.tar.gz chmod +x linux-amd64/helm sudo mv linux-amd64/helm /usr/local/bin/ install_gruntwork_utils: &install_gruntwork_utils name: install gruntwork utils command: | curl -Ls https://raw.githubusercontent.com/gruntwork-io/gruntwork-installer/master/bootstrap-gruntwork-installer.sh | bash /dev/stdin --version "${GRUNTWORK_INSTALLER_VERSION}" gruntwork-install --module-name "gruntwork-module-circleci-helpers" --repo "https://github.com/gruntwork-io/terraform-aws-ci" --tag "${MODULE_CI_CIRCLECI_HELPER_VERSION}" gruntwork-install --module-name "kubernetes-circleci-helpers" --repo "https://github.com/gruntwork-io/terraform-aws-ci" --tag "${MODULE_CI_VERSION}" gruntwork-install --module-name "gcp-helpers" --repo "https://github.com/gruntwork-io/terraform-google-ci" --tag "${MODULE_GCP_CI_VERSION}" configure-environment-for-gruntwork-module \ --mise-version ${MISE_VERSION} \ --terraform-version ${TERRAFORM_VERSION} \ --terragrunt-version ${TERRAGRUNT_VERSION} \ --packer-version ${PACKER_VERSION} \ --go-version NONE # Install OPA echo "Installing OPA version ${OPA_VERSION}" curl -sLO "https://github.com/open-policy-agent/opa/releases/download/${OPA_VERSION}/opa_linux_amd64_static" chmod +x ./opa_linux_amd64_static sudo mv ./opa_linux_amd64_static /usr/local/bin/opa # Temporary fix for installing go - remove when we can update gruntwork-module-circleci-helpers to version with fix GO_VERSION="${GO_VERSION:-1.26.0}" echo "Installing Go version ${GO_VERSION}" curl -O --silent --location --fail --show-error --retry 3 "https://golang.org/dl/go${GO_VERSION}.linux-amd64.tar.gz" sudo rm -rf /usr/local/go sudo tar -C /usr/local -xzf "go${GO_VERSION}.linux-amd64.tar.gz" sudo rm -f /usr/bin/go sudo ln -sf /usr/local/go/bin/go /usr/bin/go echo "The installed version of Go is now $(go version)" install_tofu: &install_tofu name: Install OpenTofu command: | curl -L "https://github.com/opentofu/opentofu/releases/download/v${TOFU_VERSION}/tofu_${TOFU_VERSION}_linux_amd64.zip" -o tofu.zip unzip -o tofu.zip sudo install -m 0755 tofu /usr/local/bin/tofu rm -rf tofu rm -rf tofu.zip tofu --version install_terragrunt_latest: &install_terragrunt_latest name: Install Terragrunt (latest test version) command: | echo "Installing Terragrunt ${TERRAGRUNT_TEST_VERSION}..." curl -sL "https://github.com/gruntwork-io/terragrunt/releases/download/${TERRAGRUNT_TEST_VERSION}/terragrunt_linux_amd64" -o /tmp/terragrunt chmod +x /tmp/terragrunt sudo mv /tmp/terragrunt /usr/local/bin/terragrunt terragrunt --version install_docker_buildx: &install_docker_buildx name: install docker buildx command: | curl -sLO https://github.com/docker/buildx/releases/download/v0.6.1/buildx-v0.6.1.linux-amd64 mkdir -p ~/.docker/cli-plugins mv buildx-v0.6.1.linux-amd64 ~/.docker/cli-plugins/docker-buildx chmod a+x ~/.docker/cli-plugins/docker-buildx # Verify buildx is available docker buildx create --use configure_environment_for_gcp: &configure_environment_for_gcp name: configure environment for gcp command: | # install the Google Cloud SDK CLI install-gcloud # Make GCP Service Account credentials available as a file echo $GCLOUD_SERVICE_KEY > ${HOME}/gcloud-service-key.json echo 'export GOOGLE_APPLICATION_CREDENTIALS=${HOME}/gcloud-service-key.json' >> $BASH_ENV # Tell gcloud to use the credentials and set defaults echo $GCLOUD_SERVICE_KEY | gcloud auth activate-service-account --key-file=- gcloud --quiet config set project ${GOOGLE_PROJECT_ID} gcloud --quiet config set compute/zone ${GOOGLE_COMPUTE_ZONE} version: 2 jobs: setup: <<: *env resource_class: xlarge docker: - image: cimg/python:3.10.2 steps: - checkout - restore_cache: keys: - gomod-{{ checksum "go.sum" }} # Install gruntwork utilities - run: <<: *install_gruntwork_utils - save_cache: key: gomod-{{ checksum "go.sum" }} paths: - $HOME/go/src/ # The weird way you have to set PATH in Circle 2.0 - run: | echo 'export PATH=$HOME/.local/bin:$HOME/go/bin:$HOME/terraform:$HOME/packer:$PATH' >> $BASH_ENV # Run pre-commit hooks and fail the build if any hook finds required changes. - run: name: run precommit command: | go install golang.org/x/tools/cmd/goimports@latest # Install the latest minor version for v2 pip install pre-commit~=2.9 pre-commit install pre-commit run --all-files # Build any binaries that need to be built # We always want to build the binaries to test that there are no compile failures. Also, we will use the # terratest_log_parser to parse out the test output during a failure. Finally, on releases, we'll push these # binaries to GitHub as release assets. - run: command: | # For some reason, the circleci environment requires additional module dependencies that are not captured by # our Linux or Mac OSX dev environments. We workaround this by running `go mod tidy` in the CircleCI # environment so it pulls in what it needs. go mod tidy go install github.com/mitchellh/gox@latest GO_ENABLED=0 build-go-binaries \ --parallel "$BIN_BUILD_PARALLELISM" \ --app-name terratest_log_parser \ --src-path ./cmd/terratest_log_parser \ --dest-path ./cmd/bin \ --ld-flags "-X main.VERSION=$CIRCLE_TAG -extldflags '-static'" GO_ENABLED=0 build-go-binaries \ --parallel "$BIN_BUILD_PARALLELISM" \ --app-name pick-instance-type \ --src-path ./cmd/pick-instance-type \ --dest-path ./cmd/bin \ --ld-flags "-X main.VERSION=$CIRCLE_TAG -extldflags '-static'" when: always - persist_to_workspace: root: /home/circleci paths: - project # run tests with terraform binary terraform_test: <<: *defaults resource_class: xlarge steps: - attach_workspace: at: /home/circleci - run: <<: *install_gruntwork_utils - run: <<: *install_docker_buildx # The weird way you have to set PATH in Circle 2.0 - run: | echo 'export PATH=$HOME/.local/bin:$HOME/terraform:$HOME/packer:$PATH' >> $BASH_ENV # Run the tests. Note that we set the "-p 1" flag to tell Go to run tests in each package sequentially. Without # this, Go buffers all log output until all packages are done, which with slower running tests can cause CircleCI # to kill the build after more than 10 minutes without log output. # NOTE: because this doesn't build with the kubernetes tag, it will not run the kubernetes tests. See # kubernetes_test build steps. # NOTE: terragrunt tests are excluded here and run in a separate terragrunt_test job. - run: mkdir -p /tmp/logs # check we can compile the azure code, but don't actually run the tests - run: run-go-tests --packages "-p 1 -tags=azure -run IDontExist ./modules/azure" - run: | # Run only terraform module tests run-go-tests --packages "-p 1 ./modules/terraform" | tee /tmp/logs/test_output.log - run: command: | ./cmd/bin/terratest_log_parser_linux_amd64 --testlog /tmp/logs/test_output.log --outputdir /tmp/logs when: always # Store test result and log artifacts for browsing purposes - store_artifacts: path: /tmp/logs - store_test_results: path: /tmp/logs # run tests with tofu binary terraform_test_tofu: <<: *defaults resource_class: large steps: - attach_workspace: at: /home/circleci - run: <<: *install_gruntwork_utils - run: <<: *install_docker_buildx - run: <<: *install_tofu # The weird way you have to set PATH in Circle 2.0 - run: | echo 'export PATH=$HOME/.local/bin:$HOME/terraform:$HOME/packer:$PATH' >> $BASH_ENV # remove terraform binary so tofu will be used sudo rm -f $(which terraform) # Run the tests. Note that we set the "-p 1" flag to tell Go to run tests in each package sequentially. Without # this, Go buffers all log output until all packages are done, which with slower running tests can cause CircleCI # to kill the build after more than 10 minutes without log output. # NOTE: because this doesn't build with the kubernetes tag, it will not run the kubernetes tests. See # kubernetes_test build steps. # NOTE: terragrunt tests are excluded here and run in a separate terragrunt_test job. - run: mkdir -p /tmp/logs # check we can compile the azure code, but don't actually run the tests - run: run-go-tests --packages "-p 1 -tags=azure -run IDontExist ./modules/azure" - run: | # Run only terraform module tests run-go-tests --packages "-p 1 ./modules/terraform" | tee /tmp/logs/test_output.log - run: command: | ./cmd/bin/terratest_log_parser_linux_amd64 --testlog /tmp/logs/test_output.log --outputdir /tmp/logs when: always # Store test result and log artifacts for browsing purposes - store_artifacts: path: /tmp/logs - store_test_results: path: /tmp/logs # We run the GCP tests in a separate build step using the Docker executor for better isolation and resiliency. Using # The Docker executor ensures GCP tests do not erroneously make metadata network calls within CircleCI's private # environment. For more information see: https://github.com/gruntwork-io/terratest/pull/765. gcp_test: <<: *env docker: - image: cimg/base:2022.03 steps: - attach_workspace: at: /home/circleci - run: <<: *install_gruntwork_utils # The weird way you have to set PATH in Circle 2.0 - run: | echo 'export PATH=$HOME/.local/bin:$HOME/terraform:$HOME/packer:$PATH' >> $BASH_ENV - run: <<: *configure_environment_for_gcp # Run the GCP tests. These tests are run because the gcp build tag is included, and we explicitly # select the GCP tests - run: command: | mkdir -p /tmp/logs # Run the unit tests first, then the integration tests. They are separate because the integration tests # require additional filtering. run-go-tests --packages "-tags gcp ./modules/gcp" | tee /tmp/logs/test_output.log run-go-tests --packages "-tags=gcp ./test/gcp" | tee /tmp/logs/test_output.log - run: command: | ./cmd/bin/terratest_log_parser_linux_amd64 --testlog /tmp/logs/test_output.log --outputdir /tmp/logs when: always # Store test result and log artifacts for browsing purposes - store_artifacts: path: /tmp/logs - store_test_results: path: /tmp/logs kubernetes_test: <<: *defaults steps: - attach_workspace: at: /home/circleci - run: <<: *install_gruntwork_utils # The weird way you have to set PATH in Circle 2.0 - run: | echo 'export PATH=$HOME/.local/bin:$HOME/terraform:$HOME/packer:$PATH' >> $BASH_ENV - run: <<: *setup_minikube # Run the Kubernetes tests. These tests are run because the kubernetes build tag is included, and we explicitly # select the kubernetes tests - run: command: | mkdir -p /tmp/logs # Run the unit tests first, then the integration tests. They are separate because the integration tests # require additional filtering. run-go-tests --packages "-tags kubernetes ./modules/k8s" | tee /tmp/logs/test_output.log run-go-tests --packages "-tags kubernetes -run TestKubernetes ./test" | tee -a /tmp/logs/test_output.log - run: command: | ./cmd/bin/terratest_log_parser_linux_amd64 --testlog /tmp/logs/test_output.log --outputdir /tmp/logs when: always # Store test result and log artifacts for browsing purposes - store_artifacts: path: /tmp/logs - store_test_results: path: /tmp/logs helm_test: <<: *defaults resource_class: large steps: - attach_workspace: at: /home/circleci - run: <<: *install_gruntwork_utils # The weird way you have to set PATH in Circle 2.0 - run: | echo 'export PATH=$HOME/.local/bin:$HOME/terraform:$HOME/packer:$PATH' >> $BASH_ENV - run: <<: *setup_minikube - run: <<: *install_helm # Run the Helm tests. These tests are run because the helm build tag is included, and we explicitly # select the helm tests - run: command: | mkdir -p /tmp/logs # Run the unit tests first, then the integration tests. They are separate because the integration tests # require additional filtering. run-go-tests --packages "-tags helm ./modules/helm" | tee /tmp/logs/test_output.log run-go-tests --packages "-tags helm -run TestHelm ./test" | tee -a /tmp/logs/test_output.log - run: command: | ./cmd/bin/terratest_log_parser_linux_amd64 --testlog /tmp/logs/test_output.log --outputdir /tmp/logs when: always # Store test result and log artifacts for browsing purposes - store_artifacts: path: /tmp/logs - store_test_results: path: /tmp/logs # Dedicated terragrunt tests with terraform as the underlying IaC binary terragrunt_test: <<: *defaults resource_class: large steps: - attach_workspace: at: /home/circleci - run: <<: *install_gruntwork_utils - run: <<: *install_terragrunt_latest - run: <<: *install_docker_buildx # The weird way you have to set PATH in Circle 2.0 - run: | echo 'export PATH=$HOME/.local/bin:$HOME/terraform:$HOME/packer:$PATH' >> $BASH_ENV # Run the terragrunt-specific tests. These tests specifically target the terragrunt module # and require terragrunt binary to be available (which is installed via install_gruntwork_utils) - run: command: | mkdir -p /tmp/logs # Run only the terragrunt module tests run-go-tests --packages "-p 1 ./modules/terragrunt" | tee /tmp/logs/test_output.log - run: command: | ./cmd/bin/terratest_log_parser_linux_amd64 --testlog /tmp/logs/test_output.log --outputdir /tmp/logs when: always # Store test result and log artifacts for browsing purposes - store_artifacts: path: /tmp/logs - store_test_results: path: /tmp/logs # Dedicated terragrunt tests with tofu as the underlying IaC binary terragrunt_test_tofu: <<: *defaults resource_class: large steps: - attach_workspace: at: /home/circleci - run: <<: *install_gruntwork_utils - run: <<: *install_tofu - run: <<: *install_terragrunt_latest - run: <<: *install_docker_buildx # The weird way you have to set PATH in Circle 2.0 - run: | echo 'export PATH=$HOME/.local/bin:$HOME/terraform:$HOME/packer:$PATH' >> $BASH_ENV # Remove terraform binary so tofu will be used by terragrunt sudo rm -f $(which terraform) # Verify tofu is available which tofu tofu --version # Run the terragrunt-specific tests with tofu as the backend - run: command: | mkdir -p /tmp/logs # Run only the terragrunt module tests run-go-tests --packages "-p 1 ./modules/terragrunt" | tee /tmp/logs/test_output.log - run: command: | ./cmd/bin/terratest_log_parser_linux_amd64 --testlog /tmp/logs/test_output.log --outputdir /tmp/logs when: always # Store test result and log artifacts for browsing purposes - store_artifacts: path: /tmp/logs - store_test_results: path: /tmp/logs deploy: <<: *defaults steps: - checkout - attach_workspace: at: /home/circleci - run: | curl -Ls https://raw.githubusercontent.com/gruntwork-io/gruntwork-installer/master/bootstrap-gruntwork-installer.sh | bash /dev/stdin --version "$GRUNTWORK_INSTALLER_VERSION" - run: | gruntwork-install --module-name "gruntwork-module-circleci-helpers" --repo "https://github.com/gruntwork-io/terraform-aws-ci" --tag "$MODULE_CI_VERSION" - run: cd cmd/bin && sha256sum * > SHA256SUMS - run: upload-github-release-assets cmd/bin/* workflows: version: 2 build-and-test: jobs: - setup: context: - AWS__PHXDEVOPS__circle-ci-test - GITHUB__PAT__gruntwork-ci filters: tags: only: /^v.*/ - kubernetes_test: context: - AWS__PHXDEVOPS__circle-ci-test - GITHUB__PAT__gruntwork-ci requires: - setup filters: tags: only: /^v.*/ - helm_test: context: - AWS__PHXDEVOPS__circle-ci-test - GITHUB__PAT__gruntwork-ci requires: - setup filters: tags: only: /^v.*/ - terragrunt_test: context: - AWS__PHXDEVOPS__circle-ci-test - GITHUB__PAT__gruntwork-ci requires: - setup filters: tags: only: /^v.*/ - terragrunt_test_tofu: context: - AWS__PHXDEVOPS__circle-ci-test - GITHUB__PAT__gruntwork-ci requires: - setup filters: tags: only: /^v.*/ - terraform_test: context: - AWS__PHXDEVOPS__circle-ci-test - GITHUB__PAT__gruntwork-ci - SLACK__TOKEN__refarch-deployer-test - SLACK__WEBHOOK__refarch-deployer-test - SLACK__CHANNEL__test-workflow-approvals requires: - setup filters: tags: only: /^v.*/ - terraform_test_tofu: context: - AWS__PHXDEVOPS__circle-ci-test - GITHUB__PAT__gruntwork-ci - SLACK__TOKEN__refarch-deployer-test - SLACK__WEBHOOK__refarch-deployer-test - SLACK__CHANNEL__test-workflow-approvals requires: - setup filters: tags: only: /^v.*/ - gcp_test: context: - GCP__automated-tests - GITHUB__PAT__gruntwork-ci requires: - setup filters: tags: only: /^v.*/ - deploy: context: - AWS__PHXDEVOPS__circle-ci-test - GITHUB__PAT__gruntwork-ci requires: - setup filters: tags: only: /^v.*/ branches: ignore: /.*/ ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: gruntwork-io ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a bug report to help us improve Terratest. title: '' labels: bug assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior, code snippets and examples which can be used to reproduce the issue. ```go // paste code snippets here ``` **Expected behavior** A clear and concise description of what you expected to happen. **Nice to have** - [ ] Terminal output - [ ] Screenshots **Versions** - Terratest version: - Environment details (Ubuntu 20.04, Windows 10, etc.): **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Submit a feature request to improve Terratest. title: '' labels: enhancement assignees: '' --- **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/no-response.yml ================================================ # Configuration for probot-no-response - https://github.com/probot/no-response # Number of days of inactivity before an Issue is closed for lack of response daysUntilClose: 30 # Label requiring a response responseRequiredLabel: more-information-needed # Comment to post when closing an Issue for lack of response. Set to `false` to disable closeComment: > This issue has been automatically closed because there has been no response to our request for more information from the original author. With only the information that is currently in the issue, we don't have enough information to take action. Please feel free to reach out if you have or find the answers we need so that we can investigate further. Thank you! ================================================ FILE: .github/pull_request_template.md ================================================ ## Description Fixes #000. ## TODOs Read the [Gruntwork contribution guidelines](https://gruntwork.notion.site/Gruntwork-Coding-Methodology-02fdcd6e4b004e818553684760bf691e). - [ ] Update the docs. - [ ] Run the relevant tests successfully, including pre-commit checks. - [ ] Ensure any 3rd party code adheres with our [license policy](https://www.notion.so/gruntwork/Gruntwork-licenses-and-open-source-usage-policy-f7dece1f780341c7b69c1763f22b1378) or delete this line if its not applicable. - [ ] Include release notes. If this PR is backward incompatible, include a migration guide. - [ ] Make a plan for release of the functionality in this PR. If it delivers value to an end user, you are responsible for ensuring it is released promptly, and correctly. If you are not a maintainer, you are responsible for finding a maintainer to do this for you. ## Release Notes (draft) Added / Removed / Updated [X]. ### Migration Guide ================================================ FILE: .github/workflows/ci.yml ================================================ name: ci-workflow # actors # source repo: official terratest repo (gruntwork-io/terratest) # forked repo: (e.g., xyz/terratest, abc/terratest) # pr: created from forked repo, against source repo (gruntwork-io/terratest/pull/{PR NUMBER}) # flow # developer fork the source repo # developer creates a branch in the forked repo # developer does the development 😎 # developer creates a pr # webhook on source repo calls a webapp when the pr has been created # this pipeline will be triggered automatically by the webapp, with the following input values # repo: name of the forked repo (e.g. xyz/terratest) # branch: branch name on the forked repo (e.g. feature/adding-some-important-module) # target_repository: home of the target_pr, which is the source repo (gruntwork-io/terratest) # target_pr: pr number on the source repo (e.g. 14, 25, etc.) on: push: branches: master workflow_dispatch: inputs: repo: description: 'Repository info' required: true branch: description: 'Name of the branch' required: true target_repository: description: 'Name of the official terratest repo' required: false default: 'gruntwork-io/terratest' target_pr: description: 'PR number on the official terratest repo' required: false skip_provider_registration: description: 'When set to true, terraform will skip provider registration (see: https://www.terraform.io/docs/providers/azurerm/index.html#skip_provider_registration for more information)' required: true default: 'false' permissions: contents: read jobs: ci-job: runs-on: [ubuntu-latest] steps: - uses: hashicorp/setup-terraform@v1 with: terraform_version: 0.15.1 terraform_wrapper: false - name: checkout to repo uses: actions/checkout@v2 with: repository: ${{ github.event.inputs.repo }} ref: ${{ github.event.inputs.branch }} - name: install golangci-lint binary run: | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b ./bin v1.53.2 - name: lint modules/azure folder id: azure_module_lint run: | # list files to be linted [ -d "modules/azure" ] && ls -li "modules/azure" # run the linter ./bin/golangci-lint run ./modules/azure/ --build-tags=azure --timeout 5m0s - name: lint test/azure folder id: azure_test_lint run: | # list files to be linted [ -d "test/azure" ] && ls -li "test/azure" # run the linter ./bin/golangci-lint run ./test/azure/ --build-tags=azure --timeout 5m0s - name: run terraform format id: azure_terraform_format run: terraform fmt -check -recursive ./examples/azure - name: login to azure cli uses: azure/login@v1.1 with: creds: ${{ secrets.AZURE_CREDENTIALS }} - name: run go unit test for azure id: azure_unit_test env: AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} run: | cd modules APP_ID=`echo $AZURE_CREDENTIALS | jq -r -c ".clientId"` APP_PASSWORD=`echo $AZURE_CREDENTIALS | jq -r -c ".clientSecret"` TENANT_ID=`echo $AZURE_CREDENTIALS | jq -r -c ".tenantId"` # if clientId, subscriptionId, tenantId doesn't provide to the go tests # by default, terratest reads them from below environment variables export ARM_CLIENT_ID="$APP_ID" export ARM_CLIENT_SECRET="$APP_PASSWORD" export ARM_SUBSCRIPTION_ID=`az account show --query "id" --output tsv` export ARM_TENANT_ID="$TENANT_ID" export ARM_SKIP_PROVIDER_REGISTRATION=${{ github.event.inputs.skip_provider_registration }} # run the unit tests under the `azure` subfolder go test ./azure/* -v -timeout 90m - name: run go test for azure id: azure_test env: AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} run: | cd test/azure APP_ID=`echo $AZURE_CREDENTIALS | jq -r -c ".clientId"` APP_PASSWORD=`echo $AZURE_CREDENTIALS | jq -r -c ".clientSecret"` TENANT_ID=`echo $AZURE_CREDENTIALS | jq -r -c ".tenantId"` # if clientId, subscriptionId, tenantId doesn't provide to the go tests # by default, terratest reads them from below environment variables export ARM_CLIENT_ID="$APP_ID" export ARM_CLIENT_SECRET="$APP_PASSWORD" export ARM_SUBSCRIPTION_ID=`az account show --query "id" --output tsv` export ARM_TENANT_ID="$TENANT_ID" export ARM_SKIP_PROVIDER_REGISTRATION=${{ github.event.inputs.skip_provider_registration }} # some resources may require ssh keys (e.g. Kubernetes, VMs, etc.) # terraform will read below environment variables # if those values didn't provide to the terraform explicitly rm -rf ssh_key* ssh-keygen -m PEM -t rsa -b 4096 -f ./ssh_key -q -N "" export TF_VAR_ssh_public_key="$PWD/ssh_key.pub" export TF_VAR_client_id="$APP_ID" export TF_VAR_client_secret="$APP_PASSWORD" # run the actual tests under the `azure` subfolder go test --tags=azure -v -timeout 90m - name: report back the result if: always() env: CURRENT_REPOSITORY: ${{ github.repository }} RUN_ID: ${{ github.run_id }} GITHUB_TOKEN: ${{ secrets.PAT }} TARGET_REPOSITORY: ${{ github.event.inputs.target_repository }} TARGET_PR: ${{ github.event.inputs.target_pr }} TEST_RESULT: ${{ steps.azure_test.conclusion }} TEST_LINT_RESULT: ${{ steps.azure_test_lint.conclusion }} MODULE_LINT_RESULT: ${{ steps.azure_module_lint.conclusion }} run: | # if no PR number provided, simply exit... [[ -z "$TARGET_PR" ]] && { echo "No PR Number provided, exiting..." ; exit 0; } # if no PAT provided, simply exit... [[ -z "$GITHUB_TOKEN" ]] && { echo "No PAT provided, exiting..." ; exit 0; } echo "PR Number provided... ${TARGET_REPOSITORY}:#${TARGET_PR}" BODY_PAYLOAD="" # if all the previous steps finished successfully, create a comment on the PR with the "success" information if [ "$TEST_RESULT" == "success" ] && [ "$TEST_LINT_RESULT" == "success" ] && [ "$MODULE_LINT_RESULT" == "success" ]; then BODY_PAYLOAD="[Microsoft CI Bot] TL;DR; success :thumbsup:\n\nYou can check the status of the CI Pipeline logs here ; https://github.com/${CURRENT_REPOSITORY}/actions/runs/$RUN_ID" # if at least one of the previous steps failed, create a comment on the PR with the "failure" information elif [ "$TEST_RESULT" == "failure" ] || [ "$TEST_LINT_RESULT" == "failure" ] || [ "$MODULE_LINT_RESULT" == "failure" ]; then BODY_PAYLOAD="[Microsoft CI Bot] TL;DR; failure :facepalm:\n\nYou can check the status of the CI Pipeline logs here ; https://github.com/${CURRENT_REPOSITORY}/actions/runs/$RUN_ID" fi echo "Comment message is ready..." echo "${BODY_PAYLOAD}" # if pipeline has something to report back to the PR if [[ -z "$BODY_PAYLOAD" ]] then echo "BODY_PAYLOAD is empty" else echo "Here is the target repository: ${TARGET_REPOSITORY}" # TARGET_REPOSITORY is in {owner}/{repo format} [[ $TARGET_REPOSITORY =~ (.*)/(.*)$ ]] # take the {owner} piece from the TARGET_REPOSITORY variable TARGET_REPO_OWNER=${BASH_REMATCH[1]} # take the {repo} piece from the TARGET_REPOSITORY variable TARGET_REPO_NAME=${BASH_REMATCH[2]} echo "Target repository is parsed: ${TARGET_REPO_OWNER} <-> ${TARGET_REPO_NAME}" # create the query string to get the pr id QUERY_PR_ID="query findPRID { repository(owner: \\\"$TARGET_REPO_OWNER\\\", name: \\\"$TARGET_REPO_NAME\\\") { pullRequest(number: $TARGET_PR) { id } } }" # get the pr id from github api PR_ID=$(curl --silent --request POST --header "Authorization: Bearer ${GITHUB_TOKEN}" --data-raw "{\"query\":\"${QUERY_PR_ID}\"}" "https://api.github.com/graphql" | jq -r '.data.repository.pullRequest.id') echo "Target PR ID is ${PR_ID}" # create the mutation string to create the comment on the pr MUTATION_ADD_COMMENT="mutation addComment { addComment(input: {subjectId: \\\"${PR_ID}\\\", body: \\\"${BODY_PAYLOAD}\\\"}) { commentEdge { node { createdAt body } } subject { id } } }" # call the github api to create the comment curl --request POST --header "Authorization: Bearer ${GITHUB_TOKEN}" --data-raw "{\"query\":\"${MUTATION_ADD_COMMENT}\"}" "https://api.github.com/graphql" echo "Comment is created..." fi ================================================ FILE: .github/workflows/lint.yml ================================================ name: Lint on: push: permissions: contents: read jobs: lint: name: Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up mise uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - id: go-cache-paths shell: bash run: | echo "go-build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" echo "go-mod=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT" echo "golangci-lint-cache=$HOME/.cache/golangci-lint" >> "$GITHUB_OUTPUT" - name: Go Build Cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: ${{ steps.go-cache-paths.outputs.go-build }} key: ${{ runner.os }}-go-build-lint-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go-build-lint- - name: Go Mod Cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: ${{ steps.go-cache-paths.outputs.go-mod }} key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go-mod- - name: golangci-lint Cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: ${{ steps.go-cache-paths.outputs.golangci-lint-cache }} key: ${{ runner.os }}-golangci-lint-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-golangci-lint- - name: Lint run: make lint-allow-list ================================================ FILE: .github/workflows/update-lint-config.yml ================================================ name: Update Lint Config on: schedule: # Run every Monday at 00:00 UTC - cron: '0 0 * * 1' workflow_dispatch: jobs: update-lint-config: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - name: Update lint config from upstream run: make update-lint-config - name: Check for changes id: check run: | if git diff --quiet .golangci.yml; then echo "changed=false" >> "$GITHUB_OUTPUT" else echo "changed=true" >> "$GITHUB_OUTPUT" fi - name: Create Pull Request if: steps.check.outputs.changed == 'true' uses: peter-evans/create-pull-request@v7 with: token: ${{ secrets.GITHUB_TOKEN }} commit-message: "chore: update golangci-lint config from upstream" title: "chore: update golangci-lint config from upstream" body: | This PR updates the golangci-lint configuration from the upstream Terragrunt repository. Source: https://github.com/gruntwork-io/terragrunt/blob/main/.golangci.yml This is an automated PR created by the update-lint-config workflow. branch: update-lint-config delete-branch: true ================================================ FILE: .gitignore ================================================ # Terraform files .terraform terraform.tfstate terraform.tfvars terraform.tfvars.json *.tfstate* .terragrunt .terragrunt-cache .terraform.lock.hcl # IDE files .idea .vscode *.iml vendor # Folder used to store temporary test data by Terratest .test-data # rbenv .ruby-version # OS X .DS_Store # Intermediate file for testing kubeconfig # environment files .env ================================================ FILE: .golangci.yml ================================================ # This file is generated from https://github.com/gruntwork-io/terragrunt/blob/main/.golangci.yml # It is automatically updated weekly via the update-lint-config workflow. Do not edit manually. version: "2" run: go: "1.26" issues-exit-code: 1 tests: true output: formats: text: path: stdout print-linter-name: true print-issued-lines: true linters: enable: - asasalint - asciicheck - bidichk - bodyclose - contextcheck - dupl - durationcheck - errchkjson - errorlint - exhaustive - fatcontext - gocheckcompilerdirectives - gochecksumtype - goconst - gocritic - gosmopolitan - lll - loggercheck - makezero - misspell - mnd - musttag - nilerr - nilnesserr - noctx - paralleltest - perfsprint - prealloc - protogetter - reassign - rowserrcheck - spancheck - sqlclosecheck - staticcheck - testableexamples - testifylint - testpackage - thelper - tparallel - unconvert - unparam - usetesting - wastedassign - wsl_v5 - zerologlint settings: dupl: threshold: 120 errcheck: check-type-assertions: false check-blank: false exclude-functions: - (*os.File).Close errorlint: errorf: true asserts: true comparison: true goconst: min-len: 3 min-occurrences: 5 gocritic: enabled-tags: - performance disabled-tags: - experimental govet: enable: - fieldalignment - printf - unusedwrite nakedret: max-func-lines: 20 staticcheck: checks: - all - -SA9005 - -QF1008 - -ST1001 unparam: check-exported: false wsl_v5: allow-whole-block: false branch-max-lines: 2 exclusions: generated: lax rules: - linters: - dupl - errcheck - gocyclo - mnd - unparam - wsl path: _test\.go # We end up with duplicated content in this package to save us from duplicating code in other packages. - linters: - dupl path: cli/flags/shared # Incrementally linting lines that are too long to ensure that # we don't have conflicts on every file in the codebase while # trying to get this merged in. - linters: - lll path-except: '^(internal/awshelper/|internal/cas/)' paths: - docs - _ci - .github - .circleci - third_party$ - builtin$ - examples$ issues: max-issues-per-linter: 0 max-same-issues: 0 formatters: enable: - goimports settings: gofmt: simplify: true exclusions: generated: lax paths: - docs - _ci - .github - .circleci - third_party$ - builtin$ - examples$ ================================================ FILE: .pre-commit-config.yaml ================================================ repos: - repo: https://github.com/gruntwork-io/pre-commit rev: v0.1.10 hooks: - id: goimports - id: terraform-fmt - repo: local hooks: - id: test-interfaces-used name: test-interfaces-used entry: bash -c 'grep -Rw "*testing.T" modules | grep -v _test.go | wc -l' language: system types: [go] pass_filenames: false ================================================ FILE: CODEOWNERS ================================================ * @denis256 @yhakbar @thisguycodes @james00012 ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: Makefile ================================================ update-lint-config: SHELL:=/bin/bash update-lint-config: curl -s https://raw.githubusercontent.com/gruntwork-io/terragrunt/main/.golangci.yml --output .golangci.yml tmpfile=$$(mktemp) ;\ { echo '# This file is generated from https://github.com/gruntwork-io/terragrunt/blob/main/.golangci.yml' ;\ echo '# It is automatically updated weekly via the update-lint-config workflow. Do not edit manually.' ;\ cat .golangci.yml; } > $${tmpfile} && mv $${tmpfile} .golangci.yml lint: golangci-lint run ./... lint-allow-list: golangci-lint run ./modules/random/... ./modules/testing/... ./modules/slack/... ./modules/collections/... ./modules/environment/... ./modules/retry/... ./modules/shell/... ./modules/git/... ./modules/files/... .PHONY: lint update-lint-config ================================================ FILE: NOTICE ================================================ terratest Copyright 2018 Gruntwork, Inc. This product includes software developed at Gruntwork (https://www.gruntwork.io/). ================================================ FILE: README.md ================================================ # Terratest [![Maintained by Gruntwork.io](https://img.shields.io/badge/maintained%20by-gruntwork.io-%235849a6.svg)](https://gruntwork.io/?ref=repo_terratest) [![CircleCI](https://dl.circleci.com/status-badge/img/gh/gruntwork-io/terratest/tree/main.svg?style=svg&circle-token=8abd167739d60e4c1b6c1502d2092339a6c6a133)](https://dl.circleci.com/status-badge/redirect/gh/gruntwork-io/terratest/tree/main) [![Go Report Card](https://goreportcard.com/badge/github.com/gruntwork-io/terratest)](https://goreportcard.com/report/github.com/gruntwork-io/terratest) [![go.dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/mod/github.com/gruntwork-io/terratest?tab=overview) ![go.mod version](https://img.shields.io/github/go-mod/go-version/gruntwork-io/terratest) Terratest is a Go library that makes it easier to write automated tests for your infrastructure code. It provides a variety of helper functions and patterns for common infrastructure testing tasks, including: - Testing Terraform code - Testing Packer templates - Testing Docker images - Executing commands on servers over SSH - Working with AWS APIs - Working with Azure APIs - Working with GCP APIs - Working with Kubernetes APIs - Testing Helm Charts - Making HTTP requests - Running shell commands - And much more Please see the following for more info: - [Terratest Website](https://terratest.gruntwork.io) - [Getting started with Terratest](https://terratest.gruntwork.io/docs/getting-started/quick-start/) - [Terratest Documentation](https://terratest.gruntwork.io/docs/) - [Contributing to Terratest](https://terratest.gruntwork.io/docs/community/contributing/) - [Commercial Support](https://gruntwork.io/support/) ## License This code is released under the Apache 2.0 License. Please see [LICENSE](LICENSE) and [NOTICE](NOTICE) for more details. Copyright © 2025 Gruntwork, Inc. ================================================ FILE: REFACTOR.md ================================================ # Terratest refactor Terratest started out as a set of Bash scripts we were using at Gruntwork to test some of our Terraform code. As the amount of Terraform code grew, it was getting trickier and trickier to test it with Bash, so we rewrote those scripts in Go. Over time, this Go code grew into a library called Terratest, which contains collection of utilities that we use to test all aspects of our [Infrastructure as Code Library](https://gruntwork.io/infrastructure-as-code-library/). We developed patterns to test Terraform configurations, Packer templates, Docker images, SSH access, AWS APIs, shell commands, and much more. We built this library because we couldn't find any existing tools out there that could do the type of real-world testing we needed. It turns out many other companies want to do this type of testing too, so now it's time to open source Terratest. This library grew organically, so it needs lots of refactoring, cleanup, and documentation to be useful to people outside of Gruntwork. This document lays out the refactoring we are planning to (a) get feedback and (b) document what changed so that when we update our code, we know how to deal with the backwards incompatibilities. ## Name change "Terratest" made sense as the name for this library when it was all about testing Terraform code, but now this library also can help you test Packer templates, Docker images, and much more. I propose that we rename it. Some ideas: - grunt-test - test-grunt - gruntUnit - iac-test - infratest ## Build updates 1. Move from Glide to Dep 1. Move from CircleCI 1.0 to 2.0 1. Add support for Google Cloud Platform 1. Move from Dep to Go Modules. ## Folder structure Change to the same folder structure we use for just about all other Gruntwork repos: - `examples`: This will contain a number of real-world examples of code you might want to test with Terratest, such as Terraform modules, Packer templates, and Docker images. The `test` folder (described below) shows how to use Terratest to test these examples. - `modules`: The Terratest source code. Move all the `.go` files and packages into this folder so it's easier to browse the repo. That does mean all Terratest imports will have to be updated to `github.com/gruntwork-io/terratest/modules/xxx`. Unit tests for the Go source code will be in this folder too (e.g., the unit test for `foo.go` will be in `foo_test.go`). - `test`: This will contain the automated tests for the examples in the `examples` folder. These will act both as an example of how to use Terratest, as well as integration tests for the library. ## Documentation Update the root `README.md` with documentation that shows how to use Terratest: - Overview of what Terratest is. - Link to blog post we'll write about Terratest (this blog post is a TODO for after this refactor). - Discuss some of the challenges of testing infrastructure code: i.e., lack of "localhost," lock of "unit tests," slowness, brittleness. - Discuss the value of doing this testing despite the challenges: i.e., there is no way to maintain lots of infrastructure code without tests, building reusable, tested, versioned modules changes how you manage infrastructure. - Discussion of test strategies: using Docker for local testing, test stages, retries, mocks, small modules, test pyramid, cleanup, `cloud-nuke`. - Point to `examples` folder for real-world code you may want to test and `test` folder for examples of how to use Terratest to test that code. - Overview of Terratest packages. Explain what each top-level package in Terratest does. We can't do a method-by-method breakdown, as that would go out of date immediately, so instead, link to the appropriate `examples` subfolder that shows real-world usage of that package. - In the future: links to our open source repos (Vault, Consul, Nomad, Couchbase, etc) that show how we use Terratest with our own code. We can't add this until we update those open source repos to this refactored version of Terratest so the code matches up. ## Package-by-package refactor I've gone through each of the packages in Terratest and took down some notes on cleanup we need to do. This is not a comprehensive list, as things will become clearer once I actually start doing the work. In fact, my plan is to first create all the examples in the `examples` folder, then write tests for them in the `test` folder using "wishful thinking" (in the [SICP](https://www.amazon.com/Structure-Interpretation-Computer-Programs-Engineering/dp/0262510871) sense), where I come up with the test API I want to have for doing the testing, and then go and refactor the Terratest code to match. ### Root package We have a lot of stuff in the root package and I propose moving all of it out into appropriate sub-packages: - `apply.go`, `apply_and_destroy.go`, `destroy.go`, `output.go`, and `output_test.go` will all be moved into `modules/terraform`, as they are all specific to testing Terraform code. - I propose deleting `rand_resources.go` and `rand_resource_test.go` and extracting its logic into other places. The `RandomResourceCollection` ended up being a, well, random collection of resources, most of which don't apply to most of our tests, and certainly won't apply to tests written by the open source community. Here's what `RandomResourceCollection` contains and what I propose to do with it: - `UniqueId`: We already have a separate method for generating a unique ID and we can pass it around as a `string`. - `AwsRegion`: This is only needed for AWS tests. We want to expand Terratest to support other clouds, so it needs to be separated anyway. Code that needs an AWS region should call a method in the `modules/aws` package to pick a random AWS region (passing in a list of forbidden regions, if necessary) and can pass that around as a `string`. - `KeyPair`: This is only needed for a small percentage of our AWS tests that deploy EC2 Instances and SSH to them. Those tests should call a standalone method in the `modules/aws` package to generate this `KeyPair` when they need it, instead of us assuming every single test needs it. - `AmiId`: We used to look up vanilla Ubuntu or Amazon Linux AMI IDs and put them in this field, but now that Terraform has `data` sources and Packer has `source_ami_filter`, this is no longer necessary. We can keep the methods around to find Ubuntu or Amazon Linux AMI IDs for tests that need them, but there's no need to assume every single test needs this. - `AccountId`: Our Terraform examples used to require an account ID to be passed in. We now avoid this to make the examples easier to use, and fetch it automatically using Terraform's `aws_caller_identity` data source if it's absolutely necessary. Code that needs an account ID should call a method in the `modules/aws` package to fetch it, but we shouldn't assume every single test needs it. - `SnsTopicArn`: A very, very small percentage of our tests needed an SNS topic passed in. Those tests should call a method in the `modules/aws` package to create this topic instead of us assuming every single test needs it. - I propose moving `terratest_options.go` to `modules/terraform/options.go` and renaming the struct within it from `TerratestOptions` to `Options`, since this is solely used for testing Terraform code. We should also rename `TemplatePath` to `TerraformDir`, as `.tf` files are technically called "configurations" and not "templates". - `url_checker.go` will be deleted. It's too hard-coded for one specific type of check. The reuse value is limited and it's not obvious the code exists, so it's best for the test cases to reimplement this themselves, with their specific needs, even if it's a tiny bit less DRY. ### _docker-images package - Rename to `test-docker-images` to make it clearer these are only used for testing. - Use these Docker images in the `examples` folder to show how to do "unit tests" for Packer templates. - Follow-up PR: build and push a new version of these Docker images on each release? - Follow-up PR: tag each new Docker image with a unique version number (e.g., sha1 of commit). ### `aws` package - Right now, much of this code has no unit tests, since it relies on resources in AWS. By adding an `examples` folder that deploys real resources in AWS, we will be able to test this code better, _and_ show users how to use this code! - `ami.go`: Update these methods to use the AWS APIs to find the latest Ubuntu / Amazon Linux AMI IDs instead of hard-coding them. - `kms.go`: What to do about `GetDedicatedTestKeyArn`? For tests that use KMS, we don't want to create a new CMK each time the test runs, as AWS charges $1/month for CMKs, even if you delete them immediately after use. This method currently assumes we have a key called `alias/dedicated-test-key` in every AWS region. Should we leave it as-is and document it for Terratest users that want to follow a similar pattern? Or perhaps read the key name from an env var? - `region.go`: What should we do about `GetGloballyForbiddenRegions`? Right now, it's hard-coded to include `us-west-2` as a globally forbidden region, as Josh is running his personal blog there. Obviously, we don't want that in the open source version. Josh, can you finally migrate your blog out of there so we don't have to have this exception? ### `log` package - Rename to `logger`. That way, we don't have to alias it as `terralog` all over our test code. - Change what the package does. Instead of creating a custom `*log.Logger` and passing it around, we are going to have a `Log` and `Logf` method you can call from anywhere. To use those methods, you have to pass them a `*testing.T`, which they will use to read out the test name. We already pass `*testing.T` to almost all of our test methods, so this reduces the number of arguments by one. ### `parallel` package - I propose removing this package entirely. Now that go has [subtests](https://blog.golang.org/subtests) that you can easily run with `t.Run()` and parallelize with `t.Parallel()`, I think that's a cleaner way of handling parallelism than this custom package. ### `packer` package - Rename `PackerOptions` to `Options` (the package name is already `packer`). ### `resources` package - `base_resources.go` is no longer necessary if we remove `RandomResourceCollection`. - `exclusions.go` is not used much and very out of date. - `terraform_options.go` is hard-coded to how we do things at Gruntwork, but won't apply to many other users. ### `terraform` package - `apply.go`: Remove `terraformDebugEnv` and instead make it easy to pass a map of env vars to the `Apply` method. Refactor `ApplyAndGetOutputWithRetry` to accept a list of errors on which to retry and how many retries to do. ### `test-util` package - `dummy_server.go`: Move into the `modules/http` package. - Remove `test-util` since that would leave it empty! ### `util` package - `collections.go`: Move into its own `modules/collections` package. - `keygen.go`: Move into `modules/ssh` package. - `network.go`: Move into `modules/aws` package. - `sleep.go`: Remove. Didn't even know we had this and doubt it gets much use! - `random.go`: Move into its own `modules/random` package. - `retry.go`: Move into its own `modules/retry` package. ## Error handling I am updating most of the methods to support handling errors in one of two ways: 1. Each method `foo` will take in a `*testing.T` and upon hitting an error, call `t.Fatal`. 1. Each method `fooE` will explicitly return any errors it hits and NOT call `t.Fatal`. Example: ```go func GetCurrentBranchName(t *testing.T) string { out, err := GetCurrentBranchNameE(t) if err != nil { t.Fatal(err) } return out } func GetCurrentBranchNameE(t *testing.T) (string, error) { cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") bytes, err := cmd.Output() if err != nil { return "", err } return strings.TrimSpace(string(bytes)), nil } ``` In most places in our code, we will use `GetCurrentBranchName`, which will call `t.Fatal` if it hits any errors. This is typically the behavior we want anyway, and not having to deal with a returned error will keep our code smaller and easier to read. However, in those cases where we may want to get the original error back and not fail the test immediately, we can use `GetCurrentBranchNameE`. ## Other thoughts on the refactor - Updating to the refactored version of Terratest will be a pain that requires lots of search & replace. But in the long term, it seems like worthwhile cleanup. - There are a bunch of patterns we often end up using throughout our tests that would be good to copy into Terratest. Anyone remember what those are off the top of our head? - Having two copies of each method (`foo` and `fooE`) is a bit tedious, but the `foo` variety is essentially the same boilerplate everywhere, so it only increases the maintenance burden on Terratest library maintainers a little, but it improves code readability for all Terratest users enormously. ================================================ FILE: SECURITY.md ================================================ # Reporting Security Issues Gruntwork takes security seriously, and we value the input of independent security researchers. If you're reading this because you're looking to engage in responsible disclosure of a security vulnerability, we want to start with thanking you for your efforts. We appreciate your work and will make every effort to acknowledge your contributions. To report a security issue, please use the GitHub Security Advisory ["Report a vulnerability"](https://github.com/gruntwork-io/terratest/security/advisories/new) button in the ["Security"](https://github.com/gruntwork-io/terratest/security) tab. After receiving the report, we will investigate the issue and inform you of next steps. After the initial reply, we may ask for additional information, and will endeavor to keep you informed of our progress. If you are reporting a bug related to an associated tool that Terratest integrates with, we ask that you report the issue directly to the maintainers of that tool. Please do not disclose the issue publicly until we have had a chance to address it. ## Expectations on timelines You can expect that Gruntwork will take any report of a security vulnerability seriously, but we ask that you also respect that it can take time to investigate and address issues given the size of the team maintaining Terratest. We will do our best to keep you informed of our progress, and provide insight into the timeline for addressing the issue. ## Thank you We appreciate your help in making Terratest more secure. Thank you for your efforts in responsibly disclosing security issues, and for your patience as we work to address them. ================================================ FILE: cmd/pick-instance-type/main.go ================================================ package main import ( "fmt" "github.com/gruntwork-io/go-commons/entrypoint" "github.com/gruntwork-io/terratest/modules/aws" "github.com/urfave/cli" ) const CustomUsageText = `Usage: pick-instance-type [OPTIONS] This tool takes in an AWS region and a list of EC2 instance types and returns the first instance type in the list that is available in all Availability Zones (AZs) in the given region, or exits with an error if no instance type is available in all AZs. This is useful because certain instance types, such as t2.micro, are not available in some of the newer AZs, while t3.micro is not available in some of the older AZs. If you have code that needs to run on a "small" instance across all AZs in many different regions, you can use this CLI tool to automatically figure out which instance type you should use. Arguments: REGION The AWS region in which to look up instance availability. E.g.: us-east-1. INSTANCE_TYPE One more more EC2 instance types. E.g.: t2.micro. Options: --help Show this help text and exit. Example: pick-instance-type ap-northeast-2 t2.micro t3.micro ` func run(cliContext *cli.Context) error { region := cliContext.Args().First() if region == "" { return fmt.Errorf("You must specify an AWS region as the first argument") } instanceTypes := cliContext.Args().Tail() if len(instanceTypes) == 0 { return fmt.Errorf("You must specify at least one instance type") } // Create mock testing.T implementation so we can re-use Terratest methods t := MockTestingT{MockName: "pick-instance-type"} recommendedInstanceType, err := aws.GetRecommendedInstanceTypeE(t, region, instanceTypes) if err != nil { return err } // Print the recommended instance type to stdout fmt.Print(recommendedInstanceType) return nil } func main() { app := entrypoint.NewApp() cli.AppHelpTemplate = CustomUsageText entrypoint.HelpTextLineWidth = 120 app.Name = "pick-instance-type" app.Author = "Gruntwork " app.Description = `This tool takes in a list of EC2 instance types (e.g., "t2.micro", "t3.micro") and returns the first instance type in the list that is available in all Availability Zones (AZs) in the given AWS region, or exits with an error if no instance type is available in all AZs.` app.Action = run entrypoint.RunApp(app) } // MockTestingT is a mock implementation of testing.TestingT. All the functions are essentially no-ops. This allows us // to use Terratest methods outside of a testing context (e.g., in a CLI tool). type MockTestingT struct { MockName string } func (t MockTestingT) Fail() {} func (t MockTestingT) FailNow() {} func (t MockTestingT) Fatal(args ...interface{}) {} func (t MockTestingT) Fatalf(format string, args ...interface{}) {} func (t MockTestingT) Error(args ...interface{}) {} func (t MockTestingT) Errorf(format string, args ...interface{}) {} func (t MockTestingT) Name() string { return t.MockName } ================================================ FILE: cmd/terratest_log_parser/main.go ================================================ // A CLI command to parse parallel terratest output to produce test summaries and break out interleaved test output. // // This command will take as input a terratest log output from either stdin (through a pipe) or from a file, and output // to a directory the following files: // outputDir // |-> TEST_NAME.log // |-> summary.log // |-> report.xml // where: // - `TEST_NAME.log` is a log for each test run that only includes the relevant logs for that test. // - `summary.log` is a summary of all the tests in the suite, including PASS/FAIL information. // - `report.xml` is the test summary in junit XML format to be consumed by a CI engine. // // Certain tradeoffs were made in the decision to implement this functionality as a separate parsing command, as opposed // to being built into the logger module as part of `Logf`. Specifically, this implementation avoids the difficulties of // hooking into go's testing framework to be able to extract the summary logs, at the expense of a more complicated // implementation in handling various corner cases due to logging flexibility. Here are the list of pros and cons of // this approach that were considered: // // Pros: // - Robust to unexpected failures in testing code, like `ctrl+c`, panics, OOM kills and the like since the parser is // not tied to the testing process. This approach is less likely to miss these entries, and can be surfaced to the // summary view for easy viewing in CI engines (no need to scroll), like the panic example. // - Can combine `go test` output (e.g `--- PASS` entries) with the log entries for the test in a single log file. // - Can extract out the summary view (those are all `go test` log entries). // - Can build `junit.xml` report that CI engines can use for test insights. // // Cons: // - Complicated implementation that is potentially brittle. E.g if someone decides to change the logging format then // this will all break. If we hook during the test, then the implementation is easier because those logs are all emitted // at certain points in code, the information of which is lost in the final log and have to parse out. // - Have to store all the logs twice (the full interleaved version, and the broken out version) because the parsing // depends on logs being available. (NOTE: this is avoidable with a pipe). package main import ( "fmt" "os" "path/filepath" "github.com/gruntwork-io/go-commons/entrypoint" "github.com/gruntwork-io/go-commons/errors" "github.com/gruntwork-io/go-commons/logging" "github.com/gruntwork-io/terratest/modules/logger/parser" "github.com/sirupsen/logrus" "github.com/urfave/cli" ) var logger = logging.GetLogger("terratest_log_parser") const CUSTOM_USAGE_TEXT = `Usage: terratest_log_parser [--help] [--log-level=info] [--testlog=LOG_INPUT] [--outputdir=OUTPUT_DIR] A tool for parsing parallel terratest output to produce a test summary and to break out the interleaved logs by test for better debuggability. Options: --log-level LEVEL Set the log level to LEVEL. Must be one of: [panic fatal error warning info debug] (default: "info") --testlog value Path to file containing test log. If unset will use stdin. --outputdir value Path to directory to output test output to. If unset will use the current directory. --help, -h show help ` func run(cliContext *cli.Context) error { filename := cliContext.String("testlog") outputDir := cliContext.String("outputdir") logLevel := cliContext.String("log-level") level, err := logrus.ParseLevel(logLevel) if err != nil { return errors.WithStackTrace(err) } logger.SetLevel(level) var file *os.File if filename != "" { logger.Infof("reading from file") file, err = os.Open(filename) if err != nil { logger.Fatalf("Error opening file: %s", err) } } else { logger.Infof("reading from stdin") file = os.Stdin } defer file.Close() outputDir, err = filepath.Abs(outputDir) if err != nil { logger.Fatalf("Error extracting absolute path of output directory: %s", err) } parser.SpawnParsers(logger, file, outputDir) return nil } func main() { app := entrypoint.NewApp() cli.AppHelpTemplate = CUSTOM_USAGE_TEXT entrypoint.HelpTextLineWidth = 120 app.Name = "terratest_log_parser" app.Author = "Gruntwork " app.Description = `A tool for parsing parallel terratest output to produce a test summary and to break out the interleaved logs by test for better debuggability.` app.Action = run currentDir, err := os.Getwd() if err != nil { logger.Fatalf("Error finding current directory: %s", err) } defaultOutputDir := filepath.Join(currentDir, "out") logInputFlag := cli.StringFlag{ Name: "testlog, l", Value: "", Usage: "Path to file containing test log. If unset will use stdin.", } outputDirFlag := cli.StringFlag{ Name: "outputdir, o", Value: defaultOutputDir, Usage: "Path to directory to output test output to. If unset will use the current directory.", } logLevelFlag := cli.StringFlag{ Name: "log-level", Value: logrus.InfoLevel.String(), Usage: fmt.Sprintf("Set the log level to `LEVEL`. Must be one of: %v", logrus.AllLevels), } app.Flags = []cli.Flag{ logLevelFlag, logInputFlag, outputDirFlag, } entrypoint.RunApp(app) } ================================================ FILE: docs/.gitignore ================================================ .jekyll-cache _site ================================================ FILE: docs/CNAME ================================================ terratest.gruntwork.io ================================================ FILE: docs/Dockerfile ================================================ FROM ruby:2.6.2-stretch MAINTAINER Gruntwork # This project requires bundler 2, but the docker image comes with bundler 1 so we need to upgrade RUN gem install bundler # Copy the Gemfile and Gemfile.lock into the image and run bundle install in a way that will be cached WORKDIR /tmp ADD Gemfile Gemfile ADD Gemfile.lock Gemfile.lock RUN bundle install RUN mkdir -p /src VOLUME ["/src"] WORKDIR /src COPY . /src # Jekyll runs on port 4000 by default EXPOSE 4000 # Run jekyll serve - jekyll will build first to create a plain html file for TOS update CMD ["./jekyll-serve.sh"] ================================================ FILE: docs/Gemfile ================================================ source "https://rubygems.org" # Hello! This is where you manage which Jekyll version is used to run. # When you want to use a different version, change it below, save the # file and run `bundle install`. Run Jekyll with `bundle exec`, like so: # # bundle exec jekyll serve # # This will help ensure the proper Jekyll version is running. # Happy Jekylling! # gem "jekyll", "~> 4.0.0" # This is the default theme for new Jekyll sites. You may change this to anything you like. # gem "minima", "~> 2.5" # If you want to use GitHub Pages, remove the "gem "jekyll"" above and # uncomment the line below. To upgrade, run `bundle update github-pages`. gem "github-pages", group: :jekyll_plugins # If you have any plugins, put them here! group :jekyll_plugins do gem 'jekyll-sitemap', '1.4.0' gem 'therubyracer', '0.12.3' gem 'less', '2.6.0' gem 'jekyll-toc' gem 'jekyll-redirect-from' end # Performance-booster for watching directories on Windows gem "wdm", "~> 0.1.1", :install_if => Gem.win_platform? ================================================ FILE: docs/README.md ================================================ # Terratest website This is the code for the [Terratest website](https://terratest.gruntwork.io). Terratest website is built with Jekyll and published on Github Pages from `docs` folder on `main` branch. # Quick Start ## Download project Clone or fork Terratest [repository](https://github.com/gruntwork-io/terratest). ## Run 1. Install [Ruby](https://www.ruby-lang.org/en/documentation/installation/). Version 2.4 or above is recommended. Consider using [rbenv](https://github.com/rbenv/rbenv) to manage Ruby versions. 2. Install `bundler`: ```bash gem install bundler ``` 3. Go to the `docs` folder: ```bash cd docs ``` 4. Install gems: ```bash bundle install ``` 5. Run the docs site locally: ```bash bundle exec jekyll serve ``` 6. Open [http://localhost:4000/](http://localhost:4000/) in your web browser. # Deployment GitHub Pages automatically rebuilds the website from the `/docs` folder whenever you commit and push changes to the `main` branch. # Working with the documentation We recommend updating the documentation *before* updating any code (see [Readme Driven Development](http://tom.preston-werner.com/2010/08/23/readme-driven-development.html)). This ensures the documentation stays up to date and allows you to think through the problem at a high level before you get lost in the weeds of coding. The Terratest website contains *Docs* collection stored in `/docs/_docs`. When you work with the documentation, it's good to preview the changes. To do that, run project as it is described in [Run section](#run). 1. [Change content on the existing page](#change-content-on-the-existing-page) 2. [Add a new page](#add-a-new-page) 3. [Remove or rename page](#remove-or-rename-page) 4. [Add custom redirection](#add-custom-redirection) ## Change content on the existing page 1. Find page in `_docs` by file name (it's the same as page's title). 2. Edit content and save file. ## Add a new page 1. Create a new file in `_docs`. The file's name and title have to be the same. 2. At the beginning of the file, add: ``` --- layout: collection-browser-doc # X Cannot be changed title: Quick start # <--- Change this categories: - getting-started # <--- Change this if needed excerpt: Learn how to work with Terratest. # <--- Change page description tags: ["Quick Start", "DRY", "backend", "CLI"] # <--- Set tags order: 100 # <--- It sorts the docs on the list nav_title: Documentation # X Cannot be changed nav_title_link: /docs/ # X Cannot be changed --- ``` * `layout` - do not change! (Layout sets components like a navigation sidebar, page header, footer, etc.) * `title` - document title * `categories` - the document's category. Four categories are in use for now: "getting-started", "testing-best-practices", "alternative-testing-tools", and "community". * `excerpt` - description. Try to keep it short. * `tags` - check other posts to see common tags, but you can set a new as well. * `order` - it is used to sort the documents within collection. * `nav_title` - the title displayed above navigation. It's optional and it's recommended to use the same as other files in the collection. * `nav_title_link` - it is URL. If it is set, the `nav_title` becomes a link with a given URL. 3. Add content at the end of the file. ## Remove or rename page 1. Find page in `_docs` by file name (it's the same as page's title). 2. Delete page or rename. ## Add custom redirection To add link to any page, including subpages outside of any collection, you can create a new file in specific collection (e.g. `_docs`), and set following content in the file: ``` --- title: Support categories: Community excerpt: Need help? tags: ["support"] redirect_to: - /support order: 301 --- ``` ## Navigation The navigation sidebar is built in `_includes/collection_browser/navigation/_collection_toc.html`. First, the script groups documents of the given collection by categories. Categories make the uppermost level in the navigation sidebar. Then, within each category, the script adds documents titles to the navigation under specific categories. Documents are sorted by `order` field set in frontmatter section. Next, headings from each document are being extracted and added to the navigation. # Development ## Project structure ``` |-- _docs # docs *collection* |-- _includes # partials |-- _layouts # layouts |-- _pages # static pages | |-- 404 # "404: Not found" page | |-- cookie-policy # "Cookie Policy" page | |-- docs # index page for *_docs* collection | |-- index # home page | |-- _posts # Posts collection - empty and not used |-- _site # website generated by Jekyll |-- assets # Javascript, Stylesheets, and images |-- scripts # useful scripts to use in development | |-- convert_md_to_adoc # contains the command to convert MD files to ADOC | |-- Gemfile |-- _config.yml # Jekyll configuration file ``` ## Documentation and Examples collections The [*documentation*](https://terratest.gruntwork.io/docs) is implemented as a Jekyll collection and built with [*Collection Browser*](#collection-browser). ### Documentation collection The index page of the *Docs* collection is in: `_pages/docs/index.html` and is available under `/docs` URL. It uses *Collection browser* from `_includes/collection_browser/browser` which makes the list of docs, adds search input with tag filter and puts navigation sidebar containing collection's categories. Collection is stored in `_docs` folder. ## Adding new pages to collections The *Docs* collection uses *collection browser* which requires to setup proper meta tags in the doc file. 1. Create a new file in collection folder. *Docs* add to the `_docs`. ``` --- layout: collection-browser-doc title: CLI options # CHANGE THIS categories: - getting-started # CHANGE THIS excerpt: >- # CHANGE THIS Terratest example description tags: ["CLI"] # CHANGE THIS order: 102 # CHANGE THIS nav_title: Documentation # OPTIONAL nav_title_link: /docs/ # OPTIONAL --- ``` * layout - always has to be: `collection-browser-doc` [DO NOT CHANGE] * title - the doc title. * categories - set one category. Use downcase with dashes, e.g. `getting-started`. * excerpt - the doc description. * tags - doc tags. * order - it is use to list documents in the right order. "Getting Started" starts from 100, "Features" starts from 200, and "Community" starts from 300. * nav_title - the title above navigation. It's optional. It's a link if `nav_title_link` is set. * nav_title_link - it is a URL. If it is set, `nav_link` is transformed to the link. ## Adding new collections To add a new collection based on *Collection browser*, like *Docs* collection: 1. Add collection to the `_config.yml`: ``` collections: my-collection: # --> Change to your collection's name output: true sort_by: order permalink: /:collection/:categories/:title/ # --> You can adjust this to your needs. You can remove ":categories" if your collection doesn't use it. ``` 2. Create a folder for collection in root directory, e.g: `_my-collection` (change name) 3. Add documents to the `_my-collection` folder and set proper meta tags (see: [Adding new docs to collections](#adding-new-docs-to-collections)). 4. Create folder for collection's index page in `_pages`. Use collection name, e.g: `_pages/my-collection`. 5. Add `index.html` file to newly create folder: ``` --- layout: collection-browser # DO NOT CAHNGE THIS title: Use cases subtitle: Learn how to work with Terratest. excerpt: Learn how to work with Terratest. permalink: /examples/ slug: examples nav_title: Documentation # OPTIONAL nav_title_link: /docs/ # OPTIONAL --- {% include collection_browser/browser.html collection=site.examples collection_name='examples' %} ``` 6. Change `title`, `subtitle`, `excerpt`, `permalink`, and `slug` in meta tags. 7. In `include` statement, set `collection` to your collection set in `_config.yml` and set `collection_name`. ## Collection Browser _The Collection Browser is strongly inspired by implementation of `guides` on *gruntwork.io* website._ The Collection Browser's purpose is to wrap Jekyll collection into: * _index_ page containing ordered list of docs with search form, * _show_ pages presenting docs' contents, and containing navigation sidebar, * and build navigation sidebar. ### Usage 1. Add collection to `_config.yml` ``` collections: my-collection: # --> Change to your collection's name output: true sort_by: order permalink: /:collection/:categories/:title/ # --> You can adjust this to your needs. You can remove ":categories" if your collection doesn't use it. ``` 2. Create a folder for collection in root directory: `_my-collection` 3. Add documents (`.md` format is recommended) to the `_my-collection` folder. 4. In each document add: ``` --- layout: collection-browser-doc # <-- It has to be "collection-browser-doc" title: CLI options # <-- [CHANGE THIS] doc's title categories: # <-- [CHANGE THIS] use single category. (Downcase and dashes instead of spaces) - getting-started excerpt: >- # <-- [CHANGE THIS] doc's description Some description. tags: ["CLI", "Another tag"] # <-- [CHANGE THIS] doc's tags order: 102 # <-- [CHANGE THIS] set different number to each doc to set right order --- ``` 5. Create `index` page for collection. Create folder with collection name in `_pages`: `my-collection` 6. Add `index.html` in `_pages/my-collection`: ``` --- layout: collection-browser # <-- It has to be "collection-browser" title: Use cases subtitle: Learn how to work with Terratest. excerpt: Learn how to work with Terratest. permalink: /use-cases/ slug: use-cases --- {% include collection_browser/browser.html collection=site.my-collection collection_name='my-collection' %} ``` Adjust meta tags and replace `my-collection` with your collection name in `{% include ... %}` ### How it works The Collection Browser needs the _index_ page in `_pages` folder. It basically imports `browser.html` from `_includes/collection_browser`. Meta tags, like `title`, `subtitle`, `excerpt`, are used by Collection Browser on _index_ page. The _index_ page is then published under URL assigned to `permalink`. The _index_ page displays the list of collection's docs. Clicking on any of them, redirects user to the collection's doc page. #### config.yml Collections are registered in the `_config.yml` file like other typical Jekyll collections. Additional field used in the configuration is: `sort_by: order`. It ensures that collection's documents are displayed in the right order. The `order` is set then in every collection document. For large collections it's recommended to split files into several folders, and then to use 3-digit numbers. So each folder would have reserved range of numbers, like: `100 - 199`, `200-299`, etc. It makes easy to add new documents without overwriting `order` fields in other docs. #### Layouts The Collection Browser uses two layouts: * `_layouts/collection-browser.html` - for the _index_ page containing the list of documents * `_layouts/collection-browser-doc.html` - for the "_show_" page of collection's doc #### Includes All Collection Browser partials are stored in `_includes/collection_browser`. * `browser.html` - it is the collection's _index_ page * `_doc-page.html` - is a starting point for collection's document pages (_show_ pages) Some includes may use partials from `_includes` directory. For example, `_includes/collection_browser/_cta-section.html` uses `_includes/links-n-built-by.html`. #### Assets Names of assets used by `collection-browser` in `assets` folder start with `collection-browser`. Collection Browser's classes in stylesheets starts mostly with `cb`, `collection-browser` and `collection-browser-doc`. Javascript files used by Collection Browser: * `collection-browser_scroll.js` - responsible for the scroll spying in navigation sidebar and sticking this bar at the top of the screen when page is being scrolled. * `collection-browser_search.js` - responsible for handling text search and tag filters. * `collection-browser_toc.js` - responsible for opening and closing navigation sidebar on the document page. #### Navigation Sidebar The navigation sidebar is built in `_includes/collection_browser/navigation/_collection_toc.html`. Read more: [Navigation](#navigation) ## Markdown (md) > AsciiDoc (adoc) converter Recommended format of documents is Github Markdown (`.md`). If you use the Markdown format (`.md`), and you want to convert to AsciiDoc format, you can do this with *Pandoc*: 1. Create `input.md` file and paste there Markdown content 2. Run: ``` $ pandoc --from=gfm --to=asciidoc --wrap=none --atx-headers input.md > output.adoc ``` 3. The converted content in `.adoc` format is printed in `output.adoc` You can use also `scripts/convert_md_to_adoc.sh`. ## AsciiDoc (adoc) > Markdown (md) converter To convert from `.adoc` (AsciiDoc) format to Markdown, you need *AsciiDoctor* and *Pandoc*. First, install Asciidoctor: ``` $ sudo apt-get install asciidoctor ``` Next, install Pandoc: https://pandoc.org/installing.html Then you can use script: `scripts/convert_adoc_to_md.sh` or use the command: ``` $ asciidoctor -b docbook input.adoc && pandoc -f docbook -t gfm input.xml -o output.md --wrap=none --atx-headers ``` In both cases: 1. create a file: `input.adoc`, 2. add content to the file, 3. run script or command, 4. The output can be found in `output.md`. ================================================ FILE: docs/_config.yml ================================================ # Welcome to Jekyll! # # This config file is meant for settings that affect your whole blog, values # which you are expected to set up once and rarely edit after that. If you find # yourself editing this file very often, consider using Jekyll's data files # feature for the data you need to update frequently. # # For technical reasons, this file is *NOT* reloaded automatically when you use # 'bundle exec jekyll serve'. If you change this file, please restart the server process. # # If you need help with YAML syntax, here are some quick references for you: # https://learn-the-web.algonquindesign.ca/topics/markdown-yaml-cheat-sheet/#yaml # https://learnxinyminutes.com/docs/yaml/ # # Site settings # These are used to personalize your new site. If you look in the HTML files, # you will see them accessed via {{ site.title }}, {{ site.email }}, and so on. # You can create any custom variable you would like, and they will be accessible # in the templates via {{ site.myvariable }}. title: Terratest url: "https://terratest.gruntwork.io" email: info@gruntwork.io name: "Terratest | Automated tests for your infrastructure code." description: >- # this means to ignore newlines until "baseurl:" Terratest is a Go library that provides patterns and helper functions for testing infrastructure, with 1st-class support for Terraform, Packer, Docker, Kubernetes, AWS, GCP, and more. baseurl: "" # the subpath of your site, e.g. /blog full_company_name: "Gruntwork, Inc" thumbnail_path: "/assets/img/terratest-thumbnail.png" repository: "github.com/gruntwork-io/terratest" twitter_username: https://twitter.com/gruntwork_io github_username: https://github.com/gruntwork-io github_api_url: https://raw.githubusercontent.com/gruntwork-io/terratest/main # Build settings # theme: minima assets_base_url: '/assets/' gtm_tracker: GTM-5TTJJGTL theme: null plugins: - jekyll-toc - jekyll-redirect-from - jekyll-sitemap sass: sass_dir: assets/css style: compressed whitelist: - jekyll-redirect-from include: ['_pages'] collections: docs: output: true sort_by: order permalink: /:collection/:categories/:title/ # Exclude from processing. # The following items will not be processed, by default. # Any item listed under the `exclude:` key here will be automatically added to # the internal "default list". # # Excluded items can be processed by explicitly listing the directories or # their entries' file path in the `include:` list. # exclude: - .sass-cache/ - .jekyll-cache/ - gemfiles/ - Gemfile - Gemfile.lock - node_modules/ - vendor/bundle/ - vendor/cache/ - vendor/gems/ - vendor/ruby/ - scripts/ ================================================ FILE: docs/_data/examples.yml ================================================ - id: terraform-hello-world name: Terraform Hello, World Example image: /assets/img/logos/terraform-logo.png files: - url: /examples/terraform-hello-world-example/main.tf id: terraform_code - url: /test/terraform_hello_world_example_test.go id: test_code default: true learn_more: - name: Terraform Hello, World url: https://github.com/gruntwork-io/terratest/tree/main/examples/terraform-hello-world-example - id: packer-hello-world name: Packer Hello, World Example image: /assets/img/logos/packer-logo.png files: - url: /examples/packer-hello-world-example/build.pkr.hcl id: packer_code - url: /test/packer_hello_world_example_test.go id: test_code default: true learn_more: - name: Packer Hello, World url: https://github.com/gruntwork-io/terratest/tree/main/examples/packer-hello-world-example - id: docker-hello-world name: Docker Hello, World Example image: /assets/img/logos/docker-logo.png files: - url: /examples/docker-hello-world-example/Dockerfile id: docker_code prism_lang: docker - url: /test/docker_hello_world_example_test.go id: test_code default: true learn_more: - name: Docker Hello, World url: https://github.com/gruntwork-io/terratest/tree/main/examples/docker-hello-world-example - id: kubernetes-hello-world name: Kubernetes Hello, World Example image: /assets/img/logos/kubernetes-logo.png files: - url: /examples/kubernetes-hello-world-example/hello-world-deployment.yml id: k8s_code - url: /test/kubernetes_hello_world_example_test.go id: test_code default: true learn_more: - name: Kubernetes Hello, World url: https://github.com/gruntwork-io/terratest/tree/main/examples/kubernetes-hello-world-example - id: terragrunt-hello-world name: Terragrunt Example image: /assets/img/logos/terragrunt-logo.png files: - url: /examples/terragrunt-example/terragrunt.hcl id: terragrunt_code - url: /examples/terragrunt-example/main.tf id: terraform_code - url: /modules/terragrunt/terragrunt_example_test.go id: test_code default: true learn_more: - name: Terragrunt Unit url: https://github.com/gruntwork-io/terratest/tree/main/examples/terragrunt-example - name: Terragrunt Stack url: https://github.com/gruntwork-io/terratest/tree/main/examples/terragrunt-multi-module-example - id: aws-hello-world name: AWS Hello, World Example image: /assets/img/logos/aws-logo.png files: - url: /examples/terraform-aws-hello-world-example/main.tf id: terraform_code - url: /test/terraform_aws_hello_world_example_test.go id: test_code default: true learn_more: - name: AWS Hello, World url: https://github.com/gruntwork-io/terratest/tree/main/examples/terraform-aws-hello-world-example - id: gcp-hello-world name: GCP Hello, World Example image: /assets/img/logos/gcp-logo.png files: - url: /examples/terraform-gcp-hello-world-example/main.tf id: terraform_code - url: /test/gcp/terraform_gcp_hello_world_example_test.go id: test_code default: true learn_more: - name: GCP Hello, World url: https://github.com/gruntwork-io/terratest/tree/main/examples/terraform-gcp-hello-world-example - id: azure-basic name: Azure Hello, World Example image: /assets/img/logos/azure-logo.png files: - url: /examples/azure/terraform-azure-example/main.tf id: terraform_main_code - url: /examples/azure/terraform-azure-example/outputs.tf id: terraform_output_code - url: /examples/azure/terraform-azure-example/variables.tf id: terraform_var_code - url: /test/azure/terraform_azure_example_test.go id: test_code default: true learn_more: - name: Terraform Azure Example url: https://github.com/gruntwork-io/terratest/tree/main/examples/azure/terraform-azure-example - id: opa-terraform name: OPA Terraform Example image: /assets/img/logos/opa-logo.png files: - url: /examples/terraform-opa-example/pass/main_pass.tf id: pass_terraform_main_code - url: /examples/terraform-opa-example/fail/main_fail.tf id: fail_terraform_main_code - url: /examples/terraform-opa-example/policy/enforce_source.rego id: policy_main_code - url: /test/terraform_opa_example_test.go id: test_code default: true learn_more: - name: Terraform OPA Example url: https://github.com/gruntwork-io/terratest/tree/main/examples/terraform-opa-example - id: client-factory name: Azure Client Factory display_in_examples: false files: - url: /modules/azure/client_factory.go id: client_factory_code - url: /modules/azure/client_factory_test.go id: client_factory_test - url: /modules/azure/compute.go id: client_factory_helper ================================================ FILE: docs/_data/prism_extends.yml ================================================ sh: bash tpl: yaml tf: hcl tfvars: hcl yml: yaml ================================================ FILE: docs/_docs/01_getting-started/examples.md ================================================ --- title: Examples category: getting-started excerpt: Examples are the best way to start testing Terraform, Docker, Packer, Kubernetes, AWS, GCP, and more with Terratest. tags: ["example"] redirect_to: - /examples/ order: 102 nav_title: Documentation nav_title_link: /docs/ --- ================================================ FILE: docs/_docs/01_getting-started/godoc.md ================================================ --- layout: collection-browser-doc title: GoDoc category: getting-started excerpt: >- Browse Terratest methods and types in GoDoc. tags: ["packages"] redirect_to: - https://godoc.org/github.com/gruntwork-io/terratest target_blank: true order: 104 nav_title: Documentation nav_title_link: /docs/ --- ================================================ FILE: docs/_docs/01_getting-started/introduction.md ================================================ --- layout: collection-browser-doc title: Introduction category: getting-started toc: true excerpt: >- Terratest provides a variety of helper functions and patterns for common infrastructure testing tasks. Learn more about Terratest basic usage. tags: ["basic-usage"] order: 100 nav_title: Documentation nav_title_link: /docs/ --- ## Introduction Terratest is a Go library that makes it easier to write automated tests for your infrastructure code. It provides a variety of helper functions and patterns for common infrastructure testing tasks, including: - Testing Terraform code - Testing Packer templates - Testing Docker images - Executing commands on servers over SSH - Working with AWS APIs - Working with Azure APIs - Working with GCP APIs - Working with Kubernetes APIs - Enforcing policies with OPA - Testing Helm Charts - Making HTTP requests - Running shell commands - And much more ## Watch: “How to test infrastructure code” Yevgeniy Brikman talks about how to write automated tests for infrastructure code, including the code written for use with tools such as Terraform, Docker, Packer, and Kubernetes. Topics covered include: unit tests, integration tests, end-to-end tests, dependency injection, test parallelism, retries and error handling, static analysis, property testing and CI / CD for infrastructure code. This presentation was recorded at QCon San Francisco 2019: https://qconsf.com/. ### Slides Slides to the video can be found here: [Slides: How to test infrastructure code](https://www.slideshare.net/brikis98/how-to-test-infrastructure-code-automated-testing-for-terraform-kubernetes-docker-packer-and-more){:target="\_blank"}. ## Gruntwork Terratest was developed at [Gruntwork](https://gruntwork.io/) to help maintain the [Infrastructure as Code Library](https://gruntwork.io/infrastructure-as-code-library/), which contains over 300,000 lines of code written in Terraform, Go, Python, and Bash, and is used in production by hundreds of companies.
See how to get started with Terratest Quick Start
================================================ FILE: docs/_docs/01_getting-started/packages-overview.md ================================================ --- layout: collection-browser-doc title: Package by package overview category: getting-started excerpt: >- Learn more about Terratest modules and how they can help you test different types infrastructure. tags: ["packages"] order: 103 nav_title: Documentation nav_title_link: /docs/ --- Now that you've had a chance to browse the examples and their tests, here's an overview of the packages you'll find in Terratest's [modules folder](https://github.com/gruntwork-io/terratest/tree/main/modules) and how they can help you test different types infrastructure: {:.doc-styled-table} | Package | Description | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **aws** | Functions that make it easier to work with the AWS APIs. Examples: find an EC2 Instance by tag, get the IPs of EC2 Instances in an ASG, create an EC2 KeyPair, look up a VPC ID. | | **azure** | Functions that make it easier to work with the Azure APIs. Examples: get the size of a virtual machine, get the tags of a virtual machine. | | **collections** | Go doesn't have much of a collections library built-in, so this package has a few helper methods for working with lists and maps. Examples: subtract two lists from each other. | | **docker** | Functions that make it easier to work with Docker and Docker Compose. Examples: run `docker compose` commands. | | **environment** | Functions for interacting with os environment. Examples: check for first non empty environment variable in a list. | | **files** | Functions for manipulating files and folders. Examples: check if a file exists, copy a folder and all of its contents. | | **gcp** | Functions that make it easier to work with the GCP APIs. Examples: Add labels to a Compute Instance, get the Public IPs of an Instance, Get a list of Instances in a Managed Instance Group, Work with Storage Buckets and Objects. | | **git** | Functions for working with Git. Examples: get the name of the current Git branch. | | **helm** | Functions for working with Helm. Examples: Install a Helm chart. | | **http-helper** | Functions for making HTTP requests. Examples: make an HTTP request to a URL and check the status code and body contain the expected values, run a simple HTTP server locally. | | **k8s** | Functions that make it easier to work with Kubernetes. Examples: Getting the list of nodes in a cluster, waiting until all nodes in a cluster is ready. | | **logger** | A replacement for Go's `t.Log` and `t.Logf` that writes the logs to `stdout` immediately, rather than buffering them until the very end of the test. This makes debugging and iterating easier. | | **logger/parser** | Includes functions for parsing out interleaved go test output and piecing out the individual test logs. Used by the [terratest_log_parser](https://github.com/gruntwork-io/terratest/tree/main/cmd/terratest_log_parser) command. | | **oci** | Functions that make it easier to work with OCI. Examples: Getting the most recent image of a compartment + OS pair, deleting a custom image, retrieving a random subnet. | | **packer** | Functions for working with Packer. Examples: run a Packer build and return the ID of the artifact that was created. | | **random** | Functions for generating random data. Examples: generate a unique ID that can be used to namespace resources so multiple tests running in parallel don't clash. | | **retry** | Functions for retrying actions. Examples: retry a function up to a maximum number of retries, retry a function until a stop function is called, wait up to a certain timeout for a function to complete. These are especially useful when working with distributed systems and eventual consistency. | | **shell** | Functions to run shell commands. Examples: run a shell command and return its `stdout` and `stderr`. | | **ssh** | Functions to SSH to servers. Examples: SSH to a server, execute a command, and return `stdout` and `stderr`. | | **terraform** | Functions for working with Terraform. Examples: run `terraform init`, `terraform apply`, `terraform destroy`. | | **terragrunt** | Functions for working with Terragrunt. Examples: run `terragrunt apply --all`, `terragrunt destroy --all`, test stack configurations with dependencies, and work with Terragrunt stacks. | | **test_structure** | Functions for structuring your tests to speed up local iteration. Examples: break up your tests into stages so that any stage can be skipped by setting an environment variable. | ================================================ FILE: docs/_docs/01_getting-started/quick-start.md ================================================ --- layout: collection-browser-doc title: Quick start category: getting-started excerpt: Learn how to start with Terratest. tags: ["quick-start"] order: 101 nav_title: Documentation nav_title_link: /docs/ custom_js: - examples - prism --- ## Requirements Terratest uses the Go testing framework. To use Terratest, you need to install: - [Go](https://golang.org/) (requires version >=1.21.1) ## Setting up your project The easiest way to get started with Terratest is to copy one of the examples and its corresponding tests from this repo. This quick start section uses a Terraform example, but check out the [Examples]({{site.baseurl}}/examples/) section for other types of infrastructure code you can test (e.g., Packer, Kubernetes, etc). 1. Create an `examples` and `test` folder. 1. Copy the folder including all the files from the [basic terraform example](https://github.com/gruntwork-io/terratest/tree/main/examples/terraform-basic-example/) into the `examples` folder. 1. Copy the [basic terraform example test](https://github.com/gruntwork-io/terratest/blob/main/test/terraform_basic_example_test.go) into the `test` folder. 1. To configure dependencies, run: ```bash cd test go mod init "" go mod tidy ``` Where `` is the name of your module, typically in the format `github.com//`. 1. To run the tests: ```bash cd test go test -v -timeout 30m ``` *(See [Timeouts and logging]({{ site.baseurl }}/docs/testing-best-practices/timeouts-and-logging/) for why the `-timeout` parameter is used.)* ## Terratest intro The basic usage pattern for writing automated tests with Terratest is to: 1. Write tests using Go’s built-in [package testing](https://golang.org/pkg/testing/): you create a file ending in `_test.go` and run tests with the `go test` command. E.g., `go test my_test.go`. 1. Use Terratest to execute your _real_ IaC tools (e.g., Terraform, Packer, etc.) to deploy _real_ infrastructure (e.g., servers) in a _real_ environment (e.g., AWS). 1. Use the tools built into Terratest to validate that the infrastructure works correctly in that environment by making HTTP requests, API calls, SSH connections, etc. 1. Undeploy everything at the end of the test. To make this sort of testing easier, Terratest provides a variety of helper functions and patterns for common infrastructure testing tasks, such as testing Terraform code, testing Packer templates, testing Docker images, executing commands on servers over SSH, making HTTP requests, working with AWS APIs, and so on. ## Example #1: Terraform "Hello, World" Let's start with the simplest possible [Terraform](https://www.terraform.io/) code, which just outputs the text, "Hello, World" (if you’re new to Terraform, check out our [Comprehensive Guide to Terraform](https://blog.gruntwork.io/a-comprehensive-guide-to-terraform-b3d32832baca)): {% include examples/explorer.html example_id='terraform-hello-world' file_id='terraform_code' class='wide quick-start-examples' skip_learn_more=true skip_view_on_github=true skip_tags=true %} How can you test this code to be confident it works correctly? Well, let’s think about how you would test it manually: 1. Run `terraform init` and `terraform apply` to execute the code. 1. When `apply` finishes, check that the output variable says, "Hello, World". 1. When you're done testing, run `terraform destroy` to clean everything up. Using Terratest, you can write an automated test that performs the exact same steps! Here’s what the code looks like: {% include examples/explorer.html example_id='terraform-hello-world' file_id='test_code' class='wide quick-start-examples' skip_learn_more=true skip_view_on_github=true skip_tags=true %} This code does all the steps we mentioned above, including running `terraform init`, `terraform apply`, reading the output variable using `terraform output`, checking its value is what we expect, and running `terraform destroy` (using [`defer`](https://blog.golang.org/defer-panic-and-recover) to run it at the end of the test, whether the test succeeds or fails). If you put this code in a file called `terraform_hello_world_example_test.go`, you can run it by executing `go test`, and you’ll see output that looks like this (truncated for readability): ``` $ go test -v === RUN TestTerraformHelloWorldExample Running command terraform with args [init] Initializing provider plugins... [...] Terraform has been successfully initialized! [...] Apply complete! Resources: 0 added, 0 changed, 0 destroyed. Outputs: hello_world = "Hello, World!" [...] Running command terraform with args [destroy -force -input=false] [...] Destroy complete! Resources: 2 destroyed. --- PASS: TestTerraformHelloWorldExample (149.36s) ``` Success! ## Example #2: Terraform and AWS Let's now try out a more realistic Terraform example. Here is some Terraform code that deploys a simple web server in AWS: {% include examples/explorer.html example_id='aws-hello-world' file_id='terraform_code' class='wide quick-start-examples' skip_learn_more=true skip_view_on_github=true skip_tags=true %} The code above deploys an [EC2 Instance](https://aws.amazon.com/ec2/) that is running an Ubuntu [Amazon Machine Image (AMI)](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/AMIs.html). To keep this example simple, we specify a [User Data](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/user-data.html#user-data-api-cli) script that, while the server is booting, fires up a dirt-simple web server that returns “Hello, World” on port 8080. How can you test this code to be confident it works correctly? Well, let’s again think about how you would test it manually: 1. Run `terraform init` and `terraform apply` to deploy the web server into your AWS account. 1. When `apply` finishes, get the IP of the web server by reading the `public_ip` output variable. 1. Open the IP in your web browser with port 8080 and make sure it says “Hello, World”. Note that it can take 1–2 minutes for the server to boot up, so you may have to retry a few times. 1. When you’re done testing, run `terraform destroy` to clean everything up. Here's how we can automate the steps above using Terratest: {% include examples/explorer.html example_id='aws-hello-world' file_id='test_code' class='wide quick-start-examples' skip_learn_more=true skip_view_on_github=true skip_tags=true %} This test code runs `terraform init` and `terraform apply`, reads the server IP using `terraform output`, makes HTTP requests to the web server (including plenty of retries to account for the server taking time to boot), checks the HTTP response is what we expect, and then runs `terraform destroy` at the end. If you put this code in a file called `terraform_aws_hello_world_example_test.go`, you can run just this test by passing the `-run` argument to `go test` as follows: ``` $ go test -v -run TestTerraformAwsHelloWorldExample -timeout 30m === RUN TestTerraformAwsHelloWorldExample Running command terraform with args [init] Initializing provider plugins... [...] Terraform has been successfully initialized! [...] Running command terraform with args [apply -auto-approve] aws_instance.example: Creating... associate_public_ip_address: "" => "" availability_zone: "" => "" ephemeral_block_device.#: "" => "" instance_type: "" => "t2.micro" key_name: "" => "" [...] Apply complete! Resources: 2 added, 0 changed, 0 destroyed. Outputs: public_ip = 52.67.41.31 [...] Making an HTTP GET call to URL http://52.67.41.31:8080 dial tcp 52.67.41.31:8080: getsockopt: connection refused. Sleeping for 5s and will try again. Making an HTTP GET call to URL http://52.67.41.31:8080 dial tcp 52.67.41.31:8080: getsockopt: connection refused. Sleeping for 5s and will try again. Making an HTTP GET call to URL http://52.67.41.31:8080 Success! [...] Running command terraform with args [destroy -force -input=false] [...] Destroy complete! Resources: 2 destroyed. --- PASS: TestTerraformAwsHelloWorldExample (149.36s) ``` Success! Now, every time you make a change to this Terraform code, the test code can run and make sure your web server works as expected. Note that in the `go test` command above, we set `-timeout 30m`. This is because Go sets a default test time out of 10 minutes, and if your test take longer than that to run, Go will panic, and kill the test code part way through. This is not only annoying, but also prevents the clean up code from running (the `terraform destroy`), leaving you with lots of resources hanging in your AWS account. To prevent this, we always recommend setting a high test timeout; the test above doesn't actually take anywhere near 30 minutes (typical runtime is ~3 minutes), but we give lots of extra buffer to be extra sure that the test always has a chance to finish cleanly. ## Example #3: Docker You can use Terratest for testing a variety of infrastructure code, not just Terraform. For example, you can use it to test your [Docker](https://www.docker.com/) images: {% include examples/explorer.html example_id='docker-hello-world' file_id='docker_code' class='wide quick-start-examples' skip_learn_more=true skip_view_on_github=true skip_tags=true %} The `Dockerfile` above creates a simple Docker image that uses Ubuntu 18.04 as a base and writes the text "Hello, World!" to a text file. At this point, you should already know the drill. First, let's think through how you'd test this `Dockerfile` manually: 1. Run `docker build` to build the Docker image. 1. Run the image via `docker run`. 1. Check that the running Docker container has a text file with the text "Hello, World!" in it. Here's how you can use Terratest to automate this process: {% include examples/explorer.html example_id='docker-hello-world' file_id='test_code' class='wide quick-start-examples' skip_learn_more=true skip_view_on_github=true skip_tags=true %} Instead of using Terraform helpers, this test code uses Terratest's Docker helpers to run `docker build`, `docker run`, and check the contents of the text file. As before, you can run this test using `go test`! ## Example #4: Kubernetes Terratest also provides helpers for testing your [Kubernetes](https://kubernetes.io/) code. For example, here's a Kubernetes manifest you might want to test: {% include examples/explorer.html example_id='kubernetes-hello-world' file_id='k8s_code' class='wide quick-start-examples' skip_learn_more=true skip_view_on_github=true skip_tags=true %} This manifest deploys the [Docker training webapp](https://hub.docker.com/r/training/webapp/), a simple app that responds with the text "Hello, World!", as a Kubernetes Deployment and exposes it to the outside world on port 5000 using a `LoadBalancer`. To test this code manually, you would: 1. Run `kubectl apply` to deploy the Docker training webapp. 1. Use the Kubernetes APIs to figure out the endpoint to hit for the load balancer. 1. Open the endpoint in your web browser on port 5000 and make sure it says “Hello, World”. Note that, depending on your Kubernetes cluster, it could take a minute or two for the Docker container to come up, so you may have to retry a few times. 1. When you're done testing, run `kubectl delete` to clean everything up. Here's how you automate this process with Terratest: {% include examples/explorer.html example_id='kubernetes-hello-world' file_id='test_code' class='wide quick-start-examples' skip_learn_more=true skip_view_on_github=true skip_tags=true %} The test code above uses Kuberenetes helpers built into Terratest to run `kubectl apply`, wait for the service to come up, get the service endpoint, make HTTP requests to the service (with plenty of retries), check the response is what we expect, and runs `kubectl delete` at the end. You run this test with `go test` as well! ## Give it a shot! The above is just a small taste of what you can do with [Terratest](https://github.com/gruntwork-io/terratest). To learn more: 1. Check out the [examples]({{site.baseurl}}/examples/) and the corresponding automated tests for those examples for fully working (and tested!) sample code. 1. Browse through the list of [Terratest packages]({{site.baseurl}}/docs/getting-started/packages-overview/) to get a sense of all the tools available in Terratest. 1. Read our [Testing Best Practices Guide]({{site.baseurl}}/docs/#testing-best-practices). 1. Check out real-world examples of Terratest usage in our open source infrastructure modules: [Consul](https://github.com/hashicorp/terraform-aws-consul), [Vault](https://github.com/hashicorp/terraform-aws-vault), [Nomad](https://github.com/hashicorp/terraform-aws-nomad). Happy testing! ================================================ FILE: docs/_docs/01_getting-started/testing-terragrunt.md ================================================ --- layout: collection-browser-doc title: Testing Terragrunt category: getting-started excerpt: >- Learn how to test Terragrunt configurations with Terratest. tags: ["terragrunt", "testing", "quick-start"] order: 104 nav_title: Documentation nav_title_link: /docs/ --- ## Overview Terratest provides two approaches for testing Terragrunt configurations: | Approach | Use Case | Package | |----------|----------|---------| | **Unit** | Testing individual units | `modules/terraform` with `TerraformBinary: "terragrunt"` | | **Stack** | Testing a stack of units with `--all` commands | `modules/terragrunt` | ## Unit Testing For testing a single Terragrunt unit, use the `terraform` package with `TerraformBinary` set to `"terragrunt"`: ```go func TestTerragruntModule(t *testing.T) { t.Parallel() terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ TerraformDir: "../examples/my-module", TerraformBinary: "terragrunt", }) defer terraform.Destroy(t, terraformOptions) terraform.Apply(t, terraformOptions) output := terraform.Output(t, terraformOptions, "my_output") assert.Equal(t, "expected_value", output) } ``` ## Stack Testing For testing a stack of units with dependencies, use the dedicated `terragrunt` package: ```go func TestStack(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("../live/prod", t.Name()) require.NoError(t, err) options := &terragrunt.Options{ TerragruntDir: testFolder, } defer terragrunt.DestroyAll(t, options) terragrunt.ApplyAll(t, options) exitCode := terragrunt.PlanAllExitCode(t, options) require.Equal(t, 0, exitCode) } ``` ### Available Functions | Function | Description | |----------|-------------| | `Init` | Run `terragrunt init` | | `ApplyAll` | Run `terragrunt apply --all` | | `DestroyAll` | Run `terragrunt destroy --all` | | `PlanAllExitCode` | Run `terragrunt plan --all`, return exit code | | `ValidateAll` | Run `terragrunt validate --all` | | `FormatAll` | Run `terragrunt fmt --all` | | `RunAll` | Run any command with `--all` flag | ### Stack Functions For Terragrunt [stacks](https://terragrunt.gruntwork.io/docs/features/stacks/): | Function | Description | |----------|-------------| | `StackGenerate` | Generate stack from `terragrunt.stack.hcl` | | `StackRun` | Run command on generated stack | | `StackClean` | Remove `.terragrunt-stack` directory | | `Output` | Get stack output value | | `OutputAll` | Get all stack outputs as map | ## Further Reading - [Terragrunt Documentation](https://terragrunt.gruntwork.io/) - [Stack example](https://github.com/gruntwork-io/terratest/tree/main/examples/terragrunt-multi-module-example) - [terragrunt package reference](https://pkg.go.dev/github.com/gruntwork-io/terratest/modules/terragrunt) ================================================ FILE: docs/_docs/02_testing-best-practices/alternative-testing-tools.md ================================================ --- layout: collection-browser-doc title: Alternative testing tools category: testing-best-practices excerpt: >- Learn more about alternatives to Terratest, and how Terratest compares to other testing tools. tags: ["testing-best-practices", "alternatives"] order: 211 nav_title: Documentation nav_title_link: /docs/ --- ## A list of infrastructure testing tools Below is a list of other infrastructure testing tools you may wish to use in addition to Terratest. Check out [How Terratest compares to other testing tools]({{site.baseurl}}/docs/testing-best-practices/alternative-testing-tools/#how-terratest-compares-to-other-testing-tools) to understand the trade-offs. 1. [kitchen-terraform](https://github.com/newcontext-oss/kitchen-terraform) 1. [rspec-terraform](https://github.com/bsnape/rspec-terraform) 1. [serverspec](https://serverspec.org/) 1. [inspec](https://www.inspec.io/) 1. [Goss](https://github.com/aelsabbahy/goss) 1. [awspec](https://github.com/k1LoW/awspec) 1. [Terraform's acceptance testing framework](https://github.com/hashicorp/terraform/blob/master/.github/CONTRIBUTING.md#acceptance-tests-testing-interactions-with-external-services) 1. [ruby_terraform](https://github.com/infrablocks/ruby_terraform) ## Why Terratest? Our experience with building the [Infrastructure as Code Library](https://gruntwork.io/infrastructure-as-code-library/) is that the _only_ way to create reliable, maintainable infrastructure code is to have a thorough suite of real-world, end-to-end acceptance tests. Without these sorts of tests, you simply cannot be confident that the infrastructure code actually works. This is especially important with modern DevOps, as all the tools are changing so quickly. Terratest has helped us catch bugs not only in our own code, but also in AWS, Azure, Terraform, Packer, Kafka, Elasticsearch, CircleCI, and so on. Moreover, by running tests nightly, we're able to catch backwards incompatible changes and regressions in our dependencies (e.g., backwards incompatibilities in new versions of Terraform) as early as possible. ## How Terratest compares to other testing tools Most of the other infrastructure testing tools we've seen are focused on making it easy to check the properties of a single server or resource. For example, the various `xxx-spec` tools offer a nice, concise language for connecting to a server and checking if, say, `httpd` is installed and running. These tools are effectively verifying that individual "properties" of your infrastructure meet a certain spec. Terratest approaches the testing problem from a different angle. The question we're trying to answer is, "does the infrastructure actually work?" Instead of checking individual server properties (e.g., is `httpd` installed and running), we'll actually make HTTP requests to the server and check that we get the expected response; or we'll store data in a database and make sure we can read it back out; or we'll try to deploy a new version of a Docker container and make sure the orchestration tool can roll out the new container with no downtime. Moreover, we use Terratest not only with individual servers, but to test entire systems. For example, the automated tests for the [Vault module](https://github.com/hashicorp/terraform-aws-vault/tree/master/modules) do the following: 1. Use Packer to build an AMI. 1. Use Terraform to create self-signed TLS certificates. 1. Use Terraform to deploy all the infrastructure: a Vault cluster (which runs the AMI from the previous step), Consul cluster, load balancers, security groups, S3 buckets, and so on. 1. SSH to a Vault node to initialize the cluster. 1. SSH to all the Vault nodes to unseal them. 1. Use the Vault SDK to store data in Vault. 1. Use the Vault SDK to make sure you can read the same data back out of Vault. 1. Use Terraform to undeploy and clean up all the infrastructure. The steps above are exactly what you would've done to test the Vault module manually. Terratest helps automate this process. You can think of Terratest as a way to do end-to-end, acceptance or integration testing, whereas most other tools are focused on unit or functional testing. ================================================ FILE: docs/_docs/02_testing-best-practices/avoid-test-caching.md ================================================ --- layout: collection-browser-doc title: Avoid test caching category: testing-best-practices excerpt: >- Since Go 1.10, test results are automatically cached. See how to turn off caching test results. tags: ["testing-best-practices", "cache"] order: 207 nav_title: Documentation nav_title_link: /docs/ --- Since Go 1.10, test results are automatically [cached](https://golang.org/doc/go1.10#test). This can lead to Go not running your tests again if you haven't changed any of the Go code. Since you're probably mainly manipulating Terraform files, you should consider turning the caching of test results off. This ensures that the tests are run every time you run `go test` and the result is not just read from the cache. To turn caching off, you can set the `-count` flag to `1` force the tests to run: ```shell $ go test -count=1 -timeout 30m -p 1 ./... ``` ================================================ FILE: docs/_docs/02_testing-best-practices/cleanup.md ================================================ --- layout: collection-browser-doc title: Cleanup category: testing-best-practices excerpt: >- Since automated tests with Terratest deploy real resources into real environments, you'll want to make sure your tests always cleanup after themselves. tags: ["testing-best-practices", "clean", "terraform-destroy", "terraform-apply"] order: 204 nav_title: Documentation nav_title_link: /docs/ --- Since automated tests with Terratest deploy real resources into real environments, you'll want to make sure your tests always cleanup after themselves so you don't leave a bunch of resources lying around. Typically, you should use Go's `defer` keyword to ensure that the cleanup code always runs, even if the test hits an error along the way. For example, if your test runs `terraform apply`, you should run `terraform destroy` at the end to clean up: ```go // Ensure cleanup always runs defer terraform.Destroy(t, options) // Deploy terraform.Apply(t, options) // Validate checkServerWorks(t, options) ``` Of course, despite your best efforts, occasionally cleanup will fail, perhaps due to the CI server going down, or a bug in your code, or a temporary network outage. To handle those cases, we run a tool called [cloud-nuke](https://github.com/gruntwork-io/cloud-nuke) in our test AWS account on a nightly basis to clean up any leftover resources. ================================================ FILE: docs/_docs/02_testing-best-practices/debugging-interleaved-test-output.md ================================================ --- layout: collection-browser-doc title: Debugging interleaved test output category: testing-best-practices excerpt: >- Learn more about `terratest_log_parser`. tags: ["testing-best-practices", "logger"] order: 206 nav_title: Documentation nav_title_link: /docs/ --- ## Debugging interleaved test output **Note**: The `terratest_log_parser` requires an explicit installation. See [Installing the utility binaries](#installing-the-utility-binaries) for installation instructions. If you log using Terratest's `logger` package, you may notice that all the test outputs are interleaved from the parallel execution. This may make it difficult to debug failures, as it can be tedious to sift through the logs to find the relevant entries for a failing test, let alone find the test that failed. Therefore, Terratest ships with a utility binary `terratest_log_parser` that can be used to break out the logs. To use the utility, you simply give it the log output from a `go test` run and a desired output directory: ```bash go test -timeout 30m | tee test_output.log terratest_log_parser -testlog test_output.log -outputdir test_output ``` This will: - Create a file `TEST_NAME.log` for each test it finds from the test output containing the logs corresponding to that test. - Create a `summary.log` file containing the test result lines for each test. - Create a `report.xml` file containing a Junit XML file of the test summary (so it can be integrated in your CI). The output can be integrated in your CI engine to further enhance the debugging experience. See Terratest's own [circleci configuration](https://github.com/gruntwork-io/terratest/blob/main/.circleci/config.yml) for an example of how to integrate the utility with CircleCI. This provides for each build: - A test summary view showing you which tests failed: ![CircleCI test summary]({{site.baseurl}}/assets/img/docs/debugging-interleaved-test-output/circleci-test-summary.png) - A snapshot of all the logs broken out by test: ![CircleCI logs]({{site.baseurl}}/assets/img/docs/debugging-interleaved-test-output/circleci-logs.png) ## Installing the utility binaries Terratest also ships utility binaries that you can use to improve the debugging experience (see [Debugging interleaved test output](#debugging-interleaved-test-output)). The compiled binaries are shipped separately from the library in the [Releases page](https://github.com/gruntwork-io/terratest/releases). The following binaries are currently available with `terratest`: {:.doc-styled-table} | Command | Description | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **terratest_log_parser** | Parses test output from the `go test` command and breaks out the interleaved logs into logs for each test. Integrate with your CI environment to help debug failing tests. | | **pick-instance-type** | Takes an AWS region and a list of EC2 instance types and returns the first instance type in the list that is available in all Availability Zones in the given region, or exits with an error if no instance type is available in all AZs. This is useful because certain instance types, such as t2.micro, are not available in some newer AZs, while t3.micro is not available in some older AZs. If you have code that needs to run on a "small" instance across all AZs in many regions, you can use this CLI tool to automatically figure out which instance type you should use. | You can install any binary using one of the following methods: - [Manual installation](#manual-installation) - [go install](#go-install) - [gruntwork-installer](#gruntwork-installer) ### Manual installation To install the binary manually, download the version that matches your platform and place it somewhere on your `PATH`. For example to install version 0.13.13 of `terratest_log_parser`: ```bash # This example assumes a linux 64bit machine # Use curl to download the binary curl --location --silent --fail --show-error -o terratest_log_parser https://github.com/gruntwork-io/terratest/releases/download/v0.13.13/terratest_log_parser_linux_amd64 # Make the downloaded binary executable chmod +x terratest_log_parser # Finally, we place the downloaded binary to a place in the PATH sudo mv terratest_log_parser /usr/local/bin ``` ### go install `go` supports building and installing packages and commands from source using the [go install](https://pkg.go.dev/cmd/go#hdr-Compile_and_install_packages_and_dependencies) command. To install the binaries with `go install`, point `go install` to the repo and path where the main code for each relevant command lives. For example, you can install the terratest log parser binary with: ``` go install github.com/gruntwork-io/terratest/cmd/terratest_log_parser@latest ``` Similarly, to install `pick-instance-type`, you can run: ``` go install github.com/gruntwork-io/terratest/cmd/pick-instance-type@latest ``` ### gruntwork-installer You can also use [the gruntwork-installer utility](https://github.com/gruntwork-io/gruntwork-installer) to install the binaries, which will do the above steps and automatically select the right binary for your platform: ```bash gruntwork-install --binary-name 'terratest_log_parser' --repo 'https://github.com/gruntwork-io/terratest' --tag 'v0.13.13' ``` ================================================ FILE: docs/_docs/02_testing-best-practices/error-handling.md ================================================ --- layout: collection-browser-doc title: Error handling category: testing-best-practices excerpt: >- Learn how to handle errors. tags: ["testing-best-practices", "terraform", "error"] order: 208 nav_title: Documentation nav_title_link: /docs/ --- Just about every method `foo` in Terratest comes in two versions: `foo` and `fooE` (e.g., `terraform.Apply` and `terraform.ApplyE`). - `foo`: The base method takes a `t *testing.T` as an argument. If the method hits any errors, it calls `t.Fatal` to fail the test. - `fooE`: Methods that end with the capital letter `E` always return an `error` as the last argument and never call `t.Fatal` themselves. This allows you to decide how to handle errors. You will use the base method name most of the time, as it allows you to keep your code more concise by avoiding `if err != nil` checks all over the place: ```go terraform.Init(t, terraformOptions) terraform.Apply(t, terraformOptions) url := terraform.Output(t, terraformOptions, "url") ``` In the code above, if `Init`, `Apply`, or `Output` hits an error, the method will call `t.Fatal` and fail the test immediately, which is typically the behavior you want. However, if you are _expecting_ an error and don't want it to cause a test failure, use the method name that ends with a capital `E`: ```go if _, err := terraform.InitE(t, terraformOptions); err != nil { // Do something with err } if _, err := terraform.ApplyE(t, terraformOptions); err != nil { // Do something with err } url, err := terraform.OutputE(t, terraformOptions, "url") if err != nil { // Do something with err } ``` As you can see, the code above is more verbose, but gives you more flexibility with how to handle errors. ================================================ FILE: docs/_docs/02_testing-best-practices/idempotent.md ================================================ --- layout: collection-browser-doc title: Idempotent category: testing-best-practices excerpt: >- Test that your Terraform configuration results in consistent deployments. tags: ["testing-best-practices", "idempotent", "terraform"] order: 212 nav_title: Documentation nav_title_link: /docs/ --- A Terraform configuration is idempotent when a second apply results in 0 changes. An idempotent configuration ensures that: 1. What you define in Terraform is exactly what is being deployed. 1. Detection of bugs in Terraform resources and providers that might affect your configuration. You can use Terratest's `terraform.ApplyAndIdempotent()` function to both apply your Terraform configuration and test its idempotency. ```go terraform.ApplyAndIdempotent(t, terraformOptions) ``` If a second apply of your Terraform configuration results in changes then your test will fail. ================================================ FILE: docs/_docs/02_testing-best-practices/iterating-locally-using-docker.md ================================================ --- layout: collection-browser-doc title: Iterating locally using Docker category: testing-best-practices excerpt: >- If you're writing scripts (i.e., Bash, Python, or Go), you should be able to test them locally using Docker. Docker containers typically build 10x faster and start 100x faster than real servers. tags: ["testing-best-practices", "docker"] order: 209 nav_title: Documentation nav_title_link: /docs/ --- For most infrastructure code, your only option is to deploy into a real environment such as AWS. However, if you're writing scripts (i.e., Bash, Python, or Go), you should be able to test them locally using Docker. Docker containers typically build 10x faster and start 100x faster than real servers, so using Docker for testing can help you iterate much faster. Here are some techniques we use with Docker: - If your script is used in a Packer template, add a [Docker builder](https://www.packer.io/docs/builders/docker.html) to the template so you can create a Docker image from the same code. See the [Packer Docker Example](https://github.com/gruntwork-io/terratest/tree/main/examples/packer-docker-example) for working sample code. - We have prebuilt Docker images for major Linux distros that have many important dependencies (e.g., curl, vim, tar, sudo) already installed. See the [test-docker-images folder](https://github.com/gruntwork-io/terratest/tree/main/test-docker-images) for more details. - Create a `docker-compose.yml` to make it easier to run your Docker image with all the ports, environment variables, and other settings it needs. See the [Packer Docker Example](https://github.com/gruntwork-io/terratest/tree/main/examples/packer-docker-example) for working sample code. - With scripts in Docker, you can replace _some_ real-world dependencies with mocks! One way to do this is to create some "mock scripts" and to bind-mount them in `docker-compose.yml` in a way that replaces the real dependency. For example, if your script calls the `aws` CLI, you could create a mock script called `aws` that shows up earlier in the `PATH`. Using mocks allows you to test 100% locally, without external dependencies such as AWS. ================================================ FILE: docs/_docs/02_testing-best-practices/iterating-locally-using-test-stages.md ================================================ --- layout: collection-browser-doc title: Iterating locally using test stages category: testing-best-practices excerpt: >- Learn more about Terratest's `test_structure`. tags: ["testing-best-practices", "test_structure"] order: 210 nav_title: Documentation nav_title_link: /docs/ --- Most automated tests written with Terratest consist of multiple "stages", such as: 1. Build an AMI using Packer 1. Deploy the AMI using Terraform 1. Validate that the AMI works as expected 1. Undeploy the AMI using Terraform Often, while testing locally, you'll want to re-run some subset of these stages over and over again: for example, you might want to repeatedly run the validation step while you work out the kinks. Having to run _all_ of these stages each time you change a single line of code can be very slow. This is where Terratest's `test_structure` package comes in handy: it allows you to explicitly break up your tests into stages and to be able to disable any one of those stages by setting an environment variable. Check out the [terraform_packer_example_test.go](https://github.com/gruntwork-io/terratest/blob/main/test/terraform_packer_example_test.go) for working sample code. ## How to skip stages To skip a stage, set `SKIP_` to any non-empty value. For example, to re-run deploy and validation without rebuilding the AMI: ```bash SKIP_build_ami=true go test -v -run TestTerraformPackerExample ``` This works because each stage saves its outputs (AMI IDs, Terraform options, etc.) to the working directory using functions like `SaveString` and `SaveAmiId`. Subsequent stages load this cached data using `LoadString`, `LoadAmiId`, etc. When any `SKIP_*` variable is set, Terratest also skips copying to a temp folder, preserving cached state between runs. ================================================ FILE: docs/_docs/02_testing-best-practices/namespacing.md ================================================ --- layout: collection-browser-doc title: Namespacing category: testing-best-practices excerpt: >- Learn how to avoid conflicts due to duplicated identifiers. tags: ["testing-best-practices", "namespace", "id", "identifiers"] order: 203 nav_title: Documentation nav_title_link: /docs/ --- Just about all resources your tests create (e.g., servers, load balancers, machine images) should be "namespaced" with a unique name to ensure that: 1. You don't accidentally overwrite any "production" resources in that environment (though as mentioned in the previous section, your test environment should be completely isolated from prod anyway). 1. You don't accidentally clash with other tests running in parallel. For example, when deploying AWS infrastructure with Terraform, that typically means exposing variables that allow you to configure auto scaling group names, security group names, IAM role names, and any other names that must be unique. You can use Terratest's `random.UniqueId()` function to generate identifiers that are short enough to use in resource names (just 6 characters) but random enough to make it unlikely that you'll have a conflict. ```go uniqueId := random.UniqueId() instanceName := fmt.Sprintf("terratest-http-example-%s", uniqueId) terraformOptions := &terraform.Options { TerraformDir: "../examples/terraform-http-example", Vars: map[string]interface{} { "instance_name": instanceName, }, } terraform.Apply(t, terraformOptions) ``` ================================================ FILE: docs/_docs/02_testing-best-practices/picking-instance-types.md ================================================ --- layout: collection-browser-doc title: Picking EC2 instance types category: testing-best-practices excerpt: >- Pick EC2 instance types that are available in the current AWS region. tags: ["testing-best-practices", "aws", "ec2"] order: 213 nav_title: Documentation nav_title_link: /docs/ --- It's common to want to test infrastructure code that deploys [EC2 instances](https://aws.amazon.com/ec2/) into AWS. There are many different [instance types](https://aws.amazon.com/ec2/instance-types/), but not all instance types are available in all [regions or availability zones (AZs)](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html). For example, `t3.micro` is sometimes available only in newer AZs, while `t2.micro` is sometimes only available in older AZs. If you are testing code that needs to deploy a "small" instance across many regions, this can make it tricky to know which region to pick. To help work around this problem, Terratest includes: 1. [`GetRecommendedInstanceType`](#getrecommendedinstancetype): A Go function that helps you pick a recommended instance type. 1. [`pick-instance-type`](#pick-instance-type): A CLI tool that helps you pick a recommended instance type. ## `GetRecommendedInstanceType` `GetRecommendedInstanceType` takes in an AWS region and a list of EC2 instance types and returns the first instance type in the list that is available in all Availability Zones (AZs) in the given region. If there's no instance available in all AZs, this function exits with an error. Example usage: ```go aws.GetRecommendedInstanceType(t, "eu-west-1", []string{"t2.micro", "t3.micro"}) // As of July, 2020, returns "t2.micro" aws.GetRecommendedInstanceType(t, "ap-northeast-2", []string{"t2.micro", "t3.micro"}) // As of July, 2020, returns "t3.micro" ``` ## `pick-instance-type` `pick-instance-type` is a CLI tool that you can download from the [Terratest releases page](https://github.com/gruntwork-io/terratest/releases) (click "Assets" under any release). It takes in an AWS region and a list of EC2 instance types and prints to `stdout` the first instance type in the list that is available in all Availability Zones (AZs) in the given region. If there's no instance available in all AZs, `pick-instance-type` exits with an error. Example usage: ```bash # Data below is from July, 2020 $ pick-instance-type eu-west-1 t2.micro t3.micro t2.micro $ pick-instance-type ap-northeast-2 t2.micro t3.micro t3.micro ``` ================================================ FILE: docs/_docs/02_testing-best-practices/testing-environment.md ================================================ --- layout: collection-browser-doc title: Testing environment category: testing-best-practices excerpt: >- Learn more about testing environments. tags: ["testing-best-practices"] order: 202 nav_title: Documentation nav_title_link: /docs/ --- Since most automated tests written with Terratest can make potentially destructive changes in your environment, we strongly recommend running tests in an environment that is totally separate from production. For example, if you are testing infrastructure code for AWS, you should run your tests in a completely separate AWS account. This means that you will have to write your infrastructure code in such a way that you can plug in ([dependency injection](https://en.wikipedia.org/wiki/Dependency_injection)) environment-specific details, such as account IDs, domain names, IP addresses, etc. Adding support for this will typically make your code cleaner and more flexible. ================================================ FILE: docs/_docs/02_testing-best-practices/timeouts-and-logging.md ================================================ --- layout: collection-browser-doc title: Timeouts and logging category: testing-best-practices excerpt: >- Long-running infrastructure tests may exceed timeouts or can be killed if they do not prompt logs. tags: ["testing-best-practices", "timeout", "error"] order: 205 nav_title: Documentation nav_title_link: /docs/ --- Go's package testing has a default timeout of 10 minutes, after which it forcibly kills your tests—even your cleanup code won't run! It's not uncommon for infrastructure tests to take longer than 10 minutes, so you'll almost always want to increase the timeout by using the `-timeout` option, which takes a `go` duration string (e.g `10m` for 10 minutes or `1h` for 1 hour): ```bash go test -timeout 30m ``` Note that many CI systems will also kill your tests if they don't see any log output for a certain period of time (e.g., 10 minutes in CircleCI). If you use Go's `t.Log` and `t.Logf` for logging in your tests, you'll find that these functions buffer all log output until the very end of the test (see https://github.com/golang/go/issues/24929 for more info). If you have a long-running test, this might mean you get no log output for more than 10 minutes, and the CI system will shut down your tests. Moreover, if your test has a bug that causes it to hang, you won't see any log output at all to help you debug it. Therefore, we recommend instead using Terratest's `logger.Log` and `logger.Logf` functions, which log to `stdout` immediately: ```go func TestFoo(t *testing.T) { logger.Log(t, "This will show up in stdout immediately") } ``` Finally, if you're testing multiple Go packages, be aware that Go will buffer log output—even that sent directly to `stdout` by `logger.Log` and `logger.Logf`—until all the tests in the package are done. This leads to the same difficulties with CI servers and debugging. The workaround is to tell Go to test each package sequentially using the `-p 1` flag: ```bash go test -timeout 30m -p 1 ./... ``` See the [Cleanup]({{site.baseurl}}/docs/testing-best-practices/cleanup/) for more information on how to setup robust clean up procedures in the face of test timeouts and instabilities. ================================================ FILE: docs/_docs/02_testing-best-practices/tools-and-plugins.md ================================================ --- layout: collection-browser-doc title: Tools and Plugins category: testing-best-practices excerpt: >- Additional tools and plugins for terratest to make integration with existing workflows easier. tags: ["testing-best-practices", "alternatives","plugins","tooling"] order: 214 nav_title: Documentation nav_title_link: /docs/ --- ## Tools and Plugins This page contains a list of tools and plugins for Terratest to make integration with existing workflows easier. If you've created other tools that integrate with Terratest, a PR to add it to this list is very welcome! - [Tools and Plugins](#tools-and-plugins) - [Terratest Maven Plugin](#terratest-maven-plugin) ### Terratest Maven Plugin The Terratest Maven Plugin aims to bring Terratest to the JVM world. Create your Go based tests beside your Java code with Maven and run them together. You can export the results into Json or an HTML page. As the plugin is MIT licensed, it is easy and painless to integrate into any Java+Maven combination. To learn more check out the website: [Terratest Maven Plugin](https://terratest-maven-plugin.github.io) and the [GitHub repository](https://github.com/terratest-maven-plugin/terratest-maven-plugin) ================================================ FILE: docs/_docs/02_testing-best-practices/unit-integration-end-to-end-test.md ================================================ --- layout: collection-browser-doc title: Unit tests, integration tests, end-to-end tests category: testing-best-practices excerpt: >- See the talk about unit tests, integration tests, end-to-end tests, dependency injection, test parallelism, retries, error handling, and static analysis. tags: ["testing-best-practices"] order: 201 nav_title: Documentation nav_title_link: /docs/ --- For an introduction to Terratest, including unit tests, integration tests, end-to-end tests, dependency injection, test parallelism, retries, error handling, and static analysis, see the talk "Automated Testing for Terraform, Docker, Packer, Kubernetes, and More". Link to the video at [infoq.com](https://www.infoq.com/presentations/automated-testing-terraform-docker-packer/). ## Slides Slides to the video can be found here: [Slides: How to test infrastructure code](https://www.slideshare.net/brikis98/how-to-test-infrastructure-code-automated-testing-for-terraform-kubernetes-docker-packer-and-more){:target="_blank"}. ================================================ FILE: docs/_docs/04_community/contributing.md ================================================ --- layout: collection-browser-doc title: Contributing category: community excerpt: >- Terratest is an open source project, and contributions from the community are very welcome! tags: ["contributing", "community"] order: 400 nav_title: Documentation nav_title_link: /docs/ custom_js: - examples - prism - collection-browser_scroll - collection-browser_search - collection-browser_toc --- Terratest is an open source project, and contributions from the community are very welcome\! Please check out the [Contribution Guidelines](#contribution-guidelines) and [Developing Terratest](#developing-terratest) for instructions. ## Contribution Guidelines Contributions to this repo are very welcome! We follow a fairly standard [pull request process](https://help.github.com/articles/about-pull-requests/) for contributions, subject to the following guidelines: 1. [Types of contributions](#types-of-contributions) 1. [File a GitHub issue](#file-a-github-issue) 1. [Update the documentation](#update-the-documentation) 1. [Update the tests](#update-the-tests) 1. [Update the code](#update-the-code) 1. [Create a pull request](#create-a-pull-request) 1. [Merge and release](#merge-and-release) ### Types of contributions Broadly speaking, Terratest contains two types of helper functions: 1. Integrations with external tools 1. Infrastructure and validation helpers We accept different types of contributions for each of these two types of helper functions, as described next. #### Integrations with external tools These are helper functions that integrate with various DevOps tools—e.g., Terraform, Docker, Packer, and Kubernetes—that you can use to deploy infrastructure in your automated tests. Examples: * `terraform.InitAndApply`: run `terraform init` and `terraform apply`. * `packer.BuildArtifacts`: run `packer build`. * `shell.RunCommandAndGetOutput`: run an arbitrary shell command and return `stdout` and `stderr` as a string. Here are the guidelines for contributions with external tools: 1. **Fixes and improvements to existing integrations**: All bug fixes and new features for existing tool integrations are very welcome! 1. **New integrations**: Before contributing an integration with a totally new tool, please file a GitHub issue to discuss with us if it's something we are interested in supporting and maintaining. For example, we may be open to new integrations with Docker and Kubernetes tools, but we may not be open to integrations with Chef or Puppet, as there are already testing tools available for them. #### Infrastructure and validation helpers These are helper functions for creating, destroying, and validating infrastructure directly via API calls or SDKs. Examples: * `http_helper.HttpGetWithRetry`: make an HTTP request, retrying until you get a certain expected response. * `ssh.CheckSshCommand`: SSH to a server and execute a command. * `aws.CreateS3Bucket`: create an S3 bucket. * `aws.GetPrivateIpsOfEc2Instances`: use the AWS APIs to fetch IPs of some EC2 instances. The number of possible such helpers is nearly infinite, so to avoid Terratest becoming a gigantic, sprawling library we ask that contributions for new infrastructure helpers are limited to: 1. **Platforms**: we currently only support three major public clouds (AWS, GCP, Azure) and Kubernetes. There is some code contributed earlier for other platforms (e.g., OCI), but until we have the time/resources to support those platforms fully, we will only accept contributions for the major public clouds and Kubernetes. 1. **Complexity**: we ask that you only contribute infrastructure and validation helpers for code that is relatively complex to do from scratch. For example, a helper that merely wraps an existing function in the AWS or GCP SDK is not a great choice, as the wrapper isn't contributing much value, but is bloating the Terratest API. On the other hand, helpers that expose simple APIs for complex logic are great contributions: `ssh.CheckSshCommand` is a great example of this, as it provides a simple one-line interface for dozens of lines of complicated SSH logic. 1. **Popularity**: Terratest should only contain helpers for common use cases that come up again and again in the course of testing. We don't want to bloat the library with lots of esoteric helpers for rarely used tools, so here's a quick litmus test: (a) Is this helper something you've used once or twice in your own tests, or is it something you're using over and over again? (b) Does this helper only apply to some use case specific to your company or is it likely that many other Terratest users are hitting this use case over and over again too? 1. **Creating infrastructure**: we try to keep helper functions that create infrastructure (e.g., use the AWS SDK to create an S3 bucket or EC2 instance) to a minimum, as those functions typically require maintaining state (so that they are idempotent and can clean up that infrastructure at the end of the test) and dealing with asynchronous and eventually consistent cloud APIs. This can be surprisingly complicated, so we typically recommend using a tool like Terraform, which already handles all that complexity, to create any infrastructure you need at test time, and running Terratest's built-in `terraform` helpers as necessary. If you're considering contributing a function that creates infrastructure directly (e.g., using a cloud provider's APIs), please file a GitHub issue to explain why such a function would be a better choice than using a tool like Terraform. ### File a GitHub issue Before starting any work, we recommend filing a GitHub issue in this repo. This is your chance to ask questions and get feedback from the maintainers and the community before you sink a lot of time into writing (possibly the wrong) code. If there is anything you're unsure about, just ask! ### Update the documentation We recommend updating the documentation *before* updating any code (see [Readme Driven Development](http://tom.preston-werner.com/2010/08/23/readme-driven-development.html)). This ensures the documentation stays up to date and allows you to think through the problem at a high level before you get lost in the weeds of coding. The documentation is built with Jekyll and hosted on the Github Pages from `docs` folder on `main` branch. Check out [Terratest website](https://github.com/gruntwork-io/terratest/tree/main/docs#working-with-the-documentation) to learn more about working with the documentation. ### Update the tests We also recommend updating the automated tests *before* updating any code (see [Test Driven Development](https://en.wikipedia.org/wiki/Test-driven_development)). That means you add or update a test case, verify that it's failing with a clear error message, and *then* make the code changes to get that test to pass. This ensures the tests stay up to date and verify all the functionality in this Module, including whatever new functionality you're adding in your contribution. The instructions for running the automated tests can be found [here](https://terratest.gruntwork.io/docs/community/contributing/#developing-terratest). ### Update the code At this point, make your code changes and use your new test case to verify that everything is working. As you work, please make every effort to avoid unnecessary backwards incompatible changes. This generally means that you should not delete or rename anything in a public API. If a backwards incompatible change cannot be avoided, please make sure to call that out when you submit a pull request, explaining why the change is absolutely necessary. Note that we use pre-commit hooks with this project. To ensure they run: 1. Install [pre-commit](https://pre-commit.com/). 1. Run `pre-commit install`. One of the pre-commit hooks we run is [goimports](https://godoc.org/golang.org/x/tools/cmd/goimports). To prevent the hook from failing, make sure to : 1. Install [goimports](https://godoc.org/golang.org/x/tools/cmd/goimports) 1. Run `goimports -w .`. We have a [style guide](https://gruntwork.io/guides/style%20guides/golang-style-guide/) for the Go programming language, in which we documented some best practices for writing Go code. Please ensure your code adheres to the guidelines outlined in the guide. ### Create a pull request [Create a pull request](https://help.github.com/articles/creating-a-pull-request/) with your changes. Please make sure to include the following: 1. A description of the change, including a link to your GitHub issue. 1. The output of your automated test run, preferably in a [GitHub Gist](https://gist.github.com/). We cannot run automated tests for pull requests automatically due to [security concerns](https://circleci.com/docs/2.0/oss/#security), so we need you to manually provide this test output so we can verify that everything is working. 1. Any notes on backwards incompatibility or downtime. #### Validate the Pull Request for Azure Platform If you're contributing code for the [Azure Platform](https://azure.com) and if you have an active _Azure subscription_, it's recommended to follow the below guidelines after [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). If you're contributing code for any other platform (e.g., AWS, GCP, etc), you can skip these steps. > Once the Terratest maintainers add `Azure` tag and _Approve_ the PR, following pipeline will run automatically to perform a full validation of the Azure contribution. You also can run the pipeline manually on your forked repo by following the below guideline. We have a separate CI pipeline for _Azure_ code. To run it on a forked repo: 1. Run the following [Azure Cli](https://docs.microsoft.com/cli/azure/) command on your preferred Terminal to create Azure credentials and copy the output: ```bash az ad sp create-for-rbac --name "terratest-az-cli" --role contributor --sdk-auth ``` 1. Go to Secrets settings page under `Settings` tab in your forked project, `https://github.com//terratest/settings`, on GitHub. 1. Create a new `Secret` named `AZURE_CREDENTIALS` and paste the Azure credentials you copied from the 1st step as the value > `AZURE_CREDENTIALS` will be stored in _your_ GitHub account; neither the Terratest maintainers nor anyone else will have any access to it. Under the hood, GitHub stores your secrets in a secure, encrypted format (see: [GitHub Actions Secrets Reference](https://docs.github.com/en/free-pro-team@latest/actions/reference/encrypted-secrets) for more information). Once the secret is created, it's only possible to update or delete it; the value of the secret can't be viewed. GitHub uses a [libsodium sealed box](https://libsodium.gitbook.io/doc/public-key_cryptography/sealed_boxes) to help ensure that secrets are encrypted before they reach GitHub. 1. Create a [new Personal Access Token (PAT)](https://github.com/settings/tokens/new) page under [Settings](https://github.com/settings/profile) / [Developer Settings](https://github.com/settings/apps), making sure `write:discussion` and `public_repo` scopes are checked. Click the _Generate token_ button and copy the generated PAT. 1. Go back to settings/secrets in your fork and [Create a new Secret](https://docs.github.com/actions/reference/encrypted-secrets#creating-encrypted-secrets-for-a-repository) named `PAT`. Paste the output from the 4th step as the value > `PAT` will be stored in _your_ GitHub account; neither the Terratest maintainers nor anyone else will have any access to it. Under the hood, GitHub stores your secrets in a secure, encrypted format (see: [GitHub Actions Secrets Reference](https://docs.github.com/en/free-pro-team@latest/actions/reference/encrypted-secrets) for more information). Once the secret is created, it's only possible to update or delete it; the value of the secret can't be viewed. GitHub uses a [libsodium sealed box](https://libsodium.gitbook.io/doc/public-key_cryptography/sealed_boxes) to help ensure that secrets are encrypted before they reach GitHub. 1. Go to Actions tab on GitHub (https://github.com//terratest/actions) 1. Click `ci-workflow` workflow 1. Click `Run workflow` button and fill the fields in the drop down * _Repository Info_ : name of the forked repo (_e.g. xyz/terratest_) * _Name of the branch_ : branch name on the forked repo (_e.g. feature/adding-some-important-module_) * _Name of the official terratest repo_ : home of the target pr (_gruntwork-io/terratest_) * PR number on the official terratest repo : pr number on the official terratest repo (_e.g. 14, 25, etc._). Setting this value will leave a success/failure comment in the PR once CI completes execution. * Skip provider registration : set true if you want to skip terraform provider registration for debug purposes (_false_ or _true_) 1. Wait for the `ci-workflow` to be finished > The pipeline will use the given Azure subscription and deploy real resources in your Azure account as part of running the test. When the tests finish, they will tear down the resources they created. Of course, if there is a bug or glitch that prevents the clean up code from running, some resources may be left behind, but this is rare. Note that these resources may cost you money! You are responsible for all charges in your Azure subscription. 1. PR with the given _PR Number_ will have the result of the `ci-workflow` as a comment ### Merge and release The maintainers for this repo will review your code and provide feedback. Once the PR is accepted, they will merge the code and release a new version, which you'll be able to find in the [releases page](https://github.com/gruntwork-io/terratest/releases). ## Developing Terratest 1. [Running tests](#running-tests) 1. [Versioning](#versioning) 1. [Developing For Azure](#developing-for-azure) ### Running tests Terratest itself includes a number of automated tests. **Note #1**: Some of these tests create real resources in an AWS account. That means they cost money to run, especially if you don't clean up after yourself. Please be considerate of the resources you create and take extra care to clean everything up when you're done! **Note #2**: In order to run tests that access your AWS account, you will need to configure your [AWS CLI credentials](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html). For example, you could set the credentials as the environment variables `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`. **Note #3**: Never hit `CTRL + C` or cancel a build once tests are running or the cleanup tasks won't run! **Prerequisite**: The tests expect Terraform, Terragrunt, Packer, and/or Docker to already be installed and in your `PATH`. To run all the tests: ```bash go test -v -timeout 30m -p 1 ./... ``` To run the tests in a specific folder: ```bash cd "" go test -timeout 30m ``` To run a specific test in a specific folder: ```bash cd "" go test -timeout 30m -run "" ``` ### Versioning This repo follows the principles of [Semantic Versioning](http://semver.org/). You can find each new release, along with the changelog, in the [Releases Page](https://github.com/gruntwork-io/terratest/releases). During initial development, the major version will be 0 (e.g., `0.x.y`), which indicates the code does not yet have a stable API. Once we hit `1.0.0`, we will make every effort to maintain a backwards compatible API and use the MAJOR, MINOR, and PATCH versions on each release to indicate any incompatibilities. ### Developing For Azure Azure supports multliple cloud environments. In order to properly register the correct environment for you test code, you need to use the Azure SDK Client Factory. #### Azure SDK Client Factory This documentation provides and overview of the `client_factory.go` module, targeted use cases, and behaviors. This module is intended to provide support for and simplify working with Azure's multiple cloud environments (Azure Public, Azure Government, Azure China, Azure Germany and Azure Stack). Developers looking to contribute to additional support for Azure to Terratest should leverage client_factory and use the patterns below to add a resource REST client from Azure Go SDK. By doing so, it provides a consistent means for developers using Terratest to test their Azure Infrastructure to connect to the correct cloud and its associated REST apis. ##### Background The Azure REST APIs support both Public and sovereign cloud environments (at the moment this includes Public, US Government, Germany, China, and Azure Stack environments). If you are interacting with an environment other than public cloud, you need to set the base URI for the Azure REST API you are interacting with. ###### Base URI You must use the correct base URI's for the Azure REST API's (either directly or via Azure SDK for GO) to communicate with a cloud environment other than Azure Public. The Azure Go SDK supports this by using the `WithBaseURI` suffixed calls when creating service clients. For example, when using the `VirtualMachinesClient` with the public cloud, a developer would normally write code for the public cloud like so: ```go import ( "github.com/Azure/azure-sdk-for-go/services/compute/mgmt/2019-07-01/compute" ) func SomeVMHelperMethod() { subscriptionID := "your subscription ID" // Create a VM client and return vmClient, err := compute.NewVirtualMachinesClient(subscriptionID) // Use client / etc } ``` However, this code will not work in non-Public cloud environments as the REST endpoints have different URIs depending on environment. Instead, you need to use an alternative method (provided in the Azure REST SDK for Go) to get a properly configured client (*all REST API clients should support this alternate method*): ```go import ( "github.com/Azure/azure-sdk-for-go/services/compute/mgmt/2019-07-01/compute" ) func SomeVMHelperMethod() { subscriptionID := "your subscription ID" baseURI := "management.azure.com" // Create a VM client and return vmClient, err := compute.NewVirtualMachinesClientWithBaseURI(baseURI, subscriptionID) // Use client / etc } ``` Using code similar to above, you can communicate with any Azure cloud environment just by changing the base URI that is passed to the clients (Azure Public shown in above example). ##### Lookup Environment Metadata Developers MUST avoid hardcoding these base URI's. Instead, they should be looked up from an authoritative source. The AutoRest-GO library (used by the Go SDK) provides such functionality. The `client_factory` module makes use of the AutoRest `EnvironmentFromName(envName string)` function to return the appropriate structure. This method and Environment structure is documented on GoDoc [here](https://godoc.org/github.com/Azure/go-autorest/autorest/azure#EnvironmentFromName). To configure different cloud environments, we will use the same `AZURE_ENVIRONMENT` environment variable that the Go SDK uses. This can currently be set to one of the following values: |Value |Cloud Environment | |---------------------------|-------------------| |"AzureChinaCloud" |ChinaCloud | |"AzureGermanCloud" |GermanCloud | |"AzurePublicCloud" |PublicCloud | |"AzureUSGovernmentCloud" |USGovernmentCloud | |"AzureStackCloud" |Azure stack | When using the "AzureStackCloud" setting, you MUST also set the `AZURE_ENVIRONMENT_FILEPATH` variable to point to a JSON file containing your Azure Stack URI details. ##### Putting it all together `client_factory` implements this pattern described above in order to instantiate and return properly configured *REST SDK for GO* clients so that test implementers don't have to consider REST API client implementation as long as they have the correct `AZURE_ENVIRONMENT` env setting. If this environment variable is not set, the client will assume public cloud as the cloud environment to communicate with. We strongly recommend developers creating Terratest helper methods for Azure use this pattern with client factory to create REST API clients. This will reduce effort for Terratest users creating test for Azure resources. Note the following: * TERRAFORM uses [ARM_ENVIRONMENT](https://www.terraform.io/docs/backends/types/azurerm.html#environment) environment variable to set the correct cloud environment. * The default behavior of the `client_factory` is to use the AzurePublicCloud environment. This requires no work from the developer to configure, and ensures consistent behavior with the current SDK code. ###### Wait, I don't see the client in client factory for the rest api I want to interact with If you require a client that is not already implemented in client factory for your helper method, you will need to create a corresponding method that instantiates the client and accepts base URI following the patterns discussed. Below is a walkthrough for adding a client to client factory. ##### Walkthrough, adding a client to client_factory ###### Add your client namespace to client factory In the Azure SDK for GO, each service should have a module that implements that services client. You can find the correct module [here](https://godoc.org/github.com/Azure/azure-sdk-for-go). Add that module to the client factory imports. Below is an example for client imports that shows clients for compute, container service and subscriptions. {% include examples/explorer.html example_id='client-factory' file_id='client_factory_code' class='wide quick-start-examples' skip_learn_more=true skip_view_on_github=true skip_tags=true snippet_id='client_factory_example.imports' %} ###### Add your client method to instantiate the client The next step is to add your method to instantiate the client. Below is an example of adding the method to create a client for Virtual Machines, note that we lookup the environment using `getEnvironmentEndpointE` and then pass that base URI to the actual method on the Virtual Machines Module to create the client `NewVirtualMachinesClientWithBaseURI`. {% include examples/explorer.html example_id='client-factory' file_id='client_factory_code' class='wide quick-start-examples' skip_learn_more=true skip_view_on_github=true skip_tags=true snippet_id='client_factory_example.CreateClient' %} ###### Add a unit test to client_factory_test.go In order to ensure that your CreateClient method works properly, add a unit test to `client_factory_test.go`. The unit test MUST assert that the base URI is correctly set for your client. Some key points for writing your unit test are: - Use table-driven testing to test the various combinations of cloud environments - Give the test case a descriptive name so it is easy to identify which test failed. - PRs will be rejected if a client is added without a corresponding unit test. Below is an example of the Virtual Machines client unit test: {% include examples/explorer.html example_id='client-factory' file_id='client_factory_test' class='wide quick-start-examples' skip_learn_more=true skip_view_on_github=true skip_tags=true snippet_id='client_factory_example.UnitTest' %} ###### Use your CreateClient method in your helper We now can use this client creation method in our helpers to create a Virtual Machines client. Below is an example for how to call into this create method from `client_factory`: {% include examples/explorer.html example_id='client-factory' file_id='client_factory_helper' class='wide quick-start-examples' skip_learn_more=true skip_view_on_github=true skip_tags=true snippet_id='client_factory_example.helper' %} ================================================ FILE: docs/_docs/04_community/license.md ================================================ --- layout: collection-browser-doc title: License category: community excerpt: >- This code is released under the Apache 2.0 License. Read more here. tags: ["license"] order: 402 nav_title: Documentation nav_title_link: /docs/ --- ## License This code is released under the Apache 2.0 License. See [LICENSE](https://github.com/gruntwork-io/terratest/blob/main/LICENSE){:target="_blank"} and [NOTICE](https://github.com/gruntwork-io/terratest/blob/main/NOTICE){:target="_blank"} for more details. ================================================ FILE: docs/_docs/04_community/support.md ================================================ --- layout: collection-browser-doc title: Support category: community excerpt: >- Need help? tags: ["support", "community"] order: 401 nav_title: Documentation nav_title_link: /docs/ --- ## Github Discussions Search our [Knowledge Base](https://github.com/gruntwork-io/knowledge-base/discussions) to find existing questions or ask your own. Github Discussions is a good place for general discussions and questions. ## Github Issues Read through [existing issues](https://github.com/gruntwork-io/terratest/issues) or post a new one. Github issues is a good place to: - report a bug, - ask for a help, - ask for improvements, - to start contributing by solving simple issues. ## Commercial support Does your company rely on Terratest in production? If so, you can get commercial support directly from Gruntwork, the creators of Terratest! Check out the [Gruntwork Support Page](https://gruntwork.io/support) for more details. ================================================ FILE: docs/_includes/built-by.html ================================================

Built by Gruntwork

Your entire infrastructure. Defined as code. In about a day.

Explore Gruntwork.io
================================================ FILE: docs/_includes/canonical-url.html ================================================ {% if include.url %}{% assign url = include.url %}{% else %}{% assign url = page.url %}{% endif %}{{ site.url | replace:'www.','' }}{{site.baseurl}}{{ url | replace:'index.html','' }} ================================================ FILE: docs/_includes/collection_browser/_cta-section.html ================================================ {% include links-n-built-by.html %} ================================================ FILE: docs/_includes/collection_browser/_doc-header.html ================================================
{% if page.image %} post logo {% endif %}

{{ page.title }}

{% if page.tags %} {% for tag in page.tags %} {% capture tag-name %}{{tag | upcase }}{% endcapture %} {{tag-name | capitalize}} {% endfor %} {% endif %}
================================================ FILE: docs/_includes/collection_browser/_doc-page.html ================================================
{% include collection_browser/_doc-header.html %}
{% assign data_scroll_after_selector = '.cb-doc-header' %} {% assign data_scroll_until_selector = '.cb-doc-detail' %} {% include collection_browser/_sidebar.html data_scroll_until_selector=data_scroll_until_selector data_scroll_after_selector=data_scroll_after_selector %}
{{ content }}
================================================ FILE: docs/_includes/collection_browser/_doc-thumb.html ================================================ {% assign cb_doc_card_class = '' %} {% if doc.index_list !=nil and doc.index_list.no_hover_enlarge_effect == true %} {% assign cb_doc_card_class = 'no-hover-enlarge' %} {% endif %}
{% if doc.image %} {% if doc.index_list !=nil and doc.index_list.read_more_btn == true %} Read more {% endif %} {% unless doc.index_list !=nil and doc.index_list.disable_card_link == true %} {% endunless %}
card image
{% if doc.index_list !=nil and doc.index_list.disable_card_link == true %}
{{ doc.title }}
{% else %}
{{ doc.title }}
{% endif %} {% include collection_browser/_doc-thumb__excerpt.html doc=doc %}
{% unless doc.index_list !=nil and doc.index_list.disable_card_link == true %} {% endunless %} {% else %} {% if doc.index_list !=nil and doc.index_list.read_more_btn == true %} Read more {% endif %} {% unless doc.index_list !=nil and doc.index_list.disable_card_link == true %} {% endunless %}
{% if doc.index_list !=nil and doc.index_list.disable_card_link == true %}
{{ doc.title }}
{% else %}
{{ doc.title }}
{% endif %} {% include collection_browser/_doc-thumb__excerpt.html doc=doc %}
{% unless doc.index_list !=nil and doc.index_list.disable_card_link == true %} {% endunless %} {% endif %}
================================================ FILE: docs/_includes/collection_browser/_doc-thumb__excerpt.html ================================================ {% if doc.excerpt_md %} {{ doc.excerpt_md | markdownify }} {% elsif doc.excerpt_html %} {{ doc.excerpt_html }} {% else %}

{{ doc.excerpt | strip_html }}

{% endif %} ================================================ FILE: docs/_includes/collection_browser/_docs-list.html ================================================
{% for doc_group in include.docs_grouped %}

{{ doc_group.name | capitalize | replace: '-',' ' | replace: '%20', ' ' }}

{% assign sorted = doc_group.items | sort: 'order' %} {% for doc in sorted %} {% include collection_browser/_doc-thumb.html doc=doc %} {% endfor %} {% endfor %}
================================================ FILE: docs/_includes/collection_browser/_no-search-results.html ================================================
no search results

We couldn’t find any results matching your search.

================================================ FILE: docs/_includes/collection_browser/_search.html ================================================

Tags

{% for tag in tags %} {% assign tag_name = tag %}
{% endfor %}
================================================ FILE: docs/_includes/collection_browser/_sidebar.html ================================================
{% capture nav_title %}{{page.nav_title}}{% endcapture %} {% capture nav_title_link %}{{page.nav_title_link}}{% endcapture %} {% if nav_title != empty and nav_title_link != empty %} {% elsif nav_title != empty and nav_title_link == empty %} {% endif %} {% include collection_browser/navigation/_collection_toc.html collection_name='docs' %}
================================================ FILE: docs/_includes/collection_browser/browser.html ================================================ {% assign collection_name = include.collection_name %} {% assign nav_title = include.nav_title %} {% assign nav_title_link = include.nav_title_link %} {% assign categories = "" | split: "|" %} {% assign tags = "" | split: "|" %} {% for doc in include.collection %} {% for category in doc.categories %} {% assign categories = categories | push: category | uniq %} {% endfor %} {% for tag in doc.tags %} {% assign tags = tags | push: tag | uniq %} {% endfor %} {% endfor %} {% assign docs_grouped = include.collection | group_by: "category" %}
{% include collection_browser/_search.html tags=tags collection_name=include.collection_name %}
{% assign data_scroll_after_selector = '.cb-search-cmp' %} {% assign data_scroll_until_selector = '.cb-doc-listing' %} {% include collection_browser/_sidebar.html data_scroll_until_selector=data_scroll_until_selector data_scroll_after_selector=data_scroll_after_selector %} {% include collection_browser/_docs-list.html categories=categories collection_name=include.collection_name docs_grouped=docs_grouped %}
{% include collection_browser/_no-search-results.html %}
{% include collection_browser/_cta-section.html %} ================================================ FILE: docs/_includes/collection_browser/navigation/_collection_toc.html ================================================ {% capture tocWorkspace %} {% comment %} To build navigation: == A1: Group docs by categories == A2: [Loop] Go by categories... == A3: ADD CATEGORY LINK TO THE NAV (It's the uppermost level of links in navigation.) == A4: [Loop] Go by documents in the category... == B1: ADD DOC'S TITLE TO NAV == B2: [Loop] Go by headings in a doc... == B3: ADD HEADING TO THE NAV == A6: Final steps Add ID and classes to the navigation, and `markdonify` content. {% endcomment %} {% capture my_toc %}{% endcapture %} {% assign collection_name = include.collection_name | default: 'posts' %} {% comment %} ======================= A1: GROUP BY CATEGORIES ======================= {% endcomment %} {% assign collection_grouped = site[collection_name] | group_by: 'category' %} {% assign sort_by = include.sort_by | default: 'order' %} {% assign orderedList = include.ordered | default: false %} {% assign minHeader = include.h_min | default: 1 %} {% assign maxHeader = include.h_max | default: 6 %} {% assign firstHeader = true %} {% assign level = 0 %} {% assign docHeaderIndentAmount = 1 %} {% comment %} ======================= A2: CATEGORIES LOOP ======================= {% endcomment %} {% for category in collection_grouped %} {% capture col_id %}{{ collection_name | downcase | replace: ' ', '-' | replace: ':', ''}}{% endcapture %} {% capture cat_id %}{{ category.name | downcase | replace: ' ', '-' | replace: ':', ''}}{% endcapture %} {% comment %} ======================= A3: BUILD CATEGORY LINK ======================= The category link is defined as link to the heading on index page. {% endcomment %} {% capture category_link %}{{ site.baseurl }}/{{ collection_name }}#{{ category.name }}{% endcapture %} {% capture my_toc %}{{ my_toc }} - [{{ category.name | replace: '-',' ' | replace: '%20', ' ' | capitalize }}]({{ category_link }}){% endcapture %} {% comment %} ======================= A4: DOCS LOOP ======================= Loop over the docs within a category. {% endcomment %} {% assign items = category.items | sort: sort_by %} {% for doc in items %} {% capture n_cont %}{{ doc.content }}{% endcapture %} {% assign n_cont_md = n_cont | markdownify %} {% comment %} ======================= B1: ADD DOC TITLE TO NAV ======================= Add document's title to the navigation. {% endcomment %} {% capture doc_id %}{{ doc.title | downcase | downcase | replace: ' ', '-' | replace: ':', ''}}{% endcapture %} {% assign doc_url = doc.url %} {% if doc.redirect_to %} {% assign doc_url = doc.redirect_to.first %} {% endif %} {% capture doc_url_prefix %}{{ doc_url | slice: 0, 4 }}{% endcapture %} {% unless doc_url_prefix == "http" %} {% capture doc_url %}{{ site.baseurl }}{{ doc_url }}{% endcapture %} {% endunless %} {% capture target_blank %}{% endcapture %} {% if doc.target_blank %} {% capture target_blank %}{:target="_blank"}{% endcapture %} {% endif %} {% capture my_toc %}{{ my_toc }} - [{{ doc.title }}]({{ doc_url }}){{ target_blank }}{% endcapture %} {% comment %} =============== B2: Find headings in doc and add to nav ================ Goes heading by heading (h2, h3, h4, etc.), and adds it to the navigation. {% endcomment %} {% assign nodes = n_cont_md | split: ' maxHeader %} {% continue %} {% endif %} {% if firstHeader %} {% assign firstHeader = false %} {% assign minHeader = headerLevel %} {% endif %} {% assign indentAmount = headerLevel | minus: minHeader | plus: docHeaderIndentAmount %} {% assign _workspace = node | split: '' | first }}>{% endcapture %} {% assign header = _workspace[0] | replace: _hAttrToStrip, '' %} {% assign space = '' %} {% for i in (1..indentAmount) %} {% assign space = space | prepend: ' ' %} {% endfor %} {% unless include.item_class == blank %} {% capture listItemClass %}{{ include.item_class | replace: '%level%', headerLevel }}{% endcapture %} {% endunless %} {% capture heading_body %}{% if include.sanitize %}{{ header | strip_html }}{% else %}{{ header }}{% endif %}{% endcapture %} {% comment %} ======================= B3: ADD HEADING TO THE NAV ======================= Go heading-by-heading (h2, h3, h4, etc.), and add it to the navigation. {% endcomment %} {% capture my_toc %}{{ my_toc }} {{ space }}- [{{ heading_body | replace: "|", "\|" }}]({% if include.baseurl %}{{ include.baseurl }}{% endif %}{{ site.baseurl }}{{doc.url}}#{{ html_id }}){% if include.anchor_class %}{:.{{ include.anchor_class }}}{% endif %}{% endcapture %} {% endfor %} {% endfor %} {% endfor %} {% comment %} ======================= A6: FINAL STEPS ======================= {% endcomment %} {% if include.class %} {% capture my_toc %}{:.{{ include.class }}} {{ my_toc | lstrip }}{% endcapture %} {% endif %} {% if include.id %} {% capture my_toc %}{: #{{ include.id }}} {{ my_toc | lstrip }}{% endcapture %} {% endif %} {% endcapture %} {% assign tocWorkspace = '' %} {{ my_toc | markdownify | strip }} ================================================ FILE: docs/_includes/examples/example.html ================================================
{% for file in include.example.files %} {% if include.file_id == nil or include.file_id == file.id %} {% assign assign_class = '' %} {% if file.default or include.file_id == file.id %} {% assign assign_class = 'active' %} {% endif %} {% assign url_split = file.url | split: '/' %} {% assign file_name = url_split.last %} {% if file.name %} {% assign file_name = file.name %} {% endif %}
{% endif %} {% endfor %}
{% for file in include.example.files %} {% if include.file_id == nil or include.file_id == file.id %} {% assign assign_class = '' %} {% if file.default or include.file_id == file.id %} {% assign assign_class = 'active' %} {% endif %} {% assign url_split = file.url | split: '/' %} {% assign file_name = url_split.last %} {% if file.name %} {% assign file_name = file.name %} {% endif %} {% assign prism_lang = '' %} {% assign file_name_splitted = file.url | split: '.' %} {% if site.data.prism_extends[file_name_splitted.last] %} {% assign prism_lang = site.data.prism_extends[file_name_splitted.last] %} {% elsif file_name_splitted.last %} {% assign prism_lang = file_name_splitted.last %} {% endif %} {% if file.prism_lang %} {% assign prism_lang = file.prism_lang %} {% endif %}
Loading...
{% unless include.skip_view_on_github %} {% endunless %}
{% endif %} {% endfor %} {% if include.example.learn_more and include.skip_learn_more != true %}
Learn more:
{% endif %}
================================================ FILE: docs/_includes/examples/explorer.html ================================================
{% for example in site.data.examples %} {% if include.example_id == nil or include.example_id == example.id %} {% assign render_example = true %} {% if render_example %} {% include examples/example.html example=example file_id=include.file_id skip_learn_more=include.skip_learn_more skip_view_on_github=include.skip_view_on_github skip_tags=include.skip_tags snippet_id=include.snippet_id %} {% endif %} {% endif %} {% endfor %}
================================================ FILE: docs/_includes/favicon.html ================================================ {% assign base_favicon_url = site.assets_base_url | append: 'img/favicon' | prepend: site.baseurl %} ================================================ FILE: docs/_includes/footer.html ================================================ ================================================ FILE: docs/_includes/get-access.html ================================================
Get access to a library of over 300,000 lines of battle-tested, production grade infrastructure code, all thoroughly tested with Terratest. Explore Gruntwork.io
================================================ FILE: docs/_includes/head.html ================================================ {% if page.title %}{{ page.title }}{% else %}{{ site.name }}{% endif %} {% if page.noindex == true %} {% else %} {% endif %} {% include styles.html %} {% include favicon.html %} {% include share-meta.html %} ================================================ FILE: docs/_includes/header-min.html ================================================
{% include navbar.html %} Shape
================================================ FILE: docs/_includes/header.html ================================================
{% include navbar.html %}

{{ page.title }}

{{ page.subtitle }}
Shape Hero
================================================ FILE: docs/_includes/links-n-built-by.html ================================================
{% include built-by.html %} {% include links-section.html %}
================================================ FILE: docs/_includes/links-n-get-access.html ================================================ ================================================ FILE: docs/_includes/links-section.html ================================================ ================================================ FILE: docs/_includes/logo.html ================================================ ================================================ FILE: docs/_includes/navbar.html ================================================
================================================ FILE: docs/_includes/scripts.html ================================================ {% if page.custom_js %} {% for js_file in page.custom_js %} {% endfor %} {% elsif layout.custom_js %} {% for js_file in layout.custom_js %} {% endfor %} {% endif %} {% if site.gtm_tracker %} {% endif %} ================================================ FILE: docs/_includes/share-meta.html ================================================ {% capture title %}{% if page.title %}{{ page.title }}{% else %}{{ site.name }}{% endif %}{% endcapture %} {% capture description %}{% if page.excerpt %}{{ page.excerpt | strip_html | strip_newlines }}{% else %}{{ site.description }}{% endif %}{% endcapture %} {% capture img %}{% if page.image %}{{ page.image }}{% else %}{{ site.thumbnail_path }}{% endif %}{% endcapture %} ================================================ FILE: docs/_includes/styles.html ================================================ ================================================ FILE: docs/_includes/switch.html ================================================
================================================ FILE: docs/_includes/video-player.html ================================================
video video
================================================ FILE: docs/_layouts/collection-browser-doc.html ================================================ --- custom_js: - prism - collection-browser_scroll - collection-browser_search - collection-browser_toc - video-player --- {% include head.html %} {% include header-min.html %} {% include collection_browser/_doc-page.html content=content %} {% include links-n-built-by.html %} {% include footer.html %} {% include scripts.html %} ================================================ FILE: docs/_layouts/collection-browser.html ================================================ --- custom_js: - prism - collection-browser_scroll - collection-browser_search - collection-browser_toc - video-player --- {% include head.html %} {% include header.html %} {{ content }} {% include footer.html %} {% include scripts.html %} ================================================ FILE: docs/_layouts/contact.html ================================================ {% include head.html %}
{% include navbar.html %} Shape Shape
{{ content }}
{% include links-section.html %}
{% include footer.html %} {% include scripts.html %} ================================================ FILE: docs/_layouts/default.html ================================================ --- custom_js: - video-player - examples - prism --- {% include head.html %} {{ content }} {% include footer.html %} {% include scripts.html %} ================================================ FILE: docs/_layouts/post.html ================================================ --- layout: default custom_js: - prism - video-player ---
{{ content }}
================================================ FILE: docs/_layouts/subpage.html ================================================ {% include head.html %} {% include header.html %}
{{ content }}
{% include links-n-built-by.html %} {% include footer.html %} {% include scripts.html %} ================================================ FILE: docs/_pages/404/404.md ================================================ --- permalink: /404.html slug: 404 layout: subpage title: 404 subtitle: Page not found :( classes: text-large text-center subpage-404 --- The requested page could not be found. ================================================ FILE: docs/_pages/commercial-support/index.html ================================================ --- permalink: /commercial-support/ redirect_to: - https://gruntwork.io/support --- ================================================ FILE: docs/_pages/contact/_contact-form.html ================================================
contact-form-back
Which plans are you interested in?



================================================ FILE: docs/_pages/contact/index.html ================================================ --- layout: contact title: contact subtitle: Get help via email, chat, and phone/video from the team that created Terratest. excerpt: Get help via email, chat, and phone/video from the team that created Terratest. permalink: /contact/ slug: contact nav_title: Contact nav_title_link: /docs/ custom_js: - prism - examples - collection-browser_toc - contact-form use_recaptcha: true ---

Contact Us

Speak to a real human!

Use the form below or send an email to info@gruntwork.io.

contact-form-back
{% include_relative _contact-form.html %} Shape
================================================ FILE: docs/_pages/cookie-policy/index.md ================================================ --- layout: subpage permalink: /cookie-policy/ slug: cookie-policy redirect_to: - https://gruntwork.io/cookie-policy/ --- ================================================ FILE: docs/_pages/docs/index.html ================================================ --- layout: collection-browser title: Documentation subtitle: Learn how to work with Terratest. excerpt: Learn how to work with Terratest. permalink: /docs/ slug: docs nav_title: Documentation --- {% include collection_browser/browser.html collection=site.docs collection_name='docs' %} ================================================ FILE: docs/_pages/examples/index.html ================================================ --- layout: collection-browser title: Examples subtitle: The best way to learn how to use Terratest is through examples. excerpt: The best way to learn how to use Terratest is through examples. permalink: /examples/ slug: examples nav_title: Documentation nav_title_link: /docs/ custom_js: - prism - examples - collection-browser_scroll - collection-browser_toc ---
{% assign data_scroll_after_selector = '.main' %} {% assign data_scroll_until_selector = '#cb-doc-listings' %} {% include collection_browser/_sidebar.html data_scroll_until_selector=data_scroll_until_selector data_scroll_after_selector=data_scroll_after_selector %}
{% include examples/explorer.html class='wide' id='examples_page' test_for_display=true %}
================================================ FILE: docs/_pages/index/_built_by.html ================================================

Built by Gruntwork

Your entire infrastructure. Defined as code. In about a day.

Explore Gruntwork.io shapes
================================================ FILE: docs/_pages/index/_cta_section.html ================================================ ================================================ FILE: docs/_pages/index/_header.html ================================================
{% include navbar.html %}
Hero

Automated tests for your infrastructure code.

Terratest is a Go library that provides patterns and helper functions for testing infrastructure, with 1st-class support for Terraform, Packer, Docker, Kubernetes, AWS, GCP, and more. Let's Get Started
================================================ FILE: docs/_pages/index/_terratest-in-4-steps.html ================================================

Test infrastructure code with Terratest in 4 steps

Write test using Go
Create a file ending in _test.go and run tests with the go test command. E.g., go test my_test.go.
Use Terratest to deploy infrastructure
Use Terratest to execute your real IaC tools (e.g., Terraform, Packer, etc.) to deploy real infrastructure (e.g., servers) in a real environment (e.g., AWS).
Validate infrastructure
Use the tools built into Terratest to validate that the infrastructure works correctly in that environment by making HTTP requests, API calls, SSH connections, etc.
Undeploy
Undeploy everything at the end of the test.
================================================ FILE: docs/_pages/index/_test-with-terratest.html ================================================

Test with Terratest

{% include examples/explorer.html id='index_page' %}
================================================ FILE: docs/_pages/index/_watch.html ================================================
Watch: quote

How to test infrastructure code:
automated testing for Terraform, Kubernetes, Docker, Packer and more

The talk is a step-by-step, live-coding class on how to write automated tests for infrastructure code, including the code you write for use with tools such as Terraform, Kubernetes, Docker, and Packer. Topics covered include unit tests, integration tests, end-to-end tests, test parallelism, retries, error handling, static analysis, and more.

Check out the video and slides for the talk.
{% include video-player.html url='https://www.youtube.com/embed/xhHOW0EF5u8?autoplay=1' %}
================================================ FILE: docs/_pages/index/index.html ================================================ --- layout: default permalink: / slug: index-page ---
{% include_relative _header.html %} {% include_relative _terratest-in-4-steps.html %}
{% include_relative _test-with-terratest.html %} {% include_relative _cta_section.html %} {% include_relative _watch.html %}
{% include links-n-built-by.html %}
================================================ FILE: docs/_pages/thanks/index.html ================================================ --- layout: default title: Thank you. excerpt: Thank you for contacting us. A Grunt will be in touch soon! permalink: /thanks/ ---
{% include navbar.html %}

{{ page.title }}

{{ page.excerpt }}

Go to the Terratest home page

================================================ FILE: docs/_posts/.keep ================================================ ================================================ FILE: docs/assets/css/_variables.scss ================================================ $primary-color: #07a7fd; $primary-color-2: #068ee4; $gray-color-1: #f0f0f1; $gray-color-2: #dedede; $gray-color-3: #bbbdc0; $text-color: #1e252f; $inline-code-color-base: #0352c2; $inline-code-bg-color-base: $gray-color-1; $primary-gradient-start-color: #001191; $primary-gradient-stop-color: #06a3ff; $secondary-gradient-start-color: #4eb9fb; $secondary-gradient-stop-color: #2683f5; $font-size-base: 16px; $font-size-xxs: 11px; $font-size-xs: 13px; $font-size-sm: 14px; $font-size-lg: 20px; $font-size-h1: 35px; $font-size-h2: 28px; $box-shadow-sm: 0 2px 34px 0 rgba(0, 0, 0, 0.1); ================================================ FILE: docs/assets/css/bootstrap/scss/bootstrap.scss ================================================ /*! * Bootstrap v3.3.7 (http://getbootstrap.com) * Copyright 2011-2016 Twitter, Inc. * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) */ /*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */ html { font-family: sans-serif; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; } body { margin: 0; } article, aside, details, figcaption, figure, footer, header, hgroup, main, menu, nav, section, summary { display: block; } audio, canvas, progress, video { display: inline-block; vertical-align: baseline; } audio:not([controls]) { display: none; height: 0; } [hidden], template { display: none; } a { background-color: transparent; } a:active, a:hover { outline: 0; } abbr[title] { border-bottom: 1px dotted; } b, strong { font-weight: bold; } dfn { font-style: italic; } h1 { font-size: 2em; margin: 0.67em 0; } mark { background: #ff0; color: #000; } small { font-size: 80%; } sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } sup { top: -0.5em; } sub { bottom: -0.25em; } img { border: 0; } svg:not(:root) { overflow: hidden; } figure { margin: 1em 40px; } hr { box-sizing: content-box; height: 0; } pre { overflow: auto; } code, kbd, pre, samp { font-family: monospace, monospace; font-size: 1em; } button, input, optgroup, select, textarea { color: inherit; font: inherit; margin: 0; } button { overflow: visible; } button, select { text-transform: none; } button, html input[type="button"], input[type="reset"], input[type="submit"] { -webkit-appearance: button; cursor: pointer; } button[disabled], html input[disabled] { cursor: default; } button::-moz-focus-inner, input::-moz-focus-inner { border: 0; padding: 0; } input { line-height: normal; } input[type="checkbox"], input[type="radio"] { box-sizing: border-box; padding: 0; } input[type="number"]::-webkit-inner-spin-button, input[type="number"]::-webkit-outer-spin-button { height: auto; } input[type="search"] { -webkit-appearance: textfield; box-sizing: content-box; } input[type="search"]::-webkit-search-cancel-button, input[type="search"]::-webkit-search-decoration { -webkit-appearance: none; } fieldset { border: 1px solid #c0c0c0; margin: 0 2px; padding: 0.35em 0.625em 0.75em; } legend { border: 0; padding: 0; } textarea { overflow: auto; } optgroup { font-weight: bold; } table { border-collapse: collapse; border-spacing: 0; } td, th { padding: 0; } /*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */ @media print { *, *:before, *:after { background: transparent !important; color: #000 !important; box-shadow: none !important; text-shadow: none !important; } a, a:visited { text-decoration: underline; } a[href]:after { content: " (" attr(href) ")"; } abbr[title]:after { content: " (" attr(title) ")"; } a[href^="#"]:after, a[href^="javascript:"]:after { content: ""; } pre, blockquote { border: 1px solid #999; page-break-inside: avoid; } thead { display: table-header-group; } tr, img { page-break-inside: avoid; } img { max-width: 100% !important; } p, h2, h3 { orphans: 3; widows: 3; } h2, h3 { page-break-after: avoid; } .navbar { display: none; } .btn > .caret, .dropup > .btn > .caret { border-top-color: #000 !important; } .label { border: 1px solid #000; } .table { border-collapse: collapse !important; } .table td, .table th { background-color: #fff !important; } .table-bordered th, .table-bordered td { border: 1px solid #ddd !important; } } @font-face { font-family: 'Glyphicons Halflings'; src: url('../fonts/glyphicons-halflings-regular.eot'); src: url('../fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), url('../fonts/glyphicons-halflings-regular.woff2') format('woff2'), url('../fonts/glyphicons-halflings-regular.woff') format('woff'), url('../fonts/glyphicons-halflings-regular.ttf') format('truetype'), url('../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg'); } .glyphicon { position: relative; top: 1px; display: inline-block; font-family: 'Glyphicons Halflings'; font-style: normal; font-weight: normal; line-height: 1; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } .glyphicon-asterisk:before { content: "\002a"; } .glyphicon-plus:before { content: "\002b"; } .glyphicon-euro:before, .glyphicon-eur:before { content: "\20ac"; } .glyphicon-minus:before { content: "\2212"; } .glyphicon-cloud:before { content: "\2601"; } .glyphicon-envelope:before { content: "\2709"; } .glyphicon-pencil:before { content: "\270f"; } .glyphicon-glass:before { content: "\e001"; } .glyphicon-music:before { content: "\e002"; } .glyphicon-search:before { content: "\e003"; } .glyphicon-heart:before { content: "\e005"; } .glyphicon-star:before { content: "\e006"; } .glyphicon-star-empty:before { content: "\e007"; } .glyphicon-user:before { content: "\e008"; } .glyphicon-film:before { content: "\e009"; } .glyphicon-th-large:before { content: "\e010"; } .glyphicon-th:before { content: "\e011"; } .glyphicon-th-list:before { content: "\e012"; } .glyphicon-ok:before { content: "\e013"; } .glyphicon-remove:before { content: "\e014"; } .glyphicon-zoom-in:before { content: "\e015"; } .glyphicon-zoom-out:before { content: "\e016"; } .glyphicon-off:before { content: "\e017"; } .glyphicon-signal:before { content: "\e018"; } .glyphicon-cog:before { content: "\e019"; } .glyphicon-trash:before { content: "\e020"; } .glyphicon-home:before { content: "\e021"; } .glyphicon-file:before { content: "\e022"; } .glyphicon-time:before { content: "\e023"; } .glyphicon-road:before { content: "\e024"; } .glyphicon-download-alt:before { content: "\e025"; } .glyphicon-download:before { content: "\e026"; } .glyphicon-upload:before { content: "\e027"; } .glyphicon-inbox:before { content: "\e028"; } .glyphicon-play-circle:before { content: "\e029"; } .glyphicon-repeat:before { content: "\e030"; } .glyphicon-refresh:before { content: "\e031"; } .glyphicon-list-alt:before { content: "\e032"; } .glyphicon-lock:before { content: "\e033"; } .glyphicon-flag:before { content: "\e034"; } .glyphicon-headphones:before { content: "\e035"; } .glyphicon-volume-off:before { content: "\e036"; } .glyphicon-volume-down:before { content: "\e037"; } .glyphicon-volume-up:before { content: "\e038"; } .glyphicon-qrcode:before { content: "\e039"; } .glyphicon-barcode:before { content: "\e040"; } .glyphicon-tag:before { content: "\e041"; } .glyphicon-tags:before { content: "\e042"; } .glyphicon-book:before { content: "\e043"; } .glyphicon-bookmark:before { content: "\e044"; } .glyphicon-print:before { content: "\e045"; } .glyphicon-camera:before { content: "\e046"; } .glyphicon-font:before { content: "\e047"; } .glyphicon-bold:before { content: "\e048"; } .glyphicon-italic:before { content: "\e049"; } .glyphicon-text-height:before { content: "\e050"; } .glyphicon-text-width:before { content: "\e051"; } .glyphicon-align-left:before { content: "\e052"; } .glyphicon-align-center:before { content: "\e053"; } .glyphicon-align-right:before { content: "\e054"; } .glyphicon-align-justify:before { content: "\e055"; } .glyphicon-list:before { content: "\e056"; } .glyphicon-indent-left:before { content: "\e057"; } .glyphicon-indent-right:before { content: "\e058"; } .glyphicon-facetime-video:before { content: "\e059"; } .glyphicon-picture:before { content: "\e060"; } .glyphicon-map-marker:before { content: "\e062"; } .glyphicon-adjust:before { content: "\e063"; } .glyphicon-tint:before { content: "\e064"; } .glyphicon-edit:before { content: "\e065"; } .glyphicon-share:before { content: "\e066"; } .glyphicon-check:before { content: "\e067"; } .glyphicon-move:before { content: "\e068"; } .glyphicon-step-backward:before { content: "\e069"; } .glyphicon-fast-backward:before { content: "\e070"; } .glyphicon-backward:before { content: "\e071"; } .glyphicon-play:before { content: "\e072"; } .glyphicon-pause:before { content: "\e073"; } .glyphicon-stop:before { content: "\e074"; } .glyphicon-forward:before { content: "\e075"; } .glyphicon-fast-forward:before { content: "\e076"; } .glyphicon-step-forward:before { content: "\e077"; } .glyphicon-eject:before { content: "\e078"; } .glyphicon-chevron-left:before { content: "\e079"; } .glyphicon-chevron-right:before { content: "\e080"; } .glyphicon-plus-sign:before { content: "\e081"; } .glyphicon-minus-sign:before { content: "\e082"; } .glyphicon-remove-sign:before { content: "\e083"; } .glyphicon-ok-sign:before { content: "\e084"; } .glyphicon-question-sign:before { content: "\e085"; } .glyphicon-info-sign:before { content: "\e086"; } .glyphicon-screenshot:before { content: "\e087"; } .glyphicon-remove-circle:before { content: "\e088"; } .glyphicon-ok-circle:before { content: "\e089"; } .glyphicon-ban-circle:before { content: "\e090"; } .glyphicon-arrow-left:before { content: "\e091"; } .glyphicon-arrow-right:before { content: "\e092"; } .glyphicon-arrow-up:before { content: "\e093"; } .glyphicon-arrow-down:before { content: "\e094"; } .glyphicon-share-alt:before { content: "\e095"; } .glyphicon-resize-full:before { content: "\e096"; } .glyphicon-resize-small:before { content: "\e097"; } .glyphicon-exclamation-sign:before { content: "\e101"; } .glyphicon-gift:before { content: "\e102"; } .glyphicon-leaf:before { content: "\e103"; } .glyphicon-fire:before { content: "\e104"; } .glyphicon-eye-open:before { content: "\e105"; } .glyphicon-eye-close:before { content: "\e106"; } .glyphicon-warning-sign:before { content: "\e107"; } .glyphicon-plane:before { content: "\e108"; } .glyphicon-calendar:before { content: "\e109"; } .glyphicon-random:before { content: "\e110"; } .glyphicon-comment:before { content: "\e111"; } .glyphicon-magnet:before { content: "\e112"; } .glyphicon-chevron-up:before { content: "\e113"; } .glyphicon-chevron-down:before { content: "\e114"; } .glyphicon-retweet:before { content: "\e115"; } .glyphicon-shopping-cart:before { content: "\e116"; } .glyphicon-folder-close:before { content: "\e117"; } .glyphicon-folder-open:before { content: "\e118"; } .glyphicon-resize-vertical:before { content: "\e119"; } .glyphicon-resize-horizontal:before { content: "\e120"; } .glyphicon-hdd:before { content: "\e121"; } .glyphicon-bullhorn:before { content: "\e122"; } .glyphicon-bell:before { content: "\e123"; } .glyphicon-certificate:before { content: "\e124"; } .glyphicon-thumbs-up:before { content: "\e125"; } .glyphicon-thumbs-down:before { content: "\e126"; } .glyphicon-hand-right:before { content: "\e127"; } .glyphicon-hand-left:before { content: "\e128"; } .glyphicon-hand-up:before { content: "\e129"; } .glyphicon-hand-down:before { content: "\e130"; } .glyphicon-circle-arrow-right:before { content: "\e131"; } .glyphicon-circle-arrow-left:before { content: "\e132"; } .glyphicon-circle-arrow-up:before { content: "\e133"; } .glyphicon-circle-arrow-down:before { content: "\e134"; } .glyphicon-globe:before { content: "\e135"; } .glyphicon-wrench:before { content: "\e136"; } .glyphicon-tasks:before { content: "\e137"; } .glyphicon-filter:before { content: "\e138"; } .glyphicon-briefcase:before { content: "\e139"; } .glyphicon-fullscreen:before { content: "\e140"; } .glyphicon-dashboard:before { content: "\e141"; } .glyphicon-paperclip:before { content: "\e142"; } .glyphicon-heart-empty:before { content: "\e143"; } .glyphicon-link:before { content: "\e144"; } .glyphicon-phone:before { content: "\e145"; } .glyphicon-pushpin:before { content: "\e146"; } .glyphicon-usd:before { content: "\e148"; } .glyphicon-gbp:before { content: "\e149"; } .glyphicon-sort:before { content: "\e150"; } .glyphicon-sort-by-alphabet:before { content: "\e151"; } .glyphicon-sort-by-alphabet-alt:before { content: "\e152"; } .glyphicon-sort-by-order:before { content: "\e153"; } .glyphicon-sort-by-order-alt:before { content: "\e154"; } .glyphicon-sort-by-attributes:before { content: "\e155"; } .glyphicon-sort-by-attributes-alt:before { content: "\e156"; } .glyphicon-unchecked:before { content: "\e157"; } .glyphicon-expand:before { content: "\e158"; } .glyphicon-collapse-down:before { content: "\e159"; } .glyphicon-collapse-up:before { content: "\e160"; } .glyphicon-log-in:before { content: "\e161"; } .glyphicon-flash:before { content: "\e162"; } .glyphicon-log-out:before { content: "\e163"; } .glyphicon-new-window:before { content: "\e164"; } .glyphicon-record:before { content: "\e165"; } .glyphicon-save:before { content: "\e166"; } .glyphicon-open:before { content: "\e167"; } .glyphicon-saved:before { content: "\e168"; } .glyphicon-import:before { content: "\e169"; } .glyphicon-export:before { content: "\e170"; } .glyphicon-send:before { content: "\e171"; } .glyphicon-floppy-disk:before { content: "\e172"; } .glyphicon-floppy-saved:before { content: "\e173"; } .glyphicon-floppy-remove:before { content: "\e174"; } .glyphicon-floppy-save:before { content: "\e175"; } .glyphicon-floppy-open:before { content: "\e176"; } .glyphicon-credit-card:before { content: "\e177"; } .glyphicon-transfer:before { content: "\e178"; } .glyphicon-cutlery:before { content: "\e179"; } .glyphicon-header:before { content: "\e180"; } .glyphicon-compressed:before { content: "\e181"; } .glyphicon-earphone:before { content: "\e182"; } .glyphicon-phone-alt:before { content: "\e183"; } .glyphicon-tower:before { content: "\e184"; } .glyphicon-stats:before { content: "\e185"; } .glyphicon-sd-video:before { content: "\e186"; } .glyphicon-hd-video:before { content: "\e187"; } .glyphicon-subtitles:before { content: "\e188"; } .glyphicon-sound-stereo:before { content: "\e189"; } .glyphicon-sound-dolby:before { content: "\e190"; } .glyphicon-sound-5-1:before { content: "\e191"; } .glyphicon-sound-6-1:before { content: "\e192"; } .glyphicon-sound-7-1:before { content: "\e193"; } .glyphicon-copyright-mark:before { content: "\e194"; } .glyphicon-registration-mark:before { content: "\e195"; } .glyphicon-cloud-download:before { content: "\e197"; } .glyphicon-cloud-upload:before { content: "\e198"; } .glyphicon-tree-conifer:before { content: "\e199"; } .glyphicon-tree-deciduous:before { content: "\e200"; } .glyphicon-cd:before { content: "\e201"; } .glyphicon-save-file:before { content: "\e202"; } .glyphicon-open-file:before { content: "\e203"; } .glyphicon-level-up:before { content: "\e204"; } .glyphicon-copy:before { content: "\e205"; } .glyphicon-paste:before { content: "\e206"; } .glyphicon-alert:before { content: "\e209"; } .glyphicon-equalizer:before { content: "\e210"; } .glyphicon-king:before { content: "\e211"; } .glyphicon-queen:before { content: "\e212"; } .glyphicon-pawn:before { content: "\e213"; } .glyphicon-bishop:before { content: "\e214"; } .glyphicon-knight:before { content: "\e215"; } .glyphicon-baby-formula:before { content: "\e216"; } .glyphicon-tent:before { content: "\26fa"; } .glyphicon-blackboard:before { content: "\e218"; } .glyphicon-bed:before { content: "\e219"; } .glyphicon-apple:before { content: "\f8ff"; } .glyphicon-erase:before { content: "\e221"; } .glyphicon-hourglass:before { content: "\231b"; } .glyphicon-lamp:before { content: "\e223"; } .glyphicon-duplicate:before { content: "\e224"; } .glyphicon-piggy-bank:before { content: "\e225"; } .glyphicon-scissors:before { content: "\e226"; } .glyphicon-bitcoin:before { content: "\e227"; } .glyphicon-btc:before { content: "\e227"; } .glyphicon-xbt:before { content: "\e227"; } .glyphicon-yen:before { content: "\00a5"; } .glyphicon-jpy:before { content: "\00a5"; } .glyphicon-ruble:before { content: "\20bd"; } .glyphicon-rub:before { content: "\20bd"; } .glyphicon-scale:before { content: "\e230"; } .glyphicon-ice-lolly:before { content: "\e231"; } .glyphicon-ice-lolly-tasted:before { content: "\e232"; } .glyphicon-education:before { content: "\e233"; } .glyphicon-option-horizontal:before { content: "\e234"; } .glyphicon-option-vertical:before { content: "\e235"; } .glyphicon-menu-hamburger:before { content: "\e236"; } .glyphicon-modal-window:before { content: "\e237"; } .glyphicon-oil:before { content: "\e238"; } .glyphicon-grain:before { content: "\e239"; } .glyphicon-sunglasses:before { content: "\e240"; } .glyphicon-text-size:before { content: "\e241"; } .glyphicon-text-color:before { content: "\e242"; } .glyphicon-text-background:before { content: "\e243"; } .glyphicon-object-align-top:before { content: "\e244"; } .glyphicon-object-align-bottom:before { content: "\e245"; } .glyphicon-object-align-horizontal:before { content: "\e246"; } .glyphicon-object-align-left:before { content: "\e247"; } .glyphicon-object-align-vertical:before { content: "\e248"; } .glyphicon-object-align-right:before { content: "\e249"; } .glyphicon-triangle-right:before { content: "\e250"; } .glyphicon-triangle-left:before { content: "\e251"; } .glyphicon-triangle-bottom:before { content: "\e252"; } .glyphicon-triangle-top:before { content: "\e253"; } .glyphicon-console:before { content: "\e254"; } .glyphicon-superscript:before { content: "\e255"; } .glyphicon-subscript:before { content: "\e256"; } .glyphicon-menu-left:before { content: "\e257"; } .glyphicon-menu-right:before { content: "\e258"; } .glyphicon-menu-down:before { content: "\e259"; } .glyphicon-menu-up:before { content: "\e260"; } * { -webkit-box-sizing: border-box; -moz-box-sizing: border-box; box-sizing: border-box; } *:before, *:after { -webkit-box-sizing: border-box; -moz-box-sizing: border-box; box-sizing: border-box; } html { font-size: 10px; -webkit-tap-highlight-color: rgba(0, 0, 0, 0); } body { font-family: 'Source Sans Pro', sans-serif; font-size: 16px; line-height: 1.7; color: #1e252f; background-color: #1d252f; } input, button, select, textarea { font-family: inherit; font-size: inherit; line-height: inherit; } a { color: #2d7ef4; text-decoration: none; } a:hover, a:focus { color: #0b58ca; text-decoration: underline; } a:focus { outline: 5px auto -webkit-focus-ring-color; outline-offset: -2px; } figure { margin: 0; } img { vertical-align: middle; } .img-responsive, .thumbnail > img, .thumbnail a > img, .carousel-inner > .item > img, .carousel-inner > .item > a > img { display: block; max-width: 100%; height: auto; } .img-rounded { border-radius: 0; } .img-thumbnail { padding: 4px; line-height: 1.7; background-color: #1d252f; border: 1px solid #dddddd; border-radius: 0; -webkit-transition: all 0.2s ease-in-out; -o-transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out; display: inline-block; max-width: 100%; height: auto; } .img-circle { border-radius: 50%; } hr { margin-top: 27px; margin-bottom: 27px; border: 0; border-top: 1px solid #194c5f; } .sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); border: 0; } .sr-only-focusable:active, .sr-only-focusable:focus { position: static; width: auto; height: auto; margin: 0; overflow: visible; clip: auto; } [role="button"] { cursor: pointer; } h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 { font-family: inherit; font-weight: 400; line-height: 1.1; color: #1e252f; } h1 small, h2 small, h3 small, h4 small, h5 small, h6 small, .h1 small, .h2 small, .h3 small, .h4 small, .h5 small, .h6 small, h1 .small, h2 .small, h3 .small, h4 .small, h5 .small, h6 .small, .h1 .small, .h2 .small, .h3 .small, .h4 .small, .h5 .small, .h6 .small { font-weight: normal; line-height: 1; color: #757575; } h1, .h1, h2, .h2, h3, .h3 { margin-top: 27px; margin-bottom: 13.5px; } h1 small, .h1 small, h2 small, .h2 small, h3 small, .h3 small, h1 .small, .h1 .small, h2 .small, .h2 .small, h3 .small, .h3 .small { font-size: 65%; } h4, .h4, h5, .h5, h6, .h6 { margin-top: 13.5px; margin-bottom: 13.5px; } h4 small, .h4 small, h5 small, .h5 small, h6 small, .h6 small, h4 .small, .h4 .small, h5 .small, .h5 .small, h6 .small, .h6 .small { font-size: 75%; } h1, .h1 { font-size: 57px; } h2, .h2 { font-size: 51px; } h3, .h3 { font-size: 45px; } h4, .h4 { font-size: 39px; } h5, .h5 { font-size: 29px; } h6, .h6 { font-size: 20px; } p { margin: 0 0 13.5px; } .lead { margin-bottom: 27px; font-size: 18px; font-weight: 300; line-height: 1.4; } @media (min-width: 768px) { .lead { font-size: 24px; } } small, .small { font-size: 87%; } mark, .mark { background-color: #fff4cc; padding: .2em; } .text-left { text-align: left; } .text-right { text-align: right; } .text-center { text-align: center; } .text-justify { text-align: justify; } .text-nowrap { white-space: nowrap; } .text-lowercase { text-transform: lowercase; } .text-uppercase { text-transform: uppercase; } .text-capitalize { text-transform: capitalize; } .text-muted { color: rgba(30, 37, 47, 0.6); } .text-primary { color: #2d7ef4; } a.text-primary:hover, a.text-primary:focus { color: #0c63e2; } .text-success { color: #24b47e; } a.text-success:hover, a.text-success:focus { color: #1c8a60; } .text-info { color: #06a2ff; } a.text-info:hover, a.text-info:focus { color: #0084d2; } .text-warning { color: #de8e27; } a.text-warning:hover, a.text-warning:focus { color: #b6721c; } .text-danger { color: #ff2b67; } a.text-danger:hover, a.text-danger:focus { color: #f70046; } .bg-primary { color: #fff; background-color: #2d7ef4; } a.bg-primary:hover, a.bg-primary:focus { background-color: #0c63e2; } .bg-success { background-color: #e3f1e4; } a.bg-success:hover, a.bg-success:focus { background-color: #c1e0c3; } .bg-info { background-color: #e5f3fa; } a.bg-info:hover, a.bg-info:focus { background-color: #badff2; } .bg-warning { background-color: #fff4cc; } a.bg-warning:hover, a.bg-warning:focus { background-color: #ffe999; } .bg-danger { background-color: #f9e9e9; } a.bg-danger:hover, a.bg-danger:focus { background-color: #eec1c1; } .page-header { padding-bottom: 12.5px; margin: 54px 0 27px; border-bottom: 1px solid #194c5f; } ul, ol { margin-top: 0; margin-bottom: 13.5px; } ul ul, ol ul, ul ol, ol ol { margin-bottom: 0; } .list-unstyled { padding-left: 0; list-style: none; } .list-inline { padding-left: 0; list-style: none; margin-left: -5px; } .list-inline > li { display: inline-block; padding-left: 5px; padding-right: 5px; } dl { margin-top: 0; margin-bottom: 27px; } dt, dd { line-height: 1.7; } dt { font-weight: bold; } dd { margin-left: 0; } @media (min-width: 992px) { .dl-horizontal dt { float: left; width: 160px; clear: left; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .dl-horizontal dd { margin-left: 180px; } } abbr[title], abbr[data-original-title] { cursor: help; border-bottom: 1px dotted #e6e6e6; } .initialism { font-size: 90%; text-transform: uppercase; } blockquote { padding: 13.5px 27px; margin: 0 0 27px; font-size: 20px; border-left: 5px solid rgba(255, 255, 255, 0.14); } blockquote p:last-child, blockquote ul:last-child, blockquote ol:last-child { margin-bottom: 0; } blockquote footer, blockquote small, blockquote .small { display: block; font-size: 80%; line-height: 1.7; color: rgba(30, 37, 47, 0.6); } blockquote footer:before, blockquote small:before, blockquote .small:before { content: '\2014 \00A0'; } .blockquote-reverse, blockquote.pull-right { padding-right: 15px; padding-left: 0; border-right: 5px solid rgba(255, 255, 255, 0.14); border-left: 0; text-align: right; } .blockquote-reverse footer:before, blockquote.pull-right footer:before, .blockquote-reverse small:before, blockquote.pull-right small:before, .blockquote-reverse .small:before, blockquote.pull-right .small:before { content: ''; } .blockquote-reverse footer:after, blockquote.pull-right footer:after, .blockquote-reverse small:after, blockquote.pull-right small:after, .blockquote-reverse .small:after, blockquote.pull-right .small:after { content: '\00A0 \2014'; } address { margin-bottom: 27px; font-style: normal; line-height: 1.7; } code, kbd, pre, samp { font-family: Menlo, Monaco, Consolas, "Courier New", monospace; } code { padding: 2px 4px; font-size: 90%; color: #c7254e; background-color: #f9f2f4; border-radius: 0; } kbd { padding: 2px 4px; font-size: 90%; color: #ffffff; background-color: #333333; border-radius: 0; box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.25); } kbd kbd { padding: 0; font-size: 100%; font-weight: bold; box-shadow: none; } pre { display: block; padding: 13px; margin: 0 0 13.5px; font-size: 15px; line-height: 1.7; word-break: break-all; word-wrap: break-word; color: #404040; background-color: #f5f5f5; border: 1px solid #cccccc; border-radius: 0; } pre code { padding: 0; font-size: inherit; color: inherit; white-space: pre-wrap; background-color: transparent; border-radius: 0; } .pre-scrollable { max-height: 340px; overflow-y: scroll; } .container { margin-right: auto; margin-left: auto; padding-left: 15px; padding-right: 15px; } @media (min-width: 768px) { .container { width: 750px; } } @media (min-width: 992px) { .container { width: 970px; } } @media (min-width: 992px) { .container { width: 970px; } } .container-fluid { margin-right: auto; margin-left: auto; padding-left: 15px; padding-right: 15px; } .row { margin-left: -15px; margin-right: -15px; } .col-xs-1, .col-sm-1, .col-md-1, .col-lg-1, .col-xs-2, .col-sm-2, .col-md-2, .col-lg-2, .col-xs-3, .col-sm-3, .col-md-3, .col-lg-3, .col-xs-4, .col-sm-4, .col-md-4, .col-lg-4, .col-xs-5, .col-sm-5, .col-md-5, .col-lg-5, .col-xs-6, .col-sm-6, .col-md-6, .col-lg-6, .col-xs-7, .col-sm-7, .col-md-7, .col-lg-7, .col-xs-8, .col-sm-8, .col-md-8, .col-lg-8, .col-xs-9, .col-sm-9, .col-md-9, .col-lg-9, .col-xs-10, .col-sm-10, .col-md-10, .col-lg-10, .col-xs-11, .col-sm-11, .col-md-11, .col-lg-11, .col-xs-12, .col-sm-12, .col-md-12, .col-lg-12 { position: relative; min-height: 1px; padding-left: 15px; padding-right: 15px; } .col-xs-1, .col-xs-2, .col-xs-3, .col-xs-4, .col-xs-5, .col-xs-6, .col-xs-7, .col-xs-8, .col-xs-9, .col-xs-10, .col-xs-11, .col-xs-12 { float: left; } .col-xs-12 { width: 100%; } .col-xs-11 { width: 91.66666667%; } .col-xs-10 { width: 83.33333333%; } .col-xs-9 { width: 75%; } .col-xs-8 { width: 66.66666667%; } .col-xs-7 { width: 58.33333333%; } .col-xs-6 { width: 50%; } .col-xs-5 { width: 41.66666667%; } .col-xs-4 { width: 33.33333333%; } .col-xs-3 { width: 25%; } .col-xs-2 { width: 16.66666667%; } .col-xs-1 { width: 8.33333333%; } .col-xs-pull-12 { right: 100%; } .col-xs-pull-11 { right: 91.66666667%; } .col-xs-pull-10 { right: 83.33333333%; } .col-xs-pull-9 { right: 75%; } .col-xs-pull-8 { right: 66.66666667%; } .col-xs-pull-7 { right: 58.33333333%; } .col-xs-pull-6 { right: 50%; } .col-xs-pull-5 { right: 41.66666667%; } .col-xs-pull-4 { right: 33.33333333%; } .col-xs-pull-3 { right: 25%; } .col-xs-pull-2 { right: 16.66666667%; } .col-xs-pull-1 { right: 8.33333333%; } .col-xs-pull-0 { right: auto; } .col-xs-push-12 { left: 100%; } .col-xs-push-11 { left: 91.66666667%; } .col-xs-push-10 { left: 83.33333333%; } .col-xs-push-9 { left: 75%; } .col-xs-push-8 { left: 66.66666667%; } .col-xs-push-7 { left: 58.33333333%; } .col-xs-push-6 { left: 50%; } .col-xs-push-5 { left: 41.66666667%; } .col-xs-push-4 { left: 33.33333333%; } .col-xs-push-3 { left: 25%; } .col-xs-push-2 { left: 16.66666667%; } .col-xs-push-1 { left: 8.33333333%; } .col-xs-push-0 { left: auto; } .col-xs-offset-12 { margin-left: 100%; } .col-xs-offset-11 { margin-left: 91.66666667%; } .col-xs-offset-10 { margin-left: 83.33333333%; } .col-xs-offset-9 { margin-left: 75%; } .col-xs-offset-8 { margin-left: 66.66666667%; } .col-xs-offset-7 { margin-left: 58.33333333%; } .col-xs-offset-6 { margin-left: 50%; } .col-xs-offset-5 { margin-left: 41.66666667%; } .col-xs-offset-4 { margin-left: 33.33333333%; } .col-xs-offset-3 { margin-left: 25%; } .col-xs-offset-2 { margin-left: 16.66666667%; } .col-xs-offset-1 { margin-left: 8.33333333%; } .col-xs-offset-0 { margin-left: 0%; } @media (min-width: 768px) { .col-sm-1, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-10, .col-sm-11, .col-sm-12 { float: left; } .col-sm-12 { width: 100%; } .col-sm-11 { width: 91.66666667%; } .col-sm-10 { width: 83.33333333%; } .col-sm-9 { width: 75%; } .col-sm-8 { width: 66.66666667%; } .col-sm-7 { width: 58.33333333%; } .col-sm-6 { width: 50%; } .col-sm-5 { width: 41.66666667%; } .col-sm-4 { width: 33.33333333%; } .col-sm-3 { width: 25%; } .col-sm-2 { width: 16.66666667%; } .col-sm-1 { width: 8.33333333%; } .col-sm-pull-12 { right: 100%; } .col-sm-pull-11 { right: 91.66666667%; } .col-sm-pull-10 { right: 83.33333333%; } .col-sm-pull-9 { right: 75%; } .col-sm-pull-8 { right: 66.66666667%; } .col-sm-pull-7 { right: 58.33333333%; } .col-sm-pull-6 { right: 50%; } .col-sm-pull-5 { right: 41.66666667%; } .col-sm-pull-4 { right: 33.33333333%; } .col-sm-pull-3 { right: 25%; } .col-sm-pull-2 { right: 16.66666667%; } .col-sm-pull-1 { right: 8.33333333%; } .col-sm-pull-0 { right: auto; } .col-sm-push-12 { left: 100%; } .col-sm-push-11 { left: 91.66666667%; } .col-sm-push-10 { left: 83.33333333%; } .col-sm-push-9 { left: 75%; } .col-sm-push-8 { left: 66.66666667%; } .col-sm-push-7 { left: 58.33333333%; } .col-sm-push-6 { left: 50%; } .col-sm-push-5 { left: 41.66666667%; } .col-sm-push-4 { left: 33.33333333%; } .col-sm-push-3 { left: 25%; } .col-sm-push-2 { left: 16.66666667%; } .col-sm-push-1 { left: 8.33333333%; } .col-sm-push-0 { left: auto; } .col-sm-offset-12 { margin-left: 100%; } .col-sm-offset-11 { margin-left: 91.66666667%; } .col-sm-offset-10 { margin-left: 83.33333333%; } .col-sm-offset-9 { margin-left: 75%; } .col-sm-offset-8 { margin-left: 66.66666667%; } .col-sm-offset-7 { margin-left: 58.33333333%; } .col-sm-offset-6 { margin-left: 50%; } .col-sm-offset-5 { margin-left: 41.66666667%; } .col-sm-offset-4 { margin-left: 33.33333333%; } .col-sm-offset-3 { margin-left: 25%; } .col-sm-offset-2 { margin-left: 16.66666667%; } .col-sm-offset-1 { margin-left: 8.33333333%; } .col-sm-offset-0 { margin-left: 0%; } } @media (min-width: 992px) { .col-md-1, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11, .col-md-12 { float: left; } .col-md-12 { width: 100%; } .col-md-11 { width: 91.66666667%; } .col-md-10 { width: 83.33333333%; } .col-md-9 { width: 75%; } .col-md-8 { width: 66.66666667%; } .col-md-7 { width: 58.33333333%; } .col-md-6 { width: 50%; } .col-md-5 { width: 41.66666667%; } .col-md-4 { width: 33.33333333%; } .col-md-3 { width: 25%; } .col-md-2 { width: 16.66666667%; } .col-md-1 { width: 8.33333333%; } .col-md-pull-12 { right: 100%; } .col-md-pull-11 { right: 91.66666667%; } .col-md-pull-10 { right: 83.33333333%; } .col-md-pull-9 { right: 75%; } .col-md-pull-8 { right: 66.66666667%; } .col-md-pull-7 { right: 58.33333333%; } .col-md-pull-6 { right: 50%; } .col-md-pull-5 { right: 41.66666667%; } .col-md-pull-4 { right: 33.33333333%; } .col-md-pull-3 { right: 25%; } .col-md-pull-2 { right: 16.66666667%; } .col-md-pull-1 { right: 8.33333333%; } .col-md-pull-0 { right: auto; } .col-md-push-12 { left: 100%; } .col-md-push-11 { left: 91.66666667%; } .col-md-push-10 { left: 83.33333333%; } .col-md-push-9 { left: 75%; } .col-md-push-8 { left: 66.66666667%; } .col-md-push-7 { left: 58.33333333%; } .col-md-push-6 { left: 50%; } .col-md-push-5 { left: 41.66666667%; } .col-md-push-4 { left: 33.33333333%; } .col-md-push-3 { left: 25%; } .col-md-push-2 { left: 16.66666667%; } .col-md-push-1 { left: 8.33333333%; } .col-md-push-0 { left: auto; } .col-md-offset-12 { margin-left: 100%; } .col-md-offset-11 { margin-left: 91.66666667%; } .col-md-offset-10 { margin-left: 83.33333333%; } .col-md-offset-9 { margin-left: 75%; } .col-md-offset-8 { margin-left: 66.66666667%; } .col-md-offset-7 { margin-left: 58.33333333%; } .col-md-offset-6 { margin-left: 50%; } .col-md-offset-5 { margin-left: 41.66666667%; } .col-md-offset-4 { margin-left: 33.33333333%; } .col-md-offset-3 { margin-left: 25%; } .col-md-offset-2 { margin-left: 16.66666667%; } .col-md-offset-1 { margin-left: 8.33333333%; } .col-md-offset-0 { margin-left: 0%; } } @media (min-width: 992px) { .col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11, .col-lg-12 { float: left; } .col-lg-12 { width: 100%; } .col-lg-11 { width: 91.66666667%; } .col-lg-10 { width: 83.33333333%; } .col-lg-9 { width: 75%; } .col-lg-8 { width: 66.66666667%; } .col-lg-7 { width: 58.33333333%; } .col-lg-6 { width: 50%; } .col-lg-5 { width: 41.66666667%; } .col-lg-4 { width: 33.33333333%; } .col-lg-3 { width: 25%; } .col-lg-2 { width: 16.66666667%; } .col-lg-1 { width: 8.33333333%; } .col-lg-pull-12 { right: 100%; } .col-lg-pull-11 { right: 91.66666667%; } .col-lg-pull-10 { right: 83.33333333%; } .col-lg-pull-9 { right: 75%; } .col-lg-pull-8 { right: 66.66666667%; } .col-lg-pull-7 { right: 58.33333333%; } .col-lg-pull-6 { right: 50%; } .col-lg-pull-5 { right: 41.66666667%; } .col-lg-pull-4 { right: 33.33333333%; } .col-lg-pull-3 { right: 25%; } .col-lg-pull-2 { right: 16.66666667%; } .col-lg-pull-1 { right: 8.33333333%; } .col-lg-pull-0 { right: auto; } .col-lg-push-12 { left: 100%; } .col-lg-push-11 { left: 91.66666667%; } .col-lg-push-10 { left: 83.33333333%; } .col-lg-push-9 { left: 75%; } .col-lg-push-8 { left: 66.66666667%; } .col-lg-push-7 { left: 58.33333333%; } .col-lg-push-6 { left: 50%; } .col-lg-push-5 { left: 41.66666667%; } .col-lg-push-4 { left: 33.33333333%; } .col-lg-push-3 { left: 25%; } .col-lg-push-2 { left: 16.66666667%; } .col-lg-push-1 { left: 8.33333333%; } .col-lg-push-0 { left: auto; } .col-lg-offset-12 { margin-left: 100%; } .col-lg-offset-11 { margin-left: 91.66666667%; } .col-lg-offset-10 { margin-left: 83.33333333%; } .col-lg-offset-9 { margin-left: 75%; } .col-lg-offset-8 { margin-left: 66.66666667%; } .col-lg-offset-7 { margin-left: 58.33333333%; } .col-lg-offset-6 { margin-left: 50%; } .col-lg-offset-5 { margin-left: 41.66666667%; } .col-lg-offset-4 { margin-left: 33.33333333%; } .col-lg-offset-3 { margin-left: 25%; } .col-lg-offset-2 { margin-left: 16.66666667%; } .col-lg-offset-1 { margin-left: 8.33333333%; } .col-lg-offset-0 { margin-left: 0%; } } table { background-color: transparent; } caption { padding-top: 15px; padding-bottom: 15px; color: rgba(30, 37, 47, 0.6); text-align: left; } th { text-align: left; } .table { width: 100%; max-width: 100%; margin-bottom: 27px; } .table > thead > tr > th, .table > tbody > tr > th, .table > tfoot > tr > th, .table > thead > tr > td, .table > tbody > tr > td, .table > tfoot > tr > td { padding: 15px; line-height: 1.7; vertical-align: top; border-top: 1px solid #194c5f; } .table > thead > tr > th { vertical-align: bottom; border-bottom: 2px solid #194c5f; } .table > caption + thead > tr:first-child > th, .table > colgroup + thead > tr:first-child > th, .table > thead:first-child > tr:first-child > th, .table > caption + thead > tr:first-child > td, .table > colgroup + thead > tr:first-child > td, .table > thead:first-child > tr:first-child > td { border-top: 0; } .table > tbody + tbody { border-top: 2px solid #194c5f; } .table .table { background-color: #1d252f; } .table-condensed > thead > tr > th, .table-condensed > tbody > tr > th, .table-condensed > tfoot > tr > th, .table-condensed > thead > tr > td, .table-condensed > tbody > tr > td, .table-condensed > tfoot > tr > td { padding: 8px 15px; } .table-bordered { border: 1px solid #194c5f; } .table-bordered > thead > tr > th, .table-bordered > tbody > tr > th, .table-bordered > tfoot > tr > th, .table-bordered > thead > tr > td, .table-bordered > tbody > tr > td, .table-bordered > tfoot > tr > td { border: 1px solid #194c5f; } .table-bordered > thead > tr > th, .table-bordered > thead > tr > td { border-bottom-width: 2px; } .table-striped > tbody > tr:nth-of-type(odd) { background-color: #242e3b; } .table-hover > tbody > tr:hover { background-color: #2d7ef4; } table col[class*="col-"] { position: static; float: none; display: table-column; } table td[class*="col-"], table th[class*="col-"] { position: static; float: none; display: table-cell; } .table > thead > tr > td.active, .table > tbody > tr > td.active, .table > tfoot > tr > td.active, .table > thead > tr > th.active, .table > tbody > tr > th.active, .table > tfoot > tr > th.active, .table > thead > tr.active > td, .table > tbody > tr.active > td, .table > tfoot > tr.active > td, .table > thead > tr.active > th, .table > tbody > tr.active > th, .table > tfoot > tr.active > th { background-color: #2d7ef4; } .table-hover > tbody > tr > td.active:hover, .table-hover > tbody > tr > th.active:hover, .table-hover > tbody > tr.active:hover > td, .table-hover > tbody > tr:hover > .active, .table-hover > tbody > tr.active:hover > th { background-color: #156ff3; } .table > thead > tr > td.success, .table > tbody > tr > td.success, .table > tfoot > tr > td.success, .table > thead > tr > th.success, .table > tbody > tr > th.success, .table > tfoot > tr > th.success, .table > thead > tr.success > td, .table > tbody > tr.success > td, .table > tfoot > tr.success > td, .table > thead > tr.success > th, .table > tbody > tr.success > th, .table > tfoot > tr.success > th { background-color: #e3f1e4; } .table-hover > tbody > tr > td.success:hover, .table-hover > tbody > tr > th.success:hover, .table-hover > tbody > tr.success:hover > td, .table-hover > tbody > tr:hover > .success, .table-hover > tbody > tr.success:hover > th { background-color: #d2e9d4; } .table > thead > tr > td.info, .table > tbody > tr > td.info, .table > tfoot > tr > td.info, .table > thead > tr > th.info, .table > tbody > tr > th.info, .table > tfoot > tr > th.info, .table > thead > tr.info > td, .table > tbody > tr.info > td, .table > tfoot > tr.info > td, .table > thead > tr.info > th, .table > tbody > tr.info > th, .table > tfoot > tr.info > th { background-color: #e5f3fa; } .table-hover > tbody > tr > td.info:hover, .table-hover > tbody > tr > th.info:hover, .table-hover > tbody > tr.info:hover > td, .table-hover > tbody > tr:hover > .info, .table-hover > tbody > tr.info:hover > th { background-color: #d0e9f6; } .table > thead > tr > td.warning, .table > tbody > tr > td.warning, .table > tfoot > tr > td.warning, .table > thead > tr > th.warning, .table > tbody > tr > th.warning, .table > tfoot > tr > th.warning, .table > thead > tr.warning > td, .table > tbody > tr.warning > td, .table > tfoot > tr.warning > td, .table > thead > tr.warning > th, .table > tbody > tr.warning > th, .table > tfoot > tr.warning > th { background-color: #fff4cc; } .table-hover > tbody > tr > td.warning:hover, .table-hover > tbody > tr > th.warning:hover, .table-hover > tbody > tr.warning:hover > td, .table-hover > tbody > tr:hover > .warning, .table-hover > tbody > tr.warning:hover > th { background-color: #ffefb3; } .table > thead > tr > td.danger, .table > tbody > tr > td.danger, .table > tfoot > tr > td.danger, .table > thead > tr > th.danger, .table > tbody > tr > th.danger, .table > tfoot > tr > th.danger, .table > thead > tr.danger > td, .table > tbody > tr.danger > td, .table > tfoot > tr.danger > td, .table > thead > tr.danger > th, .table > tbody > tr.danger > th, .table > tfoot > tr.danger > th { background-color: #f9e9e9; } .table-hover > tbody > tr > td.danger:hover, .table-hover > tbody > tr > th.danger:hover, .table-hover > tbody > tr.danger:hover > td, .table-hover > tbody > tr:hover > .danger, .table-hover > tbody > tr.danger:hover > th { background-color: #f4d5d5; } .table-responsive { overflow-x: auto; min-height: 0.01%; } @media screen and (max-width: 767px) { .table-responsive { width: 100%; margin-bottom: 20.25px; overflow-y: hidden; -ms-overflow-style: -ms-autohiding-scrollbar; border: 1px solid #194c5f; } .table-responsive > .table { margin-bottom: 0; } .table-responsive > .table > thead > tr > th, .table-responsive > .table > tbody > tr > th, .table-responsive > .table > tfoot > tr > th, .table-responsive > .table > thead > tr > td, .table-responsive > .table > tbody > tr > td, .table-responsive > .table > tfoot > tr > td { white-space: nowrap; } .table-responsive > .table-bordered { border: 0; } .table-responsive > .table-bordered > thead > tr > th:first-child, .table-responsive > .table-bordered > tbody > tr > th:first-child, .table-responsive > .table-bordered > tfoot > tr > th:first-child, .table-responsive > .table-bordered > thead > tr > td:first-child, .table-responsive > .table-bordered > tbody > tr > td:first-child, .table-responsive > .table-bordered > tfoot > tr > td:first-child { border-left: 0; } .table-responsive > .table-bordered > thead > tr > th:last-child, .table-responsive > .table-bordered > tbody > tr > th:last-child, .table-responsive > .table-bordered > tfoot > tr > th:last-child, .table-responsive > .table-bordered > thead > tr > td:last-child, .table-responsive > .table-bordered > tbody > tr > td:last-child, .table-responsive > .table-bordered > tfoot > tr > td:last-child { border-right: 0; } .table-responsive > .table-bordered > tbody > tr:last-child > th, .table-responsive > .table-bordered > tfoot > tr:last-child > th, .table-responsive > .table-bordered > tbody > tr:last-child > td, .table-responsive > .table-bordered > tfoot > tr:last-child > td { border-bottom: 0; } } fieldset { padding: 0; margin: 0; border: 0; min-width: 0; } legend { display: block; width: 100%; padding: 0; margin-bottom: 27px; font-size: 24px; line-height: inherit; color: rgba(30, 37, 47, 0.6); border: 0; border-bottom: 1px solid rgba(255, 255, 255, 0.14); } label { display: inline-block; max-width: 100%; margin-bottom: 5px; font-weight: bold; } input[type="search"] { -webkit-box-sizing: border-box; -moz-box-sizing: border-box; box-sizing: border-box; } input[type="radio"], input[type="checkbox"] { margin: 4px 0 0; margin-top: 1px \9; line-height: normal; } input[type="file"] { display: block; } input[type="range"] { display: block; width: 100%; } select[multiple], select[size] { height: auto; } input[type="file"]:focus, input[type="radio"]:focus, input[type="checkbox"]:focus { outline: 5px auto -webkit-focus-ring-color; outline-offset: -2px; } output { display: block; padding-top: 7px; font-size: 16px; line-height: 1.7; color: #1e252f; } .form-control { display: block; width: 100%; height: 41px; padding: 6px 12px; font-size: 16px; line-height: 1.7; color: #1e252f; background-color: #44576e; background-image: none; border: 1px solid #194c5f; border-radius: 0; -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); -webkit-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s; -o-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s; transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s; } .form-control:focus { border-color: #66afe9; outline: 0; -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6); box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6); } .form-control::-moz-placeholder { color: rgba(30, 37, 47, 0.6); opacity: 1; } .form-control:-ms-input-placeholder { color: rgba(30, 37, 47, 0.6); } .form-control::-webkit-input-placeholder { color: rgba(30, 37, 47, 0.6); } .form-control::-ms-expand { border: 0; background-color: transparent; } .form-control[disabled], .form-control[readonly], fieldset[disabled] .form-control { background-color: transparent; opacity: 1; } .form-control[disabled], fieldset[disabled] .form-control { cursor: not-allowed; } textarea.form-control { height: auto; } input[type="search"] { -webkit-appearance: none; } @media screen and (-webkit-min-device-pixel-ratio: 0) { input[type="date"].form-control, input[type="time"].form-control, input[type="datetime-local"].form-control, input[type="month"].form-control { line-height: 41px; } input[type="date"].input-sm, input[type="time"].input-sm, input[type="datetime-local"].input-sm, input[type="month"].input-sm, .input-group-sm input[type="date"], .input-group-sm input[type="time"], .input-group-sm input[type="datetime-local"], .input-group-sm input[type="month"] { line-height: 33px; } input[type="date"].input-lg, input[type="time"].input-lg, input[type="datetime-local"].input-lg, input[type="month"].input-lg, .input-group-lg input[type="date"], .input-group-lg input[type="time"], .input-group-lg input[type="datetime-local"], .input-group-lg input[type="month"] { line-height: 49px; } } .form-group { margin-bottom: 24px; } .radio, .checkbox { position: relative; display: block; margin-top: 10px; margin-bottom: 10px; } .radio label, .checkbox label { min-height: 27px; padding-left: 20px; margin-bottom: 0; font-weight: normal; cursor: pointer; } .radio input[type="radio"], .radio-inline input[type="radio"], .checkbox input[type="checkbox"], .checkbox-inline input[type="checkbox"] { position: absolute; margin-left: -20px; margin-top: 4px \9; } .radio + .radio, .checkbox + .checkbox { margin-top: -5px; } .radio-inline, .checkbox-inline { position: relative; display: inline-block; padding-left: 20px; margin-bottom: 0; vertical-align: middle; font-weight: normal; cursor: pointer; } .radio-inline + .radio-inline, .checkbox-inline + .checkbox-inline { margin-top: 0; margin-left: 10px; } input[type="radio"][disabled], input[type="checkbox"][disabled], input[type="radio"].disabled, input[type="checkbox"].disabled, fieldset[disabled] input[type="radio"], fieldset[disabled] input[type="checkbox"] { cursor: not-allowed; } .radio-inline.disabled, .checkbox-inline.disabled, fieldset[disabled] .radio-inline, fieldset[disabled] .checkbox-inline { cursor: not-allowed; } .radio.disabled label, .checkbox.disabled label, fieldset[disabled] .radio label, fieldset[disabled] .checkbox label { cursor: not-allowed; } .form-control-static { padding-top: 7px; padding-bottom: 7px; margin-bottom: 0; min-height: 43px; } .form-control-static.input-lg, .form-control-static.input-sm { padding-left: 0; padding-right: 0; } .input-sm { height: 33px; padding: 5px 10px; font-size: 14px; line-height: 1.5; border-radius: 0; } select.input-sm { height: 33px; line-height: 33px; } textarea.input-sm, select[multiple].input-sm { height: auto; } .form-group-sm .form-control { height: 33px; padding: 5px 10px; font-size: 14px; line-height: 1.5; border-radius: 0; } .form-group-sm select.form-control { height: 33px; line-height: 33px; } .form-group-sm textarea.form-control, .form-group-sm select[multiple].form-control { height: auto; } .form-group-sm .form-control-static { height: 33px; min-height: 41px; padding: 6px 10px; font-size: 14px; line-height: 1.5; } .input-lg { height: 49px; padding: 10px 16px; font-size: 20px; line-height: 1.3333333; border-radius: 0; } select.input-lg { height: 49px; line-height: 49px; } textarea.input-lg, select[multiple].input-lg { height: auto; } .form-group-lg .form-control { height: 49px; padding: 10px 16px; font-size: 20px; line-height: 1.3333333; border-radius: 0; } .form-group-lg select.form-control { height: 49px; line-height: 49px; } .form-group-lg textarea.form-control, .form-group-lg select[multiple].form-control { height: auto; } .form-group-lg .form-control-static { height: 49px; min-height: 47px; padding: 11px 16px; font-size: 20px; line-height: 1.3333333; } .has-feedback { position: relative; } .has-feedback .form-control { padding-right: 51.25px; } .form-control-feedback { position: absolute; top: 0; right: 0; z-index: 2; display: block; width: 41px; height: 41px; line-height: 41px; text-align: center; pointer-events: none; } .input-lg + .form-control-feedback, .input-group-lg + .form-control-feedback, .form-group-lg .form-control + .form-control-feedback { width: 49px; height: 49px; line-height: 49px; } .input-sm + .form-control-feedback, .input-group-sm + .form-control-feedback, .form-group-sm .form-control + .form-control-feedback { width: 33px; height: 33px; line-height: 33px; } .has-success .help-block, .has-success .control-label, .has-success .radio, .has-success .checkbox, .has-success .radio-inline, .has-success .checkbox-inline, .has-success.radio label, .has-success.checkbox label, .has-success.radio-inline label, .has-success.checkbox-inline label { color: #24b47e; } .has-success .form-control { border-color: #24b47e; -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); } .has-success .form-control:focus { border-color: #1c8a60; -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #5fdfaf; box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #5fdfaf; } .has-success .input-group-addon { color: #24b47e; border-color: #24b47e; background-color: #e3f1e4; } .has-success .form-control-feedback { color: #24b47e; } .has-warning .help-block, .has-warning .control-label, .has-warning .radio, .has-warning .checkbox, .has-warning .radio-inline, .has-warning .checkbox-inline, .has-warning.radio label, .has-warning.checkbox label, .has-warning.radio-inline label, .has-warning.checkbox-inline label { color: #de8e27; } .has-warning .form-control { border-color: #de8e27; -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); } .has-warning .form-control:focus { border-color: #b6721c; -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ebbc7f; box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ebbc7f; } .has-warning .input-group-addon { color: #de8e27; border-color: #de8e27; background-color: #fff4cc; } .has-warning .form-control-feedback { color: #de8e27; } .has-error .help-block, .has-error .control-label, .has-error .radio, .has-error .checkbox, .has-error .radio-inline, .has-error .checkbox-inline, .has-error.radio label, .has-error.checkbox label, .has-error.radio-inline label, .has-error.checkbox-inline label { color: #ff2b67; } .has-error .form-control { border-color: #ff2b67; -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); } .has-error .form-control:focus { border-color: #f70046; -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ff91b0; box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ff91b0; } .has-error .input-group-addon { color: #ff2b67; border-color: #ff2b67; background-color: #f9e9e9; } .has-error .form-control-feedback { color: #ff2b67; } .has-feedback label ~ .form-control-feedback { top: 32px; } .has-feedback label.sr-only ~ .form-control-feedback { top: 0; } .help-block { display: block; margin-top: 5px; margin-bottom: 10px; color: #50627d; } @media (min-width: 768px) { .form-inline .form-group { display: inline-block; margin-bottom: 0; vertical-align: middle; } .form-inline .form-control { display: inline-block; width: auto; vertical-align: middle; } .form-inline .form-control-static { display: inline-block; } .form-inline .input-group { display: inline-table; vertical-align: middle; } .form-inline .input-group .input-group-addon, .form-inline .input-group .input-group-btn, .form-inline .input-group .form-control { width: auto; } .form-inline .input-group > .form-control { width: 100%; } .form-inline .control-label { margin-bottom: 0; vertical-align: middle; } .form-inline .radio, .form-inline .checkbox { display: inline-block; margin-top: 0; margin-bottom: 0; vertical-align: middle; } .form-inline .radio label, .form-inline .checkbox label { padding-left: 0; } .form-inline .radio input[type="radio"], .form-inline .checkbox input[type="checkbox"] { position: relative; margin-left: 0; } .form-inline .has-feedback .form-control-feedback { top: 0; } } .form-horizontal .radio, .form-horizontal .checkbox, .form-horizontal .radio-inline, .form-horizontal .checkbox-inline { margin-top: 0; margin-bottom: 0; padding-top: 7px; } .form-horizontal .radio, .form-horizontal .checkbox { min-height: 34px; } .form-horizontal .form-group { margin-left: -15px; margin-right: -15px; } @media (min-width: 768px) { .form-horizontal .control-label { text-align: right; margin-bottom: 0; padding-top: 7px; } } .form-horizontal .has-feedback .form-control-feedback { right: 15px; } @media (min-width: 768px) { .form-horizontal .form-group-lg .control-label { padding-top: 11px; font-size: 20px; } } @media (min-width: 768px) { .form-horizontal .form-group-sm .control-label { padding-top: 6px; font-size: 14px; } } .btn { display: inline-block; margin-bottom: 0; font-weight: 700; text-align: center; vertical-align: middle; touch-action: manipulation; cursor: pointer; background-image: none; border: 1px solid transparent; white-space: nowrap; padding: 6px 12px; font-size: 16px; line-height: 1.7; border-radius: 30px; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; } .btn:focus, .btn:active:focus, .btn.active:focus, .btn.focus, .btn:active.focus, .btn.active.focus { outline: 5px auto -webkit-focus-ring-color; outline-offset: -2px; } .btn:hover, .btn:focus, .btn.focus { color: #1e252f; text-decoration: none; } .btn:active, .btn.active { outline: 0; background-image: none; -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); } .btn.disabled, .btn[disabled], fieldset[disabled] .btn { cursor: not-allowed; opacity: 0.65; filter: alpha(opacity=65); -webkit-box-shadow: none; box-shadow: none; } a.btn.disabled, fieldset[disabled] a.btn { pointer-events: none; } .btn-default { color: #1e252f; background-color: #90a3bb; border-color: transparent; } .btn-default:focus, .btn-default.focus { color: #1e252f; background-color: #7189a7; border-color: rgba(0, 0, 0, 0); } .btn-default:hover { color: #1e252f; background-color: #7189a7; border-color: rgba(0, 0, 0, 0); } .btn-default:active, .btn-default.active, .open > .dropdown-toggle.btn-default { color: #1e252f; background-color: #7189a7; border-color: rgba(0, 0, 0, 0); } .btn-default:active:hover, .btn-default.active:hover, .open > .dropdown-toggle.btn-default:hover, .btn-default:active:focus, .btn-default.active:focus, .open > .dropdown-toggle.btn-default:focus, .btn-default:active.focus, .btn-default.active.focus, .open > .dropdown-toggle.btn-default.focus { color: #1e252f; background-color: #5d7797; border-color: rgba(0, 0, 0, 0); } .btn-default:active, .btn-default.active, .open > .dropdown-toggle.btn-default { background-image: none; } .btn-default.disabled:hover, .btn-default[disabled]:hover, fieldset[disabled] .btn-default:hover, .btn-default.disabled:focus, .btn-default[disabled]:focus, fieldset[disabled] .btn-default:focus, .btn-default.disabled.focus, .btn-default[disabled].focus, fieldset[disabled] .btn-default.focus { background-color: #90a3bb; border-color: transparent; } .btn-default .badge { color: #90a3bb; background-color: #1e252f; } .btn-primary { color: #ffffff; background-color: #2d7ef4; border-color: #156ff3; } .btn-primary:focus, .btn-primary.focus { color: #ffffff; background-color: #0c63e2; border-color: #073981; } .btn-primary:hover { color: #ffffff; background-color: #0c63e2; border-color: #0a54c0; } .btn-primary:active, .btn-primary.active, .open > .dropdown-toggle.btn-primary { color: #ffffff; background-color: #0c63e2; border-color: #0a54c0; } .btn-primary:active:hover, .btn-primary.active:hover, .open > .dropdown-toggle.btn-primary:hover, .btn-primary:active:focus, .btn-primary.active:focus, .open > .dropdown-toggle.btn-primary:focus, .btn-primary:active.focus, .btn-primary.active.focus, .open > .dropdown-toggle.btn-primary.focus { color: #ffffff; background-color: #0a54c0; border-color: #073981; } .btn-primary:active, .btn-primary.active, .open > .dropdown-toggle.btn-primary { background-image: none; } .btn-primary.disabled:hover, .btn-primary[disabled]:hover, fieldset[disabled] .btn-primary:hover, .btn-primary.disabled:focus, .btn-primary[disabled]:focus, fieldset[disabled] .btn-primary:focus, .btn-primary.disabled.focus, .btn-primary[disabled].focus, fieldset[disabled] .btn-primary.focus { background-color: #2d7ef4; border-color: #156ff3; } .btn-primary .badge { color: #2d7ef4; background-color: #ffffff; } .btn-success { color: #ffffff; background-color: #24b47e; border-color: #209f6f; } .btn-success:focus, .btn-success.focus { color: #ffffff; background-color: #1c8a60; border-color: #0b3525; } .btn-success:hover { color: #ffffff; background-color: #1c8a60; border-color: #166c4b; } .btn-success:active, .btn-success.active, .open > .dropdown-toggle.btn-success { color: #ffffff; background-color: #1c8a60; border-color: #166c4b; } .btn-success:active:hover, .btn-success.active:hover, .open > .dropdown-toggle.btn-success:hover, .btn-success:active:focus, .btn-success.active:focus, .open > .dropdown-toggle.btn-success:focus, .btn-success:active.focus, .btn-success.active.focus, .open > .dropdown-toggle.btn-success.focus { color: #ffffff; background-color: #166c4b; border-color: #0b3525; } .btn-success:active, .btn-success.active, .open > .dropdown-toggle.btn-success { background-image: none; } .btn-success.disabled:hover, .btn-success[disabled]:hover, fieldset[disabled] .btn-success:hover, .btn-success.disabled:focus, .btn-success[disabled]:focus, fieldset[disabled] .btn-success:focus, .btn-success.disabled.focus, .btn-success[disabled].focus, fieldset[disabled] .btn-success.focus { background-color: #24b47e; border-color: #209f6f; } .btn-success .badge { color: #24b47e; background-color: #ffffff; } .btn-info { color: #ffffff; background-color: #06a2ff; border-color: #0094eb; } .btn-info:focus, .btn-info.focus { color: #ffffff; background-color: #0084d2; border-color: #00446c; } .btn-info:hover { color: #ffffff; background-color: #0084d2; border-color: #006dae; } .btn-info:active, .btn-info.active, .open > .dropdown-toggle.btn-info { color: #ffffff; background-color: #0084d2; border-color: #006dae; } .btn-info:active:hover, .btn-info.active:hover, .open > .dropdown-toggle.btn-info:hover, .btn-info:active:focus, .btn-info.active:focus, .open > .dropdown-toggle.btn-info:focus, .btn-info:active.focus, .btn-info.active.focus, .open > .dropdown-toggle.btn-info.focus { color: #ffffff; background-color: #006dae; border-color: #00446c; } .btn-info:active, .btn-info.active, .open > .dropdown-toggle.btn-info { background-image: none; } .btn-info.disabled:hover, .btn-info[disabled]:hover, fieldset[disabled] .btn-info:hover, .btn-info.disabled:focus, .btn-info[disabled]:focus, fieldset[disabled] .btn-info:focus, .btn-info.disabled.focus, .btn-info[disabled].focus, fieldset[disabled] .btn-info.focus { background-color: #06a2ff; border-color: #0094eb; } .btn-info .badge { color: #06a2ff; background-color: #ffffff; } .btn-warning { color: #ffffff; background-color: #e39f48; border-color: #e09332; } .btn-warning:focus, .btn-warning.focus { color: #ffffff; background-color: #d78721; border-color: #7f5013; } .btn-warning:hover { color: #ffffff; background-color: #d78721; border-color: #b8741c; } .btn-warning:active, .btn-warning.active, .open > .dropdown-toggle.btn-warning { color: #ffffff; background-color: #d78721; border-color: #b8741c; } .btn-warning:active:hover, .btn-warning.active:hover, .open > .dropdown-toggle.btn-warning:hover, .btn-warning:active:focus, .btn-warning.active:focus, .open > .dropdown-toggle.btn-warning:focus, .btn-warning:active.focus, .btn-warning.active.focus, .open > .dropdown-toggle.btn-warning.focus { color: #ffffff; background-color: #b8741c; border-color: #7f5013; } .btn-warning:active, .btn-warning.active, .open > .dropdown-toggle.btn-warning { background-image: none; } .btn-warning.disabled:hover, .btn-warning[disabled]:hover, fieldset[disabled] .btn-warning:hover, .btn-warning.disabled:focus, .btn-warning[disabled]:focus, fieldset[disabled] .btn-warning:focus, .btn-warning.disabled.focus, .btn-warning[disabled].focus, fieldset[disabled] .btn-warning.focus { background-color: #e39f48; border-color: #e09332; } .btn-warning .badge { color: #e39f48; background-color: #ffffff; } .btn-danger { color: #ffffff; background-color: #ff2b67; border-color: #ff1255; } .btn-danger:focus, .btn-danger.focus { color: #ffffff; background-color: #f70046; border-color: #910029; } .btn-danger:hover { color: #ffffff; background-color: #f70046; border-color: #d3003c; } .btn-danger:active, .btn-danger.active, .open > .dropdown-toggle.btn-danger { color: #ffffff; background-color: #f70046; border-color: #d3003c; } .btn-danger:active:hover, .btn-danger.active:hover, .open > .dropdown-toggle.btn-danger:hover, .btn-danger:active:focus, .btn-danger.active:focus, .open > .dropdown-toggle.btn-danger:focus, .btn-danger:active.focus, .btn-danger.active.focus, .open > .dropdown-toggle.btn-danger.focus { color: #ffffff; background-color: #d3003c; border-color: #910029; } .btn-danger:active, .btn-danger.active, .open > .dropdown-toggle.btn-danger { background-image: none; } .btn-danger.disabled:hover, .btn-danger[disabled]:hover, fieldset[disabled] .btn-danger:hover, .btn-danger.disabled:focus, .btn-danger[disabled]:focus, fieldset[disabled] .btn-danger:focus, .btn-danger.disabled.focus, .btn-danger[disabled].focus, fieldset[disabled] .btn-danger.focus { background-color: #ff2b67; border-color: #ff1255; } .btn-danger .badge { color: #ff2b67; background-color: #ffffff; } .btn-link { color: #2d7ef4; font-weight: normal; border-radius: 0; } .btn-link, .btn-link:active, .btn-link.active, .btn-link[disabled], fieldset[disabled] .btn-link { background-color: transparent; -webkit-box-shadow: none; box-shadow: none; } .btn-link, .btn-link:hover, .btn-link:focus, .btn-link:active { border-color: transparent; } .btn-link:hover, .btn-link:focus { color: #0b58ca; text-decoration: underline; background-color: transparent; } .btn-link[disabled]:hover, fieldset[disabled] .btn-link:hover, .btn-link[disabled]:focus, fieldset[disabled] .btn-link:focus { color: #757575; text-decoration: none; } .btn-lg, .btn-group-lg > .btn { padding: 10px 16px; font-size: 20px; line-height: 1.3333333; border-radius: 45px; } .btn-sm, .btn-group-sm > .btn { padding: 5px 10px; font-size: 14px; line-height: 1.5; border-radius: 20px; } .btn-xs, .btn-group-xs > .btn { padding: 1px 5px; font-size: 14px; line-height: 1.5; border-radius: 20px; } .btn-block { display: block; width: 100%; } .btn-block + .btn-block { margin-top: 5px; } input[type="submit"].btn-block, input[type="reset"].btn-block, input[type="button"].btn-block { width: 100%; } .fade { opacity: 0; -webkit-transition: opacity 0.15s linear; -o-transition: opacity 0.15s linear; transition: opacity 0.15s linear; } .fade.in { opacity: 1; } .collapse { display: none; } .collapse.in { display: block; } tr.collapse.in { display: table-row; } tbody.collapse.in { display: table-row-group; } .collapsing { position: relative; height: 0; overflow: hidden; -webkit-transition-property: height, visibility; transition-property: height, visibility; -webkit-transition-duration: 0.35s; transition-duration: 0.35s; -webkit-transition-timing-function: ease; transition-timing-function: ease; } .caret { display: inline-block; width: 0; height: 0; margin-left: 2px; vertical-align: middle; border-top: 4px dashed; border-top: 4px solid \9; border-right: 4px solid transparent; border-left: 4px solid transparent; } .dropup, .dropdown { position: relative; } .dropdown-toggle:focus { outline: 0; } .dropdown-menu { position: absolute; top: 100%; left: 0; z-index: 1000; display: none; float: left; min-width: 160px; padding: 5px 0; margin: 2px 0 0; list-style: none; font-size: 16px; text-align: left; background-color: #1d252f; border: 1px solid #cccccc; border: 1px solid #ff2b67; border-radius: 0; -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); background-clip: padding-box; } .dropdown-menu.pull-right { right: 0; left: auto; } .dropdown-menu .divider { height: 1px; margin: 12.5px 0; overflow: hidden; background-color: rgba(255, 255, 255, 0.1); } .dropdown-menu > li > a { display: block; padding: 3px 20px; clear: both; font-weight: normal; line-height: 1.7; color: #ffffff; white-space: nowrap; } .dropdown-menu > li > a:hover, .dropdown-menu > li > a:focus { text-decoration: none; color: #ffffff; background-color: #06a2ff; } .dropdown-menu > .active > a, .dropdown-menu > .active > a:hover, .dropdown-menu > .active > a:focus { color: #ffffff; text-decoration: none; outline: 0; background-color: #2d7ef4; } .dropdown-menu > .disabled > a, .dropdown-menu > .disabled > a:hover, .dropdown-menu > .disabled > a:focus { color: rgba(30, 37, 47, 0.6); } .dropdown-menu > .disabled > a:hover, .dropdown-menu > .disabled > a:focus { text-decoration: none; background-color: transparent; background-image: none; filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); cursor: not-allowed; } .open > .dropdown-menu { display: block; } .open > a { outline: 0; } .dropdown-menu-right { left: auto; right: 0; } .dropdown-menu-left { left: 0; right: auto; } .dropdown-header { display: block; padding: 3px 20px; font-size: 14px; line-height: 1.7; color: #1e252f; white-space: nowrap; } .dropdown-backdrop { position: fixed; left: 0; right: 0; bottom: 0; top: 0; z-index: 990; } .pull-right > .dropdown-menu { right: 0; left: auto; } .dropup .caret, .navbar-fixed-bottom .dropdown .caret { border-top: 0; border-bottom: 4px dashed; border-bottom: 4px solid \9; content: ""; } .dropup .dropdown-menu, .navbar-fixed-bottom .dropdown .dropdown-menu { top: auto; bottom: 100%; margin-bottom: 2px; } @media (min-width: 992px) { .navbar-right .dropdown-menu { left: auto; right: 0; } .navbar-right .dropdown-menu-left { left: 0; right: auto; } } .btn-group, .btn-group-vertical { position: relative; display: inline-block; vertical-align: middle; } .btn-group > .btn, .btn-group-vertical > .btn { position: relative; float: left; } .btn-group > .btn:hover, .btn-group-vertical > .btn:hover, .btn-group > .btn:focus, .btn-group-vertical > .btn:focus, .btn-group > .btn:active, .btn-group-vertical > .btn:active, .btn-group > .btn.active, .btn-group-vertical > .btn.active { z-index: 2; } .btn-group .btn + .btn, .btn-group .btn + .btn-group, .btn-group .btn-group + .btn, .btn-group .btn-group + .btn-group { margin-left: -1px; } .btn-toolbar { margin-left: -5px; } .btn-toolbar .btn, .btn-toolbar .btn-group, .btn-toolbar .input-group { float: left; } .btn-toolbar > .btn, .btn-toolbar > .btn-group, .btn-toolbar > .input-group { margin-left: 5px; } .btn-group > .btn:not(:first-child):not(:last-child):not(.dropdown-toggle) { border-radius: 0; } .btn-group > .btn:first-child { margin-left: 0; } .btn-group > .btn:first-child:not(:last-child):not(.dropdown-toggle) { border-bottom-right-radius: 0; border-top-right-radius: 0; } .btn-group > .btn:last-child:not(:first-child), .btn-group > .dropdown-toggle:not(:first-child) { border-bottom-left-radius: 0; border-top-left-radius: 0; } .btn-group > .btn-group { float: left; } .btn-group > .btn-group:not(:first-child):not(:last-child) > .btn { border-radius: 0; } .btn-group > .btn-group:first-child:not(:last-child) > .btn:last-child, .btn-group > .btn-group:first-child:not(:last-child) > .dropdown-toggle { border-bottom-right-radius: 0; border-top-right-radius: 0; } .btn-group > .btn-group:last-child:not(:first-child) > .btn:first-child { border-bottom-left-radius: 0; border-top-left-radius: 0; } .btn-group .dropdown-toggle:active, .btn-group.open .dropdown-toggle { outline: 0; } .btn-group > .btn + .dropdown-toggle { padding-left: 8px; padding-right: 8px; } .btn-group > .btn-lg + .dropdown-toggle { padding-left: 12px; padding-right: 12px; } .btn-group.open .dropdown-toggle { -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); } .btn-group.open .dropdown-toggle.btn-link { -webkit-box-shadow: none; box-shadow: none; } .btn .caret { margin-left: 0; } .btn-lg .caret { border-width: 5px 5px 0; border-bottom-width: 0; } .dropup .btn-lg .caret { border-width: 0 5px 5px; } .btn-group-vertical > .btn, .btn-group-vertical > .btn-group, .btn-group-vertical > .btn-group > .btn { display: block; float: none; width: 100%; max-width: 100%; } .btn-group-vertical > .btn-group > .btn { float: none; } .btn-group-vertical > .btn + .btn, .btn-group-vertical > .btn + .btn-group, .btn-group-vertical > .btn-group + .btn, .btn-group-vertical > .btn-group + .btn-group { margin-top: -1px; margin-left: 0; } .btn-group-vertical > .btn:not(:first-child):not(:last-child) { border-radius: 0; } .btn-group-vertical > .btn:first-child:not(:last-child) { border-top-right-radius: 30px; border-top-left-radius: 30px; border-bottom-right-radius: 0; border-bottom-left-radius: 0; } .btn-group-vertical > .btn:last-child:not(:first-child) { border-top-right-radius: 0; border-top-left-radius: 0; border-bottom-right-radius: 30px; border-bottom-left-radius: 30px; } .btn-group-vertical > .btn-group:not(:first-child):not(:last-child) > .btn { border-radius: 0; } .btn-group-vertical > .btn-group:first-child:not(:last-child) > .btn:last-child, .btn-group-vertical > .btn-group:first-child:not(:last-child) > .dropdown-toggle { border-bottom-right-radius: 0; border-bottom-left-radius: 0; } .btn-group-vertical > .btn-group:last-child:not(:first-child) > .btn:first-child { border-top-right-radius: 0; border-top-left-radius: 0; } .btn-group-justified { display: table; width: 100%; table-layout: fixed; border-collapse: separate; } .btn-group-justified > .btn, .btn-group-justified > .btn-group { float: none; display: table-cell; width: 1%; } .btn-group-justified > .btn-group .btn { width: 100%; } .btn-group-justified > .btn-group .dropdown-menu { left: auto; } [data-toggle="buttons"] > .btn input[type="radio"], [data-toggle="buttons"] > .btn-group > .btn input[type="radio"], [data-toggle="buttons"] > .btn input[type="checkbox"], [data-toggle="buttons"] > .btn-group > .btn input[type="checkbox"] { position: absolute; clip: rect(0, 0, 0, 0); pointer-events: none; } .input-group { position: relative; display: table; border-collapse: separate; } .input-group[class*="col-"] { float: none; padding-left: 0; padding-right: 0; } .input-group .form-control { position: relative; z-index: 2; float: left; width: 100%; margin-bottom: 0; } .input-group .form-control:focus { z-index: 3; } .input-group-lg > .form-control, .input-group-lg > .input-group-addon, .input-group-lg > .input-group-btn > .btn { height: 49px; padding: 10px 16px; font-size: 20px; line-height: 1.3333333; border-radius: 0; } select.input-group-lg > .form-control, select.input-group-lg > .input-group-addon, select.input-group-lg > .input-group-btn > .btn { height: 49px; line-height: 49px; } textarea.input-group-lg > .form-control, textarea.input-group-lg > .input-group-addon, textarea.input-group-lg > .input-group-btn > .btn, select[multiple].input-group-lg > .form-control, select[multiple].input-group-lg > .input-group-addon, select[multiple].input-group-lg > .input-group-btn > .btn { height: auto; } .input-group-sm > .form-control, .input-group-sm > .input-group-addon, .input-group-sm > .input-group-btn > .btn { height: 33px; padding: 5px 10px; font-size: 14px; line-height: 1.5; border-radius: 0; } select.input-group-sm > .form-control, select.input-group-sm > .input-group-addon, select.input-group-sm > .input-group-btn > .btn { height: 33px; line-height: 33px; } textarea.input-group-sm > .form-control, textarea.input-group-sm > .input-group-addon, textarea.input-group-sm > .input-group-btn > .btn, select[multiple].input-group-sm > .form-control, select[multiple].input-group-sm > .input-group-addon, select[multiple].input-group-sm > .input-group-btn > .btn { height: auto; } .input-group-addon, .input-group-btn, .input-group .form-control { display: table-cell; } .input-group-addon:not(:first-child):not(:last-child), .input-group-btn:not(:first-child):not(:last-child), .input-group .form-control:not(:first-child):not(:last-child) { border-radius: 0; } .input-group-addon, .input-group-btn { width: 1%; white-space: nowrap; vertical-align: middle; } .input-group-addon { padding: 6px 12px; font-size: 16px; font-weight: normal; line-height: 1; color: #1e252f; text-align: center; background-color: #44576e; border: 1px solid #194c5f; border-radius: 0; } .input-group-addon.input-sm { padding: 5px 10px; font-size: 14px; border-radius: 0; } .input-group-addon.input-lg { padding: 10px 16px; font-size: 20px; border-radius: 0; } .input-group-addon input[type="radio"], .input-group-addon input[type="checkbox"] { margin-top: 0; } .input-group .form-control:first-child, .input-group-addon:first-child, .input-group-btn:first-child > .btn, .input-group-btn:first-child > .btn-group > .btn, .input-group-btn:first-child > .dropdown-toggle, .input-group-btn:last-child > .btn:not(:last-child):not(.dropdown-toggle), .input-group-btn:last-child > .btn-group:not(:last-child) > .btn { border-bottom-right-radius: 0; border-top-right-radius: 0; } .input-group-addon:first-child { border-right: 0; } .input-group .form-control:last-child, .input-group-addon:last-child, .input-group-btn:last-child > .btn, .input-group-btn:last-child > .btn-group > .btn, .input-group-btn:last-child > .dropdown-toggle, .input-group-btn:first-child > .btn:not(:first-child), .input-group-btn:first-child > .btn-group:not(:first-child) > .btn { border-bottom-left-radius: 0; border-top-left-radius: 0; } .input-group-addon:last-child { border-left: 0; } .input-group-btn { position: relative; font-size: 0; white-space: nowrap; } .input-group-btn > .btn { position: relative; } .input-group-btn > .btn + .btn { margin-left: -1px; } .input-group-btn > .btn:hover, .input-group-btn > .btn:focus, .input-group-btn > .btn:active { z-index: 2; } .input-group-btn:first-child > .btn, .input-group-btn:first-child > .btn-group { margin-right: -1px; } .input-group-btn:last-child > .btn, .input-group-btn:last-child > .btn-group { z-index: 2; margin-left: -1px; } .nav { margin-bottom: 0; padding-left: 0; list-style: none; } .nav > li { position: relative; display: block; } .nav > li > a { position: relative; display: block; padding: 10px 15px; } .nav > li > a:hover, .nav > li > a:focus { text-decoration: none; background-color: rgba(255, 255, 255, 0.06); } .nav > li.disabled > a { color: rgba(30, 37, 47, 0.6); } .nav > li.disabled > a:hover, .nav > li.disabled > a:focus { color: rgba(30, 37, 47, 0.6); text-decoration: none; background-color: transparent; cursor: not-allowed; } .nav .open > a, .nav .open > a:hover, .nav .open > a:focus { background-color: rgba(255, 255, 255, 0.06); border-color: #2d7ef4; } .nav .nav-divider { height: 1px; margin: 12.5px 0; overflow: hidden; background-color: #e5e5e5; } .nav > li > a > img { max-width: none; } .nav-tabs { border-bottom: 1px solid rgba(255, 255, 255, 0.1); } .nav-tabs > li { float: left; margin-bottom: -1px; } .nav-tabs > li > a { margin-right: 2px; line-height: 1.7; border: 1px solid transparent; border-radius: 0 0 0 0; } .nav-tabs > li > a:hover { border-color: rgba(255, 255, 255, 0.1) rgba(255, 255, 255, 0.1) rgba(255, 255, 255, 0.1); } .nav-tabs > li.active > a, .nav-tabs > li.active > a:hover, .nav-tabs > li.active > a:focus { color: #1e252f; background-color: #1d252f; border: 1px solid rgba(255, 255, 255, 0.1); border-bottom-color: transparent; cursor: default; } .nav-tabs.nav-justified { width: 100%; border-bottom: 0; } .nav-tabs.nav-justified > li { float: none; } .nav-tabs.nav-justified > li > a { text-align: center; margin-bottom: 5px; } .nav-tabs.nav-justified > .dropdown .dropdown-menu { top: auto; left: auto; } @media (min-width: 768px) { .nav-tabs.nav-justified > li { display: table-cell; width: 1%; } .nav-tabs.nav-justified > li > a { margin-bottom: 0; } } .nav-tabs.nav-justified > li > a { margin-right: 0; border-radius: 0; } .nav-tabs.nav-justified > .active > a, .nav-tabs.nav-justified > .active > a:hover, .nav-tabs.nav-justified > .active > a:focus { border: 1px solid #dddddd; } @media (min-width: 768px) { .nav-tabs.nav-justified > li > a { border-bottom: 1px solid #dddddd; border-radius: 0 0 0 0; } .nav-tabs.nav-justified > .active > a, .nav-tabs.nav-justified > .active > a:hover, .nav-tabs.nav-justified > .active > a:focus { border-bottom-color: #1d252f; } } .nav-pills > li { float: left; } .nav-pills > li > a { border-radius: 0; } .nav-pills > li + li { margin-left: 2px; } .nav-pills > li.active > a, .nav-pills > li.active > a:hover, .nav-pills > li.active > a:focus { color: #ffffff; background-color: #2d7ef4; } .nav-stacked > li { float: none; } .nav-stacked > li + li { margin-top: 2px; margin-left: 0; } .nav-justified { width: 100%; } .nav-justified > li { float: none; } .nav-justified > li > a { text-align: center; margin-bottom: 5px; } .nav-justified > .dropdown .dropdown-menu { top: auto; left: auto; } @media (min-width: 768px) { .nav-justified > li { display: table-cell; width: 1%; } .nav-justified > li > a { margin-bottom: 0; } } .nav-tabs-justified { border-bottom: 0; } .nav-tabs-justified > li > a { margin-right: 0; border-radius: 0; } .nav-tabs-justified > .active > a, .nav-tabs-justified > .active > a:hover, .nav-tabs-justified > .active > a:focus { border: 1px solid #dddddd; } @media (min-width: 768px) { .nav-tabs-justified > li > a { border-bottom: 1px solid #dddddd; border-radius: 0 0 0 0; } .nav-tabs-justified > .active > a, .nav-tabs-justified > .active > a:hover, .nav-tabs-justified > .active > a:focus { border-bottom-color: #1d252f; } } .tab-content > .tab-pane { display: none; } .tab-content > .active { display: block; } .nav-tabs .dropdown-menu { margin-top: -1px; border-top-right-radius: 0; border-top-left-radius: 0; } .navbar { position: relative; min-height: 80px; margin-bottom: 0; border: 1px solid transparent; } @media (min-width: 992px) { .navbar { border-radius: 0; } } @media (min-width: 992px) { .navbar-header { float: left; } } .navbar-collapse { overflow-x: visible; padding-right: 15px; padding-left: 15px; border-top: 1px solid transparent; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1); -webkit-overflow-scrolling: touch; } .navbar-collapse.in { overflow-y: auto; } @media (min-width: 992px) { .navbar-collapse { width: auto; border-top: 0; box-shadow: none; } .navbar-collapse.collapse { display: block !important; height: auto !important; padding-bottom: 0; overflow: visible !important; } .navbar-collapse.in { overflow-y: visible; } .navbar-fixed-top .navbar-collapse, .navbar-static-top .navbar-collapse, .navbar-fixed-bottom .navbar-collapse { padding-left: 0; padding-right: 0; } } .navbar-fixed-top .navbar-collapse, .navbar-fixed-bottom .navbar-collapse { max-height: 340px; } @media (max-device-width: 480px) and (orientation: landscape) { .navbar-fixed-top .navbar-collapse, .navbar-fixed-bottom .navbar-collapse { max-height: 200px; } } .container > .navbar-header, .container-fluid > .navbar-header, .container > .navbar-collapse, .container-fluid > .navbar-collapse { margin-right: -15px; margin-left: -15px; } @media (min-width: 992px) { .container > .navbar-header, .container-fluid > .navbar-header, .container > .navbar-collapse, .container-fluid > .navbar-collapse { margin-right: 0; margin-left: 0; } } .navbar-static-top { z-index: 1000; border-width: 0 0 1px; } @media (min-width: 992px) { .navbar-static-top { border-radius: 0; } } .navbar-fixed-top, .navbar-fixed-bottom { position: fixed; right: 0; left: 0; z-index: 1030; } @media (min-width: 992px) { .navbar-fixed-top, .navbar-fixed-bottom { border-radius: 0; } } .navbar-fixed-top { top: 0; border-width: 0 0 1px; } .navbar-fixed-bottom { bottom: 0; margin-bottom: 0; border-width: 1px 0 0; } .navbar-brand { float: left; padding: 26.5px 15px; font-size: 20px; line-height: 27px; height: 80px; } .navbar-brand:hover, .navbar-brand:focus { text-decoration: none; } .navbar-brand > img { display: block; } @media (min-width: 992px) { .navbar > .container .navbar-brand, .navbar > .container-fluid .navbar-brand { margin-left: -15px; } } .navbar-toggle { position: relative; float: right; margin-right: 15px; padding: 9px 10px; margin-top: 23px; margin-bottom: 23px; background-color: transparent; background-image: none; border: 1px solid transparent; border-radius: 0; } .navbar-toggle:focus { outline: 0; } .navbar-toggle .icon-bar { display: block; width: 22px; height: 2px; border-radius: 1px; } .navbar-toggle .icon-bar + .icon-bar { margin-top: 4px; } @media (min-width: 992px) { .navbar-toggle { display: none; } } .navbar-nav { margin: 13.25px -15px; } .navbar-nav > li > a { padding-top: 10px; padding-bottom: 10px; line-height: 27px; } @media (max-width: 991px) { .navbar-nav .open .dropdown-menu { position: static; float: none; width: auto; margin-top: 0; background-color: transparent; border: 0; box-shadow: none; } .navbar-nav .open .dropdown-menu > li > a, .navbar-nav .open .dropdown-menu .dropdown-header { padding: 5px 15px 5px 25px; } .navbar-nav .open .dropdown-menu > li > a { line-height: 27px; } .navbar-nav .open .dropdown-menu > li > a:hover, .navbar-nav .open .dropdown-menu > li > a:focus { background-image: none; } } @media (min-width: 992px) { .navbar-nav { float: left; margin: 0; } .navbar-nav > li { float: left; } .navbar-nav > li > a { padding-top: 26.5px; padding-bottom: 26.5px; } } .navbar-form { margin-left: -15px; margin-right: -15px; padding: 10px 15px; border-top: 1px solid transparent; border-bottom: 1px solid transparent; -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1); margin-top: 19.5px; margin-bottom: 19.5px; } @media (min-width: 768px) { .navbar-form .form-group { display: inline-block; margin-bottom: 0; vertical-align: middle; } .navbar-form .form-control { display: inline-block; width: auto; vertical-align: middle; } .navbar-form .form-control-static { display: inline-block; } .navbar-form .input-group { display: inline-table; vertical-align: middle; } .navbar-form .input-group .input-group-addon, .navbar-form .input-group .input-group-btn, .navbar-form .input-group .form-control { width: auto; } .navbar-form .input-group > .form-control { width: 100%; } .navbar-form .control-label { margin-bottom: 0; vertical-align: middle; } .navbar-form .radio, .navbar-form .checkbox { display: inline-block; margin-top: 0; margin-bottom: 0; vertical-align: middle; } .navbar-form .radio label, .navbar-form .checkbox label { padding-left: 0; } .navbar-form .radio input[type="radio"], .navbar-form .checkbox input[type="checkbox"] { position: relative; margin-left: 0; } .navbar-form .has-feedback .form-control-feedback { top: 0; } } @media (max-width: 991px) { .navbar-form .form-group { margin-bottom: 5px; } .navbar-form .form-group:last-child { margin-bottom: 0; } } @media (min-width: 992px) { .navbar-form { width: auto; border: 0; margin-left: 0; margin-right: 0; padding-top: 0; padding-bottom: 0; -webkit-box-shadow: none; box-shadow: none; } } .navbar-nav > li > .dropdown-menu { margin-top: 0; border-top-right-radius: 0; border-top-left-radius: 0; } .navbar-fixed-bottom .navbar-nav > li > .dropdown-menu { margin-bottom: 0; border-top-right-radius: 0; border-top-left-radius: 0; border-bottom-right-radius: 0; border-bottom-left-radius: 0; } .navbar-btn { margin-top: 19.5px; margin-bottom: 19.5px; } .navbar-btn.btn-sm { margin-top: 23.5px; margin-bottom: 23.5px; } .navbar-btn.btn-xs { margin-top: 29px; margin-bottom: 29px; } .navbar-text { margin-top: 26.5px; margin-bottom: 26.5px; } @media (min-width: 992px) { .navbar-text { float: left; margin-left: 15px; margin-right: 15px; } } @media (min-width: 992px) { .navbar-left { float: left !important; } .navbar-right { float: right !important; margin-right: -15px; } .navbar-right ~ .navbar-right { margin-right: 0; } } .navbar-default { background-color: transparent; border-color: transparent; } .navbar-default .navbar-brand { color: #ffffff; } .navbar-default .navbar-brand:hover, .navbar-default .navbar-brand:focus { color: rgba(255, 255, 255, 0.7); background-color: transparent; } .navbar-default .navbar-text { color: #ffffff; } .navbar-default .navbar-nav > li > a { color: #ffffff; } .navbar-default .navbar-nav > li > a:hover, .navbar-default .navbar-nav > li > a:focus { color: rgba(255, 255, 255, 0.7); background-color: transparent; } .navbar-default .navbar-nav > .active > a, .navbar-default .navbar-nav > .active > a:hover, .navbar-default .navbar-nav > .active > a:focus { color: rgba(255, 255, 255, 0.7); background-color: transparent; } .navbar-default .navbar-nav > .disabled > a, .navbar-default .navbar-nav > .disabled > a:hover, .navbar-default .navbar-nav > .disabled > a:focus { color: rgba(255, 255, 255, 0.5); background-color: transparent; } .navbar-default .navbar-toggle { border-color: transparent; } .navbar-default .navbar-toggle:hover, .navbar-default .navbar-toggle:focus { background-color: rgba(255, 255, 255, 0.14); } .navbar-default .navbar-toggle .icon-bar { background-color: #ffffff; } .navbar-default .navbar-collapse, .navbar-default .navbar-form { border-color: transparent; } .navbar-default .navbar-nav > .open > a, .navbar-default .navbar-nav > .open > a:hover, .navbar-default .navbar-nav > .open > a:focus { background-color: transparent; color: rgba(255, 255, 255, 0.7); } @media (max-width: 991px) { .navbar-default .navbar-nav .open .dropdown-menu > li > a { color: #ffffff; } .navbar-default .navbar-nav .open .dropdown-menu > li > a:hover, .navbar-default .navbar-nav .open .dropdown-menu > li > a:focus { color: rgba(255, 255, 255, 0.7); background-color: transparent; } .navbar-default .navbar-nav .open .dropdown-menu > .active > a, .navbar-default .navbar-nav .open .dropdown-menu > .active > a:hover, .navbar-default .navbar-nav .open .dropdown-menu > .active > a:focus { color: rgba(255, 255, 255, 0.7); background-color: transparent; } .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a, .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a:hover, .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a:focus { color: rgba(255, 255, 255, 0.5); background-color: transparent; } } .navbar-default .navbar-link { color: #ffffff; } .navbar-default .navbar-link:hover { color: rgba(255, 255, 255, 0.7); } .navbar-default .btn-link { color: #ffffff; } .navbar-default .btn-link:hover, .navbar-default .btn-link:focus { color: rgba(255, 255, 255, 0.7); } .navbar-default .btn-link[disabled]:hover, fieldset[disabled] .navbar-default .btn-link:hover, .navbar-default .btn-link[disabled]:focus, fieldset[disabled] .navbar-default .btn-link:focus { color: rgba(255, 255, 255, 0.5); } .navbar-inverse { background-color: #222222; border-color: #080808; } .navbar-inverse .navbar-brand { color: #9c9c9c; } .navbar-inverse .navbar-brand:hover, .navbar-inverse .navbar-brand:focus { color: #ffffff; background-color: transparent; } .navbar-inverse .navbar-text { color: #9c9c9c; } .navbar-inverse .navbar-nav > li > a { color: #9c9c9c; } .navbar-inverse .navbar-nav > li > a:hover, .navbar-inverse .navbar-nav > li > a:focus { color: #ffffff; background-color: transparent; } .navbar-inverse .navbar-nav > .active > a, .navbar-inverse .navbar-nav > .active > a:hover, .navbar-inverse .navbar-nav > .active > a:focus { color: #ffffff; background-color: #080808; } .navbar-inverse .navbar-nav > .disabled > a, .navbar-inverse .navbar-nav > .disabled > a:hover, .navbar-inverse .navbar-nav > .disabled > a:focus { color: #444444; background-color: transparent; } .navbar-inverse .navbar-toggle { border-color: #333333; } .navbar-inverse .navbar-toggle:hover, .navbar-inverse .navbar-toggle:focus { background-color: #333333; } .navbar-inverse .navbar-toggle .icon-bar { background-color: #ffffff; } .navbar-inverse .navbar-collapse, .navbar-inverse .navbar-form { border-color: #101010; } .navbar-inverse .navbar-nav > .open > a, .navbar-inverse .navbar-nav > .open > a:hover, .navbar-inverse .navbar-nav > .open > a:focus { background-color: #080808; color: #ffffff; } @media (max-width: 991px) { .navbar-inverse .navbar-nav .open .dropdown-menu > .dropdown-header { border-color: #080808; } .navbar-inverse .navbar-nav .open .dropdown-menu .divider { background-color: #080808; } .navbar-inverse .navbar-nav .open .dropdown-menu > li > a { color: #9c9c9c; } .navbar-inverse .navbar-nav .open .dropdown-menu > li > a:hover, .navbar-inverse .navbar-nav .open .dropdown-menu > li > a:focus { color: #ffffff; background-color: transparent; } .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a, .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a:hover, .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a:focus { color: #ffffff; background-color: #080808; } .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a, .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a:hover, .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a:focus { color: #444444; background-color: transparent; } } .navbar-inverse .navbar-link { color: #9c9c9c; } .navbar-inverse .navbar-link:hover { color: #ffffff; } .navbar-inverse .btn-link { color: #9c9c9c; } .navbar-inverse .btn-link:hover, .navbar-inverse .btn-link:focus { color: #ffffff; } .navbar-inverse .btn-link[disabled]:hover, fieldset[disabled] .navbar-inverse .btn-link:hover, .navbar-inverse .btn-link[disabled]:focus, fieldset[disabled] .navbar-inverse .btn-link:focus { color: #444444; } .breadcrumb { padding: 8px 15px; margin-bottom: 27px; list-style: none; background-color: #27313f; border-radius: 0; } .breadcrumb > li { display: inline-block; } .breadcrumb > li + li:before { content: "/\00a0"; padding: 0 5px; color: rgba(30, 37, 47, 0.6); } .breadcrumb > .active { color: rgba(30, 37, 47, 0.6); } .pagination { display: inline-block; padding-left: 0; margin: 27px 0; border-radius: 0; } .pagination > li { display: inline; } .pagination > li > a, .pagination > li > span { position: relative; float: left; padding: 6px 12px; line-height: 1.7; text-decoration: none; color: #2d7ef4; background-color: #ffffff; border: 1px solid #dddddd; margin-left: -1px; } .pagination > li:first-child > a, .pagination > li:first-child > span { margin-left: 0; border-bottom-left-radius: 0; border-top-left-radius: 0; } .pagination > li:last-child > a, .pagination > li:last-child > span { border-bottom-right-radius: 0; border-top-right-radius: 0; } .pagination > li > a:hover, .pagination > li > span:hover, .pagination > li > a:focus, .pagination > li > span:focus { z-index: 2; color: #0b58ca; background-color: #e6e6e6; border-color: #dddddd; } .pagination > .active > a, .pagination > .active > span, .pagination > .active > a:hover, .pagination > .active > span:hover, .pagination > .active > a:focus, .pagination > .active > span:focus { z-index: 3; color: #ffffff; background-color: #2d7ef4; border-color: #2d7ef4; cursor: default; } .pagination > .disabled > span, .pagination > .disabled > span:hover, .pagination > .disabled > span:focus, .pagination > .disabled > a, .pagination > .disabled > a:hover, .pagination > .disabled > a:focus { color: #757575; background-color: #ffffff; border-color: #dddddd; cursor: not-allowed; } .pagination-lg > li > a, .pagination-lg > li > span { padding: 10px 16px; font-size: 20px; line-height: 1.3333333; } .pagination-lg > li:first-child > a, .pagination-lg > li:first-child > span { border-bottom-left-radius: 0; border-top-left-radius: 0; } .pagination-lg > li:last-child > a, .pagination-lg > li:last-child > span { border-bottom-right-radius: 0; border-top-right-radius: 0; } .pagination-sm > li > a, .pagination-sm > li > span { padding: 5px 10px; font-size: 14px; line-height: 1.5; } .pagination-sm > li:first-child > a, .pagination-sm > li:first-child > span { border-bottom-left-radius: 0; border-top-left-radius: 0; } .pagination-sm > li:last-child > a, .pagination-sm > li:last-child > span { border-bottom-right-radius: 0; border-top-right-radius: 0; } .pager { padding-left: 0; margin: 27px 0; list-style: none; text-align: center; } .pager li { display: inline; } .pager li > a, .pager li > span { display: inline-block; padding: 5px 14px; background-color: #ffffff; border: 1px solid #dddddd; border-radius: 15px; } .pager li > a:hover, .pager li > a:focus { text-decoration: none; background-color: #e6e6e6; } .pager .next > a, .pager .next > span { float: right; } .pager .previous > a, .pager .previous > span { float: left; } .pager .disabled > a, .pager .disabled > a:hover, .pager .disabled > a:focus, .pager .disabled > span { color: #757575; background-color: #ffffff; cursor: not-allowed; } .label { display: inline; padding: .2em .6em .3em; font-size: 75%; font-weight: bold; line-height: 1; color: #ffffff; text-align: center; white-space: nowrap; vertical-align: baseline; border-radius: .25em; } a.label:hover, a.label:focus { color: #ffffff; text-decoration: none; cursor: pointer; } .label:empty { display: none; } .btn .label { position: relative; top: -1px; } .label-default { background-color: #90a3bb; } .label-default[href]:hover, .label-default[href]:focus { background-color: #7189a7; } .label-primary { background-color: #2d7ef4; } .label-primary[href]:hover, .label-primary[href]:focus { background-color: #0c63e2; } .label-success { background-color: #24b47e; } .label-success[href]:hover, .label-success[href]:focus { background-color: #1c8a60; } .label-info { background-color: #06a2ff; } .label-info[href]:hover, .label-info[href]:focus { background-color: #0084d2; } .label-warning { background-color: #e39f48; } .label-warning[href]:hover, .label-warning[href]:focus { background-color: #d78721; } .label-danger { background-color: #ff2b67; } .label-danger[href]:hover, .label-danger[href]:focus { background-color: #f70046; } .badge { display: inline-block; min-width: 10px; padding: 3px 7px; font-size: 14px; font-weight: bold; color: #ffffff; line-height: 1; vertical-align: middle; white-space: nowrap; text-align: center; background-color: #757575; border-radius: 10px; } .badge:empty { display: none; } .btn .badge { position: relative; top: -1px; } .btn-xs .badge, .btn-group-xs > .btn .badge { top: 0; padding: 1px 5px; } a.badge:hover, a.badge:focus { color: #ffffff; text-decoration: none; cursor: pointer; } .list-group-item.active > .badge, .nav-pills > .active > a > .badge { color: #2d7ef4; background-color: #ffffff; } .list-group-item > .badge { float: right; } .list-group-item > .badge + .badge { margin-right: 5px; } .nav-pills > li > a > .badge { margin-left: 3px; } .jumbotron { padding-top: 30px; padding-bottom: 30px; margin-bottom: 30px; color: inherit; background-color: rgba(255, 255, 255, 0.1); } .jumbotron h1, .jumbotron .h1 { color: inherit; } .jumbotron p { margin-bottom: 15px; font-size: 24px; font-weight: 200; } .jumbotron > hr { border-top-color: rgba(230, 230, 230, 0.1); } .container .jumbotron, .container-fluid .jumbotron { border-radius: 0; padding-left: 15px; padding-right: 15px; } .jumbotron .container { max-width: 100%; } @media screen and (min-width: 768px) { .jumbotron { padding-top: 48px; padding-bottom: 48px; } .container .jumbotron, .container-fluid .jumbotron { padding-left: 60px; padding-right: 60px; } .jumbotron h1, .jumbotron .h1 { font-size: 72px; } } .thumbnail { display: block; padding: 4px; margin-bottom: 27px; line-height: 1.7; background-color: #1d252f; border: 1px solid #dddddd; border-radius: 0; -webkit-transition: border 0.2s ease-in-out; -o-transition: border 0.2s ease-in-out; transition: border 0.2s ease-in-out; } .thumbnail > img, .thumbnail a > img { margin-left: auto; margin-right: auto; } a.thumbnail:hover, a.thumbnail:focus, a.thumbnail.active { border-color: #2d7ef4; } .thumbnail .caption { padding: 9px; color: #1e252f; } .alert { padding: 15px; margin-bottom: 27px; border: 1px solid transparent; border-radius: 0; } .alert h4 { margin-top: 0; color: inherit; } .alert .alert-link { font-weight: bold; } .alert > p, .alert > ul { margin-bottom: 0; } .alert > p + p { margin-top: 5px; } .alert-dismissable, .alert-dismissible { padding-right: 35px; } .alert-dismissable .close, .alert-dismissible .close { position: relative; top: -2px; right: -21px; color: inherit; } .alert-success { background-color: #e3f1e4; border-color: #bddebf; color: #24b47e; } .alert-success hr { border-top-color: #acd5af; } .alert-success .alert-link { color: #1c8a60; } .alert-info { background-color: #e5f3fa; border-color: #b3dbf1; color: #06a2ff; } .alert-info hr { border-top-color: #9dd1ed; } .alert-info .alert-link { color: #0084d2; } .alert-warning { background-color: #fff4cc; border-color: #ffe071; color: #de8e27; } .alert-warning hr { border-top-color: #ffda58; } .alert-warning .alert-link { color: #b6721c; } .alert-danger { background-color: #f9e9e9; border-color: #f1c9c9; color: #ff2b67; } .alert-danger hr { border-top-color: #ecb5b5; } .alert-danger .alert-link { color: #f70046; } @-webkit-keyframes progress-bar-stripes { from { background-position: 40px 0; } to { background-position: 0 0; } } @keyframes progress-bar-stripes { from { background-position: 40px 0; } to { background-position: 0 0; } } .progress { overflow: hidden; height: 27px; margin-bottom: 27px; background-color: rgba(255, 255, 255, 0.06); border-radius: 0; -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); } .progress-bar { float: left; width: 0%; height: 100%; font-size: 14px; line-height: 27px; color: #ffffff; text-align: center; background-color: #2d7ef4; -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15); box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15); -webkit-transition: width 0.6s ease; -o-transition: width 0.6s ease; transition: width 0.6s ease; } .progress-striped .progress-bar, .progress-bar-striped { background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); background-size: 40px 40px; } .progress.active .progress-bar, .progress-bar.active { -webkit-animation: progress-bar-stripes 2s linear infinite; -o-animation: progress-bar-stripes 2s linear infinite; animation: progress-bar-stripes 2s linear infinite; } .progress-bar-success { background-color: #24b47e; } .progress-striped .progress-bar-success { background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); } .progress-bar-info { background-color: #06a2ff; } .progress-striped .progress-bar-info { background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); } .progress-bar-warning { background-color: #e39f48; } .progress-striped .progress-bar-warning { background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); } .progress-bar-danger { background-color: #ff2b67; } .progress-striped .progress-bar-danger { background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); } .media { margin-top: 15px; } .media:first-child { margin-top: 0; } .media, .media-body { zoom: 1; overflow: hidden; } .media-body { width: 10000px; } .media-object { display: block; } .media-object.img-thumbnail { max-width: none; } .media-right, .media > .pull-right { padding-left: 10px; } .media-left, .media > .pull-left { padding-right: 10px; } .media-left, .media-right, .media-body { display: table-cell; vertical-align: top; } .media-middle { vertical-align: middle; } .media-bottom { vertical-align: bottom; } .media-heading { margin-top: 0; margin-bottom: 5px; } .media-list { padding-left: 0; list-style: none; } .list-group { margin-bottom: 20px; padding-left: 0; } .list-group-item { position: relative; display: block; padding: 10px 15px; margin-bottom: -1px; background-color: transparent; border: 1px solid rgba(255, 255, 255, 0.1); } .list-group-item:first-child { border-top-right-radius: 0; border-top-left-radius: 0; } .list-group-item:last-child { margin-bottom: 0; border-bottom-right-radius: 0; border-bottom-left-radius: 0; } a.list-group-item, button.list-group-item { color: #1e252f; } a.list-group-item .list-group-item-heading, button.list-group-item .list-group-item-heading { color: #1e252f; } a.list-group-item:hover, button.list-group-item:hover, a.list-group-item:focus, button.list-group-item:focus { text-decoration: none; color: #1e252f; background-color: rgba(255, 255, 255, 0.06); } button.list-group-item { width: 100%; text-align: left; } .list-group-item.disabled, .list-group-item.disabled:hover, .list-group-item.disabled:focus { background-color: transparent; color: rgba(255, 255, 255, 0.22); cursor: not-allowed; } .list-group-item.disabled .list-group-item-heading, .list-group-item.disabled:hover .list-group-item-heading, .list-group-item.disabled:focus .list-group-item-heading { color: inherit; } .list-group-item.disabled .list-group-item-text, .list-group-item.disabled:hover .list-group-item-text, .list-group-item.disabled:focus .list-group-item-text { color: rgba(30, 37, 47, 0.6); } .list-group-item.active, .list-group-item.active:hover, .list-group-item.active:focus { z-index: 2; color: #ffffff; background-color: #2d7ef4; border-color: #2d7ef4; } .list-group-item.active .list-group-item-heading, .list-group-item.active:hover .list-group-item-heading, .list-group-item.active:focus .list-group-item-heading, .list-group-item.active .list-group-item-heading > small, .list-group-item.active:hover .list-group-item-heading > small, .list-group-item.active:focus .list-group-item-heading > small, .list-group-item.active .list-group-item-heading > .small, .list-group-item.active:hover .list-group-item-heading > .small, .list-group-item.active:focus .list-group-item-heading > .small { color: inherit; } .list-group-item.active .list-group-item-text, .list-group-item.active:hover .list-group-item-text, .list-group-item.active:focus .list-group-item-text { color: #eff5fe; } .list-group-item-success { color: #24b47e; background-color: #e3f1e4; } a.list-group-item-success, button.list-group-item-success { color: #24b47e; } a.list-group-item-success .list-group-item-heading, button.list-group-item-success .list-group-item-heading { color: inherit; } a.list-group-item-success:hover, button.list-group-item-success:hover, a.list-group-item-success:focus, button.list-group-item-success:focus { color: #24b47e; background-color: #d2e9d4; } a.list-group-item-success.active, button.list-group-item-success.active, a.list-group-item-success.active:hover, button.list-group-item-success.active:hover, a.list-group-item-success.active:focus, button.list-group-item-success.active:focus { color: #fff; background-color: #24b47e; border-color: #24b47e; } .list-group-item-info { color: #06a2ff; background-color: #e5f3fa; } a.list-group-item-info, button.list-group-item-info { color: #06a2ff; } a.list-group-item-info .list-group-item-heading, button.list-group-item-info .list-group-item-heading { color: inherit; } a.list-group-item-info:hover, button.list-group-item-info:hover, a.list-group-item-info:focus, button.list-group-item-info:focus { color: #06a2ff; background-color: #d0e9f6; } a.list-group-item-info.active, button.list-group-item-info.active, a.list-group-item-info.active:hover, button.list-group-item-info.active:hover, a.list-group-item-info.active:focus, button.list-group-item-info.active:focus { color: #fff; background-color: #06a2ff; border-color: #06a2ff; } .list-group-item-warning { color: #de8e27; background-color: #fff4cc; } a.list-group-item-warning, button.list-group-item-warning { color: #de8e27; } a.list-group-item-warning .list-group-item-heading, button.list-group-item-warning .list-group-item-heading { color: inherit; } a.list-group-item-warning:hover, button.list-group-item-warning:hover, a.list-group-item-warning:focus, button.list-group-item-warning:focus { color: #de8e27; background-color: #ffefb3; } a.list-group-item-warning.active, button.list-group-item-warning.active, a.list-group-item-warning.active:hover, button.list-group-item-warning.active:hover, a.list-group-item-warning.active:focus, button.list-group-item-warning.active:focus { color: #fff; background-color: #de8e27; border-color: #de8e27; } .list-group-item-danger { color: #ff2b67; background-color: #f9e9e9; } a.list-group-item-danger, button.list-group-item-danger { color: #ff2b67; } a.list-group-item-danger .list-group-item-heading, button.list-group-item-danger .list-group-item-heading { color: inherit; } a.list-group-item-danger:hover, button.list-group-item-danger:hover, a.list-group-item-danger:focus, button.list-group-item-danger:focus { color: #ff2b67; background-color: #f4d5d5; } a.list-group-item-danger.active, button.list-group-item-danger.active, a.list-group-item-danger.active:hover, button.list-group-item-danger.active:hover, a.list-group-item-danger.active:focus, button.list-group-item-danger.active:focus { color: #fff; background-color: #ff2b67; border-color: #ff2b67; } .list-group-item-heading { margin-top: 0; margin-bottom: 5px; } .list-group-item-text { margin-bottom: 0; line-height: 1.3; } .panel { margin-bottom: 27px; background-color: transparent; border: 1px solid transparent; border-radius: 0; -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05); box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05); } .panel-body { padding: 23px 20px 20px 20px; } .panel-heading { padding: 20px; border-bottom: 1px solid transparent; border-top-right-radius: -1; border-top-left-radius: -1; } .panel-heading > .dropdown .dropdown-toggle { color: inherit; } .panel-title { margin-top: 0; margin-bottom: 0; font-size: 18px; color: inherit; } .panel-title > a, .panel-title > small, .panel-title > .small, .panel-title > small > a, .panel-title > .small > a { color: inherit; } .panel-footer { padding: 20px; background-color: rgba(255, 255, 255, 0.06); border-top: 1px solid rgba(255, 255, 255, 0.1); border-bottom-right-radius: -1; border-bottom-left-radius: -1; } .panel > .list-group, .panel > .panel-collapse > .list-group { margin-bottom: 0; } .panel > .list-group .list-group-item, .panel > .panel-collapse > .list-group .list-group-item { border-width: 1px 0; border-radius: 0; } .panel > .list-group:first-child .list-group-item:first-child, .panel > .panel-collapse > .list-group:first-child .list-group-item:first-child { border-top: 0; border-top-right-radius: -1; border-top-left-radius: -1; } .panel > .list-group:last-child .list-group-item:last-child, .panel > .panel-collapse > .list-group:last-child .list-group-item:last-child { border-bottom: 0; border-bottom-right-radius: -1; border-bottom-left-radius: -1; } .panel > .panel-heading + .panel-collapse > .list-group .list-group-item:first-child { border-top-right-radius: 0; border-top-left-radius: 0; } .panel-heading + .list-group .list-group-item:first-child { border-top-width: 0; } .list-group + .panel-footer { border-top-width: 0; } .panel > .table, .panel > .table-responsive > .table, .panel > .panel-collapse > .table { margin-bottom: 0; } .panel > .table caption, .panel > .table-responsive > .table caption, .panel > .panel-collapse > .table caption { padding-left: 23px 20px 20px 20px; padding-right: 23px 20px 20px 20px; } .panel > .table:first-child, .panel > .table-responsive:first-child > .table:first-child { border-top-right-radius: -1; border-top-left-radius: -1; } .panel > .table:first-child > thead:first-child > tr:first-child, .panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child, .panel > .table:first-child > tbody:first-child > tr:first-child, .panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child { border-top-left-radius: -1; border-top-right-radius: -1; } .panel > .table:first-child > thead:first-child > tr:first-child td:first-child, .panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child td:first-child, .panel > .table:first-child > tbody:first-child > tr:first-child td:first-child, .panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child td:first-child, .panel > .table:first-child > thead:first-child > tr:first-child th:first-child, .panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child th:first-child, .panel > .table:first-child > tbody:first-child > tr:first-child th:first-child, .panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child th:first-child { border-top-left-radius: -1; } .panel > .table:first-child > thead:first-child > tr:first-child td:last-child, .panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child td:last-child, .panel > .table:first-child > tbody:first-child > tr:first-child td:last-child, .panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child td:last-child, .panel > .table:first-child > thead:first-child > tr:first-child th:last-child, .panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child th:last-child, .panel > .table:first-child > tbody:first-child > tr:first-child th:last-child, .panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child th:last-child { border-top-right-radius: -1; } .panel > .table:last-child, .panel > .table-responsive:last-child > .table:last-child { border-bottom-right-radius: -1; border-bottom-left-radius: -1; } .panel > .table:last-child > tbody:last-child > tr:last-child, .panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child, .panel > .table:last-child > tfoot:last-child > tr:last-child, .panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child { border-bottom-left-radius: -1; border-bottom-right-radius: -1; } .panel > .table:last-child > tbody:last-child > tr:last-child td:first-child, .panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child td:first-child, .panel > .table:last-child > tfoot:last-child > tr:last-child td:first-child, .panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child td:first-child, .panel > .table:last-child > tbody:last-child > tr:last-child th:first-child, .panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child th:first-child, .panel > .table:last-child > tfoot:last-child > tr:last-child th:first-child, .panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child th:first-child { border-bottom-left-radius: -1; } .panel > .table:last-child > tbody:last-child > tr:last-child td:last-child, .panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child td:last-child, .panel > .table:last-child > tfoot:last-child > tr:last-child td:last-child, .panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child td:last-child, .panel > .table:last-child > tbody:last-child > tr:last-child th:last-child, .panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child th:last-child, .panel > .table:last-child > tfoot:last-child > tr:last-child th:last-child, .panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child th:last-child { border-bottom-right-radius: -1; } .panel > .panel-body + .table, .panel > .panel-body + .table-responsive, .panel > .table + .panel-body, .panel > .table-responsive + .panel-body { border-top: 1px solid #194c5f; } .panel > .table > tbody:first-child > tr:first-child th, .panel > .table > tbody:first-child > tr:first-child td { border-top: 0; } .panel > .table-bordered, .panel > .table-responsive > .table-bordered { border: 0; } .panel > .table-bordered > thead > tr > th:first-child, .panel > .table-responsive > .table-bordered > thead > tr > th:first-child, .panel > .table-bordered > tbody > tr > th:first-child, .panel > .table-responsive > .table-bordered > tbody > tr > th:first-child, .panel > .table-bordered > tfoot > tr > th:first-child, .panel > .table-responsive > .table-bordered > tfoot > tr > th:first-child, .panel > .table-bordered > thead > tr > td:first-child, .panel > .table-responsive > .table-bordered > thead > tr > td:first-child, .panel > .table-bordered > tbody > tr > td:first-child, .panel > .table-responsive > .table-bordered > tbody > tr > td:first-child, .panel > .table-bordered > tfoot > tr > td:first-child, .panel > .table-responsive > .table-bordered > tfoot > tr > td:first-child { border-left: 0; } .panel > .table-bordered > thead > tr > th:last-child, .panel > .table-responsive > .table-bordered > thead > tr > th:last-child, .panel > .table-bordered > tbody > tr > th:last-child, .panel > .table-responsive > .table-bordered > tbody > tr > th:last-child, .panel > .table-bordered > tfoot > tr > th:last-child, .panel > .table-responsive > .table-bordered > tfoot > tr > th:last-child, .panel > .table-bordered > thead > tr > td:last-child, .panel > .table-responsive > .table-bordered > thead > tr > td:last-child, .panel > .table-bordered > tbody > tr > td:last-child, .panel > .table-responsive > .table-bordered > tbody > tr > td:last-child, .panel > .table-bordered > tfoot > tr > td:last-child, .panel > .table-responsive > .table-bordered > tfoot > tr > td:last-child { border-right: 0; } .panel > .table-bordered > thead > tr:first-child > td, .panel > .table-responsive > .table-bordered > thead > tr:first-child > td, .panel > .table-bordered > tbody > tr:first-child > td, .panel > .table-responsive > .table-bordered > tbody > tr:first-child > td, .panel > .table-bordered > thead > tr:first-child > th, .panel > .table-responsive > .table-bordered > thead > tr:first-child > th, .panel > .table-bordered > tbody > tr:first-child > th, .panel > .table-responsive > .table-bordered > tbody > tr:first-child > th { border-bottom: 0; } .panel > .table-bordered > tbody > tr:last-child > td, .panel > .table-responsive > .table-bordered > tbody > tr:last-child > td, .panel > .table-bordered > tfoot > tr:last-child > td, .panel > .table-responsive > .table-bordered > tfoot > tr:last-child > td, .panel > .table-bordered > tbody > tr:last-child > th, .panel > .table-responsive > .table-bordered > tbody > tr:last-child > th, .panel > .table-bordered > tfoot > tr:last-child > th, .panel > .table-responsive > .table-bordered > tfoot > tr:last-child > th { border-bottom: 0; } .panel > .table-responsive { border: 0; margin-bottom: 0; } .panel-group { margin-bottom: 27px; } .panel-group .panel { margin-bottom: 0; border-radius: 0; } .panel-group .panel + .panel { margin-top: 5px; } .panel-group .panel-heading { border-bottom: 0; } .panel-group .panel-heading + .panel-collapse > .panel-body, .panel-group .panel-heading + .panel-collapse > .list-group { border-top: 1px solid rgba(255, 255, 255, 0.1); } .panel-group .panel-footer { border-top: 0; } .panel-group .panel-footer + .panel-collapse .panel-body { border-bottom: 1px solid rgba(255, 255, 255, 0.1); } .panel-default { border-color: #194c5f; } .panel-default > .panel-heading { color: #1e252f; background-color: #242e3b; border-color: #194c5f; } .panel-default > .panel-heading + .panel-collapse > .panel-body { border-top-color: #194c5f; } .panel-default > .panel-heading .badge { color: #242e3b; background-color: #1e252f; } .panel-default > .panel-footer + .panel-collapse > .panel-body { border-bottom-color: #194c5f; } .panel-primary { border-color: #2d7ef4; } .panel-primary > .panel-heading { color: #ffffff; background-color: #2d7ef4; border-color: #2d7ef4; } .panel-primary > .panel-heading + .panel-collapse > .panel-body { border-top-color: #2d7ef4; } .panel-primary > .panel-heading .badge { color: #2d7ef4; background-color: #ffffff; } .panel-primary > .panel-footer + .panel-collapse > .panel-body { border-bottom-color: #2d7ef4; } .panel-success { border-color: #bddebf; } .panel-success > .panel-heading { color: #24b47e; background-color: #e3f1e4; border-color: #bddebf; } .panel-success > .panel-heading + .panel-collapse > .panel-body { border-top-color: #bddebf; } .panel-success > .panel-heading .badge { color: #e3f1e4; background-color: #24b47e; } .panel-success > .panel-footer + .panel-collapse > .panel-body { border-bottom-color: #bddebf; } .panel-info { border-color: #b3dbf1; } .panel-info > .panel-heading { color: #06a2ff; background-color: #e5f3fa; border-color: #b3dbf1; } .panel-info > .panel-heading + .panel-collapse > .panel-body { border-top-color: #b3dbf1; } .panel-info > .panel-heading .badge { color: #e5f3fa; background-color: #06a2ff; } .panel-info > .panel-footer + .panel-collapse > .panel-body { border-bottom-color: #b3dbf1; } .panel-warning { border-color: #ffe071; } .panel-warning > .panel-heading { color: #de8e27; background-color: #fff4cc; border-color: #ffe071; } .panel-warning > .panel-heading + .panel-collapse > .panel-body { border-top-color: #ffe071; } .panel-warning > .panel-heading .badge { color: #fff4cc; background-color: #de8e27; } .panel-warning > .panel-footer + .panel-collapse > .panel-body { border-bottom-color: #ffe071; } .panel-danger { border-color: #f1c9c9; } .panel-danger > .panel-heading { color: #ff2b67; background-color: #f9e9e9; border-color: #f1c9c9; } .panel-danger > .panel-heading + .panel-collapse > .panel-body { border-top-color: #f1c9c9; } .panel-danger > .panel-heading .badge { color: #f9e9e9; background-color: #ff2b67; } .panel-danger > .panel-footer + .panel-collapse > .panel-body { border-bottom-color: #f1c9c9; } .embed-responsive { position: relative; display: block; height: 0; padding: 0; overflow: hidden; } .embed-responsive .embed-responsive-item, .embed-responsive iframe, .embed-responsive embed, .embed-responsive object, .embed-responsive video { position: absolute; top: 0; left: 0; bottom: 0; height: 100%; width: 100%; border: 0; } .embed-responsive-16by9 { padding-bottom: 56.25%; } .embed-responsive-4by3 { padding-bottom: 75%; } .well { min-height: 20px; padding: 19px; margin-bottom: 20px; background-color: #242e3b; border: 1px solid transparent; border-radius: 0; -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05); box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05); } .well blockquote { border-color: #ddd; border-color: rgba(0, 0, 0, 0.15); } .well-lg { padding: 24px; border-radius: 0; } .well-sm { padding: 9px; border-radius: 0; } .close { float: right; font-size: 24px; font-weight: bold; line-height: 1; color: #ffffff; text-shadow: 0 1px 0 #1d252f; opacity: 0.2; filter: alpha(opacity=20); } .close:hover, .close:focus { color: #ffffff; text-decoration: none; cursor: pointer; opacity: 0.5; filter: alpha(opacity=50); } button.close { padding: 0; cursor: pointer; background: transparent; border: 0; -webkit-appearance: none; } .modal-open { overflow: hidden; } .modal { display: none; overflow: hidden; position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 1050; -webkit-overflow-scrolling: touch; outline: 0; } .modal.fade .modal-dialog { -webkit-transform: translate(0, -25%); -ms-transform: translate(0, -25%); -o-transform: translate(0, -25%); transform: translate(0, -25%); -webkit-transition: -webkit-transform 0.3s ease-out; -moz-transition: -moz-transform 0.3s ease-out; -o-transition: -o-transform 0.3s ease-out; transition: transform 0.3s ease-out; } .modal.in .modal-dialog { -webkit-transform: translate(0, 0); -ms-transform: translate(0, 0); -o-transform: translate(0, 0); transform: translate(0, 0); } .modal-open .modal { overflow-x: hidden; overflow-y: auto; } .modal-dialog { position: relative; width: auto; margin: 10px; } .modal-content { position: relative; background-color: #1d252f; border: 1px solid #999999; border: 1px solid transparent; border-radius: 0; -webkit-box-shadow: 0 3px 9px rgba(0, 0, 0, 0.5); box-shadow: 0 3px 9px rgba(0, 0, 0, 0.5); background-clip: padding-box; outline: 0; } .modal-backdrop { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 1040; background-color: #2d7ef4; } .modal-backdrop.fade { opacity: 0; filter: alpha(opacity=0); } .modal-backdrop.in { opacity: 0.4; filter: alpha(opacity=40); } .modal-header { padding: 15px 20px 11px 20px; border-bottom: 1px solid transparent; } .modal-header .close { margin-top: -2px; } .modal-title { margin: 0; line-height: 1.7; } .modal-body { position: relative; padding: 15px 20px; } .modal-footer { padding: 15px 20px; text-align: right; border-top: 1px solid transparent; } .modal-footer .btn + .btn { margin-left: 5px; margin-bottom: 0; } .modal-footer .btn-group .btn + .btn { margin-left: -1px; } .modal-footer .btn-block + .btn-block { margin-left: 0; } .modal-scrollbar-measure { position: absolute; top: -9999px; width: 50px; height: 50px; overflow: scroll; } @media (min-width: 768px) { .modal-dialog { width: 768px; margin: 30px auto; } .modal-content { -webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5); box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5); } .modal-sm { width: 300px; } } @media (min-width: 992px) { .modal-lg { width: 900px; } } .tooltip { position: absolute; z-index: 1070; display: block; font-family: 'Source Sans Pro', sans-serif; font-style: normal; font-weight: normal; letter-spacing: normal; line-break: auto; line-height: 1.7; text-align: left; text-align: start; text-decoration: none; text-shadow: none; text-transform: none; white-space: normal; word-break: normal; word-spacing: normal; word-wrap: normal; font-size: 14px; opacity: 0; filter: alpha(opacity=0); } .tooltip.in { opacity: 0.9; filter: alpha(opacity=90); } .tooltip.top { margin-top: -3px; padding: 5px 0; } .tooltip.right { margin-left: 3px; padding: 0 5px; } .tooltip.bottom { margin-top: 3px; padding: 5px 0; } .tooltip.left { margin-left: -3px; padding: 0 5px; } .tooltip-inner { max-width: 200px; padding: 3px 8px; color: #ffffff; text-align: center; background-color: #000000; border-radius: 0; } .tooltip-arrow { position: absolute; width: 0; height: 0; border-color: transparent; border-style: solid; } .tooltip.top .tooltip-arrow { bottom: 0; left: 50%; margin-left: -5px; border-width: 5px 5px 0; border-top-color: #000000; } .tooltip.top-left .tooltip-arrow { bottom: 0; right: 5px; margin-bottom: -5px; border-width: 5px 5px 0; border-top-color: #000000; } .tooltip.top-right .tooltip-arrow { bottom: 0; left: 5px; margin-bottom: -5px; border-width: 5px 5px 0; border-top-color: #000000; } .tooltip.right .tooltip-arrow { top: 50%; left: 0; margin-top: -5px; border-width: 5px 5px 5px 0; border-right-color: #000000; } .tooltip.left .tooltip-arrow { top: 50%; right: 0; margin-top: -5px; border-width: 5px 0 5px 5px; border-left-color: #000000; } .tooltip.bottom .tooltip-arrow { top: 0; left: 50%; margin-left: -5px; border-width: 0 5px 5px; border-bottom-color: #000000; } .tooltip.bottom-left .tooltip-arrow { top: 0; right: 5px; margin-top: -5px; border-width: 0 5px 5px; border-bottom-color: #000000; } .tooltip.bottom-right .tooltip-arrow { top: 0; left: 5px; margin-top: -5px; border-width: 0 5px 5px; border-bottom-color: #000000; } .popover { position: absolute; top: 0; left: 0; z-index: 1060; display: none; max-width: 276px; padding: 1px; font-family: 'Source Sans Pro', sans-serif; font-style: normal; font-weight: normal; letter-spacing: normal; line-break: auto; line-height: 1.7; text-align: left; text-align: start; text-decoration: none; text-shadow: none; text-transform: none; white-space: normal; word-break: normal; word-spacing: normal; word-wrap: normal; font-size: 16px; background-color: #ffffff; background-clip: padding-box; border: 1px solid #cccccc; border: 1px solid rgba(0, 0, 0, 0.2); border-radius: 0; -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); } .popover.top { margin-top: -10px; } .popover.right { margin-left: 10px; } .popover.bottom { margin-top: 10px; } .popover.left { margin-left: -10px; } .popover-title { margin: 0; padding: 8px 14px; font-size: 16px; background-color: #f7f7f7; border-bottom: 1px solid #ebebeb; border-radius: -1 -1 0 0; } .popover-content { padding: 9px 14px; } .popover > .arrow, .popover > .arrow:after { position: absolute; display: block; width: 0; height: 0; border-color: transparent; border-style: solid; } .popover > .arrow { border-width: 11px; } .popover > .arrow:after { border-width: 10px; content: ""; } .popover.top > .arrow { left: 50%; margin-left: -11px; border-bottom-width: 0; border-top-color: #999999; border-top-color: rgba(0, 0, 0, 0.25); bottom: -11px; } .popover.top > .arrow:after { content: " "; bottom: 1px; margin-left: -10px; border-bottom-width: 0; border-top-color: #ffffff; } .popover.right > .arrow { top: 50%; left: -11px; margin-top: -11px; border-left-width: 0; border-right-color: #999999; border-right-color: rgba(0, 0, 0, 0.25); } .popover.right > .arrow:after { content: " "; left: 1px; bottom: -10px; border-left-width: 0; border-right-color: #ffffff; } .popover.bottom > .arrow { left: 50%; margin-left: -11px; border-top-width: 0; border-bottom-color: #999999; border-bottom-color: rgba(0, 0, 0, 0.25); top: -11px; } .popover.bottom > .arrow:after { content: " "; top: 1px; margin-left: -10px; border-top-width: 0; border-bottom-color: #ffffff; } .popover.left > .arrow { top: 50%; right: -11px; margin-top: -11px; border-right-width: 0; border-left-color: #999999; border-left-color: rgba(0, 0, 0, 0.25); } .popover.left > .arrow:after { content: " "; right: 1px; border-right-width: 0; border-left-color: #ffffff; bottom: -10px; } .carousel { position: relative; } .carousel-inner { position: relative; overflow: hidden; width: 100%; } .carousel-inner > .item { display: none; position: relative; -webkit-transition: 0.6s ease-in-out left; -o-transition: 0.6s ease-in-out left; transition: 0.6s ease-in-out left; } .carousel-inner > .item > img, .carousel-inner > .item > a > img { line-height: 1; } @media all and (transform-3d), (-webkit-transform-3d) { .carousel-inner > .item { -webkit-transition: -webkit-transform 0.6s ease-in-out; -moz-transition: -moz-transform 0.6s ease-in-out; -o-transition: -o-transform 0.6s ease-in-out; transition: transform 0.6s ease-in-out; -webkit-backface-visibility: hidden; -moz-backface-visibility: hidden; backface-visibility: hidden; -webkit-perspective: 1000px; -moz-perspective: 1000px; perspective: 1000px; } .carousel-inner > .item.next, .carousel-inner > .item.active.right { -webkit-transform: translate3d(100%, 0, 0); transform: translate3d(100%, 0, 0); left: 0; } .carousel-inner > .item.prev, .carousel-inner > .item.active.left { -webkit-transform: translate3d(-100%, 0, 0); transform: translate3d(-100%, 0, 0); left: 0; } .carousel-inner > .item.next.left, .carousel-inner > .item.prev.right, .carousel-inner > .item.active { -webkit-transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0); left: 0; } } .carousel-inner > .active, .carousel-inner > .next, .carousel-inner > .prev { display: block; } .carousel-inner > .active { left: 0; } .carousel-inner > .next, .carousel-inner > .prev { position: absolute; top: 0; width: 100%; } .carousel-inner > .next { left: 100%; } .carousel-inner > .prev { left: -100%; } .carousel-inner > .next.left, .carousel-inner > .prev.right { left: 0; } .carousel-inner > .active.left { left: -100%; } .carousel-inner > .active.right { left: 100%; } .carousel-control { position: absolute; top: 0; left: 0; bottom: 0; width: 15%; opacity: 0.5; filter: alpha(opacity=50); font-size: 20px; color: #ffffff; text-align: center; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); background-color: rgba(0, 0, 0, 0); } .carousel-control.left { background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.0001) 100%); background-image: -o-linear-gradient(left, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.0001) 100%); background-image: linear-gradient(to right, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.0001) 100%); background-repeat: repeat-x; filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1); } .carousel-control.right { left: auto; right: 0; background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, 0.0001) 0%, rgba(0, 0, 0, 0.5) 100%); background-image: -o-linear-gradient(left, rgba(0, 0, 0, 0.0001) 0%, rgba(0, 0, 0, 0.5) 100%); background-image: linear-gradient(to right, rgba(0, 0, 0, 0.0001) 0%, rgba(0, 0, 0, 0.5) 100%); background-repeat: repeat-x; filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1); } .carousel-control:hover, .carousel-control:focus { outline: 0; color: #ffffff; text-decoration: none; opacity: 0.9; filter: alpha(opacity=90); } .carousel-control .icon-prev, .carousel-control .icon-next, .carousel-control .glyphicon-chevron-left, .carousel-control .glyphicon-chevron-right { position: absolute; top: 50%; margin-top: -10px; z-index: 5; display: inline-block; } .carousel-control .icon-prev, .carousel-control .glyphicon-chevron-left { left: 50%; margin-left: -10px; } .carousel-control .icon-next, .carousel-control .glyphicon-chevron-right { right: 50%; margin-right: -10px; } .carousel-control .icon-prev, .carousel-control .icon-next { width: 20px; height: 20px; line-height: 1; font-family: serif; } .carousel-control .icon-prev:before { content: '\2039'; } .carousel-control .icon-next:before { content: '\203a'; } .carousel-indicators { position: absolute; bottom: 10px; left: 50%; z-index: 15; width: 60%; margin-left: -30%; padding-left: 0; list-style: none; text-align: center; } .carousel-indicators li { display: inline-block; width: 10px; height: 10px; margin: 1px; text-indent: -999px; border: 1px solid #ffffff; border-radius: 10px; cursor: pointer; background-color: #000 \9; background-color: rgba(0, 0, 0, 0); } .carousel-indicators .active { margin: 0; width: 12px; height: 12px; background-color: #ffffff; } .carousel-caption { position: absolute; left: 15%; right: 15%; bottom: 20px; z-index: 10; padding-top: 20px; padding-bottom: 20px; color: #ffffff; text-align: center; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); } .carousel-caption .btn { text-shadow: none; } @media screen and (min-width: 768px) { .carousel-control .glyphicon-chevron-left, .carousel-control .glyphicon-chevron-right, .carousel-control .icon-prev, .carousel-control .icon-next { width: 30px; height: 30px; margin-top: -10px; font-size: 30px; } .carousel-control .glyphicon-chevron-left, .carousel-control .icon-prev { margin-left: -10px; } .carousel-control .glyphicon-chevron-right, .carousel-control .icon-next { margin-right: -10px; } .carousel-caption { left: 20%; right: 20%; padding-bottom: 30px; } .carousel-indicators { bottom: 20px; } } .clearfix:before, .clearfix:after, .dl-horizontal dd:before, .dl-horizontal dd:after, .container:before, .container:after, .container-fluid:before, .container-fluid:after, .row:before, .row:after, .form-horizontal .form-group:before, .form-horizontal .form-group:after, .btn-toolbar:before, .btn-toolbar:after, .btn-group-vertical > .btn-group:before, .btn-group-vertical > .btn-group:after, .nav:before, .nav:after, .navbar:before, .navbar:after, .navbar-header:before, .navbar-header:after, .navbar-collapse:before, .navbar-collapse:after, .pager:before, .pager:after, .panel-body:before, .panel-body:after, .modal-header:before, .modal-header:after, .modal-footer:before, .modal-footer:after { content: " "; display: table; } .clearfix:after, .dl-horizontal dd:after, .container:after, .container-fluid:after, .row:after, .form-horizontal .form-group:after, .btn-toolbar:after, .btn-group-vertical > .btn-group:after, .nav:after, .navbar:after, .navbar-header:after, .navbar-collapse:after, .pager:after, .panel-body:after, .modal-header:after, .modal-footer:after { clear: both; } .center-block { display: block; margin-left: auto; margin-right: auto; } .pull-right { float: right !important; } .pull-left { float: left !important; } .hide { display: none !important; } .show { display: block !important; } .invisible { visibility: hidden; } .text-hide { font: 0/0 a; color: transparent; text-shadow: none; background-color: transparent; border: 0; } .hidden { display: none !important; } .affix { position: fixed; } @-ms-viewport { width: device-width; } .visible-xs, .visible-sm, .visible-md, .visible-lg { display: none !important; } .visible-xs-block, .visible-xs-inline, .visible-xs-inline-block, .visible-sm-block, .visible-sm-inline, .visible-sm-inline-block, .visible-md-block, .visible-md-inline, .visible-md-inline-block, .visible-lg-block, .visible-lg-inline, .visible-lg-inline-block { display: none !important; } @media (max-width: 767px) { .visible-xs { display: block !important; } table.visible-xs { display: table !important; } tr.visible-xs { display: table-row !important; } th.visible-xs, td.visible-xs { display: table-cell !important; } } @media (max-width: 767px) { .visible-xs-block { display: block !important; } } @media (max-width: 767px) { .visible-xs-inline { display: inline !important; } } @media (max-width: 767px) { .visible-xs-inline-block { display: inline-block !important; } } @media (min-width: 768px) and (max-width: 991px) { .visible-sm { display: block !important; } table.visible-sm { display: table !important; } tr.visible-sm { display: table-row !important; } th.visible-sm, td.visible-sm { display: table-cell !important; } } @media (min-width: 768px) and (max-width: 991px) { .visible-sm-block { display: block !important; } } @media (min-width: 768px) and (max-width: 991px) { .visible-sm-inline { display: inline !important; } } @media (min-width: 768px) and (max-width: 991px) { .visible-sm-inline-block { display: inline-block !important; } } @media (min-width: 992px) and (max-width: 991px) { .visible-md { display: block !important; } table.visible-md { display: table !important; } tr.visible-md { display: table-row !important; } th.visible-md, td.visible-md { display: table-cell !important; } } @media (min-width: 992px) and (max-width: 991px) { .visible-md-block { display: block !important; } } @media (min-width: 992px) and (max-width: 991px) { .visible-md-inline { display: inline !important; } } @media (min-width: 992px) and (max-width: 991px) { .visible-md-inline-block { display: inline-block !important; } } @media (min-width: 992px) { .visible-lg { display: block !important; } table.visible-lg { display: table !important; } tr.visible-lg { display: table-row !important; } th.visible-lg, td.visible-lg { display: table-cell !important; } } @media (min-width: 992px) { .visible-lg-block { display: block !important; } } @media (min-width: 992px) { .visible-lg-inline { display: inline !important; } } @media (min-width: 992px) { .visible-lg-inline-block { display: inline-block !important; } } @media (max-width: 767px) { .hidden-xs { display: none !important; } } @media (min-width: 768px) and (max-width: 991px) { .hidden-sm { display: none !important; } } @media (min-width: 992px) and (max-width: 991px) { .hidden-md { display: none !important; } } @media (min-width: 992px) { .hidden-lg { display: none !important; } } .visible-print { display: none !important; } @media print { .visible-print { display: block !important; } table.visible-print { display: table !important; } tr.visible-print { display: table-row !important; } th.visible-print, td.visible-print { display: table-cell !important; } } .visible-print-block { display: none !important; } @media print { .visible-print-block { display: block !important; } } .visible-print-inline { display: none !important; } @media print { .visible-print-inline { display: inline !important; } } .visible-print-inline-block { display: none !important; } @media print { .visible-print-inline-block { display: inline-block !important; } } @media print { .hidden-print { display: none !important; } } ================================================ FILE: docs/assets/css/collection_browser.scss ================================================ .collection-browser-doc.subpage .subpage__header { padding-top: 5px; padding-bottom: 5px; overflow: hidden; &.subpage__header--min { overflow: visible; .header-shapes-top { position: absolute; top: -20px; width: 80%; max-width: 800px; left: auto; right: 510px; @media all and (max-width: 1200px) { right: 410px; } @media all and (max-width: 991px) { right: auto; width: 600px; top: -5px; left: 50%; margin-left: -300px; } @media all and (max-width: 767px) { display: none; } } } } .container-cb-lg { max-width: 1600px; margin: auto; padding-left: 15px; padding-right: 15px; .col-md-2-5 { width: 24.9%; max-width: 400px; } #toc { font-family: 'Source Sans Pro', sans-serif; max-width: 350px; } } @media all and (min-width: 1200px) { .collection-browser .container-cb-lg .cb-doc-content, .collection-browser-doc .container-cb-lg .cb-doc-content { margin-left: 0; } } @media all and (max-width: 991px) { .collection-browser .container-cb-lg .col-md-2-5, .collection-browser-doc .container-cb-lg .col-md-2-5 { max-width: 100vw; padding-left: 0px; padding-right: 0px; } .collection-browser .container-cb-lg .col-md-2-5 #toc, .collection-browser-doc .container-cb-lg .col-md-2-5 #toc { padding-left: 15px; padding-right: 15px; width: 100%; max-width: 992px; top: 0; } .collection-browser .container-cb-lg .col-md-2-5 #toc.fixed, .collection-browser-doc .container-cb-lg .col-md-2-5 #toc.fixed { position: static; } } .cb-doc-listing { max-width: 800px; margin-left: auto; margin-right: auto; @media all and (min-width: 1200px) { & { max-width: 800px; margin-left: 0; margin-right: auto; } } } .collection-browser-doc { h1 { margin: 20px auto; font-size: 35px; line-height: normal; font-weight: normal; text-align: center; color: #1e252f; } .cb-doc-content { max-width: 800px; margin-left: auto; margin-right: auto; img { max-width: 100%; } } .cb-doc-detail { margin-bottom: 150px; .cb-doc-header { margin-bottom: 50px; } h2 { margin-top: 50px; margin-bottom: 20px; border-bottom: solid 2px #5b4de5; font-weight: bold; font-size: 28px; &:first-child { margin-top: 0px; } } h3 { margin-top: 50px; font-weight: normal; font-size: 28px; } h4 { margin-top: 50px; font-weight: normal; font-size: 22px; } p, .listingblock { margin-bottom: 20px; } } .cb-doc-title-image { padding: 0px; max-height: 60px; max-width: 130px; margin: 30px auto; } .cb-doc-title { color: #000000; font-size: 35px; line-height: normal; text-align: center; max-width: 760px; margin: 0 auto; p { font-weight: 600; margin-bottom: 0px; opacity: 0.5; font-size: 14px; letter-spacing: 0.39px; } h1, h2, h3, h4, h5, h6 { color: #1e252f; } } .breadcrumb { font-size: 14px; margin-bottom: 0; letter-spacing: -0.2px; background-color: white; font-weight: normal; margin-top: 30px; > a { color: #5b4de5; text-decoration: none; &:last-child span { border-bottom: 1px solid #5b4de5; text-shadow: 0.5px 0 0; &:hover { border-bottom: 2px solid #5b4de5; text-shadow: 0.5px 0 0; } } &:first-child { &:hover, &:active { border-bottom: 2px solid #5b4de5; text-shadow: 0.5px 0 0; } } span { &:hover, &:active { border-bottom: 2px solid #5b4de5; text-shadow: 0.5px 0 0; } } } } .imageblock { margin-bottom: 30px; .title { text-align: center; color: #bbbdc0; } } } #toc.cb-doc-sidebar { padding: 15px 5px; padding-top: 20px; font-size: 14px; a { color: #bbbdc0; } .nav-doc-collection-link { border-bottom: 1px solid #1e252f; width: 100%; padding: 5px 0; text-transform: capitalize; font-weight: bold; color: #1e252f; a { text-transform: capitalize; font-weight: bold; color: #1e252f; } } .arrow-icon { font-size: 13px; line-height: 13px; padding-right: 4px; padding-left: 2px; margin-right: 5px; } ul { list-style: none; padding-left: 15px; li:last-child { border-bottom-width: 0px; } } > ul { padding: 5px 0 5px 15px; > li { a { text-transform: none; font-size: 14px; color: #bbbdc0; } border-bottom: 1px solid #bbbdc0; > ul > li { border-bottom: 1px solid #bbbdc0; } } padding-left: 0px; } .arrow-icon { color: #bbbdc0; } .no-collapsable .arrow-icon { font-size: 10px; } [data-toggle="collapse"] { .arrow-icon, + a { color: #5b4de5; } &.collapsed { .arrow-icon { color: #bbbdc0; -webkit-transform: rotate(-90deg); -moz-transform: rotate(-90deg); -o-transform: rotate(-90deg); -ms-transform: rotate(-90deg); transform: rotate(-90deg); } + a { color: #bbbdc0; } } &.active { .arrow-icon { color: #5b4de5; } + a { color: #5b4de5; } } } } .cb-input-placeholder { font-size: 14px; font-style: normal; opacity: 1 !important; font-weight: 600; letter-spacing: -0.23px; color: #0000; } .remove-visibility { display: none; clear: both; } .no-padding { padding: 0 !important; margin: 0 !important; } .no-transform { transform: none; box-shadow: none; outline: none !important; } .active-button { background-color: #fff !important; position: relative; border-radius: 8px !important; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); cursor: pointer; border: 1px solid #dedede !important; outline-color: #fff; } .cb-outline { padding: 0; a { color: #999999; &:active, &:hover { text-decoration: none; color: #06a6fd; } } ul { font-size: 16px; font-weight: bold; letter-spacing: -0.2px; } } .col-md-2-5 { position: relative; min-height: 1px; padding-right: 15px; padding-left: 15px; max-height: 100vh; overflow-y: auto; width: 25%; } @media (min-width: 992px) { .col-md-2-5 { float: left; width: 20.66666667%; } } @media all and (max-width: 991px) { .collection-browser .col-md-2-5, .collection-browser-doc .col-md-2-5 { width: 100vw; height: 100vh; background: #fff; position: fixed; left: -120vw; top: 0; z-index: 100; transition: all 0.2s ease; overflow: initial; } .collection-browser .col-md-2-5 #toc.cd-doc-sidebar.fixed, .collection-browser-doc .col-md-2-5 #toc.cd-doc-sidebar.fixed { position: static; } .collection-browser .col-md-2-5.opened, .collection-browser-doc .col-md-2-5.opened { left: 0; } .collection-browser .col-md-2-5.opened #toc-toggle-open, .collection-browser-doc .col-md-2-5.opened #toc-toggle-open { display: none; } .collection-browser .col-md-2-5 #toc.cb-doc-sidebar > ul > li a, .collection-browser-doc .col-md-2-5 #toc.cb-doc-sidebar > ul > li a { font-size: 18px; } .collection-browser .col-md-2-5 #toc.cd-doc-sidebar > ul > li .arrow-icon, .collection-browser-doc .col-md-2-5 #toc.cd-doc-sidebar > ul > li .arrow-icon { font-size: 15px; line-height: 15px; } } .toc-toggle { width: 100%; padding: 5px; .toc-toggle__button { background: #fff; width: 40px; height: 40px; border-radius: 50%; border: 1px solid #bbbdc0; display: flex; align-items: center; justify-content: center; &:hover { cursor: pointer; color: #5b4de5; } } #toc-toggle-open { position: fixed; bottom: 15px; left: 15px; z-index: 10; } #toc-toggle-close { margin-right: 0; margin-left: auto; position: relative; z-index: 101; } } .collection-browser { .cb-doc-listing h3 { max-width: 90%; margin-left: auto; margin-right: auto; margin-bottom: 10px; } .cb-doc-card { max-width: 90%; margin-left: auto; margin-right: auto; } #toc { max-height: 100vh; overflow-y: auto; ul { margin-top: 0; } } } #toc { padding: 0; &.fixed { width: inherit; padding-right: 16px !important; } a { font-weight: normal; text-transform: uppercase; color: #1e252f; color: #999999; &:active, &:hover { text-decoration: none; color: #06a6fd; } } ul { font-size: 16px; font-weight: bold; letter-spacing: -0.2px; } .section-nav, .sectlevel1 { display: block; z-index: 2; padding: 30px 5px; overflow-y: auto; max-height: 100vh; overflow-wrap: break-word; } .section-nav li, .sectlevel1 li { list-style-type: none; } .section-nav li:hover, .sectlevel1 li:hover { cursor: pointer; } .sectlevel2 { padding-left: 20px !important; li { line-height: 16px; margin-bottom: 8px; } } a { &.selected, &:hover, &:active { color: #5b4de5 !important; text-decoration: none; font-weight: bold; } } } @media (max-width: 991px) { #toc { .section-nav, .sectlevel1 { display: none; clear: both; } } } body .cb .cb-section-white { font-family: 'Open Sans', sans-serif; color: #1e252f; } .cb-doc-listing { .category-head { font-size: 30px; font-weight: 600; line-height: normal; letter-spacing: 0.84px; color: #1e252f; margin-top: 40px; margin-bottom: 11px; &:first-of-type { margin-top: 1em; } &:hover > a.anchorjs-link { text-decoration: none; display: none; } } .card { color: #1e252f; cursor: pointer; min-height: 100px; overflow: hidden; position: relative; display: flex; align-items: center; & > .row { width: 100%; } .card-title { margin-top: 0; margin-bottom: 8px; font-size: 20px; letter-spacing: 0.56px; &:hover > a.anchorjs-link { color: #FFFFFF; text-decoration: none; display: none; } } a.card-title__link:hover { color: $primary-color; a, b, h5 { color: $primary-color; } } a { text-decoration: none; } .card-body { padding: 25px 30px 25px 30px; } .img-inner { position: absolute; top: 50%; -ms-transform: translateY(-50%); transform: translateY(-50%); } img { max-width: 100%; display: block; margin: auto auto; padding: 11px 20px 11px 30px; max-height: 85px; } p { font-size: 13px; font-weight: inherit; line-height: 1.5; letter-spacing: 0.34px; margin: 0; } h6, h5, h4, h3, h2, a { color: #1e252f; } code { color: #c7254e !important; } } .cb-doc-card { margin-bottom: 15px; border: solid 1px #dedede; border-radius: 10px; transition: 0.2s all ease; } .float-right { float: right; } .card-shadow:hover { -webkit-box-shadow: 0 5px 20px 0 rgba(0, 0, 0, 0.1); /* Safari, Android, iOS */ -moz-box-shadow: 0 5px 20px 0 rgba(0, 0, 0, 0.1); /* Firefox */ box-shadow: 0 5px 20px 0 rgba(0, 0, 0, 0.1); border: none; transform: scale(1.1) translateZ(0); } } @media all and (max-width: 768px) { .cb-doc-listing .card .card-body { padding: 10px 15px 10px 15px; } } .cb .section-hero { .container { margin-bottom: 20px; } h1 { font-size: 40px; letter-spacing: 1.13px; margin-bottom: 11px; } p { font-size: 20px; font-weight: inherit; line-height: 1.5; letter-spacing: 0.56px; } } .section-hero-blue { height: 409px; color: #fff; label { margin-bottom: 29px; font-size: 35px; line-height: normal; letter-spacing: 0.98px; text-align: center; color: #ffffff; } img { margin: -1%; position: relative; height: 231px; width: 112px; margin-bottom: 23px; } a { padding: 14px 39px; margin-bottom: 49px; color: #08a9fb; font-size: 14px; background-color: white; &:active, &:hover { color: #08a9fb; cursor: pointer; transform: none; box-shadow: none; outline: none !important; } } } .searchrow { margin-top: 20px; padding: 0; .input-group { width: 100%; padding-right: 19px; z-index: 0; } button { border-bottom-left-radius: 8px; border-top-left-radius: 8px; min-height: 56px; height: 56px; &:hover, &:focus { transform: none; box-shadow: none; outline: none !important; } } input { background: #f5f9fa; border-top-right-radius: 8px; border-bottom-right-radius: 8px; min-height: 56px; height: 56px; border: none; color: #1e252f; transform: none; box-shadow: none; outline: none !important; &:active, &:focus { transform: none; box-shadow: none; outline: none !important; } } } .cb-input-box { &::-webkit-input-placeholder, &::-moz-placeholder, &::-ms-placeholder, &::placeholder { font-size: 14px; font-style: normal; opacity: 1 !important; font-weight: 600; letter-spacing: -0.23px; color: #0000; } } .cloud-filter { display: flex; justify-content: center; margin-bottom: 11px; .filter { width: 109px; height: 76px; cursor: pointer; float: left; border-radius: 8px; border: none; background-color: #f5f9fa; text-align: center; &:hover, &:active { background-color: #fff !important; position: relative; border-radius: 8px !important; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); cursor: pointer; border: 1px solid #dedede !important; outline-color: #fff; } img { vertical-align: middle; max-height: 55px; padding: 8px; } .helper { display: inline-block; height: 100%; vertical-align: middle; } &:nth-last-of-type(2) { border-radius: 0; } &:last-child { border-top-left-radius: 0; border-bottom-left-radius: 0; } &:first-child { border-top-right-radius: 0; border-bottom-right-radius: 0; } } } .no-azure-results { display: none; h2 { color: #1e252f; font-size: 30px; line-height: normal; letter-spacing: 0.84px; margin-bottom: 8px; margin-top: 91px; &:hover > a.anchorjs-link { color: #FFFFFF; text-decoration: none; display: none; } } p { font-size: 20px; opacity: 0.7; line-height: 1.5; letter-spacing: 0.56px; color: #000000; margin-bottom: 24px; } .btn { border-radius: 23.5px; background-image: linear-gradient(to bottom, #5cbde7, #569cea); &:hover, &:active { outline: none; transform: none; box-shadow: none; border-radius: 23.5px; background-image: linear-gradient(to bottom, #5cbde7, #569cea); } } } #tags-filter { display: flex; justify-content: flex-end; button { border-radius: 8px; background: rgba(0, 185, 248, 0.2); padding: 0 15px; box-shadow: none; border: none; font-size: 18px; font-weight: 600; letter-spacing: 0.51px; text-align: center; color: #06a6fd; &:hover, &:focus, &:active { transform: none; box-shadow: none; outline: none !important; background: rgba(0, 185, 248, 0.1); color: #06a6fd; } } } .filter-options { padding: 0; margin: 16px; .card { padding: 30px 109px; border-radius: 8px; background-color: #f5f9fa; min-height: 193px; max-height: 423px; .tags { display: flex; flex-wrap: wrap; flex-direction: column; max-height: 333px; } .checkbox { padding-left: 20px; position: relative; display: block; margin-bottom: 0; input { opacity: 0; position: absolute; z-index: 1; cursor: pointer; margin-left: -20px; &:checked + label { &::before { border: 2px solid #06a6fd; } &::after { content: ''; display: inline-block; position: absolute; width: 13px; height: 13px; left: 2px; top: 4px; margin-left: -18px; border: 2px solid #06a6fd; border-radius: 2px; background-color: #06a6fd; } } } label { display: inline-block; position: relative; padding-left: 10px; &::before { content: ''; display: inline-block; position: absolute; width: 18px; height: 18px; border-radius: 2px; border: 2px solid #939596; background-color: #fff; left: -18px; } } + .checkbox { margin-top: 10px !important; } input:checked + label { &::before { background-color: #06a6fd; } &::after { content: ''; position: absolute; width: 10px; height: 7px; background: #06a6fd; left: 4px; border: 2px solid #fff; border-top: none; border-right: none; -webkit-transform: rotate(-45deg); -moz-transform: rotate(-45deg); -o-transform: rotate(-45deg); -ms-transform: rotate(-45deg); transform: rotate(-45deg); } } label::before { border-radius: 2px; } } p { letter-spacing: -0.2px; color: #8f9fa7; text-transform: uppercase; font-size: 14px; font-weight: bold; margin-bottom: 2px; } label { font-size: 16px; font-weight: 600; letter-spacing: -0.23px; color: #939596; text-transform: capitalize; line-height: 1.3em; } } } .fixed { position: fixed; top: 0; } .bottom { position: absolute; bottom: 0; top: auto; } .row.equal { display: flex; display: -webkit-flex; flex-wrap: wrap; } #toc .sectlevel1 .expanded ul { display: block; font-size: 14px; font-weight: normal; } .no-results-space { &:after { margin-bottom: 40%; } width: 100%; } @media (max-width: 991px) { .cb .section-hero { padding-top: 0; } .filter-options .card { padding: 20px 40px; } .categories { display: none !important; } .cb-doc-listing .card-shadow { &:hover, &:active { transform: scale(1.05) translateZ(0); } .card img { padding: 11px; } } .breadcrumb { margin-bottom: 0; } } @media only screen and (max-width: 767px) { .cb .section-hero { h1 { font-size: 29px; } p { font-size: 14px; } } .post-detail { h1 { font-size: 29px; } .post-title { padding: 0 !important; } } .post-content { h2 { margin: 0 !important; font-size: 28px !important; } h3 { font-size: 22px !important; } } .searchrow, .filter-options { display: none !important; } .cloud-filter .filter { height: 60px; width: 90px; } #listings { display: flex; justify-content: center; } .cb-doc-listing { .category-head { font-size: 20px; } .card .card-title { font-size: 16px; } } .card-shadow:hover { -webkit-box-shadow: 0 5px 20px 0 rgba(0, 0, 0, 0.1); /* Safari, Android, iOS */ -moz-box-shadow: 0 5px 20px 0 rgba(0, 0, 0, 0.1); /* Firefox */ box-shadow: 0 5px 20px 0 rgba(0, 0, 0, 0.1); border: none; } .cb-newsletter { padding: 0 !important; label { font-size: 22px !important; } p { font-size: 16px; } .btn { padding: 6px 20px !important; } } .section-hero-blue { label { font-size: 25px; } a { padding: 6px 20px; } } p, pre { font-size: 14px; } code { font-size: 14px; &[class*="language-"] { font-size: 14px; } } pre[class*="language-"] { font-size: 14px; } } .cb-newsletter, .no-results { padding: 5em 6em; font-size: 16px; color: #1e252f; display: inline-block; } .cb-newsletter .modal-box, .no-results .modal-box { padding-bottom: 3em; } .cb-newsletter .cb-input-box, .no-results .cb-input-box { background-color: #f5f9fa; border: none; border-top-left-radius: 20px; border-bottom-left-radius: 20px; font-size: 16px; color: black; height: 42px; z-index: 1; overflow: hidden; padding-right: 16px; box-shadow: none; } .cb-newsletter .cb-input-box:hover, .no-results .cb-input-box:hover, .cb-newsletter .cb-input-box:active, .no-results .cb-input-box:active, .cb-newsletter .cb-input-box:focus, .no-results .cb-input-box:focus { border: 1px solid #07a7fc; z-index: 1 !important; padding: 12px; transform: none; box-shadow: none; outline: none !important; } .cb-newsletter .cb-input-box:invalid, .no-results .cb-input-box:invalid { border: 1px solid #ef5a73; } .cb-newsletter label, .no-results label { font-size: 28px; line-height: 40px; letter-spacing: 0.79px; font-weight: 600; color: #1e252f; } .cb-newsletter img, .no-results img { margin-bottom: 2%; } .cb-newsletter .btn, .no-results .btn { text-decoration: none; background: #07a7fc; font-size: 15px; margin-top: -2px; border-radius: 25px !important; margin-left: -13px; padding: 11px 24px; position: relative; z-index: 2; } .cb-newsletter .btn:hover, .no-results .btn:hover, .cb-newsletter .btn:focus, .no-results .btn:focus { background: #06a6fd; transform: none; box-shadow: none; outline: none !important; } @media (min-width: 768px) { #newsletter-success .modal-dialog { margin: 30px auto; width: auto; } } #newsletter-success { label { font-size: 28px; font-weight: 600; line-height: 1.43; letter-spacing: 0.79px; color: #1e252f; margin-bottom: 10px; } p { color: #1e252f; font-size: 16px; line-height: 22px; letter-spacing: 0.45px; } .modal-content { background: #fff; max-width: 468px; height: 320px; border-radius: 8px; .modal-body { padding: 50px 39px; .img { margin-bottom: 19px; } } } .btn { width: 131px; height: 47px; border-radius: 23.5px; background-color: #06a6fd; color: #ffffff; margin-top: 20px; &:hover, &:focus { background: #06a6fd; outline: none; transform: none; box-shadow: none; outline: none !important; } } } .cb-cta-card { background-color: white; color: black; box-shadow: 3px 4px #c5c2c2; border-radius: 5px; padding: 0 24px 0; margin-top: 10px; p { color: black; font-size: 14px; margin-top: -20%; } .deploy { margin-top: 6%; width: 100%; border-radius: 6px; font-size: 14px; background-color: #06a6fd; color: white !important; } .dismiss-cta { color: #06a6fd; &:hover, &:focus { text-decoration: none; transform: none; box-shadow: none; outline: none !important; } } img { max-width: 100%; } } @media (max-width: 992px) { .cb-cta-card { display: none; clear: both; } } .cb-section-white { background-color: #fff; position: relative; color: #1e252f; padding: 20px 25px 0 25px; font-family: 'Open Sans', sans-serif; min-height: 400px; .container-fluid { max-width: 1200px; } } .img-center { max-width: 184px; max-height: 184px; padding: 15px; display: block; margin: 0 auto; } .post-bg { background-color: #f6f8fa; } .post-detail { h1 { margin: 20px auto; font-size: 35px; line-height: normal; letter-spacing: 0.98px; text-align: center; color: #1e252f; } .post-title-image { padding: 0px; max-height: 110px; } .post-title { color: #000000; padding: 1em 2em 0 2em; letter-spacing: 0.98px; font-size: 35px; line-height: normal; text-align: center; max-width: 760px; margin: 0 auto; p { font-weight: 600; margin-bottom: 0px; opacity: 0.5; font-size: 14px; letter-spacing: 0.39px; } h1, h2, h3, h4, h5, h6 { color: #1e252f; } } .breadcrumb { font-size: 14px; margin-bottom: 0; letter-spacing: -0.2px; background-color: white; > a { color: #06a6fd; text-decoration: none; &:last-child span { border-bottom: 1px solid #06a6fd; text-shadow: 0.5px 0 0; &:hover { border-bottom: 2px solid #06a6fd; text-shadow: 0.5px 0 0; } } &:first-child { &:hover, &:active { border-bottom: 2px solid #06a6fd; text-shadow: 0.5px 0 0; } } span { &:hover, &:active { border-bottom: 2px solid #06a6fd; text-shadow: 0.5px 0 0; } } } } h2 { border-bottom: solid 1px #07a7fc; } } .post-content { padding: 0 0 50px 0; color: #1e252f; line-height: 1.38; max-width: 760px; margin: 0 auto; .imageblock > .title { font-size: 12px; margin: 5px 0 15px 0; color: rgba(0, 0, 0, 0.54); text-align: center; } .sect2 { .paragraph, dd { margin-bottom: 10px; } dl { margin: 0; } .listingblock { margin: 20px 0; &.title { font-size: 12px; margin: 15px 0 0 0; color: rgba(0, 0, 0, 0.54); text-align: left; } } .hdlist1 a { text-decoration: none; color: #1e252f; } .dlist, .ulist { padding: 0; margin: 20px 0; } .dlist dt, .ulist dt { margin-top: 15px; } pre { &.highlight > code, &:not(.highlight) { padding: 15px; background: rgba(245, 245, 245, 0.3); border: 1px solid #efefef; } } .token { &.operator, &.entity, &.url, &.variable { background: transparent !important; } } } p, li { font-size: 16px; letter-spacing: 0.45px; margin: 0; } p em, li em { font-style: italic; } p code, li code { background-color: #e6f6fe; color: #000; } p > { a, em > a { color: #06a6fd; text-decoration: none; font-weight: bold; } } img { max-width: 100%; padding-top: 10px; display: block; margin: 0 auto; &:hover { cursor: zoom-in; } } pre { padding: 0; border: none; font-size: 14px; overflow-y: scroll; text-overflow: initial; max-height: 100vh; margin: 10px 0; &[class*="language-"] { padding: 0; border: none; font-size: 14px; overflow-y: scroll; text-overflow: initial; max-height: 100vh; margin: 10px 0; } } h2 { font-size: 38px; line-height: 1.43; letter-spacing: 0.79px; margin: 70px 0 28px 0; } h3 { font-size: 28px; line-height: 1.43; letter-spacing: 0.79px; margin: 40px 0 18px 0; } h1, h2, h3, h4, h5, h6 { color: #1e252f; } h1 a:link, h2 a:link, h3 a:link, h4 a:link, h5 a:link, h6 a:link { color: #1e252f; font-size: .9em !important; margin-left: -0.8em !important; padding-right: 0.1em !important; transform: none; box-shadow: none; outline: none !important; } h1:hover > a.anchorjs-link, h5:hover > a.anchorjs-link, h6:hover > a.anchorjs-link { color: #FFFFFF; text-decoration: none; display: none; } table { font-size: 12px; letter-spacing: 0.34px; color: #1e252f; caption { color: #1e252f; } thead { font-weight: 600; th { padding: 7px 0; } } tbody { background-color: #f5f9fa; tr { border-bottom: 15px solid #fff; td { padding: 5px; p { font-size: 12px; letter-spacing: 0.34px; padding: 10px; } &:nth-child(even) { background-color: #fafcfc; } } } } .tableblock { line-height: 20px; } } } .admonitionblock-content { position: relative; padding: 19px; border-top: 9px solid #06a6fd; background-color: #f5f9fa; text-align: center; margin: 40px 0; .title { font-weight: bold; margin-bottom: 0 !important; } table { text-align: center; margin-left: auto; margin-right: auto; tbody tr { border-bottom: none !important; .icon .title { margin-right: 40px; position: relative; font-size: 0; text-align: center; &:after { display: block; position: absolute; color: #fff; content: "!"; border-radius: 50%; width: 21px; height: 21px; line-height: 21px; font-weight: normal; left: 0; font-size: 16px; margin-top: -9px; } } a { color: #07a7fc; text-decoration: none; font-weight: 600; } td { text-align: initial; font-size: 12px; line-height: normal; letter-spacing: 0.34px; color: #1e252f; padding: 0; p { text-align: initial; font-size: 12px; line-height: normal; letter-spacing: 0.34px; color: #1e252f; padding: 0; } .js-subscribe-cta, p .js-subscribe-cta { color: #07a7fc; text-decoration: none; font-weight: 600; } .js-subscribe-cta:hover, p .js-subscribe-cta:hover { cursor: pointer; text-decoration: underline; } .title, p .title { margin-bottom: 12px; } code, p code { background: #e5ecee !important; color: #1e252f; font-family: AndaleMono; font-weight: normal; font-size: 12px; } &:nth-child(even), p:nth-child(even) { background-color: inherit !important; } } } } } .admonitionblock { &.important, &.note { position: relative; padding: 19px; border-top: 9px solid #06a6fd; background-color: #f5f9fa; text-align: center; margin: 40px 0; } &.important .title, &.note .title { font-weight: bold; margin-bottom: 0 !important; } &.important table, &.note table { text-align: center; margin-left: auto; margin-right: auto; } &.important table tbody tr, &.note table tbody tr { border-bottom: none !important; } &.important table tbody tr .icon .title, &.note table tbody tr .icon .title { margin-right: 40px; position: relative; font-size: 0; text-align: center; } &.important table tbody tr .icon .title:after, &.note table tbody tr .icon .title:after { display: block; position: absolute; color: #fff; content: "!"; border-radius: 50%; width: 21px; height: 21px; line-height: 21px; font-weight: normal; left: 0; font-size: 16px; margin-top: -9px; } &.important table tbody tr a, &.note table tbody tr a { color: #07a7fc; text-decoration: none; font-weight: 600; } &.important table tbody tr td, &.note table tbody tr td, &.important table tbody tr td p, &.note table tbody tr td p { text-align: initial; font-size: 12px; line-height: normal; letter-spacing: 0.34px; color: #1e252f; padding: 0; } &.important table tbody tr td .js-subscribe-cta, &.note table tbody tr td .js-subscribe-cta, &.important table tbody tr td p .js-subscribe-cta, &.note table tbody tr td p .js-subscribe-cta { color: #07a7fc; text-decoration: none; font-weight: 600; } &.important table tbody tr td .js-subscribe-cta:hover, &.note table tbody tr td .js-subscribe-cta:hover, &.important table tbody tr td p .js-subscribe-cta:hover, &.note table tbody tr td p .js-subscribe-cta:hover { cursor: pointer; text-decoration: underline; } &.important table tbody tr td .title, &.note table tbody tr td .title, &.important table tbody tr td p .title, &.note table tbody tr td p .title { margin-bottom: 12px; } &.important table tbody tr td code, &.note table tbody tr td code, &.important table tbody tr td p code, &.note table tbody tr td p code { background: #e5ecee !important; color: #1e252f; font-family: AndaleMono; font-weight: normal; font-size: 12px; } &.important table tbody tr td:nth-child(even), &.note table tbody tr td:nth-child(even), &.important table tbody tr td p:nth-child(even), &.note table tbody tr td p:nth-child(even) { background-color: inherit !important; } &.important .title:after, &.note .title:after { background-color: #06a6fd; border: 1px solid #06a6fd; } &.error { position: relative; padding: 19px; border-top: 9px solid #06a6fd; background-color: #f5f9fa; text-align: center; margin: 40px 0; .title { font-weight: bold; margin-bottom: 0 !important; } table { text-align: center; margin-left: auto; margin-right: auto; tbody tr { border-bottom: none !important; .icon .title { margin-right: 40px; position: relative; font-size: 0; text-align: center; &:after { display: block; position: absolute; color: #fff; content: "!"; border-radius: 50%; width: 21px; height: 21px; line-height: 21px; font-weight: normal; left: 0; font-size: 16px; margin-top: -9px; } } a { color: #07a7fc; text-decoration: none; font-weight: 600; } td { text-align: initial; font-size: 12px; line-height: normal; letter-spacing: 0.34px; color: #1e252f; padding: 0; p { text-align: initial; font-size: 12px; line-height: normal; letter-spacing: 0.34px; color: #1e252f; padding: 0; } .js-subscribe-cta, p .js-subscribe-cta { color: #07a7fc; text-decoration: none; font-weight: 600; } .js-subscribe-cta:hover, p .js-subscribe-cta:hover { cursor: pointer; text-decoration: underline; } .title, p .title { margin-bottom: 12px; } code, p code { background: #e5ecee !important; color: #1e252f; font-family: AndaleMono; font-weight: normal; font-size: 12px; } &:nth-child(even), p:nth-child(even) { background-color: inherit !important; } } } } .title:after { background-color: #ef5a73; border: 1px solid #ef5a73; } } } .center { display: flex; justify-content: center; } .white-section { background-color: #fff; padding: 20px 25px 0 25px; min-height: 200px; } .comments { padding: 50px; h2 { font-size: 28px; font-style: normal; font-stretch: normal; line-height: 1.43; letter-spacing: 0.79px; text-align: center; color: #1e252f; padding: 5px 7px; margin: 0; border-bottom: 2px solid #06a6fd; } .sm-icon { color: #fff; border-radius: 50%; width: 18px; height: 18px; font-size: 14px; background-color: #06a6fd; margin: 20px 8px 0 0; text-align: center; padding: 2px; } .share-buttons ul { padding: 0; display: flex; list-style-type: none; li { display: inline; a { text-decoration: none; } } } .utterances { margin: 20px 0 0 0; body { background-color: black; } .timeline .comment-header { background-color: #fff; border-bottom: none; } } } .timeline-comment.current-user .comment-header { background-color: black; } .subscribe-modal { max-width: 600px; .modal-content { background: white; display: flex; flex-direction: column; position: relative; border: none; -webkit-border-radius: 0px !important; -moz-border-radius: 0px !important; border-radius: 8px !important; .modal-header { background: linear-gradient(#7cd7ff, #07a7fc, #57a1ea); position: relative; display: block; width: 100%; max-height: inherit; border-top-left-radius: 8px; border-top-right-radius: 8px; img { width: 130px; margin-bottom: -12px; } .close { position: absolute; padding: 0px 20px; right: 0px; font-size: 34px; font-weight: 100; } } .modal-body { padding: 1px 60px; h2, h3, p { color: #1e252f; } h2 { margin: 30px 0; font-size: 28px; } h3 { font-size: 20px; line-height: 1.22; letter-spacing: 0.51px; } p { font-size: 14px; line-height: 1.5; letter-spacing: 0.39px; } a { text-decoration: none; font-weight: 600; color: #06a6fd; } ul { text-align: left; li { list-style-type: none; position: relative; &:before { content: ""; background-color: transparent; position: absolute; left: -30px; top: 0px; width: 10px; border-bottom: 4px solid #07a6fd; height: 23px; border-right: 4px solid #07a6fd; transform: rotate(45deg); -o-transform: rotate(45deg); -ms-transform: rotate(45deg); -webkit-transform: rotate(45deg); } } } } .modal-footer { text-align: center; font-size: 14px; .btn-primary { background: linear-gradient(to bottom, #5cbde7, #569cea); padding: 10px 27px; border-radius: 23.5px; height: 47px; margin-bottom: 10px; } .btn-link { letter-spacing: -0.2px; color: #06a6fd; font-size: 14px; font-weight: 600; margin-bottom: 10px; &:hover { transform: none; box-shadow: none; outline: none !important; } } .btn-primary:hover { transform: none; box-shadow: none; outline: none !important; } } } } .cb-post-cta { display: flex; flex-direction: column; justify-content: center; align-items: center; margin: 80px 0; margin-bottom: 10px; .title { font-weight: bold; color: $text-color; font-size: $font-size-h2; margin-bottom: 15px; } } .doc-styled-table { font-size: $font-size-sm; th { border-bottom: 1px solid $primary-color; } td { padding: 3px; border-bottom: 1px solid $gray-color-1; } } ================================================ FILE: docs/assets/css/components.scss ================================================ .box-component { position: relative; z-index: 1; max-width: 800px; margin: auto; .box { max-width: 800px; padding: 15px; padding-top: 50px; padding-bottom: 70px; text-align: center; h1, h2, h3, h4 { text-align: center; font-size: 44px; line-height: 1.27; margin-top: 10px; margin-bottom: 10px; } .subtitle { font-size: 22px; margin-bottom: 30px; } .btn.btn-large { min-width: 320px; } } } .links-section-cmp { min-height: 200px; width: 100%; text-align: center; padding-top: 90px; padding-bottom: 90px; margin-top: -80px; position: relative; display: flex; flex-direction: column; h2 { font-weight: bold; font-size: 34px; position: relative; z-index: 1; } .links { display: flex; align-items: center; justify-content: center; width: 100%; max-width: 980px; padding: 0 30px; margin: auto; position: relative; z-index: 1; a { margin-right: 25px; &:last-child { margin-right: 0px; } } } .links-shapes-left { position: absolute; left: 10vw; top: -52px; width: 30vw; max-width: 330px; } .links-shapes-right { position: absolute; right: 0; top: -40px; width: 460px; max-width: 460px; } } .get-access-cmp{ display: flex; flex-direction: column; align-items: center; justify-content: center; padding-top:100px; padding-bottom:100px; text-align: center; width: 100%; span{ font-size:26px; font-weight: 300; padding: 40px; max-width: 550px; } } .links-n-get-access__section{ .links-section-cmp { margin-top: 0; } } .index-page .links-section-cmp { padding-top: 90px; padding-bottom: 90px; margin-top: -80px; .links-shapes-right { position: absolute; right: 0; top: -40px; width: 525px; } } @media all and (max-width: 768px) { .links-section-cmp { padding-bottom: 60px; .links { flex-direction: column; a { margin: 10px auto; &:last-child { margin-right: auto; } } } } } .use-cases-cmp { z-index: 1; position: relative; } .subpage .subpage__main.subpage-404 { min-height: 50vh; display: flex; align-items: center; justify-content: center; } // // COOKIES // ---------------------------------------------- /* Elmo - The Grunty Cookie Policy Gatekeeper */ #gruntyCookie { background: #ffffff; display: block; color: #1e252f; position: fixed; bottom: 10px; left: 50%; width: 90vw; transform: translateX(-50%); margin: 0 auto; max-width: 600px; padding: 10px 25px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; z-index: 1112; box-shadow: 0 8px 16px 0 rgba(34, 50, 84, 0.6); border-radius: 8px; text-align: center; p { margin: 0; float: left; padding: 15px 0 10px; font-size: 18px; line-height: 22px; a { &:link, &:visited, &:hover, &:active { color: #5b4de5; font-weight: bold; } } } #cookieModalClose { float: right; margin-left: 10px; margin-top: 5px; } } @media (max-width: 767px) { #gruntyCookie { width: 100%; max-width: none; border-radius: 0; bottom: 0; padding: 4px 10px; p { width: 78%; display: inline-block; text-align: left; } #cookieModalClose { width: 20%; display: inline-block; float: none; margin: 18px 0 10px; } } } .cookieModalCBeginAnimate { -webkit-animation: gruntyCookieModal 0.5s; animation: gruntyCookieModal 0.5s; } /* Chrome, Safari, Opera */ @-webkit-keyframes gruntyCookieModal { from { opacity: 0; transform: translate(0px, 40px); } to { opacity: 1; transform: translate(0px, 0px); } } /* Standard syntax */ @keyframes gruntyCookieModal { from { opacity: 0; transform: translate(-50%, 40px); } to { opacity: 1; transform: translate(-50%, 0px); } } // // VIDEO PLAYER // ---------------------------------------------- .video-player { position: relative; cursor: pointer; .btn-video { position: absolute; top: 50%; left: 50%; margin-top: -79px; margin-left: -79px; } &:hover { .frame { opacity: 0.92; } .btn-video { opacity: 0.72; } } } // // TABLES: basic-table-style // ---------------------------------------------- .basic-table-style { width: 100%; font-size: $font-size-xs; tr { th, td { border-bottom: 1px solid $gray-color-1; padding: 3px; } th { } td { } } } ================================================ FILE: docs/assets/css/examples.scss ================================================ .examples__container { max-width: 1200px; padding: 15px 50px; margin-left: auto; margin-right: auto; .examples__nav { display: flex; align-items: stretch; border-bottom: 1px solid $gray-color-1; .hidden-navs, .hidden-navs__static-links { display: none; } .navs { display: flex; align-items: stretch; width: 100%; position: relative; .examples__nav-item { flex: 1; height: 80px; display: flex; align-items: center; justify-content: center; flex-direction: column; padding: 5px; max-width: 200px; min-width: 100px; border-bottom: 2px solid #fff; max-height: 100px; cursor: pointer; img { max-width: 120px; max-height: 80px; margin-bottom: 5px; } .name { font-size: $font-size-xxs; font-weight: bold; color: $gray-color-2; text-transform: uppercase; letter-spacing: 0.2px; } &.active { border-bottom: 2px solid $primary-color-2; .name { color: $primary-color-2; } } &:not(.active) { img { -webkit-filter: grayscale(100%); filter: grayscale(100%); opacity: 0.25; } &:hover { img { opacity: 1; -webkit-filter: grayscale(0%); filter: grayscale(0%); } .name { color: $primary-color-2; } } } img { width: 100%; } } .navs__visible-bar { flex: 1; display: flex; align-items: stretch; } .navs__dropdown-input { width: 150px; } .navs__dropdown-arrow { width: 100px; display: flex; justify-content: center; align-items: center; } .navs__dropdown-menu { position: absolute; top: 80px; right: 0px; display: none; min-width: 260px; width: 100%; max-width: 400px; border: 1px solid $gray-color-1; border-radius: 4px; flex-direction: column; justify-content: center; align-items: stretch; box-shadow: $box-shadow-sm; background: #fff; .examples__nav-item { padding: 20px 10px; width: 100%; max-width: 100%; border-bottom: 1px solid $gray-color-1; &:last-child { border-width: 0px; } } &.active { display: flex; z-index: 10; } } @media all and (max-width: 439px) { .navs__visible-bar { display: none; } .navs__dropdown-input { width: 100%; .examples__nav-item { max-width: 100%; } } } } } .examples__block { display: none; &.active { display: block; } .examples__tabs { display: flex; margin-top: 30px; .tab { min-width: 50px; display: flex; flex-direction: column; border: 1px solid $gray-color-1; border-bottom: 2px solid $gray-color-1; padding: 10px 15px; cursor: pointer; label { line-height: 1; font-weight: normal; font-size: $font-size-xxs; color: $gray-color-3; } .filename { font-size: $font-size-xs; color: $gray-color-3; } &.active { border-bottom: 2px solid $primary-color-2; label { font-weight: bold; color: $primary-color-2; } .filename { color: $text-color; } } &:hover { opacity: 0.7; border-bottom: 2px solid $primary-color-2; label { color: $primary-color-2; } .filename { color: $text-color; } } } } @media all and (max-width: 800px) { .examples__tabs { flex-direction: column; } } .examples__code { display: none; width: 60%; position: relative; margin-bottom: 50px; pre[class*="language-"] { font-size: 14px; margin-top: 0px; border-top-left-radius: 0; code { font-size: 11px; line-height: 22px; } } &.active { display: block; } } .example__file-link { padding-top: 20px; padding-bottom: 20px; font-size: $font-size-xs; word-break: break-all; p { margin-bottom: 4px; font-weight: bold; } @media all and (max-width: 768px) { font-size: 12px; } } .description { display: none; } .examples__learn-more { min-height: 300px; padding: 15px 50px; text-align: center; margin-top: 50px; .title { font-size: 44px; font-weight: 300; } ul { list-style: none; padding-left: 0px; li { padding: 5px 10px; a { font-size: $font-size-lg; font-weight: bold; color: $primary-color-2; text-decoration: none; &:hover, &:focus, &:active { text-decoration: none; } &:hover { opacity: .8; } } } } } } } @media all and (max-width: 800px) { .examples__container { padding-left: 10px; padding-right: 10px; .examples__block { .examples__code { width: 100%; } } } } @media all and (max-width: 768px) { .examples__container { .examples__block { .description { column-count: 1; } } } } .code-popup-handler { position: absolute; right: 0px; width: 28px; height: 28px; color: #fff; margin-right: -14px; margin-top: -16px; cursor: pointer; z-index: 1; .number { border-radius: 50%; font-weight: bold; font-family: 'Source Sans Pro', sans-serif; display: flex; align-items: center; justify-content: center; background: $secondary-gradient-start-color; background: -webkit-linear-gradient(-45deg, $secondary-gradient-start-color, $secondary-gradient-stop-color); background: linear-gradient(135deg, $secondary-gradient-start-color, $secondary-gradient-stop-color); } &.sm-scale .number, &.sm-scale .shadow-bg-1, &.sm-scale .shadow-bg-2 { transform: scale(0.88); } .shadow-bg-1 { z-index: -1; position: absolute; width: 34px; height: 34px; opacity: 0.2; top: 50%; margin-top: -17px; left: 50%; margin-left: -17px; background: #068ee4; border-radius: 50%; } .shadow-bg-2 { z-index: -2; position: absolute; width: 40px; height: 40px; opacity: 0.2; top: 50%; margin-top: -20px; left: 50%; margin-left: -20px; background: #068ee4; border-radius: 50%; } &:hover { .shadow-bg-1, .shadow-bg-2 { opacity: 0.28; } } .popup { display: none; width: 300px; background: #fff; box-shadow: $box-shadow-sm; color: $text-color; align-items: stretch; border-radius: 4px; .left-border { border-top-left-radius: 4px; border-bottom-left-radius: 4px; width: 4px; min-width: 4px; background: $secondary-gradient-start-color; background: -webkit-linear-gradient(90deg, $secondary-gradient-start-color, $secondary-gradient-stop-color); background: linear-gradient(180deg, $secondary-gradient-start-color, $secondary-gradient-stop-color); } .content { padding: 5px; .title { font-size: $font-size-xxs; font-weight: bold; } p, .text { font-size: $font-size-xxs; font-weight: normal; margin-bottom: 0; } } } &.active { transform: scale(1.2); .number { background: $primary-gradient-start-color; background: -webkit-linear-gradient(0deg, $primary-gradient-start-color, $primary-gradient-stop-color); background: linear-gradient(90deg, $primary-gradient-start-color, $primary-gradient-stop-color); } .shadow-bg-1, .shadow-bg-2 { display: none; } .popup { display: flex; position: absolute; left: 50px; top: 0; } } &.active.sm-scale { .number { transform: scale(0.92); } } } @media all and (max-width: 1200px) { .code-popup-handler { .popup { width: 200px; } } } @media all and (max-width: 800px) { .code-popup-handler { margin-right: 0; right: 5px; &.active { .popup { left: -220px; } } } } @media all and (max-width: 350px) { .code-popup-handler { margin-right: 0; right: 3px; &.active { .popup { width: 180px; left: -182px; } } } } .examples__container.quick-start-examples { padding: 0; .examples__nav { display: none; } .examples__code { margin-bottom: 30px; } } .examples__container.wide { .examples__block { .examples__code { width: 100%; } } .code-popup-handler { .popup { left: 40px; width: 280px; } @media all and (max-width: 1600px) { .popup { width: 220px; } } @media all and (max-width: 1450px) { .popup { width: 180px; } } @media all and (max-width: 1366px) { .popup { width: 280px; right: 40px; left: auto; } } @media all and (max-width: 400px) { .popup { width: 200px; } } } } ================================================ FILE: docs/assets/css/global.scss ================================================ // // BODY // ------------------------------------------------------- body { background: #fff; } @media all and (max-width: 991) { body.modal-opened { width: 100vw; height: 100vh; overflow: hidden; } } // // BADGES, TAGS // ------------------------------------------------------- .badge { background: rgba(91, 77, 229, 0.1); padding: 9px 26px; border-radius: 4px; color: #5b4de5; font-size: 12px; letter-spacing: 0.34px; font-weight: bold; text-align: center; } // // BOX // ------------------------------------------------------- .box { max-width: 900px; margin: 0 auto; border-radius: 10px; background-color: #ffffff; max-width: 800px; } .box-with-links { padding: 15px; padding-top: 40px; padding-bottom: 60px; h1, h2, h3, h4 { text-align: center; font-weight: bold; font-size: 44px; line-height: 1.27; margin-top: 10px; margin-bottom: 20px; } .card-links { display: flex; flex-direction: column; align-items: center; justify-content: center; .card-link { max-width: 90%; } } } // // BUTTONS // ------------------------------------------------------- .btn { padding: 17px 30px; border: none; font-size: 16px; box-shadow: 0 5px 14px 0 rgba(22, 62, 122, 0.33); text-transform: uppercase; letter-spacing: 2px; line-height: 1; &:focus, &:hover { transform: translateY(1px); box-shadow: 0 2px 8px 0 rgba(22, 62, 122, 0.53); } } .btn-link { box-shadow: none; } .btn-primary { &:focus, &:hover { color: #fff; } } .btn-white { background: #fff; color: #2d7ef4; &:focus, &:hover { color: #2d7ef4; background: #fff; } } .btn-lg, .btn-group-lg > .btn { height: 60px; border-radius: 30px; padding: 20px 30px; min-width: 200px; } .btn-sm, .btn-group-sm > .btn { height: 40px; border-radius: 30px; padding: 12px 15px; min-width: 100px; font-size: 12px; line-height: 16px; } .btn-info { &:focus, &:hover { color: #fff; } } .btn-primary-hollow { border: 1px solid #13cfe7; &:hover { background: #9c9c9c; background: -webkit-linear-gradient(-45deg, #3c3c3c, #5e5e5e); background: linear-gradient(135deg, #3c3c3c, #5e5e5e); } } .btn-light { background: #fff; &:focus, &:hover { color: #13cfe7; background: #fff; } } .btn-group-social-icons { margin-top: 6px; margin-left: -4px; .btn { margin: 0; padding: 0; border: none; font-size: 22px; line-height: 22px; background: transparent; box-shadow: none; } } .btn-group-package-icons { .btn { margin: 0; padding: 0 6px; border: none; background: transparent; box-shadow: none; } img { margin-left: 6px; } } .btn-primary { &:focus, &:hover { background: #80cdfc; background: -webkit-linear-gradient(-45deg, #80cdfc, #579ff7); background: linear-gradient(135deg, #80cdfc, #579ff7); } } .btn-info { background: #ff2b67; background: -webkit-linear-gradient(-45deg, #ff2b67, #ff5442); background: linear-gradient(135deg, #ff2b67, #ff5442); } .btn-info { &:focus, &:hover { background: #ff024a; background: -webkit-linear-gradient(-45deg, #ff024a, #ff2f19); background: linear-gradient(135deg, #ff024a, #ff2f19); } } .card-link { transition: 0.2s all ease; display: flex; min-height: 56px; width: 100%; align-items: center; justify-content: center; margin: 2px 0px; a { border-radius: 10px; border: solid 1px #e8e8e8; background-color: #ffffff; color: #1e252f; text-decoration: none; padding: 15px 25px; width: 100%; font-size: 18px; line-height: 18px; &:hover { -webkit-box-shadow: 0 2px 34px 0 rgba(0, 0, 0, 0.1); -moz-box-shadow: 0 2px 34px 0 rgba(0, 0, 0, 0.1); box-shadow: 0 2px 34px 0 rgba(0, 0, 0, 0.1); border: none; transform: scale(1.02) translateZ(0); } } } .link-tag-styled { background: #fff; padding: 7px 12px; border-radius: 10px; text-transform: uppercase; line-height: 20px; font-weight: bold; letter-spacing: 2px; text-decoration: none; color: #2d7ef4; &:hover, &:active, &:focus { text-decoration: none; } img { margin-right: 5px; } .link-icon { height: 16px; } } .btn-outline-white { border: 2px solid white; } // // CODE // ------------------------------------------------------- code, pre { border-color: $gray-color-1; } .code-snippet { font-family: 'Fira Mono', monospace; pre { background: $gray-color-1; border-width: 0px; border-radius: 10px; padding: 20px; text-align: left; } .purple { color: #5b55e8; } .salmon { color: #ee4f5d; } } .highlighter-rouge.language-plaintext .highlight { background: #F5F3FD; code { color: #545492; } } pre[class*="language-"] { background: $gray-color-1; } code[class*="language-"], pre[class*="language-"] { line-height: 15px; } code::selection, pre[class*="language-"]::selection, pre[class*="language-"] span::selection { background: rgba(0,0,30,0.1) !important; } code.highlighter-rouge.language-plaintext { background: $gray-color-1; color: #5b4de5; border-radius: 4px; padding-left: 8px; padding-right: 8px; font-size: 87%; } code { background: none; color: $inline-code-color-base; background: $inline-code-bg-color-base; font-size: 87%; } p code, h1 code, h2 code, h3 code, h4 code, h5 code { background: rgba(91, 77, 229, 0.1); color: #5b4de5; border-radius: 4px; padding-left: 8px; padding-right: 8px; } // // GRADIENTS // ------------------------------------------------------- .gradient-primary.gradient-vertical, .sub-page .navbar-default, .index-page .section-hero, .section-bg-blue, .how-it-works .section-hero { background: $primary-gradient-start-color; background: -webkit-linear-gradient(top, $primary-gradient-start-color, $primary-gradient-stop-color); background: linear-gradient(to bottom, $primary-gradient-start-color, $primary-gradient-stop-color); } .gradient-primary.gradient-diagonal { background: $primary-gradient-start-color; background: -webkit-linear-gradient(-45deg, $primary-gradient-start-color, $primary-gradient-stop-color); background: linear-gradient(135deg, $primary-gradient-start-color, $primary-gradient-stop-color); } .btn-primary { background: $secondary-gradient-start-color; background: -webkit-linear-gradient(-45deg, $secondary-gradient-start-color, $secondary-gradient-stop-color); background: linear-gradient(135deg, $secondary-gradient-start-color, $secondary-gradient-stop-color); } .gradient-primary-hover { &.gradient-vertical { background: #80cdfc; background: -webkit-linear-gradient(top, #80cdfc, #579ff7); background: linear-gradient(to bottom, #80cdfc, #579ff7); } &.gradient-diagonal { background: #80cdfc; background: -webkit-linear-gradient(-45deg, #80cdfc, #579ff7); background: linear-gradient(135deg, #80cdfc, #579ff7); } } // // GRIDS & SECTIONS // ------------------------------------------------------- .row-divider { padding-top: 20px; padding-bottom: 20px; } .flex { display: flex; } .align-items--stretch { align-items: stretch; } .align-items--end { align-items: flex-end; } @media (min-width: 768px) { .row-divider > div { + div { border-left: 1px solid #194c5f; } &:first-child { padding-left: 0; padding-right: 30px; } &:last-child { padding-right: 0; padding-left: 30px; } } } @media (max-width: 991px) { .container { padding-right: 30px; padding-left: 30px; } } .container > .row + .row { padding-top: 45px; } .index-page .container > .row + .row { padding-top: 60px; } @media (min-width: 768px) { .container > .row + .row { padding-top: 60px; } } @media (min-width: 992px) { .index-page .section-dark .container > .row { padding-top: 90px; } } .section-blue { background-color: #0FC0EF; color: #fff; } .section { padding-top: 45px; padding-bottom: 45px; } @media (min-width: 768px) { .section { padding-top: 60px; padding-bottom: 60px; } } @media (min-width: 768px) { .section-hero-with-button { padding-top: 30px; padding-bottom: 20px; } } .index-page .section-hero { color: #fff; background: $primary-gradient-start-color; background: -webkit-linear-gradient(-45deg, $primary-gradient-start-color, $primary-gradient-stop-color); background: linear-gradient(135deg, $primary-gradient-start-color, $primary-gradient-stop-color); } @media (min-width: 768px) { .index-page .section-hero { padding-top: 30px; .floating-img-infrastructure-in-a-day { position: absolute; width: 220px; top: 30px; left: -60px; } } } @media (min-width: 992px) { .index-page .section-hero { margin-top: -84px; padding-top: 174px; padding-bottom: 135px; .floating-img-infrastructure-in-a-day { position: absolute; width: auto; top: -60px; left: 15px; } } } .section-bg-blue { color: #fff; padding-bottom: 20px; padding-top: 84px; } @media (min-width: 991px) { .section-bg-blue { margin-top: -84px; } } .sub-page .section-hero { background: url('../img/bg-header-squares.png') center bottom no-repeat, linear-gradient(#1a232d, #2e3b4a); } @media only screen { .sub-page .section-hero { border-bottom: 2px solid rgba(15, 192, 239, 0.25); } } .index-page .main .section-dark { background-image: url('../img/bg-content-squares.png'); background-position: center top; background-repeat: no-repeat; } @media (min-width: 992px) { .index-page .main .section-dark { padding-bottom: 190px; .container { position: relative; &::after { content: ""; display: block; position: absolute; height: 305px; width: 224px; bottom: -260px; right: 6px; z-index: 980; background: url('../img/infrastructure-cube-icons/infrastructure-endcap.png') right bottom no-repeat; background-size: 224px 305px; } } } } @media (min-width: 768px) { .section-sub-hero { padding-top: 20px; padding-bottom: 20px; } } .thanks-page { .header { background: linear-gradient(135deg, #001191, #06a3ff); } .section-thanks { display: flex; justify-content: center; align-items: center; min-height: 60vh; .copy-container { margin-top: 10px; } } } .section-heading { padding-bottom: 0; margin-bottom: -30px; h1, h2 { margin-bottom: 0; line-height: 1.4; } } // // HEADING // ---------------------------------------------- h1, .h1, h2, .h2, h3, .h3 { font-weight: 200; } .section-colorful-bg { h1, .h1, h2, .h2, h3, .h3, h4, .h4 { color: #ffffff; } } h1, .h1 { margin-top: 38.88px; margin-bottom: 27px; line-height: 1.15; } h2, .h2 { margin-top: 32.4px; margin-bottom: 21.6px; line-height: 1.21; } h3, .h3 { margin-top: 27px; margin-bottom: 19.44px; line-height: 1.27; } h4, .h4 { margin-top: 23.76px; margin-bottom: 17.28px; line-height: 1.33; } h5, .h5 { margin-top: 20.52px; margin-bottom: 15.12px; line-height: 1.4; } h6, .h6 { margin-top: 18.36px; margin-bottom: 14.04px; line-height: 1.45; } @media all and (max-width: 992px) { h1, .h1 { font-size: 44px; } h2, .h2 { font-size: 38px; } h3, .h3 { font-size: 36px; } h4, .h4 { font-size: 32px; } h5, .h5 { font-size: 26px; } h6, .h6 { font-size: 20px; } } .h2-tag-style { font-size: 16px; text-transform: uppercase; display: table; padding: 7px 20px; color: #5b4de5; font-weight: bold; letter-spacing: 2px; background: rgba(91, 77, 229, 0.1); border-radius: 10px; margin: 30px auto; } .sub-page .section-hero h1 + p { margin-top: -15px; } .section > .container > .row > div[class*="col-"] > { p:first-child, h1:first-child, h2:first-child, h3:first-child, h4:first-child, h5:first-child, h6:first-child { margin-top: 0; } p:last-child, h1:last-child, h2:last-child, h3:last-child, h4:last-child, h5:last-child, h6:last-child { margin-bottom: 0; } } // // LINES // ---------------------------------------------- hr { margin: 45px 0; border-width: 2px; border-color: #194c5f; &.hr-dim { border-color: #1e5c73; } &.hr-xl { margin: 75px 0; } &.hr-lg { margin: 60px 0; } &.hr-sm { margin: 20px 0; } &.hr-xs { margin: 10px 0; } &.hr-dashed { border-style: dashed; } &.hr-reset { border-color: initial; border-width: 1px; } &.short { width: 80%; max-width: 300px; border-width: 1px; opacity: 0.3; background-color: #1e252f; margin: 45px auto; } } footer hr { border-color: #ffffff; border-width: 1px; } // // NAVBAR // ------------------------------------------------------- .navbar-nav > li > .dropdown-menu { margin-top: -20px; border-top-width: 2px; border-right: 2px solid #194C5F; border-bottom: 2px solid #194C5F; border-left: 2px solid #194C5F; } .navbar-nav { li { &:first-child { a { display: flex; span { margin-right: 12px; padding: 0; background: linear-gradient(101.84deg, #FE3162 2.31%, #FF4F47 98.56%); border-radius: 3.5px; font-weight: 600; min-width: 52px; display: flex; align-items: center; justify-content: center; } } } } } @media (min-width: 768px) { .dropdown:hover .dropdown-menu { display: block; } } .navbar-brand { display: flex; align-items: center; justify-content: center; } .close { opacity: 1.0; } .navbar-default, .navbar-collapse { border: none; } .index-page .navbar-default { border: none; } @media all and (min-width: 1200px) { .navbar.navbar-default { max-width: 90%; margin: 0px auto; } } .navbar-brand { flex-direction: column; align-items: flex-start; } .navbar-nav > li > a { padding-right: 10px; padding-left: 10px; text-transform: uppercase; letter-spacing: 2px; font-size: 16px; &.btn { padding: 6px 12px; margin-top: 19px; margin-bottom: 19px; margin-left: 20px; &:hover, &:focus { color: #fff; } } } .github-nav-link { img { max-height: 18px; margin-top: -3px; &:hover { opacity: 0.7; } } } @media (min-width: 992px) { .navbar-nav > li > a { padding-right: 20px; padding-left: 20px; } } @media (min-width: 1200px) { .navbar-nav > li > a.contact-sales { margin-right: -5em; } } @media (min-width: 1400px) { .navbar-nav > li > a.contact-sales { margin-right: -9em; } } @media (min-width: 768px) { .navbar-nav.navbar-center { position: absolute; left: 50%; transform: translatex(-50%); } } // // SHADOWS // ------------------------------------------------------- .shadow { box-shadow: 0 2px 34px 0 rgba(0, 0, 0, 0.1); } // // TEXT & TYPOGRAPHY // ------------------------------------------------------- @media only screen and (max-width: 767px) { p + ul > li { font-size: 14px; } } label { font-weight: 400; margin-bottom: 6px; color: #1e252f; } p a:not(.btn) { text-decoration: underline; &:hover, &:focus { text-decoration: underline; } &.caret-right { text-decoration: none; } } a.caret-right::after { font-family: 'FontAwesome'; content: "\f0da"; margin-left: 4px; } .section-blue a { color: #fff; &:hover, &:focus { color: rgba(255, 255, 255, 0.7); } } // // TEXT-DECORATION // ------------------------------------------------------- .new-badge{ background: #ee4f5d; padding: 0px 10px; border-radius: 3px; display: inline-block; color: #fff; font-family: 'Source Sans Pro', sans-serif; font-size: 16px; font-weight: 800; } // // LOGO // ------------------------------------------------------- .terragrunt-logo { display: flex; flex-direction: column; align-items: flex-start; a { color: #fff; &:hover { opacity: 0.8; text-decoration: none; } &:focus, &:active { text-decoration: none; } &.logo-terragrunt { font-size: 32px; font-weight: 700; } &.gruntwork { font-size: 14px; } } } // // SUBPAGE // ---------------------------------------------- .sub-page { .main { position: relative; border-bottom: 2px solid rgba(15, 192, 239, 0.25); > .section:last-child { padding-bottom: 120px; } &::after { content: ""; display: block; position: absolute; height: 55px; width: 100%; bottom: -34px; z-index: 990; background-image: url('../img/hr-boxes.png'); background-position: center bottom; background-repeat: no-repeat; background-size: 80px 55px; } } &.customers .main > .section:last-child { padding-bottom: 0; } } // // FOOTER // ---------------------------------------------- .section-footer { margin-top: -30px; padding-top: 0; } .section-footer-copyright { background-color: transparent; color: #647b9c; a { color: rgba(100, 123, 156, 0.8); &:hover, &:focus { color: #647b9c; } } } .footer { background: linear-gradient(100deg, #1b242e 5%, #2a3644 98%); padding-top: 60px; padding-bottom: 60px; > .container-fluid { max-width: 1200px; margin: 0 auto; } .terragrunt-logo { margin-bottom: 10px; margin-top: 10px; .logo-terragrunt { margin-bottom: 4px; } } .subtitle { color: #fff; margin-bottom: 30px; font-size: 14px; } a, ul > li > a { color: #fff; } a:hover, ul > li > a:hover, a:active, ul > li > a:active, a:focus, ul > li > a:focus { color: #fff; text-decoration: none; } h4 { font-size: 16px; color: #fff; text-transform: uppercase; font-weight: bold; margin-bottom: 5px; } .learn-col { text-align: right; ul { display: flex; flex-wrap: wrap; list-style: none; padding-left: 0; margin-left: 0; justify-content: flex-end; li { margin-bottom: 1rem; margin-left: 40px; } } } .copy-container { display: flex; align-items: center; justify-content: flex-end; margin-top: 90px; ul { margin-left: 0; margin-bottom: 0; li { display: inline-block; margin-left: 0; margin-bottom: 0; line-height: 1; margin-right: 20px; a { font-size: 12px; } } } } .footer__copyright { font-size: 12px; margin-top: 2px; color: #fff; opacity: 0.5; line-height: 1; } } @media all and (max-width: 991px) { .footer { .row { display: flex; align-items: center; } .learn-col { text-align: center; ul { flex-direction: column; justify-content: center; align-items: center; li { margin-left: 0px; } } .copy-container { ul { flex-direction: row; } } } } } @media all and (max-width: 768px) { .footer { text-align: center; .row { flex-direction: column; justify-content: center; } .learn-col { margin-top: 30px; .copy-container { justify-content: center; flex-direction: column; margin-top: 40px; ul { flex-direction: column; li { margin-bottom: 10px; margin-right: 0; } } } } .terragrunt-logo { align-items: center; } } } @media all and (max-width: 400px) { .footer { text-align: center; .btn.btn-primary { font-size: 12px; } } } ================================================ FILE: docs/assets/css/pages/contact.scss ================================================ .contact { font-family: "Source Sans Pro", sans-serif; background: #001191; background: -webkit-linear-gradient(-45deg, #001191, #06a3ff); background: linear-gradient(135deg, #001191, #06a3ff); #error-message { text-align: center; h3 { font-size: 24px; font-weight: 500; } } .has-error { border-color: #ff2b67!important; } .navbar-header { margin: auto; .navbar-toggle { .icon-bar { &:nth-child(3) { width: 16px; margin-left: auto; } } } } .hidden-shape { display: none; } .built-by-cmp { display: none; } .subpage__header { background: transparent; position: relative; .header-shapes-top { position: absolute; top: -50px; left: 200px; } } .links-section-cmp { background: transparent; } .subpage__contact { background: transparent; max-width: 1200px; width: 90%; margin: auto; min-height: 1000px; .contact-column { display: flex; flex-direction: column; justify-content: left; margin-top: 100px; color: white; h1 { color: white; } .contact-subtitle { font-size: 26px; line-height: 1.2; a { color: white; &:hover { opacity: .9; } } } .contact-subtitle-back { margin-top: 100px; } } .form-column { margin-top: 1rem; .contact-form-container { padding: 20px 30px; border-radius: 6px; border: solid 1px #cececf; background: #fff; max-width: 550px; position: relative; .contact-form-back { position: absolute; right: -40px; z-index: -1; top: 50px; } #submit-button { min-width: 180px; } #contact-form { display: flex; flex-direction: column; label { margin-top: 2rem; font-size:16px; font-weight: bold; } input, textarea { border-radius: 6px; border: solid 1px #cececf; text-indent: 12px; &::placeholder { color: #cececf; } } span { margin: 2rem 0; font-size: 16px; font-weight: normal; font-stretch: normal; font-style: normal; line-height: normal; letter-spacing: normal; color: #1e252f; } textarea { resize: vertical; } input{ height: 50px; } .radio-element{ display: flex; align-items: center; position: relative; input { position: absolute; opacity: 0; cursor: pointer; height: 0; width: 0; margin: auto 0; &:checked ~ .checkmark:after { display: block; } } .checkmark { position: absolute; left: 0; height: 16px; width: 16px; margin: 0; border: 1px solid #0237ae; border-radius: 3px; &:after { content: ""; position: absolute; display: none; left: 5px; top: 2px; width: 5px; height: 9px; border: solid #0237ae; border-width: 0 2px 2px 0; -webkit-transform: rotate(45deg); -ms-transform: rotate(45deg); transform: rotate(45deg); } } label { cursor: pointer; margin: auto 1rem auto 0; padding-left: 25px; width: 100%; text-transform: uppercase; } .radio-label { width: auto; margin: 0; position: absolute; left: 0; top: 0; height: 100%; display: flex; align-items: center; } .radio-btn-contact{ input[radio]{ margin:0; padding:0; } } } } } } } } .contact__page { min-height: 100vh; } @media only screen and (max-width: 992px) { .contact { .links { flex-wrap: wrap; a { margin-top: 15px; } } .contact-form-container { margin: auto; } .contact-form-back { display: none; } .header-shapes-bottom { margin: auto; margin-top: 2rem; width: 100%; max-width: 500px; } .hidden-shape-xs { display: none; } .hidden-shape { display: block; } .subpage__contact { .form-column { margin-top: 3rem; } .contact-column { margin-top: 0; } } .subpage__header { padding-top: 20px; padding-bottom: 0; .header-shapes-top { top: -35px; left: 160px; } } } } @media only screen and (max-width: 440px) { .contact { .subpage__contact { .form-column { .contact-form-container { #contact-form { .radio-element { label { font-size: 14px; } } } } } } } } ================================================ FILE: docs/assets/css/pages/cookie-policy.scss ================================================ .page-cookie-policy { .container > .row + .row { padding-top: 28px; } } ================================================ FILE: docs/assets/css/pages/home.scss ================================================ body.index-page { header.header { z-index: 20; position: relative; } .section.section-hero { position: relative; } .header-bg { position: absolute; width: 100%; height: 100%; left: 0; top: 0; z-index: 0; overflow-x: hidden; .header-shapes-top { z-index: 0; position: absolute; top: 0; width: 60vw; min-width: 600px; max-width: 800px; margin-left: -20%; left: 60vw; } } .header-shapes-bottom { position: absolute; bottom: 5%; right: 0; width: 250px; height: 400px; background-position: top left; background-size: 250px 400px; background-repeat: no-repeat; } .index-page__header { z-index: 1; position: relative; } .navbar.navbar-default { padding-top: 30px; } .section-hero { margin-top: 0px; padding-top: 0px; padding-bottom: 70px; } .index-page__header { display: flex; width: 100%; margin: auto; .img-container { flex: 1; max-width: 50%; position: relative; .header-shapes-hero { position: absolute; max-width: 800px; left: 15%; height: calc(100% + 220px); bottom: -72px; } } .text-container { flex: 1; padding: 15px; max-width: 600px; position: relative; h1 { line-height: 1; margin-top: 10px; } .subtitle { font-size: 22px; display: block; margin-bottom: 40px; line-height: 1.23; } .btn { min-width: 250px; } } } .link-to-test-with-terratest { text-decoration: underline; cursor: pointer; &:hover { opacity: 0.7; } } .index-page__terratest-in-4-steps { h2 { max-width: 800px; margin-left: auto; margin-right: auto; } .test-steps { display: flex; align-items: stretch; justify-content: center; padding: 15px 50px; max-width: 1200px; margin-left: auto; margin-right: auto; margin-bottom: 120px; flex-wrap: wrap; .test-step { flex: 1; display: flex; flex-direction: column; padding: 0; min-width: 240px; .icon-wrapper { display: flex; margin-bottom: 20px; .line { flex: 1; border-top: 1px dashed $gray-color-3; display: block; margin-left: 0; margin-right: 2px; margin-top: 15px; max-width: calc(50% - 36px); } img { margin-left: 5px; margin-right: 5px; } } .text-wrapper { display: flex; flex-direction: column; text-align: center; padding-left: 5px; padding-right: 5px; } label { font-size: $font-size-lg; font-weight: bold; max-width: 280px; line-height: 1.2; min-height: 58px; } .desc { font-size: $font-size-sm; max-width: 280px; } @media all and (min-width: 451px) { &:first-child { .icon-wrapper { justify-content: flex-end; } } } &:last-child { .icon-wrapper { justify-content: flex-start; } } @media all and (max-width: 1100px) { min-width: 400px; margin-bottom: 20px; .icon-wrapper { .line { display: none; } } .desc { max-width: 400px; } .text-wrapper { text-align: left; label { min-height: auto; } } } @media all and (max-width: 1100px) { flex-direction: row; margin-bottom: 40px; .text-wrapper { margin-left: 15px; } } @media all and (max-width: 450px) { flex-direction: column; min-width: auto; .text-wrapper { margin-left: 0px; } img { width: 46px; } } } @media all and (max-width: 1100px) { max-width: 600px; margin-left: auto; margin-right: auto; margin-bottom: 50px; } @media all and (max-width: 450px) { flex-direction: column; padding: 15px; } } } .index-page__cta-section { padding-top: 100px; padding-bottom: 100px; position: relative; margin-bottom: 120px; .btn { z-index: 1; position: relative; } .left-img { position: absolute; left: 0; top: -208px; } .right-img { position: absolute; right: 100px; top: -220px; } @media all and (max-width: 991px) { margin-bottom: 30px; .right-img { width: 300px; top: -180px; right: 5%; } .left-img { width: 300px; top: -100px; } } @media all and (max-width: 600px) { .right-img { display: none; } .left-img { width: 80%; top: -100px; } } @media all and (max-width: 450px) { .hide-on-cxs { display: none; } } } .code-mark { display: absolute; right: 0px; width: 200px; height: 200px; background: red; } .index-page__watch { max-width: 1200px; margin-left: auto; margin-right: auto; padding-top: 100px; .title-label { color: $primary-color; font-weight: bold; display: block; margin-bottom: 30px; font-size: 22px; } h2 { font-size: 34px; } .btn-sm { margin-right: 10px; } @media all and (max-width: 991px) { .row.flex { flex-direction: column; &>.col-xs-12:first-child { margin-bottom: 50px; max-width: 800px; margin-left: auto; margin-right: auto; } .col-xs-12 { align-items: center; justify-content: center; } } } } .index-page__built-by { text-align: center; position: relative; overflow-x: hidden; margin-bottom: 30px; padding-bottom: 80px; .subtitle { font-size: 22px; } .bg-image { position: absolute; z-index: -1; width: 1440px; bottom: 0; left: 50%; transform: translateX(-50%); } } } @media all and (max-width: 1600px) { body.index-page { .index-page__header .img-container .header-shapes-hero { left: 15px; } } } @media all and (max-width: 1310px) { body.index-page { .header-shapes-bottom { display: none; } .index-page__header .img-container .header-shapes-hero { left: 5vw; right: auto; height: 120%; max-height: 440px; } } } @media all and (max-width: 1200px) { body.index-page { .index-page__header .img-container .header-shapes-hero { right: 5vw; left: auto; } .index-page__header .text-container .subtitle { font-size: 20px; } } } @media all and (max-width: 991px) { body.index-page { .header-shapes-bottom { display: none; } .index-page__header { justify-content: center; .img-container { display: none; } .text-container { text-align: center; max-width: 700px; .subtitle { font-size: 16px; } } } .index-page__key-features .index-page__key-feature { text-align: center; max-width: 700px; .subtitle { max-width: 100%; font-size: 16px; } img { &.custom-width, &.custom-width-2 { display: none; } } .custom-width-3 { display: block; margin: auto; margin-bottom: 20px; } .code-snippet { font-size: 10px; pre { max-width: 600px; margin: 0 auto 30px auto; } } } } } @media all and (max-width: 768px) { body.index-page { .index-page__key-features .index-page__key-feature h3 { margin-top: 5px; } hr.short { margin: 10px auto; } } } @media all and (max-width: 480px) { body.index-page .index-page__key-features .index-page__key-feature .code-snippet pre { font-size: 10px; } } ================================================ FILE: docs/assets/css/pages/support.scss ================================================ .page-support { .subheader { font-weight: bold; font-style:italic; margin: -16px 0 10px 0; } } ================================================ FILE: docs/assets/css/prism.css ================================================ /* PrismJS 1.17.1 https://prismjs.com/download#themes=prism-solarizedlight&languages=markup+css+clike+javascript+bash+ruby+docker+go+hcl+java+json+python+yaml */ /* Solarized Color Schemes originally by Ethan Schoonover http://ethanschoonover.com/solarized Ported for PrismJS by Hector Matos Website: https://krakendev.io Twitter Handle: https://twitter.com/allonsykraken) */ /* SOLARIZED HEX --------- ------- base03 #002b36 base02 #073642 base01 #586e75 base00 #657b83 base0 #839496 base1 #93a1a1 base2 #eee8d5 base3 #fdf6e3 yellow #b58900 orange #cb4b16 red #dc322f magenta #d33682 violet #6c71c4 blue #268bd2 cyan #2aa198 green #859900 */ code[class*="language-"], pre[class*="language-"] { color: #657b83; /* base00 */ font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; font-size: 1em; text-align: left; white-space: pre; word-spacing: normal; word-break: normal; word-wrap: normal; line-height: 1.5; -moz-tab-size: 4; -o-tab-size: 4; tab-size: 4; -webkit-hyphens: none; -moz-hyphens: none; -ms-hyphens: none; hyphens: none; } pre[class*="language-"]::-moz-selection, pre[class*="language-"] ::-moz-selection, code[class*="language-"]::-moz-selection, code[class*="language-"] ::-moz-selection { background: #073642; /* base02 */ } pre[class*="language-"]::selection, pre[class*="language-"] ::selection, code[class*="language-"]::selection, code[class*="language-"] ::selection { background: #073642; /* base02 */ } /* Code blocks */ pre[class*="language-"] { padding: 1em; margin: .5em 0; overflow: auto; border-radius: 0.3em; } :not(pre) > code[class*="language-"], pre[class*="language-"] { background-color: #f0f0f1; /* base3 */ } /* Inline code */ :not(pre) > code[class*="language-"] { padding: .1em; border-radius: .3em; } .token.comment, .token.prolog, .token.doctype, .token.cdata { color: #93a1a1; /* base1 */ } .token.punctuation { color: #586e75; /* base01 */ } .namespace { opacity: .7; } .token.property, .token.tag, .token.boolean, .token.number, .token.constant, .token.symbol, .token.deleted { color: #268bd2; /* blue */ } .token.selector, .token.attr-name, .token.string, .token.char, .token.builtin, .token.url, .token.inserted { color: #2aa198; /* cyan */ } .token.entity { color: #657b83; /* base00 */ background: #eee8d5; /* base2 */ } .token.atrule, .token.attr-value, .token.keyword { color: #859900; /* green */ } .token.function, .token.class-name { color: #b58900; /* yellow */ } .token.regex, .token.important, .token.variable { color: #cb4b16; /* orange */ } .token.important, .token.bold { font-weight: bold; } .token.italic { font-style: italic; } .token.entity { cursor: help; } ================================================ FILE: docs/assets/css/prism_custom.scss ================================================ .token.atrule, .token.attr-value, .token.keyword { color: #07a7fd; } .token.function, .token.class-name { color: #0352c2; } .token.selector, .token.attr-name, .token.string, .token.char, .token.builtin, .token.url, .token.inserted { color: #7fae17; } code[class*="language-"], pre[class*="language-"] { color: #1e252f; } .token.punctuation { color: #1e252f; } .token.regex, .token.important, .token.variable { color: #ea1473; } .highlight pre[class*="language-"] { line-height: 25px; } ================================================ FILE: docs/assets/css/styles.scss ================================================ --- # Frontmatter --- @import "_variables"; @import "bootstrap/scss/bootstrap"; @import "global"; @import "components"; @import "collection_browser"; @import "examples"; @import "subpage"; @import "prism_custom"; @import "pages/cookie-policy"; @import "pages/home"; @import "pages/support"; @import "pages/contact"; @import "utilities"; body.index-page { .header-shapes-bottom { background-image: url('{{site.baseurl}}/assets/img/home/terratest_top_right.svg'); } } ================================================ FILE: docs/assets/css/subpage.scss ================================================ .subpage { .subpage-top-spacing.main.subpage__main { padding-top: 120px; } .subpage__header { background: $primary-gradient-start-color; background: -webkit-linear-gradient(-45deg, $primary-gradient-start-color, $primary-gradient-stop-color); background: linear-gradient(135deg, $primary-gradient-start-color, $primary-gradient-stop-color); position: relative; margin-top: 0; padding-top: 30px; padding-bottom: 70px; z-index: 1; .subpage__hero { position: relative; z-index: 1; text-align: center; h1 { font-weight: bold; margin-bottom: 5px; } .subtitle { color: #fff; font-size: 22px; } } .header { z-index: 20; position: relative; } .overflow-hide { overflow: hidden; width: 100%; height: 100%; max-width: 100vw; position: relative; } .header-bg { position: absolute; width: 100%; height: 100%; left: 0; top: 0; z-index: 0; max-width: 100vw; .header-shapes-left { z-index: 0; position: absolute; top: 50%; margin-top: -63px; width: 400px; margin-left: 0; left: 0; transition: all 0.2s ease; @media all and (max-width: 410px) { max-width: 400px; width: 90%; } } .header-shapes-right { z-index: 0; position: absolute; right: 10%; width: 230px; bottom: -50px; transition: all 0.5s ease; } } } .main { padding-top: 15px; padding-bottom: 60px; &.subpage__main { padding: 30px 15px 120px; max-width: 800px; margin: auto; } h1, h2, h3, h4, h5, h6 { font-weight: bold; margin-bottom: 5px; margin-top: 50px; } h1 { font-size: 35px; } h2 { font-size: 32px; } h3 { font-size: 29px; } h4 { font-size: 23px; } h5 { font-size: 16px; } h6 { font-size: 13px; } ul { margin-top: 15px; li p { margin-bottom: 0px; } } } } @media all and (max-width: 768px) { .header-shapes-right { display: none; } } ================================================ FILE: docs/assets/css/utilities.scss ================================================ .text-red { color: #f44336; } .text-pink { color: #e91e63; } .text-purple { color: #9c27b0; } .text-deep-purple { color: #673ab7; } .text-indigo { color: #3f51b5; } .text-blue { color: #2196f3; } .text-light-blue { color: #03a9f4; } .text-cyan { color: #00bcd4; } .text-teal { color: #009688; } .text-green { color: #4caf50; } .text-light-green { color: #8bc34a; } .text-lime { color: #cddc39; } .text-yellow { color: #ffeb3b; } .text-amber { color: #ffc107; } .text-orange { color: #ff9800; } .text-deep-orange { color: #ff5722; } .text-brown { color: #795548; } .text-gray { color: #545454; } .text-default { color: #1e252f; > a { color: #1e252f; &:hover, &:focus { color: #1e252f; } } } .text-white, .link-white, .text-white > a, .link-white > a { color: #fff; } a { &.text-white, &.link-white { color: #fff; } } .text-white:hover, .link-white:hover, .text-white > a:hover, .link-white > a:hover { color: #fff; } a { &.text-white:hover, &.link-white:hover { color: #fff; } } .text-white:focus, .link-white:focus, .text-white > a:focus, .link-white > a:focus { color: #fff; } a { &.text-white:focus, &.link-white:focus { color: #fff; } } .link-muted { color: rgba(10, 12, 16, 0.6); > a { color: rgba(10, 12, 16, 0.6); } } .text-muted > a, a.text-muted { color: rgba(10, 12, 16, 0.6); } .link-muted { &:hover, > a:hover { color: rgba(30, 37, 47, 0.6); } } .text-muted > a:hover, a.text-muted:hover { color: rgba(30, 37, 47, 0.6); } .link-muted { &:focus, > a:focus { color: rgba(30, 37, 47, 0.6); } } .text-muted > a:focus, a.text-muted:focus { color: rgba(30, 37, 47, 0.6); } @media (max-width: 992px) { .text-sm-center { text-align: center; } } body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; font-weight: 400; } .text-normal { font-weight: 400; } .text-bold { font-weight: 700; } .text-lg { font-size: 20px; } .big { font-size: 115%; } .link-decoration-none { text-decoration: none; &:hover, &:focus { text-decoration: none; } } h1 a { color: inherit; text-decoration: none; &:focus, &:hover { color: inherit; text-decoration: none; } } h2 a { color: inherit; text-decoration: none; &:focus, &:hover { color: inherit; text-decoration: none; } } h3 a { color: inherit; text-decoration: none; &:focus, &:hover { color: inherit; text-decoration: none; } } h4 a { color: inherit; text-decoration: none; &:focus, &:hover { color: inherit; text-decoration: none; } } h5 a { color: inherit; text-decoration: none; &:focus, &:hover { color: inherit; text-decoration: none; } } h6 a { color: inherit; text-decoration: none; &:focus, &:hover { color: inherit; text-decoration: none; } } .text-tiny { font-size: 12px; } .text-large { font-size: 22px; line-height: 1.33; } .margin-top-lg { margin-top: 30px; } .margin-top-xlg { margin-top: 50px; } .margin-top-none { margin-top: 0 !important; } .margin-bottom-none { margin-bottom: 0 !important; } .padding-top-none { padding-top: 0 !important; } .padding-bottom-none { padding-bottom: 0 !important; } .page-header { padding-bottom: 21.6px; margin: 60px 0 45px; &.page-header-sm { margin: 40.5px 0 21.6px; padding-bottom: 10.8px; } } .header, .main { background-color: transparent; } .pull-none { float: none; } .container .container { width: 100%; } .row img { max-width: 100%; } .icon-flipped { transform: scaleX(-1); -moz-transform: scaleX(-1); -webkit-transform: scaleX(-1); -ms-transform: scaleX(-1); } .d-inline-block { display: inline-block; } .flex { display: -webkit-flex; display: -moz-flex; display: -ms-flex; display: -o-flex; display: flex; } .flex-column { -webkit-flex-direction: column; -ms-flex-direction: column; flex-direction: column; } .flex-middle { -webkit-justify-content: center; justify-content: center; } .flex-full { -webkit-flex: 1 1 auto; -ms-flex: 1 1 auto; flex: 1 1 auto; } .flex-1 { -webkit-flex: 1; -ms-flex: 1; flex: 1; } .underline { text-decoration: underline; } .nowrap-white-space { white-space: nowrap; } ================================================ FILE: docs/assets/img/favicon/browserconfig.xml ================================================ #ffffff ================================================ FILE: docs/assets/img/favicon/manifest.json ================================================ { "name": "App", "icons": [ { "src": "/assets/img/favicon/android-icon-36x36.png", "sizes": "36x36", "type": "image\/png", "density": "0.75" }, { "src": "/assets/img/favicon/android-icon-48x48.png", "sizes": "48x48", "type": "image\/png", "density": "1.0" }, { "src": "/assets/img/favicon/android-icon-72x72.png", "sizes": "72x72", "type": "image\/png", "density": "1.5" }, { "src": "/assets/img/favicon/android-icon-96x96.png", "sizes": "96x96", "type": "image\/png", "density": "2.0" }, { "src": "/assets/img/favicon/android-icon-144x144.png", "sizes": "144x144", "type": "image\/png", "density": "3.0" }, { "src": "/assets/img/favicon/android-icon-192x192.png", "sizes": "192x192", "type": "image\/png", "density": "4.0" } ] } ================================================ FILE: docs/assets/js/collection-browser_scroll.js ================================================ $(document).ready(function () { const getElementForDataSelector = function (parentElement, selectorName, elementName) { const selector = parentElement.data(selectorName); if (!selector) { throw new Error(`You must specify a 'data-${selectorName}' attribute for '${elementName}'.`); } const element = $(selector); if (element.length !== 1) { throw new Error(`Expected one element that matched selector '${selector}' for '${elementName}' but got ${element.length}`); } return element; }; // Move the TOC on the left side of the page with the user as the user scrolls down, so the TOC is always visible. // Only start moving the TOC once the user has scrolled past the element specified in scroll-after-selector. Stop // moving it at the bottom of the content. const moveToCWithScrolling = function () { const sidebar = $(".js-scroll-with-user"); const scrollAfter = getElementForDataSelector(sidebar, 'scroll-after-selector', 'moveTocWithScrolling'); const scrollUntil = getElementForDataSelector(sidebar, 'scroll-until-selector', 'moveTocWithScrolling'); const scrollPosition = $(window).scrollTop(); const scrollAfterHeightBottom = scrollAfter.offset().top + scrollAfter.innerHeight(); const contentHeight = scrollUntil.innerHeight() + scrollAfterHeightBottom; const sidebarHeight = sidebar.height(); const sidebarBottomPos = scrollPosition + sidebarHeight; // Only start moving the TOC once we're past the scroll-after item if (scrollPosition >= scrollAfterHeightBottom) { // Stop moving the TOC when we're at the bottom of the content if (sidebarBottomPos >= contentHeight) { sidebar.removeClass('fixed'); sidebar.addClass('bottom'); } else { sidebar.addClass('fixed'); sidebar.removeClass('bottom'); } } else { sidebar.removeClass('fixed'); sidebar.removeClass('bottom'); } }; // Show a dot next to the part of the TOC where the user has scrolled to. We can't use bootstrap's built-in ScrollSpy // because with Bootstrap 3.3.7, it only works with a Bootstrap Nav, whereas our TOC is auto-generated and does not // use Bootstrap Nav classes/markup. const scrollSpy = function () { const content = $(".js-scroll-spy"); const nav = getElementForDataSelector(content, 'scroll-spy-nav-selector', 'scrollSpy'); const allNavLinks = nav.find('a'); allNavLinks.removeClass('selected'); // Only consider an item in view if it's visible in the top 20% of the screen const buffer = $(window).height() / 5; const scrollPosition = $(window).scrollTop(); const contentHeadings = content.find('h2, h3, h4'); const visibleHeadings = contentHeadings.filter((index, el) => scrollPosition + buffer >= $(el).offset().top); if (visibleHeadings.length > 0) { const selectedHeading = visibleHeadings.last(); const selectedHeadingId = selectedHeading.attr('id'); if (selectedHeadingId) { const hash = `#${selectedHeadingId}`; const selectedNavLink = nav.find(`a[href$='${hash}']`); if (selectedNavLink.length > 0) { selectedNavLink.addClass('selected'); const allTopLevelNavListItems = nav.find('.sectlevel1 > li'); const parentNavListItem = selectedNavLink.parents('.sectlevel2').parent(); const topLevelNavListItem = selectedNavLink.parents('.sectlevel1'); } } } }; $(window).scroll(moveToCWithScrolling); $(moveToCWithScrolling); $(window).scroll(scrollSpy); $(scrollSpy); $('.post-detail img').on('click', function () { window.open(this.src, '_blank') }) }); ================================================ FILE: docs/assets/js/collection-browser_search.js ================================================ /** * Javascript for the Collection Browser search. * * TOC: * - FILTER FUNCTIONS - functions to extract the terms from DOM element(s) and use them in search engine to show/hide elements. * - MAIN - INITIALIZE - initializes Browser Search and registers actions (click etc.) on filter components. * - SEARCH ENGINE - here is the logic to show & hide elements accoriding to filters terms. * - OTHER */ (function () { /** ********************************************************************* **/ /** ********* FILTER FUNCTIONS ********* **/ /** ********************************************************************* **/ /** * Note * This function is wrapped in a "debounce" so that if the user is typing quickly, we aren't trying to run searches * (and fire Google Analytics events!) on every key stroke, but only when they pause from typing. * @type {Function} */ const searchInputFilter = debounce(function (event) { const target = $(event.currentTarget) const collectionName = target.data('collection_name') filterSearchData(collectionName) }, 250); /** ********************************************************************* **/ /** ********* MAIN - INITIALIZE ********* **/ /** ********************************************************************* **/ /** * Bind actions to search input and tags. * If you want to add more filter components (like cloud providers on gruntwork.io/guides) * you can register actions on them here. */ function initializeCbSearch(el) { const collectionName = el.data('collection_name') /* SEARCH INPUT box on page */ $('#cb-search-box-'+collectionName).on("keyup", searchInputFilter); /* Triggered when TAGS filter checkboxes are checked */ $(document) .on('click', '[data-collection_name="'+collectionName+'"] .tags', function () { filterSearchData(collectionName); }); } /* Find collection browser's search component on the page and initialize: */ if($('.cb-search-cmp').length > 0) { $('.cb-search-cmp').each(function () { initializeCbSearch($(this)) showNoResults(false) }) } /** ********************************************************************* **/ /** ********* SEARCH ENGINE ********* **/ /** ********************************************************************* **/ /** * Filters posts/docs/entries against search input and tags. * It always gets terms from both: search input and tags whenever any of them changed. */ function filterSearchData(collectionName) { // Get data from all filter components // a) Get Search input: const searchInputValue = $('#cb-search-box-'+collectionName).val().toLowerCase().split(" ").filter(v => v != '') // b) Get tags: let checkedTags = [] $('[data-collection_name="'+collectionName+'"] input[type="checkbox"]:checked') .each(function () { checkedTags.push($(this).val()) }) // If there is no filter terms, show all posts. Otherwise, filter posts: if (searchInputValue.length === 0 && checkedTags.length === 0) { showNoResults(false) showAll() } else { // Get the list of posts and categories to show const toShow = filterDocs(collectionName, searchInputValue, checkedTags) // If there is no posts to show, display no-results component if (toShow.docs.length === 0) { hideAll() showNoResults(true) } else { // Hide no-results component showNoResults(false) // Hide all elements hideAll() // Show elements toShow.docs.forEach(docId => { showDoc(docId) }) toShow.categories.forEach(catId => { showCategory(catId) }) } } } /** * Filter docs (entries) against search input and checked tags * It returns list of documents and categories to show (satisfying search terms). */ function filterDocs(collectionName, searchInputValue, checkedTags) { // Fetch docs data const docs = fetchDocsData(collectionName) let toShowDocs = [] let toShowCategories = [] // Check each doc's data against search value and selected tags: docs.forEach(doc => { if (containsText(doc, searchInputValue) && containsTag(doc, checkedTags)) { toShowDocs.push(doc.id) if (toShowCategories.indexOf(doc.category) === -1) { toShowCategories.push(doc.category.replace(/\s+/g, '-')) } } }) return { docs: toShowDocs, categories: toShowCategories } } function containsTerms(content, terms) { let allMatches = true terms.forEach(term => { if (content.indexOf(term.toLowerCase()) < 0) { allMatches = false } }) return allMatches } function containsText(doc, terms) { const content = doc.text || doc.title + " " + doc.excerpt + " " + doc.category + " " + doc.content + " " + doc.tags return containsTerms(content, terms) } function containsTag(doc, terms) { const content = doc.tags return containsTerms(content, terms) } /** * Function to fetch posts/docs data. * Now it gets from window, but it can be transformed to get it from API. */ function fetchDocsData(collectionName) { return window['bc_'+collectionName+'Entries'] } /** ********************************************************************* **/ /** ********* OTHER ********* **/ /** ********************************************************************* **/ // Returns a function, that, as long as it continues to be invoked, will not be // triggered. The function will be called after it stops being called for N // milliseconds. If `immediate` is passed, trigger the function on the leading // edge, instead of the trailing. Ensures a given task doesn't fire so often // that it bricks browser performance. From: // https://davidwalsh.name/javascript-debounce-function function debounce(func, wait, immediate) { let timeout return function () { const context = this, args = arguments const later = function () { timeout = null if (!immediate) func.apply(context, args) }; const callNow = immediate && !timeout clearTimeout(timeout) timeout = setTimeout(later, wait) if (callNow) func.apply(context, args) }; } /** * Functions to show & hide items on the page */ function showAll() { $('.cb-doc-card').show() $('.category-head').show() $('.categories ul li').show() } function hideAll() { $('.cb-doc-card').hide() $('.category-head').hide() $('.categories ul li').hide() } function showDoc(docId) { $('#' + docId + '.cb-doc-card').show() } function showCategory(categoryId) { $(`.categories ul [data-category=${categoryId}]`).show() $(`#${categoryId}.category-head`).show() } /** * Show / hide no-results component */ function showNoResults(state) { if (state) { $('#no-matches').show() } else { $('#no-matches').hide() } } }()); ================================================ FILE: docs/assets/js/collection-browser_toc.js ================================================ $(document).ready(function () { $('#toc-toggle-open').on('click', function () { $('.col-md-2-5').addClass('opened') $('body').addClass('modal-opened') }) $('#toc-toggle-close').on('click', function () { $('.col-md-2-5').removeClass('opened') $('body').removeClass('modal-opened') }) $('#toc a').on('click', function (){ $('.col-md-2-5').removeClass('opened') $('body').removeClass('modal-opened') }) /* Collapsing toc */ $('#toc ul ul').addClass('collapse') // Change initial icon for nav without children: $('#toc .nav-collapse-handler').each(function () { if ($(this).siblings('ul').length === 0) { $(this).find('.glyphicon').removeClass('glyphicon-triangle-bottom') $(this).find('.glyphicon').addClass('glyphicon-chevron-down') $(this).addClass('no-children') } }) // Expand / collpase on click $('#toc .nav-collapse-handler').on('click', function() { toggleNav($(this)) }) $(docSidebarInitialExpand) }) // Expand / collpase on click function toggleNav(el) { if (el.hasClass('collapsed')) { if (!el.hasClass('no-children')) { el.removeClass('collapsed') el.siblings('ul').collapse('show') } } else { el.addClass('collapsed') el.siblings('ul').collapse('hide') } } const docSidebarInitialExpand = function () { const toc = $('#toc') const pathname = window.location.pathname const hash = window.location.hash toc.find('a[href="'+pathname+hash+'"]').each(function(i, nav) { $(nav).parents('ul').each(function(i, el) { $(el).collapse('show') $(el).siblings('span.nav-collapse-handler:not(.no-children)').removeClass('collapsed') $(el).siblings('span.nav-collapse-handler').addClass('active') }) $(nav).siblings('span.nav-collapse-handler:not(.no-children)').removeClass('collapsed') $(nav).siblings('span.nav-collapse-handler').addClass('active') $(nav).siblings('ul').collapse('show') }) } ================================================ FILE: docs/assets/js/contact-form.js ================================================ /* Contact form */ $(function() { var submitButton = $("#submit-button"); var form = $("#contact-form"); selectPlanFromUrl(); submitButton.on("click", submitForm); function selectPlanFromUrl() { var params = window.location.search; if (params === "?plan=enterprise") { var plan = $("#enterprise"); plan.prop('checked', true); } else { var plan = $("#pro"); plan.prop('checked', true); } } function submitForm(e) { e.preventDefault(); if(validateForm()) { var data = form.serialize(form.get(0), { hash: true }); submitToFormSpree(data); } function submitToFormSpree(data) { submitButton.html("Sending..."); submitButton.prop("disabled", true); var postParams = { url: form.attr('action'), type: "POST", data: data, dataType: "json" }; $.ajax(postParams) .done(function() { inCall = false; window.location.replace("/thanks"); }) .fail(function() { showFormError( "Oops, something went wrong! Please try again. If the issue persists please email us directly at info@gruntwork.io" ); inCall = false; submitButton.html("Submit"); submitButton.prop("disabled", false); }); } function showInputError(el) { $(el).addClass("has-error"); }; function showFormError(message) { $("#error-message").html( '

' + message + "

" ); }; function clearErrors() { $("#error-message").html(""); form.find("*").removeClass("has-error"); }; function validateForm() { var isValid = true; clearErrors(); form.find("[required]").each(function(index, el) { if (!$(el).val()) { isValid = false; showInputError(el); showFormError("Please fill in all required fields"); } }); return isValid; }; } }); ================================================ FILE: docs/assets/js/cookie.js ================================================ --- --- /** * Cookie notice * @author AKOS * * This cookie script must load AFTER the Intercom code above to detect the global "Intercom" variable * and render the cookie notice right after Intercom's script is injected into the . This will ensure * that our cookie notice renders ABOVE the Intercom bubble to avoid conflicts with z-index. */ (function ($) { "use strict"; var cookieInnerHtml = '

By using this website you agree to our cookie policy

'; var initCookie = function () { // Don't create cookie notice if already acknowledged if (getCookiebyName('GruntyCookie')) { return; } // Create the cookie modal var $cookieModal = $('
'); $cookieModal.attr('id', 'gruntyCookie'); $cookieModal.css('z-index', '2147483647'); $cookieModal.html(cookieInnerHtml); $(document).on('click', '#cookieModalClose', function () { setCookie('GruntyCookie', '1', 365); $cookieModal.hide(); }); $('body').append($cookieModal); }; initCookie(); })(window.jQuery); ================================================ FILE: docs/assets/js/examples.js ================================================ --- --- $(document).ready(function () { const CODE_LINE_HEIGHT = 22 const CODE_BLOCK_PADDING = 10 window.examples = { tags: {}, nav: {} } initExamplesNav() $(window).resize($.debounce(250, function() { buildExamplesNav() })) // Activate first example $('.examples__container').each(function (i, ec) { // Find first element: const firstElementId = $(ec).find('.examples__nav-item').data('id') // Open first element: openExample($(ec).attr('id'), firstElementId) // Open example when user clicks on tab $('.navs').on('click', '.examples__nav-item:not(.static-link)', function() { openExample($(ec).attr('id'), $(this).data('id')) $('.navs__dropdown-menu').removeClass('active') }) }) // Open example and scroll to examples section when user clicks on // tech in the header $('.link-to-test-with-terratest').on('click', function() { // Find any containting the keyword from data-target const found = $('.navs .examples__nav-item[data-id*="'+$(this).data('target')+'"]') if (found && found.length > 0) { openExample('index_page', $(found[0]).data('id')) } else { // RESCUE: If none found, open any (first available): openExample('index_page', $($('.navs .examples__nav-item')[0]).data('id')) } scrollToTests() }) // Switch between code snippets (files) $('.examples__tabs .tab').on('click', function() { $(this).parents('.examples__tabs').find('.tab').removeClass('active') $(this).addClass('active') $(this).parents('.examples__block').find('.examples__code').removeClass('active') $($(this).data('target')).addClass('active') loadCodeSnippet() }) // Open dropdown of technologies to select $('.navs__dropdown-arrow').on('click', function() { $('.navs__dropdown-menu').toggleClass('active') }) // Open popup when user click on circle with the number $('.examples__container').on('click', '.code-popup-handler', function() { const isActive = $(this).hasClass('active') $('.code-popup-handler').removeClass('active') if (!isActive) { $(this).addClass('active') } }) function scrollToTests() { $([document.documentElement, document.body]).animate({ scrollTop: $('#index-page__test-with-terratest').offset().top }, 500) } function openExample(exampleContainerId, target) { // Change active nav in window state and rebuild navigation first const $ecId = $('#'+exampleContainerId) window.examples.nav[exampleContainerId].current = target buildExamplesNav() // Change active tab in navigation $ecId.find('.examples__nav-item').removeClass('active') const jTarget = $('.navs .examples__nav-item[data-id="'+target+'"]') jTarget.addClass('active') // Change the block below navigation (with code snippets) $ecId.find('.examples__block').removeClass('active') $ecId.find('#example__block-' + target).addClass('active') // Set current tab $ecId.find('.examples__nav .navs').removeClass('active') loadCodeSnippet() } function loadCodeSnippet() { $('.examples__block.active .examples__code.active').each(async function (i, activeCodeSnippet) { const $activeCodeSnippet = $(activeCodeSnippet) const exampleTarget = $(this).data('example') const fileId = $(this).data('target') const snippetId = $(this).data('snippet-id') if (!$activeCodeSnippet.data('loaded')) { try { const response = await fetch($activeCodeSnippet.data('url')) let content = await response.text() $activeCodeSnippet.attr('data-loaded', true) if ($activeCodeSnippet.data('skip-tags')) { // Remove the website::tag::xxx:: prefix from the code snippet content = content.replace(/website::tag::.*?:: ?/mg, '') } else { findTags(content, exampleTarget, fileId) // Remove the website::tag::xxx:: comment entirely from the code snippet content = content.replace(/^.*website::tag.*\n?/mg, '') } // Find the range specified by range-id if specified if (snippetId) { snippet = extractSnippet(content, snippetId) $activeCodeSnippet.find('code').text(snippet) } else { $activeCodeSnippet.find('code').text(content) } Prism.highlightAll() } catch(err) { $activeCodeSnippet.find('code').text('Resource could not be loaded.') console.error(err) } } updatePopups() openPopup(exampleTarget, 1) }) } function extractSnippet(content, snippetId) { // Split the content into an array of lines lines = content.split('\n') // Search the array for "snippet-tag-start::{id}" - save location const startLine = searchTagInLines(`snippet-tag-start::${snippetId}`, lines) // Search the array for "snippet-tag-end::{id}" - save location const endLine = searchTagInLines(`snippet-tag-end::${snippetId}`, lines) // If you have both a start and end, slice as below if (startLine >= 0 && endLine >= 0) { const range = lines.slice(startLine + 2, endLine) return range.join('\n') } else { console.error('Could not find specified range.') return content } } function searchTagInLines (tagRegExp, lines) { return lines.findIndex(line => line.match(tagRegExp)) } function findTags(content, exampleTarget, fileId) { let tags = [] let regexpTags = /website::tag::(\d)::\s*(.*)/mg let match = regexpTags.exec(content) do { if (match && match.length > 0) { tags.push({ text: match[2], tag: match[0], step: match[1], line: findLineNumber(content, match[0]) }) } } while((match = regexpTags.exec(content)) !== null) window.examples.tags[exampleTarget] = Object.assign({ [fileId]: tags }, window.examples.tags[exampleTarget] ) } function findLineNumber(content, text) { let tagIndex = content.indexOf(text) let tempString = content.substring(0, tagIndex) let lineNumber = tempString.split('\n').length return lineNumber } function updatePopups() { $('.code-popup-handler').remove() const activeCode = $('.examples__block.active .examples__code.active') const exampleTarget = activeCode.data('example') const fileId = activeCode.data('target') const exampleTargetTags = window.examples.tags[exampleTarget] || {}; const fileTags = exampleTargetTags[fileId]; if (fileTags) { const tagsLen = fileTags.length fileTags.map( function(v,k) { const top = (CODE_LINE_HEIGHT * (v.line - k)) + CODE_BLOCK_PADDING; // If two pop-ups are close to each other, add CSS class that will scale them down let scaleClass = '' if ( (k > 0 && Math.abs(v.line - fileTags[k-1].line) < 3 ) || (k < tagsLen - 1 && Math.abs(v.line - fileTags[k+1].line) < 3 ) ) { scaleClass = 'sm-scale' } const elToAppend = '
' + '' + v.step + '' + '
' + '' const code = $("#example__code-"+exampleTarget+"-"+fileId) code.append(elToAppend) }) } openPopup(exampleTarget, 0) } function openPopup(techName, step) { $('.code-popup-handler').removeClass('active') $('#example__block-'+techName).find('.code-popup-handler[data-step="'+step+'"]').addClass('active') } function loadExampleDescription(name) { return $('#index-page__examples').find('#example__block-'+name+' .description').html() } function initExamplesNav() { window.examples.nav = {} $('.examples__container').each(function(eci, ec) { $(ec).find('.examples__nav .hidden-navs').each(function(rni, refNavs) { let navsArr = [] let currentNav $(refNavs).find('.examples__nav-item').each( function(ni, nav) { if ($(nav).hasClass('active')) { currentNav = $(nav).data('id') } navsArr.push($(nav)) }) window.examples.nav = Object.assign({ [$(ec).attr('id')]: { current: currentNav, items: navsArr } }, window.examples.nav) }) }) } function buildExamplesNav() { $('.examples__container').each(function(eci, ec) { const ecId = $(ec).attr('id') const containerWidth = $(ec).width() const NAV_WIDTH = 150 const ARROW_SLOT_WIDTH = 100 const noOfVisible = Math.floor((containerWidth - NAV_WIDTH - ARROW_SLOT_WIDTH) / 150) const $visibleBar = $($(ec).find('.navs__visible-bar')) const $dropdownInput = $($(ec).find('.navs__dropdown-input')) const $dropdownMenu = $($(ec).find('.navs__dropdown-menu')) $visibleBar.html('') $dropdownInput.html('') $dropdownMenu.html('') let settingCurrent = false // Build initial a navigation bar if (window.examples.nav && ecId in window.examples.nav && window.examples.nav[ecId].items) { // Visible elements let breakSlice = noOfVisible > window.examples.nav[ecId].items.length ? window.examples.nav[ecId].items.length : noOfVisible let visibleEls = window.examples.nav[ecId].items.slice(0, breakSlice) let hiddenEls = window.examples.nav[ecId].items.slice(breakSlice, window.examples.nav[ecId].items.length) let visibleNavIsActive = false let hiddenNavIsActive = -1 if (window.examples.nav[ecId].current) { visibleEls.map( function(x,i) { if(x.data('id') === window.examples.nav[ecId].current) { visibleNavIsActive = true x.addClass('active') } }) hiddenEls.map( function(x,i) { if(x.data('id') === window.examples.nav[ecId].current) { hiddenNavIsActive = i x.addClass('active') } }) } visibleEls.map(function(nav,i) { $visibleBar.append($(nav).clone()) }) if (hiddenNavIsActive > -1) { const sliced = hiddenEls.splice(hiddenNavIsActive, 1) $dropdownInput.append($(sliced[0]).clone()) } else { $dropdownInput.append($(hiddenEls.shift()).clone()) } hiddenEls.map(function(nav,i) { $dropdownMenu.append($(nav).clone()) }) // Add static links $dropdownMenu.append($(ec).find('.hidden-navs__static-links').html()) } }) } }) ================================================ FILE: docs/assets/js/main.js ================================================ /*! jQuery v1.12.4 | (c) jQuery Foundation | jquery.org/license */ !function(a,b){"object"==typeof module&&"object"==typeof module.exports?module.exports=a.document?b(a,!0):function(a){if(!a.document)throw new Error("jQuery requires a window with a document");return b(a)}:b(a)}("undefined"!=typeof window?window:this,function(a,b){var c=[],d=a.document,e=c.slice,f=c.concat,g=c.push,h=c.indexOf,i={},j=i.toString,k=i.hasOwnProperty,l={},m="1.12.4",n=function(a,b){return new n.fn.init(a,b)},o=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,p=/^-ms-/,q=/-([\da-z])/gi,r=function(a,b){return b.toUpperCase()};n.fn=n.prototype={jquery:m,constructor:n,selector:"",length:0,toArray:function(){return e.call(this)},get:function(a){return null!=a?0>a?this[a+this.length]:this[a]:e.call(this)},pushStack:function(a){var b=n.merge(this.constructor(),a);return b.prevObject=this,b.context=this.context,b},each:function(a){return n.each(this,a)},map:function(a){return this.pushStack(n.map(this,function(b,c){return a.call(b,c,b)}))},slice:function(){return this.pushStack(e.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(a){var b=this.length,c=+a+(0>a?b:0);return this.pushStack(c>=0&&b>c?[this[c]]:[])},end:function(){return this.prevObject||this.constructor()},push:g,sort:c.sort,splice:c.splice},n.extend=n.fn.extend=function(){var a,b,c,d,e,f,g=arguments[0]||{},h=1,i=arguments.length,j=!1;for("boolean"==typeof g&&(j=g,g=arguments[h]||{},h++),"object"==typeof g||n.isFunction(g)||(g={}),h===i&&(g=this,h--);i>h;h++)if(null!=(e=arguments[h]))for(d in e)a=g[d],c=e[d],g!==c&&(j&&c&&(n.isPlainObject(c)||(b=n.isArray(c)))?(b?(b=!1,f=a&&n.isArray(a)?a:[]):f=a&&n.isPlainObject(a)?a:{},g[d]=n.extend(j,f,c)):void 0!==c&&(g[d]=c));return g},n.extend({expando:"jQuery"+(m+Math.random()).replace(/\D/g,""),isReady:!0,error:function(a){throw new Error(a)},noop:function(){},isFunction:function(a){return"function"===n.type(a)},isArray:Array.isArray||function(a){return"array"===n.type(a)},isWindow:function(a){return null!=a&&a==a.window},isNumeric:function(a){var b=a&&a.toString();return!n.isArray(a)&&b-parseFloat(b)+1>=0},isEmptyObject:function(a){var b;for(b in a)return!1;return!0},isPlainObject:function(a){var b;if(!a||"object"!==n.type(a)||a.nodeType||n.isWindow(a))return!1;try{if(a.constructor&&!k.call(a,"constructor")&&!k.call(a.constructor.prototype,"isPrototypeOf"))return!1}catch(c){return!1}if(!l.ownFirst)for(b in a)return k.call(a,b);for(b in a);return void 0===b||k.call(a,b)},type:function(a){return null==a?a+"":"object"==typeof a||"function"==typeof a?i[j.call(a)]||"object":typeof a},globalEval:function(b){b&&n.trim(b)&&(a.execScript||function(b){a.eval.call(a,b)})(b)},camelCase:function(a){return a.replace(p,"ms-").replace(q,r)},nodeName:function(a,b){return a.nodeName&&a.nodeName.toLowerCase()===b.toLowerCase()},each:function(a,b){var c,d=0;if(s(a)){for(c=a.length;c>d;d++)if(b.call(a[d],d,a[d])===!1)break}else for(d in a)if(b.call(a[d],d,a[d])===!1)break;return a},trim:function(a){return null==a?"":(a+"").replace(o,"")},makeArray:function(a,b){var c=b||[];return null!=a&&(s(Object(a))?n.merge(c,"string"==typeof a?[a]:a):g.call(c,a)),c},inArray:function(a,b,c){var d;if(b){if(h)return h.call(b,a,c);for(d=b.length,c=c?0>c?Math.max(0,d+c):c:0;d>c;c++)if(c in b&&b[c]===a)return c}return-1},merge:function(a,b){var c=+b.length,d=0,e=a.length;while(c>d)a[e++]=b[d++];if(c!==c)while(void 0!==b[d])a[e++]=b[d++];return a.length=e,a},grep:function(a,b,c){for(var d,e=[],f=0,g=a.length,h=!c;g>f;f++)d=!b(a[f],f),d!==h&&e.push(a[f]);return e},map:function(a,b,c){var d,e,g=0,h=[];if(s(a))for(d=a.length;d>g;g++)e=b(a[g],g,c),null!=e&&h.push(e);else for(g in a)e=b(a[g],g,c),null!=e&&h.push(e);return f.apply([],h)},guid:1,proxy:function(a,b){var c,d,f;return"string"==typeof b&&(f=a[b],b=a,a=f),n.isFunction(a)?(c=e.call(arguments,2),d=function(){return a.apply(b||this,c.concat(e.call(arguments)))},d.guid=a.guid=a.guid||n.guid++,d):void 0},now:function(){return+new Date},support:l}),"function"==typeof Symbol&&(n.fn[Symbol.iterator]=c[Symbol.iterator]),n.each("Boolean Number String Function Array Date RegExp Object Error Symbol".split(" "),function(a,b){i["[object "+b+"]"]=b.toLowerCase()});function s(a){var b=!!a&&"length"in a&&a.length,c=n.type(a);return"function"===c||n.isWindow(a)?!1:"array"===c||0===b||"number"==typeof b&&b>0&&b-1 in a}var t=function(a){var b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u="sizzle"+1*new Date,v=a.document,w=0,x=0,y=ga(),z=ga(),A=ga(),B=function(a,b){return a===b&&(l=!0),0},C=1<<31,D={}.hasOwnProperty,E=[],F=E.pop,G=E.push,H=E.push,I=E.slice,J=function(a,b){for(var c=0,d=a.length;d>c;c++)if(a[c]===b)return c;return-1},K="checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",L="[\\x20\\t\\r\\n\\f]",M="(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+",N="\\["+L+"*("+M+")(?:"+L+"*([*^$|!~]?=)"+L+"*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|("+M+"))|)"+L+"*\\]",O=":("+M+")(?:\\((('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|((?:\\\\.|[^\\\\()[\\]]|"+N+")*)|.*)\\)|)",P=new RegExp(L+"+","g"),Q=new RegExp("^"+L+"+|((?:^|[^\\\\])(?:\\\\.)*)"+L+"+$","g"),R=new RegExp("^"+L+"*,"+L+"*"),S=new RegExp("^"+L+"*([>+~]|"+L+")"+L+"*"),T=new RegExp("="+L+"*([^\\]'\"]*?)"+L+"*\\]","g"),U=new RegExp(O),V=new RegExp("^"+M+"$"),W={ID:new RegExp("^#("+M+")"),CLASS:new RegExp("^\\.("+M+")"),TAG:new RegExp("^("+M+"|[*])"),ATTR:new RegExp("^"+N),PSEUDO:new RegExp("^"+O),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+L+"*(even|odd|(([+-]|)(\\d*)n|)"+L+"*(?:([+-]|)"+L+"*(\\d+)|))"+L+"*\\)|)","i"),bool:new RegExp("^(?:"+K+")$","i"),needsContext:new RegExp("^"+L+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+L+"*((?:-\\d)?\\d*)"+L+"*\\)|)(?=[^-]|$)","i")},X=/^(?:input|select|textarea|button)$/i,Y=/^h\d$/i,Z=/^[^{]+\{\s*\[native \w/,$=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,_=/[+~]/,aa=/'|\\/g,ba=new RegExp("\\\\([\\da-f]{1,6}"+L+"?|("+L+")|.)","ig"),ca=function(a,b,c){var d="0x"+b-65536;return d!==d||c?b:0>d?String.fromCharCode(d+65536):String.fromCharCode(d>>10|55296,1023&d|56320)},da=function(){m()};try{H.apply(E=I.call(v.childNodes),v.childNodes),E[v.childNodes.length].nodeType}catch(ea){H={apply:E.length?function(a,b){G.apply(a,I.call(b))}:function(a,b){var c=a.length,d=0;while(a[c++]=b[d++]);a.length=c-1}}}function fa(a,b,d,e){var f,h,j,k,l,o,r,s,w=b&&b.ownerDocument,x=b?b.nodeType:9;if(d=d||[],"string"!=typeof a||!a||1!==x&&9!==x&&11!==x)return d;if(!e&&((b?b.ownerDocument||b:v)!==n&&m(b),b=b||n,p)){if(11!==x&&(o=$.exec(a)))if(f=o[1]){if(9===x){if(!(j=b.getElementById(f)))return d;if(j.id===f)return d.push(j),d}else if(w&&(j=w.getElementById(f))&&t(b,j)&&j.id===f)return d.push(j),d}else{if(o[2])return H.apply(d,b.getElementsByTagName(a)),d;if((f=o[3])&&c.getElementsByClassName&&b.getElementsByClassName)return H.apply(d,b.getElementsByClassName(f)),d}if(c.qsa&&!A[a+" "]&&(!q||!q.test(a))){if(1!==x)w=b,s=a;else if("object"!==b.nodeName.toLowerCase()){(k=b.getAttribute("id"))?k=k.replace(aa,"\\$&"):b.setAttribute("id",k=u),r=g(a),h=r.length,l=V.test(k)?"#"+k:"[id='"+k+"']";while(h--)r[h]=l+" "+qa(r[h]);s=r.join(","),w=_.test(a)&&oa(b.parentNode)||b}if(s)try{return H.apply(d,w.querySelectorAll(s)),d}catch(y){}finally{k===u&&b.removeAttribute("id")}}}return i(a.replace(Q,"$1"),b,d,e)}function ga(){var a=[];function b(c,e){return a.push(c+" ")>d.cacheLength&&delete b[a.shift()],b[c+" "]=e}return b}function ha(a){return a[u]=!0,a}function ia(a){var b=n.createElement("div");try{return!!a(b)}catch(c){return!1}finally{b.parentNode&&b.parentNode.removeChild(b),b=null}}function ja(a,b){var c=a.split("|"),e=c.length;while(e--)d.attrHandle[c[e]]=b}function ka(a,b){var c=b&&a,d=c&&1===a.nodeType&&1===b.nodeType&&(~b.sourceIndex||C)-(~a.sourceIndex||C);if(d)return d;if(c)while(c=c.nextSibling)if(c===b)return-1;return a?1:-1}function la(a){return function(b){var c=b.nodeName.toLowerCase();return"input"===c&&b.type===a}}function ma(a){return function(b){var c=b.nodeName.toLowerCase();return("input"===c||"button"===c)&&b.type===a}}function na(a){return ha(function(b){return b=+b,ha(function(c,d){var e,f=a([],c.length,b),g=f.length;while(g--)c[e=f[g]]&&(c[e]=!(d[e]=c[e]))})})}function oa(a){return a&&"undefined"!=typeof a.getElementsByTagName&&a}c=fa.support={},f=fa.isXML=function(a){var b=a&&(a.ownerDocument||a).documentElement;return b?"HTML"!==b.nodeName:!1},m=fa.setDocument=function(a){var b,e,g=a?a.ownerDocument||a:v;return g!==n&&9===g.nodeType&&g.documentElement?(n=g,o=n.documentElement,p=!f(n),(e=n.defaultView)&&e.top!==e&&(e.addEventListener?e.addEventListener("unload",da,!1):e.attachEvent&&e.attachEvent("onunload",da)),c.attributes=ia(function(a){return a.className="i",!a.getAttribute("className")}),c.getElementsByTagName=ia(function(a){return a.appendChild(n.createComment("")),!a.getElementsByTagName("*").length}),c.getElementsByClassName=Z.test(n.getElementsByClassName),c.getById=ia(function(a){return o.appendChild(a).id=u,!n.getElementsByName||!n.getElementsByName(u).length}),c.getById?(d.find.ID=function(a,b){if("undefined"!=typeof b.getElementById&&p){var c=b.getElementById(a);return c?[c]:[]}},d.filter.ID=function(a){var b=a.replace(ba,ca);return function(a){return a.getAttribute("id")===b}}):(delete d.find.ID,d.filter.ID=function(a){var b=a.replace(ba,ca);return function(a){var c="undefined"!=typeof a.getAttributeNode&&a.getAttributeNode("id");return c&&c.value===b}}),d.find.TAG=c.getElementsByTagName?function(a,b){return"undefined"!=typeof b.getElementsByTagName?b.getElementsByTagName(a):c.qsa?b.querySelectorAll(a):void 0}:function(a,b){var c,d=[],e=0,f=b.getElementsByTagName(a);if("*"===a){while(c=f[e++])1===c.nodeType&&d.push(c);return d}return f},d.find.CLASS=c.getElementsByClassName&&function(a,b){return"undefined"!=typeof b.getElementsByClassName&&p?b.getElementsByClassName(a):void 0},r=[],q=[],(c.qsa=Z.test(n.querySelectorAll))&&(ia(function(a){o.appendChild(a).innerHTML="",a.querySelectorAll("[msallowcapture^='']").length&&q.push("[*^$]="+L+"*(?:''|\"\")"),a.querySelectorAll("[selected]").length||q.push("\\["+L+"*(?:value|"+K+")"),a.querySelectorAll("[id~="+u+"-]").length||q.push("~="),a.querySelectorAll(":checked").length||q.push(":checked"),a.querySelectorAll("a#"+u+"+*").length||q.push(".#.+[+~]")}),ia(function(a){var b=n.createElement("input");b.setAttribute("type","hidden"),a.appendChild(b).setAttribute("name","D"),a.querySelectorAll("[name=d]").length&&q.push("name"+L+"*[*^$|!~]?="),a.querySelectorAll(":enabled").length||q.push(":enabled",":disabled"),a.querySelectorAll("*,:x"),q.push(",.*:")})),(c.matchesSelector=Z.test(s=o.matches||o.webkitMatchesSelector||o.mozMatchesSelector||o.oMatchesSelector||o.msMatchesSelector))&&ia(function(a){c.disconnectedMatch=s.call(a,"div"),s.call(a,"[s!='']:x"),r.push("!=",O)}),q=q.length&&new RegExp(q.join("|")),r=r.length&&new RegExp(r.join("|")),b=Z.test(o.compareDocumentPosition),t=b||Z.test(o.contains)?function(a,b){var c=9===a.nodeType?a.documentElement:a,d=b&&b.parentNode;return a===d||!(!d||1!==d.nodeType||!(c.contains?c.contains(d):a.compareDocumentPosition&&16&a.compareDocumentPosition(d)))}:function(a,b){if(b)while(b=b.parentNode)if(b===a)return!0;return!1},B=b?function(a,b){if(a===b)return l=!0,0;var d=!a.compareDocumentPosition-!b.compareDocumentPosition;return d?d:(d=(a.ownerDocument||a)===(b.ownerDocument||b)?a.compareDocumentPosition(b):1,1&d||!c.sortDetached&&b.compareDocumentPosition(a)===d?a===n||a.ownerDocument===v&&t(v,a)?-1:b===n||b.ownerDocument===v&&t(v,b)?1:k?J(k,a)-J(k,b):0:4&d?-1:1)}:function(a,b){if(a===b)return l=!0,0;var c,d=0,e=a.parentNode,f=b.parentNode,g=[a],h=[b];if(!e||!f)return a===n?-1:b===n?1:e?-1:f?1:k?J(k,a)-J(k,b):0;if(e===f)return ka(a,b);c=a;while(c=c.parentNode)g.unshift(c);c=b;while(c=c.parentNode)h.unshift(c);while(g[d]===h[d])d++;return d?ka(g[d],h[d]):g[d]===v?-1:h[d]===v?1:0},n):n},fa.matches=function(a,b){return fa(a,null,null,b)},fa.matchesSelector=function(a,b){if((a.ownerDocument||a)!==n&&m(a),b=b.replace(T,"='$1']"),c.matchesSelector&&p&&!A[b+" "]&&(!r||!r.test(b))&&(!q||!q.test(b)))try{var d=s.call(a,b);if(d||c.disconnectedMatch||a.document&&11!==a.document.nodeType)return d}catch(e){}return fa(b,n,null,[a]).length>0},fa.contains=function(a,b){return(a.ownerDocument||a)!==n&&m(a),t(a,b)},fa.attr=function(a,b){(a.ownerDocument||a)!==n&&m(a);var e=d.attrHandle[b.toLowerCase()],f=e&&D.call(d.attrHandle,b.toLowerCase())?e(a,b,!p):void 0;return void 0!==f?f:c.attributes||!p?a.getAttribute(b):(f=a.getAttributeNode(b))&&f.specified?f.value:null},fa.error=function(a){throw new Error("Syntax error, unrecognized expression: "+a)},fa.uniqueSort=function(a){var b,d=[],e=0,f=0;if(l=!c.detectDuplicates,k=!c.sortStable&&a.slice(0),a.sort(B),l){while(b=a[f++])b===a[f]&&(e=d.push(f));while(e--)a.splice(d[e],1)}return k=null,a},e=fa.getText=function(a){var b,c="",d=0,f=a.nodeType;if(f){if(1===f||9===f||11===f){if("string"==typeof a.textContent)return a.textContent;for(a=a.firstChild;a;a=a.nextSibling)c+=e(a)}else if(3===f||4===f)return a.nodeValue}else while(b=a[d++])c+=e(b);return c},d=fa.selectors={cacheLength:50,createPseudo:ha,match:W,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(a){return a[1]=a[1].replace(ba,ca),a[3]=(a[3]||a[4]||a[5]||"").replace(ba,ca),"~="===a[2]&&(a[3]=" "+a[3]+" "),a.slice(0,4)},CHILD:function(a){return a[1]=a[1].toLowerCase(),"nth"===a[1].slice(0,3)?(a[3]||fa.error(a[0]),a[4]=+(a[4]?a[5]+(a[6]||1):2*("even"===a[3]||"odd"===a[3])),a[5]=+(a[7]+a[8]||"odd"===a[3])):a[3]&&fa.error(a[0]),a},PSEUDO:function(a){var b,c=!a[6]&&a[2];return W.CHILD.test(a[0])?null:(a[3]?a[2]=a[4]||a[5]||"":c&&U.test(c)&&(b=g(c,!0))&&(b=c.indexOf(")",c.length-b)-c.length)&&(a[0]=a[0].slice(0,b),a[2]=c.slice(0,b)),a.slice(0,3))}},filter:{TAG:function(a){var b=a.replace(ba,ca).toLowerCase();return"*"===a?function(){return!0}:function(a){return a.nodeName&&a.nodeName.toLowerCase()===b}},CLASS:function(a){var b=y[a+" "];return b||(b=new RegExp("(^|"+L+")"+a+"("+L+"|$)"))&&y(a,function(a){return b.test("string"==typeof a.className&&a.className||"undefined"!=typeof a.getAttribute&&a.getAttribute("class")||"")})},ATTR:function(a,b,c){return function(d){var e=fa.attr(d,a);return null==e?"!="===b:b?(e+="","="===b?e===c:"!="===b?e!==c:"^="===b?c&&0===e.indexOf(c):"*="===b?c&&e.indexOf(c)>-1:"$="===b?c&&e.slice(-c.length)===c:"~="===b?(" "+e.replace(P," ")+" ").indexOf(c)>-1:"|="===b?e===c||e.slice(0,c.length+1)===c+"-":!1):!0}},CHILD:function(a,b,c,d,e){var f="nth"!==a.slice(0,3),g="last"!==a.slice(-4),h="of-type"===b;return 1===d&&0===e?function(a){return!!a.parentNode}:function(b,c,i){var j,k,l,m,n,o,p=f!==g?"nextSibling":"previousSibling",q=b.parentNode,r=h&&b.nodeName.toLowerCase(),s=!i&&!h,t=!1;if(q){if(f){while(p){m=b;while(m=m[p])if(h?m.nodeName.toLowerCase()===r:1===m.nodeType)return!1;o=p="only"===a&&!o&&"nextSibling"}return!0}if(o=[g?q.firstChild:q.lastChild],g&&s){m=q,l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),j=k[a]||[],n=j[0]===w&&j[1],t=n&&j[2],m=n&&q.childNodes[n];while(m=++n&&m&&m[p]||(t=n=0)||o.pop())if(1===m.nodeType&&++t&&m===b){k[a]=[w,n,t];break}}else if(s&&(m=b,l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),j=k[a]||[],n=j[0]===w&&j[1],t=n),t===!1)while(m=++n&&m&&m[p]||(t=n=0)||o.pop())if((h?m.nodeName.toLowerCase()===r:1===m.nodeType)&&++t&&(s&&(l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),k[a]=[w,t]),m===b))break;return t-=e,t===d||t%d===0&&t/d>=0}}},PSEUDO:function(a,b){var c,e=d.pseudos[a]||d.setFilters[a.toLowerCase()]||fa.error("unsupported pseudo: "+a);return e[u]?e(b):e.length>1?(c=[a,a,"",b],d.setFilters.hasOwnProperty(a.toLowerCase())?ha(function(a,c){var d,f=e(a,b),g=f.length;while(g--)d=J(a,f[g]),a[d]=!(c[d]=f[g])}):function(a){return e(a,0,c)}):e}},pseudos:{not:ha(function(a){var b=[],c=[],d=h(a.replace(Q,"$1"));return d[u]?ha(function(a,b,c,e){var f,g=d(a,null,e,[]),h=a.length;while(h--)(f=g[h])&&(a[h]=!(b[h]=f))}):function(a,e,f){return b[0]=a,d(b,null,f,c),b[0]=null,!c.pop()}}),has:ha(function(a){return function(b){return fa(a,b).length>0}}),contains:ha(function(a){return a=a.replace(ba,ca),function(b){return(b.textContent||b.innerText||e(b)).indexOf(a)>-1}}),lang:ha(function(a){return V.test(a||"")||fa.error("unsupported lang: "+a),a=a.replace(ba,ca).toLowerCase(),function(b){var c;do if(c=p?b.lang:b.getAttribute("xml:lang")||b.getAttribute("lang"))return c=c.toLowerCase(),c===a||0===c.indexOf(a+"-");while((b=b.parentNode)&&1===b.nodeType);return!1}}),target:function(b){var c=a.location&&a.location.hash;return c&&c.slice(1)===b.id},root:function(a){return a===o},focus:function(a){return a===n.activeElement&&(!n.hasFocus||n.hasFocus())&&!!(a.type||a.href||~a.tabIndex)},enabled:function(a){return a.disabled===!1},disabled:function(a){return a.disabled===!0},checked:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&!!a.checked||"option"===b&&!!a.selected},selected:function(a){return a.parentNode&&a.parentNode.selectedIndex,a.selected===!0},empty:function(a){for(a=a.firstChild;a;a=a.nextSibling)if(a.nodeType<6)return!1;return!0},parent:function(a){return!d.pseudos.empty(a)},header:function(a){return Y.test(a.nodeName)},input:function(a){return X.test(a.nodeName)},button:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&"button"===a.type||"button"===b},text:function(a){var b;return"input"===a.nodeName.toLowerCase()&&"text"===a.type&&(null==(b=a.getAttribute("type"))||"text"===b.toLowerCase())},first:na(function(){return[0]}),last:na(function(a,b){return[b-1]}),eq:na(function(a,b,c){return[0>c?c+b:c]}),even:na(function(a,b){for(var c=0;b>c;c+=2)a.push(c);return a}),odd:na(function(a,b){for(var c=1;b>c;c+=2)a.push(c);return a}),lt:na(function(a,b,c){for(var d=0>c?c+b:c;--d>=0;)a.push(d);return a}),gt:na(function(a,b,c){for(var d=0>c?c+b:c;++db;b++)d+=a[b].value;return d}function ra(a,b,c){var d=b.dir,e=c&&"parentNode"===d,f=x++;return b.first?function(b,c,f){while(b=b[d])if(1===b.nodeType||e)return a(b,c,f)}:function(b,c,g){var h,i,j,k=[w,f];if(g){while(b=b[d])if((1===b.nodeType||e)&&a(b,c,g))return!0}else while(b=b[d])if(1===b.nodeType||e){if(j=b[u]||(b[u]={}),i=j[b.uniqueID]||(j[b.uniqueID]={}),(h=i[d])&&h[0]===w&&h[1]===f)return k[2]=h[2];if(i[d]=k,k[2]=a(b,c,g))return!0}}}function sa(a){return a.length>1?function(b,c,d){var e=a.length;while(e--)if(!a[e](b,c,d))return!1;return!0}:a[0]}function ta(a,b,c){for(var d=0,e=b.length;e>d;d++)fa(a,b[d],c);return c}function ua(a,b,c,d,e){for(var f,g=[],h=0,i=a.length,j=null!=b;i>h;h++)(f=a[h])&&(c&&!c(f,d,e)||(g.push(f),j&&b.push(h)));return g}function va(a,b,c,d,e,f){return d&&!d[u]&&(d=va(d)),e&&!e[u]&&(e=va(e,f)),ha(function(f,g,h,i){var j,k,l,m=[],n=[],o=g.length,p=f||ta(b||"*",h.nodeType?[h]:h,[]),q=!a||!f&&b?p:ua(p,m,a,h,i),r=c?e||(f?a:o||d)?[]:g:q;if(c&&c(q,r,h,i),d){j=ua(r,n),d(j,[],h,i),k=j.length;while(k--)(l=j[k])&&(r[n[k]]=!(q[n[k]]=l))}if(f){if(e||a){if(e){j=[],k=r.length;while(k--)(l=r[k])&&j.push(q[k]=l);e(null,r=[],j,i)}k=r.length;while(k--)(l=r[k])&&(j=e?J(f,l):m[k])>-1&&(f[j]=!(g[j]=l))}}else r=ua(r===g?r.splice(o,r.length):r),e?e(null,g,r,i):H.apply(g,r)})}function wa(a){for(var b,c,e,f=a.length,g=d.relative[a[0].type],h=g||d.relative[" "],i=g?1:0,k=ra(function(a){return a===b},h,!0),l=ra(function(a){return J(b,a)>-1},h,!0),m=[function(a,c,d){var e=!g&&(d||c!==j)||((b=c).nodeType?k(a,c,d):l(a,c,d));return b=null,e}];f>i;i++)if(c=d.relative[a[i].type])m=[ra(sa(m),c)];else{if(c=d.filter[a[i].type].apply(null,a[i].matches),c[u]){for(e=++i;f>e;e++)if(d.relative[a[e].type])break;return va(i>1&&sa(m),i>1&&qa(a.slice(0,i-1).concat({value:" "===a[i-2].type?"*":""})).replace(Q,"$1"),c,e>i&&wa(a.slice(i,e)),f>e&&wa(a=a.slice(e)),f>e&&qa(a))}m.push(c)}return sa(m)}function xa(a,b){var c=b.length>0,e=a.length>0,f=function(f,g,h,i,k){var l,o,q,r=0,s="0",t=f&&[],u=[],v=j,x=f||e&&d.find.TAG("*",k),y=w+=null==v?1:Math.random()||.1,z=x.length;for(k&&(j=g===n||g||k);s!==z&&null!=(l=x[s]);s++){if(e&&l){o=0,g||l.ownerDocument===n||(m(l),h=!p);while(q=a[o++])if(q(l,g||n,h)){i.push(l);break}k&&(w=y)}c&&((l=!q&&l)&&r--,f&&t.push(l))}if(r+=s,c&&s!==r){o=0;while(q=b[o++])q(t,u,g,h);if(f){if(r>0)while(s--)t[s]||u[s]||(u[s]=F.call(i));u=ua(u)}H.apply(i,u),k&&!f&&u.length>0&&r+b.length>1&&fa.uniqueSort(i)}return k&&(w=y,j=v),t};return c?ha(f):f}return h=fa.compile=function(a,b){var c,d=[],e=[],f=A[a+" "];if(!f){b||(b=g(a)),c=b.length;while(c--)f=wa(b[c]),f[u]?d.push(f):e.push(f);f=A(a,xa(e,d)),f.selector=a}return f},i=fa.select=function(a,b,e,f){var i,j,k,l,m,n="function"==typeof a&&a,o=!f&&g(a=n.selector||a);if(e=e||[],1===o.length){if(j=o[0]=o[0].slice(0),j.length>2&&"ID"===(k=j[0]).type&&c.getById&&9===b.nodeType&&p&&d.relative[j[1].type]){if(b=(d.find.ID(k.matches[0].replace(ba,ca),b)||[])[0],!b)return e;n&&(b=b.parentNode),a=a.slice(j.shift().value.length)}i=W.needsContext.test(a)?0:j.length;while(i--){if(k=j[i],d.relative[l=k.type])break;if((m=d.find[l])&&(f=m(k.matches[0].replace(ba,ca),_.test(j[0].type)&&oa(b.parentNode)||b))){if(j.splice(i,1),a=f.length&&qa(j),!a)return H.apply(e,f),e;break}}}return(n||h(a,o))(f,b,!p,e,!b||_.test(a)&&oa(b.parentNode)||b),e},c.sortStable=u.split("").sort(B).join("")===u,c.detectDuplicates=!!l,m(),c.sortDetached=ia(function(a){return 1&a.compareDocumentPosition(n.createElement("div"))}),ia(function(a){return a.innerHTML="","#"===a.firstChild.getAttribute("href")})||ja("type|href|height|width",function(a,b,c){return c?void 0:a.getAttribute(b,"type"===b.toLowerCase()?1:2)}),c.attributes&&ia(function(a){return a.innerHTML="",a.firstChild.setAttribute("value",""),""===a.firstChild.getAttribute("value")})||ja("value",function(a,b,c){return c||"input"!==a.nodeName.toLowerCase()?void 0:a.defaultValue}),ia(function(a){return null==a.getAttribute("disabled")})||ja(K,function(a,b,c){var d;return c?void 0:a[b]===!0?b.toLowerCase():(d=a.getAttributeNode(b))&&d.specified?d.value:null}),fa}(a);n.find=t,n.expr=t.selectors,n.expr[":"]=n.expr.pseudos,n.uniqueSort=n.unique=t.uniqueSort,n.text=t.getText,n.isXMLDoc=t.isXML,n.contains=t.contains;var u=function(a,b,c){var d=[],e=void 0!==c;while((a=a[b])&&9!==a.nodeType)if(1===a.nodeType){if(e&&n(a).is(c))break;d.push(a)}return d},v=function(a,b){for(var c=[];a;a=a.nextSibling)1===a.nodeType&&a!==b&&c.push(a);return c},w=n.expr.match.needsContext,x=/^<([\w-]+)\s*\/?>(?:<\/\1>|)$/,y=/^.[^:#\[\.,]*$/;function z(a,b,c){if(n.isFunction(b))return n.grep(a,function(a,d){return!!b.call(a,d,a)!==c});if(b.nodeType)return n.grep(a,function(a){return a===b!==c});if("string"==typeof b){if(y.test(b))return n.filter(b,a,c);b=n.filter(b,a)}return n.grep(a,function(a){return n.inArray(a,b)>-1!==c})}n.filter=function(a,b,c){var d=b[0];return c&&(a=":not("+a+")"),1===b.length&&1===d.nodeType?n.find.matchesSelector(d,a)?[d]:[]:n.find.matches(a,n.grep(b,function(a){return 1===a.nodeType}))},n.fn.extend({find:function(a){var b,c=[],d=this,e=d.length;if("string"!=typeof a)return this.pushStack(n(a).filter(function(){for(b=0;e>b;b++)if(n.contains(d[b],this))return!0}));for(b=0;e>b;b++)n.find(a,d[b],c);return c=this.pushStack(e>1?n.unique(c):c),c.selector=this.selector?this.selector+" "+a:a,c},filter:function(a){return this.pushStack(z(this,a||[],!1))},not:function(a){return this.pushStack(z(this,a||[],!0))},is:function(a){return!!z(this,"string"==typeof a&&w.test(a)?n(a):a||[],!1).length}});var A,B=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/,C=n.fn.init=function(a,b,c){var e,f;if(!a)return this;if(c=c||A,"string"==typeof a){if(e="<"===a.charAt(0)&&">"===a.charAt(a.length-1)&&a.length>=3?[null,a,null]:B.exec(a),!e||!e[1]&&b)return!b||b.jquery?(b||c).find(a):this.constructor(b).find(a);if(e[1]){if(b=b instanceof n?b[0]:b,n.merge(this,n.parseHTML(e[1],b&&b.nodeType?b.ownerDocument||b:d,!0)),x.test(e[1])&&n.isPlainObject(b))for(e in b)n.isFunction(this[e])?this[e](b[e]):this.attr(e,b[e]);return this}if(f=d.getElementById(e[2]),f&&f.parentNode){if(f.id!==e[2])return A.find(a);this.length=1,this[0]=f}return this.context=d,this.selector=a,this}return a.nodeType?(this.context=this[0]=a,this.length=1,this):n.isFunction(a)?"undefined"!=typeof c.ready?c.ready(a):a(n):(void 0!==a.selector&&(this.selector=a.selector,this.context=a.context),n.makeArray(a,this))};C.prototype=n.fn,A=n(d);var D=/^(?:parents|prev(?:Until|All))/,E={children:!0,contents:!0,next:!0,prev:!0};n.fn.extend({has:function(a){var b,c=n(a,this),d=c.length;return this.filter(function(){for(b=0;d>b;b++)if(n.contains(this,c[b]))return!0})},closest:function(a,b){for(var c,d=0,e=this.length,f=[],g=w.test(a)||"string"!=typeof a?n(a,b||this.context):0;e>d;d++)for(c=this[d];c&&c!==b;c=c.parentNode)if(c.nodeType<11&&(g?g.index(c)>-1:1===c.nodeType&&n.find.matchesSelector(c,a))){f.push(c);break}return this.pushStack(f.length>1?n.uniqueSort(f):f)},index:function(a){return a?"string"==typeof a?n.inArray(this[0],n(a)):n.inArray(a.jquery?a[0]:a,this):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(a,b){return this.pushStack(n.uniqueSort(n.merge(this.get(),n(a,b))))},addBack:function(a){return this.add(null==a?this.prevObject:this.prevObject.filter(a))}});function F(a,b){do a=a[b];while(a&&1!==a.nodeType);return a}n.each({parent:function(a){var b=a.parentNode;return b&&11!==b.nodeType?b:null},parents:function(a){return u(a,"parentNode")},parentsUntil:function(a,b,c){return u(a,"parentNode",c)},next:function(a){return F(a,"nextSibling")},prev:function(a){return F(a,"previousSibling")},nextAll:function(a){return u(a,"nextSibling")},prevAll:function(a){return u(a,"previousSibling")},nextUntil:function(a,b,c){return u(a,"nextSibling",c)},prevUntil:function(a,b,c){return u(a,"previousSibling",c)},siblings:function(a){return v((a.parentNode||{}).firstChild,a)},children:function(a){return v(a.firstChild)},contents:function(a){return n.nodeName(a,"iframe")?a.contentDocument||a.contentWindow.document:n.merge([],a.childNodes)}},function(a,b){n.fn[a]=function(c,d){var e=n.map(this,b,c);return"Until"!==a.slice(-5)&&(d=c),d&&"string"==typeof d&&(e=n.filter(d,e)),this.length>1&&(E[a]||(e=n.uniqueSort(e)),D.test(a)&&(e=e.reverse())),this.pushStack(e)}});var G=/\S+/g;function H(a){var b={};return n.each(a.match(G)||[],function(a,c){b[c]=!0}),b}n.Callbacks=function(a){a="string"==typeof a?H(a):n.extend({},a);var b,c,d,e,f=[],g=[],h=-1,i=function(){for(e=a.once,d=b=!0;g.length;h=-1){c=g.shift();while(++h-1)f.splice(c,1),h>=c&&h--}),this},has:function(a){return a?n.inArray(a,f)>-1:f.length>0},empty:function(){return f&&(f=[]),this},disable:function(){return e=g=[],f=c="",this},disabled:function(){return!f},lock:function(){return e=!0,c||j.disable(),this},locked:function(){return!!e},fireWith:function(a,c){return e||(c=c||[],c=[a,c.slice?c.slice():c],g.push(c),b||i()),this},fire:function(){return j.fireWith(this,arguments),this},fired:function(){return!!d}};return j},n.extend({Deferred:function(a){var b=[["resolve","done",n.Callbacks("once memory"),"resolved"],["reject","fail",n.Callbacks("once memory"),"rejected"],["notify","progress",n.Callbacks("memory")]],c="pending",d={state:function(){return c},always:function(){return e.done(arguments).fail(arguments),this},then:function(){var a=arguments;return n.Deferred(function(c){n.each(b,function(b,f){var g=n.isFunction(a[b])&&a[b];e[f[1]](function(){var a=g&&g.apply(this,arguments);a&&n.isFunction(a.promise)?a.promise().progress(c.notify).done(c.resolve).fail(c.reject):c[f[0]+"With"](this===d?c.promise():this,g?[a]:arguments)})}),a=null}).promise()},promise:function(a){return null!=a?n.extend(a,d):d}},e={};return d.pipe=d.then,n.each(b,function(a,f){var g=f[2],h=f[3];d[f[1]]=g.add,h&&g.add(function(){c=h},b[1^a][2].disable,b[2][2].lock),e[f[0]]=function(){return e[f[0]+"With"](this===e?d:this,arguments),this},e[f[0]+"With"]=g.fireWith}),d.promise(e),a&&a.call(e,e),e},when:function(a){var b=0,c=e.call(arguments),d=c.length,f=1!==d||a&&n.isFunction(a.promise)?d:0,g=1===f?a:n.Deferred(),h=function(a,b,c){return function(d){b[a]=this,c[a]=arguments.length>1?e.call(arguments):d,c===i?g.notifyWith(b,c):--f||g.resolveWith(b,c)}},i,j,k;if(d>1)for(i=new Array(d),j=new Array(d),k=new Array(d);d>b;b++)c[b]&&n.isFunction(c[b].promise)?c[b].promise().progress(h(b,j,i)).done(h(b,k,c)).fail(g.reject):--f;return f||g.resolveWith(k,c),g.promise()}});var I;n.fn.ready=function(a){return n.ready.promise().done(a),this},n.extend({isReady:!1,readyWait:1,holdReady:function(a){a?n.readyWait++:n.ready(!0)},ready:function(a){(a===!0?--n.readyWait:n.isReady)||(n.isReady=!0,a!==!0&&--n.readyWait>0||(I.resolveWith(d,[n]),n.fn.triggerHandler&&(n(d).triggerHandler("ready"),n(d).off("ready"))))}});function J(){d.addEventListener?(d.removeEventListener("DOMContentLoaded",K),a.removeEventListener("load",K)):(d.detachEvent("onreadystatechange",K),a.detachEvent("onload",K))}function K(){(d.addEventListener||"load"===a.event.type||"complete"===d.readyState)&&(J(),n.ready())}n.ready.promise=function(b){if(!I)if(I=n.Deferred(),"complete"===d.readyState||"loading"!==d.readyState&&!d.documentElement.doScroll)a.setTimeout(n.ready);else if(d.addEventListener)d.addEventListener("DOMContentLoaded",K),a.addEventListener("load",K);else{d.attachEvent("onreadystatechange",K),a.attachEvent("onload",K);var c=!1;try{c=null==a.frameElement&&d.documentElement}catch(e){}c&&c.doScroll&&!function f(){if(!n.isReady){try{c.doScroll("left")}catch(b){return a.setTimeout(f,50)}J(),n.ready()}}()}return I.promise(b)},n.ready.promise();var L;for(L in n(l))break;l.ownFirst="0"===L,l.inlineBlockNeedsLayout=!1,n(function(){var a,b,c,e;c=d.getElementsByTagName("body")[0],c&&c.style&&(b=d.createElement("div"),e=d.createElement("div"),e.style.cssText="position:absolute;border:0;width:0;height:0;top:0;left:-9999px",c.appendChild(e).appendChild(b),"undefined"!=typeof b.style.zoom&&(b.style.cssText="display:inline;margin:0;border:0;padding:1px;width:1px;zoom:1",l.inlineBlockNeedsLayout=a=3===b.offsetWidth,a&&(c.style.zoom=1)),c.removeChild(e))}),function(){var a=d.createElement("div");l.deleteExpando=!0;try{delete a.test}catch(b){l.deleteExpando=!1}a=null}();var M=function(a){var b=n.noData[(a.nodeName+" ").toLowerCase()],c=+a.nodeType||1;return 1!==c&&9!==c?!1:!b||b!==!0&&a.getAttribute("classid")===b},N=/^(?:\{[\w\W]*\}|\[[\w\W]*\])$/,O=/([A-Z])/g;function P(a,b,c){if(void 0===c&&1===a.nodeType){var d="data-"+b.replace(O,"-$1").toLowerCase();if(c=a.getAttribute(d),"string"==typeof c){try{c="true"===c?!0:"false"===c?!1:"null"===c?null:+c+""===c?+c:N.test(c)?n.parseJSON(c):c}catch(e){}n.data(a,b,c)}else c=void 0; }return c}function Q(a){var b;for(b in a)if(("data"!==b||!n.isEmptyObject(a[b]))&&"toJSON"!==b)return!1;return!0}function R(a,b,d,e){if(M(a)){var f,g,h=n.expando,i=a.nodeType,j=i?n.cache:a,k=i?a[h]:a[h]&&h;if(k&&j[k]&&(e||j[k].data)||void 0!==d||"string"!=typeof b)return k||(k=i?a[h]=c.pop()||n.guid++:h),j[k]||(j[k]=i?{}:{toJSON:n.noop}),"object"!=typeof b&&"function"!=typeof b||(e?j[k]=n.extend(j[k],b):j[k].data=n.extend(j[k].data,b)),g=j[k],e||(g.data||(g.data={}),g=g.data),void 0!==d&&(g[n.camelCase(b)]=d),"string"==typeof b?(f=g[b],null==f&&(f=g[n.camelCase(b)])):f=g,f}}function S(a,b,c){if(M(a)){var d,e,f=a.nodeType,g=f?n.cache:a,h=f?a[n.expando]:n.expando;if(g[h]){if(b&&(d=c?g[h]:g[h].data)){n.isArray(b)?b=b.concat(n.map(b,n.camelCase)):b in d?b=[b]:(b=n.camelCase(b),b=b in d?[b]:b.split(" ")),e=b.length;while(e--)delete d[b[e]];if(c?!Q(d):!n.isEmptyObject(d))return}(c||(delete g[h].data,Q(g[h])))&&(f?n.cleanData([a],!0):l.deleteExpando||g!=g.window?delete g[h]:g[h]=void 0)}}}n.extend({cache:{},noData:{"applet ":!0,"embed ":!0,"object ":"clsid:D27CDB6E-AE6D-11cf-96B8-444553540000"},hasData:function(a){return a=a.nodeType?n.cache[a[n.expando]]:a[n.expando],!!a&&!Q(a)},data:function(a,b,c){return R(a,b,c)},removeData:function(a,b){return S(a,b)},_data:function(a,b,c){return R(a,b,c,!0)},_removeData:function(a,b){return S(a,b,!0)}}),n.fn.extend({data:function(a,b){var c,d,e,f=this[0],g=f&&f.attributes;if(void 0===a){if(this.length&&(e=n.data(f),1===f.nodeType&&!n._data(f,"parsedAttrs"))){c=g.length;while(c--)g[c]&&(d=g[c].name,0===d.indexOf("data-")&&(d=n.camelCase(d.slice(5)),P(f,d,e[d])));n._data(f,"parsedAttrs",!0)}return e}return"object"==typeof a?this.each(function(){n.data(this,a)}):arguments.length>1?this.each(function(){n.data(this,a,b)}):f?P(f,a,n.data(f,a)):void 0},removeData:function(a){return this.each(function(){n.removeData(this,a)})}}),n.extend({queue:function(a,b,c){var d;return a?(b=(b||"fx")+"queue",d=n._data(a,b),c&&(!d||n.isArray(c)?d=n._data(a,b,n.makeArray(c)):d.push(c)),d||[]):void 0},dequeue:function(a,b){b=b||"fx";var c=n.queue(a,b),d=c.length,e=c.shift(),f=n._queueHooks(a,b),g=function(){n.dequeue(a,b)};"inprogress"===e&&(e=c.shift(),d--),e&&("fx"===b&&c.unshift("inprogress"),delete f.stop,e.call(a,g,f)),!d&&f&&f.empty.fire()},_queueHooks:function(a,b){var c=b+"queueHooks";return n._data(a,c)||n._data(a,c,{empty:n.Callbacks("once memory").add(function(){n._removeData(a,b+"queue"),n._removeData(a,c)})})}}),n.fn.extend({queue:function(a,b){var c=2;return"string"!=typeof a&&(b=a,a="fx",c--),arguments.lengthh;h++)b(a[h],c,g?d:d.call(a[h],h,b(a[h],c)));return e?a:j?b.call(a):i?b(a[0],c):f},Z=/^(?:checkbox|radio)$/i,$=/<([\w:-]+)/,_=/^$|\/(?:java|ecma)script/i,aa=/^\s+/,ba="abbr|article|aside|audio|bdi|canvas|data|datalist|details|dialog|figcaption|figure|footer|header|hgroup|main|mark|meter|nav|output|picture|progress|section|summary|template|time|video";function ca(a){var b=ba.split("|"),c=a.createDocumentFragment();if(c.createElement)while(b.length)c.createElement(b.pop());return c}!function(){var a=d.createElement("div"),b=d.createDocumentFragment(),c=d.createElement("input");a.innerHTML="
a",l.leadingWhitespace=3===a.firstChild.nodeType,l.tbody=!a.getElementsByTagName("tbody").length,l.htmlSerialize=!!a.getElementsByTagName("link").length,l.html5Clone="<:nav>"!==d.createElement("nav").cloneNode(!0).outerHTML,c.type="checkbox",c.checked=!0,b.appendChild(c),l.appendChecked=c.checked,a.innerHTML="",l.noCloneChecked=!!a.cloneNode(!0).lastChild.defaultValue,b.appendChild(a),c=d.createElement("input"),c.setAttribute("type","radio"),c.setAttribute("checked","checked"),c.setAttribute("name","t"),a.appendChild(c),l.checkClone=a.cloneNode(!0).cloneNode(!0).lastChild.checked,l.noCloneEvent=!!a.addEventListener,a[n.expando]=1,l.attributes=!a.getAttribute(n.expando)}();var da={option:[1,""],legend:[1,"
","
"],area:[1,"",""],param:[1,"",""],thead:[1,"","
"],tr:[2,"","
"],col:[2,"","
"],td:[3,"","
"],_default:l.htmlSerialize?[0,"",""]:[1,"X
","
"]};da.optgroup=da.option,da.tbody=da.tfoot=da.colgroup=da.caption=da.thead,da.th=da.td;function ea(a,b){var c,d,e=0,f="undefined"!=typeof a.getElementsByTagName?a.getElementsByTagName(b||"*"):"undefined"!=typeof a.querySelectorAll?a.querySelectorAll(b||"*"):void 0;if(!f)for(f=[],c=a.childNodes||a;null!=(d=c[e]);e++)!b||n.nodeName(d,b)?f.push(d):n.merge(f,ea(d,b));return void 0===b||b&&n.nodeName(a,b)?n.merge([a],f):f}function fa(a,b){for(var c,d=0;null!=(c=a[d]);d++)n._data(c,"globalEval",!b||n._data(b[d],"globalEval"))}var ga=/<|&#?\w+;/,ha=/r;r++)if(g=a[r],g||0===g)if("object"===n.type(g))n.merge(q,g.nodeType?[g]:g);else if(ga.test(g)){i=i||p.appendChild(b.createElement("div")),j=($.exec(g)||["",""])[1].toLowerCase(),m=da[j]||da._default,i.innerHTML=m[1]+n.htmlPrefilter(g)+m[2],f=m[0];while(f--)i=i.lastChild;if(!l.leadingWhitespace&&aa.test(g)&&q.push(b.createTextNode(aa.exec(g)[0])),!l.tbody){g="table"!==j||ha.test(g)?""!==m[1]||ha.test(g)?0:i:i.firstChild,f=g&&g.childNodes.length;while(f--)n.nodeName(k=g.childNodes[f],"tbody")&&!k.childNodes.length&&g.removeChild(k)}n.merge(q,i.childNodes),i.textContent="";while(i.firstChild)i.removeChild(i.firstChild);i=p.lastChild}else q.push(b.createTextNode(g));i&&p.removeChild(i),l.appendChecked||n.grep(ea(q,"input"),ia),r=0;while(g=q[r++])if(d&&n.inArray(g,d)>-1)e&&e.push(g);else if(h=n.contains(g.ownerDocument,g),i=ea(p.appendChild(g),"script"),h&&fa(i),c){f=0;while(g=i[f++])_.test(g.type||"")&&c.push(g)}return i=null,p}!function(){var b,c,e=d.createElement("div");for(b in{submit:!0,change:!0,focusin:!0})c="on"+b,(l[b]=c in a)||(e.setAttribute(c,"t"),l[b]=e.attributes[c].expando===!1);e=null}();var ka=/^(?:input|select|textarea)$/i,la=/^key/,ma=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,na=/^(?:focusinfocus|focusoutblur)$/,oa=/^([^.]*)(?:\.(.+)|)/;function pa(){return!0}function qa(){return!1}function ra(){try{return d.activeElement}catch(a){}}function sa(a,b,c,d,e,f){var g,h;if("object"==typeof b){"string"!=typeof c&&(d=d||c,c=void 0);for(h in b)sa(a,h,c,d,b[h],f);return a}if(null==d&&null==e?(e=c,d=c=void 0):null==e&&("string"==typeof c?(e=d,d=void 0):(e=d,d=c,c=void 0)),e===!1)e=qa;else if(!e)return a;return 1===f&&(g=e,e=function(a){return n().off(a),g.apply(this,arguments)},e.guid=g.guid||(g.guid=n.guid++)),a.each(function(){n.event.add(this,b,e,d,c)})}n.event={global:{},add:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,o,p,q,r=n._data(a);if(r){c.handler&&(i=c,c=i.handler,e=i.selector),c.guid||(c.guid=n.guid++),(g=r.events)||(g=r.events={}),(k=r.handle)||(k=r.handle=function(a){return"undefined"==typeof n||a&&n.event.triggered===a.type?void 0:n.event.dispatch.apply(k.elem,arguments)},k.elem=a),b=(b||"").match(G)||[""],h=b.length;while(h--)f=oa.exec(b[h])||[],o=q=f[1],p=(f[2]||"").split(".").sort(),o&&(j=n.event.special[o]||{},o=(e?j.delegateType:j.bindType)||o,j=n.event.special[o]||{},l=n.extend({type:o,origType:q,data:d,handler:c,guid:c.guid,selector:e,needsContext:e&&n.expr.match.needsContext.test(e),namespace:p.join(".")},i),(m=g[o])||(m=g[o]=[],m.delegateCount=0,j.setup&&j.setup.call(a,d,p,k)!==!1||(a.addEventListener?a.addEventListener(o,k,!1):a.attachEvent&&a.attachEvent("on"+o,k))),j.add&&(j.add.call(a,l),l.handler.guid||(l.handler.guid=c.guid)),e?m.splice(m.delegateCount++,0,l):m.push(l),n.event.global[o]=!0);a=null}},remove:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,o,p,q,r=n.hasData(a)&&n._data(a);if(r&&(k=r.events)){b=(b||"").match(G)||[""],j=b.length;while(j--)if(h=oa.exec(b[j])||[],o=q=h[1],p=(h[2]||"").split(".").sort(),o){l=n.event.special[o]||{},o=(d?l.delegateType:l.bindType)||o,m=k[o]||[],h=h[2]&&new RegExp("(^|\\.)"+p.join("\\.(?:.*\\.|)")+"(\\.|$)"),i=f=m.length;while(f--)g=m[f],!e&&q!==g.origType||c&&c.guid!==g.guid||h&&!h.test(g.namespace)||d&&d!==g.selector&&("**"!==d||!g.selector)||(m.splice(f,1),g.selector&&m.delegateCount--,l.remove&&l.remove.call(a,g));i&&!m.length&&(l.teardown&&l.teardown.call(a,p,r.handle)!==!1||n.removeEvent(a,o,r.handle),delete k[o])}else for(o in k)n.event.remove(a,o+b[j],c,d,!0);n.isEmptyObject(k)&&(delete r.handle,n._removeData(a,"events"))}},trigger:function(b,c,e,f){var g,h,i,j,l,m,o,p=[e||d],q=k.call(b,"type")?b.type:b,r=k.call(b,"namespace")?b.namespace.split("."):[];if(i=m=e=e||d,3!==e.nodeType&&8!==e.nodeType&&!na.test(q+n.event.triggered)&&(q.indexOf(".")>-1&&(r=q.split("."),q=r.shift(),r.sort()),h=q.indexOf(":")<0&&"on"+q,b=b[n.expando]?b:new n.Event(q,"object"==typeof b&&b),b.isTrigger=f?2:3,b.namespace=r.join("."),b.rnamespace=b.namespace?new RegExp("(^|\\.)"+r.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,b.result=void 0,b.target||(b.target=e),c=null==c?[b]:n.makeArray(c,[b]),l=n.event.special[q]||{},f||!l.trigger||l.trigger.apply(e,c)!==!1)){if(!f&&!l.noBubble&&!n.isWindow(e)){for(j=l.delegateType||q,na.test(j+q)||(i=i.parentNode);i;i=i.parentNode)p.push(i),m=i;m===(e.ownerDocument||d)&&p.push(m.defaultView||m.parentWindow||a)}o=0;while((i=p[o++])&&!b.isPropagationStopped())b.type=o>1?j:l.bindType||q,g=(n._data(i,"events")||{})[b.type]&&n._data(i,"handle"),g&&g.apply(i,c),g=h&&i[h],g&&g.apply&&M(i)&&(b.result=g.apply(i,c),b.result===!1&&b.preventDefault());if(b.type=q,!f&&!b.isDefaultPrevented()&&(!l._default||l._default.apply(p.pop(),c)===!1)&&M(e)&&h&&e[q]&&!n.isWindow(e)){m=e[h],m&&(e[h]=null),n.event.triggered=q;try{e[q]()}catch(s){}n.event.triggered=void 0,m&&(e[h]=m)}return b.result}},dispatch:function(a){a=n.event.fix(a);var b,c,d,f,g,h=[],i=e.call(arguments),j=(n._data(this,"events")||{})[a.type]||[],k=n.event.special[a.type]||{};if(i[0]=a,a.delegateTarget=this,!k.preDispatch||k.preDispatch.call(this,a)!==!1){h=n.event.handlers.call(this,a,j),b=0;while((f=h[b++])&&!a.isPropagationStopped()){a.currentTarget=f.elem,c=0;while((g=f.handlers[c++])&&!a.isImmediatePropagationStopped())a.rnamespace&&!a.rnamespace.test(g.namespace)||(a.handleObj=g,a.data=g.data,d=((n.event.special[g.origType]||{}).handle||g.handler).apply(f.elem,i),void 0!==d&&(a.result=d)===!1&&(a.preventDefault(),a.stopPropagation()))}return k.postDispatch&&k.postDispatch.call(this,a),a.result}},handlers:function(a,b){var c,d,e,f,g=[],h=b.delegateCount,i=a.target;if(h&&i.nodeType&&("click"!==a.type||isNaN(a.button)||a.button<1))for(;i!=this;i=i.parentNode||this)if(1===i.nodeType&&(i.disabled!==!0||"click"!==a.type)){for(d=[],c=0;h>c;c++)f=b[c],e=f.selector+" ",void 0===d[e]&&(d[e]=f.needsContext?n(e,this).index(i)>-1:n.find(e,this,null,[i]).length),d[e]&&d.push(f);d.length&&g.push({elem:i,handlers:d})}return h]","i"),va=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:-]+)[^>]*)\/>/gi,wa=/\s*$/g,Aa=ca(d),Ba=Aa.appendChild(d.createElement("div"));function Ca(a,b){return n.nodeName(a,"table")&&n.nodeName(11!==b.nodeType?b:b.firstChild,"tr")?a.getElementsByTagName("tbody")[0]||a.appendChild(a.ownerDocument.createElement("tbody")):a}function Da(a){return a.type=(null!==n.find.attr(a,"type"))+"/"+a.type,a}function Ea(a){var b=ya.exec(a.type);return b?a.type=b[1]:a.removeAttribute("type"),a}function Fa(a,b){if(1===b.nodeType&&n.hasData(a)){var c,d,e,f=n._data(a),g=n._data(b,f),h=f.events;if(h){delete g.handle,g.events={};for(c in h)for(d=0,e=h[c].length;e>d;d++)n.event.add(b,c,h[c][d])}g.data&&(g.data=n.extend({},g.data))}}function Ga(a,b){var c,d,e;if(1===b.nodeType){if(c=b.nodeName.toLowerCase(),!l.noCloneEvent&&b[n.expando]){e=n._data(b);for(d in e.events)n.removeEvent(b,d,e.handle);b.removeAttribute(n.expando)}"script"===c&&b.text!==a.text?(Da(b).text=a.text,Ea(b)):"object"===c?(b.parentNode&&(b.outerHTML=a.outerHTML),l.html5Clone&&a.innerHTML&&!n.trim(b.innerHTML)&&(b.innerHTML=a.innerHTML)):"input"===c&&Z.test(a.type)?(b.defaultChecked=b.checked=a.checked,b.value!==a.value&&(b.value=a.value)):"option"===c?b.defaultSelected=b.selected=a.defaultSelected:"input"!==c&&"textarea"!==c||(b.defaultValue=a.defaultValue)}}function Ha(a,b,c,d){b=f.apply([],b);var e,g,h,i,j,k,m=0,o=a.length,p=o-1,q=b[0],r=n.isFunction(q);if(r||o>1&&"string"==typeof q&&!l.checkClone&&xa.test(q))return a.each(function(e){var f=a.eq(e);r&&(b[0]=q.call(this,e,f.html())),Ha(f,b,c,d)});if(o&&(k=ja(b,a[0].ownerDocument,!1,a,d),e=k.firstChild,1===k.childNodes.length&&(k=e),e||d)){for(i=n.map(ea(k,"script"),Da),h=i.length;o>m;m++)g=k,m!==p&&(g=n.clone(g,!0,!0),h&&n.merge(i,ea(g,"script"))),c.call(a[m],g,m);if(h)for(j=i[i.length-1].ownerDocument,n.map(i,Ea),m=0;h>m;m++)g=i[m],_.test(g.type||"")&&!n._data(g,"globalEval")&&n.contains(j,g)&&(g.src?n._evalUrl&&n._evalUrl(g.src):n.globalEval((g.text||g.textContent||g.innerHTML||"").replace(za,"")));k=e=null}return a}function Ia(a,b,c){for(var d,e=b?n.filter(b,a):a,f=0;null!=(d=e[f]);f++)c||1!==d.nodeType||n.cleanData(ea(d)),d.parentNode&&(c&&n.contains(d.ownerDocument,d)&&fa(ea(d,"script")),d.parentNode.removeChild(d));return a}n.extend({htmlPrefilter:function(a){return a.replace(va,"<$1>")},clone:function(a,b,c){var d,e,f,g,h,i=n.contains(a.ownerDocument,a);if(l.html5Clone||n.isXMLDoc(a)||!ua.test("<"+a.nodeName+">")?f=a.cloneNode(!0):(Ba.innerHTML=a.outerHTML,Ba.removeChild(f=Ba.firstChild)),!(l.noCloneEvent&&l.noCloneChecked||1!==a.nodeType&&11!==a.nodeType||n.isXMLDoc(a)))for(d=ea(f),h=ea(a),g=0;null!=(e=h[g]);++g)d[g]&&Ga(e,d[g]);if(b)if(c)for(h=h||ea(a),d=d||ea(f),g=0;null!=(e=h[g]);g++)Fa(e,d[g]);else Fa(a,f);return d=ea(f,"script"),d.length>0&&fa(d,!i&&ea(a,"script")),d=h=e=null,f},cleanData:function(a,b){for(var d,e,f,g,h=0,i=n.expando,j=n.cache,k=l.attributes,m=n.event.special;null!=(d=a[h]);h++)if((b||M(d))&&(f=d[i],g=f&&j[f])){if(g.events)for(e in g.events)m[e]?n.event.remove(d,e):n.removeEvent(d,e,g.handle);j[f]&&(delete j[f],k||"undefined"==typeof d.removeAttribute?d[i]=void 0:d.removeAttribute(i),c.push(f))}}}),n.fn.extend({domManip:Ha,detach:function(a){return Ia(this,a,!0)},remove:function(a){return Ia(this,a)},text:function(a){return Y(this,function(a){return void 0===a?n.text(this):this.empty().append((this[0]&&this[0].ownerDocument||d).createTextNode(a))},null,a,arguments.length)},append:function(){return Ha(this,arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=Ca(this,a);b.appendChild(a)}})},prepend:function(){return Ha(this,arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=Ca(this,a);b.insertBefore(a,b.firstChild)}})},before:function(){return Ha(this,arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this)})},after:function(){return Ha(this,arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this.nextSibling)})},empty:function(){for(var a,b=0;null!=(a=this[b]);b++){1===a.nodeType&&n.cleanData(ea(a,!1));while(a.firstChild)a.removeChild(a.firstChild);a.options&&n.nodeName(a,"select")&&(a.options.length=0)}return this},clone:function(a,b){return a=null==a?!1:a,b=null==b?a:b,this.map(function(){return n.clone(this,a,b)})},html:function(a){return Y(this,function(a){var b=this[0]||{},c=0,d=this.length;if(void 0===a)return 1===b.nodeType?b.innerHTML.replace(ta,""):void 0;if("string"==typeof a&&!wa.test(a)&&(l.htmlSerialize||!ua.test(a))&&(l.leadingWhitespace||!aa.test(a))&&!da[($.exec(a)||["",""])[1].toLowerCase()]){a=n.htmlPrefilter(a);try{for(;d>c;c++)b=this[c]||{},1===b.nodeType&&(n.cleanData(ea(b,!1)),b.innerHTML=a);b=0}catch(e){}}b&&this.empty().append(a)},null,a,arguments.length)},replaceWith:function(){var a=[];return Ha(this,arguments,function(b){var c=this.parentNode;n.inArray(this,a)<0&&(n.cleanData(ea(this)),c&&c.replaceChild(b,this))},a)}}),n.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(a,b){n.fn[a]=function(a){for(var c,d=0,e=[],f=n(a),h=f.length-1;h>=d;d++)c=d===h?this:this.clone(!0),n(f[d])[b](c),g.apply(e,c.get());return this.pushStack(e)}});var Ja,Ka={HTML:"block",BODY:"block"};function La(a,b){var c=n(b.createElement(a)).appendTo(b.body),d=n.css(c[0],"display");return c.detach(),d}function Ma(a){var b=d,c=Ka[a];return c||(c=La(a,b),"none"!==c&&c||(Ja=(Ja||n("') $(this).find('.frame').remove() $(this).find('.btn-video').remove() } }) }) ================================================ FILE: docs/docker-compose.yml ================================================ version: '3' services: web: build: . volumes: # Bind mount the working dir so the app reloads automatically every time you make a change # Note that we use 'delegated' to improve perf on OSX: https://docs.docker.com/docker-for-mac/osxfs-caching/ - .:/src:delegated # Bind a Docker-only volume to the generated _site folder to make it clear we don't need to sync that folder # back to the host OS. That makes Jekyll in Docker work much faster. - generated_site:/src/_site ports: - "4000:4000" # Expose port for jekyll livereload - "35729:35729" environment: - JEKYLL_ENV=development volumes: generated_site: ================================================ FILE: docs/jekyll-serve.sh ================================================ #!/bin/bash set -e echo -e "\e[1;31mRun Jekyll serve to watch for changes" bundle exec jekyll serve --no-watch --livereload --drafts --host 0.0.0.0 ================================================ FILE: docs/scripts/convert_adoc_to_md.sh ================================================ # Required: # - asciidoctor # - pandoc # # Install Asciidoctor: # $ sudo apt-get install asciidoctor # # Install Pandoc # https://pandoc.org/installing.html # # 1. Create input.adoc file # 2. paste adoc-formatted content to input.adoc # 3. run script # 4. The output will be printed to the output.md file. asciidoctor -b docbook input.adoc && pandoc -f docbook -t gfm input.xml -o output.md --wrap=none --atx-headers ================================================ FILE: docs/scripts/convert_md_to_adoc.sh ================================================ # Create input.md file, paste markdown text, and run script. The output will be printed to the output.adoc file. pandoc --from=gfm --to=asciidoc --wrap=none --atx-headers input.md > output.adoc ================================================ FILE: examples/azure/README.md ================================================ # Terratest Configuration and Setup Terratest uses Go to make calls to Azure through the azure-sdk-for-go library and independently confirm the actual Azure resource property matches the expected state provided by Terraform output variables. - Instructions for running each Azure Terratest module are included in each Terraform example sub-folder: - examples/azure/terraform-azure-\*-example/README.md - Tests which assert against expected Terraform output values are located in the the respective go files of the folder: - [test/azure/terraform-azure-\*-example_test.go](../../test/azure) - Test APIs which provide the actual Azure resource property values via the azure-sdk-for-go are located in the folder: - [modules/azure](../../modules/azure) ## Go Dependencies Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH` These modules are currently using the latest version of Go and was tested with **go1.14.4**. ## Azure-sdk-for-go version Let's make sure [go.mod](https://github.com/gruntwork-io/terratest/blob/main/go.mod) includes the appropriate [azure-sdk-for-go version](https://github.com/Azure/azure-sdk-for-go/releases/tag/v46.1.0): ```go require ( ... github.com/Azure/azure-sdk-for-go v46.1.0+incompatible ... ) ``` If we make changes to either the **go.mod** or the **go test file**, we should make sure that the go build command works still. ```powershell go build terraform_azure_*_test.go ``` ## Review Environment Variables As part of configuring terraform for Azure, we'll want to check that we have set the appropriate [credentials](https://docs.microsoft.com/azure/terraform/terraform-install-configure?toc=https%3A%2F%2Fdocs.microsoft.com%2Fen-us%2Fazure%2Fterraform%2Ftoc.json&bc=https%3A%2F%2Fdocs.microsoft.com%2Fen-us%2Fazure%2Fbread%2Ftoc.json#set-up-terraform-access-to-azure) and also that we set the [environment variables](https://docs.microsoft.com/azure/terraform/terraform-install-configure?toc=https%3A%2F%2Fdocs.microsoft.com%2Fen-us%2Fazure%2Fterraform%2Ftoc.json&bc=https%3A%2F%2Fdocs.microsoft.com%2Fen-us%2Fazure%2Fbread%2Ftoc.json#configure-terraform-environment-variables) on the testing host. For non-commercial cloud deployments, set the "AZURE_ENVIRONMENT" environment variable to the appropriate cloud environment (this is used by the [client_factory](../../modules/azure/client_factory.go) to set the correct azure endpoints): ```bash export ARM_CLIENT_ID=your_app_id export ARM_CLIENT_SECRET=your_password export ARM_SUBSCRIPTION_ID=your_subscription_id export ARM_TENANT_ID=your_tenant_id # AZURE_ENVIRONMENT is the name of the Azure environment to use. Set to one of the following: export AZURE_ENVIRONMENT=AzureUSGovernmentCloud export AZURE_ENVIRONMENT=AzureChinaCloud export AZURE_ENVIRONMENT=AzureGermanCloud export AZURE_ENVIRONMENT=AzurePublicCloud export AZURE_ENVIRONMENT=AzureStackCloud ``` Note, in a Windows environment, these should be set as **system environment variables**. We can use a PowerShell console with administrative rights to update these environment variables: ```powershell [System.Environment]::SetEnvironmentVariable("ARM_CLIENT_ID",$your_app_id,[System.EnvironmentVariableTarget]::Machine) [System.Environment]::SetEnvironmentVariable("ARM_CLIENT_SECRET",$your_password,[System.EnvironmentVariableTarget]::Machine) [System.Environment]::SetEnvironmentVariable("ARM_SUBSCRIPTION_ID",$your_subscription_id,[System.EnvironmentVariableTarget]::Machine) [System.Environment]::SetEnvironmentVariable("ARM_TENANT_ID",$your_tenant_id,[System.EnvironmentVariableTarget]::Machine) [System.Environment]::SetEnvironmentVariable("AZURE_ENVIRONMENT",$your_azure_env,[System.EnvironmentVariableTarget]::Machine) ``` ================================================ FILE: examples/azure/terraform-azure-aci-example/README.md ================================================ # Terraform Azure Example This folder contains a Terraform module that deploys resources in [Azure](https://azure.microsoft.com/) to demonstrate how you can use Terratest to write automated tests for your Azure Terraform code. This module deploys an [Azure Container Instance](https://azure.microsoft.com/en-us/services/container-instances/). Check out [test/azure/terraform_azure_aci_example_test.go](/test/azure/terraform_azure_aci_example_test.go) to see how you can write automated tests for this module. **WARNING**: This module and the automated tests for it deploy real resources into your Azure account which can cost you money. The resources are all part of the [Azure Free Account](https://azure.microsoft.com/en-us/free/), so if you haven't used that up, it should be free, but you are completely responsible for all Azure charges. ## Running this module manually 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Ensure [environment variables](../README.md#review-environment-variables) are available 1. Run `terraform init` 1. Run `terraform apply` 1. When you're done, run `terraform destroy` ## Running automated tests against this module 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Configure your Terratest [Go test environment](../README.md) 1. `cd test/azure` 1. `go build terraform_azure_aci_example_test.go` 1. `go test -v -timeout 60m -tags azure -run TestTerraformAzureACIExample` ================================================ FILE: examples/azure/terraform-azure-aci-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN AZURE CONTAINER Instance # This is an example of how to deploy an Azure Container Instance # See test/terraform_azure_aci_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- # ------------------------------------------------------------------------------ # CONFIGURE OUR AZURE CONNECTION # ------------------------------------------------------------------------------ terraform { required_providers { azurerm = { version = "~>2.29.0" source = "hashicorp/azurerm" } } } provider "azurerm" { features {} } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RESOURCE GROUP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_resource_group" "rg" { name = "terratest-aci-rg-${var.postfix}" location = var.location } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN AZURE CONTAINER INSTANCE # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_container_group" "aci" { name = "aci${var.postfix}" location = azurerm_resource_group.rg.location resource_group_name = azurerm_resource_group.rg.name ip_address_type = "public" dns_name_label = "aci${var.postfix}" os_type = "Linux" container { name = "hello-world" image = "mcr.microsoft.com/azuredocs/aci-helloworld:latest" cpu = "0.5" memory = "1.5" ports { port = 443 protocol = "TCP" } } tags = { Environment = "Development" } } ================================================ FILE: examples/azure/terraform-azure-aci-example/output.tf ================================================ output "resource_group_name" { value = azurerm_resource_group.rg.name } output "ip_address" { value = azurerm_container_group.aci.ip_address } output "fqdn" { value = azurerm_container_group.aci.fqdn } output "container_instance_name" { value = azurerm_container_group.aci.name } ================================================ FILE: examples/azure/terraform-azure-aci-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # ARM_CLIENT_ID # ARM_CLIENT_SECRET # ARM_SUBSCRIPTION_ID # ARM_TENANT_ID # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "location" { description = "The supported azure location where the resource exists" type = string default = "West US2" } variable "postfix" { description = "A postfix string to centrally mitigate resource name collisions." type = string default = "1276" } ================================================ FILE: examples/azure/terraform-azure-acr-example/README.md ================================================ # Terraform Azure Container Registry Example This folder contains a Terraform module that deploys resources in [Azure](https://azure.microsoft.com/) to demonstrate how you can use Terratest to write automated tests for your Azure Terraform code. This module deploys an [Azure Container Registry](https://azure.microsoft.com/en-us/services/container-registry/). Check out [test/azure/terraform_azure_acr_example_test.go](/test/azure/terraform_azure_acr_example_test.go) to see how you can write automated tests for this module. **WARNING**: This module and the automated tests for it deploy real resources into your Azure account which can cost you money. The resources are all part of the [Azure Free Account](https://azure.microsoft.com/en-us/free/), so if you haven't used that up, it should be free, but you are completely responsible for all Azure charges. ## Running this module manually 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Ensure [environment variables](../README.md#review-environment-variables) are available 1. Run `terraform init` 1. Run `terraform apply` 1. When you're done, run `terraform destroy` ## Running automated tests against this module 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Configure your Terratest [Go test environment](../README.md) 1. `cd test/azure` 1. `go build terraform_azure_acr_example_test.go` 1. `go test -v -timeout 60m -tags azure -run TestTerraformAzureACRExample` ================================================ FILE: examples/azure/terraform-azure-acr-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN AZURE CONTAINER REGISTRY # This is an example of how to deploy an Azure Container Registry # See test/terraform_azure_acr_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- # ------------------------------------------------------------------------------ # CONFIGURE OUR AZURE CONNECTION # ------------------------------------------------------------------------------ terraform { required_providers { azurerm = { version = "~>2.29.0" source = "hashicorp/azurerm" } } } provider "azurerm" { features {} } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RESOURCE GROUP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_resource_group" "rg" { name = "terratest-acr-rg-${var.postfix}" location = var.location } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN AZURE CONTAINER REGISTRY # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_container_registry" "acr" { name = "acr${var.postfix}" location = azurerm_resource_group.rg.location resource_group_name = azurerm_resource_group.rg.name sku = var.sku admin_enabled = true tags = { Environment = "Development" } } ================================================ FILE: examples/azure/terraform-azure-acr-example/output.tf ================================================ output "resource_group_name" { value = azurerm_resource_group.rg.name } output "container_registry_name" { value = azurerm_container_registry.acr.name } output "login_server" { value = azurerm_container_registry.acr.login_server } output "admin_username" { value = azurerm_container_registry.acr.admin_username sensitive = true } output "admin_password" { value = azurerm_container_registry.acr.admin_password sensitive = true } ================================================ FILE: examples/azure/terraform-azure-acr-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # ARM_CLIENT_ID # ARM_CLIENT_SECRET # ARM_SUBSCRIPTION_ID # ARM_TENANT_ID # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "sku" { description = "SKU tier for the ACR." default = "Premium" } variable "location" { description = "The supported azure location where the resource exists" type = string default = "West US2" } variable "postfix" { description = "A postfix string to centrally mitigate resource name collisions." type = string default = "1276" } ================================================ FILE: examples/azure/terraform-azure-actiongroup-example/README.md ================================================ # Terraform Azure Action Group Example This folder contains a Terraform module that deploys an [Azure Action Group](https://docs.microsoft.com/en-us/azure/azure-monitor/platform/action-groups) in [Azure](https://azure.microsoft.com/) to demonstrate how you can use Terratest to write automated tests for your Azure Terraform code. Check out [test/azure/terraform_azure_actiongroup_example_test.go](/test/azure/terraform/azure_actiongroup_example_test.go) to see how you can write automated tests for this module and validate the configuration of the parameters and options. **WARNING**: This module and the automated tests for it deploy real resources into your Azure account which can cost you money. ## Prerequisite: Setup Azure CLI access 1. Sign up for [Azure](https://azure.microsoft.com/). 1. Install [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli?view=azure-cli-latest) 2. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 3. Login to Azure on the CLI with `az login` or `az login --use-device`, and then configure the CLI. ## Running this module manually 1. Create [Service Principal](https://docs.microsoft.com/en-us/cli/azure/create-an-azure-service-principal-azure-cli?view=azure-cli-latest) then set the value to the environment variables. 1. Run `terraform init`. 2. Run `terraform apply`. 3. Log into Azure to validate resource was created. 4. When you're done, run `terraform destroy`. ### Example ```bash $ az login $ export ARM_SUBSCRIPTION_ID={YOUR_SUBSCRIPTION_ID} $ az ad sp create-for-rbac $ export TF_VAR_client_id={YOUR_SERVICE_PRINCIPAL_APP_ID} $ export TF_VAR_client_secret={YOUR_SERVICE_PRINCIPAL_PASSWORD} $ terraform init $ terraform apply $ terraform destroy ``` ## Running automated tests against this module 1. Create [Service Principal](https://docs.microsoft.com/en-us/cli/azure/create-an-azure-service-principal-azure-cli?view=azure-cli-latest) then set the value to the environment variables. 1. Install [Golang](https://golang.org/) version `1.13+` required. 1. `cd test/azure` 1. `go test -v -timeout 60m -tags azure -run TestTerraformAzureActionGroupExample` ### Example ```bash $ az login $ export ARM_SUBSCRIPTION_ID={YOUR_SUBSCRIPTION_ID} $ export TF_VAR_client_id={YOUR_SERVICE_PRINCIPAL_APP_ID} $ export TF_VAR_client_secret={YOUR_SERVICE_PRINCIPAL_PASSWORD} $ cd test/azure $ go test -v -timeout 60m -tags azure -run TestTerraformAzureActionGroupExample ``` ================================================ FILE: examples/azure/terraform-azure-actiongroup-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN ACTION GROUP # This is an example of how to deploy an Azure Action Group to be used for Azure Alerts # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # CONFIGURE OUR AZURE CONNECTION # --------------------------------------------------------------------------------------------------------------------- provider "azurerm" { features {} } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RESOURCE GROUP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_resource_group" "rg" { name = var.resource_group_name location = var.location } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN AZURE APP SERVICE PLAN # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_monitor_action_group" "actionGroup" { name = var.app_name resource_group_name = azurerm_resource_group.rg.name short_name = var.short_name tags = azurerm_resource_group.rg.tags dynamic "email_receiver" { for_each = var.enable_email ? ["email_receiver"] : [] content { name = var.email_name email_address = var.email_address use_common_alert_schema = true } } dynamic "sms_receiver" { for_each = var.enable_sms ? ["sms_receiver"] : [] content { name = var.sms_name country_code = var.sms_country_code phone_number = var.sms_phone_number } } dynamic "webhook_receiver" { for_each = var.enable_webhook ? ["webhook_receiver"] : [] content { name = var.webhook_name service_uri = var.webhook_service_uri use_common_alert_schema = true } } } ================================================ FILE: examples/azure/terraform-azure-actiongroup-example/output.tf ================================================ output "action_group_id" { value = azurerm_monitor_action_group.actionGroup.id } ================================================ FILE: examples/azure/terraform-azure-actiongroup-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- variable "resource_group_name" { description = "Name of the resource group that exists in Azure" type = string } variable "app_name" { description = "The base name of the application used in the naming convention." type = string } variable "location" { description = "Specifies the supported Azure location where the resource exists. Changing this forces a new resource to be created." type = string } variable "short_name" { description = "Shorthand name for SMS texts." type = string } # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "enable_email" { description = "Enable email alert capabilities" type = bool default = false } variable "email_name" { description = "Friendly Name for email address" type = string default = "" } variable "email_address" { description = "email address to send alerts to" type = string default = "" } variable "enable_sms" { description = "Enable Texting Alerts" type = bool default = false } variable "sms_name" { description = "Friendly Name for phone number" type = string default = "" } variable "sms_country_code" { description = "Country Code for phone number" type = number default = 1 } variable "sms_phone_number" { description = "Phone number for text alerts" type = number default = 0 } variable "enable_webhook" { description = "Enable Web Hook Alerts" type = bool default = false } variable "webhook_name" { description = "Friendly Name for web hook" type = string default = "" } variable "webhook_service_uri" { description = "The full URI for the webhook" type = string default = "" } ================================================ FILE: examples/azure/terraform-azure-aks-example/README.md ================================================ # Terraform Azure AKS Example This folder contains a Terraform module that deploys a basic AKS cluster in [Azure](https://azure.microsoft.com/) to demonstrate how you can use Terratest to write automated tests for your Azure Terraform code. This module deploys [Azure Kubenetes Service](https://azure.microsoft.com/en-us/services/kubernetes-service/), then deploys nginx by a kubernetes yaml file with a Public IP Address using the `Service` resource. Check out [test/azure/terraform_azure_aks_example_test.go](/test/azure/terraform_azure_aks_example_test.go) to see how you can write automated tests for this module and validate the configuration of the parameters and options. **WARNING**: This module and the automated tests for it deploy real resources into your Azure account which can cost you money. ## Prerequisite: Setup Azure CLI access 1. Sign up for [Azure](https://azure.microsoft.com/). 1. Install [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Install [kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/) and make sure it's on your `PATH`. 1. Login to Azure on the CLI with `az login` or `az login --use-device`, and then configure the CLI. ## Running this module manually 1. Create [Service Principal](https://docs.microsoft.com/en-us/cli/azure/create-an-azure-service-principal-azure-cli?view=azure-cli-latest) then set the value to the environment variables. 1. Run `terraform init` 1. Run `terraform apply` 1. Apply `nginx-deployment.yml` 1. Watch the service until Public IPAddress is assigned. 1. Send http request to the Public IPAddress, make sure it returns 200. 1. When you're done, run `terraform destroy`. ### Example ```bash $ az login $ export ARM_SUBSCRIPTION_ID={YOUR_SUBSCRIPTION_ID} $ az ad sp create-for-rbac $ export TF_VAR_client_id={YOUR_SERVICE_PRINCIPAL_APP_ID} $ export TF_VAR_client_secret={YOUR_SERVICE_PRINCIPAL_PASSWORD} $ terraform init $ terraform apply $ kubectl --kubeconfig ./kubeconfig -f ./nginx-deployment.yml $ kubectl --kubeconfig ./kubeconfig get svc -w // Open browser and access the Nginx Service IPAddress $ terraform destroy ``` ## Running automated tests against this module 1. Create [Service Principal](https://docs.microsoft.com/en-us/cli/azure/create-an-azure-service-principal-azure-cli?view=azure-cli-latest) then set the value to the environment variables. 1. Install [Golang](https://golang.org/) version `1.13+` required. 1. `cd test` 1. `go test -v -timeout 60m -tags azure -run TestTerraformAzureAKS` ### Example ```bash $ az login $ export ARM_SUBSCRIPTION_ID={YOUR_SUBSCRIPTION_ID} $ export TF_VAR_client_id={YOUR_SERVICE_PRINCIPAL_APP_ID} $ export TF_VAR_client_secret={YOUR_SERVICE_PRINCIPAL_PASSWORD} $ cd test $ go test -v -timeout 60m -tags azure -run TestTerraformAzureAKS ``` ================================================ FILE: examples/azure/terraform-azure-aks-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN AZURE AKS CLUSTER # This is an example of how to deploy an Azure AKS cluster with load balancer in front of the service # to handle providing the public interface into the cluster. # --------------------------------------------------------------------------------------------------------------------- # See test/azure/terraform_azure_aks_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" } # ------------------------------------------------------------------------------ # CONFIGURE OUR AZURE CONNECTION # ------------------------------------------------------------------------------ provider "azurerm" { features {} } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RESOURCE GROUP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_resource_group" "k8s" { name = var.resource_group_name location = var.location } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN AZURE KUBERNETES CLUSTER # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_kubernetes_cluster" "k8s" { name = var.cluster_name location = azurerm_resource_group.k8s.location resource_group_name = azurerm_resource_group.k8s.name dns_prefix = var.dns_prefix linux_profile { admin_username = "ubuntu" ssh_key { key_data = file(var.ssh_public_key) } } default_node_pool { name = "agentpool" node_count = var.agent_count vm_size = "Standard_DS2_v2" } service_principal { client_id = var.client_id client_secret = var.client_secret } automatic_upgrade_channel = "stable" tags = { Environment = "Development" } } # --------------------------------------------------------------------------------------------------------------------- # CREATE KUBECONFIG FILE # --------------------------------------------------------------------------------------------------------------------- resource "local_file" "kubeconfig" { content = azurerm_kubernetes_cluster.k8s.kube_config_raw filename = "kubeconfig" depends_on = [ azurerm_kubernetes_cluster.k8s ] } ================================================ FILE: examples/azure/terraform-azure-aks-example/nginx-deployment.yml ================================================ --- apiVersion: apps/v1 kind: Deployment metadata: name: nginx-deployment spec: selector: matchLabels: app: nginx replicas: 1 template: metadata: labels: app: nginx spec: containers: - name: nginx image: nginx:1.15.7 ports: - containerPort: 80 --- kind: Service apiVersion: v1 metadata: name: nginx-service spec: selector: app: nginx ports: - protocol: TCP targetPort: 80 port: 80 type: LoadBalancer ================================================ FILE: examples/azure/terraform-azure-aks-example/output.tf ================================================ output "client_key" { value = azurerm_kubernetes_cluster.k8s.kube_config.0.client_key } output "client_certificate" { value = azurerm_kubernetes_cluster.k8s.kube_config.0.client_certificate sensitive = true } output "cluster_ca_certificate" { value = azurerm_kubernetes_cluster.k8s.kube_config.0.cluster_ca_certificate sensitive = true } output "cluster_username" { value = azurerm_kubernetes_cluster.k8s.kube_config.0.username sensitive = true } output "cluster_password" { value = azurerm_kubernetes_cluster.k8s.kube_config.0.password sensitive = true } output "kube_config" { value = azurerm_kubernetes_cluster.k8s.kube_config_raw sensitive = true } output "host" { value = azurerm_kubernetes_cluster.k8s.kube_config.0.host sensitive = true } ================================================ FILE: examples/azure/terraform-azure-aks-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- variable "client_id" { description = "The Service Principal Client Id for AKS to modify Azure resources." } variable "client_secret" { description = "The Service Principal Client Password for AKS to modify Azure resources." } # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "agent_count" { description = "The number of the nodes of the AKS cluster." default = 3 } variable "ssh_public_key" { description = "The public key for the ssh connection to the nodes." default = "~/.ssh/id_rsa.pub" } variable "dns_prefix" { description = "The prefix to set for the AKS cluster resoureces names." default = "k8stest" } variable "cluster_name" { description = "The name to set for the AKS cluster." default = "k8stest" } variable "resource_group_name" { description = "The name to set for the resource group." default = "azure-k8stest" } variable "location" { description = "The location to set for the AKS cluster." default = "Central US" } ================================================ FILE: examples/azure/terraform-azure-availabilityset-example/README.md ================================================ # Terraform Azure Availability Set Example This folder contains a simple Terraform module that deploys resources in [Azure](https://azure.microsoft.com/) to demonstrate how you can use Terratest to write automated tests for your Azure Terraform code. This module deploys an Availability Set with one attched Virtual Machine. - An [Availability Set](https://docs.microsoft.com/en-us/azure/virtual-machines/availability) that gives the module the following: - `Availability Set` with the name specified in the `availability_set_name` output variable. - `Fault Domain Count` with the value specified in the `availability_set_fdc` output variable. - A [Virtual Machine](https://azure.microsoft.com/en-us/services/virtual-machines/) that gives the Availability Set the following: - [Virtual Machine](https://docs.microsoft.com/en-us/azure/virtual-machines/) with the name specified in the `vm_name` output variable. Check out [test/azure/terraform_azure_availabilityset_example_test.go](/test/azure/terraform_azure_availabilityset_example_test.go) to see how you can write automated tests for this module. Note that the Availability Set and VM in this module don't actually do anything; it just runs the resources for demonstration purposes. **WARNING**: This module and the automated tests for it deploy real resources into your Azure account which can cost you money. The resources are all part of the [Azure Free Account](https://azure.microsoft.com/en-us/free/), so if you haven't used that up, it should be free, but you are completely responsible for all Azure charges. ## Running this module manually 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Ensure [environment variables](../README.md#review-environment-variables) are available 1. Run `terraform init` 1. Run `terraform apply` 1. When you're done, run `terraform destroy` ## Running automated tests against this module 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Configure your Terratest [Go test environment](../README.md) 1. `cd test/azure` 1. `go build terraform_azure_availabilityset_example_test.go` 1. `go test -v -run TestTerraformAzureAvailabilitySetExample` ================================================ FILE: examples/azure/terraform-azure-availabilityset-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN AZURE AVAILABILITY SET # This is an example of how to deploy an Azure Availability Set with a Virtual Machine in the availability set # and the minimum network resources for the VM. # --------------------------------------------------------------------------------------------------------------------- # See test/azure/terraform_azure_availabilityset_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- provider "azurerm" { features {} } # --------------------------------------------------------------------------------------------------------------------- # PIN TERRAFORM VERSION TO >= 0.12 # The examples have been upgraded to 0.12 syntax # --------------------------------------------------------------------------------------------------------------------- terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" required_providers { azurerm = { version = "~> 2.50" source = "hashicorp/azurerm" } } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RESOURCE GROUP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_resource_group" "avs" { name = "terratest-avs-rg-${var.postfix}" location = var.location } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY THE AVAILABILITY SET # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_availability_set" "avs" { name = "avs-${var.postfix}" location = azurerm_resource_group.avs.location resource_group_name = azurerm_resource_group.avs.name platform_fault_domain_count = var.avs_fault_domain_count managed = true } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY MINIMAL NETWORK RESOURCES FOR VM # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_virtual_network" "avs" { name = "vnet-${var.postfix}" address_space = ["10.0.0.0/16"] location = azurerm_resource_group.avs.location resource_group_name = azurerm_resource_group.avs.name } resource "azurerm_subnet" "avs" { name = "subnet-${var.postfix}" resource_group_name = azurerm_resource_group.avs.name virtual_network_name = azurerm_virtual_network.avs.name address_prefixes = ["10.0.17.0/24"] } resource "azurerm_network_interface" "avs" { name = "nic-${var.postfix}" location = azurerm_resource_group.avs.location resource_group_name = azurerm_resource_group.avs.name ip_configuration { name = "config-${var.postfix}-01" subnet_id = azurerm_subnet.avs.id private_ip_address_allocation = "Dynamic" } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY VIRTUAL MACHINE # This VM does not actually do anything and is the smallest size VM available with an Ubuntu image # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_virtual_machine" "avs" { name = "vm-${var.postfix}" location = azurerm_resource_group.avs.location resource_group_name = azurerm_resource_group.avs.name network_interface_ids = [azurerm_network_interface.avs.id] availability_set_id = azurerm_availability_set.avs.id vm_size = "Standard_B1ls" delete_os_disk_on_termination = true delete_data_disks_on_termination = true storage_image_reference { publisher = "Canonical" offer = "UbuntuServer" sku = "18.04-LTS" version = "latest" } storage_os_disk { name = "osdisk-${var.postfix}" caching = "None" create_option = "FromImage" managed_disk_type = "Standard_LRS" } os_profile { computer_name = "vm-${var.postfix}" admin_username = "testadmin" admin_password = random_password.avs.result } os_profile_linux_config { disable_password_authentication = false } depends_on = [random_password.avs] } resource "random_password" "avs" { length = 16 override_special = "-_%@" min_upper = "1" min_lower = "1" min_numeric = "1" min_special = "1" } ================================================ FILE: examples/azure/terraform-azure-availabilityset-example/outputs.tf ================================================ output "resource_group_name" { value = azurerm_resource_group.avs.name } output "availability_set_name" { value = azurerm_availability_set.avs.name } output "availability_set_fdc" { value = azurerm_availability_set.avs.platform_fault_domain_count } output "vm_name" { value = azurerm_virtual_machine.avs.name } ================================================ FILE: examples/azure/terraform-azure-availabilityset-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # ARM_CLIENT_ID # ARM_CLIENT_SECRET # ARM_SUBSCRIPTION_ID # ARM_TENANT_ID # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "avs_fault_domain_count" { description = "Domain Fault Domain Count of the Availability Set" type = number default = 3 } variable "location" { description = "The Azure location where to deploy your resources too" type = string default = "East US" } variable "postfix" { description = "A postfix string to centrally mitigate resource name collisions" type = string default = "resource" } ================================================ FILE: examples/azure/terraform-azure-container-apps-example/README.md ================================================ # Terraform Azure Container Apps Example This folder contains a simple Terraform module that deploys resources in [Azure](https://azure.microsoft.com/) to demonstrate how you can use Terratest to write automated tests for your Azure Terraform code. This module deploys two [Azure Container Apps](https://learn.microsoft.com/en-us/azure/container-apps/overview) (one app and a job). Check out [test/azure/terraform_azure_container_apps_example_test.go](./../../../test/azure/terraform_azure_container_apps_example_test.go) to see how you can write automated tests for this module and validate the configuration of the parameters and options. **WARNING**: This module and the automated tests for it deploy real resources into your Azure account which can cost you money. ## Running this module manually 1. Sign up for [Azure](https://azure.microsoft.com/). 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Ensure [environment variables](../README.md#review-environment-variables) are available 1. Run `terraform init` 1. Run `terraform apply` 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Configure your Terratest [Go test environment](../README.md) 1. `cd test/azure` 1. `go build terraform_azure_container_apps_example_test.go` 1. `go test -v -timeout 60m -tags azure -run TestTerraformAzureContainerAppExample` ================================================ FILE: examples/azure/terraform-azure-container-apps-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN AZURE CONTAINER APPS # This is an example of how to deploy an Azure Container App and Azure Container App Job with the minimum set of options. # --------------------------------------------------------------------------------------------------------------------- # See test/azure/terraform_azure_container_apps_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- provider "azurerm" { features {} } # --------------------------------------------------------------------------------------------------------------------- # PIN TERRAFORM VERSION TO >= 0.12 # The examples have been upgraded to 0.12 syntax # --------------------------------------------------------------------------------------------------------------------- terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" required_providers { azurerm = { version = "~> 3.103" source = "hashicorp/azurerm" } } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RESOURCE GROUP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_resource_group" "aca" { name = "terratest-rg-${var.postfix}" location = "East US" } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A AZURE APP ENVIRONMENT # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_container_app_environment" "aca" { name = "terratest-aca-env-${var.postfix}" location = azurerm_resource_group.aca.location resource_group_name = azurerm_resource_group.aca.name } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A AZURE CONTAINER APP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_container_app" "aca" { name = "terratest-aca-${var.postfix}" resource_group_name = azurerm_resource_group.aca.name container_app_environment_id = azurerm_container_app_environment.aca.id revision_mode = "Single" template { container { name = "terratest-aca-app-${var.postfix}" image = "mcr.microsoft.com/azuredocs/containerapps-helloworld:latest" cpu = "0.5" memory = "1.0Gi" } } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A AZURE CONTAINER APP JOB # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_container_app_job" "aca" { name = "terratest-aca-job-${var.postfix}" location = azurerm_resource_group.aca.location resource_group_name = azurerm_resource_group.aca.name container_app_environment_id = azurerm_container_app_environment.aca.id replica_timeout_in_seconds = 10 template { container { name = "terratest-aca-job-${var.postfix}" image = "busybox:stable" command = ["echo", "Hello, World!"] cpu = "0.5" memory = "1.0Gi" } } manual_trigger_config { parallelism = 1 } } ================================================ FILE: examples/azure/terraform-azure-container-apps-example/outputs.tf ================================================ output "resource_group_name" { value = azurerm_resource_group.aca.name } output "container_app_env_name" { value = azurerm_container_app_environment.aca.name } output "container_app_name" { value = azurerm_container_app.aca.name } output "container_app_job_name" { value = azurerm_container_app_job.aca.name } ================================================ FILE: examples/azure/terraform-azure-container-apps-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # ARM_CLIENT_ID # ARM_CLIENT_SECRET # ARM_SUBSCRIPTION_ID # ARM_TENANT_ID # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "location" { description = "The Azure location where to deploy your resources too" type = string default = "East US" } variable "postfix" { description = "A postfix string to centrally mitigate resource name collisions" type = string default = "resource" } ================================================ FILE: examples/azure/terraform-azure-cosmosdb-example/README.md ================================================ # Terraform Azure CosmosDB Example This folder contains a complete Terraform Cosmos DB module that deploys resources in [Azure](https://azure.microsoft.com/) to demonstrate how you can use Terratest to write automated tests for your Azure Terraform code. This module deploys the following resources: - A [Cosmos DB Account](https://azure.microsoft.com/services/cosmos-db/) configured with: - A [SQL Database](https://docs.microsoft.com/en-gb/azure/cosmos-db/account-databases-containers-items#azure-cosmos-databases) - Three [SQL Containers](https://docs.microsoft.com/en-gb/azure/cosmos-db/account-databases-containers-items#azure-cosmos-containers) Check out [test/azure/terraform_azure_cosmosdb_example_test.go](./../../../test/azure/terraform_azure_cosmosdb_example_test.go) to see how you can write automated tests for this module and validate the configuration of the parameters and options. **WARNING**: This module and the automated tests for it deploy real resources into your Azure account which can cost you money. ## Running this module manually 1. Sign up for [Azure](https://azure.microsoft.com/). 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Ensure [environment variables](../README.md#review-environment-variables) are available 1. Run `terraform init` 1. Run `terraform apply` 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Configure your Terratest [Go test environment](../README.md) 1. `cd test/azure` 1. `go build terraform_azure_cosmosdb_example_test.go` 1. `go test -v -timeout 60m -tags azure -run TestTerraformAzureCosmosDBExample` ================================================ FILE: examples/azure/terraform-azure-cosmosdb-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN AZURE VIRTUAL MACHINE # This is an example of how to deploy an Azure Virtual Machine with the minimum network resources. # --------------------------------------------------------------------------------------------------------------------- # See test/azure/terraform_azure_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- provider "azurerm" { features {} } # --------------------------------------------------------------------------------------------------------------------- # PIN TERRAFORM VERSION TO >= 0.12 # The examples have been upgraded to 0.12 syntax # --------------------------------------------------------------------------------------------------------------------- terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" required_providers { azurerm = { version = "~> 2.29" source = "hashicorp/azurerm" } } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RESOURCE GROUP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_resource_group" "rg" { name = "terratest-cosmos-rg-${var.postfix}" location = var.location } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A COSMOSDB ACCOUNT # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_cosmosdb_account" "test" { name = "terratest-${var.postfix}" location = azurerm_resource_group.rg.location resource_group_name = azurerm_resource_group.rg.name offer_type = "Standard" kind = "GlobalDocumentDB" consistency_policy { consistency_level = "Session" max_interval_in_seconds = 5 max_staleness_prefix = 100 } geo_location { location = azurerm_resource_group.rg.location failover_priority = 0 } } resource "azurerm_cosmosdb_sql_database" "testdb" { name = "testdb" throughput = var.throughput resource_group_name = azurerm_resource_group.rg.name account_name = azurerm_cosmosdb_account.test.name } resource "azurerm_cosmosdb_sql_container" "container1" { name = "test-container-1" throughput = var.throughput partition_key_path = "/key1" resource_group_name = azurerm_cosmosdb_account.test.resource_group_name account_name = azurerm_cosmosdb_account.test.name database_name = azurerm_cosmosdb_sql_database.testdb.name } resource "azurerm_cosmosdb_sql_container" "container2" { name = "test-container-2" partition_key_path = "/key2" resource_group_name = azurerm_cosmosdb_account.test.resource_group_name account_name = azurerm_cosmosdb_account.test.name database_name = azurerm_cosmosdb_sql_database.testdb.name } resource "azurerm_cosmosdb_sql_container" "container3" { name = "test-container-3" partition_key_path = "/key3" resource_group_name = azurerm_cosmosdb_account.test.resource_group_name account_name = azurerm_cosmosdb_account.test.name database_name = azurerm_cosmosdb_sql_database.testdb.name } ================================================ FILE: examples/azure/terraform-azure-cosmosdb-example/outputs.tf ================================================ output "resource_group_name" { value = azurerm_resource_group.rg.name } output "account_name" { value = azurerm_cosmosdb_account.test.name } output "endpoint" { value = azurerm_cosmosdb_account.test.endpoint } output "primary_key" { value = azurerm_cosmosdb_account.test.primary_key sensitive = true } ================================================ FILE: examples/azure/terraform-azure-cosmosdb-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "postfix" { description = "A postfix string to centrally mitigate resource name collisions" type = string default = "resource" } variable "location" { description = "The location to set for the CosmosDB instance." default = "East US" } variable "throughput" { description = "The RU/s throughput for the database account." default = 400 } ================================================ FILE: examples/azure/terraform-azure-datafactory-example/README.md ================================================ # Terraform Azure Data Factory Example This folder contains a Terraform module that deploys resources in [Azure](https://azure.microsoft.com/) to demonstrate how you can use Terratest to write automated tests for your Azure Terraform code. This module deploys a Data Factory. - A [Azure MySQL Database](https://azure.microsoft.com/en-us/products/data-factory). Check out [test/azure/terraform_azure_datafactory_example_test.go](./../../../test/azure/terraform_azure_datafactory_example_test.go) to see how you can write automated tests for this module and validate the configuration of the parameters and options. **WARNING**: This module and the automated tests for it deploy real resources into your Azure account which can cost you money. ## Running this module manually 1. Sign up for [Azure](https://azure.microsoft.com/). 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Ensure [environment variables](../README.md#review-environment-variables) are available 1. Run `terraform init` 1. Run `terraform apply` 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Configure your Terratest [Go test environment](../README.md) 1. `cd test/azure` 1. `go build terraform_azure_datafactory_example_test.go` 2. `go test -v -timeout 60m -tags azure -run TestTerraformAzureDataFactoryExample` ================================================ FILE: examples/azure/terraform-azure-datafactory-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN AZURE DATA FACTORY # This is an example of how to deploy an AZURE Data Factory # See test/terraform_azure_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # CONFIGURE OUR AZURE CONNECTION # --------------------------------------------------------------------------------------------------------------------- terraform { required_providers { azurerm = { version = "~> 2.93.0" source = "hashicorp/azurerm" } } } provider "azurerm" { features {} } # --------------------------------------------------------------------------------------------------------------------- # CREATE RANDOM PASSWORD # --------------------------------------------------------------------------------------------------------------------- # Random password is used as an example to simplify the deployment and improve the security of the database. # This is not as a production recommendation as the password is stored in the Terraform state file. resource "random_password" "password" { length = 16 override_special = "-_%@" min_upper = "1" min_lower = "1" min_numeric = "1" min_special = "1" } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RESOURCE GROUP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_resource_group" "datafactory_rg" { name = "terratest-datafactory-${var.postfix}" location = var.location } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A DATA FACTORY # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_data_factory" "data_factory" { name = "datafactory${var.postfix}" location = azurerm_resource_group.datafactory_rg.location resource_group_name = azurerm_resource_group.datafactory_rg.name } ================================================ FILE: examples/azure/terraform-azure-datafactory-example/outputs.tf ================================================ output "resource_group_name" { value = azurerm_resource_group.datafactory_rg.name } output "datafactory_name" { value = azurerm_data_factory.data_factory.name } ================================================ FILE: examples/azure/terraform-azure-datafactory-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # ARM_CLIENT_ID # ARM_CLIENT_SECRET # ARM_SUBSCRIPTION_ID # ARM_TENANT_ID # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "location" { description = "The supported azure location where the resource exists" type = string default = "West US2" } variable "postfix" { description = "A postfix string to centrally mitigate resource name collisions." type = string default = "resource" } ================================================ FILE: examples/azure/terraform-azure-disk-example/README.md ================================================ # Terraform Azure Disk Example This folder contains a simple Terraform module that deploys resources in [Azure](https://azure.microsoft.com/) to demonstrate how you can use Terratest to write automated tests for your Azure Terraform code. This module deploys an [Azure managed disk](https://azure.microsoft.com/services/storage/disks). Check out [test/azure/terraform_azure_disk_example_test.go](/test/azure/terraform_azure_disk_example_test.go) to see how you can write automated tests for this module. Note that the resources deployed in this module don't actually do anything; it just runs the resources for demonstration purposes. **WARNING**: This module and the automated tests for it deploy real resources into your Azure account which can cost you money. The resources are all part of the [Azure Free Account](https://azure.microsoft.com/free/), so if you haven't used that up, it should be free, but you are completely responsible for all Azure charges. ## Running this module manually 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Ensure [environment variables](../README.md#review-environment-variables) are available 1. Run `terraform init` 1. Run `terraform apply` 1. When you're done, run `terraform destroy` ## Running automated tests against this module 1. Sign up for [Azure](https://azure.microsoft.com/). 2. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/cli/azure/azure-cli-configuration?view=azure-cli-latest). 3. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 4. [Review environment variables](#review-environment-variables). 5. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 6. `cd test` 7. Make sure [the azure-sdk-for-go versions match](#check-go-dependencies) in [/test/go.mod](/test/go.mod) and in [test/azure/terraform_azure_disk_example_test.go](/test/azure/terraform_azure_disk_example_test.go). 8. `go test -v -run TestTerraformAzureDiskExample` ================================================ FILE: examples/azure/terraform-azure-disk-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN AZURE MANAGED DISK # This is an example of how to deploy a managed disk. # --------------------------------------------------------------------------------------------------------------------- # See test/azure/terraform_azure_disk_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- provider "azurerm" { features {} } terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" required_providers { azurerm = { version = "~> 2.29" source = "hashicorp/azurerm" } } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RESOURCE GROUP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_resource_group" "disk_rg" { name = "terratest-disk-rg-${var.postfix}" location = var.location } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY THE DISK # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_managed_disk" "disk" { name = "disk-${var.postfix}" location = azurerm_resource_group.disk_rg.location resource_group_name = azurerm_resource_group.disk_rg.name storage_account_type = var.disk_type create_option = "Empty" disk_size_gb = 10 } ================================================ FILE: examples/azure/terraform-azure-disk-example/outputs.tf ================================================ output "resource_group_name" { value = azurerm_resource_group.disk_rg.name } output "disk_name" { value = azurerm_managed_disk.disk.name } output "disk_type" { value = azurerm_managed_disk.disk.storage_account_type } ================================================ FILE: examples/azure/terraform-azure-disk-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # ARM_CLIENT_ID # ARM_CLIENT_SECRET # ARM_SUBSCRIPTION_ID # ARM_TENANT_ID # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "postfix" { description = "A postfix string to centrally mitigate resource name collisions" type = string default = "resource" } variable "location" { description = "The Azure region in which to deploy your resources to" type = string default = "East US" } variable "disk_type" { description = "The managed disk type" type = string default = "Standard_LRS" } ================================================ FILE: examples/azure/terraform-azure-example/README.md ================================================ # Terraform Azure Example This folder contains a simple Terraform module that deploys resources in [Azure](https://azure.microsoft.com/) to demonstrate how you can use Terratest to write automated tests for your Azure Terraform code. This module deploys a [Virtual Machine](https://azure.microsoft.com/en-us/services/virtual-machines/) and gives that VM a `Name` tag with the value specified in the `vm_name` variable. Check out [test/azure/terraform_azure_example_test.go](/test/azure/terraform_azure_example_test.go) to see how you can write automated tests for this module. Note that the Virtual Machine in this module doesn't actually do anything; it just runs a Vanilla Ubuntu 16.04 image for demonstration purposes. For slightly more complicated, real-world examples of Terraform modules, see [terraform-http-example](/examples/terraform-http-example) and [terraform-ssh-example](/examples/terraform-ssh-example). **WARNING**: This module and the automated tests for it deploy real resources into your Azure account which can cost you money. The resources are all part of the [Azure Free Account](https://azure.microsoft.com/en-us/free/), so if you haven't used that up, it should be free, but you are completely responsible for all Azure charges. ## Running this module manually 1. Sign up for [Azure](https://azure.microsoft.com/). 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest). 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Run `terraform init`. 1. Run `terraform apply`. 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [Azure](https://azure.microsoft.com/). 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest). 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. [Review environment variables](#review-environment-variables). 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. Make sure [the azure-sdk-for-go versions match](#check-go-dependencies) in [/test/go.mod](/test/go.mod) and in [test/azure/terraform_azure_example_test.go](/test/azure/terraform_azure_example_test.go). 1. `go build terraform_azure_example_test.go` 1. `go test -v -run TestTerraformAzureExample` ## Check Go Dependencies Check that the `github.com/Azure/azure-sdk-for-go` version in your generated `go.mod` for this test matches the version in the terratest [go.mod](https://github.com/gruntwork-io/terratest/blob/main/go.mod) file. > This was tested with **go1.14.1**. ### Check Azure-sdk-for-go version Let's make sure [go.mod](https://github.com/gruntwork-io/terratest/blob/main/go.mod) includes the appropriate [azure-sdk-for-go version](https://github.com/Azure/azure-sdk-for-go/releases/tag/v38.1.0): ```go require ( ... github.com/Azure/azure-sdk-for-go v38.1.0+incompatible ... ) ``` We should check that [test/azure/terraform_azure_example_test.go](/test/azure/terraform_azure_example_test.go) includes the corresponding [azure-sdk-for-go package](https://github.com/Azure/azure-sdk-for-go/tree/master/services/compute/mgmt/2019-07-01/compute): ```go import ( "github.com/Azure/azure-sdk-for-go/services/compute/mgmt/2019-07-01/compute" ... ) ``` If we make changes to either the **go.mod** or the **go test file**, we should make sure that the go build command works still. ```powershell go build terraform_azure_example_test.go ``` ## Review Environment Variables As part of configuring terraform for Azure, we'll want to check that we have set the appropriate [credentials](https://docs.microsoft.com/azure/terraform/terraform-install-configure?toc=https%3A%2F%2Fdocs.microsoft.com%2Fen-us%2Fazure%2Fterraform%2Ftoc.json&bc=https%3A%2F%2Fdocs.microsoft.com%2Fen-us%2Fazure%2Fbread%2Ftoc.json#set-up-terraform-access-to-azure) and also that we set the [environment variables](https://docs.microsoft.com/azure/terraform/terraform-install-configure?toc=https%3A%2F%2Fdocs.microsoft.com%2Fen-us%2Fazure%2Fterraform%2Ftoc.json&bc=https%3A%2F%2Fdocs.microsoft.com%2Fen-us%2Fazure%2Fbread%2Ftoc.json#configure-terraform-environment-variables) on the testing host. For non-commercial cloud deployments, set the "AZURE_ENVIRONMENT" environment variable to the appropriate cloud environment (this is used by the [client_factory](../../modules/azure/client_factory.go) to set the correct azure endpoints): ```bash export ARM_CLIENT_ID=your_app_id export ARM_CLIENT_SECRET=your_password export ARM_SUBSCRIPTION_ID=your_subscription_id export ARM_TENANT_ID=your_tenant_id # AZURE_ENVIRONMENT is the name of the Azure environment to use. Set to one of the following: export AZURE_ENVIRONMENT=AzureUSGovernmentCloud export AZURE_ENVIRONMENT=AzureChinaCloud export AZURE_ENVIRONMENT=AzureGermanCloud export AZURE_ENVIRONMENT=AzurePublicCloud export AZURE_ENVIRONMENT=AzureStackCloud ``` Note, in a Windows environment, these should be set as **system environment variables**. We can use a PowerShell console with administrative rights to update these environment variables: ```powershell [System.Environment]::SetEnvironmentVariable("ARM_CLIENT_ID",$your_app_id,[System.EnvironmentVariableTarget]::Machine) [System.Environment]::SetEnvironmentVariable("ARM_CLIENT_SECRET",$your_password,[System.EnvironmentVariableTarget]::Machine) [System.Environment]::SetEnvironmentVariable("ARM_SUBSCRIPTION_ID",$your_subscription_id,[System.EnvironmentVariableTarget]::Machine) [System.Environment]::SetEnvironmentVariable("ARM_TENANT_ID",$your_tenant_id,[System.EnvironmentVariableTarget]::Machine) [System.Environment]::SetEnvironmentVariable("AZURE_ENVIRONMENT",$your_azure_env,[System.EnvironmentVariableTarget]::Machine) ``` ================================================ FILE: examples/azure/terraform-azure-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN AZURE VIRTUAL MACHINE # This is an example of how to deploy an Azure Virtual Machine with the minimum network resources. # --------------------------------------------------------------------------------------------------------------------- # See test/azure/terraform_azure_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- provider "azurerm" { features {} } # --------------------------------------------------------------------------------------------------------------------- # PIN TERRAFORM VERSION TO >= 0.12 # The examples have been upgraded to 0.12 syntax # --------------------------------------------------------------------------------------------------------------------- terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" required_providers { azurerm = { version = "~> 2.50" source = "hashicorp/azurerm" } } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RESOURCE GROUP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_resource_group" "main" { name = "terratest-rg-${var.postfix}" location = "East US" } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY VIRTUAL NETWORK RESOURCES # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_virtual_network" "main" { name = "vnet-${var.postfix}" address_space = ["10.0.0.0/16"] location = azurerm_resource_group.main.location resource_group_name = azurerm_resource_group.main.name } resource "azurerm_subnet" "internal" { name = "subnet-${var.postfix}" resource_group_name = azurerm_resource_group.main.name virtual_network_name = azurerm_virtual_network.main.name address_prefixes = ["10.0.17.0/24"] } resource "azurerm_network_interface" "main" { name = "nic-${var.postfix}" location = azurerm_resource_group.main.location resource_group_name = azurerm_resource_group.main.name ip_configuration { name = "terratestconfiguration1" subnet_id = azurerm_subnet.internal.id private_ip_address_allocation = "Dynamic" } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A VIRTUAL MACHINE RUNNING UBUNTU # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_virtual_machine" "main" { name = "vm-${var.postfix}" location = azurerm_resource_group.main.location resource_group_name = azurerm_resource_group.main.name network_interface_ids = [azurerm_network_interface.main.id] vm_size = "Standard_B1s" delete_os_disk_on_termination = true delete_data_disks_on_termination = true storage_image_reference { publisher = "Canonical" offer = "UbuntuServer" sku = "16.04-LTS" version = "latest" } storage_os_disk { name = "terratestosdisk1" caching = "ReadWrite" create_option = "FromImage" managed_disk_type = "Standard_LRS" } os_profile { computer_name = "vm-${var.postfix}" admin_username = var.username admin_password = random_password.main.result } os_profile_linux_config { disable_password_authentication = false } depends_on = [random_password.main] } # Random password is used as an example to simplify the deployment and improve the security of the remote VM. # This is not as a production recommendation as the password is stored in the Terraform state file. resource "random_password" "main" { length = 16 override_special = "-_%@" min_upper = "1" min_lower = "1" min_numeric = "1" min_special = "1" } ================================================ FILE: examples/azure/terraform-azure-example/outputs.tf ================================================ output "resource_group_name" { value = azurerm_resource_group.main.name } output "vm_name" { value = azurerm_virtual_machine.main.name } ================================================ FILE: examples/azure/terraform-azure-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # ARM_CLIENT_ID # ARM_CLIENT_SECRET # ARM_SUBSCRIPTION_ID # ARM_TENANT_ID # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "location" { description = "The Azure location where to deploy your resources too" type = string default = "East US" } variable "postfix" { description = "A postfix string to centrally mitigate resource name collisions" type = string default = "resource" } variable "username" { description = "The username to be provisioned into your VM" type = string default = "testadmin" } ================================================ FILE: examples/azure/terraform-azure-frontdoor-example/README.md ================================================ # Terraform Azure Front Door Example This folder contains a simple Terraform module that deploys resources in [Azure](https://azure.microsoft.com/) to demonstrate how you can use Terratest to write automated tests for your Azure Terraform code. This module deploys an [Azure Front Door](https://azure.microsoft.com/en-us/services/frontdoor/). Check out [test/azure/terraform_azure_frontdoor_example_test.go](./../../../test/azure/terraform_azure_frontdoor_example_test.go) to see how you can write automated tests for this module and validate the configuration of the parameters and options. **WARNING**: This module and the automated tests for it deploy real resources into your Azure account which can cost you money. ## Running this module manually 1. Sign up for [Azure](https://azure.microsoft.com/). 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Ensure [environment variables](../README.md#review-environment-variables) are available 1. Run `terraform init` 1. Run `terraform apply` 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Configure your Terratest [Go test environment](../README.md) 1. `cd test/azure` 1. `go build terraform_azure_frontdoor_example_test.go` 1. `go test -v -timeout 60m -tags azure -run TestTerraformAzureFrontDoorExample` ================================================ FILE: examples/azure/terraform-azure-frontdoor-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN AZURE FRONT DOOR # This is an example of how to deploy an Azure Front Door with the minimum resources. # --------------------------------------------------------------------------------------------------------------------- # See test/azure/terraform_azure_frontdoor_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- terraform { required_version = ">=0.14.0" } provider "azurerm" { features {} } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RESOURCE GROUP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_resource_group" "rg" { name = "terratest-frontdoor-rg-${var.postfix}" location = var.location } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY FRONT DOOR # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_frontdoor" "frontdoor" { name = "terratest-afd-${var.postfix}" resource_group_name = azurerm_resource_group.rg.name backend_pool_settings { enforce_backend_pools_certificate_name_check = false } routing_rule { name = "terratestRoutingRule1" accepted_protocols = ["Http", "Https"] patterns_to_match = ["/*"] frontend_endpoints = ["terratestEndpoint"] forwarding_configuration { forwarding_protocol = "MatchRequest" backend_pool_name = "terratestBackend" } } backend_pool_load_balancing { name = "terratestLoadBalanceSetting" } backend_pool_health_probe { name = "terratestHealthProbeSetting" } backend_pool { name = "terratestBackend" backend { host_header = var.backend_host address = var.backend_host http_port = 80 https_port = 443 } load_balancing_name = "terratestLoadBalanceSetting" health_probe_name = "terratestHealthProbeSetting" } frontend_endpoint { name = "terratestEndpoint" host_name = "terratest-afd-${var.postfix}.azurefd.net" } } ================================================ FILE: examples/azure/terraform-azure-frontdoor-example/output.tf ================================================ output "resource_group_name" { value = azurerm_resource_group.rg.name } output "front_door_name" { description = "Specifies the name of the Front Door service." value = azurerm_frontdoor.frontdoor.name } output "front_door_url" { description = "Specifies the host name of the frontend_endpoint. Must be a domain name." value = azurerm_frontdoor.frontdoor.frontend_endpoint[0].host_name } output "front_door_endpoint_name" { description = "Specifies the friendly name of the frontend_endpoint" value = azurerm_frontdoor.frontdoor.frontend_endpoint[0].name } ================================================ FILE: examples/azure/terraform-azure-frontdoor-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # ARM_CLIENT_ID # ARM_CLIENT_SECRET # ARM_SUBSCRIPTION_ID # ARM_TENANT_ID # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "location" { description = "The Azure location where to deploy your resources too" type = string default = "East US" } variable "postfix" { description = "A postfix string to centrally mitigate resource name collisions" type = string default = "resource" } variable "backend_host" { description = "The IP address or FQDN of the backend" type = string default = "www.bing.com" } ================================================ FILE: examples/azure/terraform-azure-functionapp-example/README.md ================================================ # Terraform Azure Function App Example This folder contains a Terraform module that deploys a Function App in [Azure](https://azure.microsoft.com/) to demonstrate how you can use Terratest to write automated tests for your Azure Terraform code. This module deploys [Azure Storage](https://azure.microsoft.com/en-us/services/storage/), [Azure Function App](https://azure.microsoft.com/en-us/services/functions/), [Azure Function App](https://azure.microsoft.com/en-us/services/functions/). Check out [test/azure/terraform_azure_functionapp_example_test.go](/test/azure/terraform_azure_functionapp_example_test.go) to see how you can write automated tests for this module and validate the configuration of the parameters and options. **WARNING**: This module and the automated tests for it deploy real resources into your Azure account which can cost you money. The resources are all part of the [Azure Free Account](https://azure.microsoft.com/free/), so if you haven't used that up, it should be free, but you are completely responsible for all Azure charges. ## Running this module manually 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Ensure [environment variables](../README.md#review-environment-variables) are available 1. Run `terraform init` 1. Run `terraform apply` 1. When you're done, run `terraform destroy` ## Running automated tests against this module 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Configure your Terratest [Go test environment](../README.md) 1. `cd test/azure` 1. `go build terraform_azure_functionapp_example_test.go` 1. `go test -v -run TestTerraformAzureFunctionAppExample` ================================================ FILE: examples/azure/terraform-azure-functionapp-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # Deploy an Azure storage account, service plan, function app, and application insights # This is an example of how to deploy an Azure function app. # See test/terraform_azure_functionapp_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # CONFIGURE OUR AZURE CONNECTION # --------------------------------------------------------------------------------------------------------------------- terraform { required_providers { azurerm = { version = "~>2.29.0" source = "hashicorp/azurerm" } } } provider "azurerm" { features {} } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY THE RESOURCE GROUP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_resource_group" "app_rg" { name = "terratest-functionapp-rg-${var.postfix}" location = var.location } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY THE AZURE STORAGE ACCOUNT # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_storage_account" "storage" { name = "storageaccount${var.postfix}" resource_group_name = azurerm_resource_group.app_rg.name location = azurerm_resource_group.app_rg.location account_tier = "Standard" account_replication_type = "LRS" } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY THE APP SERVICE PLAN # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_app_service_plan" "app_service_plan" { name = "appservice-plan-${var.postfix}" location = azurerm_resource_group.app_rg.location resource_group_name = azurerm_resource_group.app_rg.name kind = "FunctionApp" sku { tier = "Standard" size = "S1" } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY THE APPLICATION INSIGHTS # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_application_insights" "application_insights" { name = "appinsights-${var.postfix}" location = azurerm_resource_group.app_rg.location resource_group_name = azurerm_resource_group.app_rg.name application_type = "web" } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY THE AZURE FUNCTION APP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_function_app" "function_app" { name = "functionapp-${var.postfix}" location = azurerm_resource_group.app_rg.location resource_group_name = azurerm_resource_group.app_rg.name app_service_plan_id = azurerm_app_service_plan.app_service_plan.id storage_account_name = azurerm_storage_account.storage.name storage_account_access_key = azurerm_storage_account.storage.primary_access_key app_settings = { "APPINSIGHTS_INSTRUMENTATIONKEY" = azurerm_application_insights.application_insights.instrumentation_key "APPLICATIONINSIGHTS_CONNECTION_STRING" = "InstrumentationKey=${azurerm_application_insights.application_insights.instrumentation_key}" } } ================================================ FILE: examples/azure/terraform-azure-functionapp-example/output.tf ================================================ output "resource_group_name" { value = azurerm_resource_group.app_rg.name } output "function_app_id" { value = azurerm_function_app.function_app.id } output "default_hostname" { value = azurerm_function_app.function_app.default_hostname } output "function_app_kind" { value = azurerm_function_app.function_app.kind } output "function_app_name" { value = azurerm_function_app.function_app.name } ================================================ FILE: examples/azure/terraform-azure-functionapp-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # ARM_CLIENT_ID # ARM_CLIENT_SECRET # ARM_SUBSCRIPTION_ID # ARM_TENANT_ID # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "location" { description = "The supported azure location where the resource exists" type = string default = "West US2" } variable "postfix" { description = "A postfix string to centrally mitigate resource name collisions." type = string default = "1276" } ================================================ FILE: examples/azure/terraform-azure-keyvault-example/README.md ================================================ # Terraform Azure Keyvault Example This folder contains a simple Terraform module that deploys resources in [Azure](https://azure.microsoft.com/) to demonstrate how you can use Terratest to write automated tests for your Azure Terraform code. This module deploys a Key Vault with one secret, key, and certificate each. - A [Key Vault](https://azure.microsoft.com/services/key-vault/) that gives the module the following: - [Secret](https://docs.microsoft.com/azure/key-vault/general/about-keys-secrets-certificates) with the value specified in the `secret_name` output variable. - [Key](https://docs.microsoft.com/azure/key-vault/general/about-keys-secrets-certificates) with the value specified in the `key_name` output variable. - [Certificate](https://docs.microsoft.com/azure/key-vault/general/about-keys-secrets-certificates) with the value specified in the `certificate_name` output variable. Check out [test/azure/terraform_azure_keyvault_example_test.go](/test/azure/terraform_azure_keyvault_example_test.go) to see how you can write automated tests for this module. Note that the Key Vault in this module don't actually do anything; it just runs the resources for demonstration purposes. **WARNING**: This module and the automated tests for it deploy real resources into your Azure account which can cost you money. The resources are all part of the [Azure Free Account](https://azure.microsoft.com/free/), so if you haven't used that up, it should be free, but you are completely responsible for all Azure charges. ## Running this module manually 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Ensure [environment variables](../README.md#review-environment-variables) are available 1. Run `terraform init` 1. Run `terraform apply` 1. When you're done, run `terraform destroy` ## Running automated tests against this module 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Configure your Terratest [Go test environment](../README.md) 1. `cd test/azure` 1. `go build terraform_azure_keyvault_example_test.go` 1. `go test -v -run TestTerraformAzureKeyVaultExample` ================================================ FILE: examples/azure/terraform-azure-keyvault-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN AZURE KEY VAULT # This is an example of how to deploy a Key Vault # --------------------------------------------------------------------------------------------------------------------- # See test/azure/terraform_azure_keyvault_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- provider "azurerm" { features { key_vault { purge_soft_delete_on_destroy = false } } } # --------------------------------------------------------------------------------------------------------------------- # PIN TERRAFORM VERSION TO >= 0.12 # The examples have been upgraded to 0.12 syntax # --------------------------------------------------------------------------------------------------------------------- terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" required_providers { azurerm = { version = "~>3.0" source = "hashicorp/azurerm" } } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RESOURCE GROUP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_resource_group" "resource_group" { name = "terratest-kv-rg-${var.postfix}" location = var.location } # --------------------------------------------------------------------------------------------------------------------- # CONFIGURE A CLIENT FOR KEY VAULT ACCESS # --------------------------------------------------------------------------------------------------------------------- data "azurerm_client_config" "current" {} # --------------------------------------------------------------------------------------------------------------------- # CONFIGURE AN ACCESS POLICY TO MANAGE THE SECRET, KEY, AND CERTIFICATE # --------------------------------------------------------------------------------------------------------------------- data "azurerm_key_vault_access_policy" "contributor" { name = "Key, Secret, & Certificate Management" } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A KEY VAULT # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_key_vault" "key_vault" { name = "keyvault-${var.postfix}" location = azurerm_resource_group.resource_group.location resource_group_name = azurerm_resource_group.resource_group.name enabled_for_disk_encryption = true tenant_id = data.azurerm_client_config.current.tenant_id soft_delete_retention_days = 7 purge_protection_enabled = false sku_name = "standard" access_policy { tenant_id = data.azurerm_client_config.current.tenant_id object_id = data.azurerm_client_config.current.object_id key_permissions = [ "Create", "Get", "List", "Delete", "Purge", "SetRotationPolicy", "GetRotationPolicy" ] secret_permissions = [ "Set", "Get", "List", "Delete", "Purge", ] certificate_permissions = [ "Create", "Delete", "DeleteIssuers", "Get", "GetIssuers", "Import", "List", "ListIssuers", "ManageContacts", "ManageIssuers", "SetIssuers", "Update", "Purge", ] } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A SECRET TO THE KEY VAULT # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_key_vault_secret" "key_vault_secret" { name = "${var.secret_name}-${var.postfix}" value = "mysecret" key_vault_id = azurerm_key_vault.key_vault.id } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A KEY TO THE KEY VAULT # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_key_vault_key" "key_vault_key" { name = "${var.key_name}-${var.postfix}" key_vault_id = azurerm_key_vault.key_vault.id key_type = "RSA" key_size = 2048 key_opts = [ "decrypt", "encrypt", "sign", "unwrapKey", "verify", "wrapKey", ] } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A CERTIFICATE TO THE KEY VAULT # The example uses a sample pfx file with plain text password to make it easier to test. However, in production modules # should use a more secure mechanisms for transferring these files. # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_key_vault_certificate" "key_vault_certificate" { name = "${var.certificate_name}-${var.postfix}" key_vault_id = azurerm_key_vault.key_vault.id certificate { contents = filebase64("example.pfx") password = "password" } certificate_policy { issuer_parameters { name = "Self" } key_properties { exportable = true key_size = 2048 key_type = "RSA" reuse_key = false } secret_properties { content_type = "application/x-pkcs12" } } } ================================================ FILE: examples/azure/terraform-azure-keyvault-example/outputs.tf ================================================ output "resource_group_name" { value = azurerm_resource_group.resource_group.name } output "key_vault_name" { value = azurerm_key_vault.key_vault.name } output "secret_name" { value = azurerm_key_vault_secret.key_vault_secret.name } output "key_name" { value = azurerm_key_vault_key.key_vault_key.name } output "certificate_name" { value = azurerm_key_vault_certificate.key_vault_certificate.name } ================================================ FILE: examples/azure/terraform-azure-keyvault-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # ARM_CLIENT_ID # ARM_CLIENT_SECRET # ARM_SUBSCRIPTION_ID # ARM_TENANT_ID # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "location" { description = "The location to set for the storage account." type = string default = "East US" } variable "secret_name" { description = "The name to set for the key vault secret." type = string default = "secret1" } variable "key_name" { description = "The name to set for the key vault key." type = string default = "key1" } variable "certificate_name" { description = "The name to set for the key vault certificate." type = string default = "certificate1" } variable "postfix" { description = "A postfix string to centrally mitigate resource name collisions" type = string default = "resource" } ================================================ FILE: examples/azure/terraform-azure-loadbalancer-example/README.md ================================================ # Terraform Load Balancer Example This folder contains a simple Terraform module that deploys resources in [Azure](https://azure.microsoft.com/) to demonstrate how you can use Terratest to write automated tests for your Azure Terraform code. This module deploys two Load Balancers for Public and Private IP scenarios. - A Public [Load Balancer](https://docs.microsoft.com/azure/load-balancer/) that gives the module the following: - `Load Balancer` with the name specified in the `lb_public_name` and configuration in the `lb_public_fe_config_name` output variables. - A Private [Load Balancer](https://docs.microsoft.com/azure/load-balancer/) that gives the module the following: - `Load Balancer` with the name specified in the `lb_private_name` and static Frontend IP Configuration in the `lb_private_fe_config_static_name` output variables. - A Default [Load Balancer](https://docs.microsoft.com/azure/load-balancer/) that gives the module the following: - `Load Balancer` with the name specified in the `lb_default_name` and no Frontend configuration. - Networking that provides the following for the Load Balancer module with the following: - [Virtual Network](https://docs.microsoft.com/azure/virtual-network/) with the name specified in the `vnet_name` output variable. - [Subnet](https://docs.microsoft.com/azure/virtual-network/virtual-network-manage-subnet) with the name specified in the `subnet_name` output variable. - [Public IP Address](https://docs.microsoft.com/azure/virtual-network/virtual-network-public-ip-address) with the name specified in the `public_address_name` output variable. Check out [test/azure/terraform_azure_loadbalancer_example_test.go](/test/azure/terraform_azure_loadbalancer_example_test.go) to see how you can write automated tests for this module. Note that the Load Balancers and their associated resources in this module don't actually do anything; they are created before running the tests, for demonstration purposes. **WARNING**: This module and the automated tests for it deploy real resources into your Azure account which can cost you money. The resources are all part of the [Azure Free Account](https://azure.microsoft.com/free/), so if you haven't used that up, it should be free, but you are completely responsible for all Azure charges. ## Running this module manually 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Ensure [environment variables](../README.md#review-environment-variables) are available 1. Run `terraform init` 1. Run `terraform apply` 1. When you're done, run `terraform destroy` ## Running automated tests against this module 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Configure your Terratest [Go test environment](../README.md) 1. `cd test/azure` 1. `go build terraform_azure_loadbalancer_example_test.go` 1. `go test -v -run TestTerraformAzureLoadBalancerExample` ================================================ FILE: examples/azure/terraform-azure-loadbalancer-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN AZURE AVAILABILITY SET # This is an example of how to deploy an Azure Availability Set with a Virtual Machine in the availability set # and the minimum network resources for the VM. # --------------------------------------------------------------------------------------------------------------------- # See test/azure/terraform_azure_loadbalancer_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- provider "azurerm" { features {} } # --------------------------------------------------------------------------------------------------------------------- # PIN TERRAFORM VERSION TO >= 0.12 # The examples have been upgraded to 0.12 syntax # --------------------------------------------------------------------------------------------------------------------- terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" required_providers { azurerm = { version = "~>2.29" source = "hashicorp/azurerm" } } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RESOURCE GROUP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_resource_group" "lb_rg" { name = "terratest-lb-rg-${var.postfix}" location = var.location } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY VIRTUAL NETWORK # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_virtual_network" "vnet" { name = "vnet-${var.postfix}" location = azurerm_resource_group.lb_rg.location resource_group_name = azurerm_resource_group.lb_rg.name address_space = ["10.200.0.0/21"] } resource "azurerm_subnet" "subnet" { name = "subnet-${var.postfix}" resource_group_name = azurerm_resource_group.lb_rg.name virtual_network_name = azurerm_virtual_network.vnet.name address_prefixes = ["10.200.2.0/25"] } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY LOAD BALANCER WITH PUBLIC IP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_public_ip" "pip" { name = "pip-${var.postfix}" location = azurerm_resource_group.lb_rg.location resource_group_name = azurerm_resource_group.lb_rg.name allocation_method = "Static" ip_version = "IPv4" sku = "Basic" idle_timeout_in_minutes = "4" } resource "azurerm_lb" "public" { name = "lb-public-${var.postfix}" location = azurerm_resource_group.lb_rg.location resource_group_name = azurerm_resource_group.lb_rg.name sku = "Basic" frontend_ip_configuration { name = "config-public" public_ip_address_id = azurerm_public_ip.pip.id } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY LOAD BALANCER WITH PRIVATE IPs # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_lb" "private" { name = "lb-private-${var.postfix}" location = azurerm_resource_group.lb_rg.location resource_group_name = azurerm_resource_group.lb_rg.name sku = "Basic" frontend_ip_configuration { name = "config-private-static" subnet_id = azurerm_subnet.subnet.id private_ip_address = var.lb_private_ip private_ip_address_allocation = "Static" } frontend_ip_configuration { name = "config-private-dynamic" subnet_id = azurerm_subnet.subnet.id private_ip_address_allocation = "Dynamic" } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY LOAD BALANCER WITH NO FRONTEND CONFIGURATION # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_lb" "default" { name = "lb-no-frontend-${var.postfix}" location = azurerm_resource_group.lb_rg.location resource_group_name = azurerm_resource_group.lb_rg.name sku = "Basic" } ================================================ FILE: examples/azure/terraform-azure-loadbalancer-example/outputs.tf ================================================ output "lb_default_name" { value = azurerm_lb.default.name } output "lb_private_name" { value = azurerm_lb.private.name } output "lb_private_fe_config_static_name" { value = azurerm_lb.private.frontend_ip_configuration[0].name } output "lb_private_fe_config_dynamic_name" { value = azurerm_lb.private.frontend_ip_configuration[1].name } output "lb_private_ip_static" { value = azurerm_lb.private.frontend_ip_configuration[0].private_ip_address } output "lb_private_ip_dynamic" { value = azurerm_lb.private.frontend_ip_configuration[1].private_ip_address } output "lb_public_name" { value = azurerm_lb.public.name } output "lb_public_fe_config_name" { value = azurerm_lb.public.frontend_ip_configuration[0].name } output "public_address_name" { value = azurerm_public_ip.pip.name } output "resource_group_name" { value = azurerm_resource_group.lb_rg.name } ================================================ FILE: examples/azure/terraform-azure-loadbalancer-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # ARM_CLIENT_ID # ARM_CLIENT_SECRET # ARM_SUBSCRIPTION_ID # ARM_TENANT_ID # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "lb_private_ip" { description = "Private IP for the Private Load Balancer" type = string default = "10.200.2.10" } variable "location" { description = "The Azure location where to deploy your resources too" type = string default = "East US" } variable "postfix" { description = "A postfix string to centrally mitigate resource name collisions" type = string default = "resource" } ================================================ FILE: examples/azure/terraform-azure-loganalytics-example/README.md ================================================ # Terraform Azure Log Analytics Example This folder contains a Terraform module that deploys resources in [Azure](https://azure.microsoft.com/) to demonstrate how you can use TerraTest to write automated tests for your Azure Terraform code. This module deploys a Log Analytics Workspace. - A [Log Analytics Workspace](https://docs.microsoft.com/azure/azure-monitor/platform/log-analytics-agent) that gives the module the following: - [Name](https://docs.microsoft.com/azure/azure-monitor/learn/quick-create-workspace#:~:text=%20Create%20a%20Log%20Analytics%20workspace%20in%20the,and%20region%20as%20in%20the%20deleted...%20More%20) with the value specified in the `loganalytics_workspace_name` output variable. - [Sku](https://docs.microsoft.com/azure/azure-monitor/learn/quick-create-workspace#:~:text=%20Create%20a%20Log%20Analytics%20workspace%20in%20the,and%20region%20as%20in%20the%20deleted...%20More%20) with the value specified in the `loganalytics_workspace_sku` output variable. - [RetentionPeriodInDays](https://docs.microsoft.com/azure/azure-monitor/learn/quick-create-workspace#:~:text=%20Create%20a%20Log%20Analytics%20workspace%20in%20the,and%20region%20as%20in%20the%20deleted...%20More%20) with the value specified in the `loganalytics_workspace_retention` output variable. Check out [test/azure/terraform_azure_loganalytics_example_test.go](/test/azure/terraform_azure_loganalytics_example_test.go) to see how you can write automated tests for this module. Note that the Log Analytics Workspace in this module don't actually do anything; it just runs the resources for demonstration purposes. **WARNING**: This module and the automated tests for it deploy real resources into your Azure account which can cost you money. The resources are all part of the [Azure Free Account](https://azure.microsoft.com/free/), so if you haven't used that up, it should be free, but you are completely responsible for all Azure charges. ## Running this module manually 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Ensure [environment variables](../README.md#review-environment-variables) are available 1. Run `terraform init` 1. Run `terraform apply` 1. When you're done, run `terraform destroy` ## Running automated tests against this module 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Configure your TerraTest [Go test environment](../README.md) 1. `cd test/azure` 1. `go build terraform_azure_loganalytics_example_test.go` 1. `go test -v -run TestTerraformAzureLogAnalyticsExample` ================================================ FILE: examples/azure/terraform-azure-loganalytics-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # DEPLOY LOG ANALYTICS # This is an example of how to deploy a Log Analytics workspace resource. # --------------------------------------------------------------------------------------------------------------------- # See test/azure/terraform_azure_loganalytics_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- provider "azurerm" { features {} } # PIN TERRAFORM VERSION terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" required_providers { azurerm = { version = "~> 2.20" source = "hashicorp/azurerm" } } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RESOURCE GROUP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_resource_group" "resource_group" { name = "terratest-log-rg-${var.postfix}" location = var.location } resource "azurerm_log_analytics_workspace" "log_analytics_workspace" { name = "log-ws-${var.postfix}" location = azurerm_resource_group.resource_group.location resource_group_name = azurerm_resource_group.resource_group.name sku = "PerGB2018" retention_in_days = 30 } ================================================ FILE: examples/azure/terraform-azure-loganalytics-example/outputs.tf ================================================ output "resource_group_name" { value = azurerm_resource_group.resource_group.name } output "loganalytics_workspace_name" { value = azurerm_log_analytics_workspace.log_analytics_workspace.name } output "loganalytics_workspace_sku" { value = azurerm_log_analytics_workspace.log_analytics_workspace.sku } output "loganalytics_workspace_retention" { value = azurerm_log_analytics_workspace.log_analytics_workspace.retention_in_days } ================================================ FILE: examples/azure/terraform-azure-loganalytics-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # ARM_CLIENT_ID # ARM_CLIENT_SECRET # ARM_SUBSCRIPTION_ID # ARM_TENANT_ID # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "location" { description = "The location to set for the storage account." type = string default = "East US" } variable "postfix" { description = "A postfix string to centrally mitigate resource name collisions" type = string default = "resource" } ================================================ FILE: examples/azure/terraform-azure-monitor-example/README.md ================================================ # Terraform Azure Monitor Example This folder contains a simple Terraform module that deploys resources in [Azure](https://azure.microsoft.com/) to demonstrate how you can use Terratest to write automated tests for your Azure Terraform code. This module deploys an Azure Storage Account and Azure Azure Key Vault with an Azure Monitor Diagnostic Setting. - A [Diagnostic Setting](https://docs.microsoft.com/azure/azure-monitor/platform/diagnostic-settings-template) Check out [test/azure/terraform_azure_monitor_example_test.go](/test/azure/terraform_azure_monitor_example_test.go) to see how you can write automated tests for this module. Note that the resources deployed in this module don't actually do anything; it just runs the resources for demonstration purposes. **WARNING**: This module and the automated tests for it deploy real resources into your Azure account which can cost you money. The resources are all part of the [Azure Free Account](https://azure.microsoft.com/free/), so if you haven't used that up, it should be free, but you are completely responsible for all Azure charges. ## Running this module manually 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Ensure [environment variables](../README.md#review-environment-variables) are available 1. Run `terraform init` 1. Run `terraform apply` 1. When you're done, run `terraform destroy` ## Running automated tests against this module 1. Sign up for [Azure](https://azure.microsoft.com/). 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/cli/azure/azure-cli-configuration?view=azure-cli-latest). 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. [Review environment variables](#review-environment-variables). 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. Make sure [the azure-sdk-for-go versions match](#check-go-dependencies) in [/go.mod](/go.mod) and in [test/azure/terraform_azure_monitor_example_test.go](/test/azure/terraform_azure_monitor_example_test.go). 1. `go build test/azure/terraform_azure_monitor_example_test.go` 1. `go test -v -run TestTerraformAzureMonitorExample` ================================================ FILE: examples/azure/terraform-azure-monitor-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN AZURE MONITOR DIAGNOSTIC SETTING # This is an example of how to deploy an Azure Monitor Diagnostic Setting # for a key vault with a storage account. # --------------------------------------------------------------------------------------------------------------------- # See test/azure/terraform_azure_monitor_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- provider "azurerm" { features { key_vault { purge_soft_delete_on_destroy = true } } } terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" required_providers { azurerm = { version = "~> 2.29" source = "hashicorp/azurerm" } azuread = { version = "=0.7.0" source = "hashicorp/azuread" } } } resource "random_string" "short" { length = 3 lower = true upper = false number = false special = false } resource "random_string" "long" { length = 6 lower = true upper = false number = false special = false } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RESOURCE GROUP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_resource_group" "monitor" { name = "terratest-monitor-rg-${var.postfix}" location = var.location } data "azurerm_client_config" "current" {} # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A STORAGE ACCOUNT # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_storage_account" "monitor" { name = format("%s%s", "storage", random_string.long.result) resource_group_name = azurerm_resource_group.monitor.name location = azurerm_resource_group.monitor.location account_tier = "Standard" account_replication_type = "GRS" tags = { environment = "staging" } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A KEY VAULT # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_key_vault" "monitor" { name = "kv-${var.postfix}" location = azurerm_resource_group.monitor.location resource_group_name = azurerm_resource_group.monitor.name enabled_for_disk_encryption = true tenant_id = data.azurerm_client_config.current.tenant_id soft_delete_enabled = true purge_protection_enabled = false sku_name = "standard" access_policy { tenant_id = data.azurerm_client_config.current.tenant_id object_id = data.azurerm_client_config.current.object_id key_permissions = [ "create", "get", "list", "delete", ] secret_permissions = [ "set", "get", "list", "delete", ] certificate_permissions = [ "create", "delete", "deleteissuers", "get", "getissuers", "import", "list", "listissuers", "managecontacts", "manageissuers", "setissuers", "update", ] } network_acls { default_action = "Deny" bypass = "AzureServices" } tags = { environment = "Testing" } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A DIAGNOSTIC SETTING # https://www.terraform.io/docs/providers/azurerm/r/monitor_diagnostic_setting.html # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_monitor_diagnostic_setting" "monitor" { name = var.diagnosticSettingName target_resource_id = azurerm_key_vault.monitor.id storage_account_id = azurerm_storage_account.monitor.id log { category = "AuditEvent" enabled = false retention_policy { enabled = false } } metric { category = "AllMetrics" retention_policy { enabled = false } } } ================================================ FILE: examples/azure/terraform-azure-monitor-example/outputs.tf ================================================ output "resource_group_name" { value = azurerm_resource_group.monitor.name } output "diagnostic_setting_name" { value = azurerm_monitor_diagnostic_setting.monitor.name } output "diagnostic_setting_id" { value = azurerm_monitor_diagnostic_setting.monitor.id } output "keyvault_id" { value = azurerm_key_vault.monitor.id } ================================================ FILE: examples/azure/terraform-azure-monitor-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # ARM_CLIENT_ID # ARM_CLIENT_SECRET # ARM_SUBSCRIPTION_ID # ARM_TENANT_ID # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "postfix" { description = "A postfix string to centrally mitigate resource name collisions" type = string default = "resource" } variable "diagnosticSettingName" { description = "The diagnostic setting name" type = string default = "diag-test" } variable "location" { description = "The Azure region in which to deploy your resources to" type = string default = "East US" } ================================================ FILE: examples/azure/terraform-azure-mysqldb-example/README.md ================================================ # Terraform Azure MySQL DB Example This folder contains a Terraform module that deploys resources in [Azure](https://azure.microsoft.com/) to demonstrate how you can use Terratest to write automated tests for your Azure Terraform code. This module deploys a database for MySQL. - A [Azure MySQL Database](https://azure.microsoft.com/services/mysql/). Check out [test/azure/terraform_azure_mysqldb_example_test.go](./../../../test/azure/terraform_azure_mysqldb_example_test.go) to see how you can write automated tests for this module and validate the configuration of the parameters and options. **WARNING**: This module and the automated tests for it deploy real resources into your Azure account which can cost you money. ## Running this module manually 1. Sign up for [Azure](https://azure.microsoft.com/). 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Ensure [environment variables](../README.md#review-environment-variables) are available 1. Run `terraform init` 1. Run `terraform apply` 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Configure your Terratest [Go test environment](../README.md) 1. `cd test/azure` 1. `go build terraform_azure_mysqldb_example_test.go` 1. `go test -v -timeout 60m -tags azure -run TestTerraformAzureMySQLDBExample` ================================================ FILE: examples/azure/terraform-azure-mysqldb-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN AZURE MySQL Database # This is an example of how to deploy an Azure Mysql database. # See test/terraform_azure_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # CONFIGURE OUR AZURE CONNECTION # --------------------------------------------------------------------------------------------------------------------- terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_providers { azurerm = { version = "~>2.29.0" source = "hashicorp/azurerm" } } } provider "azurerm" { features {} } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RESOURCE GROUP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_resource_group" "mysql_rg" { name = "terratest-mysql-${var.postfix}" location = var.location } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AZURE MySQL SERVER # --------------------------------------------------------------------------------------------------------------------- # Random password is used as an example to simplify the deployment and improve the security of the database. # This is not as a production recommendation as the password is stored in the Terraform state file. resource "random_password" "password" { length = 16 override_special = "_%@" min_upper = "1" min_lower = "1" min_numeric = "1" min_special = "1" } resource "azurerm_mysql_server" "mysqlserver" { name = "mysqlserver-${var.postfix}" location = azurerm_resource_group.mysql_rg.location resource_group_name = azurerm_resource_group.mysql_rg.name administrator_login = var.mysqlserver_admin_login administrator_login_password = random_password.password.result sku_name = var.mysqlserver_sku_name storage_mb = var.mysqlserver_storage_mb version = "5.7" auto_grow_enabled = true geo_redundant_backup_enabled = false infrastructure_encryption_enabled = true backup_retention_days = 7 public_network_access_enabled = false ssl_enforcement_enabled = true ssl_minimal_tls_version_enforced = "TLS1_2" } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AZURE MySQL DATABASE # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_mysql_database" "mysqldb" { name = "mysqldb-${var.postfix}" resource_group_name = azurerm_resource_group.mysql_rg.name server_name = azurerm_mysql_server.mysqlserver.name charset = var.mysqldb_charset collation = var.mysqldb_collation } ================================================ FILE: examples/azure/terraform-azure-mysqldb-example/outputs.tf ================================================ output "resource_group_name" { value = azurerm_resource_group.mysql_rg.name } output "mysql_server_name" { value = azurerm_mysql_server.mysqlserver.name } output "sql_server_full_domain_name" { value = azurerm_mysql_server.mysqlserver.fqdn } output "sql_server_admin_login" { value = azurerm_mysql_server.mysqlserver.administrator_login } output "sql_server_admin_login_pass" { value = azurerm_mysql_server.mysqlserver.administrator_login_password sensitive = true } output "mysql_database_name" { value = azurerm_mysql_database.mysqldb.name } ================================================ FILE: examples/azure/terraform-azure-mysqldb-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # ARM_CLIENT_ID # ARM_CLIENT_SECRET # ARM_SUBSCRIPTION_ID # ARM_TENANT_ID # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "location" { description = "The supported azure location where the resource exists" type = string default = "West US2" } variable "mysqlserver_admin_login" { description = "The administrator login name for the mysql server." type = string default = "mysqladmin" } variable "mysqlserver_sku_name" { description = "The SKU name for the mysql server." type = string default = "GP_Gen5_2" } variable "mysqlserver_storage_mb" { description = "The Max storage allowed for mysql server." type = string default = "5120" } variable "mysqldb_charset" { description = "The charset for mysql data base." type = string default = "utf8" } variable "mysqldb_collation" { description = "The collation for mysql data base." type = string default = "utf8_unicode_ci" } variable "postfix" { description = "A postfix string to centrally mitigate resource name collisions." type = string default = "resource" } ================================================ FILE: examples/azure/terraform-azure-network-example/README.md ================================================ # Terraform Azure Network Example This folder contains a simple Terraform module that deploys resources in [Azure](https://azure.microsoft.com/) to demonstrate how you can use Terratest to write automated tests for your Azure Terraform code. This module deploys to a Virtual Network two Network Interface Cards, one with an internal only IP and another with an internal and external Public IP. - A [Virtual Network](https://azure.microsoft.com/en-us/services/virtual-network/) module that includes the following resources: - [Virtual Network](https://docs.microsoft.com/en-us/azure/virtual-network/) with the name specified in the `virtual_network_name` variable. - [Subnet](https://docs.microsoft.com/en-us/rest/api/virtualnetwork/subnets) with the name specified in the `subnet_name` variable. - [Public Address](https://docs.microsoft.com/en-us/azure/virtual-network/public-ip-addresses) with the name specified in the `public_ip_name` variable. - [Internal Network Interface](https://docs.microsoft.com/en-us/azure/virtual-network/virtual-network-network-interface) with the name specified in the `network_interface_internal` variable. - [ExternalNetwork Interface](https://docs.microsoft.com/en-us/azure/virtual-network/virtual-network-network-interface) with the name specified in the `network_interface_external` variable. Check out [test/azure/terraform_azure_network_example_test.go](/test/azure/terraform_azure_network_example_test.go) to see how you can write automated tests for this module. Note that the Azure Virtual Network, Subnet, Network Interface and Public IP resources in this module don't actually do anything; it just runs the resources for demonstration purposes. **WARNING**: This module and the automated tests for it deploy real resources into your Azure account which can cost you money. The resources are all part of the [Azure Free Account](https://azure.microsoft.com/en-us/free/), so if you haven't used that up, it should be free, but you are completely responsible for all Azure charges. ## Running this module manually 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Ensure [environment variables](../README.md#review-environment-variables) are available 1. Run `terraform init` 1. Run `terraform apply` 1. When you're done, run `terraform destroy` ## Running automated tests against this module 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Configure your Terratest [Go test environment](../README.md) 1. `cd test/azure` 1. `go build terraform_azure_network_example_test.go` 1. `go test -v -run TestTerraformAzureNetworkExample` ================================================ FILE: examples/azure/terraform-azure-network-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN AZURE NETWORK # This is an example of how to deploy frequent Azure Networking Resources. Note this network doesn't actually do # anything and is only created for the example to test their commonly needed and integrated properties. # --------------------------------------------------------------------------------------------------------------------- # See test/azure/terraform_azure_network_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- provider "azurerm" { features {} } # --------------------------------------------------------------------------------------------------------------------- # PIN TERRAFORM VERSION TO >= 0.12 # The examples have been upgraded to 0.12 syntax # --------------------------------------------------------------------------------------------------------------------- terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" required_providers { azurerm = { version = "~>2.20" source = "hashicorp/azurerm" } } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RESOURCE GROUP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_resource_group" "net" { name = "terratest-network-rg-${var.postfix}" location = var.location } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY VIRTUAL NETWORK # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_virtual_network" "net" { name = "vnet-${var.postfix}" location = azurerm_resource_group.net.location resource_group_name = azurerm_resource_group.net.name address_space = ["10.0.0.0/16"] dns_servers = [var.dns_ip_01, var.dns_ip_02] } resource "azurerm_subnet" "net" { name = "subnet-${var.postfix}" resource_group_name = azurerm_resource_group.net.name virtual_network_name = azurerm_virtual_network.net.name address_prefixes = [var.subnet_prefix] } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY PRIVATE NETWORK INTERFACE # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_network_interface" "net01" { name = "nic-private-${var.postfix}" location = azurerm_resource_group.net.location resource_group_name = azurerm_resource_group.net.name ip_configuration { name = "terratestconfiguration1" subnet_id = azurerm_subnet.net.id private_ip_address_allocation = "Static" private_ip_address = var.private_ip } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY PUBLIC ADDRESS AND NETWORK INTERFACE # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_public_ip" "net" { name = "pip-${var.postfix}" resource_group_name = azurerm_resource_group.net.name location = azurerm_resource_group.net.location allocation_method = "Static" ip_version = "IPv4" sku = "Basic" idle_timeout_in_minutes = "4" domain_name_label = var.domain_name_label } resource "azurerm_network_interface" "net02" { name = "nic-public-${var.postfix}" location = azurerm_resource_group.net.location resource_group_name = azurerm_resource_group.net.name ip_configuration { name = "terratestconfiguration1" subnet_id = azurerm_subnet.net.id private_ip_address_allocation = "Dynamic" public_ip_address_id = azurerm_public_ip.net.id } } ================================================ FILE: examples/azure/terraform-azure-network-example/outputs.tf ================================================ output "resource_group_name" { value = azurerm_resource_group.net.name } output "virtual_network_name" { value = azurerm_virtual_network.net.name } output "subnet_name" { value = azurerm_subnet.net.name } output "public_address_name" { value = azurerm_public_ip.net.name } output "network_interface_internal" { value = azurerm_network_interface.net01.name } output "network_interface_external" { value = azurerm_network_interface.net02.name } ================================================ FILE: examples/azure/terraform-azure-network-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # ARM_CLIENT_ID # ARM_CLIENT_SECRET # ARM_SUBSCRIPTION_ID # ARM_TENANT_ID # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- variable "domain_name_label" { description = "The Domain Name Label for the Public IP Address" type = string } # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "dns_ip_01" { description = "The first DNS Server IP for the Virtual Network" type = string default = "10.0.0.5" } variable "dns_ip_02" { description = "The second DNS Server IP for the Virtual Network" type = string default = "10.0.0.6" } variable "location" { description = "The Azure Region to deploy resources too" type = string default = "East US" } variable "postfix" { description = "The postfix that will be attached to all resources deployed" type = string default = "resource" } variable "private_ip" { description = "The Static Private IP for the Internal NIC" type = string default = "10.0.20.5" } variable "subnet_prefix" { description = "The subnet range of IPs for the Virtual Network" type = string default = "10.0.20.0/24" } ================================================ FILE: examples/azure/terraform-azure-nsg-example/README.md ================================================ # Terraform Azure NSG Example This folder contains a simple Terraform module that deploys resources in [Azure](https://azure.microsoft.com/) to demonstrate how you can use Terratest to write automated tests for your Azure Terraform code. This module deploys the following: * A [Virtual Machine](https://azure.microsoft.com/en-us/services/virtual-machines/) that gives the module the following: * [Virtual Machine](https://docs.microsoft.com/en-us/azure/virtual-machines/) with the value specified in the `vm_name` variable along with a random value for the `postfix` variable (set from test code). * A [Network Security Group](https://docs.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview) created with a single custom rule to allow SSH (port 22) with the nsg name specified in the `nsg_name` variable along with a random value for the `postfix` variable (set from test code). Check out [test/azure/terraform_azure_nsg_example_test.go](/test/azure/terraform_azure_nsg_example_test.go) to see how you can write automated tests for this module. Note that the resources deployed in this module don't actually do anything; it just runs the resources for demonstration purposes. **WARNING**: This module and the automated tests for it deploy real resources into your Azure account which can cost you money. The resources are all part of the [Azure Free Account](https://azure.microsoft.com/en-us/free/), so if you haven't used that up, it should be free, but you are completely responsible for all Azure charges. ## Running this module manually 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Ensure [environment variables](../README.md#review-environment-variables) are available 1. Run `terraform init` 1. Run `terraform apply` 1. When you're done, run `terraform destroy` ## Running automated tests against this module 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Configure your Terratest [Go test environment](../README.md) 1. `cd test/azure` 1. `go build terraform_azure_nsg_example_test.go` 1. `go test -v -run TestTerraformAzureNsgExample` ================================================ FILE: examples/azure/terraform-azure-nsg-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN AZURE VM ALONG WITH AN EXAMPLE NETWORK SECURITY GROUP (NSG) # This is an example of how to deploy an NSG along with the minimum networking resources # to support a basic virtual machine. # --------------------------------------------------------------------------------------------------------------------- # See test/azure/terraform_azure_nsg_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- provider "azurerm" { features {} } # --------------------------------------------------------------------------------------------------------------------- # PIN TERRAFORM VERSION TO >= 0.12 # The examples have been upgraded to 0.12 syntax # --------------------------------------------------------------------------------------------------------------------- terraform { required_version = ">= 0.12" required_providers { azurerm = { version = "~> 2.50" source = "hashicorp/azurerm" } } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RESOURCE GROUP # See test/terraform_azure_nsg_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_resource_group" "nsg_rg" { name = "${var.resource_group_name}-${var.postfix}" location = var.location } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY VIRTUAL NETWORK RESOURCES # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_virtual_network" "vnet" { name = "${var.vnet_name}-${var.postfix}" address_space = ["10.0.0.0/16"] location = azurerm_resource_group.nsg_rg.location resource_group_name = azurerm_resource_group.nsg_rg.name } resource "azurerm_subnet" "internal" { name = "${var.subnet_name}-${var.postfix}" resource_group_name = azurerm_resource_group.nsg_rg.name virtual_network_name = azurerm_virtual_network.vnet.name address_prefixes = ["10.0.17.0/24"] } resource "azurerm_network_interface" "main" { name = "${var.vm_nic_name}-${var.postfix}" location = azurerm_resource_group.nsg_rg.location resource_group_name = azurerm_resource_group.nsg_rg.name ip_configuration { name = "${var.vm_nic_ip_config_name}-${var.postfix}" subnet_id = azurerm_subnet.internal.id private_ip_address_allocation = "Dynamic" } } resource "azurerm_network_security_group" "nsg_example" { name = "${var.nsg_name}-${var.postfix}" location = azurerm_resource_group.nsg_rg.location resource_group_name = azurerm_resource_group.nsg_rg.name } resource "azurerm_network_interface_security_group_association" "main" { network_interface_id = azurerm_network_interface.main.id network_security_group_id = azurerm_network_security_group.nsg_example.id } resource "azurerm_network_security_rule" "allow_ssh" { name = "${var.nsg_ssh_rule_name}-${var.postfix}" description = "${var.nsg_ssh_rule_name}-${var.postfix}" priority = 100 direction = "Inbound" access = "Allow" protocol = "Tcp" source_port_range = "*" destination_port_range = 22 source_address_prefix = "*" destination_address_prefix = "*" resource_group_name = azurerm_resource_group.nsg_rg.name network_security_group_name = azurerm_network_security_group.nsg_example.name } resource "azurerm_network_security_rule" "block_http" { name = "${var.nsg_http_rule_name}-${var.postfix}" description = "${var.nsg_http_rule_name}-${var.postfix}" priority = 200 direction = "Inbound" access = "Deny" protocol = "Tcp" source_port_range = "*" destination_port_range = 80 source_address_prefix = "*" destination_address_prefix = "*" resource_group_name = azurerm_resource_group.nsg_rg.name network_security_group_name = azurerm_network_security_group.nsg_example.name } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A VIRTUAL MACHINE RUNNING UBUNTU # This VM does not actually do anything and is the smallest size VM available with an Ubuntu image # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_virtual_machine" "vm_example" { name = "${var.vm_name}-${var.postfix}" location = azurerm_resource_group.nsg_rg.location resource_group_name = azurerm_resource_group.nsg_rg.name network_interface_ids = [azurerm_network_interface.main.id] vm_size = var.vm_size delete_os_disk_on_termination = true delete_data_disks_on_termination = true storage_image_reference { publisher = "Canonical" offer = "UbuntuServer" sku = "16.04-LTS" version = "latest" } storage_os_disk { name = "${var.os_disk_name}-${var.postfix}" caching = "ReadWrite" create_option = "FromImage" managed_disk_type = "Standard_LRS" } os_profile { computer_name = var.hostname admin_username = var.username admin_password = random_password.nsg.result } os_profile_linux_config { disable_password_authentication = false } # Correctly setup the dependencies to make sure resources are correctly destroyed. depends_on = [ azurerm_network_interface_security_group_association.main ] } resource "random_password" "nsg" { length = 16 override_special = "-_%@" min_upper = "1" min_lower = "1" min_numeric = "1" min_special = "1" } ================================================ FILE: examples/azure/terraform-azure-nsg-example/outputs.tf ================================================ output "vm_name" { value = azurerm_virtual_machine.vm_example.name } output "resource_group_name" { value = azurerm_resource_group.nsg_rg.name } output "nsg_name" { value = azurerm_network_security_group.nsg_example.name } output "ssh_rule_name" { value = azurerm_network_security_rule.allow_ssh.name } output "http_rule_name" { value = azurerm_network_security_rule.block_http.name } ================================================ FILE: examples/azure/terraform-azure-nsg-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # ARM_CLIENT_ID # ARM_CLIENT_SECRET # ARM_SUBSCRIPTION_ID # ARM_TENANT_ID # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "postfix" { description = "Random postfix string used for each test run; set from the test file at runtime." type = string default = "qwefgt" } variable "resource_group_name" { description = "Name for the resource group holding resources for this example" type = string default = "terratest-nsg-rg" } variable "location" { description = "The Azure region in which to deploy this sample" type = string default = "East US" } variable "vnet_name" { description = "Name for the example virtual network" type = string default = "vnet01" } variable "subnet_name" { description = "Name for the example virtual network default subnet" type = string default = "subnet01" } variable "vm_nic_name" { description = "Name for the NIC attached to our example VM." type = string default = "nic01" } variable "vm_nic_ip_config_name" { description = "Name for the NIC IP configuration attached to our example VM." type = string default = "nic_ipconfig01" } variable "nsg_name" { description = "Name for the example NSG." type = string default = "nsg01" } variable "nsg_ssh_rule_name" { description = "Name for the example SSH NSG rule used in this example." type = string default = "nsgrule01" } variable "nsg_http_rule_name" { description = "Name for the example HTTP NSG rule used in this example." type = string default = "nsgrule02" } variable "vm_name" { description = "The name of the VM used in this example" type = string default = "vm01" } variable "vm_size" { description = "The size of the VM to deploy" type = string default = "Standard_B1s" } variable "hostname" { description = "The hostname of the new VM to be configured" type = string default = "vm01" } variable "os_disk_name" { description = "The of the OS disk to use on our example VM." type = string default = "osdisk01" } variable "username" { description = "The username to be provisioned into your VM" type = string default = "testadmin" } variable "password" { description = "The password to configure for SSH access" type = string default = "!@#PasswordSetInCode!@#" } ================================================ FILE: examples/azure/terraform-azure-postgresql-example/README.md ================================================ # Terraform Azure PostgreSQL DB Example This folder contains a Terraform module that deploys resources in [Azure](https://azure.microsoft.com/) to demonstrate how you can use Terratest to write automated tests for your Azure Terraform code. This module deploys a database for PostgreSQL. - A [Azure PostgreSQL Database](https://azure.microsoft.com/services/postgresql/). Check out [test/azure/terraform_azure_postgresqldb_example_test.go](./../../../test/azure/terraform_azure_postgresqldb_example_test.go) to see how you can write automated tests for this module and validate the configuration of the parameters and options. **WARNING**: This module and the automated tests for it deploy real resources into your Azure account which can cost you money. ## Running this module manually 1. Sign up for [Azure](https://azure.microsoft.com/). 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Ensure [environment variables](../README.md#review-environment-variables) are available 1. Run `terraform init` 1. Run `terraform apply` 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Configure your Terratest [Go test environment](../README.md) 1. `cd test/azure` 1. `go build terraform_azure_postgresql_example_test.go` 1. `go test -v -timeout 60m -tags azure -run TestPostgreSQLDatabase` ================================================ FILE: examples/azure/terraform-azure-postgresql-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN PostgreSQL Database # This is an example of how to deploy an Azure PostgreSQL database. # See test/terraform_azure_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # CONFIGURE OUR AZURE CONNECTION # --------------------------------------------------------------------------------------------------------------------- provider "azurerm" { features {} } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RESOURCE GROUP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_resource_group" "rg" { name = "${var.resource_group_name}-${var.postfix}" location = var.location } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AZURE PostgreSQL SERVER # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_postgresql_server" "postgresqlserver" { name = "postgresqlserver-${var.postfix}" location = azurerm_resource_group.rg.location resource_group_name = azurerm_resource_group.rg.name sku_name = "B_Gen5_2" storage_mb = 5120 backup_retention_days = 7 geo_redundant_backup_enabled = false auto_grow_enabled = true administrator_login = "pgsqladmin" administrator_login_password = random_password.password.result version = "11" ssl_enforcement_enabled = true } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AZURE PostgreSQL Database # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_postgresql_database" "postgresqldb" { name = "postgresqldb" resource_group_name = azurerm_resource_group.rg.name server_name = azurerm_postgresql_server.postgresqlserver.name charset = "UTF8" collation = "English_United States.1252" } # --------------------------------------------------------------------------------------------------------------------- # Use a random password geneerator # --------------------------------------------------------------------------------------------------------------------- resource "random_password" "password" { length = 20 special = true upper = true lower = true number = true } ================================================ FILE: examples/azure/terraform-azure-postgresql-example/output.tf ================================================ output "sku_name" { value = azurerm_postgresql_server.postgresqlserver.sku_name } output "servername" { value = azurerm_postgresql_server.postgresqlserver.name } output "rgname" { value = azurerm_resource_group.rg.name } ================================================ FILE: examples/azure/terraform-azure-postgresql-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # ARM_CLIENT_ID # ARM_CLIENT_SECRET # ARM_SUBSCRIPTION_ID # ARM_TENANT_ID # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "resource_group_name" { description = "Name for the resource group holding resources for this example" type = string default = "terratest-postgres-rg" } variable "location" { description = "The Azure region in which to deploy this sample" type = string default = "East US" } variable "postfix" { description = "A postfix string to centrally mitigate resource name collisions." type = string default = "resource" } ================================================ FILE: examples/azure/terraform-azure-recoveryservices-example/README.md ================================================ # Terraform Azure Recovery Services Example This folder contains a simple Terraform module that deploys resources in [Azure](https://azure.microsoft.com/) to demonstrate how you can use Terratest to write automated tests for your Azure Terraform code. This module deploys a Recovery Services Vault with one backup virtual machine policy. - A [Recovery Services](https://azure.microsoft.com/services/backup/) that gives the module the following: - [Backup Vault](https://docs.microsoft.com/azure/backup/backup-azure-recovery-services-vault-overview) with the value specified in the `recovery_service_vault_name` output variable. - [Backup VM Policy](https://azure.microsoft.com/en-in/updates/azure-vm-backup-policy-management/) with the value specified in the `backup_policy_vm_name` output variable. Check out [test/azure/terraform_azure_recoveryservices_example_test.go](/test/azure/terraform_azure_recoveryservices_example_test.go) to see how you can write automated tests for this module. Note that the Recovery Services Vault and backup virtual machine policy in this module don't actually do anything; it just runs the resources for demonstration purposes. **WARNING**: This module and the automated tests for it deploy real resources into your Azure account which can cost you money. The resources are all part of the [Azure Free Account](https://azure.microsoft.com/free/), so if you haven't used that up, it should be free, but you are completely responsible for all Azure charges. ## Running this module manually 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Ensure [environment variables](../README.md#review-environment-variables) are available 1. Run `terraform init` 1. Run `terraform apply` 1. When you're done, run `terraform destroy` ## Running automated tests against this module 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Configure your Terratest [Go test environment](../README.md) 1. `cd test/azure` 1. `go build terraform_azure_recoveryservices_example_test.go` 1. `go test -v -run TestTerraformAzureRecoveryServicesExample` ================================================ FILE: examples/azure/terraform-azure-recoveryservices-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN AZURE AVAILABILITY SET # This is an example of how to deploy an Azure Availability Set with a Virtual Machine in the availability set # and the minimum network resources for the VM. # --------------------------------------------------------------------------------------------------------------------- # See test/azure/terraform_azure_availabilityset_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- provider "azurerm" { features {} } terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" required_providers { azurerm = { version = "~> 2.20" source = "hashicorp/azurerm" } } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RESOURCE GROUP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_resource_group" "resource_group" { name = "terratest-ars-rg-${var.postfix}" location = var.location } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RECOVERY SERVICES VAULT # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_recovery_services_vault" "vault" { name = "rsvault${var.postfix}" location = azurerm_resource_group.resource_group.location resource_group_name = azurerm_resource_group.resource_group.name sku = "Standard" } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A BACKUP POLICY # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_backup_policy_vm" "vm_policy" { name = "vmpolicy-${var.postfix}" resource_group_name = azurerm_resource_group.resource_group.name recovery_vault_name = azurerm_recovery_services_vault.vault.name timezone = "UTC" backup { frequency = "Daily" time = "23:00" } retention_daily { count = 10 } retention_weekly { count = 42 weekdays = ["Sunday", "Wednesday", "Friday", "Saturday"] } retention_monthly { count = 7 weekdays = ["Sunday", "Wednesday"] weeks = ["First", "Last"] } retention_yearly { count = 77 weekdays = ["Sunday"] weeks = ["Last"] months = ["January"] } } ================================================ FILE: examples/azure/terraform-azure-recoveryservices-example/outputs.tf ================================================ output "resource_group_name" { value = azurerm_resource_group.resource_group.name } output "recovery_service_vault_name" { value = azurerm_recovery_services_vault.vault.name } output "backup_policy_vm_name" { value = azurerm_backup_policy_vm.vm_policy.name } ================================================ FILE: examples/azure/terraform-azure-recoveryservices-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # ARM_CLIENT_ID # ARM_CLIENT_SECRET # ARM_SUBSCRIPTION_ID # ARM_TENANT_ID # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "location" { description = "The location to set for the storage account." type = string default = "East US" } variable "postfix" { description = "A postfix string to centrally mitigate resource name collisions" type = string default = "resource" } ================================================ FILE: examples/azure/terraform-azure-resourcegroup-example/README.md ================================================ # Terraform Azure Resource Group Example This folder contains a simple Terraform module that deploys resources in [Azure](https://azure.microsoft.com/) to demonstrate how you can use TerraTest to write automated tests for your Azure Terraform code. This module deploys a Resource Group. - A [Resource Group](https://docs.microsoft.com/azure/azure-resource-manager/management/overview) with no other resources. Check out [test/azure/terraform_azure_resourcegroup_example_test.go](/test/azure/terraform_azure_resourcegroup_example_test.go) to see how you can write automated tests for this module. Note that the Resource Group this module creates does not actually do anything; it just runs the resources for demonstration purposes. **WARNING**: This module and the automated tests for it deploy real resources into your Azure account which can cost you money. The resources are all part of the [Azure Free Account](https://azure.microsoft.com/free/), so if you haven't used that up, it should be free, but you are completely responsible for all Azure charges. ## Running this module manually 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Ensure [environment variables](../README.md#review-environment-variables) are available 1. Run `terraform init` 1. Run `terraform apply` 1. When you're done, run `terraform destroy` ## Running automated tests against this module 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Configure your TerraTest [Go test environment](../README.md) 1. `cd test/azure` 1. `go build terraform_azure_resourcegroup_example_test.go` 1. `go test -v -run TestTerraformAzureResourceGroupExample` ================================================ FILE: examples/azure/terraform-azure-resourcegroup-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RESOURCE GROUP # This is an example of how to deploy a Resource Group # --------------------------------------------------------------------------------------------------------------------- # See test/azure/terraform_azure_resourcegroup_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- provider "azurerm" { features {} } # PIN TERRAFORM VERSION terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" required_providers { azurerm = { version = "~> 2.20" source = "hashicorp/azurerm" } } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RESOURCE GROUP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_resource_group" "resource_group" { name = "terratest-rg-${var.postfix}" location = var.location } ================================================ FILE: examples/azure/terraform-azure-resourcegroup-example/outputs.tf ================================================ output "resource_group_name" { value = azurerm_resource_group.resource_group.name } ================================================ FILE: examples/azure/terraform-azure-resourcegroup-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # ARM_CLIENT_ID # ARM_CLIENT_SECRET # ARM_SUBSCRIPTION_ID # ARM_TENANT_ID # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "location" { description = "The location to set for the resource group." type = string default = "East US" } variable "postfix" { description = "A postfix string to centrally mitigate resource name collisions." type = string default = "resource" } ================================================ FILE: examples/azure/terraform-azure-servicebus-example/README.md ================================================ # Terraform Azure Service Bus Example This folder contains a Terraform module that deploys resources in [Azure](https://azure.microsoft.com/) to demonstrate how you can use Terratest to write automated tests for your Azure Terraform code. This module deploys a Service Bus. - A [Service Bus](https://docs.microsoft.com/en-us/azure/service-bus-messaging/service-bus-messaging-overview) with the namespace specified in the `namespace_name` variable. Check out [test/azure/terraform_azure_servicebus_example_test.go](./../../../test/azure/terraform_azure_servicebus_example_test.go) to see how you can write automated tests for this module and validate the configuration of the parameters and options. **WARNING**: This module and the automated tests for it deploy real resources into your Azure account which can cost you money. The resources are all part of the [Azure Free Account](https://azure.microsoft.com/en-us/free/), so if you haven't used that up, it should be free, but you are completely responsible for all Azure charges. ## Running this module manually 1. Sign up for [Azure](https://azure.microsoft.com/). 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Ensure [environment variables](../README.md#review-environment-variables) are available 1. Run `terraform init` 1. Run `terraform apply` 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Configure your Terratest [Go test environment](../README.md) 1. `cd test/azure` 1. `go build terraform_azure_servicebus_example_test.go` 1. `go test -v -run TestTerraformAzureServiceBusExample` ================================================ FILE: examples/azure/terraform-azure-servicebus-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN AZURE SERVICE BUS # This is an example of how to deploy an Azure service bus. # See test/terraform_azure_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # CONFIGURE OUR AZURE CONNECTION # --------------------------------------------------------------------------------------------------------------------- provider "azurerm" { features {} } terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_providers { azurerm = { version = "~>2.29" source = "hashicorp/azurerm" } } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RESOURCE GROUP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_resource_group" "servicebus_rg" { name = "terratest-sb-rg-${var.postfix}" location = var.location } # --------------------------------------------------------------------------------------------------------------------- # Define locals variables # --------------------------------------------------------------------------------------------------------------------- locals { topic_authorization_rules = flatten([ for topic in var.topics : [ for rule in topic.authorization_rules : merge( rule, { topic_name = topic.name }) ] ]) topic_subscriptions = flatten([ for topic in var.topics : [ for subscription in topic.subscriptions : merge( subscription, { topic_name = topic.name }) ] ]) topic_subscription_rules = flatten([ for subscription in local.topic_subscriptions : merge({ filter_type = "" sql_filter = "" action = "" }, subscription, { topic_name = subscription.topic_name subscription_name = subscription.name }) if subscription.filter_type != null ]) } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AZURE Service Bus Namespace # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_servicebus_namespace" "servicebus" { name = "terratest-namespace-${var.namespace_name}" location = azurerm_resource_group.servicebus_rg.location resource_group_name = azurerm_resource_group.servicebus_rg.name sku = var.sku tags = var.tags } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AZURE Service Bus Namespace Authorization Rule # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_servicebus_namespace_authorization_rule" "sbnamespaceauth" { count = length(var.namespace_authorization_rules) name = var.namespace_authorization_rules[count.index].policy_name namespace_name = azurerm_servicebus_namespace.servicebus.name resource_group_name = azurerm_resource_group.servicebus_rg.name listen = var.namespace_authorization_rules[count.index].claims.listen send = var.namespace_authorization_rules[count.index].claims.send manage = var.namespace_authorization_rules[count.index].claims.manage } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AZURE Service Bus Topic # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_servicebus_topic" "sptopic" { count = length(var.topics) name = var.topics[count.index].name resource_group_name = azurerm_resource_group.servicebus_rg.name namespace_name = azurerm_servicebus_namespace.servicebus.name requires_duplicate_detection = var.topics[count.index].requires_duplicate_detection default_message_ttl = var.topics[count.index].default_message_ttl enable_partitioning = var.topics[count.index].enable_partitioning support_ordering = var.topics[count.index].support_ordering } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AZURE Service Bus Topic Authorization Rule # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_servicebus_topic_authorization_rule" "topicaauth" { count = length(local.topic_authorization_rules) name = local.topic_authorization_rules[count.index].policy_name resource_group_name = azurerm_resource_group.servicebus_rg.name namespace_name = azurerm_servicebus_namespace.servicebus.name topic_name = local.topic_authorization_rules[count.index].topic_name listen = local.topic_authorization_rules[count.index].claims.listen send = local.topic_authorization_rules[count.index].claims.send manage = local.topic_authorization_rules[count.index].claims.manage depends_on = [azurerm_servicebus_topic.sptopic] } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AZURE Service Bus Subscription # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_servicebus_subscription" "subscription" { count = length(local.topic_subscriptions) name = local.topic_subscriptions[count.index].name resource_group_name = azurerm_resource_group.servicebus_rg.name namespace_name = azurerm_servicebus_namespace.servicebus.name topic_name = local.topic_subscriptions[count.index].topic_name max_delivery_count = local.topic_subscriptions[count.index].max_delivery_count lock_duration = local.topic_subscriptions[count.index].lock_duration forward_to = local.topic_subscriptions[count.index].forward_to dead_lettering_on_message_expiration = local.topic_subscriptions[count.index].dead_lettering_on_message_expiration depends_on = [azurerm_servicebus_topic.sptopic] } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AZURE Service Bus Subscription Rules # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_servicebus_subscription_rule" "subrules" { count = length(local.topic_subscription_rules) name = local.topic_subscription_rules[count.index].name resource_group_name = azurerm_resource_group.servicebus_rg.name namespace_name = azurerm_servicebus_namespace.servicebus.name topic_name = local.topic_subscription_rules[count.index].topic_name subscription_name = local.topic_subscription_rules[count.index].subscription_name filter_type = local.topic_subscription_rules[count.index].filter_type != "" ? "SqlFilter" : null sql_filter = local.topic_subscription_rules[count.index].sql_filter action = local.topic_subscription_rules[count.index].action depends_on = [azurerm_servicebus_subscription.subscription] } ================================================ FILE: examples/azure/terraform-azure-servicebus-example/outputs.tf ================================================ output "resource_group" { description = "The resource group name of the Service Bus namespace." value = azurerm_resource_group.servicebus_rg.name } output "namespace_name" { description = "The namespace name." value = azurerm_servicebus_namespace.servicebus.name } output "namespace_id" { description = "The namespace ID." value = azurerm_servicebus_namespace.servicebus.id sensitive = true } output "namespace_authorization_rules" { description = "List of namespace authorization rules." value = { for auth in azurerm_servicebus_namespace_authorization_rule.sbnamespaceauth : auth.name => { listen = auth.listen send = auth.send manage = auth.manage } } sensitive = true } output "service_bus_namespace_default_primary_key" { description = "The primary access key for the authorization rule RootManageSharedAccessKey which is created automatically by Azure." value = azurerm_servicebus_namespace.servicebus.default_primary_key sensitive = true } output "service_bus_namespace_default_connection_string" { description = "The primary connection string for the authorization rule RootManageSharedAccessKey which is created automatically by Azure." value = azurerm_servicebus_namespace.servicebus.default_primary_connection_string sensitive = true } output "topics" { description = "All topics with the corresponding subscriptions" value = { for topic in azurerm_servicebus_topic.sptopic : topic.name => { id = topic.id name = topic.name authorization_rules = { for auth in azurerm_servicebus_topic_authorization_rule.topicaauth : auth.name => { listen = auth.listen send = auth.send manage = auth.manage } if topic.name == auth.topic_name } subscriptions = { for subscription in azurerm_servicebus_subscription.subscription : subscription.name => { name = subscription.name } if topic.name == subscription.topic_name } } } } ================================================ FILE: examples/azure/terraform-azure-servicebus-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # ARM_CLIENT_ID # ARM_CLIENT_SECRET # ARM_SUBSCRIPTION_ID # ARM_TENANT_ID # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "location" { description = "The supported azure location where the resource exists" type = string default = "West US2" } variable "postfix" { description = "string mitigate resource name collisions." type = string default = "servicebus" } variable "namespace_name" { description = "The name of the namespace." type = string default = "testservicebus101" } variable "sku" { description = "The SKU of the namespace. The options are: `Basic`, `Standard`, `Premium`." type = string default = "Standard" } variable "tags" { description = " A mapping of tags to assign to the resource." type = map(string) default = {} } variable "namespace_authorization_rules" { description = "List of namespace authorization rules." type = list(object({ policy_name = string claims = object({ listen = bool, manage = bool, send = bool }) })) default = [] } variable "topics" { description = "topics list" type = list(object({ name = string default_message_ttl = string //ISO 8601 format enable_partitioning = bool requires_duplicate_detection = bool support_ordering = bool authorization_rules = list(object({ policy_name = string claims = object({ listen = bool, manage = bool, send = bool }) })) subscriptions = list(object({ name = string max_delivery_count = number lock_duration = string //ISO 8601 format forward_to = string //set with the topic name that will be used for forwarding. Otherwise, set to "" dead_lettering_on_message_expiration = bool filter_type = string // SqlFilter is the only supported type now. sql_filter = string //Required when filter_type is set to SqlFilter action = string })) })) default = [] } ================================================ FILE: examples/azure/terraform-azure-sqldb-example/README.md ================================================ # Terraform Azure SQL DB Example This folder contains a Terraform module that deploys resources in [Azure](https://azure.microsoft.com/) to demonstrate how you can use Terratest to write automated tests for your Azure Terraform code. This module deploys a SQL server, and a SQL database. - A [SQL server](https://azure.microsoft.com/services/sql-database/campaign/) with the name specified in the `sql_server_name` variable. - A [SQL Database](https://azure.microsoft.com/services/sql-database/) with the name specified in the `sql_database_name` variable. Check out [test/azure/terraform_azure_sqldb_example_test.go](./../../../test/azure/terraform_azure_sqldb_example_test.go) to see how you can write automated tests for this module and validate the configuration of the parameters and options. **WARNING**: This module and the automated tests for it deploy real resources into your Azure account which can cost you money. The resources are all part of the [Azure Free Account](https://azure.microsoft.com/en-us/free/), so if you haven't used that up, it should be free, but you are completely responsible for all Azure charges. ## Running this module manually 1. Sign up for [Azure](https://azure.microsoft.com/). 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Ensure [environment variables](../README.md#review-environment-variables) are available 1. Run `terraform init` 1. Run `terraform apply` 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Configure your Terratest [Go test environment](../README.md) 1. `cd test/azure` 1. `go build terraform_azure_sqldb_example_test.go` 1. `go test -v -run TestTerraformAzureSQLDBExample` ================================================ FILE: examples/azure/terraform-azure-sqldb-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN AZURE SQL Database # This is an example of how to deploy an Azure sql database. # See test/terraform_azure_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # CONFIGURE OUR AZURE CONNECTION # --------------------------------------------------------------------------------------------------------------------- provider "azurerm" { features {} } terraform { required_providers { azurerm = { version = "~>2.29" source = "hashicorp/azurerm" } } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RESOURCE GROUP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_resource_group" "sql_rg" { name = "terratest-rg-${var.postfix}" location = var.location } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AZURE SQL SERVER # --------------------------------------------------------------------------------------------------------------------- resource "random_password" "password" { length = 16 override_special = "_%@" min_upper = "1" min_lower = "1" min_numeric = "1" min_special = "1" } resource "azurerm_sql_server" "sqlserver" { name = "mssqlserver-${var.postfix}" resource_group_name = azurerm_resource_group.sql_rg.name location = azurerm_resource_group.sql_rg.location version = "12.0" administrator_login = var.sqlserver_admin_login administrator_login_password = random_password.password.result tags = { environment = var.tags } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AZURE SQL DATA BASE # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_sql_database" "sqldb" { name = "sqldb-${var.postfix}" resource_group_name = azurerm_resource_group.sql_rg.name location = azurerm_resource_group.sql_rg.location server_name = azurerm_sql_server.sqlserver.name tags = { environment = var.tags } } ================================================ FILE: examples/azure/terraform-azure-sqldb-example/outputs.tf ================================================ output "resource_group_name" { value = azurerm_resource_group.sql_rg.name } output "sql_database_id" { value = azurerm_sql_database.sqldb.id } output "sql_database_name" { value = azurerm_sql_database.sqldb.name } output "sql_server_id" { value = azurerm_sql_server.sqlserver.id } output "sql_server_name" { value = azurerm_sql_server.sqlserver.name } output "sql_server_full_domain_name" { value = azurerm_sql_server.sqlserver.fully_qualified_domain_name } output "sql_server_admin_login" { value = azurerm_sql_server.sqlserver.administrator_login } output "sql_server_admin_login_pass" { value = azurerm_sql_server.sqlserver.administrator_login_password sensitive = true } ================================================ FILE: examples/azure/terraform-azure-sqldb-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # ARM_CLIENT_ID # ARM_CLIENT_SECRET # ARM_SUBSCRIPTION_ID # ARM_TENANT_ID # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "location" { description = "The supported azure location where the resource exists" type = string default = "West US2" } variable "sqlserver_admin_login" { description = "The administrator login name for the sql server." type = string default = "AdminUser2314" } variable "tags" { description = "A mapping of tags to assign to the resource." type = string default = "Development" } variable "postfix" { description = "A postfix string to centrally mitigate resource name collisions" type = string default = "resource" } ================================================ FILE: examples/azure/terraform-azure-sqlmanagedinstance-example/README.md ================================================ # Terraform Azure SQL DB Example This folder contains a Terraform module that deploys resources in [Azure](https://azure.microsoft.com/) to demonstrate how you can use Terratest to write automated tests for your Azure Terraform code. This module deploys a SQL Managed Instance, and a SQL Managed Instance database. - A [SQL Managed Instance](https://azure.microsoft.com/en-us/products/azure-sql/managed-instance/). - A SQL Managed Database. Check out [test/azure/terraform_azure_sqlmanagedinstance_example_test.go](./../../../test/azure/terraform_azure_sqlmanagedinstance_example_test.go) to see how you can write automated tests for this module and validate the configuration of the parameters and options. **WARNING**: This module and the automated tests for it deploy real resources into your Azure account which can cost you money. The resources are all part of the [Azure Free Account](https://azure.microsoft.com/en-us/free/), so if you haven't used that up, it should be free, but you are completely responsible for all Azure charges. ## Running this module manually 1. Sign up for [Azure](https://azure.microsoft.com/). 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Ensure [environment variables](../README.md#review-environment-variables) are available 1. Run `terraform init` 1. Run `terraform apply` 1. When you're done, run `terraform destroy`. ## Running automated tests against this module **WARNING**: The deploymnet for this module usually takes more than 4-6 hours as stated in the [microsoft docs](https://learn.microsoft.com/en-us/azure/azure-sql/managed-instance/management-operations-overview?view=azuresql#duration), so please make sure to set the timeout accordingly in the below go test command. 1. Sign up for [Azure](https://azure.microsoft.com/) 2. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 3. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 4. Configure your Terratest [Go test environment](../README.md) 5. `cd test/azure` 6. `go build terraform_azure_sqlmanagedinstance_example_test.go` 7. `go test -v -run TestTerraformAzureSQLManagedInstanceExample -timeout ` ================================================ FILE: examples/azure/terraform-azure-sqlmanagedinstance-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN AZURE SQL Managed Instance # This is an example of how to deploy an AZURE SQL Managed Instance # See test/terraform_azure_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # CONFIGURE OUR AZURE CONNECTION # --------------------------------------------------------------------------------------------------------------------- provider "azurerm" { version = "~>3.13.0" features {} } # --------------------------------------------------------------------------------------------------------------------- # CREATE RANDOM PASSWORD # --------------------------------------------------------------------------------------------------------------------- # Random password is used as an example to simplify the deployment and improve the security of the database. # This is not as a production recommendation as the password is stored in the Terraform state file. resource "random_password" "password" { length = 16 override_special = "-_%@" min_upper = "1" min_lower = "1" min_numeric = "1" min_special = "1" } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RESOURCE GROUP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_resource_group" "sqlmi_rg" { name = "terratest-sqlmi-${var.postfix}" location = var.location } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY NETWORK RESOURCES # This network includes a public address for integration test demonstration purposes # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_network_security_group" "sqlmi_nt_sec_grp" { name = "securitygroup-${var.postfix}" location = azurerm_resource_group.sqlmi_rg.location resource_group_name = azurerm_resource_group.sqlmi_rg.name } resource "azurerm_network_security_rule" "allow_misubnet_inbound" { name = "allow_subnet_${var.postfix}" priority = 200 direction = "Inbound" access = "Allow" protocol = "*" source_port_range = "*" destination_port_range = "*" source_address_prefix = "10.0.0.0/24" destination_address_prefix = "*" resource_group_name = azurerm_resource_group.sqlmi_rg.name network_security_group_name = azurerm_network_security_group.sqlmi_nt_sec_grp.name } resource "azurerm_virtual_network" "sqlmi_vm" { name = "vnet-${var.postfix}" resource_group_name = azurerm_resource_group.sqlmi_rg.name address_space = ["10.0.0.0/16"] location = azurerm_resource_group.sqlmi_rg.location } resource "azurerm_subnet" "sqlmi_sub" { name = "subnet-${var.postfix}" resource_group_name = azurerm_resource_group.sqlmi_rg.name virtual_network_name = azurerm_virtual_network.sqlmi_vm.name address_prefixes = ["10.0.0.0/24"] delegation { name = "managedinstancedelegation" service_delegation { name = "Microsoft.Sql/managedInstances" actions = ["Microsoft.Network/virtualNetworks/subnets/join/action", "Microsoft.Network/virtualNetworks/subnets/prepareNetworkPolicies/action", "Microsoft.Network/virtualNetworks/subnets/unprepareNetworkPolicies/action"] } } } resource "azurerm_subnet_network_security_group_association" "sqlmi_sb_assoc" { subnet_id = azurerm_subnet.sqlmi_sub.id network_security_group_id = azurerm_network_security_group.sqlmi_nt_sec_grp.id } resource "azurerm_route_table" "sqlmi_rt" { name = "routetable-${var.postfix}" location = azurerm_resource_group.sqlmi_rg.location resource_group_name = azurerm_resource_group.sqlmi_rg.name disable_bgp_route_propagation = false depends_on = [ azurerm_subnet.sqlmi_sub, ] } resource "azurerm_subnet_route_table_association" "sqlmi_sb_rt_assoc" { subnet_id = azurerm_subnet.sqlmi_sub.id route_table_id = azurerm_route_table.sqlmi_rt.id } # DEPLOY managed sql instance ## This depends on vnet ## resource "azurerm_mssql_managed_instance" "sqlmi_mi" { name = "sqlmi${var.postfix}" resource_group_name = azurerm_resource_group.sqlmi_rg.name location = azurerm_resource_group.sqlmi_rg.location license_type = var.sqlmi_license_type sku_name = var.sku_name storage_size_in_gb = var.storage_size subnet_id = azurerm_subnet.sqlmi_sub.id vcores = var.cores administrator_login = var.admin_login administrator_login_password = "thisIsDog11" } resource "azurerm_mssql_managed_database" "sqlmi_db" { name = var.sqlmi_db_name managed_instance_id = azurerm_mssql_managed_instance.sqlmi_mi.id } ================================================ FILE: examples/azure/terraform-azure-sqlmanagedinstance-example/outputs.tf ================================================ output "resource_group_name" { value = azurerm_resource_group.sqlmi_rg.name } output "network_security_group_name" { value = azurerm_network_security_group.sqlmi_nt_sec_grp.name } output "virtual_network_name" { value = azurerm_virtual_network.sqlmi_vm.name } output "subnet_name" { value = azurerm_subnet.sqlmi_sub.name } output "managed_instance_name" { value = azurerm_mssql_managed_instance.sqlmi_mi.name } output "managed_instance_db_name" { value = azurerm_mssql_managed_database.sqlmi_db.name } ================================================ FILE: examples/azure/terraform-azure-sqlmanagedinstance-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # ARM_CLIENT_ID # ARM_CLIENT_SECRET # ARM_SUBSCRIPTION_ID # ARM_TENANT_ID # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "location" { description = "The supported azure location where the resource exists" type = string default = "West US2" } variable "sqlmi_license_type" { description = "The license type for the sql managed instance" type = string default = "BasePrice" } variable "sku_name" { description = "The sku name for the sql managed instance" type = string default = "GP_Gen5" } variable "storage_size" { description = "The storage for the sql managed instance" type = string default = 32 } variable "cores" { description = "The vcores for the sql managed instance" type = string default = 4 } variable "admin_login" { description = "The login for the sql managed instance" type = string default = "sqlmiadmin" } variable "sqlmi_db_name" { description = "The Database for the sql managed instance" type = string default = "testdb" } variable "postfix" { description = "A postfix string to centrally mitigate resource name collisions." type = string default = "resource" } ================================================ FILE: examples/azure/terraform-azure-storage-example/README.md ================================================ # Terraform Azure Storage Example This folder contains a simple Terraform module that deploys resources in [Azure](https://azure.microsoft.com/) to demonstrate how you can use TerraTest to write automated tests for your Azure Terraform code. This module deploys a Storage Account. - An [Azure Storage Account](https://azure.microsoft.com/services/storage/) that gives the module the following: - [Stock Account Name](https://azure.microsoft.com/services/storage/) with the value specified in the `storage_account_name` output variable. - [Storage Account Tier](https://azure.microsoft.com/services/storage/) with the value specified in the `"storage_account_account_tier` output variable. - [Storage Account Kind](https://azure.microsoft.com/services/storage/) with the value specified in the `"storage_account_account_kind` output variable. - [Storage Container](https://azure.microsoft.com/services/storage/) with the value specified in the `"storage_container_name` output variable. Check out [test/azure/terraform_azure_storage_example_test.go](/test/azure/terraform_azure_storage_example_test.go) to see how you can write automated tests for this module. Note that the Storage Account in this module don't actually do anything; it just runs the resources for demonstration purposes. **WARNING**: This module and the automated tests for it deploy real resources into your Azure account which can cost you money. The resources are all part of the [Azure Free Account](https://azure.microsoft.com/free/), so if you haven't used that up, it should be free, but you are completely responsible for all Azure charges. ## Running this module manually 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Ensure [environment variables](../README.md#review-environment-variables) are available 1. Run `terraform init` 1. Run `terraform apply` 1. When you're done, run `terraform destroy` ## Running automated tests against this module 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Configure your TerraTest [Go test environment](../README.md) 1. `cd test/azure` 1. `go build terraform_azure_storage_example_test.go` 1. `go test -v -run TestTerraformAzureStorageExample` ================================================ FILE: examples/azure/terraform-azure-storage-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A STORAGE ACCOUNT SET # This is an example of how to deploy a Storage Account. # --------------------------------------------------------------------------------------------------------------------- # See test/azure/terraform_azure_storage_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- provider "azurerm" { features {} } # PIN TERRAFORM VERSION terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" required_providers { azurerm = { version = "~> 2.20" source = "hashicorp/azurerm" } } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RESOURCE GROUP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_resource_group" "resource_group" { name = "terratest-storage-rg-${var.postfix}" location = var.location } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A STORAGE ACCOUNT # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_storage_account" "storage_account" { name = "storage${var.postfix}" resource_group_name = azurerm_resource_group.resource_group.name location = azurerm_resource_group.resource_group.location account_kind = var.storage_account_kind account_tier = var.storage_account_tier account_replication_type = var.storage_replication_type } # --------------------------------------------------------------------------------------------------------------------- # ADD A CONTAINER TO THE STORAGE ACCOUNT # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_storage_container" "container" { name = "container1" storage_account_name = azurerm_storage_account.storage_account.name container_access_type = var.container_access_type } ================================================ FILE: examples/azure/terraform-azure-storage-example/outputs.tf ================================================ output "resource_group_name" { value = azurerm_resource_group.resource_group.name } output "storage_account_name" { value = azurerm_storage_account.storage_account.name } output "storage_account_account_tier" { value = azurerm_storage_account.storage_account.account_tier } output "storage_account_account_kind" { value = azurerm_storage_account.storage_account.account_kind } output "storage_container_name" { value = azurerm_storage_container.container.name } ================================================ FILE: examples/azure/terraform-azure-storage-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # ARM_CLIENT_ID # ARM_CLIENT_SECRET # ARM_SUBSCRIPTION_ID # ARM_TENANT_ID # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "location" { description = "The location to set for the storage account." type = string default = "East US" } variable "storage_account_kind" { description = "The kind of storage account to set" type = string default = "StorageV2" } variable "storage_account_tier" { description = "The tier of storage account to set" type = string default = "Standard" } variable "storage_replication_type" { description = "The replication type of storage account to set" type = string default = "GRS" } variable "container_access_type" { description = "The replication type of storage account to set" type = string default = "private" } variable "postfix" { description = "A postfix string to centrally mitigate resource name collisions" type = string default = "resource" } ================================================ FILE: examples/azure/terraform-azure-synapse-example/README.md ================================================ # Terraform Azure Synapse Example This folder contains a Terraform module that deploys resources in [Azure](https://azure.microsoft.com/) to demonstrate how you can use Terratest to write automated tests for your Azure Terraform code. This module deploys below resource: - A [Azure Synapse Analytics](https://azure.microsoft.com/en-us/products/synapse-analytics/). Check out [test/azure/terraform_azure_synapse_example_test.go](./../../../test/azure/terraform_azure_synapse_example_test.go) to see how you can write automated tests for this module and validate the configuration of the parameters and options. **WARNING**: This module and the automated tests for it deploy real resources into your Azure account which can cost you money. ## Running this module manually 1. Sign up for [Azure](https://azure.microsoft.com/). 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Ensure [environment variables](../README.md#review-environment-variables) are available 1. Run `terraform init` 1. Run `terraform apply` 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Configure your Terratest [Go test environment](../README.md) 1. `cd test/azure` 1. `go build terraform_azure_synapse_example_test.go` 1. `go test -v -timeout 60m -tags azure -run TestTerraformAzureSynapseExample` ================================================ FILE: examples/azure/terraform-azure-synapse-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN AZURE Synapse Analytics # This is an example of how to deploy an AZURE Synapse Analytics # See test/terraform_azure_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # CONFIGURE OUR AZURE CONNECTION # --------------------------------------------------------------------------------------------------------------------- provider "azurerm" { features {} } terraform { required_providers { azurerm = { version = "~>2.93.0" source = "hashicorp/azurerm" } } } # --------------------------------------------------------------------------------------------------------------------- # CREATE RANDOM PASSWORD # --------------------------------------------------------------------------------------------------------------------- # Random password is used as an example to simplify the deployment and improve the security of the database. # This is not as a production recommendation as the password is stored in the Terraform state file. resource "random_password" "password" { length = 16 override_special = "-_%@" min_upper = "1" min_lower = "1" min_numeric = "1" min_special = "1" } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RESOURCE GROUP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_resource_group" "synapse_rg" { name = "terratest-synapse-${var.postfix}" location = var.location } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A STORAGE ACCOUNT # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_storage_account" "storage_account" { name = "storage${var.postfix}" resource_group_name = azurerm_resource_group.synapse_rg.name location = azurerm_resource_group.synapse_rg.location account_kind = var.storage_account_kind account_tier = var.storage_account_tier account_replication_type = var.storage_account_replication_type } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A DATA LAKE GEN2 # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_storage_data_lake_gen2_filesystem" "dl_gen2" { name = "dlgen2-${var.postfix}" storage_account_id = azurerm_storage_account.storage_account.id } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A SYNAPSE WORKSPACE # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_synapse_workspace" "synapse_workspace" { name = "mysynapse${var.postfix}" resource_group_name = azurerm_resource_group.synapse_rg.name location = azurerm_resource_group.synapse_rg.location storage_data_lake_gen2_filesystem_id = azurerm_storage_data_lake_gen2_filesystem.dl_gen2.id sql_administrator_login = var.synapse_sql_user sql_administrator_login_password = random_password.password.result managed_virtual_network_enabled = true } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A SYNAPSE SQL POOL # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_synapse_sql_pool" "synapse_pool" { name = "sqlpool${var.postfix}" synapse_workspace_id = azurerm_synapse_workspace.synapse_workspace.id sku_name = var.synapse_sqlpool_sku_name create_mode = var.synapse_sqlpool_create_mode } ================================================ FILE: examples/azure/terraform-azure-synapse-example/outputs.tf ================================================ output "resource_group_name" { value = azurerm_resource_group.synapse_rg.name } output "synapse_storage_name" { value = azurerm_storage_account.storage_account.name } output "synapse_dlgen2_name" { value = azurerm_storage_data_lake_gen2_filesystem.dl_gen2.name } output "synapse_workspace_name" { value = azurerm_synapse_workspace.synapse_workspace.name } output "synapse_sqlpool_name" { value = azurerm_synapse_sql_pool.synapse_pool.name } ================================================ FILE: examples/azure/terraform-azure-synapse-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # ARM_CLIENT_ID # ARM_CLIENT_SECRET # ARM_SUBSCRIPTION_ID # ARM_TENANT_ID # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "location" { description = "The supported azure location where the resource exists" type = string default = "West US2" } variable "storage_account_kind" { description = "The kind of storage account to set" type = string default = "StorageV2" } variable "storage_account_tier" { description = "The tier of storage account to set" type = string default = "Standard" } variable "storage_account_replication_type" { description = "The replication type of storage account to set" type = string default = "GRS" } variable "synapse_sql_user" { description = "The sql pool user password for synapse" type = string default = "sqladminuser" } variable "synapse_sqlpool_sku_name" { description = "The sku name for the synapse sql pool" type = string default = "DW100c" } variable "synapse_sqlpool_create_mode" { description = "The create mode for the synapse sql pool" type = string default = "Default" } variable "postfix" { description = "A postfix string to centrally mitigate resource name collisions." type = string default = "resource" } ================================================ FILE: examples/azure/terraform-azure-vm-example/README.md ================================================ # Terraform Azure Virtual Machine Example This folder contains a complete Terraform VM module that deploys resources in [Azure](https://azure.microsoft.com/) to demonstrate how you can use Terratest to write automated tests for your Azure Virtual Machine Terraform code. This module deploys these resources: - A [Virtual Machine](https://azure.microsoft.com/services/virtual-machines/) and gives that VM the following resources: - [Virtual Machine](https://docs.microsoft.com/azure/virtual-machines/) with the name specified in the `vm_name` variable. - [Managed Disk](https://docs.microsoft.com/azure/virtual-machines/managed-disks-overview) with the name specified in the `managed_disk_name` variable. - [Availability Set](https://docs.microsoft.com/azure/virtual-machines/availability) with the name specified in the `availability_set_name` variable. - A [Virtual Network](https://azure.microsoft.com/services/virtual-network/) module that contains the following resources: - [Virtual Network](https://docs.microsoft.com/azure/virtual-network/) with the name specified in the `virtual_network_name` variable. - [Subnet](https://docs.microsoft.com/rest/api/virtualnetwork/subnets) with the name specified in the `subnet_name` variable. - [Public Address](https://docs.microsoft.com/azure/virtual-network/public-ip-addresses) with the name specified in the `public_ip_name` variable. - [Network Interface](https://docs.microsoft.com/azure/virtual-network/virtual-network-network-interface) with the name specified in the `network_interface_name` variable. Check out [test/azure/terraform_azure_vm_test.go](/test/azure/terraform_azure_vm_example_test.go) to see how you can write automated tests for this module. Note that the Virtual Machine module creates a Microsoft Windows Server Image with a managed disk, availability set and network configuration for demonstration purposes. **WARNING**: This module and the automated tests for it deploy real resources into your Azure account which can cost you money. The resources are all part of the [Azure Free Account](https://azure.microsoft.com/free/), so if you haven't used that up, it should be free, but you are completely responsible for all Azure charges. ## Running this module manually 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CL tools](https://docs.microsoft.com/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Ensure [environment variables](../README.md#review-environment-variables) are available 1. Run `terraform init` 1. Run `terraform apply` 1. When you're done, run `terraform destroy` ## Running automated tests against this module 1. Sign up for [Azure](https://azure.microsoft.com/) 1. Configure your Azure credentials using one of the [supported methods for Azure CLI tools](https://docs.microsoft.com/cli/azure/azure-cli-configuration?view=azure-cli-latest) 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH` 1. Configure your Terratest [Go test environment](../README.md) 1. `cd test/azure` 1. `go build terraform_azure_vm_test.go` 1. `go test -run -v -timeout 20m TestTerraformAzureVmExample` ================================================ FILE: examples/azure/terraform-azure-vm-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN ADVANCED AZURE VIRTUAL MACHINE # This is an advanced example of how to deploy an Azure Virtual Machine in an availability set, managed disk # and networking with a public IP. # --------------------------------------------------------------------------------------------------------------------- # See test/azure/terraform_azure_vm_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- provider "azurerm" { features {} } # --------------------------------------------------------------------------------------------------------------------- # PIN TERRAFORM VERSION # --------------------------------------------------------------------------------------------------------------------- terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" required_providers { azurerm = { version = "~> 2.50" source = "hashicorp/azurerm" } } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A RESOURCE GROUP # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_resource_group" "vm_rg" { name = "terratest-vm-rg-${var.postfix}" location = var.location } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY NETWORK RESOURCES # This network includes a public address for integration test demonstration purposes # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_virtual_network" "vnet" { name = "vnet-${var.postfix}" address_space = ["10.0.0.0/16"] location = azurerm_resource_group.vm_rg.location resource_group_name = azurerm_resource_group.vm_rg.name } resource "azurerm_subnet" "subnet" { name = "subnet-${var.postfix}" resource_group_name = azurerm_resource_group.vm_rg.name virtual_network_name = azurerm_virtual_network.vnet.name address_prefixes = [var.subnet_prefix] } resource "azurerm_public_ip" "pip" { name = "pip-${var.postfix}" resource_group_name = azurerm_resource_group.vm_rg.name location = azurerm_resource_group.vm_rg.location allocation_method = "Static" ip_version = "IPv4" sku = "Standard" idle_timeout_in_minutes = "4" } # Public and Private IPs assigned to one NIC for test demonstration purposes resource "azurerm_network_interface" "nic" { name = "nic-${var.postfix}" location = azurerm_resource_group.vm_rg.location resource_group_name = azurerm_resource_group.vm_rg.name ip_configuration { name = "terratestconfiguration1" subnet_id = azurerm_subnet.subnet.id private_ip_address_allocation = "Static" private_ip_address = var.private_ip public_ip_address_id = azurerm_public_ip.pip.id } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN AVAILABILITY SET # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_availability_set" "avs" { name = "avs-${var.postfix}" location = azurerm_resource_group.vm_rg.location resource_group_name = azurerm_resource_group.vm_rg.name platform_fault_domain_count = 2 managed = true } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY VIRTUAL MACHINE # This VM does not actually do anything and is the smallest size VM available with a Windows image # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_virtual_machine" "vm_example" { name = "vm-${var.postfix}" location = azurerm_resource_group.vm_rg.location resource_group_name = azurerm_resource_group.vm_rg.name network_interface_ids = [azurerm_network_interface.nic.id] availability_set_id = azurerm_availability_set.avs.id vm_size = var.vm_size license_type = var.vm_license_type delete_os_disk_on_termination = true delete_data_disks_on_termination = true storage_image_reference { publisher = var.vm_image_publisher offer = var.vm_image_offer sku = var.vm_image_sku version = var.vm_image_version } storage_os_disk { name = "osdisk-${var.postfix}" caching = "ReadWrite" create_option = "FromImage" managed_disk_type = var.disk_type } os_profile { computer_name = "vm-${var.postfix}" admin_username = var.user_name admin_password = random_password.rand.result } os_profile_windows_config { provision_vm_agent = true } tags = { "Version" = "0.0.1" "Environment" = "dev" } depends_on = [random_password.rand] } # Random password is used as an example to simplify the deployment and improve the security of the remote VM. # This is not as a production recommendation as the password is stored in the Terraform state file. resource "random_password" "rand" { length = 16 override_special = "-_%@" min_upper = "1" min_lower = "1" min_numeric = "1" min_special = "1" } # --------------------------------------------------------------------------------------------------------------------- # ATTACH A MANAGED DISK TO THE VIRTUAL MACHINE # --------------------------------------------------------------------------------------------------------------------- resource "azurerm_managed_disk" "disk" { name = "disk-${var.postfix}" location = azurerm_resource_group.vm_rg.location resource_group_name = azurerm_resource_group.vm_rg.name storage_account_type = var.disk_type create_option = "Empty" disk_size_gb = 10 } resource "azurerm_virtual_machine_data_disk_attachment" "vm_disk" { managed_disk_id = azurerm_managed_disk.disk.id virtual_machine_id = azurerm_virtual_machine.vm_example.id caching = "ReadWrite" lun = 10 } ================================================ FILE: examples/azure/terraform-azure-vm-example/outputs.tf ================================================ output "availability_set_name" { value = azurerm_availability_set.avs.name } output "managed_disk_name" { value = azurerm_managed_disk.disk.name } output "managed_disk_type" { value = azurerm_managed_disk.disk.storage_account_type } output "network_interface_name" { value = azurerm_network_interface.nic.name } output "os_disk_name" { value = azurerm_virtual_machine.vm_example.storage_os_disk[0].name } output "private_ip" { value = azurerm_network_interface.nic.ip_configuration[0].private_ip_address } output "public_ip_name" { value = azurerm_public_ip.pip.name } output "resource_group_name" { value = azurerm_resource_group.vm_rg.name } output "subnet_name" { value = azurerm_subnet.subnet.name } output "virtual_network_name" { value = azurerm_virtual_network.vnet.name } output "vm_admin_username" { value = nonsensitive(azurerm_virtual_machine.vm_example.os_profile[*].admin_username) } output "vm_image_sku" { value = azurerm_virtual_machine.vm_example.storage_image_reference[*].sku } output "vm_image_version" { value = azurerm_virtual_machine.vm_example.storage_image_reference[*].version } output "vm_name" { value = azurerm_virtual_machine.vm_example.name } output "vm_size" { value = azurerm_virtual_machine.vm_example.vm_size } output "vm_tags" { value = azurerm_virtual_machine.vm_example.tags } ================================================ FILE: examples/azure/terraform-azure-vm-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # ARM_CLIENT_ID # ARM_CLIENT_SECRET # ARM_SUBSCRIPTION_ID # ARM_TENANT_ID # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "disk_type" { description = "The type of the Virtual Machine disks" type = string default = "Standard_LRS" } variable "location" { description = "The Azure location where to deploy your resources too" type = string default = "East US" } variable "postfix" { description = "A postfix string to centrally mitigate resource name collisions" type = string default = "resource" } variable "private_ip" { description = "The Static Private IP for the Internal NIC" type = string default = "10.0.17.4" } variable "subnet_prefix" { description = "The subnet range of IPs for the Virtual Network" type = string default = "10.0.17.0/24" } variable "user_name" { description = "The username to be provisioned into the vm" type = string default = "testadmin" } # Small Windows Server Image, available with Azure Free Account variable "vm_image_publisher" { description = "The storage image reference Publisher from which the VM is created" type = string default = "MicrosoftWindowsServer" } variable "vm_image_offer" { description = "The storage image reference Offer from which the VM is created" type = string default = "WindowsServer" } variable "vm_image_sku" { description = "The storage image reference SKU from which the VM is created" type = string default = "2019-Datacenter-Core-smalldisk" } variable "vm_image_version" { description = "The storage image reference Version from which the VM is created" type = string default = "latest" } variable "vm_license_type" { description = "The License Type from which the VM is created" type = string default = "Windows_Server" } variable "vm_size" { description = "The Azure VM Size of the VM" type = string default = "Standard_B1s" } ================================================ FILE: examples/docker-compose-stdout-example/Dockerfile ================================================ # website::tag::1:: Build a simple Docker image that contains a text file with the contents "Hello, World!" FROM ubuntu:20.04 COPY ./bash_script.sh /usr/local/bin/bash_script.sh ================================================ FILE: examples/docker-compose-stdout-example/bash_script.sh ================================================ #!/bin/bash set -e echo "stdout: message" >&2 echo -e "stderr: error" ================================================ FILE: examples/docker-compose-stdout-example/docker-compose.yml ================================================ version: '2.0' services: bash_script: build: context: . entrypoint: bash_script.sh ================================================ FILE: examples/docker-hello-world-example/Dockerfile ================================================ # website::tag::1:: Build a simple Docker image that contains a text file with the contents "Hello, World!" FROM ubuntu:18.04 RUN echo 'Hello, World!' > /test.txt ================================================ FILE: examples/docker-hello-world-example/README.md ================================================ # Docker "Hello, World" Example This folder contains a `Dockerfile` to build a very simple Docker image—one which contains a text file with the text "Hello, World"!—to demonstrate how you can use Terratest to write automated tests for your Docker images. Check out [test/docker_hello_world_example_test.go](/test/docker_hello_world_example_test.go) to see how you can write automated tests for this simple Docker image. ## Building the Docker container 1. Install [Docker](https://www.docker.com/) and make sure it's on your `PATH`. 1. Run `docker build -t gruntwork/docker-hello-world-example .`. 1. Run `docker run -it --rm gruntwork/docker-hello-world-example cat /test.txt`. 1. You should see the text "Hello, World!" ## Running automated tests against the Docker container 1. Install [Docker](https://www.docker.com/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `go test -v -run TestDockerHelloWorldExample` ================================================ FILE: examples/helm-basic-example/Chart.yaml ================================================ apiVersion: v1 name: helm-basic-example description: A minimal Helm chart to demonstrate how to use terratest to test helm charts version: 0.0.1 ================================================ FILE: examples/helm-basic-example/README.md ================================================ # Helm Basic Example This folder contains a minimal helm chart to demonstrate how you can use Terratest to test your helm charts. There are two kinds of tests you can perform on a helm chart: - Helm Template tests are tests designed to test the logic of the templates. These tests should run `helm template` with various input values and parse the yaml to validate any logic embedded in the templates (e.g by reading them in using client-go). Since templates are not statically typed, the goal of these tests is to promote fast cycle time - Helm Integration tests are tests that are designed to deploy the infrastructure and validate that it actually works as expected. If you consider the templates to be syntactic tests, these are semantic tests that validate the behavior of the deployed resources. The helm chart deploys a single replica `Deployment` resource given the container image spec and a `Service` that exposes it. This chart requires the `containerImageRepo` and `containerImageTag` input values. See the corresponding terratest code for an example of how to test this chart: - [helm_basic_example_template_test.go](/test/helm_basic_example_template_test.go): the template tests for this chart. - [helm_basic_example_integration_test.go](/test/helm_basic_example_integration_test.go): the integration test for this chart. This test will deploy the Helm Chart and verify the `Service` endpoint. ## Running automated tests against this Helm Chart 1. Install and setup [helm](https://docs.helm.sh/using_helm/#installing-helm) 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `dep ensure` 1. `go test -v -tags helm -run TestHelmBasicExampleTemplate` for the template test 1. `go test -v -tags helm -run TestHelmBasicExampleDeployment` for the integration test **NOTE**: we have build tags to differentiate kubernetes tests from non-kubernetes tests, and further differentiate helm tests. This is done because minikube is heavy and can interfere with docker related tests in terratest. Similarly, helm can overload the minikube system and thus interfere with the other kubernetes tests. Specifically, many of the tests start to fail with `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. ================================================ FILE: examples/helm-basic-example/templates/_helpers.tpl ================================================ {{/* vim: set filetype=mustache: */}} {{/* Expand the name of the chart. */}} {{- define "helm-basic-example.name" -}} {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} {{- end -}} {{/* Create a default fully qualified app name. We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). If release name contains chart name it will be used as a full name. */}} {{- define "helm-basic-example.fullname" -}} {{- if .Values.fullnameOverride -}} {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} {{- else -}} {{- $name := default .Chart.Name .Values.nameOverride -}} {{- if contains $name .Release.Name -}} {{- .Release.Name | trunc 63 | trimSuffix "-" -}} {{- else -}} {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} {{- end -}} {{- end -}} {{- end -}} {{/* Create chart name and version as used by the chart label. */}} {{- define "helm-basic-example.chart" -}} {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} {{- end -}} ================================================ FILE: examples/helm-basic-example/templates/deployment.yaml ================================================ apiVersion: apps/v1 kind: Deployment metadata: name: {{ include "helm-basic-example.fullname" . }} namespace: {{ .Release.Namespace }} labels: # These labels are required by helm. You can read more about required labels in the chart best pracices guide: # https://docs.helm.sh/chart_best_practices/#standard-labels helm.sh/chart: {{ include "helm-basic-example.chart" . }} app.kubernetes.io/name: {{ include "helm-basic-example.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/managed-by: {{ .Release.Service }} spec: replicas: 1 selector: matchLabels: app.kubernetes.io/name: {{ include "helm-basic-example.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} template: metadata: labels: app.kubernetes.io/name: {{ include "helm-basic-example.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} spec: containers: - name: app {{- $repo := required "containerImageRepo is required" .Values.containerImageRepo }} {{- $tag := required "containerImageTag is required" .Values.containerImageTag }} image: "{{ $repo }}:{{ $tag }}" ports: - containerPort: 80 ================================================ FILE: examples/helm-basic-example/templates/service.yaml ================================================ apiVersion: v1 kind: Service metadata: name: {{ include "helm-basic-example.fullname" . }} labels: # These labels are required by helm. You can read more about required labels in the chart best practices guide: # https://docs.helm.sh/chart_best_practices/#standard-labels helm.sh/chart: {{ include "helm-basic-example.chart" . }} app.kubernetes.io/name: {{ include "helm-basic-example.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/managed-by: {{ .Release.Service }} spec: selector: app.kubernetes.io/name: {{ include "helm-basic-example.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} type: NodePort ports: - protocol: TCP targetPort: 80 port: 80 ================================================ FILE: examples/helm-basic-example/values.yaml ================================================ # This chart purposefully does not provide any values, to demonstrate how to test required values. # Note that the following two values must be specified if you wish to deploy this chart: # containerImageRepo is a string that describes the image repository to pull the container image from. # containerImageRepo: nginx # containerImageTag is a string that describes the image tag to use when pulling the container image. # containerImageTag: v1.15.4 ================================================ FILE: examples/helm-dependency-example/.gitignore ================================================ charts/ requirements.lock ================================================ FILE: examples/helm-dependency-example/Chart.yaml ================================================ apiVersion: v1 name: helm-dependency-example description: A minimal Helm chart to demonstrate how to use terratest to test helm charts with dependency version: 0.0.1 dependencies: - name: helm-basic-example alias: basic repository: file://../helm-basic-example condition: basic.enabled version: 0.0.1 ================================================ FILE: examples/helm-dependency-example/README.md ================================================ # Helm Dependency Example This folder contains a minimal helm chart to demonstrate how you can use Terratest to test your helm charts with dependencies. There are two kinds of tests you can perform on a helm chart: - Helm Template tests are tests designed to test the logic of the templates. These tests should run `helm template` with various input values and parse the yaml to validate any logic embedded in the templates (e.g by reading them in using client-go). Since templates are not statically typed, the goal of these tests is to promote fast cycle time The helm chart deploys a single replica `Deployment` resource given the container image spec and a `Service` that exposes it. This chart requires the `containerImageRepo` and `containerImageTag` input values. See the corresponding terratest code for an example of how to test this chart: - [helm_basic_example_template_test.go](/test/helm_basic_example_template_test.go): the template tests for this chart. ## Running automated tests against this Helm Chart 1. Install and setup [helm](https://docs.helm.sh/using_helm/#installing-helm) 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `dep ensure` 1. `go test -v -tags helm -run TestHelmDependencyExampleTemplate` for the template test **NOTE**: we have build tags to differentiate kubernetes tests from non-kubernetes tests, and further differentiate helm tests. This is done because minikube is heavy and can interfere with docker related tests in terratest. Similarly, helm can overload the minikube system and thus interfere with the other kubernetes tests. Specifically, many of the tests start to fail with `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. ================================================ FILE: examples/helm-dependency-example/templates/_helpers.tpl ================================================ {{/* vim: set filetype=mustache: */}} {{/* Expand the name of the chart. */}} {{- define "helm-dependency-example.name" -}} {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} {{- end -}} {{/* Create a default fully qualified app name. We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). If release name contains chart name it will be used as a full name. */}} {{- define "helm-dependency-example.fullname" -}} {{- if .Values.fullnameOverride -}} {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} {{- else -}} {{- $name := default .Chart.Name .Values.nameOverride -}} {{- if contains $name .Release.Name -}} {{- .Release.Name | trunc 63 | trimSuffix "-" -}} {{- else -}} {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} {{- end -}} {{- end -}} {{- end -}} {{/* Create chart name and version as used by the chart label. */}} {{- define "helm-dependency-example.chart" -}} {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} {{- end -}} ================================================ FILE: examples/helm-dependency-example/templates/deployment.yaml ================================================ apiVersion: apps/v1 kind: Deployment metadata: name: {{ include "helm-dependency-example.fullname" . }} namespace: {{ .Release.Namespace }} labels: # These labels are required by helm. You can read more about required labels in the chart best pracices guide: # https://docs.helm.sh/chart_best_practices/#standard-labels helm.sh/chart: {{ include "helm-dependency-example.chart" . }} app.kubernetes.io/name: {{ include "helm-dependency-example.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/managed-by: {{ .Release.Service }} spec: replicas: 1 selector: matchLabels: app.kubernetes.io/name: {{ include "helm-dependency-example.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} template: metadata: labels: app.kubernetes.io/name: {{ include "helm-dependency-example.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} spec: containers: - name: app {{- $repo := required "containerImageRepo is required" .Values.containerImageRepo }} {{- $tag := required "containerImageTag is required" .Values.containerImageTag }} image: "{{ $repo }}:{{ $tag }}" ports: - containerPort: 80 ================================================ FILE: examples/helm-dependency-example/templates/service.yaml ================================================ apiVersion: v1 kind: Service metadata: name: {{ include "helm-dependency-example.fullname" . }} labels: # These labels are required by helm. You can read more about required labels in the chart best practices guide: # https://docs.helm.sh/chart_best_practices/#standard-labels helm.sh/chart: {{ include "helm-dependency-example.chart" . }} app.kubernetes.io/name: {{ include "helm-dependency-example.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/managed-by: {{ .Release.Service }} spec: selector: app.kubernetes.io/name: {{ include "helm-dependency-example.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} type: NodePort ports: - protocol: TCP targetPort: 80 port: 80 ================================================ FILE: examples/helm-dependency-example/values.yaml ================================================ # This chart purposefully does not provide any values, to demonstrate how to test required values. # Note that the following two values must be specified if you wish to deploy this chart: # containerImageRepo is a string that describes the image repository to pull the container image from. # containerImageRepo: nginx # containerImageTag is a string that describes the image tag to use when pulling the container image. # containerImageTag: v1.15.4 ================================================ FILE: examples/kubernetes-basic-example/README.md ================================================ # Kubernetes Basic Example This folder contains a minimal Kubernetes resource config file to demonstrate how you can use Terratest to write automated tests for Kubernetes. This resource file deploys an nginx container as a single pod deployment with a node port service attached to it. See the corresponding terratest code for an example of how to test this resource config: - [kubernetes_basic_example_test.go](../../test/kubernetes_basic_example_test.go) for the most basic verification - [kubernetes_basic_example_service_check_test.go](../../test/kubernetes_basic_example_service_check_test.go) for a more advanced version of checking the service. ## Deploying the Kubernetes resource 1. Setup a Kubernetes cluster. We recommend using a local version: - [minikube](https://github.com/kubernetes/minikube) - [Kubernetes on Docker For Mac](https://docs.docker.com/docker-for-mac/kubernetes/) - [Kubernetes on Docker For Windows](https://docs.docker.com/docker-for-windows/kubernetes/) 1. Install and setup [kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/) to talk to the deployed Kubernetes cluster. 1. Run `kubectl apply -f nginx-deployment.yml` ## Running automated tests against this Kubernetes deployment 1. Setup a Kubernetes cluster. We recommend using a local version: - [minikube](https://github.com/kubernetes/minikube) - [Kubernetes on Docker For Mac](https://docs.docker.com/docker-for-mac/kubernetes/) - [Kubernetes on Docker For Windows](https://docs.docker.com/docker-for-windows/kubernetes/) 1. Install and setup [kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/) to talk to the deployed Kubernetes cluster. 1. Install and setup [kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/). 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `dep ensure` 1. `go test -v -tags kubernetes -run TestKubernetesBasicExample` 1. You can also run `TestKubernetesBasicExampleServiceCheck` ================================================ FILE: examples/kubernetes-basic-example/nginx-deployment.yml ================================================ --- apiVersion: apps/v1 kind: Deployment metadata: name: nginx-deployment spec: selector: matchLabels: app: nginx replicas: 1 template: metadata: labels: app: nginx spec: containers: - name: nginx image: nginx:1.15.7 ports: - containerPort: 80 --- kind: Service apiVersion: v1 metadata: name: nginx-service spec: selector: app: nginx ports: - protocol: TCP targetPort: 80 port: 80 type: NodePort ================================================ FILE: examples/kubernetes-basic-example/podinfo-daemonset.yml ================================================ --- apiVersion: apps/v1 kind: DaemonSet metadata: name: podinfo-deamonset spec: selector: matchLabels: app: podinfo template: metadata: labels: app: podinfo spec: containers: - name: podinfo image: ghcr.io/stefanprodan/podinfo:6.3.0 ports: - containerPort: 9898 ================================================ FILE: examples/kubernetes-hello-world-example/README.md ================================================ # Kubernetes "Hello, World" Example This folder contains the most minimal Kubernetes resource config—which deploys a simple webapp that responds with "Hello, World!"—to demonstrate how you can use Terratest to write automated tests for Kubernetes. Check out [test/kubernetes_hello_world_example_test.go](/test/kubernetes_hello_world_example_test.go) to see how you can write automated tests for this simple resource config. ## Deploying the Kubernetes resource 1. Setup a Kubernetes cluster. We recommend using a local version: - [Kubernetes on Docker For Mac](https://docs.docker.com/docker-for-mac/kubernetes/) - [Kubernetes on Docker For Windows](https://docs.docker.com/docker-for-windows/kubernetes/) - [minikube](https://github.com/kubernetes/minikube) 1. Install and setup [kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/) to talk to the deployed Kubernetes cluster. 1. Run `kubectl apply -f hello-world-deployment.yml` ## Running automated tests against this Kubernetes deployment 1. Setup a Kubernetes cluster. We recommend using a local version: - [Kubernetes on Docker For Mac](https://docs.docker.com/docker-for-mac/kubernetes/) - [Kubernetes on Docker For Windows](https://docs.docker.com/docker-for-windows/kubernetes/) - [minikube](https://github.com/kubernetes/minikube) 1. Install and setup [kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/) to talk to the deployed Kubernetes cluster. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `go test -v -tags kubernetes -run TestKubernetesHelloWorldExample` ================================================ FILE: examples/kubernetes-hello-world-example/hello-world-deployment.yml ================================================ --- # website::tag::1:: Deploy the hashicorp/http-echo Docker Container: https://hub.docker.com/r/hashicorp/http-echo apiVersion: apps/v1 kind: Deployment metadata: name: hello-world-deployment spec: selector: matchLabels: app: hello-world replicas: 1 template: metadata: labels: app: hello-world spec: containers: # website::tag::2:: Runs an HTTP server that responds with "Hello, World!" on port 5000 - name: hello-world image: hashicorp/http-echo args: - "-text=Hello, World!" - "-listen=:5000" ports: - containerPort: 5000 --- # website::tag::3:: Expose the webapp on port 5000 via a Kubernetes LoadBalancer. kind: Service apiVersion: v1 metadata: name: hello-world-service spec: selector: app: hello-world ports: - protocol: TCP targetPort: 5000 port: 5000 type: LoadBalancer ================================================ FILE: examples/kubernetes-kustomize-example/README.md ================================================ # Kubernetes Kustomize Example This folder contains a minimal Kubernetes resource config file to demonstrate how you can use Terratest to write automated tests for Kubernetes. This resource file deploys an nginx container as a single pod deployment with a node port service attached to it. See the corresponding terratest code for an example of how to test this resource config: - [kubernetes_kustomize_example_test.go](../../test/kubernetes_kustomize_example_test.go) ## Deploying the Kubernetes resource 1. Setup a Kubernetes cluster. We recommend using a local version: - [minikube](https://github.com/kubernetes/minikube) - [Kubernetes on Docker For Mac](https://docs.docker.com/docker-for-mac/kubernetes/) - [Kubernetes on Docker For Windows](https://docs.docker.com/docker-for-windows/kubernetes/) 1. Install and setup [kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/) to talk to the deployed Kubernetes cluster. 1. Run `kubectl apply -k .` ## Running automated tests against this Kubernetes deployment 1. Setup a Kubernetes cluster. We recommend using a local version: - [minikube](https://github.com/kubernetes/minikube) - [Kubernetes on Docker For Mac](https://docs.docker.com/docker-for-mac/kubernetes/) - [Kubernetes on Docker For Windows](https://docs.docker.com/docker-for-windows/kubernetes/) 1. Install and setup [kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/) to talk to the deployed Kubernetes cluster. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `dep ensure` 1. `go test -v -tags kubernetes -run TestKubernetesKustomizeExample` ================================================ FILE: examples/kubernetes-kustomize-example/deployment.yaml ================================================ --- apiVersion: apps/v1 kind: Deployment metadata: name: nginx-deployment spec: selector: matchLabels: app: nginx replicas: 1 template: metadata: labels: app: nginx spec: containers: - name: nginx image: nginx:1.15.7 ports: - containerPort: 80 ================================================ FILE: examples/kubernetes-kustomize-example/kustomization.yaml ================================================ resources: - ./deployment.yaml - ./service.yaml ================================================ FILE: examples/kubernetes-kustomize-example/service.yaml ================================================ --- kind: Service apiVersion: v1 metadata: name: nginx-service spec: selector: app: nginx ports: - protocol: TCP targetPort: 80 port: 1080 type: NodePort ================================================ FILE: examples/kubernetes-rbac-example/README.md ================================================ # Kubernetes RBAC Example This folder contains a Kubernetes resource config file that creates a new Namespace and a ServiceAccount that has admin level permissions in the Namespace, but nowhere else. This example is used to demonstrate how you can test RBAC permissions using terratest. See the corresponding terratest code ([kubernetes_rbac_example_test.go](../../test/kubernetes_rbac_example_test.go)) for an example of how to test this resource config: ## Deploying the Kubernetes resource 1. Setup a Kubernetes cluster. We recommend using a local version: - [minikube](https://github.com/kubernetes/minikube) - [Kubernetes on Docker For Mac](https://docs.docker.com/docker-for-mac/kubernetes/) - [Kubernetes on Docker For Windows](https://docs.docker.com/docker-for-windows/kubernetes/) 1. Install and setup [kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/) to talk to the deployed Kubernetes cluster. 1. Run `kubectl apply -f namespace-service-account.yml` ## Running automated tests against this Kubernetes deployment 1. Setup a Kubernetes cluster. We recommend using a local version: - [minikube](https://github.com/kubernetes/minikube) - [Kubernetes on Docker For Mac](https://docs.docker.com/docker-for-mac/kubernetes/) - [Kubernetes on Docker For Windows](https://docs.docker.com/docker-for-windows/kubernetes/) 1. Install and setup [kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/) to talk to the deployed Kubernetes cluster. 1. Install and setup [kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/). 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `dep ensure` 1. `go test -v -tags kubernetes -run TestKubernetesRBACExample` ================================================ FILE: examples/kubernetes-rbac-example/namespace-service-account.yml ================================================ --- apiVersion: v1 kind: Namespace metadata: name: terratest-rbac-example-namespace --- apiVersion: v1 kind: ServiceAccount metadata: name: terratest-rbac-example-service-account namespace: terratest-rbac-example-namespace --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: terratest-rbac-example-role namespace: terratest-rbac-example-namespace rules: - apiGroups: ["*"] resources: ["*"] verbs: ["*"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: terratest-rbac-example-service-account-binding namespace: terratest-rbac-example-namespace subjects: - kind: ServiceAccount name: terratest-rbac-example-service-account namespace: terratest-rbac-example-namespace roleRef: kind: Role name: terratest-rbac-example-role apiGroup: rbac.authorization.k8s.io ================================================ FILE: examples/packer-basic-example/README.md ================================================ # Packer Basic Example This folder contains a very simple Packer template to demonstrate how you can use Terratest to write automated tests for your Packer templates. The template just creates an up-to-date Ubuntu AMI by running `apt-get update` and `apt-get upgrade`. Check out [test/packer_basic_example_test.go](/test/packer_basic_example_test.go) to see how you can write automated tests for this simple template. Note that this template doesn't do anything useful; it's just here to demonstrate the simplest usage pattern for Terratest. For slightly more complicated, real-world examples of Packer templates and the corresponding tests, see [packer-docker-example](/examples/packer-docker-example) and [terraform-packer-example](/examples/terraform-packer-example). ## Installation steps 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. 1. Install [Packer](https://www.packer.io/) and make sure it's on your `PATH`. ## Building the Packer template manually (Packer >= 1.7.0) 1. Run `packer init build.pkr.hcl`. # Use build-gcp.pkr.hcl if using GCP 1. Run `packer build build.pkr.hcl`. # Use build-gcp.pkr.hcl if using GCP ## Building the Packer template manually (Packer < 1.7.0) 1. Run `packer build build.json`. ## Running automated tests against this Packer template 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. 1. Install [Packer](https://www.packer.io/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `dep ensure` 1. `go test -v -run TestPackerBasicExample` ## Running automated tests against this Packer template for the GCP builder 1. Sign up for [GCP](https://cloud.google.com/). 1. Configure your GCP credentials using one of the [Authentication](https://www.packer.io/docs/builders/googlecompute.html#authentication) methods. 1. Install [Packer](https://www.packer.io/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `dep ensure` 1. `go test -v -run TestPackerGCPBasicExample` ## Running automated tests against this Packer template for the OCI builder 1. Sign up for [OCI](https://cloud.oracle.com/cloud-infrastructure). 1. Configure your OCI credentials via [CLI Configuration Information](https://docs.cloud.oracle.com/iaas/Content/API/Concepts/sdkconfig.htm). 1. Create [VCN](https://docs.cloud.oracle.com/iaas/Content/GSG/Tasks/creatingnetwork.htm) and subnet resources in your tenancy (a.k.a. a root compartment). 1. (Optional) Create `TF_VAR_pass_phrase` environment property with the pass phrase for decrypting of the OCI [API signing key](https://docs.cloud.oracle.com/iaas/Content/API/Concepts/apisigningkey.htm) (can be omitted if the key is not protected). 1. Install [Packer](https://www.packer.io/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `dep ensure` 1. `go test -v -run TestPackerOciExample` ================================================ FILE: examples/packer-basic-example/build-gcp.pkr.hcl ================================================ packer { required_plugins { googlecompute = { version = ">=v1.0.0" source = "github.com/hashicorp/googlecompute" } } } variable "gcp_project_id" { type = string default = "" } variable "gcp_zone" { type = string default = "us-central1-a" } source "googlecompute" "ubuntu-bionic" { image_family = "terratest" image_name = "terratest-packer-example-${formatdate("YYYYMMDD-hhmm", timestamp())}" project_id = var.gcp_project_id source_image_family = "ubuntu-2204-lts" ssh_username = "ubuntu" zone = var.gcp_zone } build { sources = [ "source.googlecompute.ubuntu-bionic" ] provisioner "shell" { inline = ["sudo DEBIAN_FRONTEND=noninteractive apt-get update", "sudo DEBIAN_FRONTEND=noninteractive apt-get upgrade -y"] pause_before = "30s" } } ================================================ FILE: examples/packer-basic-example/build.pkr.hcl ================================================ packer { required_plugins { amazon = { version = ">=v1.0.0" source = "github.com/hashicorp/amazon" } oracle = { version = ">=v1.0.0" source = "github.com/hashicorp/oracle" } } } variable "ami_base_name" { type = string default = "" } variable "aws_region" { type = string default = "us-east-1" } variable "instance_type" { type = string default = "t2.micro" } variable "oci_availability_domain" { type = string default = "" } variable "oci_base_image_ocid" { type = string default = "" } variable "oci_compartment_ocid" { type = string default = "" } variable "oci_pass_phrase" { type = string default = "" } variable "oci_subnet_ocid" { type = string default = "" } data "amazon-ami" "ubuntu-jammy" { filters = { architecture = "x86_64" "block-device-mapping.volume-type" = "gp2" name = "*ubuntu-jammy-22.04-amd64-server-*" root-device-type = "ebs" virtualization-type = "hvm" } most_recent = true owners = ["099720109477"] region = var.aws_region } source "amazon-ebs" "ubuntu-example" { ami_description = "An example of how to create a custom AMI on top of Ubuntu" ami_name = "${var.ami_base_name}-terratest-packer-example" encrypt_boot = false instance_type = var.instance_type region = var.aws_region source_ami = data.amazon-ami.ubuntu-jammy.id ssh_username = "ubuntu" } source "oracle-oci" "oracle-example" { availability_domain = var.oci_availability_domain base_image_ocid = var.oci_base_image_ocid compartment_ocid = var.oci_compartment_ocid image_name = "terratest-packer-example-${formatdate("YYYYMMDD-hhmm", timestamp())}" pass_phrase = var.oci_pass_phrase shape = "VM.Standard2.1" ssh_username = "ubuntu" subnet_ocid = var.oci_subnet_ocid } build { sources = [ "source.amazon-ebs.ubuntu-example", "source.oracle-oci.oracle-example" ] provisioner "shell" { inline = ["sudo DEBIAN_FRONTEND=noninteractive apt-get update", "sudo DEBIAN_FRONTEND=noninteractive apt-get upgrade -y"] pause_before = "30s" } } ================================================ FILE: examples/packer-docker-example/README.md ================================================ # Packer Docker Example This folder contains a Packer template to demonstrate how you can use Terratest to write automated tests for your Packer templates. The template creates an Ubuntu AMI with a simple web app (built on top of Ruby / Sinatra) installed. This template _also_ creates a Docker image with the same web app installed, and contains a `docker-compose.yml` file for running that Docker image. These allow you to test your Packer template completely locally, without having to deploy to AWS. Check out [test/packer_docker_example_test.go](/test/packer_docker_example_test.go) to see how you can write automated tests for this simple template. The Docker-based tests in this folder are in some sense "unit tests" for the Packer template. To see an example of "integration tests" that deploy the AMI to AWS, check out the [terraform-packer-example](/examples/terraform-packer-example). ## Installation steps 1. Install [Packer](https://www.packer.io/) and make sure it's on your `PATH`. 1. Install [Docker](https://www.docker.com/) and make sure it's on your `PATH`. ## Building a Docker image for local testing (Packer >= 1.7.0) 1. Run `packer init build.pkr.hcl`. 1. Run `packer build build.pkr.hcl`. ## Building a Docker image for local testing (Packer < 1.7.0) 1. Run `packer build build.json`. ## Run the container 1. Run `docker compose up`. 1. You should now be able to access the sample web app at http://localhost:8080 ## Building an AMI for testing in AWS 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. 1. Install [Packer](https://www.packer.io/) and make sure it's on your `PATH`. 1. Run `packer build -only=ubuntu-ami build.json`. ## Running automated tests locally against this Packer template 1. Install [Packer](https://www.packer.io/) and make sure it's on your `PATH`. 1. Install [Docker](https://www.docker.com/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `dep ensure` 1. `go test -v -run TestPackerDockerExampleLocal` ## Running automated tests in AWS against this Packer template See [terraform-packer-example](/examples/terraform-packer-example). ================================================ FILE: examples/packer-docker-example/app.rb ================================================ # A simple web app built on top of Ruby and Sinatra. require 'sinatra' require 'json' if ARGV.length != 2 raise 'Expected exactly two arguments: SERVER_PORT SERVER_TEXT' end server_port = ARGV[0] server_text = ARGV[1] set :port, server_port set :bind, '0.0.0.0' set :server, 'puma' get '/' do server_text end ================================================ FILE: examples/packer-docker-example/build.json ================================================ { "variables": { "aws_region": "us-east-1", "ami_name_base": "terratest-packer-docker-example", "instance_type": "t2.micro" }, "builders": [{ "name": "ubuntu-ami", "ami_name": "{{user `ami_name_base`}}-{{isotime | clean_resource_name}}", "ami_description": "An example of how to create a custom AMI with a simple web app on top of Ubuntu", "instance_type": "{{user `instance_type`}}", "region": "{{user `aws_region`}}", "type": "amazon-ebs", "source_ami_filter": { "filters": { "virtualization-type": "hvm", "architecture": "x86_64", "name": "*ubuntu-jammy-22.04-amd64-server-*", "block-device-mapping.volume-type": "gp2", "root-device-type": "ebs" }, "owners": ["099720109477"], "most_recent": true }, "ssh_username": "ubuntu", "encrypt_boot": false },{ "name": "ubuntu-docker", "type": "docker", "image": "gruntwork/ubuntu-test:22.04", "commit": true, "changes": ["ENTRYPOINT [\"\"]"] }], "provisioners": [{ "type": "shell", "inline": [ "echo 'Sleeping for a few seconds to give Ubuntu time to boot up'", "sleep 30" ], "only": ["ubuntu-ami"] },{ "type": "file", "source": "{{template_dir}}", "destination": "/tmp/packer-docker-example" },{ "type": "shell", "inline": ["/tmp/packer-docker-example/configure-sinatra-app.sh"] }], "post-processors": [{ "type": "docker-tag", "repository": "gruntwork/packer-docker-example", "tag": "latest", "only": ["ubuntu-docker"] }] } ================================================ FILE: examples/packer-docker-example/build.pkr.hcl ================================================ packer { required_plugins { amazon = { version = ">=v1.0.0" source = "github.com/hashicorp/amazon" } docker = { version = ">=v1.0.1" source = "github.com/hashicorp/docker" } } } variable "ami_name_base" { type = string default = "terratest-packer-docker-example" } variable "aws_region" { type = string default = "us-east-1" } variable "instance_type" { type = string default = "t2.micro" } data "amazon-ami" "aws" { filters = { architecture = "x86_64" "block-device-mapping.volume-type" = "gp2" name = "*ubuntu-jammy-22.04-amd64-server-*" root-device-type = "ebs" virtualization-type = "hvm" } most_recent = true owners = ["099720109477"] region = var.aws_region } source "amazon-ebs" "ubuntu-ami" { ami_description = "An example of how to create a custom AMI with a simple web app on top of Ubuntu" ami_name = "${var.ami_name_base}-${formatdate("YYYYMMDD-hhmm", timestamp())}" encrypt_boot = false instance_type = var.instance_type region = var.aws_region source_ami = data.amazon-ami.aws.id ssh_username = "ubuntu" } source "docker" "ubuntu-docker" { changes = ["ENTRYPOINT [\"\"]"] commit = true image = "gruntwork/ubuntu-test:22.04" } build { sources = ["source.amazon-ebs.ubuntu-ami", "source.docker.ubuntu-docker"] provisioner "shell" { inline = ["echo 'Sleeping for a few seconds to give Ubuntu time to boot up'", "sleep 30"] only = ["amazon-ebs.ubuntu-ami"] } provisioner "file" { destination = "/tmp/packer-docker-example" source = path.root } provisioner "shell" { inline = ["/tmp/packer-docker-example/configure-sinatra-app.sh"] } post-processor "docker-tag" { only = ["docker.ubuntu-docker"] repository = "gruntwork/packer-docker-example" tag = ["latest"] } } ================================================ FILE: examples/packer-docker-example/configure-sinatra-app.sh ================================================ #!/bin/bash # Install and configure a simple web app built on top of Ruby and Sinatra set -e readonly APP_RB_SRC="/tmp/packer-docker-example/app.rb" readonly APP_RB_DST="/home/ubuntu/app.rb" echo "Installing Ruby" sudo apt-get update sudo apt-get install -y make zlib1g-dev build-essential ruby ruby-dev echo "Installing Sinatra" sudo gem install sinatra json rackup puma echo "Moving $APP_RB_SRC to $APP_RB_DST" mkdir -p "$(dirname "$APP_RB_DST")" mv "$APP_RB_SRC" "$APP_RB_DST" ================================================ FILE: examples/packer-docker-example/docker-compose.yml ================================================ # This file can be used with Docker and Docker Compose to run the web app in the Packer template 100% locally, without # having to deploy anything to AWS. version: '3' services: web_app: # The name we use for the Docker image in build.json (or build.pkr.hcl) image: gruntwork/packer-docker-example # Run the sample web app on port 8080 command: ["ruby", "/home/ubuntu/app.rb", "${SERVER_PORT}", "${SERVER_TEXT}"] # Bind-mount the Ruby app so we can have "hot reload" during testing volumes: - ./app.rb:/home/ubuntu/app.rb # Expose the sample app's port on the host OS ports: - "${SERVER_PORT}:${SERVER_PORT}" ================================================ FILE: examples/packer-hello-world-example/README.md ================================================ # Packer "Hello, World" Example This folder contains the simplest possible Packer template—one that builds a Docker image with a text file that says "Hello, World"!—to demonstrate how you can use Terratest to write automated tests for your Packer templates. Check out [test/packer_hello_world_example_test.go](/test/packer_hello_world_example_test.go) to see how you can write automated tests for this simple template. ## Installation steps 1. Install [Packer](https://www.packer.io/) and make sure it's on your `PATH`. 1. Install [Docker](https://www.docker.com/) and make sure it's on your `PATH`. ## Building the Packer template (Packer >= 1.7.0) 1. Run `packer init build.pkr.hcl`. 1. Run `packer build build.pkr.hcl`. ## Building the Packer template (Packer < 1.7.0) 1. Run `packer build build.json`. ## Run Docker 1. Run `docker run -it --rm gruntwork/packer-hello-world-example cat /test.txt`. 1. You should see the text "Hello, World!" ## Running automated tests against the Packer template 1. Install [Packer](https://www.packer.io/) and make sure it's on your `PATH`. 1. Install [Docker](https://www.docker.com/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `go test -v -run TestPackerHelloWorldExample` ================================================ FILE: examples/packer-hello-world-example/build.pkr.hcl ================================================ packer { required_plugins { docker = { version = ">=v1.0.1" source = "github.com/hashicorp/docker" } } } source "docker" "ubuntu-docker" { changes = ["ENTRYPOINT [\"\"]"] commit = true image = "gruntwork/ubuntu-test:16.04" platform = "linux/amd64" } build { sources = ["source.docker.ubuntu-docker"] provisioner "shell" { inline = ["echo 'Hello, World!' > /test.txt"] } post-processor "docker-tag" { repository = "gruntwork/packer-hello-world-example" tag = ["latest"] } } ================================================ FILE: examples/terraform-asg-scp-example/README.md ================================================ # Terraform ASG SCP Example This folder contains a simple Terraform module that deploys resources in [AWS](https://aws.amazon.com/) to demonstrate how you can use Terratest to write automated tests for your AWS Terraform code. This module deploys an ASG with one instance. The EC2 Instance allows SSH requests on the port specified by the `ssh_port` variable. Check out [test/terraform_scp_example_test.go](/test/terraform_scp_example_test.go) to see how you can write automated tests for this module. Note that the example in this module is still fairly simplified, as the EC2 Instance doesn't do a whole lot! For a more complicated, real-world, end-to-end example of a Terraform module and web server, see [terraform-packer-example](/examples/terraform-packer-example). **WARNING**: This module and the automated tests for it deploy real resources into your AWS account which can cost you money. The resources are all part of the [AWS Free Tier](https://aws.amazon.com/free/), so if you haven't used that up, it should be free, but you are completely responsible for all AWS charges. ## Overview When a test fails, it is often important to be able to quickly get to logs and config files from your deployed apps and services. Currently, getting at this information is a bit of a pain. Often times, it would be necessary to "catch it in the act". Usually this would require running tests and then "pausing"/not tearing down the infrastructure, ssh-ing to individual instances and then viewing the logs/config files that way. This in not very convenient and gets even more tricky when trying to get the same results for tests being executed by your CI server. You can use terratest to help with this task by specifying `RemoteFileSpecification` structs that describe which files you want to copy from your instances: ```go logstashSpec := aws.RemoteFileSpecification{ SshUser:sshUserName, UseSudo:true, KeyPair:keyPair, LocalDestinationDir:filepath.Join("/tmp", "logs", t.Name(), "logstash"), AsgNames: strings.Split(strings.Replace(terraform.OutputRequired(t, terraformOptions, "logstash_server_asg_names"), "\n", "", -1), ","), RemotePathToFileFilter: map[string][]string { "/var/log/logstash":{"*"}, "/etc/logstash/conf.d" : {"*"}, }, } ``` Once you've described what files you want, grabbing them from ASGs is simple with: ```go aws.FetchFilesFromAllAsgsE(t, awsRegion, logstashSpec) ``` or directly from EC2 instances with: ```go aws.FetchFilesFromInstance(t, awsRegion, sshUserName, keyPair, appServerInstanceId, true, appServerConfig, filepath.Join("/tmp", "logs", t.Name(), "app_server"), []string{"*.yml", "caFile", "*.key", "*.pem"}) ``` Finally, to put all of this together, in your go test you could do something like: ```go defer test_structure.RunTestStage(t, "grab_logs", func() { if t.Failed() { takeElkMultiClusterLogSnapshot(t, examplesDir, awsRegion, "ubuntu") } }) ``` The code above will run at the very end of your test and grab a snapshot of all of the log descriptors you've defined specifically when your tests fail. We usually like to defer the logging snapshot right before we defer the terraform teardown. This way, if our tests fail, we are able to grab a snapshot of all the relevant logs and config files across our whole deployment! You can even take this a step further and pair this with your CI's artifact storage mechanism to have all of your logs and config files attached to your broken CI build when tests failed. For example, with CircleCI, you could do something like: ```yml - store_artifacts: path: /tmp/logs ``` Now, to get at your logs when your tests fail, you will just be able to click the links right in your CI. ![logs](https://user-images.githubusercontent.com/34349331/46639252-086e0a00-cb33-11e8-8dd2-9be73ca2af56.gif) ## Running this module manually 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Run `terraform init`. 1. Run `terraform apply`. 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `dep ensure` 1. `go test -v -run TestTerraformScpExample` ================================================ FILE: examples/terraform-asg-scp-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # PIN TERRAFORM VERSION TO >= 0.12 # The examples have been upgraded to 0.12 syntax # --------------------------------------------------------------------------------------------------------------------- terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN ASG WITH ONE INSTANCE THAT ALLOWS CONNECTIONS VIA SSH # See test/terraform_scp_example.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- provider "aws" { region = var.aws_region } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN ASG WITH ONE NODE TO TEST HOW WE CAN SCP FROM THE EC2 INSTANCE IN THIS ASG # --------------------------------------------------------------------------------------------------------------------- resource "aws_launch_template" "sample_launch_template" { name_prefix = var.instance_name image_id = data.aws_ami.ubuntu.id instance_type = var.instance_type vpc_security_group_ids = [aws_security_group.example.id] key_name = var.key_pair_name } resource "aws_autoscaling_group" "sample_asg" { vpc_zone_identifier = data.aws_subnets.default_subnets.ids desired_capacity = 1 max_size = 1 min_size = 1 launch_template { id = aws_launch_template.sample_launch_template.id version = "$Latest" } } # --------------------------------------------------------------------------------------------------------------------- # CREATE A SECURITY GROUP TO CONTROL WHAT REQUESTS CAN GO IN AND OUT OF THE EC2 INSTANCES # --------------------------------------------------------------------------------------------------------------------- resource "aws_security_group" "example" { name = var.instance_name egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } ingress { from_port = var.ssh_port to_port = var.ssh_port protocol = "tcp" # To keep this example simple, we allow incoming SSH requests from any IP. In real-world usage, you should only # allow SSH requests from trusted servers, such as a bastion host or VPN server. cidr_blocks = ["0.0.0.0/0"] } } # --------------------------------------------------------------------------------------------------------------------- # LOOK UP THE LATEST UBUNTU AMI # --------------------------------------------------------------------------------------------------------------------- data "aws_ami" "ubuntu" { most_recent = true owners = ["099720109477"] # Canonical filter { name = "virtualization-type" values = ["hvm"] } filter { name = "architecture" values = ["x86_64"] } filter { name = "image-type" values = ["machine"] } filter { name = "name" values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"] } } data "aws_vpc" "default" { default = true } data "aws_subnets" "default_subnets" { filter { name = "vpc-id" values = [data.aws_vpc.default.id] } filter { name = "defaultForAz" values = [true] } } ================================================ FILE: examples/terraform-asg-scp-example/outputs.tf ================================================ output "asg_name" { value = aws_autoscaling_group.sample_asg.name } ================================================ FILE: examples/terraform-asg-scp-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # AWS_ACCESS_KEY_ID # AWS_SECRET_ACCESS_KEY # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- variable "key_pair_name" { description = "The EC2 Key Pair to associate with the EC2 Instance for SSH access." type = string } # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "aws_region" { description = "The AWS region to deploy into" type = string default = "us-east-1" } variable "instance_name" { description = "The Name tag to set for the EC2 Instance." type = string default = "terratest-example" } variable "ssh_port" { description = "The port the EC2 Instance should listen on for SSH requests." type = number default = 22 } variable "instance_type" { description = "The EC2 instance type to run." type = string default = "t2.micro" } ================================================ FILE: examples/terraform-aws-dynamodb-example/README.md ================================================ # Terraform AWS DynamoDB Example This folder contains a simple Terraform module that deploys a [DynamoDB](https://aws.amazon.com/dynamodb/) table with server-side encryption, point in time recovery and a TTL (time to live) attribute to demonstrate how you can use Terratest to write automated tests for your AWS Terraform code. Check out [test/terraform_aws_dynamodb_example_test.go](/test/terraform_aws_dynamodb_example_test.go) to see how you can test this module and validate the configuration of the parameters and options. **WARNING**: This module and the automated tests for it deploy real resources into your AWS account which can cost you money. ## Running this module manually 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Set the AWS region you want to use as the environment variable `AWS_DEFAULT_REGION`. 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Run `terraform init`. 1. Run `terraform apply`. 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `dep ensure` 1. `go test -v -run TestTerraformAwsDynamoDBExample` ================================================ FILE: examples/terraform-aws-dynamodb-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # PIN TERRAFORM VERSION TO >= 0.12 # The examples have been upgraded to 0.12 syntax # --------------------------------------------------------------------------------------------------------------------- provider "aws" { region = var.region } terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" } # --------------------------------------------------------------------------------------------------------------------- # CREATE THE DYNAMODB TABLE # --------------------------------------------------------------------------------------------------------------------- resource "aws_dynamodb_table" "example" { name = var.table_name hash_key = "userId" range_key = "department" billing_mode = "PAY_PER_REQUEST" server_side_encryption { enabled = true } point_in_time_recovery { enabled = true } attribute { name = "userId" type = "S" } attribute { name = "department" type = "S" } ttl { enabled = true attribute_name = "expires" } tags = { Environment = "production" } } ================================================ FILE: examples/terraform-aws-dynamodb-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # --------------------------------------------------------------------------------------------------------------------- variable "region" { description = "The AWS region to deploy to" type = string } # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "table_name" { description = "The name to set for the dynamoDB table." type = string default = "terratest-example" } ================================================ FILE: examples/terraform-aws-ec2-windows-example/README.md ================================================ # Windows Instance Example This folder provides a Packer template that can be used to build an Amazon Machine Image (AMI) of a Windows 2016 Server that comes pre-installed with: - The [Chocolately package manager](https://chocolatey.org/why-chocolatey) which makes it easy to install additional software packages onto Windows - Git - Python 3 In addition, this folder provides an example of how to launch a Windows instance based off this AMI that can be connected to via a Remote Desktop Protocol (RDP) client for the purposes of testing software or experimentation. This setup is ideal for "hot-reloading" code that you're actively developing and testing it against the Windows server. You can develop your code in your usual environment, perhaps a Mac or Linux laptop, yet see your changes reflected on the Windows server in seconds, by sharing a folder from your development machine with the Windows server via the RDP client. ## Quick start Pre-requistes: - [Packer version v1.8.1 or newer](https://github.com/hashicorp/packer) - [Terraform v1.0 or newer](https://github.com/hashicorp/terraform) - An AWS account with valid security credentials First, we'll build the AMI for the Windows Instance. Change into the packer directory: `cd packer` In order to build an Amazon Machine Image with Packer, you'll need to export your AWS account credentials. You can export your AWS credentials as the environment variables `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`. For more information on authenticating to your AWS account from the command line, see our blog post [Authenticating to AWS with Environment Variables](https://blog.gruntwork.io/authenticating-to-aws-with-environment-variables-e793d6f6d02e). With your credentials properly exported, you can now run the packer build: `packer build build.pkr.hcl` This may take upwards of 25 minutes to complete, but generally completes in about 5 minutes. Keep an eye on your EC2 dashboard and ensure that you have selected the correct region and that you are on the AMI view. Once your AMI status has changed from "Pending" to "Available", you can copy your AMI ID. Create a new file named `terraform.tfvars` in this same directory and enter the following variables: ```hcl ami_id = "" region = "us-east-1" root_volume_size = 100 ``` Save the file. You're now ready to run terraform plan and check the output before proceeding: `terraform plan` Take a look at the plan output and ensure everything looks correct. You should see a single EC2 instance being created along with supporting resources such as a security group and security group rules. Once you're satisfied that the plan looks good, run terraform apply to create the infrastructure: `terraform apply --auto-approve` Once your resources apply successfully you'll see a similar output message containing the public IPv4 address of your Windows instance: `instance_ip = "35.84.139.82"` ================================================ FILE: examples/terraform-aws-ec2-windows-example/main.tf ================================================ # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # LAUNCH THE WINDOWS INSTANCE # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ terraform { # This module is now only being tested with Terraform 1.1.x. However, to make upgrading easier, we are setting 1.0.0 as the minimum version. required_version = ">= 1.0.0" required_providers { aws = { source = "hashicorp/aws" version = "< 4.0" } } } # --------------------------------------------------------------------------------------------------------------------- # CONFIGURE OUR AWS CONNECTION # --------------------------------------------------------------------------------------------------------------------- provider "aws" { # The AWS region in which all resources will be created region = var.region } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY INTO THE DEFAULT VPC AND SUBNETS # To keep this example simple, we are deploying into the Default VPC and its subnets. In real-world usage, you should # deploy into a custom VPC and private subnets. # --------------------------------------------------------------------------------------------------------------------- data "aws_vpc" "default" { default = true } data "aws_subnet_ids" "all" { vpc_id = data.aws_vpc.default.id } # --------------------------------------------------------------------------------------------------------------------- # CREATE A SECURITY GROUP TO ALLOW ACCESS TO THE RDS INSTANCE # --------------------------------------------------------------------------------------------------------------------- resource "aws_security_group" "windows_instance" { name = var.name vpc_id = data.aws_vpc.default.id } resource "aws_security_group_rule" "allow_rdp" { type = "ingress" security_group_id = aws_security_group.windows_instance.id from_port = "3389" to_port = "3389" protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } resource "aws_security_group_rule" "allow_egress" { type = "egress" security_group_id = aws_security_group.windows_instance.id from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } # --------------------------------------------------------------------------------------------------------------------- # LAUNCH THE WINDOWS INSTANCE # --------------------------------------------------------------------------------------------------------------------- resource "aws_instance" "instance" { ami = var.ami instance_type = var.instance_type vpc_security_group_ids = [aws_security_group.windows_instance.id] tags = { Name = var.instance_type } } ================================================ FILE: examples/terraform-aws-ec2-windows-example/outputs.tf ================================================ output "windows_instance_public_ip" { description = "The IPv4 address of the Windows instance. Enter this value into your RDP client when connecting to your instance." value = aws_instance.instance.public_ip } ================================================ FILE: examples/terraform-aws-ec2-windows-example/packer/build.pkr.hcl ================================================ variable "instance_type" { type = string description = "The EC2 instance size / type to launch" } variable "region" { type = string description = "The AWS region to deploy the Windows instance into" } data "amazon-ami" "windows_server_2016" { filters = { name = "Windows_Server-2016-English-Full-Base-*" root-device-type = "ebs" virtualization-type = "hvm" } most_recent = true owners = ["801119661308"] region = var.region } locals { build_version = "${legacy_isotime("2006.01.02.150405")}" } source "amazon-ebs" "windows_server_2016" { ami_name = "WIN2016-CUSTOM-${local.build_version}" associate_public_ip_address = true communicator = "winrm" instance_type = var.instance_type region = var.region source_ami = "${data.amazon-ami.windows_server_2016.id}" user_data_file = "${path.root}/scripts/bootstrap_windows.txt" winrm_timeout = "15m" winrm_password = "SuperS3cr3t!!!!" winrm_username = "Administrator" } build { sources = ["source.amazon-ebs.windows_server_2016"] # Install Chocolatey package manager, then install any Chocolatey packages defined in scripts/install_packages.ps1 provisioner "powershell" { scripts = ["${path.root}/scripts/install_chocolatey.ps1", "${path.root}/scripts/install_packages.ps1"] } provisioner "windows-restart" { restart_timeout = "35m" } } ================================================ FILE: examples/terraform-aws-ec2-windows-example/packer/scripts/bootstrap_windows.txt ================================================ # This script is adapted from: https://learn.hashicorp.com/tutorials/packer/aws-windows-image?in=packer/integrations # Set administrator password net user Administrator SuperS3cr3t!!!! wmic useraccount where "name='Administrator'" set PasswordExpires=FALSE # First, make sure WinRM can't be connected to netsh advfirewall firewall set rule name="Windows Remote Management (HTTP-In)" new enable=yes action=block # Delete any existing WinRM listeners winrm delete winrm/config/listener?Address=*+Transport=HTTP 2>$Null winrm delete winrm/config/listener?Address=*+Transport=HTTPS 2>$Null # Disable group policies which block basic authentication and unencrypted login Set-ItemProperty -Path HKLM:\Software\Policies\Microsoft\Windows\WinRM\Client -Name AllowBasic -Value 1 Set-ItemProperty -Path HKLM:\Software\Policies\Microsoft\Windows\WinRM\Client -Name AllowUnencryptedTraffic -Value 1 Set-ItemProperty -Path HKLM:\Software\Policies\Microsoft\Windows\WinRM\Service -Name AllowBasic -Value 1 Set-ItemProperty -Path HKLM:\Software\Policies\Microsoft\Windows\WinRM\Service -Name AllowUnencryptedTraffic -Value 1 # Create a new WinRM listener and configure winrm create winrm/config/listener?Address=*+Transport=HTTP winrm set winrm/config/winrs '@{MaxMemoryPerShellMB="0"}' winrm set winrm/config '@{MaxTimeoutms="7200000"}' winrm set winrm/config/service '@{AllowUnencrypted="true"}' winrm set winrm/config/service '@{MaxConcurrentOperationsPerUser="12000"}' winrm set winrm/config/service/auth '@{Basic="true"}' winrm set winrm/config/client/auth '@{Basic="true"}' # Configure UAC to allow privilege elevation in remote shells $Key = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' $Setting = 'LocalAccountTokenFilterPolicy' Set-ItemProperty -Path $Key -Name $Setting -Value 1 -Force # Configure and restart the WinRM Service; Enable the required firewall exception Stop-Service -Name WinRM Set-Service -Name WinRM -StartupType Automatic netsh advfirewall firewall set rule name="Windows Remote Management (HTTP-In)" new action=allow localip=any remoteip=any Start-Service -Name WinRM ================================================ FILE: examples/terraform-aws-ec2-windows-example/packer/scripts/install_chocolatey.ps1 ================================================ Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) ================================================ FILE: examples/terraform-aws-ec2-windows-example/packer/scripts/install_packages.ps1 ================================================ choco install -y python3 choco install -y git ================================================ FILE: examples/terraform-aws-ec2-windows-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # AWS_ACCESS_KEY_ID # AWS_SECRET_ACCESS_KEY # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- variable "region" { description = "The AWS region in which all resources will be created" type = string default = "us-west-2" } variable "ami" { description = "The ID of the AMI to run on the Windows instance." type = string } # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "name" { description = "The name of the Windows instance" type = string default = "windows_test_instance" } variable "instance_type" { description = "The instance type to deploy." type = string default = "t3.small" } variable "root_volume_size" { description = "The size in GiB of the root volume. Must match the root volume size of the target AMI." type = number default = 30 } ================================================ FILE: examples/terraform-aws-ecs-example/README.md ================================================ # Terraform AWS ECS Example This folder contains a simple Terraform module that deploys a simple ECS service in [AWS](https://aws.amazon.com/) to demonstrate how you can use Terratest to write automated tests for your AWS Terraform code. This module registers a task definition with [AWS Fargate](https://aws.amazon.com/fargate/) launch type and associates it with a [service](https://docs.aws.amazon.com/AmazonECS/latest/userguide/ecs_services.html) to run and maintain a specified number of instances. Check out [test/terraform_aws_ecs_example_test.go](/test/terraform_aws_ecs_example_test.go) to see how you can write automated tests for this module and validate the configuration of the parameters and options. **WARNING**: This module and the automated tests for it deploy real resources into your AWS account which can cost you money. ## Running this module manually 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Set the AWS region you want to use as the environment variable `AWS_DEFAULT_REGION`. 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Run `terraform init`. 1. Run `terraform apply`. 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `dep ensure` 1. `go test -v -run TestTerraformAwsEcsExample` ================================================ FILE: examples/terraform-aws-ecs-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # PIN TERRAFORM VERSION TO >= 0.12 # The examples have been upgraded to 0.12 syntax # --------------------------------------------------------------------------------------------------------------------- provider "aws" { region = var.region } terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY INTO THE DEFAULT VPC AND SUBNETS # To keep this example simple, we are deploying into the Default VPC and its subnets. In real-world usage, you should # deploy into a custom VPC and private subnets. # --------------------------------------------------------------------------------------------------------------------- data "aws_vpc" "default" { default = true } data "aws_subnets" "all" { filter { name = "vpc-id" values = [data.aws_vpc.default.id] } } # --------------------------------------------------------------------------------------------------------------------- # CREATE THE ECS CLUSTER # --------------------------------------------------------------------------------------------------------------------- resource "aws_ecs_cluster" "example" { name = var.cluster_name } # --------------------------------------------------------------------------------------------------------------------- # CREATE THE ECS SERVICE AND ITS TASK DEFINITION # --------------------------------------------------------------------------------------------------------------------- resource "aws_ecs_service" "example" { name = var.service_name cluster = aws_ecs_cluster.example.arn task_definition = aws_ecs_task_definition.example.arn desired_count = 0 launch_type = "FARGATE" network_configuration { subnets = data.aws_subnets.all.ids } } resource "aws_ecs_task_definition" "example" { family = "terratest" network_mode = "awsvpc" cpu = 256 memory = 512 requires_compatibilities = ["FARGATE"] execution_role_arn = aws_iam_role.execution.arn container_definitions = <<-JSON [ { "image": "terraterst-example", "name": "terratest", "networkMode": "awsvpc" } ] JSON } # --------------------------------------------------------------------------------------------------------------------- # CREATE THE ECS TASK EXECUTION ROLE AND ATTACH APPROPRIATE AWS MANAGED POLICY # --------------------------------------------------------------------------------------------------------------------- resource "aws_iam_role" "execution" { name = "${var.cluster_name}-ecs-execution" assume_role_policy = data.aws_iam_policy_document.assume-execution.json } resource "aws_iam_role_policy_attachment" "execution" { role = aws_iam_role.execution.id policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" } data "aws_iam_policy_document" "assume-execution" { statement { effect = "Allow" actions = ["sts:AssumeRole"] principals { type = "Service" identifiers = ["ecs-tasks.amazonaws.com"] } } } ================================================ FILE: examples/terraform-aws-ecs-example/outputs.tf ================================================ output "task_definition" { value = aws_ecs_task_definition.example.arn } ================================================ FILE: examples/terraform-aws-ecs-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # AWS_ACCESS_KEY_ID # AWS_SECRET_ACCESS_KEY # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- variable "region" { description = "The AWS region to deploy to" type = string } # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "cluster_name" { description = "The name to set for the ECS cluster." type = string default = "terratest-example" } variable "service_name" { description = "The name to set for the ECS service." type = string default = "terratest-example" } ================================================ FILE: examples/terraform-aws-example/README.md ================================================ # Terraform AWS Example This folder contains a simple Terraform module that deploys resources in [AWS](https://aws.amazon.com/) to demonstrate how you can use Terratest to write automated tests for your AWS Terraform code. This module deploys an [EC2 Instance](https://aws.amazon.com/ec2/) and gives that Instance a `Name` tag with the value specified in the `instance_name` variable. Check out [test/terraform_aws_example_test.go](/test/terraform_aws_example_test.go) to see how you can write automated tests for this module. Note that the EC2 Instance in this module doesn't actually do anything; it just runs a Vanilla Ubuntu 16.04 AMI for demonstration purposes. For slightly more complicated, real-world examples of Terraform modules, see [terraform-http-example](/examples/terraform-http-example) and [terraform-ssh-example](/examples/terraform-ssh-example). **WARNING**: This module and the automated tests for it deploy real resources into your AWS account which can cost you money. The resources are all part of the [AWS Free Tier](https://aws.amazon.com/free/), so if you haven't used that up, it should be free, but you are completely responsible for all AWS charges. ## Running this module manually 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Set the AWS region you want to use as the environment variable `AWS_DEFAULT_REGION`. 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Run `terraform init`. 1. Run `terraform apply`. 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `dep ensure` 1. `go test -v -run TestTerraformAwsExample` ================================================ FILE: examples/terraform-aws-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # PIN TERRAFORM VERSION TO >= 0.12 # The examples have been upgraded to 0.12 syntax # --------------------------------------------------------------------------------------------------------------------- terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN EC2 INSTANCE RUNNING UBUNTU # See test/terraform_aws_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- resource "aws_instance" "example" { ami = data.aws_ami.ubuntu.id instance_type = var.instance_type tags = { Name = var.instance_name } } # --------------------------------------------------------------------------------------------------------------------- # LOOK UP THE LATEST UBUNTU AMI # --------------------------------------------------------------------------------------------------------------------- data "aws_ami" "ubuntu" { most_recent = true owners = ["099720109477"] # Canonical filter { name = "virtualization-type" values = ["hvm"] } filter { name = "architecture" values = ["x86_64"] } filter { name = "image-type" values = ["machine"] } filter { name = "name" values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"] } } ================================================ FILE: examples/terraform-aws-example/outputs.tf ================================================ output "instance_id" { value = aws_instance.example.id } ================================================ FILE: examples/terraform-aws-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # AWS_ACCESS_KEY_ID # AWS_SECRET_ACCESS_KEY # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "instance_name" { description = "The Name tag to set for the EC2 Instance." type = string default = "terratest-example" } variable "instance_type" { description = "The EC2 instance type to run." type = string default = "t2.micro" } ================================================ FILE: examples/terraform-aws-hello-world-example/README.md ================================================ # Terraform AWS "Hello, World" Example This folder contains a simple Terraform module that deploys resources in [AWS](https://aws.amazon.com/) to demonstrate how you can use Terratest to write automated tests for your AWS Terraform code. This module deploys an [EC2 Instance](https://aws.amazon.com/ec2/) which runs a web server that responds with "Hello, World!" on port 8080. Check out [test/terraform_aws_hello_world_example_test.go](/test/terraform_aws_hello_world_example_test.go) to see how you can write automated tests for this module. **WARNING**: This module and the automated tests for it deploy real resources into your AWS account which can cost you money. The resources are all part of the [AWS Free Tier](https://aws.amazon.com/free/), so if you haven't used that up, it should be free, but you are completely responsible for all AWS charges. ## Running this module manually 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Run `terraform init`. 1. Run `terraform apply`. 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `go test -v -run TestTerraformAwsHelloWorldExample` ================================================ FILE: examples/terraform-aws-hello-world-example/main.tf ================================================ terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" } provider "aws" { region = "us-east-2" } # website::tag::1:: Deploy an EC2 Instance. resource "aws_instance" "example" { # website::tag::2:: Run an Ubuntu 18.04 AMI on the EC2 instance. ami = "ami-0d5d9d301c853a04a" instance_type = "t2.micro" vpc_security_group_ids = [aws_security_group.instance.id] # website::tag::3:: When the instance boots, start a web server on port 8080 that responds with "Hello, World!". user_data = < index.html nohup busybox httpd -f -p 8080 & EOF } # website::tag::4:: Allow the instance to receive requests on port 8080. resource "aws_security_group" "instance" { ingress { from_port = 8080 to_port = 8080 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } } # website::tag::5:: Output the instance's public IP address. output "public_ip" { value = aws_instance.example.public_ip } ================================================ FILE: examples/terraform-aws-lambda-example/.gitignore ================================================ *.zip ================================================ FILE: examples/terraform-aws-lambda-example/README.md ================================================ # Terraform Lambda Example This folder contains a Terraform module to demonstrate how you can use Terratest to deploy a lambda function for your Terraform code. This module takes in an input variable called `function_name`, and uses the function name as an identifier for the lambda and associated resources (e.g. IAM role). Check out [test/terraform_aws_lambda_example_test.go](/test/terraform_aws_lambda_example_test.go) to see how you can write automated tests for this simple module. The function that this module creates is a simple one whose input can cause it to error or echo messages it receives. ## Running this module manually 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Run `terraform init`. 1. Run `terraform apply`. 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `dep ensure` 1. `go test -v -run TestTerraformAwsLambdaExample` ================================================ FILE: examples/terraform-aws-lambda-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # AWS LAMBDA TERRAFORM EXAMPLE # See test/terraform_aws_lambda_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- provider "aws" { region = var.region } terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" } provider "archive" { version = "1.3" } data "archive_file" "zip" { type = "zip" source_dir = "${path.module}/src" output_path = "${path.module}/${var.function_name}.zip" } resource "aws_lambda_function" "lambda" { filename = data.archive_file.zip.output_path source_code_hash = data.archive_file.zip.output_base64sha256 function_name = var.function_name role = aws_iam_role.lambda.arn handler = "bootstrap" runtime = "provided.al2023" } resource "aws_iam_role" "lambda" { name = var.function_name assume_role_policy = data.aws_iam_policy_document.policy.json } data "aws_iam_policy_document" "policy" { statement { actions = ["sts:AssumeRole"] principals { type = "Service" identifiers = ["lambda.amazonaws.com"] } } } ================================================ FILE: examples/terraform-aws-lambda-example/src/README.md ================================================ # AWS Lambda Function Handler Source The lambda executable `handler` was built using ``` shell go get github.com/aws/aws-lambda-go/lambda GOOS=linux GOARCH=amd64 go build -tags lambda.norpc -o bootstrap . ``` ================================================ FILE: examples/terraform-aws-lambda-example/src/bootstrap.go ================================================ package main import ( "context" "fmt" "github.com/aws/aws-lambda-go/lambda" ) type Event struct { ShouldFail bool `json:"ShouldFail"` Echo string `json:"Echo"` } // HandleRequest Fails if ShouldFail is `true`, otherwise echos the input. func HandleRequest(ctx context.Context, evnt *Event) (string, error) { if evnt == nil { return "", fmt.Errorf("received nil event") } if evnt.ShouldFail { return "", fmt.Errorf("failed to handle %#v", evnt) } return evnt.Echo, nil } func main() { lambda.Start(HandleRequest) } ================================================ FILE: examples/terraform-aws-lambda-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # AWS_ACCESS_KEY_ID # AWS_SECRET_ACCESS_KEY # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- variable "region" { description = "The AWS region to deploy to" type = string } variable "function_name" { description = "The name of the function to provision" } # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- ================================================ FILE: examples/terraform-aws-network-example/README.md ================================================ # Terraform AWS Network Example This folder contains a Terraform module that deploys a simple network setup to demonstrate how you can use Terratest to write automated tests for your AWS Terraform code. This module deploys two subnets within one availability zone. One subnet is public - it has a route to an internet gateway. The other subnet is private. There is a NAT gateway deployed and configured for it. Check out [test/terraform_aws_network_example_test.go](/test/terraform_aws_network_example_test.go) to see how you can write automated tests for this module and verify the basic parameters of the VPC and subnets. **WARNING**: This module and the automated tests for it deploy real resources into your AWS account which can cost you money. The resources are all part of the [AWS Free Tier](https://aws.amazon.com/rds/free/), so if you haven't used that up, it should be free, but you are completely responsible for all AWS charges. ## Running this module manually 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Set the AWS region you want to use as the environment variable `AWS_DEFAULT_REGION`. 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Run `terraform init`. 1. Run `terraform apply`. 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `dep ensure` 1. `go test -v -run TestTerraformAwsNetworkExample` ================================================ FILE: examples/terraform-aws-network-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # PIN TERRAFORM VERSION TO >= 0.12 # The examples have been upgraded to 0.12 syntax # --------------------------------------------------------------------------------------------------------------------- terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" } data "aws_availability_zones" "available" { state = "available" } provider "aws" { region = var.aws_region } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A SIMPLE NETWORK # The network has an internet gateway and two subnets - private and public - in the same availability zone. # --------------------------------------------------------------------------------------------------------------------- resource "aws_vpc" "main" { cidr_block = var.main_vpc_cidr tags = { Name = var.tag_name } } resource "aws_internet_gateway" "main_gateway" { vpc_id = aws_vpc.main.id tags = { Name = var.tag_name } } resource "aws_subnet" "private" { vpc_id = aws_vpc.main.id cidr_block = var.private_subnet_cidr map_public_ip_on_launch = false tags = { Name = var.tag_name } availability_zone = data.aws_availability_zones.available.names[0] } resource "aws_subnet" "public" { vpc_id = aws_vpc.main.id cidr_block = var.public_subnet_cidr map_public_ip_on_launch = true tags = { Name = var.tag_name } availability_zone = data.aws_availability_zones.available.names[0] } # --------------------------------------------------------------------------------------------------------------------- # CREATE AND ATTACH A ROUTING TABLE FOR THE PUBLIC NETWORK # --------------------------------------------------------------------------------------------------------------------- resource "aws_route_table" "public" { vpc_id = aws_vpc.main.id route { cidr_block = "91.189.0.0/24" gateway_id = aws_internet_gateway.main_gateway.id } tags = { Name = var.tag_name } } resource "aws_route_table_association" "public" { subnet_id = aws_subnet.public.id route_table_id = aws_route_table.public.id } # --------------------------------------------------------------------------------------------------------------------- # CREATE NAT GATEWAY FOR THE PRIVATE SUBNET # --------------------------------------------------------------------------------------------------------------------- resource "aws_eip" "nat" { domain = "vpc" } resource "aws_nat_gateway" "nat" { allocation_id = aws_eip.nat.id subnet_id = aws_subnet.public.id depends_on = [aws_internet_gateway.main_gateway] } # --------------------------------------------------------------------------------------------------------------------- # CREATE AND ATTACH A ROUTING TABLE FOR THE PRIVATE NETWORK # --------------------------------------------------------------------------------------------------------------------- resource "aws_route_table" "private" { vpc_id = aws_vpc.main.id route { cidr_block = "0.0.0.0/0" nat_gateway_id = aws_nat_gateway.nat.id } tags = { Name = var.tag_name } } resource "aws_route_table_association" "private" { subnet_id = aws_subnet.private.id route_table_id = aws_route_table.private.id } ================================================ FILE: examples/terraform-aws-network-example/output.tf ================================================ output "main_vpc_id" { value = aws_vpc.main.id description = "The main VPC id" } output "public_subnet_id" { value = aws_subnet.public.id description = "The public subnet id" } output "private_subnet_id" { value = aws_subnet.private.id description = "The private subnet id" } ================================================ FILE: examples/terraform-aws-network-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # AWS_ACCESS_KEY_ID # AWS_SECRET_ACCESS_KEY # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- variable "main_vpc_cidr" { description = "The CIDR of the main VPC" type = string } variable "public_subnet_cidr" { description = "The CIDR of public subnet" type = string } variable "private_subnet_cidr" { description = "The CIDR of the private subnet" type = string } # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "aws_region" { description = "The AWS region to deploy into" type = string default = "us-east-1" } variable "tag_name" { description = "A name used to tag the resource" type = string default = "terraform-network-example" } ================================================ FILE: examples/terraform-aws-rds-example/README.md ================================================ # Terraform AWS RDS Example This folder contains a simple Terraform module that deploys a database instance (MySQL by default) in [AWS](https://aws.amazon.com/) to demonstrate how you can use Terratest to write automated tests for your AWS Terraform code. This module deploys an [RDS Instance](https://aws.amazon.com/rds/) and associates it with an option group and parameter group to customize it. Check out [test/terraform_aws_rds_example_test.go](/test/terraform_aws_rds_example_test.go) to see how you can write automated tests for this module and validate the configuration of the parameters and options. This module does not use the database instance created in any way. It can be used though to validate any combination of inputs passed while creating database instances in AWS RDS. Hence the plain text simple password used here should not have any security implications. **WARNING**: This module and the automated tests for it deploy real resources into your AWS account which can cost you money. The resources are all part of the [AWS Free Tier](https://aws.amazon.com/rds/free/), so if you haven't used that up, it should be free, but you are completely responsible for all AWS charges. ## Running this module manually 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Set the AWS region you want to use as the environment variable `AWS_DEFAULT_REGION`. 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Run `terraform init`. 1. Run `terraform apply`. 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `dep ensure` 1. `go test -v -run TestTerraformAwsRdsExample` ================================================ FILE: examples/terraform-aws-rds-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # PIN TERRAFORM VERSION TO >= 0.12 # The examples have been upgraded to 0.12 syntax # --------------------------------------------------------------------------------------------------------------------- provider "aws" { region = var.region } terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.31 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.31" required_providers { aws = { source = "hashicorp/aws" version = ">= 4.61.0, < 5.0.0" } } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY INTO THE DEFAULT VPC AND SUBNETS # To keep this example simple, we are deploying into the Default VPC and its subnets. In real-world usage, you should # deploy into a custom VPC and private subnets. Given the subnet group needs to span multiple AZs and hence subnets we # have deployed it across all the subnets of the default VPC. # --------------------------------------------------------------------------------------------------------------------- data "aws_vpc" "default" { default = true } data "aws_subnets" "all" { filter { name = "vpc-id" values = [data.aws_vpc.default.id] } } # --------------------------------------------------------------------------------------------------------------------- # CREATE AN SUBNET GROUP ACROSS ALL THE SUBNETS OF THE DEFAULT ASG TO HOST THE RDS INSTANCE # --------------------------------------------------------------------------------------------------------------------- resource "aws_db_subnet_group" "example" { name = var.name subnet_ids = data.aws_subnets.all.ids tags = { Name = var.name } } # --------------------------------------------------------------------------------------------------------------------- # CREATE A CUSTOM PARAMETER GROUP AND AN OPTION GROUP FOR CONFIGURABILITY # --------------------------------------------------------------------------------------------------------------------- resource "aws_db_option_group" "example" { name = var.name engine_name = var.engine_name major_engine_version = var.major_engine_version tags = { Name = var.name } dynamic "option" { for_each = var.engine_name == "mysql" ? [1] : [] content { option_name = "MARIADB_AUDIT_PLUGIN" option_settings { name = "SERVER_AUDIT_EVENTS" value = "CONNECT" } } } } resource "aws_db_parameter_group" "example" { name = var.name family = var.family tags = { Name = var.name } dynamic "parameter" { for_each = var.engine_name == "mysql" ? [1] : [] content { name = "general_log" value = "0" } } } # --------------------------------------------------------------------------------------------------------------------- # CREATE A SECURITY GROUP TO ALLOW ACCESS TO THE RDS INSTANCE # --------------------------------------------------------------------------------------------------------------------- resource "aws_security_group" "db_instance" { name = var.name vpc_id = data.aws_vpc.default.id } resource "aws_security_group_rule" "allow_db_access" { type = "ingress" from_port = var.port to_port = var.port protocol = "tcp" security_group_id = aws_security_group.db_instance.id cidr_blocks = ["0.0.0.0/0"] } # --------------------------------------------------------------------------------------------------------------------- # CREATE THE DATABASE INSTANCE # --------------------------------------------------------------------------------------------------------------------- resource "aws_db_instance" "example" { identifier = var.name engine = var.engine_name engine_version = var.engine_version port = var.port db_name = var.database_name username = var.username password = var.password instance_class = var.instance_class allocated_storage = var.allocated_storage skip_final_snapshot = true license_model = var.license_model db_subnet_group_name = aws_db_subnet_group.example.id vpc_security_group_ids = [aws_security_group.db_instance.id] publicly_accessible = true parameter_group_name = aws_db_parameter_group.example.id option_group_name = aws_db_option_group.example.id tags = { Name = var.name } } ================================================ FILE: examples/terraform-aws-rds-example/outputs.tf ================================================ output "db_instance_id" { value = aws_db_instance.example.id } ================================================ FILE: examples/terraform-aws-rds-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # AWS_ACCESS_KEY_ID # AWS_SECRET_ACCESS_KEY # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # Given these are credentials, security of the values should be considered. # --------------------------------------------------------------------------------------------------------------------- variable "region" { description = "The AWS region to deploy to" type = string } variable "username" { description = "Master username of the DB" type = string } variable "password" { description = "Master password of the DB" type = string } variable "database_name" { description = "Name of the database to be created" type = string } # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "name" { description = "Name of the database" type = string default = "terratest-example" } variable "engine_name" { description = "Name of the database engine" type = string default = "mysql" } variable "family" { description = "Family of the database" type = string default = "mysql5.7" } variable "port" { description = "Port which the database should run on" type = number default = 3306 } variable "major_engine_version" { description = "MAJOR.MINOR version of the DB engine" type = string default = "5.7" } variable "engine_version" { description = "Version of the database to be launched" default = "5.7.21" type = string } variable "allocated_storage" { description = "Disk space to be allocated to the DB instance" type = number default = 5 } variable "license_model" { description = "License model of the DB instance" type = string default = "general-public-license" } variable "instance_class" { description = "Instance class to be used to run the database" type = string default = "db.t2.micro" } ================================================ FILE: examples/terraform-aws-s3-example/README.md ================================================ # Terraform AWS S3 Example This folder contains a simple Terraform module that deploys resources in [AWS](https://aws.amazon.com/) to demonstrate how you can use Terratest to write automated tests for your AWS Terraform code. This module deploys 2 [S3 Buckets](https://aws.amazon.com/s3/) - one S3 Bucket with logging and versioning enabled, and another "targetBucket" one to serve as a logging location for the first S3 Bucket. This module gives both Buckets a `Name` & `Environment` tag with the value specified in the `tag_bucket_name` and `tag_bucket_environment` variables, respectively. This module also contains a terraform variable that will create a basic bucket policy that will restrict the "origin" bucket to only accept SSL connections. Check out [test/terraform_aws_s3_example_test.go](/test/terraform_aws_s3_example_test.go) to see how you can write automated tests for this module. Note that the S3 Buckets in this module will not contain any actual objects/files after creation; they will only contain a versioning and logging configuration, as well as tags. For slightly more complicated, real-world examples of Terraform modules (with other AWS services), see [terraform-http-example](/examples/terraform-http-example) and [terraform-ssh-example](/examples/terraform-ssh-example). **WARNING**: This module and the automated tests for it deploy real resources into your AWS account which can cost you money. The resources are all part of the [AWS Free Tier](https://aws.amazon.com/free/), so if you haven't used that up, it should be free, but you are completely responsible for all AWS charges. ## Running this module manually 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Set the AWS region you want to use as the environment variable `AWS_DEFAULT_REGION`. 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Run `terraform init`. 1. Run `terraform apply`. 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `dep ensure` 1. `go test -v -run TestTerraformAwsS3Example` ================================================ FILE: examples/terraform-aws-s3-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # PIN TERRAFORM VERSION TO >= 0.12 # The examples have been upgraded to 0.12 syntax # --------------------------------------------------------------------------------------------------------------------- provider "aws" { region = var.region } terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" required_providers { aws = { source = "hashicorp/aws" # https://github.com/hashicorp/terraform-provider-aws/issues/33478 version = "5.16.0" } } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A S3 BUCKET WITH VERSIONING ENABLED INCLUDING TAGS # See test/terraform_aws_s3_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- # Deploy and configure test S3 bucket with versioning and access log resource "aws_s3_bucket" "test_bucket" { bucket = "${local.aws_account_id}-${var.tag_bucket_name}" tags = { Name = var.tag_bucket_name Environment = var.tag_bucket_environment } } resource "aws_s3_bucket_logging" "test_bucket" { bucket = aws_s3_bucket.test_bucket.id target_bucket = aws_s3_bucket.test_bucket_logs.id target_prefix = "TFStateLogs/" } resource "aws_s3_bucket_versioning" "test_bucket" { bucket = aws_s3_bucket.test_bucket.id versioning_configuration { status = "Enabled" } } resource "aws_s3_bucket_ownership_controls" "test_bucket" { bucket = aws_s3_bucket.test_bucket.id rule { object_ownership = "ObjectWriter" } depends_on = [aws_s3_bucket.test_bucket] } resource "aws_s3_bucket_acl" "test_bucket" { bucket = aws_s3_bucket.test_bucket.id acl = "private" depends_on = [aws_s3_bucket_ownership_controls.test_bucket] } # Deploy S3 bucket to collect access logs for test bucket resource "aws_s3_bucket" "test_bucket_logs" { bucket = "${local.aws_account_id}-${var.tag_bucket_name}-logs" tags = { Name = "${local.aws_account_id}-${var.tag_bucket_name}-logs" Environment = var.tag_bucket_environment } force_destroy = true } resource "aws_s3_bucket_ownership_controls" "test_bucket_logs" { bucket = aws_s3_bucket.test_bucket_logs.id rule { object_ownership = "ObjectWriter" } depends_on = [aws_s3_bucket.test_bucket_logs] } resource "aws_s3_bucket_acl" "test_bucket_logs" { bucket = aws_s3_bucket.test_bucket_logs.id acl = "log-delivery-write" depends_on = [aws_s3_bucket_ownership_controls.test_bucket_logs] } # Configure bucket access policies resource "aws_s3_bucket_policy" "bucket_access_policy" { count = var.with_policy ? 1 : 0 bucket = aws_s3_bucket.test_bucket.id policy = data.aws_iam_policy_document.s3_bucket_policy.json } data "aws_iam_policy_document" "s3_bucket_policy" { statement { effect = "Allow" principals { # TF-UPGRADE-TODO: In Terraform v0.10 and earlier, it was sometimes necessary to # force an interpolation expression to be interpreted as a list by wrapping it # in an extra set of list brackets. That form was supported for compatibility in # v0.11, but is no longer supported in Terraform v0.12. # # If the expression in the following list itself returns a list, remove the # brackets to avoid interpretation as a list of lists. If the expression # returns a single list item then leave it as-is and remove this TODO comment. identifiers = [local.aws_account_id] type = "AWS" } actions = ["*"] resources = ["${aws_s3_bucket.test_bucket.arn}/*"] } statement { effect = "Deny" principals { identifiers = ["*"] type = "AWS" } actions = ["*"] resources = ["${aws_s3_bucket.test_bucket.arn}/*"] condition { test = "Bool" variable = "aws:SecureTransport" values = [ "false", ] } } } # --------------------------------------------------------------------------------------------------------------------- # LOCALS # Used to represent any data that requires complex expressions/interpolations # --------------------------------------------------------------------------------------------------------------------- data "aws_caller_identity" "current" { } locals { aws_account_id = data.aws_caller_identity.current.account_id } ================================================ FILE: examples/terraform-aws-s3-example/outputs.tf ================================================ output "bucket_id" { value = aws_s3_bucket.test_bucket.id } output "bucket_arn" { value = aws_s3_bucket.test_bucket.arn } output "logging_target_bucket" { value = aws_s3_bucket_logging.test_bucket.target_bucket } output "logging_target_prefix" { value = aws_s3_bucket_logging.test_bucket.target_prefix } ================================================ FILE: examples/terraform-aws-s3-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # AWS_ACCESS_KEY_ID # AWS_SECRET_ACCESS_KEY # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- variable "region" { description = "The AWS region to deploy to" type = string } # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "with_policy" { description = "If set to `true`, the bucket will be created with a bucket policy." type = bool default = false } variable "tag_bucket_name" { description = "The Name tag to set for the S3 Bucket." type = string default = "Test Bucket" } variable "tag_bucket_environment" { description = "The Environment tag to set for the S3 Bucket." type = string default = "Test" } ================================================ FILE: examples/terraform-aws-ssm-example/README.md ================================================ # Terraform AWS SSM Example This folder contains a simple Terraform module that deploys an instance in [AWS](https://aws.amazon.com/) and registers it in the AWS SSM Catalog to demonstrate how you can use Terratest to write automated tests for your AWS Terraform code. Check out [test/terraform_aws_ssm_example_test.go](/test/terraform_aws_ssm_example_test.go) to see how you can write automated tests for this module. **WARNING**: This module and the automated tests for it deploy real resources into your AWS account which can cost you money. The resources are all part of the [AWS Free Tier](https://aws.amazon.com/free/), so if you haven't used that up, it should be free, but you are completely responsible for all AWS charges. ## Running this module manually 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Run `terraform init`. 1. Run `terraform apply`. 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `go test -v -run TestTerraformAwsSsmExample` ================================================ FILE: examples/terraform-aws-ssm-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # PIN TERRAFORM VERSION TO >= 0.12 # The examples have been upgraded to 0.12 syntax # --------------------------------------------------------------------------------------------------------------------- terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" } provider "aws" { region = var.region } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN INSTANCE WITH SSM SUPPORT # --------------------------------------------------------------------------------------------------------------------- data "aws_iam_policy_document" "example" { version = "2012-10-17" statement { sid = "1" actions = [ "sts:AssumeRole", ] principals { type = "Service" identifiers = ["ec2.amazonaws.com"] } } } resource "aws_iam_role" "example" { name_prefix = "example" assume_role_policy = data.aws_iam_policy_document.example.json } resource "aws_iam_role_policy_attachment" "example_ssm" { role = aws_iam_role.example.name policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEC2RoleforSSM" } resource "aws_iam_instance_profile" "example" { name_prefix = "example" role = aws_iam_role.example.name } data "aws_ami" "amazon_linux_2" { most_recent = true owners = ["amazon"] filter { name = "name" values = ["amzn2-ami-hvm*"] } } # --------------------------------------------------------------------------------------------------------------------- # The instance must have a public ip to be able to contact AWS SSM # --------------------------------------------------------------------------------------------------------------------- resource "aws_instance" "example" { ami = data.aws_ami.amazon_linux_2.id instance_type = var.instance_type associate_public_ip_address = true iam_instance_profile = aws_iam_instance_profile.example.name } ================================================ FILE: examples/terraform-aws-ssm-example/outputs.tf ================================================ output "instance_id" { value = aws_instance.example.id } ================================================ FILE: examples/terraform-aws-ssm-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # AWS_ACCESS_KEY_ID # AWS_SECRET_ACCESS_KEY # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "region" { type = string description = "The AWS region to deploy into" default = "us-east-1" } variable "instance_type" { description = "The EC2 instance type to run." type = string default = "t2.micro" } ================================================ FILE: examples/terraform-backend-example/README.md ================================================ # Terraform Backend Example This folder contains a simple Terraform module that demonstrates how you can use Terratest to configure a [Terraform Backend](https://www.terraform.io/docs/backends/) at test time. This module doesn't really do anything other than set up S3 as a backend, and allow Terratest to fill in that backend's configuration. Check out [test/terraform_backend_example_test.go](/test/terraform_backend_example_test.go) to see how you can write automated tests for this module. **WARNING**: This module and the automated tests for it deploy real resources into your AWS account which can cost you money. The resources are all part of the [AWS Free Tier](https://aws.amazon.com/free/), so if you haven't used that up, it should be free, but you are completely responsible for all AWS charges. ## Running this module manually 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Set the AWS region you want to use as the environment variable `AWS_DEFAULT_REGION`. 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Run `terraform init`. 1. Run `terraform apply`. 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `dep ensure` 1. `go test -v -run TestTerraformBackendExample` ================================================ FILE: examples/terraform-backend-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # AN EXAMPLE OF HOW TO CONFIGURE A TERRAFORM BACKEND WITH TERRATEST # Note that the example code here doesn't do anything other than set up a backend that Terratest will configure. # --------------------------------------------------------------------------------------------------------------------- terraform { # Leave the config for this backend unspecified so Terraform can fill it in. This is known as "partial configuration": # https://www.terraform.io/docs/backends/config.html#partial-configuration backend "s3" {} # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" } variable "foo" { description = "Some data to store as an output of this module" type = string } output "foo" { value = var.foo } ================================================ FILE: examples/terraform-basic-example/README.md ================================================ # Terraform Basic Example This folder contains a very simple Terraform module to demonstrate how you can use Terratest to write automated tests for your Terraform code. This module takes in an input variable called `example`, renders it using a `template_file` data source, and outputs the result in an output variable called `example`. Check out [test/terraform_basic_example_test.go](/test/terraform_basic_example_test.go) to see how you can write automated tests for this simple module. Note that this module doesn't do anything useful; it's just here to demonstrate the simplest usage pattern for Terratest. For a slightly more complicated, real-world example of a Terraform module and the corresponding tests, see [terraform-aws-example](/examples/terraform-aws-example). ## Running this module manually 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Run `terraform init`. 1. Run `terraform apply`. 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `dep ensure` 1. `go test -v -run TestTerraformBasicExample` ================================================ FILE: examples/terraform-basic-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # PIN TERRAFORM VERSION TO >= 0.12 # The examples have been upgraded to 0.12 syntax # --------------------------------------------------------------------------------------------------------------------- terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" } # --------------------------------------------------------------------------------------------------------------------- # BASIC TERRAFORM EXAMPLE # See test/terraform_aws_example.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- resource "local_file" "example" { content = "${var.example} + ${var.example2}" filename = "example.txt" } resource "local_file" "example2" { content = var.example2 filename = "example2.txt" } ================================================ FILE: examples/terraform-basic-example/outputs.tf ================================================ output "example" { value = var.example } output "example2" { value = var.example2 } output "example_list" { value = var.example_list } output "example_map" { value = var.example_map } output "example_any" { value = var.example_any } ================================================ FILE: examples/terraform-basic-example/varfile.tfvars ================================================ example2 = "test" ================================================ FILE: examples/terraform-basic-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # AWS_ACCESS_KEY_ID # AWS_SECRET_ACCESS_KEY # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "example" { description = "Example variable" type = string default = "example" } variable "example2" { description = "Example variable 2" type = string default = "" } variable "example_list" { description = "An example variable that is a list." type = list(string) default = [] } variable "example_map" { description = "An example variable that is a map." type = map(string) default = {} } variable "example_any" { description = "An example variable that is can be anything" type = any default = null } ================================================ FILE: examples/terraform-database-example/REAME.md ================================================ # Terraform Database Example This example demonstrates how to create a PostgreSQL instance using Docker with Terraform. Check out [test/terraform_database_example_test.go](/test/terraform_database_example_test.go) to learn how to write automated tests for a database. To run the Go test code, you need to provide the host, port, username, password, and database name of an existing database, which you should have already created on a cloud platform or using Docker before running the tests. Currently, only Microsoft SQL Server, PostgreSQL, and MySQL are supported. ## Running this module manually 1. Install Terraform and ensure it's available in your `PATH`. 1. Run `terraform init`. 1. Run `terraform apply`. 1. When finished, run `terraform destroy` to remove the resources. ## Running automated tests against this module 1. Install [Terraform](https://www.terraform.io/) and ensure it's available in your `PATH`. 1. Install [Golang](https://golang.org/) and ensure this code is checked out into your `GOPATH`. 1. Run `go mod tidy` to manage dependencies. 1. Run `go test -v test/terraform_database_example_test.go` to execute the tests. ================================================ FILE: examples/terraform-database-example/main.tf ================================================ terraform { required_providers { docker = { source = "kreuzwerker/docker" version = "~> 3.0" } } required_version = ">= 1.3.0" } provider "docker" { } resource "docker_network" "postgres_network" { name = "postgres_network" } resource "docker_volume" "postgres_volume" { name = "postgres_data" } resource "docker_container" "postgres" { name = "postgres" image = "postgres:15" env = [ "POSTGRES_USER=${var.username}", "POSTGRES_PASSWORD=${var.password}", "POSTGRES_DB=${var.database_name}" ] ports { internal = 5432 external = var.port } networks_advanced { name = docker_network.postgres_network.name } restart = "always" } ================================================ FILE: examples/terraform-database-example/outputs.tf ================================================ output "host" { value = var.host } output "port" { value = var.port } output "username" { value = var.username } output "password" { value = var.password } output "database_name" { value = var.database_name } ================================================ FILE: examples/terraform-database-example/variables.tf ================================================ variable "host" { default = "localhost" } variable "port" { default = "32768" } variable "username" { default = "docker" } variable "password" { default = "docker" } variable "database_name" { default = "docker" } ================================================ FILE: examples/terraform-gcp-example/README.md ================================================ # Terraform GCP Example This folder contains a simple Terraform module that deploys resources in [GCP](https://cloud.google.com/) to demonstrate how you can use Terratest to write automated tests for your GCP Terraform code. This module deploys a [Compute Instance](https://cloud.google.com/compute/) and gives that Instance a `Name` with the value specified in the `instance_name` variable. It also creates a Cloud Storage Bucket using the `bucket_name` and `bucket_location` variables. Check out [test/terraform_gcp_example_test.go](/test/gcp/terraform_gcp_example_test.go) to see how you can write automated tests for this module. Note that the Compute Instance in this module doesn't actually do anything; it just runs a Vanilla Ubuntu 16.04 Image for demonstration purposes. For slightly more complicated, real-world examples of Terraform modules, see [terraform-http-example](/examples/terraform-http-example) and [terraform-ssh-example](/examples/terraform-ssh-example). **WARNING**: This module and the automated tests for it deploy real resources into your GCP account which can cost you money. The resources are all part of the [GCP Free Tier](https://cloud.google.com/free/), so if you haven't used that up, it should be free, but you are completely responsible for all GCP charges. ## Running this module manually 1. Sign up for [GCP](https://cloud.google.com/). 1. Configure your GCP credentials using one of the [supported methods for GCP CLI tools](https://cloud.google.com/sdk/docs/quickstarts). 1. Install [Terraform](https://www.terraform.io/) and make sure it's in your `PATH`. 1. Ensure the desired Project ID is set: `export GOOGLE_CLOUD_PROJECT=terratest-ABCXYZ`. 1. Run `terraform init`. 1. Run `terraform apply`. 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [GCP](https://cloud.google.com/free/). 1. Configure your GCP credentials using the [GCP CLI tools](https://cloud.google.com/sdk/docs/quickstarts). 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. Set `GOOGLE_CLOUD_PROJECT` environment variable to your project name. 1. `cd test` 1. `dep ensure` 1. `go test -v -run TestTerraformGcpExample` ================================================ FILE: examples/terraform-gcp-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # PIN TERRAFORM VERSION TO >= 0.12 # The examples have been upgraded to 0.12 syntax # --------------------------------------------------------------------------------------------------------------------- terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A CLOUD INSTANCE RUNNING UBUNTU # See test/terraform_gcp_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- resource "google_compute_instance" "example" { project = var.gcp_project_id name = var.instance_name machine_type = var.machine_type zone = var.zone boot_disk { initialize_params { image = "ubuntu-os-cloud/ubuntu-2204-lts" } } network_interface { network = "default" access_config { } } } # --------------------------------------------------------------------------------------------------------------------- # CREATE A GOOGLE STORAGE BUCKET # --------------------------------------------------------------------------------------------------------------------- resource "google_storage_bucket" "example_bucket" { project = var.gcp_project_id name = var.bucket_name location = var.bucket_location } ================================================ FILE: examples/terraform-gcp-example/outputs.tf ================================================ output "instance_name" { value = google_compute_instance.example.name } output "public_ip" { value = google_compute_instance.example.network_interface[0].access_config[0].nat_ip } output "bucket_url" { value = google_storage_bucket.example_bucket.url } ================================================ FILE: examples/terraform-gcp-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # You must define the following environment variables. # --------------------------------------------------------------------------------------------------------------------- # GOOGLE_CREDENTIALS # or # GOOGLE_APPLICATION_CREDENTIALS variable "gcp_project_id" { description = "The ID of the GCP project in which these resources will be created." } # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "instance_name" { description = "The Name to use for the Cloud Instance." type = string default = "terratest-example" } variable "machine_type" { description = "The Machine Type to use for the Cloud Instance." type = string default = "f1-micro" } variable "zone" { description = "The Zone to launch the Cloud Instance into." type = string default = "us-central1-a" } variable "bucket_name" { description = "The Name of the example Bucket to create." type = string default = "gruntwork-terratest-bucket" } variable "bucket_location" { description = "The location to store the Bucket. This value can be regional or multi-regional." type = string default = "US" } ================================================ FILE: examples/terraform-gcp-hello-world-example/README.md ================================================ # Terraform GCP "Hello, World" Example This folder contains a simple Terraform module that deploys resources in [GCP](https://cloud.google.com/) to demonstrate how you can use Terratest to write automated tests for your GCP Terraform code. This module deploys a [Compute Instance](https://cloud.google.com/compute/) and gives that Instance a `Name` with the value specified in the `instance_name` variable. Check out [test/terraform_gcp_hello_world_example_test.go](/test/gcp/terraform_gcp_hello_world_example_test.go) to see how you can write automated tests for this module. Note that the Compute Instance in this module doesn't actually do anything; it just runs a Vanilla Ubuntu 18.04 Image for demonstration purposes. For slightly more complicated, real-world examples of Terraform modules, see [terraform-http-example](/examples/terraform-http-example) and [terraform-ssh-example](/examples/terraform-ssh-example). **WARNING**: This module and the automated tests for it deploy real resources into your GCP account which can cost you money. The resources are all part of the [GCP Free Tier](https://cloud.google.com/free/), so if you haven't used that up, it should be free, but you are completely responsible for all GCP charges. ## Running this module manually 1. Sign up for [GCP](https://cloud.google.com/). 1. Configure your GCP credentials using one of the [supported methods for GCP CLI tools](https://cloud.google.com/sdk/docs/quickstarts). 1. Install [Terraform](https://www.terraform.io/) and make sure it's in your `PATH`. 1. Ensure the desired Project ID is set: `export GOOGLE_CLOUD_PROJECT=terratest-ABCXYZ`. 1. Run `terraform init`. 1. Run `terraform apply`. 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [GCP](https://cloud.google.com/free/). 1. Configure your GCP credentials using the [GCP CLI tools](https://cloud.google.com/sdk/docs/quickstarts). 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. Set `GOOGLE_CLOUD_PROJECT` environment variable to your project name. 1. `cd test` 1. `go test -v -run TestTerraformGcpHelloWorldExample` ================================================ FILE: examples/terraform-gcp-hello-world-example/main.tf ================================================ terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" } provider "google" { region = "us-east1" } # website::tag::1:: Deploy a cloud instance resource "google_compute_instance" "example" { name = var.instance_name machine_type = "f1-micro" zone = "us-east1-b" # website::tag::2:: Run Ubuntu 22.04 on the instance boot_disk { initialize_params { image = "ubuntu-os-cloud/ubuntu-2204-lts" } } network_interface { network = "default" access_config {} } } # website::tag::3:: Allow the user to pass in a custom name for the instance variable "instance_name" { description = "The Name to use for the Cloud Instance." default = "gcp-hello-world-example" } ================================================ FILE: examples/terraform-gcp-ig-example/README.md ================================================ # Terraform GCP Managed Instance Group Example This folder contains a simple Terraform configuration that deploys resources in [GCP](https://cloud.google.com/) to demonstrate how you can use Terratest to write automated tests for your GCP Terraform code. This module deploys an [Instance Group]( https://cloud.google.com/compute/docs/instance-groups/). Check out [test/terraform_gcp_ig_example_test.go](/test/gcp/terraform_gcp_ig_example_test.go) to see how you can write automated tests for this module. Note that the Instance Group in this module doesn't actually do anything; it just runs a cluster of vanilla Ubuntu 16.04 Images for demonstration purposes. For slightly more complicated, real-world examples of Terraform modules, see [terraform-http-example](/examples/terraform-http-example) and [terraform-ssh-example](/examples/terraform-ssh-example). **WARNING**: This module and the automated tests for it deploy real resources into your GCP account which can cost you money. By launching multiple Instances as part of an Instance Group, these resources may go beyond the [GCP Free Tier]( https://cloud.google.com/free/). Naturally, you are completely responsible for all GCP charges. ## Running this module manually 1. Sign up for [GCP](https://cloud.google.com/). 1. Configure your GCP credentials using one of the [supported methods for GCP CLI tools](https://cloud.google.com/sdk/docs/quickstarts). 1. Install [Terraform](https://www.terraform.io/) and make sure it's in your `PATH`. 1. Ensure the desired Project ID is set: `export GOOGLE_CLOUD_PROJECT=terratest-ABCXYZ`. 1. Run `terraform init`. 1. Run `terraform apply`. 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [GCP](https://cloud.google.com/free/). 1. Configure your GCP credentials using the [GCP CLI tools](https://cloud.google.com/sdk/docs/quickstarts). 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. Set `GOOGLE_CLOUD_PROJECT` environment variable to your project name. 1. `cd test` 1. `dep ensure` 1. `go test -v -run TestTerraformGcpInstanceGroupExample` ================================================ FILE: examples/terraform-gcp-ig-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # PIN TERRAFORM VERSION TO >= 0.12 # The examples have been upgraded to 0.12 syntax # --------------------------------------------------------------------------------------------------------------------- terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY A REGIONAL MANAGED INSTANCE GROUP # See test/terraform_gcp_ig_example_test.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- # Create a Regional Managed Instance Group resource "google_compute_region_instance_group_manager" "example" { project = var.gcp_project_id region = var.gcp_region name = "${var.cluster_name}-ig" base_instance_name = var.cluster_name version { name = "terratest" instance_template = google_compute_instance_template.example.self_link } target_size = var.cluster_size } # Create the Instance Template that will be used to populate the Managed Instance Group. resource "google_compute_instance_template" "example" { project = var.gcp_project_id name_prefix = var.cluster_name machine_type = var.machine_type scheduling { automatic_restart = true on_host_maintenance = "MIGRATE" preemptible = false } disk { boot = true auto_delete = true source_image = "ubuntu-os-cloud/ubuntu-2204-lts" } network_interface { network = "default" # The presence of this property assigns a public IP address to each Compute Instance. We intentionally leave it # blank so that an external IP address is selected automatically. access_config { } } } ================================================ FILE: examples/terraform-gcp-ig-example/outputs.tf ================================================ output "instance_group_name" { value = google_compute_region_instance_group_manager.example.name } ================================================ FILE: examples/terraform-gcp-ig-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # You must define the following environment variables. # --------------------------------------------------------------------------------------------------------------------- # GOOGLE_CREDENTIALS # or # GOOGLE_APPLICATION_CREDENTIALS # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- variable "gcp_project_id" { description = "The ID of the GCP project in which these resources will be created." type = string } variable "gcp_region" { description = "The region in which all GCP resources will be created." type = string } # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "cluster_name" { description = "The unique identifier for the resources created by this Terraform configuration." type = string default = "terratest-example" } variable "cluster_size" { description = "The number of Compute Instances to run in the Managed Instance Group." type = number default = 3 } variable "machine_type" { description = "The Machine Type to use for the Compute Instances." type = string default = "f1-micro" } ================================================ FILE: examples/terraform-hello-world-example/README.md ================================================ # Terraform "Hello, World" Example This folder contains the simplest possible Terraform module—one that just outputs "Hello, World"—to demonstrate how you can use Terratest to write automated tests for your Terraform code. Check out [test/terraform_hello_world_example_test.go](/test/terraform_hello_world_example_test.go) to see how you can write automated tests for this simple module. Note that this module doesn't do anything useful; it's just here to demonstrate the simplest usage pattern for Terratest. For a slightly more complicated example of a Terraform module and the corresponding tests, see [terraform-basic-example](/examples/terraform-basic-example). ## Running this module manually 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Run `terraform init`. 1. Run `terraform apply`. 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `go test -v -run TestTerraformHelloWorldExample` ================================================ FILE: examples/terraform-hello-world-example/main.tf ================================================ terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" } # website::tag::1:: The simplest possible Terraform module: it just outputs "Hello, World!" output "hello_world" { value = "Hello, World!" } ================================================ FILE: examples/terraform-http-example/README.md ================================================ # Terraform HTTP Example This folder contains a simple Terraform module that deploys resources in [AWS](https://aws.amazon.com/) to demonstrate how you can use Terratest to write automated tests for your AWS Terraform code. This module deploys an [EC2 Instance](https://aws.amazon.com/ec2/) in the AWS region specified in the `aws_region` variable. The EC2 Instance runs a simple web server that listens for HTTP requests on the port specified by the `instance_port` variable and returns the text specified by the `instance_text` variable. Check out [test/terraform_http_example_test.go](/test/terraform_http_example_test.go) to see how you can write automated tests for this module. Note that the example in this module is still fairly simplified, as the "web server" we run just servers up a static `index.html`, and not in a particularly production-ready manner! For a more complicated, real-world, end-to-end example of a Terraform module and web server, see [terraform-packer-example](/examples/terraform-packer-example). **WARNING**: This module and the automated tests for it deploy real resources into your AWS account which can cost you money. The resources are all part of the [AWS Free Tier](https://aws.amazon.com/free/), so if you haven't used that up, it should be free, but you are completely responsible for all AWS charges. ## Running this module manually 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Run `terraform init`. 1. Run `terraform apply`. 1. The `instance_url` output variable shows you the URL of the web server. Try opening it in your browser! 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `dep ensure` 1. `go test -v -run TestTerraformHttpExample` ================================================ FILE: examples/terraform-http-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # PIN TERRAFORM VERSION TO >= 0.12 # The examples have been upgraded to 0.12 syntax # --------------------------------------------------------------------------------------------------------------------- terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN EC2 INSTANCE THAT RUNS A SIMPLE "HELLO, WORLD" WEB SERVER # See test/terraform_http_example.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- provider "aws" { region = var.aws_region } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY THE EC2 INSTANCE # --------------------------------------------------------------------------------------------------------------------- resource "aws_instance" "example" { ami = data.aws_ami.ubuntu.id instance_type = var.instance_type user_data = data.template_file.user_data.rendered vpc_security_group_ids = [aws_security_group.example.id] tags = { Name = var.instance_name } } # --------------------------------------------------------------------------------------------------------------------- # CREATE A SECURITY GROUP TO CONTROL WHAT REQUESTS CAN GO IN AND OUT OF THE EC2 INSTANCE # --------------------------------------------------------------------------------------------------------------------- resource "aws_security_group" "example" { name = var.instance_name ingress { from_port = var.instance_port to_port = var.instance_port protocol = "tcp" # To keep this example simple, we allow incoming HTTP requests from any IP. In real-world usage, you may want to # lock this down to just the IPs of trusted servers (e.g., of a load balancer). cidr_blocks = ["0.0.0.0/0"] } } # --------------------------------------------------------------------------------------------------------------------- # CREATE THE USER DATA SCRIPT THAT WILL RUN DURING BOOT ON THE EC2 INSTANCE # --------------------------------------------------------------------------------------------------------------------- data "template_file" "user_data" { template = file("${path.module}/user-data/user-data.sh") vars = { instance_text = var.instance_text instance_port = var.instance_port } } # --------------------------------------------------------------------------------------------------------------------- # LOOK UP THE LATEST UBUNTU AMI # --------------------------------------------------------------------------------------------------------------------- data "aws_ami" "ubuntu" { most_recent = true owners = ["099720109477"] # Canonical filter { name = "virtualization-type" values = ["hvm"] } filter { name = "architecture" values = ["x86_64"] } filter { name = "image-type" values = ["machine"] } filter { name = "name" values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"] } } ================================================ FILE: examples/terraform-http-example/outputs.tf ================================================ output "instance_id" { value = aws_instance.example.id } output "public_ip" { value = aws_instance.example.public_ip } output "instance_url" { value = "http://${aws_instance.example.public_ip}:${var.instance_port}" } ================================================ FILE: examples/terraform-http-example/user-data/user-data.sh ================================================ #!/bin/bash # This script is meant to be run in the User Data of an EC2 Instance while it's booting. It starts a simple # "Hello, World" web server. set -e # Send the log output from this script to user-data.log, syslog, and the console # From: https://alestic.com/2010/12/ec2-user-data-output/ exec > >(tee /var/log/user-data.log|logger -t user-data -s 2>/dev/console) 2>&1 # The variables below are filled in using Terraform interpolation echo "${instance_text}" > index.html nohup busybox httpd -f -p "${instance_port}" & ================================================ FILE: examples/terraform-http-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # AWS_ACCESS_KEY_ID # AWS_SECRET_ACCESS_KEY # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "aws_region" { description = "The AWS region to deploy into" type = string default = "us-east-1" } variable "instance_name" { description = "The Name tag to set for the EC2 Instance." type = string default = "terratest-example" } variable "instance_port" { description = "The port the EC2 Instance should listen on for HTTP requests." type = number default = 8080 } variable "instance_text" { description = "The text the EC2 Instance should return when it gets an HTTP request." type = string default = "Hello, World!" } variable "instance_type" { description = "The EC2 instance type to run." type = string default = "t2.micro" } ================================================ FILE: examples/terraform-opa-example/README.md ================================================ # Terraform OPA Example This folder contains an [OPA](https://www.openpolicyagent.org/) policy that validates that all module blocks use a source that comes from the `gruntwork-io` GitHub org (the [enforce_source.rego](./policy/enforce_source.rego) file). To test this policy, we provided two Terraform modules, [pass](./pass) and [fail](./fail), which will demonstrate how OPA looks when run against a module that passes the checks, and one that fails the checks. Check out [test/terraform_opa_example_test.go](/test/terraform_opa_example_test.go) to see how you can write automated tests for this module. ## Running this module manually 1. Install [OPA](https://www.openpolicyagent.org/) and make sure it's on your `PATH`. 1. Install [hcl2json](https://github.com/tmccombs/hcl2json) and make sure it's on your `PATH`. We need this to convert the terraform source code to json as OPA currently doesn't support parsing HCL. 1. Convert each terraform source code in the `pass` or `fail` folder to json by feeding it to `hcl2json`: hcl2json pass/main.tf > pass/main.json 1. Run each converted terraform json file against the OPA policy: opa eval --fail \ -i pass/main.json \ -d policy/enforce_source.rego \ 'data.enforce_source.allow' ## Running automated tests against this module 1. Install [OPA](https://www.openpolicyagent.org/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/). 1. `cd test` 1. `go test -v -run TestOPAEvalTerraformModule` ## Using extra command line arguments If you need to pass additional command line arguments to OPA eval, you can use the `ExtraArgs` field. These arguments are placed after the `eval` subcommand and before the standard arguments: ```go // For OPA eval flags (e.g., --v0-compatible for OPA v0.x compatibility) opaOpts := &opa.EvalOptions{ RulePath: "../examples/terraform-opa-example/policy/enforce_source.rego", FailMode: opa.FailUndefined, ExtraArgs: []string{"--v0-compatible"}, } // For multiple eval subcommand flags opaOpts := &opa.EvalOptions{ RulePath: "../examples/terraform-opa-example/policy/enforce_source.rego", FailMode: opa.FailUndefined, ExtraArgs: []string{"--v0-compatible", "--format", "json"}, } ``` ================================================ FILE: examples/terraform-opa-example/fail/main_fail.tf ================================================ module "instance_types" { # website::tag::1:: We expect this to fail the OPA check since it is sourcing the module locally and not from gruntwork-io GitHub. source = "../pass" aws_region = var.aws_region } ================================================ FILE: examples/terraform-opa-example/fail/output.tf ================================================ output "recommended_instance_type" { description = "The recommended instance type to use in this AWS region. This will be the first instance type in var.instance_types which is available in all AZs in this region." value = module.instance_types.recommended_instance_type } ================================================ FILE: examples/terraform-opa-example/fail/variables.tf ================================================ variable "aws_region" { description = "Region to run the instance type checks on" type = string } ================================================ FILE: examples/terraform-opa-example/pass/main_pass.tf ================================================ provider "aws" { region = var.aws_region } module "instance_types" { # website::tag::1:: We expect this to pass the OPA check since it is sourcing the module from gruntwork-io GitHub. source = "git::git@github.com:gruntwork-io/terraform-aws-utilities.git//modules/instance-type?ref=v0.6.0" instance_types = ["t2.micro", "t3.micro"] } ================================================ FILE: examples/terraform-opa-example/pass/output.tf ================================================ output "recommended_instance_type" { description = "The recommended instance type to use in this AWS region. This will be the first instance type in var.instance_types which is available in all AZs in this region." value = module.instance_types.recommended_instance_type } ================================================ FILE: examples/terraform-opa-example/pass/variables.tf ================================================ variable "aws_region" { description = "Region to run the instance type checks on" type = string } ================================================ FILE: examples/terraform-opa-example/policy/enforce_source.rego ================================================ # An example rego policy of how to enforce that all module blocks in terraform json representation source the module # from the gruntwork-io github repo on the json representation of the terraform source files. A module block in the json # representation looks like the # following: # # { # "module": { # "MODULE_LABEL": [{ # #BLOCK_CONTENT # }] # } # } package enforce_source # website::tag::1:: Only define the allow variable and set to true if the violation set is empty. allow = true if { count(violation) == 0 } # website::tag::1:: Add modules with module_label to the violation set if the source attribute does not start with a string indicating it came from gruntwork-io GitHub org. violation contains module_label if { some module_label, i startswith(input.module[module_label][i].source, "git::git@github.com:gruntwork-io") == false } ================================================ FILE: examples/terraform-opa-example/policy/enforce_source_v0.rego ================================================ # An example rego policy of how to enforce that all module blocks in terraform json representation source the module # from the gruntwork-io github repo on the json representation of the terraform source files. A module block in the json # representation looks like the # following: # # { # "module": { # "MODULE_LABEL": [{ # #BLOCK_CONTENT # }] # } # } package enforce_source # This version uses OPA v0.x syntax (also compatible with v1.x when using --v0-compatible flag) # website::tag::1:: Only define the allow variable and set to true if the violation set is empty. allow = true { count(violation) == 0 } # website::tag::1:: Add modules with module_label to the violation set if the source attribute does not start with a string indicating it came from gruntwork-io GitHub org. violation[module_label] { some module_label, i startswith(input.module[module_label][i].source, "git::git@github.com:gruntwork-io") == false } ================================================ FILE: examples/terraform-packer-example/README.md ================================================ # Terraform Packer Example This folder contains a Terraform module that deploys resources in [AWS](https://aws.amazon.com/) to demonstrate how you can use Terratest to write automated tests for your AWS Terraform code. This module deploys an [EC2 Instance](https://aws.amazon.com/ec2/) in the AWS region specified in the `aws_region` variable. The EC2 Instance runs the AMI specified via the `ami_id` variable. It is assumed that this is an AMI built using the Packer template in [packer-docker-example](/examples/packer-docker-example), which contains a simple Ruby web app. This module will configure a User Data script to start the web app, configuring it to listen for HTTP requests on the port specified by the `instance_port` variable and return the text specified by the `instance_text` variable. Check out [test/terraform_packer_example_test.go](/test/terraform_packer_example_test.go) to see how you can write automated tests for this module. Note that this is an end-to-end integration test that will build the AMI, deploy it using Terraform, validate the web app is working, and then clean everything up. The test is broken down into "stages" so that, when iterating locally, you can choose to skip any of the stages by setting an environment variable. For example, if you've already built the AMI and don't want to rebuild it each time you re-run the test, you can set the environment variable `SKIP_build_ami=true`. **WARNING**: This module and the automated tests for it deploy real resources into your AWS account which can cost you money. The resources are all part of the [AWS Free Tier](https://aws.amazon.com/free/), so if you haven't used that up, it should be free, but you are completely responsible for all AWS charges. ## Running this module manually 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Install [Packer](https://www.packer.io/) and make sure it's on your `PATH`. 1. Follow the instructions in [packer-docker-example](/examples/packer-docker-example) to build an AMI. Note down the AMI ID. 1. Open `variables.tf` and set the `ami_id` variable to the ID of the AMI you just built. 1. Run `terraform init`. 1. Run `terraform apply`. 1. The `instance_url` output variable shows you the URL of the web server. Try opening it in your browser! 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Install [Packer](https://www.packer.io/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `dep ensure` 1. `go test -v -run TestTerraformPackerExample` ================================================ FILE: examples/terraform-packer-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # PIN TERRAFORM VERSION TO >= 0.12 # The examples have been upgraded to 0.12 syntax # --------------------------------------------------------------------------------------------------------------------- terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN EC2 INSTANCE THAT RUNS A SIMPLE RUBY WEB APP BUILT USING A PACKER TEMPLATE # See test/terraform_packer_example.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- provider "aws" { region = var.aws_region } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY THE EC2 INSTANCE # --------------------------------------------------------------------------------------------------------------------- resource "aws_instance" "example" { ami = var.ami_id instance_type = var.instance_type user_data = templatefile("${path.module}/user-data/user-data.sh", { instance_text = var.instance_text instance_port = var.instance_port }) vpc_security_group_ids = [aws_security_group.example.id] tags = { Name = var.instance_name } } # --------------------------------------------------------------------------------------------------------------------- # CREATE A SECURITY GROUP TO CONTROL WHAT REQUESTS CAN GO IN AND OUT OF THE EC2 INSTANCE # --------------------------------------------------------------------------------------------------------------------- resource "aws_security_group" "example" { name = var.instance_name ingress { from_port = var.instance_port to_port = var.instance_port protocol = "tcp" # To keep this example simple, we allow incoming HTTP requests from any IP. In real-world usage, you may want to # lock this down to just the IPs of trusted servers (e.g., of a load balancer). cidr_blocks = ["0.0.0.0/0"] } } ================================================ FILE: examples/terraform-packer-example/outputs.tf ================================================ output "instance_id" { value = aws_instance.example.id } output "public_ip" { value = aws_instance.example.public_ip } output "instance_url" { value = "http://${aws_instance.example.public_ip}:${var.instance_port}" } ================================================ FILE: examples/terraform-packer-example/user-data/user-data.sh ================================================ #!/bin/bash # This script is meant to be run in the User Data of an EC2 Instance while it's booting. It starts a Ruby web app. # This script assumes it is running in an AMI built from the Packer templates in examples/packer-docker-example # (either build.json or build.pkr.hcl). set -e # Send the log output from this script to user-data.log, syslog, and the console # From: https://alestic.com/2010/12/ec2-user-data-output/ exec > >(tee /var/log/user-data.log|logger -t user-data -s 2>/dev/console) 2>&1 # The variables below are filled in using Terraform interpolation nohup ruby /home/ubuntu/app.rb "${instance_port}" "${instance_text}" & ================================================ FILE: examples/terraform-packer-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # AWS_ACCESS_KEY_ID # AWS_SECRET_ACCESS_KEY # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- variable "ami_id" { description = "The ID of the AMI to run on each EC2 Instance. Should be an AMI built from the Packer templates in examples/packer-docker-example (build.json or build.pkr.hcl)." type = string } # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "aws_region" { description = "The AWS region to deploy into" type = string default = "us-east-1" } variable "instance_name" { description = "The Name tag to set for the EC2 Instance." type = string default = "terratest-example" } variable "instance_port" { description = "The port the EC2 Instance should listen on for HTTP requests." type = number default = 8080 } variable "instance_text" { description = "The text the EC2 Instance should return when it gets an HTTP request." type = string default = "Hello, World!" } variable "instance_type" { description = "The EC2 instance type to run." type = string default = "t2.micro" } ================================================ FILE: examples/terraform-redeploy-example/README.md ================================================ # Terraform Redeploy Example This folder contains a simple Terraform module that deploys resources in [AWS](https://aws.amazon.com/) to demonstrate how you can use Terratest to write automated tests for your AWS Terraform code. This module deploys an [Auto Scaling Group (ASG)](https://docs.aws.amazon.com/autoscaling/ec2/userguide/AutoScalingGroup.html) in the AWS region specified in the `aws_region` variable and a [Load Balancer](https://aws.amazon.com/elasticloadbalancing/) to route traffic across the ASG. Each EC2 Instance in the ASG runs a simple web server that listens for HTTP requests on the port specified by the `instance_port` variable and returns the text specified by the `instance_text` variable. The ASG is configured to support zero-downtime deployments, which is something we verify in the automated test. Check out [test/terraform_redeploy_example_test.go](/test/terraform_redeploy_example_test.go) to see how you can write automated tests for this module. Note that the example in this module is still fairly simplified, as the "web server" we run just servers up a static `index.html`, and not in a particularly production-ready manner! For a more complicated, real-world, end-to-end example of a Terraform module and web server, see [terraform-packer-example](/examples/terraform-packer-example). **WARNING**: This module and the automated tests for it deploy real resources into your AWS account which can cost you money. The resources are all part of the [AWS Free Tier](https://aws.amazon.com/free/), so if you haven't used that up, it should be free, but you are completely responsible for all AWS charges. ## Running this module manually 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Run `terraform init`. 1. Run `terraform apply`. 1. The `url` output variable shows you the URL of the load balancer. Try opening it in your browser! 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `dep ensure` 1. `go test -v -run TestTerraformRedeployExample` ================================================ FILE: examples/terraform-redeploy-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # PIN TERRAFORM VERSION TO >= 0.12 # The examples have been upgraded to 0.12 syntax # --------------------------------------------------------------------------------------------------------------------- terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN AUTO SCALING GROUP (ASG) WITH AN APPLICATION LOAD BALANCER (ALB) IN FRONT OF IT # --------------------------------------------------------------------------------------------------------------------- provider "aws" { region = var.aws_region } # --------------------------------------------------------------------------------------------------------------------- # CREATE THE ASG # --------------------------------------------------------------------------------------------------------------------- resource "aws_autoscaling_group" "web_servers" { # Note that we intentionally depend on the Launch Configuration name so that creating a new Launch Configuration # (e.g. to deploy a new AMI) creates a new Auto Scaling Group. This will allow for rolling deployments. name = aws_launch_configuration.web_servers.name launch_configuration = aws_launch_configuration.web_servers.name min_size = 3 max_size = 3 desired_capacity = 3 min_elb_capacity = 3 # Deploy into all the subnets (and therefore AZs) available vpc_zone_identifier = data.aws_subnets.default.ids # Automatically register this ASG's Instances in the ALB and use the ALB's health check to determine when an Instance # needs to be replaced health_check_type = "ELB" target_group_arns = [aws_alb_target_group.web_servers.arn] tag { key = "Name" value = var.instance_name propagate_at_launch = true } # To support rolling deployments, we tell Terraform to create a new ASG before deleting the old one. Note: as # soon as you set create_before_destroy = true in one resource, you must also set it in every resource that it # depends on, or you'll get an error about cyclic dependencies (especially when removing resources). lifecycle { create_before_destroy = true } # This needs to be here to ensure the ALB has at least one listener rule before the ASG is created. Otherwise, on the # very first deployment, the ALB won't bother doing any health checks, which means min_elb_capacity will not be # achieved, and the whole deployment will fail. depends_on = [aws_alb_listener.http] } # --------------------------------------------------------------------------------------------------------------------- # CREATE THE LAUNCH CONFIGURATION # This is a "template" that defines the configuration for each EC2 Instance in the ASG # --------------------------------------------------------------------------------------------------------------------- resource "aws_launch_configuration" "web_servers" { image_id = data.aws_ami.ubuntu.id instance_type = var.instance_type security_groups = [aws_security_group.web_server.id] user_data = data.template_file.user_data.rendered key_name = var.key_pair_name # When used with an aws_autoscaling_group resource, the aws_launch_configuration must set create_before_destroy to # true. Note: as soon as you set create_before_destroy = true in one resource, you must also set it in every resource # that it depends on, or you'll get an error about cyclic dependencies (especially when removing resources). lifecycle { create_before_destroy = true } } # --------------------------------------------------------------------------------------------------------------------- # CREATE THE USER DATA SCRIPT THAT WILL RUN DURING BOOT ON THE EC2 INSTANCE # --------------------------------------------------------------------------------------------------------------------- data "template_file" "user_data" { template = file("${path.module}/user-data/user-data.sh") vars = { instance_text = var.instance_text instance_port = var.instance_port } } # --------------------------------------------------------------------------------------------------------------------- # FOR THIS EXAMPLE, WE JUST RUN A PLAIN UBUNTU 22.04 AMI # --------------------------------------------------------------------------------------------------------------------- data "aws_ami" "ubuntu" { most_recent = true owners = ["099720109477"] # Canonical filter { name = "virtualization-type" values = ["hvm"] } filter { name = "architecture" values = ["x86_64"] } filter { name = "image-type" values = ["machine"] } filter { name = "name" values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"] } } # --------------------------------------------------------------------------------------------------------------------- # CREATE A SECURITY GROUP TO CONTROL WHAT TRAFFIC CAN GO IN AND OUT OF THE EC2 INSTANCE # --------------------------------------------------------------------------------------------------------------------- resource "aws_security_group" "web_server" { name = var.instance_name vpc_id = data.aws_vpc.default.id # This is here because aws_launch_configuration.web_servers sets create_before_destroy to true and depends on this # resource lifecycle { create_before_destroy = true } } resource "aws_security_group_rule" "web_server_allow_http_inbound" { type = "ingress" from_port = var.instance_port to_port = var.instance_port protocol = "tcp" security_group_id = aws_security_group.web_server.id cidr_blocks = ["0.0.0.0/0"] } resource "aws_security_group_rule" "web_server_allow_ssh_inbound" { type = "ingress" from_port = var.ssh_port to_port = var.ssh_port protocol = "tcp" security_group_id = aws_security_group.web_server.id cidr_blocks = ["0.0.0.0/0"] } resource "aws_security_group_rule" "web_server_allow_all_outbound" { type = "egress" from_port = 0 to_port = 0 protocol = "-1" security_group_id = aws_security_group.web_server.id cidr_blocks = ["0.0.0.0/0"] } # --------------------------------------------------------------------------------------------------------------------- # CREATE AN ALB TO DISTRIBUTE TRAFFIC ACROSS THE ASG # --------------------------------------------------------------------------------------------------------------------- resource "aws_alb" "web_servers" { name = var.instance_name security_groups = [aws_security_group.alb.id] subnets = data.aws_subnets.default.ids # This is here because aws_alb_listener.http depends on this resource and sets create_before_destroy to true lifecycle { create_before_destroy = true } } # --------------------------------------------------------------------------------------------------------------------- # CREATE AN ALB LISTENER FOR HTTP REQUESTS # --------------------------------------------------------------------------------------------------------------------- resource "aws_alb_listener" "http" { load_balancer_arn = aws_alb.web_servers.arn port = var.alb_port protocol = "HTTP" default_action { type = "forward" target_group_arn = aws_alb_target_group.web_servers.arn } # This is here because aws_autoscaling_group.web_servers depends on this resource and sets create_before_destroy # to true lifecycle { create_before_destroy = true } } # --------------------------------------------------------------------------------------------------------------------- # CREATE AN ALB TARGET GROUP FOR THE ASG # This target group will perform health checks on the web servers in the ASG # --------------------------------------------------------------------------------------------------------------------- resource "aws_alb_target_group" "web_servers" { depends_on = [aws_alb.web_servers] name = var.instance_name port = var.instance_port protocol = "HTTP" vpc_id = data.aws_vpc.default.id # Give existing connections 10 seconds to complete before deregistering an instance. The default delay is 300 seconds # (5 minutes), which significantly slows down redeploys. In theory, the ALB should deregister the instance as long as # there are no open connections; in practice, it waits the full five minutes every time. If your requests are # generally processed quickly, set this to something lower (such as 10 seconds) to keep redeploys fast. deregistration_delay = 10 health_check { path = "/" interval = 15 healthy_threshold = 2 unhealthy_threshold = 2 timeout = 5 } # This is here because aws_autoscaling_group.web_servers depends on this resource and sets create_before_destroy # to true lifecycle { create_before_destroy = true } } # --------------------------------------------------------------------------------------------------------------------- # CREATE AN ALB LISTENER RULE TO SEND ALL REQUESTS TO THE ASG # --------------------------------------------------------------------------------------------------------------------- resource "aws_alb_listener_rule" "send_all_to_web_servers" { listener_arn = aws_alb_listener.http.arn priority = 100 action { type = "forward" target_group_arn = aws_alb_target_group.web_servers.arn } condition { path_pattern { values = ["*"] } } } # --------------------------------------------------------------------------------------------------------------------- # CREATE A SECURITY GROUP TO CONTROL WHAT TRAFFIC CAN GO IN AND OUT OF THE ALB # --------------------------------------------------------------------------------------------------------------------- resource "aws_security_group" "alb" { name = "${var.instance_name}-alb" vpc_id = data.aws_vpc.default.id } resource "aws_security_group_rule" "alb_allow_http_inbound" { type = "ingress" from_port = var.alb_port to_port = var.alb_port protocol = "tcp" security_group_id = aws_security_group.alb.id cidr_blocks = ["0.0.0.0/0"] } # We need to allow outbound connections from the ALB so it can perform health checks resource "aws_security_group_rule" "allow_all_outbound" { type = "egress" from_port = 0 to_port = 0 protocol = "-1" security_group_id = aws_security_group.alb.id cidr_blocks = ["0.0.0.0/0"] } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY INTO THE DEFAULT VPC AND SUBNETS # To keep this example simple, we are deploying into the Default VPC and its subnets. In real-world usage, you should # deploy into a custom VPC and private subnets. # --------------------------------------------------------------------------------------------------------------------- data "aws_vpc" "default" { default = true } data "aws_subnets" "default" { filter { name = "vpc-id" values = [data.aws_vpc.default.id] } filter { name = "defaultForAz" values = [true] } } ================================================ FILE: examples/terraform-redeploy-example/outputs.tf ================================================ output "alb_dns_name" { value = aws_alb.web_servers.dns_name } output "url" { value = "http://${aws_alb.web_servers.dns_name}:${var.alb_port}" } output "asg_name" { value = aws_autoscaling_group.web_servers.name } ================================================ FILE: examples/terraform-redeploy-example/user-data/user-data.sh ================================================ #!/bin/bash # This script is meant to be run in the User Data of an EC2 Instance while it's booting. It starts a simple # "Hello, World" web server. set -e # Send the log output from this script to user-data.log, syslog, and the console # From: https://alestic.com/2010/12/ec2-user-data-output/ exec > >(tee /var/log/user-data.log|logger -t user-data -s 2>/dev/console) 2>&1 # The variables below are filled in using Terraform interpolation echo "${instance_text}" > index.html nohup busybox httpd -f -p "${instance_port}" & ================================================ FILE: examples/terraform-redeploy-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # AWS_ACCESS_KEY_ID # AWS_SECRET_ACCESS_KEY # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "aws_region" { description = "The AWS region to deploy into (e.g. us-east-1)." type = string default = "us-east-1" } variable "instance_name" { description = "The names for the ASG and other resources in this module" type = string default = "asg-alb-example" } variable "instance_port" { description = "The port each EC2 Instance should listen on for HTTP requests." type = number default = 8080 } variable "ssh_port" { description = "The port each EC2 Instance should listen on for SSH requests." type = number default = 22 } variable "instance_text" { description = "The text each EC2 Instance should return when it gets an HTTP request." type = string default = "Hello, World!" } variable "alb_port" { description = "The port the ALB should listen on for HTTP requests" type = number default = 80 } variable "key_pair_name" { description = "The EC2 Key Pair to associate with the EC2 Instance for SSH access." type = string default = "" } variable "instance_type" { description = "The EC2 instance type to run." type = string default = "t2.micro" } ================================================ FILE: examples/terraform-remote-exec-example/README.md ================================================ # Terraform remote-exec example This folder contains a simple Terraform module that deploys resources in [AWS](https://aws.amazon.com/) to demonstrate how you can use Terratest to test modules which use `remote-exec`, `files`, and other ssh-based [provisioners](https://www.terraform.io/docs/provisioners/index.html). Check out [test/terraform_remote_exec_example_test.go](/test/terraform_remote_exec_example_test.go) to see how you can write automated tests for this module. **WARNING**: This module and the automated tests for it deploy real resources into your AWS account which can cost you money. The resources are all part of the [AWS Free Tier](https://aws.amazon.com/free/), so if you haven't used that up, it should be free, but you are completely responsible for all AWS charges. ## Running this module manually 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Run `terraform init`. 1. Run `terraform apply`. 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `dep ensure` 1. `go test -v -run TestTerraformRemoteExecExample` ================================================ FILE: examples/terraform-remote-exec-example/files/get-public-ip.sh ================================================ #!/bin/bash # For example purposes, print the public IP address of this instance. This example uses Instance Metadata Service version 2. TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600") \ && curl -H "X-aws-ec2-metadata-token: $TOKEN" -s http://169.254.169.254/latest/meta-data/public-ipv4 ================================================ FILE: examples/terraform-remote-exec-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # PIN TERRAFORM VERSION TO >= 0.12 # The examples have been upgraded to 0.12 syntax # --------------------------------------------------------------------------------------------------------------------- terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN INSTANCE, THEN TRIGGER A PROVISIONER # See test/terraform_ssh_example.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- provider "aws" { region = var.aws_region } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY THE EC2 INSTANCE WITH A PUBLIC IP # --------------------------------------------------------------------------------------------------------------------- resource "aws_instance" "example_public" { ami = data.aws_ami.ubuntu.id instance_type = var.instance_type vpc_security_group_ids = [aws_security_group.example.id] key_name = var.key_pair_name # This EC2 Instance has a public IP and will be accessible directly from the public Internet associate_public_ip_address = true tags = { Name = "${var.instance_name}-public" } } # --------------------------------------------------------------------------------------------------------------------- # CREATE A SECURITY GROUP TO CONTROL WHAT REQUESTS CAN GO IN AND OUT OF THE EC2 INSTANCES # --------------------------------------------------------------------------------------------------------------------- resource "aws_security_group" "example" { name = var.instance_name egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } ingress { from_port = var.ssh_port to_port = var.ssh_port protocol = "tcp" # To keep this example simple, we allow incoming SSH requests from any IP. In real-world usage, you should only # allow SSH requests from trusted servers, such as a bastion host or VPN server. cidr_blocks = ["0.0.0.0/0"] } } # --------------------------------------------------------------------------------------------------------------------- # Provision the server using remote-exec # --------------------------------------------------------------------------------------------------------------------- resource "null_resource" "example_provisioner" { triggers = { public_ip = aws_instance.example_public.public_ip } connection { type = "ssh" host = aws_instance.example_public.public_ip user = var.ssh_user port = var.ssh_port agent = true } // copy our example script to the server provisioner "file" { source = "files/get-public-ip.sh" destination = "/tmp/get-public-ip.sh" } // change permissions to executable and pipe its output into a new file provisioner "remote-exec" { inline = [ "chmod +x /tmp/get-public-ip.sh", "/tmp/get-public-ip.sh > /tmp/public-ip", ] } provisioner "local-exec" { # copy the public-ip file back to CWD, which will be tested command = "scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${var.ssh_user}@${aws_instance.example_public.public_ip}:/tmp/public-ip public-ip" } } # --------------------------------------------------------------------------------------------------------------------- # LOOK UP THE LATEST UBUNTU AMI # --------------------------------------------------------------------------------------------------------------------- data "aws_ami" "ubuntu" { most_recent = true owners = ["099720109477"] # Canonical filter { name = "virtualization-type" values = ["hvm"] } filter { name = "architecture" values = ["x86_64"] } filter { name = "image-type" values = ["machine"] } filter { name = "name" values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"] } } ================================================ FILE: examples/terraform-remote-exec-example/outputs.tf ================================================ output "public_instance_id" { value = aws_instance.example_public.id } output "public_instance_ip" { value = aws_instance.example_public.public_ip } ================================================ FILE: examples/terraform-remote-exec-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # AWS_ACCESS_KEY_ID # AWS_SECRET_ACCESS_KEY # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- variable "key_pair_name" { description = "The EC2 Key Pair to associate with the EC2 Instance for SSH access." type = string } # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "aws_region" { description = "The AWS region to deploy into" type = string default = "us-east-1" } variable "instance_name" { description = "The Name tag to set for the EC2 Instance." type = string default = "terratest-example" } variable "ssh_port" { description = "The port the EC2 Instance should listen on for SSH requests." type = number default = 22 } variable "ssh_user" { description = "SSH user name to use for remote exec connections," type = string default = "ubuntu" } variable "instance_type" { description = "Instance type to use for EC2 Instance" type = string default = "t2.micro" } ================================================ FILE: examples/terraform-ssh-certificate-example/README.md ================================================ # Terraform SSH Password Example This folder contains a simple Terraform module that deploys resources in [AWS](https://aws.amazon.com/) to demonstrate how you can use Terratest to write automated tests for your AWS Terraform code. This module deploys an [EC2 Instance](https://aws.amazon.com/ec2/) with a public IP in the AWS region specified in the `aws_region` variable. The EC2 Instance allows SSH requests on the port specified by the `ssh_port` variable, and is configured with a user data script so that it will authentication with ssh certificate. Please note that the Terraform deployment outlined in [the example directory for this test](/examples/terraform-ssh-certificate) will expect a default VPC to exist in the target region for deployments to go into. If this default VPC has been deleted from your AWS account, it can be recreated with the following command: ``` shell $ aws ec2 create-default-vpc --region eu-west-2 { "Vpc": { "CidrBlock": "172.31.0.0/16", "InstanceTenancy": "default", "IsDefault": true, "State": "pending", ... ``` Check out [test/terraform_ssh_password_example_test.go](/test/terraform_ssh_password_example_test.go) to see how you can write automated tests for this module. Note that the example in this module is still fairly simplified, as the EC2 Instance doesn't do a whole lot! For a more complicated, real-world, end-to-end example of a Terraform module and web server, see [terraform-packer-example](/examples/terraform-packer-example). **WARNING**: This module and the automated tests for it deploy real resources into your AWS account which can cost you money. The resources are all part of the [AWS Free Tier](https://aws.amazon.com/free/), so if you haven't used that up, it should be free, but you are completely responsible for all AWS charges. ## Running this module manually 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Run `terraform init`. 1. Run `terraform apply`. 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `dep ensure` 1. `go test -v -run TestTerraformSshCertificateExample` ================================================ FILE: examples/terraform-ssh-certificate-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # PIN TERRAFORM VERSION TO >= 0.12 # The examples have been upgraded to 0.12 syntax # --------------------------------------------------------------------------------------------------------------------- terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN EC2 INSTANCE THAT ALLOWS CONNECTIONS VIA SSH # See test/terraform_ssh_password_example.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- provider "aws" { region = var.aws_region } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY THE EC2 INSTANCE WITH A PUBLIC IP # --------------------------------------------------------------------------------------------------------------------- resource "aws_instance" "example_public" { ami = data.aws_ami.ubuntu.id instance_type = var.instance_type user_data = templatefile("${path.module}/user_data.sh", { ssh_ca_public_key = var.ssh_ca_public_key }) vpc_security_group_ids = [ aws_security_group.example.id, ] # This EC2 Instance has a public IP and will be accessible directly from the public Internet associate_public_ip_address = "true" tags = { Name = "${var.instance_name}-public" } } # --------------------------------------------------------------------------------------------------------------------- # CREATE A SECURITY GROUP TO CONTROL WHAT REQUESTS CAN GO IN AND OUT OF THE EC2 INSTANCE # --------------------------------------------------------------------------------------------------------------------- resource "aws_security_group" "example" { name = var.instance_name egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } ingress { from_port = var.ssh_port to_port = var.ssh_port protocol = "tcp" # To keep this example simple, we allow incoming SSH requests from any IP. In real-world usage, you should only # allow SSH requests from trusted servers, such as a bastion host or VPN server. cidr_blocks = ["0.0.0.0/0"] } } # --------------------------------------------------------------------------------------------------------------------- # LOOK UP THE LATEST UBUNTU AMI # --------------------------------------------------------------------------------------------------------------------- data "aws_ami" "ubuntu" { most_recent = "true" owners = ["099720109477"] # Canonical filter { name = "virtualization-type" values = ["hvm"] } filter { name = "architecture" values = ["x86_64"] } filter { name = "image-type" values = ["machine"] } filter { name = "name" values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"] } } ================================================ FILE: examples/terraform-ssh-certificate-example/outputs.tf ================================================ output "public_instance_id" { value = aws_instance.example_public.id } output "public_instance_ip" { value = aws_instance.example_public.public_ip } ================================================ FILE: examples/terraform-ssh-certificate-example/user_data.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Send the log output from this script to user-data.log, syslog, and the console # From: https://alestic.com/2010/12/ec2-user-data-output/ exec > >(tee /var/log/user-data.log | logger -t user-data -s 2>/dev/console) 2>&1 # Create our new 'terratest' user adduser --disabled-password --gecos "" terratest # Create CA pubkey file mkdir -p /etc/ssh cat > /etc/ssh/trusted-user-ca-keys.pub <<'EOKEY' ${ssh_ca_public_key} EOKEY # Drop-in configuration for sshd mkdir -p /etc/ssh/sshd_config.d echo 'TrustedUserCAKeys /etc/ssh/trusted-user-ca-keys.pub' > /etc/ssh/sshd_config.d/ca.conf # Bounce the service to apply the config change service ssh restart ================================================ FILE: examples/terraform-ssh-certificate-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # AWS_ACCESS_KEY_ID # AWS_SECRET_ACCESS_KEY # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- variable "ssh_ca_public_key" { description = "The public key for the ssh connection to the instance." type = string } # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "aws_region" { description = "The AWS region to deploy into" type = string default = "us-east-1" } variable "instance_name" { description = "The Name tag to set for the EC2 Instance." type = string default = "terratest-example" } variable "ssh_port" { description = "The port the EC2 Instance should listen on for SSH requests." type = number default = 22 } variable "instance_type" { description = "The EC2 instance type to run." type = string default = "t2.micro" } ================================================ FILE: examples/terraform-ssh-example/README.md ================================================ # Terraform SSH Example This folder contains a simple Terraform module that deploys resources in [AWS](https://aws.amazon.com/) to demonstrate how you can use Terratest to write automated tests for your AWS Terraform code. This module deploys two [EC2 Instances](https://aws.amazon.com/ec2/), one with a public IP, one with a private IP, in the AWS region specified in the `aws_region` variable. The EC2 Instances allow SSH requests on the port specified by the `ssh_port` variable. Check out [test/terraform_ssh_example_test.go](/test/terraform_ssh_example_test.go) to see how you can write automated tests for this module. Note that the example in this module is still fairly simplified, as the EC2 Instance doesn't do a whole lot! For a more complicated, real-world, end-to-end example of a Terraform module and web server, see [terraform-packer-example](/examples/terraform-packer-example). **WARNING**: This module and the automated tests for it deploy real resources into your AWS account which can cost you money. The resources are all part of the [AWS Free Tier](https://aws.amazon.com/free/), so if you haven't used that up, it should be free, but you are completely responsible for all AWS charges. ## Running this module manually 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Run `terraform init`. 1. Run `terraform apply`. 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `dep ensure` 1. `go test -v -run TestTerraformSshExample` ================================================ FILE: examples/terraform-ssh-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # PIN TERRAFORM VERSION TO >= 0.12 # The examples have been upgraded to 0.12 syntax # --------------------------------------------------------------------------------------------------------------------- terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY TWO EC2 INSTANCES THAT ALLOWS CONNECTIONS VIA SSH # See test/terraform_ssh_example.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- provider "aws" { region = var.aws_region } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY THE EC2 INSTANCE WITH A PUBLIC IP # --------------------------------------------------------------------------------------------------------------------- resource "aws_instance" "example_public" { ami = data.aws_ami.ubuntu.id instance_type = var.instance_type vpc_security_group_ids = [aws_security_group.example.id] key_name = var.key_pair_name # This EC2 Instance has a public IP and will be accessible directly from the public Internet associate_public_ip_address = true tags = { Name = "${var.instance_name}-public" } } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY THE EC2 INSTANCE WITH A PRIVATE IP # --------------------------------------------------------------------------------------------------------------------- resource "aws_instance" "example_private" { ami = data.aws_ami.ubuntu.id instance_type = var.instance_type vpc_security_group_ids = [aws_security_group.example.id] key_name = var.key_pair_name # This EC2 Instance has a private IP and will be accessible only from within the VPC associate_public_ip_address = false tags = { Name = "${var.instance_name}-private" } } # --------------------------------------------------------------------------------------------------------------------- # CREATE A SECURITY GROUP TO CONTROL WHAT REQUESTS CAN GO IN AND OUT OF THE EC2 INSTANCES # --------------------------------------------------------------------------------------------------------------------- resource "aws_security_group" "example" { name = var.instance_name egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } ingress { from_port = var.ssh_port to_port = var.ssh_port protocol = "tcp" # To keep this example simple, we allow incoming SSH requests from any IP. In real-world usage, you should only # allow SSH requests from trusted servers, such as a bastion host or VPN server. cidr_blocks = ["0.0.0.0/0"] } } # --------------------------------------------------------------------------------------------------------------------- # LOOK UP THE LATEST UBUNTU AMI # --------------------------------------------------------------------------------------------------------------------- data "aws_ami" "ubuntu" { most_recent = true owners = ["099720109477"] # Canonical filter { name = "virtualization-type" values = ["hvm"] } filter { name = "architecture" values = ["x86_64"] } filter { name = "image-type" values = ["machine"] } filter { name = "name" values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"] } } ================================================ FILE: examples/terraform-ssh-example/outputs.tf ================================================ output "public_instance_id" { value = aws_instance.example_public.id } output "public_instance_ip" { value = aws_instance.example_public.public_ip } output "private_instance_id" { value = aws_instance.example_private.id } output "private_instance_ip" { value = aws_instance.example_private.private_ip } ================================================ FILE: examples/terraform-ssh-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # AWS_ACCESS_KEY_ID # AWS_SECRET_ACCESS_KEY # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- variable "key_pair_name" { description = "The EC2 Key Pair to associate with the EC2 Instance for SSH access." type = string } # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "aws_region" { description = "The AWS region to deploy into" type = string default = "us-east-1" } variable "instance_name" { description = "The Name tag to set for the EC2 Instance." type = string default = "terratest-example" } variable "ssh_port" { description = "The port the EC2 Instance should listen on for SSH requests." type = number default = 22 } variable "instance_type" { description = "The EC2 instance type to run." type = string default = "t2.micro" } ================================================ FILE: examples/terraform-ssh-password-example/README.md ================================================ # Terraform SSH Password Example This folder contains a simple Terraform module that deploys resources in [AWS](https://aws.amazon.com/) to demonstrate how you can use Terratest to write automated tests for your AWS Terraform code. This module deploys an [EC2 Instance](https://aws.amazon.com/ec2/) with a public IP in the AWS region specified in the `aws_region` variable. The EC2 Instance allows SSH requests on the port specified by the `ssh_port` variable, and is configured with a user data script so that it will accept passwords for authentication. Please note that the Terraform deployment outlined in [the example directory for this test](/examples/terraform-ssh-password-example) will expect a default VPC to exist in the target region for deployments to go into. If this default VPC has been deleted from your AWS account, it can be recreated with the following command: ``` shell $ aws ec2 create-default-vpc --region eu-west-2 { "Vpc": { "CidrBlock": "172.31.0.0/16", "InstanceTenancy": "default", "IsDefault": true, "State": "pending", ... ``` Check out [test/terraform_ssh_password_example_test.go](/test/terraform_ssh_password_example_test.go) to see how you can write automated tests for this module. Note that the example in this module is still fairly simplified, as the EC2 Instance doesn't do a whole lot! For a more complicated, real-world, end-to-end example of a Terraform module and web server, see [terraform-packer-example](/examples/terraform-packer-example). **WARNING**: This module and the automated tests for it deploy real resources into your AWS account which can cost you money. The resources are all part of the [AWS Free Tier](https://aws.amazon.com/free/), so if you haven't used that up, it should be free, but you are completely responsible for all AWS charges. ## Running this module manually 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Run `terraform init`. 1. Run `terraform apply`. 1. When you're done, run `terraform destroy`. ## Running automated tests against this module 1. Sign up for [AWS](https://aws.amazon.com/). 1. Configure your AWS credentials using one of the [supported methods for AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html), such as setting the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. If you're using the `~/.aws/config` file for profiles then export `AWS_SDK_LOAD_CONFIG` as "True". 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `dep ensure` 1. `go test -v -run TestTerraformSshPasswordExample` ================================================ FILE: examples/terraform-ssh-password-example/main.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # PIN TERRAFORM VERSION TO >= 0.12 # The examples have been upgraded to 0.12 syntax # --------------------------------------------------------------------------------------------------------------------- terraform { # This module is now only being tested with Terraform 0.13.x. However, to make upgrading easier, we are setting # 0.12.26 as the minimum version, as that version added support for required_providers with source URLs, making it # forwards compatible with 0.13.x code. required_version = ">= 0.12.26" } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY AN EC2 INSTANCE THAT ALLOWS CONNECTIONS VIA SSH # See test/terraform_ssh_password_example.go for how to write automated tests for this code. # --------------------------------------------------------------------------------------------------------------------- provider "aws" { region = var.aws_region } # --------------------------------------------------------------------------------------------------------------------- # DEPLOY THE EC2 INSTANCE WITH A PUBLIC IP # --------------------------------------------------------------------------------------------------------------------- resource "aws_instance" "example_public" { ami = data.aws_ami.ubuntu.id instance_type = var.instance_type user_data = data.template_file.user_data.rendered vpc_security_group_ids = [ aws_security_group.example.id, ] # This EC2 Instance has a public IP and will be accessible directly from the public Internet associate_public_ip_address = "true" tags = { Name = "${var.instance_name}-public" } } # --------------------------------------------------------------------------------------------------------------------- # CREATE A SECURITY GROUP TO CONTROL WHAT REQUESTS CAN GO IN AND OUT OF THE EC2 INSTANCE # --------------------------------------------------------------------------------------------------------------------- resource "aws_security_group" "example" { name = var.instance_name egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } ingress { from_port = var.ssh_port to_port = var.ssh_port protocol = "tcp" # To keep this example simple, we allow incoming SSH requests from any IP. In real-world usage, you should only # allow SSH requests from trusted servers, such as a bastion host or VPN server. cidr_blocks = ["0.0.0.0/0"] } } # --------------------------------------------------------------------------------------------------------------------- # SET UP A TEMPLATE AROUND THE USER DATA SCRIPT # --------------------------------------------------------------------------------------------------------------------- data "template_file" "user_data" { template = file("${path.module}/user_data.sh") vars = { terratest_password = var.terratest_password } } # --------------------------------------------------------------------------------------------------------------------- # LOOK UP THE LATEST UBUNTU AMI # --------------------------------------------------------------------------------------------------------------------- data "aws_ami" "ubuntu" { most_recent = "true" owners = ["099720109477"] # Canonical filter { name = "virtualization-type" values = ["hvm"] } filter { name = "architecture" values = ["x86_64"] } filter { name = "image-type" values = ["machine"] } filter { name = "name" values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"] } } ================================================ FILE: examples/terraform-ssh-password-example/outputs.tf ================================================ output "public_instance_id" { value = aws_instance.example_public.id } output "public_instance_ip" { value = aws_instance.example_public.public_ip } ================================================ FILE: examples/terraform-ssh-password-example/user_data.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Send the log output from this script to user-data.log, syslog, and the console # From: https://alestic.com/2010/12/ec2-user-data-output/ exec > >(tee /var/log/user-data.log | logger -t user-data -s 2>/dev/console) 2>&1 # Create our new 'terratest' user adduser --disabled-password --gecos "" terratest # Set the user's password based on the random input from 'test/terraform_ssh_password_example_test.go' # shellcheck disable=SC2154 echo "terratest:${terratest_password}" | chpasswd # Enable password auth on the SSH service echo "PasswordAuthentication yes" > /etc/ssh/sshd_config.d/01-password-auth.conf # Bounce the service to apply the config change service ssh restart ================================================ FILE: examples/terraform-ssh-password-example/variables.tf ================================================ # --------------------------------------------------------------------------------------------------------------------- # ENVIRONMENT VARIABLES # Define these secrets as environment variables # --------------------------------------------------------------------------------------------------------------------- # AWS_ACCESS_KEY_ID # AWS_SECRET_ACCESS_KEY # --------------------------------------------------------------------------------------------------------------------- # REQUIRED PARAMETERS # You must provide a value for each of these parameters. # --------------------------------------------------------------------------------------------------------------------- variable "terratest_password" { description = "The password to set for the 'terratest' user within the EC2 Instance, for SSH access." type = string } # --------------------------------------------------------------------------------------------------------------------- # OPTIONAL PARAMETERS # These parameters have reasonable defaults. # --------------------------------------------------------------------------------------------------------------------- variable "aws_region" { description = "The AWS region to deploy into" type = string default = "us-east-1" } variable "instance_name" { description = "The Name tag to set for the EC2 Instance." type = string default = "terratest-example" } variable "ssh_port" { description = "The port the EC2 Instance should listen on for SSH requests." type = number default = 22 } variable "instance_type" { description = "The EC2 instance type to run." type = string default = "t2.micro" } ================================================ FILE: examples/terragrunt-example/README.md ================================================ # Terragrunt Example This folder contains a single Terragrunt unit demonstrating how to test it using Terratest's `terraform` package with `TerraformBinary: "terragrunt"`. Check out [modules/terragrunt/terragrunt_example_test.go](/modules/terragrunt/terragrunt_example_test.go) to see how you can write automated tests for this module. For testing a stack of Terragrunt units with dependencies (using `--all` commands), see [terragrunt-multi-module-example](/examples/terragrunt-multi-module-example). ## Running this module manually 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Install [Terragrunt](https://terragrunt.gruntwork.io/) and make sure it's on your `PATH`. 1. Run `terragrunt apply`. 1. When you're done, run `terragrunt destroy`. ## Running automated tests against this module 1. Install [Terraform](https://www.terraform.io/) and make sure it's on your `PATH`. 1. Install [Terragrunt](https://terragrunt.gruntwork.io/) and make sure it's on your `PATH`. 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `go test -v -run TestTerragruntExample` ================================================ FILE: examples/terragrunt-example/main.tf ================================================ variable "input" {} variable "other_input" {} output "output" { value = "${var.input} ${var.other_input}" } locals { mylocal = "local variable named mylocal" } ================================================ FILE: examples/terragrunt-example/terragrunt.hcl ================================================ inputs = { input = "one input" other_input = "another input" extraneous_input = "an unused input" } ================================================ FILE: examples/terragrunt-multi-module-example/README.md ================================================ # Terragrunt Multi-Module Example This folder contains a Terragrunt configuration with multiple modules that have dependencies (VPC → Database → App), demonstrating how to use Terratest's `terragrunt` package to test stack configurations. Check out [modules/terragrunt/terragrunt_example_test.go](/modules/terragrunt/terragrunt_example_test.go) to see how you can write automated tests for this configuration using `ApplyAll` and `DestroyAll`. ## Structure ``` . ├── modules/ # Terraform modules │ ├── vpc/ │ ├── database/ # Depends on VPC │ └── app/ # Depends on VPC and Database └── live/ # Terragrunt configurations ├── vpc/ ├── database/ └── app/ ``` ## Running this module manually 1. Install [Terraform](https://www.terraform.io/) and [Terragrunt](https://terragrunt.gruntwork.io/). 1. `cd live` 1. Run `terragrunt apply --all`. 1. When you're done, run `terragrunt destroy --all`. ## Running automated tests against this module 1. Install [Terraform](https://www.terraform.io/) and [Terragrunt](https://terragrunt.gruntwork.io/). 1. Install [Golang](https://golang.org/) and make sure this code is checked out into your `GOPATH`. 1. `cd test` 1. `go test -v -run TestTerragruntMultiModuleExample` ================================================ FILE: examples/terragrunt-multi-module-example/live/app/terragrunt.hcl ================================================ terraform { source = "../../modules//app" } dependency "vpc" { config_path = "../vpc" } dependency "database" { config_path = "../database" } inputs = { environment = "test" vpc_id = dependency.vpc.outputs.vpc_id subnet_ids = dependency.vpc.outputs.subnet_ids database_endpoint = dependency.database.outputs.database_endpoint database_port = dependency.database.outputs.database_port } ================================================ FILE: examples/terragrunt-multi-module-example/live/database/terragrunt.hcl ================================================ terraform { source = "../../modules//database" } dependency "vpc" { config_path = "../vpc" } inputs = { environment = "test" vpc_id = dependency.vpc.outputs.vpc_id subnet_ids = dependency.vpc.outputs.subnet_ids } ================================================ FILE: examples/terragrunt-multi-module-example/live/vpc/terragrunt.hcl ================================================ terraform { source = "../../modules//vpc" } inputs = { environment = "test" } ================================================ FILE: examples/terragrunt-multi-module-example/modules/app/main.tf ================================================ variable "vpc_id" { description = "VPC ID where the app will be deployed" type = string } variable "subnet_ids" { description = "Subnet IDs for the app" type = list(string) } variable "database_endpoint" { description = "Database endpoint to connect to" type = string } variable "database_port" { description = "Database port" type = number } variable "environment" { description = "Environment name" type = string } output "app_url" { description = "Application URL" value = "https://app-${var.environment}.example.com" } output "connection_string" { description = "Database connection info" value = "${var.database_endpoint}:${var.database_port}" } ================================================ FILE: examples/terragrunt-multi-module-example/modules/database/main.tf ================================================ variable "vpc_id" { description = "VPC ID where the database will be deployed" type = string } variable "subnet_ids" { description = "Subnet IDs for the database" type = list(string) } variable "environment" { description = "Environment name" type = string } output "database_endpoint" { description = "Database endpoint" value = "db-${var.environment}.example.com" } output "database_port" { description = "Database port" value = 5432 } ================================================ FILE: examples/terragrunt-multi-module-example/modules/vpc/main.tf ================================================ variable "environment" { description = "Environment name" type = string } output "vpc_id" { description = "The VPC ID" value = "vpc-${var.environment}-12345" } output "subnet_ids" { description = "List of subnet IDs" value = ["subnet-${var.environment}-a", "subnet-${var.environment}-b"] } ================================================ FILE: examples/terragrunt-second-example/main.tf ================================================ variable "input" {} variable "other_input" {} output "output" { value = "${var.input} ${var.other_input}" } ================================================ FILE: examples/terragrunt-second-example/terragrunt.hcl ================================================ inputs = { input = "one input" other_input = "another input" extraneous_input = "an unused input" more_extraneous_input = "another unused input" } ================================================ FILE: go.mod ================================================ module github.com/gruntwork-io/terratest go 1.26 require ( cloud.google.com/go v0.116.0 // indirect cloud.google.com/go/storage v1.47.0 github.com/Azure/azure-sdk-for-go v51.0.0+incompatible github.com/Azure/go-autorest/autorest v0.11.20 github.com/Azure/go-autorest/autorest/azure/auth v0.5.8 github.com/Azure/go-autorest/autorest/to v0.4.0 // indirect github.com/Azure/go-autorest/autorest/validation v0.3.1 // indirect github.com/aws/aws-lambda-go v1.47.0 github.com/go-errors/errors v1.0.2-0.20180813162953-d98b870cc4e0 // indirect github.com/go-sql-driver/mysql v1.8.1 github.com/google/go-containerregistry v0.20.2 github.com/google/uuid v1.6.0 github.com/gruntwork-io/go-commons v0.8.0 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-version v1.7.0 github.com/hashicorp/hcl/v2 v2.22.0 github.com/hashicorp/terraform-json v0.23.0 github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a github.com/jstemmer/go-junit-report v1.0.0 github.com/magiconair/properties v1.8.7 github.com/mattn/go-zglob v0.0.2-0.20190814121620-e3c945676326 github.com/miekg/dns v1.1.62 github.com/mitchellh/go-homedir v1.1.0 github.com/oracle/oci-go-sdk v7.1.0+incompatible github.com/pquerna/otp v1.4.0 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.11.1 github.com/tmccombs/hcl2json v0.6.4 github.com/urfave/cli v1.22.16 github.com/zclconf/go-cty v1.15.0 golang.org/x/crypto v0.46.0 golang.org/x/net v0.48.0 // indirect golang.org/x/oauth2 v0.34.0 google.golang.org/api v0.206.0 google.golang.org/genproto v0.0.0-20241113202542-65e8d215514f k8s.io/api v0.35.0 k8s.io/apimachinery v0.35.0 k8s.io/client-go v0.35.0 ) require ( cloud.google.com/go/cloudbuild v1.19.0 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appcontainers/armappcontainers/v3 v3.0.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2 v2.3.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/datafactory/armdatafactory/v9 v9.1.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/mysql/armmysql v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresql v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/synapse/armsynapse v0.8.0 github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates v1.4.0 github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 github.com/aws/aws-sdk-go-v2 v1.32.5 github.com/aws/aws-sdk-go-v2/config v1.28.5 github.com/aws/aws-sdk-go-v2/credentials v1.17.46 github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.41 github.com/aws/aws-sdk-go-v2/service/acm v1.30.6 github.com/aws/aws-sdk-go-v2/service/autoscaling v1.51.0 github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.44.0 github.com/aws/aws-sdk-go-v2/service/dynamodb v1.37.1 github.com/aws/aws-sdk-go-v2/service/ec2 v1.193.0 github.com/aws/aws-sdk-go-v2/service/ecr v1.36.6 github.com/aws/aws-sdk-go-v2/service/ecs v1.52.0 github.com/aws/aws-sdk-go-v2/service/iam v1.38.1 github.com/aws/aws-sdk-go-v2/service/kms v1.37.6 github.com/aws/aws-sdk-go-v2/service/lambda v1.69.0 github.com/aws/aws-sdk-go-v2/service/rds v1.91.0 github.com/aws/aws-sdk-go-v2/service/route53 v1.46.2 github.com/aws/aws-sdk-go-v2/service/s3 v1.69.0 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.34.6 github.com/aws/aws-sdk-go-v2/service/sns v1.33.6 github.com/aws/aws-sdk-go-v2/service/sqs v1.37.1 github.com/aws/aws-sdk-go-v2/service/ssm v1.56.0 github.com/aws/aws-sdk-go-v2/service/sts v1.33.1 github.com/denisenkom/go-mssqldb v0.12.3 github.com/gonvenience/ytbx v1.4.4 github.com/hashicorp/go-getter/v2 v2.2.3 github.com/homeport/dyff v1.6.0 github.com/jackc/pgx/v5 v5.7.1 github.com/lib/pq v1.10.9 github.com/slack-go/slack v0.15.0 gopkg.in/yaml.v3 v3.0.1 gotest.tools/v3 v3.5.1 ) require ( cel.dev/expr v0.25.1 // indirect cloud.google.com/go/auth v0.10.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.5 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.2.2 // indirect cloud.google.com/go/longrunning v0.6.2 // indirect cloud.google.com/go/monitoring v1.21.2 // indirect filippo.io/edwards25519 v1.1.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect github.com/Azure/go-autorest v14.2.0+incompatible // indirect github.com/Azure/go-autorest/autorest/adal v0.9.13 // indirect github.com/Azure/go-autorest/autorest/azure/cli v0.4.2 // indirect github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect github.com/Azure/go-autorest/logger v0.2.1 // indirect github.com/Azure/go-autorest/tracing v0.6.0 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect github.com/BurntSushi/toml v1.4.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.48.1 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.48.1 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.24 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.24 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.24 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.5 // indirect github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.5 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.5 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.5 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.24.6 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5 // indirect github.com/aws/smithy-go v1.22.1 // indirect github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect github.com/containerd/stargz-snapshotter/estargz v0.14.3 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dimchansky/utfbom v1.1.1 // indirect github.com/docker/cli v29.2.0+incompatible // indirect github.com/docker/distribution v2.8.2+incompatible // indirect github.com/docker/docker-credential-helpers v0.7.0 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/form3tech-oss/jwt-go v3.2.2+incompatible // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/gonvenience/bunt v1.3.5 // indirect github.com/gonvenience/neat v1.3.12 // indirect github.com/gonvenience/term v1.0.2 // indirect github.com/gonvenience/text v1.0.7 // indirect github.com/gonvenience/wrap v1.1.2 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/s2a-go v0.1.8 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect github.com/googleapis/gax-go/v2 v2.14.0 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-safetemp v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.16.5 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-ciede2000 v0.0.0-20170301095244-782e8c62fec3 // indirect github.com/mattn/go-isatty v0.0.19 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/hashstructure v1.1.0 // indirect github.com/moby/spdystream v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0-rc3 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sergi/go-diff v1.3.1 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/texttheater/golang-levenshtein v1.0.1 // indirect github.com/ulikunitz/xz v0.5.10 // indirect github.com/vbatts/tar-split v0.11.3 // indirect github.com/virtuald/go-ordered-json v0.0.0-20170621173500-b18e6e673d74 // indirect github.com/x448/float16 v0.8.4 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect go.opentelemetry.io/otel/sdk v1.40.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.30.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/term v0.38.0 // indirect golang.org/x/text v0.32.0 // indirect golang.org/x/time v0.9.0 // indirect golang.org/x/tools v0.39.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) ================================================ FILE: go.sum ================================================ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= cloud.google.com/go/auth v0.10.2 h1:oKF7rgBfSHdp/kuhXtqU/tNDr0mZqhYbEh+6SiqzkKo= cloud.google.com/go/auth v0.10.2/go.mod h1:xxA5AqpDrvS+Gkmo9RqrGGRh6WSNKKOXhY3zNOr38tI= cloud.google.com/go/auth/oauth2adapt v0.2.5 h1:2p29+dePqsCHPP1bqDJcKj4qxRyYCcbzKpFyKGt3MTk= cloud.google.com/go/auth/oauth2adapt v0.2.5/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8= cloud.google.com/go/cloudbuild v1.19.0 h1:Uo0bL251yvyWsNtO3Og9m5Z4S48cgGf3IUX7xzOcl8s= cloud.google.com/go/cloudbuild v1.19.0/go.mod h1:ZGRqbNMrVGhknIIjwASa6MqoRTOpXIVMSI+Ew5DMPuY= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/iam v1.2.2 h1:ozUSofHUGf/F4tCNy/mu9tHLTaxZFLOUiKzjcgWHGIA= cloud.google.com/go/iam v1.2.2/go.mod h1:0Ys8ccaZHdI1dEUilwzqng/6ps2YB6vRsjIe00/+6JY= cloud.google.com/go/logging v1.12.0 h1:ex1igYcGFd4S/RZWOCU51StlIEuey5bjqwH9ZYjHibk= cloud.google.com/go/logging v1.12.0/go.mod h1:wwYBt5HlYP1InnrtYI0wtwttpVU1rifnMT7RejksUAM= cloud.google.com/go/longrunning v0.6.2 h1:xjDfh1pQcWPEvnfjZmwjKQEcHnpz6lHjfy7Fo0MK+hc= cloud.google.com/go/longrunning v0.6.2/go.mod h1:k/vIs83RN4bE3YCswdXC5PFfWVILjm3hpEUlSko4PiI= cloud.google.com/go/monitoring v1.21.2 h1:FChwVtClH19E7pJ+e0xUhJPGksctZNVOk2UhMmblmdU= cloud.google.com/go/monitoring v1.21.2/go.mod h1:hS3pXvaG8KgWTSz+dAdyzPrGUYmi2Q+WFX8g2hqVEZU= cloud.google.com/go/storage v1.47.0 h1:ajqgt30fnOMmLfWfu1PWcb+V9Dxz6n+9WKjdNg5R4HM= cloud.google.com/go/storage v1.47.0/go.mod h1:Ks0vP374w0PW6jOUameJbapbQKXqkjGd/OJRp2fb9IQ= cloud.google.com/go/trace v1.11.2 h1:4ZmaBdL8Ng/ajrgKqY5jfvzqMXbrDcBsUGXOT9aqTtI= cloud.google.com/go/trace v1.11.2/go.mod h1:bn7OwXd4pd5rFuAnTrzBuoZ4ax2XQeG3qNgYmfCy0Io= filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Azure/azure-sdk-for-go v51.0.0+incompatible h1:p7blnyJSjJqf5jflHbSGhIhEpXIgIFmYZNg5uwqweso= github.com/Azure/azure-sdk-for-go v51.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0/go.mod h1:h6H6c8enJmmocHUbLiiGY6sx7f9i+X3m1CHdd5c6Rdw= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.11.0/go.mod h1:HcM1YX14R7CJcghJGOYCgdezslRSVzqwLf/q+4Y2r/0= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appcontainers/armappcontainers/v3 v3.0.0 h1:NYYoOOPGOqUXw/bGIVd6OY/K8J23a18IAlAx1tOHWNo= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appcontainers/armappcontainers/v3 v3.0.0/go.mod h1:LDN3sr8FJ36sY6ZmMes6Q2vHJ+5r1aFsE3wEo7VbXJg= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2 v2.3.0 h1:JI8PcWOImyvIUEZ0Bbmfe05FOlWkMi2KhjG+cAKaUms= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2 v2.3.0/go.mod h1:nJLFPGJkyKfDDyJiPuHIXsCi/gpJkm07EvRgiX7SGlI= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/datafactory/armdatafactory/v9 v9.1.0 h1:82oTC4oB/7AjVmPR8KMvlyHZgZ8PGdboh8c0Jol/XWY= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/datafactory/armdatafactory/v9 v9.1.0/go.mod h1:nuDWiSqiFv4Bo8LX99dl+Ecl9o1iNSLJDBsrl8iRWr4= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0/go.mod h1:LRr2FzBTQlONPPa5HREE5+RjSCTXl7BwOvYOaWTqCaI= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 h1:nnQ9vXH039UrEFxi08pPuZBE7VfqSJt343uJLw0rhWI= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0/go.mod h1:4YIVtzMFVsPwBvitCDX7J9sqthSj43QD1sP6fYc1egc= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0 h1:pPvTJ1dY0sA35JOeFq6TsY2xj6Z85Yo23Pj4wCCvu4o= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0/go.mod h1:mLfWfj8v3jfWKsL9G4eoBoXVcsqcIUTapmdKy7uGOp0= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/mysql/armmysql v1.2.0 h1:dhywcZH9yPDIje9aTqwy6psZSPzI6CJLYEprDahIBSQ= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/mysql/armmysql v1.2.0/go.mod h1:6z3b+JdBLH0eMzfBex/cvEIoEFVEwXuB0wbgdfN11iM= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresql v1.2.0 h1:0hXKrsbh2M6CQyW0TDC9Bsyd99vQmrOxiBTUfQHZjPA= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresql v1.2.0/go.mod h1:bvZZor36Jg9q9kouuMyfJ+ay77+qK+YUfThXH1FdXjU= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql v1.2.0 h1:S087deZ0kP1RUg4pU7w9U9xpUedTCbOtz+mnd0+hrkQ= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql v1.2.0/go.mod h1:B4cEyXrWBmbfMDAPnpJ1di7MAt5DKP57jPEObAvZChg= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/synapse/armsynapse v0.8.0 h1:IKCilT2DdxjeCXhiCIZb5hywpA1KDGKwpdA1WL20wT0= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/synapse/armsynapse v0.8.0/go.mod h1:IzuvA34YNVnlifc1+KhCouAKEf1VYzV439FOpyfTHzA= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates v1.4.0 h1:mtvR5ZXH5Ew6PSONd5lO5OXovWP1E3oAlgC8fpxor2Q= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates v1.4.0/go.mod h1:u560+RFVfG0CBPzkXlDW43slESbBAQjgDGi3r6z+wk8= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 h1:/g8S6wk65vfC6m3FIxJ+i5QDyN9JWwXI8Hb0Img10hU= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0/go.mod h1:gpl+q95AzZlKVI3xSoseF9QPrypk0hQqBiJYeB/cR/I= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= github.com/Azure/go-autorest/autorest v0.11.17/go.mod h1:eipySxLmqSyC5s5k1CLupqet0PSENBEDP93LQ9a8QYw= github.com/Azure/go-autorest/autorest v0.11.20 h1:s8H1PbCZSqg/DH7JMlOz6YMig6htWLNPsjDdlLqCx3M= github.com/Azure/go-autorest/autorest v0.11.20/go.mod h1:o3tqFY+QR40VOlk+pV4d77mORO64jOXSgEnPQgLK6JY= github.com/Azure/go-autorest/autorest/adal v0.9.5/go.mod h1:B7KF7jKIeC9Mct5spmyCB/A8CG/sEz1vwIRGv/bbw7A= github.com/Azure/go-autorest/autorest/adal v0.9.11/go.mod h1:nBKAnTomx8gDtl+3ZCJv2v0KACFHWTB2drffI1B68Pk= github.com/Azure/go-autorest/autorest/adal v0.9.13 h1:Mp5hbtOePIzM8pJVRa3YLrWWmZtoxRXqUEzCfJt3+/Q= github.com/Azure/go-autorest/autorest/adal v0.9.13/go.mod h1:W/MM4U6nLxnIskrw4UwWzlHfGjwUS50aOsc/I3yuU8M= github.com/Azure/go-autorest/autorest/azure/auth v0.5.8 h1:TzPg6B6fTZ0G1zBf3T54aI7p3cAT6u//TOXGPmFMOXg= github.com/Azure/go-autorest/autorest/azure/auth v0.5.8/go.mod h1:kxyKZTSfKh8OVFWPAgOgQ/frrJgeYQJPyR5fLFmXko4= github.com/Azure/go-autorest/autorest/azure/cli v0.4.2 h1:dMOmEJfkLKW/7JsokJqkyoYSgmR08hi9KrhjZb+JALY= github.com/Azure/go-autorest/autorest/azure/cli v0.4.2/go.mod h1:7qkJkT+j6b+hIpzMOwPChJhTqS8VbsqqgULzMNRugoM= github.com/Azure/go-autorest/autorest/date v0.3.0 h1:7gUk1U5M/CQbp9WoqinNzJar+8KY+LPI6wiWrP/myHw= github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= github.com/Azure/go-autorest/autorest/mocks v0.4.1 h1:K0laFcLE6VLTOwNgSxaGbUcLPuGXlNkbVvq4cW4nIHk= github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= github.com/Azure/go-autorest/autorest/to v0.4.0 h1:oXVqrxakqqV1UZdSazDOPOLvOIz+XA683u8EctwboHk= github.com/Azure/go-autorest/autorest/to v0.4.0/go.mod h1:fE8iZBn7LQR7zH/9XU2NcPR4o9jEImooCeWJcYV/zLE= github.com/Azure/go-autorest/autorest/validation v0.3.1 h1:AgyqjAd94fwNAoTjl/WQXg4VvFeRFpO+UhNyRXqF1ac= github.com/Azure/go-autorest/autorest/validation v0.3.1/go.mod h1:yhLgjC0Wda5DYXl6JAsWyUe4KVNffhoDhG0zVzUMo3E= github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/Azure/go-autorest/logger v0.2.1 h1:IG7i4p/mDa2Ce4TRyAO8IHnVhAVF3RFU+ZtXWSmf4Tg= github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.48.1 h1:UQ0AhxogsIRZDkElkblfnwjc3IaltCm2HUMvezQaL7s= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.48.1/go.mod h1:jyqM3eLpJ3IbIFDTKVz2rF9T/xWGW0rIriGwnz8l9Tk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.48.1 h1:oTX4vsorBZo/Zdum6OKPA4o7544hm6smoRv1QjpTwGo= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.48.1/go.mod h1:0wEl7vrAD8mehJyohS9HZy+WyEOaQO2mJx86Cvh93kM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.48.1 h1:8nn+rsCvTq9axyEh382S0PFLBeaFwNsT43IrPWzctRU= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.48.1/go.mod h1:viRWSEhtMZqz1rhwmOVKkWl6SwmVowfL9O2YR5gI2PE= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aws/aws-lambda-go v1.47.0 h1:0H8s0vumYx/YKs4sE7YM0ktwL2eWse+kfopsRI1sXVI= github.com/aws/aws-lambda-go v1.47.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= github.com/aws/aws-sdk-go-v2 v1.32.5 h1:U8vdWJuY7ruAkzaOdD7guwJjD06YSKmnKCJs7s3IkIo= github.com/aws/aws-sdk-go-v2 v1.32.5/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 h1:lL7IfaFzngfx0ZwUGOZdsFFnQ5uLvR0hWqqhyE7Q9M8= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7/go.mod h1:QraP0UcVlQJsmHfioCrveWOC1nbiWUl3ej08h4mXWoc= github.com/aws/aws-sdk-go-v2/config v1.28.5 h1:Za41twdCXbuyyWv9LndXxZZv3QhTG1DinqlFsSuvtI0= github.com/aws/aws-sdk-go-v2/config v1.28.5/go.mod h1:4VsPbHP8JdcdUDmbTVgNL/8w9SqOkM5jyY8ljIxLO3o= github.com/aws/aws-sdk-go-v2/credentials v1.17.46 h1:AU7RcriIo2lXjUfHFnFKYsLCwgbz1E7Mm95ieIRDNUg= github.com/aws/aws-sdk-go-v2/credentials v1.17.46/go.mod h1:1FmYyLGL08KQXQ6mcTlifyFXfJVCNJTVGuQP4m0d/UA= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20 h1:sDSXIrlsFSFJtWKLQS4PUWRvrT580rrnuLydJrCQ/yA= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20/go.mod h1:WZ/c+w0ofps+/OUqMwWgnfrgzZH1DZO1RIkktICsqnY= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.41 h1:hqcxMc2g/MwwnRMod9n6Bd+t+9Nf7d5qRg7RaXKPd6o= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.41/go.mod h1:d1eH0VrttvPmrCraU68LOyNdu26zFxQFjrVSb5vdhog= github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.24 h1:4usbeaes3yJnCFC7kfeyhkdkPtoRYPa/hTmCqMpKpLI= github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.24/go.mod h1:5CI1JemjVwde8m2WG3cz23qHKPOxbpkq0HaoreEgLIY= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.24 h1:N1zsICrQglfzaBnrfM0Ys00860C+QFwu6u/5+LomP+o= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.24/go.mod h1:dCn9HbJ8+K31i8IQ8EWmWj0EiIk0+vKiHNMxTTYveAg= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.24 h1:JX70yGKLj25+lMC5Yyh8wBtvB01GDilyRuJvXJ4piD0= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.24/go.mod h1:+Ln60j9SUTD0LEwnhEB0Xhg61DHqplBrbZpLgyjoEHg= github.com/aws/aws-sdk-go-v2/service/acm v1.30.6 h1:fDg0RlN30Xf/yYzEUL/WXqhmgFsjVb/I3230oCfyI5w= github.com/aws/aws-sdk-go-v2/service/acm v1.30.6/go.mod h1:zRR6jE3v/TcbfO8C2P+H0Z+kShiKKVaVyoIl8NQRjyg= github.com/aws/aws-sdk-go-v2/service/autoscaling v1.51.0 h1:1KzQVZi7OTixxaVJ8fWaJAUBjme+iQ3zBOCZhE4RgxQ= github.com/aws/aws-sdk-go-v2/service/autoscaling v1.51.0/go.mod h1:I1+/2m+IhnK5qEbhS3CrzjeiVloo9sItE/2K+so0fkU= github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.44.0 h1:OREVd94+oXW5a+3SSUAo4K0L5ci8cucCLu+PSiek8OU= github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.44.0/go.mod h1:Qbr4yfpNqVNl69l/GEDK+8wxLf/vHi0ChoiSDzD7thU= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.37.1 h1:vucMirlM6D+RDU8ncKaSZ/5dGrXNajozVwpmWNPn2gQ= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.37.1/go.mod h1:fceORfs010mNxZbQhfqUjUeHlTwANmIT4mvHamuUaUg= github.com/aws/aws-sdk-go-v2/service/ec2 v1.193.0 h1:RhSoBFT5/8tTmIseJUXM6INTXTQDF8+0oyxWBnozIms= github.com/aws/aws-sdk-go-v2/service/ec2 v1.193.0/go.mod h1:mzj8EEjIHSN2oZRXiw1Dd+uB4HZTl7hC8nBzX9IZMWw= github.com/aws/aws-sdk-go-v2/service/ecr v1.36.6 h1:zg+3FGHA0PBs0KM25qE/rOf2o5zsjNa1g/Qq83+SDI0= github.com/aws/aws-sdk-go-v2/service/ecr v1.36.6/go.mod h1:ZSq54Z9SIsOTf1Efwgw1msilSs4XVEfVQiP9nYVnKpM= github.com/aws/aws-sdk-go-v2/service/ecs v1.52.0 h1:7/vgFWplkusJN/m+3QOa+W9FNRqa8ujMPNmdufRaJpg= github.com/aws/aws-sdk-go-v2/service/ecs v1.52.0/go.mod h1:dPTOvmjJQ1T7Q+2+Xs2KSPrMvx+p0rpyV+HsQVnUK4o= github.com/aws/aws-sdk-go-v2/service/iam v1.38.1 h1:hfkzDZHBp9jAT4zcd5mtqckpU4E3Ax0LQaEWWk1VgN8= github.com/aws/aws-sdk-go-v2/service/iam v1.38.1/go.mod h1:u36ahDtZcQHGmVm/r+0L1sfKX4fzLEMdCqiKRKkUMVM= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.5 h1:gvZOjQKPxFXy1ft3QnEyXmT+IqneM9QAUWlM3r0mfqw= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.5/go.mod h1:DLWnfvIcm9IET/mmjdxeXbBKmTCm0ZB8p1za9BVteM8= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.5 h1:3Y457U2eGukmjYjeHG6kanZpDzJADa2m0ADqnuePYVQ= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.5/go.mod h1:CfwEHGkTjYZpkQ/5PvcbEtT7AJlG68KkEvmtwU8z3/U= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.5 h1:wtpJ4zcwrSbwhECWQoI/g6WM9zqCcSpHDJIWSbMLOu4= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.5/go.mod h1:qu/W9HXQbbQ4+1+JcZp0ZNPV31ym537ZJN+fiS7Ti8E= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.5 h1:P1doBzv5VEg1ONxnJss1Kh5ZG/ewoIE4MQtKKc6Crgg= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.5/go.mod h1:NOP+euMW7W3Ukt28tAxPuoWao4rhhqJD3QEBk7oCg7w= github.com/aws/aws-sdk-go-v2/service/kms v1.37.6 h1:CZImQdb1QbU9sGgJ9IswhVkxAcjkkD1eQTMA1KHWk+E= github.com/aws/aws-sdk-go-v2/service/kms v1.37.6/go.mod h1:YJDdlK0zsyxVBxGU48AR/Mi8DMrGdc1E3Yij4fNrONA= github.com/aws/aws-sdk-go-v2/service/lambda v1.69.0 h1:BXt75frE/FYtAmEDBJRBa2HexOw+oAZWZl6QknZEFgg= github.com/aws/aws-sdk-go-v2/service/lambda v1.69.0/go.mod h1:guz2K3x4FKSdDaoeB+TPVgJNU9oj2gftbp5cR8ela1A= github.com/aws/aws-sdk-go-v2/service/rds v1.91.0 h1:eqHz3Uih+gb0vLE5Cc4Xf733vOxsxDp6GFUUVQU4d7w= github.com/aws/aws-sdk-go-v2/service/rds v1.91.0/go.mod h1:h2jc7IleH3xHY7y+h8FH7WAZcz3IVLOB6/jXotIQ/qU= github.com/aws/aws-sdk-go-v2/service/route53 v1.46.2 h1:wmt05tPp/CaRZpPV5B4SaJ5TwkHKom07/BzHoLdkY1o= github.com/aws/aws-sdk-go-v2/service/route53 v1.46.2/go.mod h1:d+K9HESMpGb1EU9/UmmpInbGIUcAkwmcY6ZO/A3zZsw= github.com/aws/aws-sdk-go-v2/service/s3 v1.69.0 h1:Q2ax8S21clKOnHhhr933xm3JxdJebql+R7aNo7p7GBQ= github.com/aws/aws-sdk-go-v2/service/s3 v1.69.0/go.mod h1:ralv4XawHjEMaHOWnTFushl0WRqim/gQWesAMF6hTow= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.34.6 h1:1KDMKvOKNrpD667ORbZ/+4OgvUoaok1gg/MLzrHF9fw= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.34.6/go.mod h1:DmtyfCfONhOyVAJ6ZMTrDSFIeyCBlEO93Qkfhxwbxu0= github.com/aws/aws-sdk-go-v2/service/sns v1.33.6 h1:lEUtRHICiXsd7VRwRjXaY7MApT2X4Ue0Mrwe6XbyBro= github.com/aws/aws-sdk-go-v2/service/sns v1.33.6/go.mod h1:SODr0Lu3lFdT0SGsGX1TzFTapwveBrT5wztVoYtppm8= github.com/aws/aws-sdk-go-v2/service/sqs v1.37.1 h1:39WvSrVq9DD6UHkD+fx5x19P5KpRQfNdtgReDVNbelc= github.com/aws/aws-sdk-go-v2/service/sqs v1.37.1/go.mod h1:3gwPzC9LER/BTQdQZ3r6dUktb1rSjABF1D3Sr6nS7VU= github.com/aws/aws-sdk-go-v2/service/ssm v1.56.0 h1:mADKqoZaodipGgiZfuAjtlcr4IVBtXPZKVjkzUZCCYM= github.com/aws/aws-sdk-go-v2/service/ssm v1.56.0/go.mod h1:l9qF25TzH95FhcIak6e4vt79KE4I7M2Nf59eMUVjj6c= github.com/aws/aws-sdk-go-v2/service/sso v1.24.6 h1:3zu537oLmsPfDMyjnUS2g+F2vITgy5pB74tHI+JBNoM= github.com/aws/aws-sdk-go-v2/service/sso v1.24.6/go.mod h1:WJSZH2ZvepM6t6jwu4w/Z45Eoi75lPN7DcydSRtJg6Y= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5 h1:K0OQAsDywb0ltlFrZm0JHPY3yZp/S9OaoLU33S7vPS8= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5/go.mod h1:ORITg+fyuMoeiQFiVGoqB3OydVTLkClw/ljbblMq6Cc= github.com/aws/aws-sdk-go-v2/service/sts v1.33.1 h1:6SZUVRQNvExYlMLbHdlKB48x0fLbc2iVROyaNEwBHbU= github.com/aws/aws-sdk-go-v2/service/sts v1.33.1/go.mod h1:GqWyYCwLXnlUB1lOAXQyNSPqPLQJvmo8J0DWBzp9mtg= github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro= github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/containerd/stargz-snapshotter/estargz v0.14.3 h1:OqlDCK3ZVUO6C3B/5FSkDwbkEETK84kQgEeFwDC+62k= github.com/containerd/stargz-snapshotter/estargz v0.14.3/go.mod h1:KY//uOCIkSuNAHhJogcZtrNHdKrA99/FCCRjE3HD36o= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denisenkom/go-mssqldb v0.12.3 h1:pBSGx9Tq67pBOTLmxNuirNTeB8Vjmf886Kx+8Y+8shw= github.com/denisenkom/go-mssqldb v0.12.3/go.mod h1:k0mtMFOnU+AihqFxPMiF05rtiDrorD1Vrm1KEz5hxDo= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8= github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM= github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker-credential-helpers v0.7.0 h1:xtCHsjxogADNZcdv1pKUHXryefjlVRqWqIhk/uXJp0A= github.com/docker/docker-credential-helpers v0.7.0/go.mod h1:rETQfLdHNT3foU5kuNkFR1R1V12OJRRO5lzt2D1b5X0= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/form3tech-oss/jwt-go v3.2.2+incompatible h1:TcekIExNqud5crz4xD2pavyTgWiPvpYe4Xau31I0PRk= github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-errors/errors v1.0.2-0.20180813162953-d98b870cc4e0 h1:skJKxRtNmevLqnayafdLe2AsenqRupVmzZSqrvb5caU= github.com/go-errors/errors v1.0.2-0.20180813162953-d98b870cc4e0/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M= github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/gonvenience/bunt v1.3.5 h1:wSQquifvwEWtzn27k1ngLfeLaStyt0k1b/K6TrlCNAs= github.com/gonvenience/bunt v1.3.5/go.mod h1:7ApqkVBEWvX04oJ28Q2WeI/BvJM6VtukaJAU/q/pTs8= github.com/gonvenience/neat v1.3.12 h1:xwIyRbJcG9LgcDYys+HHLH9DqqHeQsUpS5CfBUeskbs= github.com/gonvenience/neat v1.3.12/go.mod h1:8OljAIgPelN0uPPO94VBqxK+Kz98d6ZFwHDg5o/PfkE= github.com/gonvenience/term v1.0.2 h1:qKa2RydbWIrabGjR/fegJwpW5m+JvUwFL8mLhHzDXn0= github.com/gonvenience/term v1.0.2/go.mod h1:wThTR+3MzWtWn7XGVW6qQ65uaVf8GHED98KmwpuEQeo= github.com/gonvenience/text v1.0.7 h1:YmIqmgTwxnACYCG59DykgMbomwteYyNhAmEUEJtPl14= github.com/gonvenience/text v1.0.7/go.mod h1:OAjH+mohRszffLY6OjgQcUXiSkbrIavooFpfIt1ZwAs= github.com/gonvenience/wrap v1.1.2 h1:xPKxNwL1HCguwyM+HlP/1CIuc9LRd7k8RodLwe9YTZA= github.com/gonvenience/wrap v1.1.2/go.mod h1:GiryBSXoI3BAAhbWD1cZVj7RZmtiu0ERi/6R6eJfslI= github.com/gonvenience/ytbx v1.4.4 h1:jQopwyaLsVGuwdxSiN4WkXjsEaFNPJ3V4lUj7eyEpzo= github.com/gonvenience/ytbx v1.4.4/go.mod h1:w37+MKCPcCMY/jpPNmEklD4xKqrOAVBO6kIWW2+uI6M= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.20.2 h1:B1wPJ1SN/S7pB+ZAimcciVD+r+yV/l/DSArMxlbwseo= github.com/google/go-containerregistry v0.20.2/go.mod h1:z38EKdKh4h7IP2gSfUUqEvalZBqs6AoLeWfUy34nQC8= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= github.com/googleapis/gax-go/v2 v2.14.0 h1:f+jMrjBPl+DL9nI4IQzLUxMq7XrAqFYB7hBPqMNIe8o= github.com/googleapis/gax-go/v2 v2.14.0/go.mod h1:lhBCnjdLrWRaPvLWhmc8IS24m9mr07qSYnHncrgo+zk= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/gruntwork-io/go-commons v0.8.0 h1:k/yypwrPqSeYHevLlEDmvmgQzcyTwrlZGRaxEM6G0ro= github.com/gruntwork-io/go-commons v0.8.0/go.mod h1:gtp0yTtIBExIZp7vyIV9I0XQkVwiQZze678hvDXof78= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-getter/v2 v2.2.3 h1:6CVzhT0KJQHqd9b0pK3xSP0CM/Cv+bVhk+jcaRJ2pGk= github.com/hashicorp/go-getter/v2 v2.2.3/go.mod h1:hp5Yy0GMQvwWVUmwLs3ygivz1JSLI323hdIE9J9m7TY= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhEyExpmo= github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/hcl/v2 v2.22.0 h1:hkZ3nCtqeJsDhPRFz5EA9iwcG1hNWGePOTw6oyul12M= github.com/hashicorp/hcl/v2 v2.22.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= github.com/hashicorp/terraform-json v0.23.0 h1:sniCkExU4iKtTADReHzACkk8fnpQXrdD2xoR+lppBkI= github.com/hashicorp/terraform-json v0.23.0/go.mod h1:MHdXbBAbSg0GvzuWazEGKAn/cyNfIB7mN6y7KJN6y2c= github.com/homeport/dyff v1.6.0 h1:AN+ikld0Fy+qx34YE7655b/bpWuxS6cL9k852pE2GUc= github.com/homeport/dyff v1.6.0/go.mod h1:FlAOFYzeKvxmU5nTrnG+qrlJVWpsFew7pt8L99p5q8k= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a h1:zPPuIq2jAWWPTrGt70eK/BSch+gFAGrNzecsoENgu2o= github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a/go.mod h1:yL958EeXv8Ylng6IfnvG4oflryUi3vgA3xPs9hmII1s= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v1.0.0 h1:8X1gzZpR+nVQLAht+L/foqOeX2l9DTZoaIPbEQHxsds= github.com/jstemmer/go-junit-report v1.0.0/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI= github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-ciede2000 v0.0.0-20170301095244-782e8c62fec3 h1:BXxTozrOU8zgC5dkpn3J6NTRdoP+hjok/e+ACr4Hibk= github.com/mattn/go-ciede2000 v0.0.0-20170301095244-782e8c62fec3/go.mod h1:x1uk6vxTiVuNt6S5R2UYgdhpj3oKojXvOXauHZ7dEnI= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-zglob v0.0.1/go.mod h1:9fxibJccNxU2cnpIKLRRFA7zX7qhkJIQWBb449FYHOo= github.com/mattn/go-zglob v0.0.2-0.20190814121620-e3c945676326 h1:ofNAzWCcyTALn2Zv40+8XitdzCgXY6e9qvXwN9W0YXg= github.com/mattn/go-zglob v0.0.2-0.20190814121620-e3c945676326/go.mod h1:9fxibJccNxU2cnpIKLRRFA7zX7qhkJIQWBb449FYHOo= github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ= github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/hashstructure v1.1.0 h1:P6P1hdjqAAknpY/M1CGipelZgp+4y9ja9kmUZPXP+H0= github.com/mitchellh/hashstructure v1.1.0/go.mod h1:xUDAozZz0Wmdiufv0uyhnHkUTN6/6d8ulp4AwfLKrmA= github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0-rc3 h1:fzg1mXZFj8YdPeNkRXMg+zb88BFV0Ys52cJydRwBkb8= github.com/opencontainers/image-spec v1.1.0-rc3/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8= github.com/oracle/oci-go-sdk v7.1.0+incompatible h1:ul/J6rOlLTuVgAB9oSBMwse0U9q8tZj3xx/NjmjRM2g= github.com/oracle/oci-go-sdk v7.1.0+incompatible/go.mod h1:VQb79nF8Z2cwLkLS35ukwStZIg5F66tcBccjip/j888= github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI= github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/slack-go/slack v0.15.0 h1:LE2lj2y9vqqiOf+qIIy0GvEoxgF1N5yLGZffmEZykt0= github.com/slack-go/slack v0.15.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/texttheater/golang-levenshtein v1.0.1 h1:+cRNoVrfiwufQPhoMzB6N0Yf/Mqajr6t1lOv8GyGE2U= github.com/texttheater/golang-levenshtein v1.0.1/go.mod h1:PYAKrbF5sAiq9wd+H82hs7gNaen0CplQ9uvm6+enD/8= github.com/tmccombs/hcl2json v0.6.4 h1:/FWnzS9JCuyZ4MNwrG4vMrFrzRgsWEOVi+1AyYUVLGw= github.com/tmccombs/hcl2json v0.6.4/go.mod h1:+ppKlIW3H5nsAsZddXPy2iMyvld3SHxyjswOZhavRDk= github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8= github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli v1.22.12/go.mod h1:sSBEIC79qR6OvcmsD4U3KABeOTxDqQtdDnaFuUN30b8= github.com/urfave/cli v1.22.16 h1:MH0k6uJxdwdeWQTwhSO42Pwr4YLrNLwBtg1MRgTqPdQ= github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po= github.com/vbatts/tar-split v0.11.3 h1:hLFqsOLQ1SsppQNTMpkpPXClLDfC2A3Zgy9OUU+RVck= github.com/vbatts/tar-split v0.11.3/go.mod h1:9QlHN18E+fEH7RdG+QAJJcuya3rqT7eXSTY7wGrAokY= github.com/virtuald/go-ordered-json v0.0.0-20170621173500-b18e6e673d74 h1:JwtAtbp7r/7QSyGz8mKUbYJBg2+6Cd7OjM8o/GNOcVo= github.com/virtuald/go-ordered-json v0.0.0-20170621173500-b18e6e673d74/go.mod h1:RmMWU37GKR2s6pgrIEB4ixgpVCt/cf7dnJv3fuH1J1c= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/zclconf/go-cty v1.15.0 h1:tTCRWxsexYUmtt/wVxgDClUe+uQusuI443uL6e+5sXQ= github.com/zclconf/go-cty v1.15.0/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0 h1:WDdP9acbMYjbKIyJUhTvtzj601sVJOqgWdUxSdR/Ysc= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0/go.mod h1:BLbf7zbNIONBLPwvFnwNHGj4zge8uTCM/UPIVW1Mq2I= go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220906165534-d0df966e6959/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.206.0 h1:A27GClesCSheW5P2BymVHjpEeQ2XHH8DI8Srs2HI2L8= google.golang.org/api v0.206.0/go.mod h1:BtB8bfjTYIrai3d8UyvPmV9REGgox7coh+ZRwm0b+W8= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20241113202542-65e8d215514f h1:zDoHYmMzMacIdjNe+P2XiTmPsLawi/pCbSPfxt6lTfw= google.golang.org/genproto v0.0.0-20241113202542-65e8d215514f/go.mod h1:Q5m6g8b5KaFFzsQFIGdJkSJDGeJiybVenoYFMMa3ohI= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/grpc/stats/opentelemetry v0.0.0-20240907200651-3ffb98b2c93a h1:UIpYSuWdWHSzjwcAFRLjKcPXFZVVLXGEM23W+NWqipw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= ================================================ FILE: internal/lib/formatting/format.go ================================================ // Package formatting provides internal utilities for formatting Terraform/Terragrunt CLI arguments. package formatting import ( "fmt" "reflect" "strconv" "strings" ) // FormatBackendConfigAsArgs formats backend configuration as Terraform CLI args. // Example: {"bucket": "my-bucket"} -> ["-backend-config=bucket=my-bucket"] func FormatBackendConfigAsArgs(vars map[string]interface{}) []string { return formatTerraformArgs(vars, "-backend-config", false, true) } // FormatPluginDirAsArgs formats plugin directory as a Terraform CLI arg. // Returns nil if pluginDir is empty. func FormatPluginDirAsArgs(pluginDir string) []string { if pluginDir == "" { return nil } return []string{fmt.Sprintf("-plugin-dir=%v", pluginDir)} } // formatTerraformArgs formats vars as CLI args with the given prefix. func formatTerraformArgs(vars map[string]interface{}, prefix string, useSpaceAsSeparator bool, omitNil bool) []string { var args []string for key, value := range vars { var argValue string if omitNil && value == nil { argValue = key } else { hclString := toHclString(value, false) argValue = fmt.Sprintf("%s=%s", key, hclString) } if useSpaceAsSeparator { args = append(args, prefix, argValue) } else { args = append(args, fmt.Sprintf("%s=%s", prefix, argValue)) } } return args } // toHclString converts Go values to HCL-formatted strings for Terraform CLI arguments. // Handles primitives, slices, and maps. Example: []int{1,2,3} -> "[1, 2, 3]" func toHclString(value interface{}, isNested bool) string { if slice, isSlice := tryToConvertToGenericSlice(value); isSlice { return sliceToHclString(slice) } else if m, isMap := tryToConvertToGenericMap(value); isMap { return mapToHclString(m) } else { return primitiveToHclString(value, isNested) } } // tryToConvertToGenericSlice converts any slice type to []interface{} using reflection. func tryToConvertToGenericSlice(value interface{}) ([]interface{}, bool) { reflectValue := reflect.ValueOf(value) if reflectValue.Kind() != reflect.Slice { return []interface{}{}, false } genericSlice := make([]interface{}, reflectValue.Len()) for i := 0; i < reflectValue.Len(); i++ { genericSlice[i] = reflectValue.Index(i).Interface() } return genericSlice, true } // tryToConvertToGenericMap converts any map[string]T to map[string]interface{} using reflection. func tryToConvertToGenericMap(value interface{}) (map[string]interface{}, bool) { reflectValue := reflect.ValueOf(value) if reflectValue.Kind() != reflect.Map { return map[string]interface{}{}, false } reflectType := reflect.TypeOf(value) if reflectType.Key().Kind() != reflect.String { return map[string]interface{}{}, false } genericMap := make(map[string]interface{}, reflectValue.Len()) mapKeys := reflectValue.MapKeys() for _, key := range mapKeys { genericMap[key.String()] = reflectValue.MapIndex(key).Interface() } return genericMap, true } func sliceToHclString(slice []interface{}) string { hclValues := []string{} for _, value := range slice { hclValue := toHclString(value, true) hclValues = append(hclValues, hclValue) } return fmt.Sprintf("[%s]", strings.Join(hclValues, ", ")) } func mapToHclString(m map[string]interface{}) string { keyValuePairs := []string{} for key, value := range m { keyValuePair := fmt.Sprintf(`"%s" = %s`, key, toHclString(value, true)) keyValuePairs = append(keyValuePairs, keyValuePair) } return fmt.Sprintf("{%s}", strings.Join(keyValuePairs, ", ")) } func primitiveToHclString(value interface{}, isNested bool) string { if value == nil { return "null" } switch v := value.(type) { case bool: return strconv.FormatBool(v) case string: // If string is nested in a larger data structure (e.g. list of string, map of string), ensure value is quoted if isNested { return fmt.Sprintf("\"%v\"", v) } return fmt.Sprintf("%v", v) default: return fmt.Sprintf("%v", v) } } ================================================ FILE: internal/lib/formatting/format_test.go ================================================ package formatting import ( "testing" "github.com/stretchr/testify/assert" ) func TestFormatBackendConfigAsArgs(t *testing.T) { t.Parallel() tests := []struct { name string input map[string]interface{} expect []string }{ { name: "empty config", input: map[string]interface{}{}, expect: []string{}, }, { name: "string value", input: map[string]interface{}{"bucket": "my-bucket"}, expect: []string{"-backend-config=bucket=my-bucket"}, }, { name: "nil value omitted", input: map[string]interface{}{"key": nil}, expect: []string{"-backend-config=key"}, }, { name: "multiple values", input: map[string]interface{}{"region": "us-east-1", "bucket": "state"}, expect: []string{"-backend-config=bucket=state", "-backend-config=region=us-east-1"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := FormatBackendConfigAsArgs(tt.input) assert.ElementsMatch(t, tt.expect, result) }) } } func TestFormatPluginDirAsArgs(t *testing.T) { t.Parallel() tests := []struct { name string input string expect []string }{ { name: "empty path", input: "", expect: nil, }, { name: "valid path", input: "/path/to/plugins", expect: []string{"-plugin-dir=/path/to/plugins"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := FormatPluginDirAsArgs(tt.input) assert.Equal(t, tt.expect, result) }) } } func TestToHclString(t *testing.T) { t.Parallel() tests := []struct { name string input interface{} expect string }{ {"nil", nil, "null"}, {"bool true", true, "true"}, {"bool false", false, "false"}, {"string", "hello", "hello"}, {"int", 42, "42"}, {"list of strings", []string{"a", "b"}, `["a", "b"]`}, {"list of ints", []int{1, 2, 3}, "[1, 2, 3]"}, {"map", map[string]string{"key": "value"}, `{"key" = "value"}`}, {"nested list", []interface{}{[]int{1, 2}}, "[[1, 2]]"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := toHclString(tt.input, false) assert.Equal(t, tt.expect, result) }) } } func TestToHclStringNested(t *testing.T) { t.Parallel() // Nested strings should be quoted result := toHclString("nested", true) assert.Equal(t, `"nested"`, result) // Non-nested strings should not be quoted result = toHclString("not-nested", false) assert.Equal(t, "not-nested", result) } ================================================ FILE: mise.toml ================================================ [tools] go = "1.26.1" golangci-lint = "2.11.3" ================================================ FILE: modules/aws/account.go ================================================ package aws import ( "context" "errors" "strings" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/gruntwork-io/terratest/modules/testing" ) // GetAccountId gets the Account ID for the currently logged in IAM User. func GetAccountId(t testing.TestingT) string { id, err := GetAccountIdE(t) if err != nil { t.Fatal(err) } return id } // GetAccountIdE gets the Account ID for the currently logged in IAM User. func GetAccountIdE(t testing.TestingT) (string, error) { stsClient, err := NewStsClientE(t, defaultRegion) if err != nil { return "", err } identity, err := stsClient.GetCallerIdentity(context.Background(), &sts.GetCallerIdentityInput{}) if err != nil { return "", err } return aws.ToString(identity.Account), nil } // An IAM arn is of the format arn:aws:iam::123456789012:user/test. The account id is the number after arn:aws:iam::, // so we split on a colon and return the 5th item. func extractAccountIDFromARN(arn string) (string, error) { arnParts := strings.Split(arn, ":") if len(arnParts) < 5 { return "", errors.New("Unrecognized format for IAM ARN: " + arn) } return arnParts[4], nil } // NewStsClientE creates a new STS client. func NewStsClientE(t testing.TestingT, region string) (*sts.Client, error) { sess, err := NewAuthenticatedSession(region) if err != nil { return nil, err } return sts.NewFromConfig(*sess), nil } ================================================ FILE: modules/aws/account_test.go ================================================ package aws import ( "testing" "github.com/stretchr/testify/assert" ) func TestGetAccountId(t *testing.T) { accountID := GetAccountId(t) assert.Regexp(t, "^[0-9]{12}$", accountID) } func TestExtractAccountIdFromValidArn(t *testing.T) { t.Parallel() expectedAccountID := "123456789012" arn := "arn:aws:iam::" + expectedAccountID + ":user/test" actualAccountID, err := extractAccountIDFromARN(arn) if err != nil { t.Fatalf("Unexpected error while extracting account id from arn %s: %s", arn, err) } if actualAccountID != expectedAccountID { t.Fatalf("Did not get expected account id. Expected: %s. Actual: %s.", expectedAccountID, actualAccountID) } } func TestExtractAccountIdFromInvalidArn(t *testing.T) { t.Parallel() _, err := extractAccountIDFromARN("invalidArn") if err == nil { t.Fatalf("Expected an error when extracting an account id from an invalid ARN, but got nil") } } ================================================ FILE: modules/aws/acm.go ================================================ package aws import ( "context" "github.com/aws/aws-sdk-go-v2/service/acm" "github.com/gruntwork-io/terratest/modules/testing" ) // GetAcmCertificateArn gets the ACM certificate for the given domain name in the given region. func GetAcmCertificateArn(t testing.TestingT, awsRegion string, certDomainName string) string { arn, err := GetAcmCertificateArnE(t, awsRegion, certDomainName) if err != nil { t.Fatal(err) } return arn } // GetAcmCertificateArnE gets the ACM certificate for the given domain name in the given region. func GetAcmCertificateArnE(t testing.TestingT, awsRegion string, certDomainName string) (string, error) { acmClient, err := NewAcmClientE(t, awsRegion) if err != nil { return "", err } result, err := acmClient.ListCertificates(context.Background(), &acm.ListCertificatesInput{}) if err != nil { return "", err } for _, summary := range result.CertificateSummaryList { if *summary.DomainName == certDomainName { return *summary.CertificateArn, nil } } return "", nil } // NewAcmClient create a new ACM client. func NewAcmClient(t testing.TestingT, region string) *acm.Client { client, err := NewAcmClientE(t, region) if err != nil { t.Fatal(err) } return client } // NewAcmClientE creates a new ACM client. func NewAcmClientE(t testing.TestingT, awsRegion string) (*acm.Client, error) { sess, err := NewAuthenticatedSession(awsRegion) if err != nil { return nil, err } return acm.NewFromConfig(*sess), nil } ================================================ FILE: modules/aws/ami.go ================================================ package aws import ( "context" "fmt" "sort" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/testing" ) // These are commonly used AMI account IDs. const ( CanonicalAccountId = "099720109477" CentOsAccountId = "679593333241" AmazonAccountId = "amazon" ) // DeleteAmiAndAllSnapshots will delete the given AMI along with all EBS snapshots that backed that AMI func DeleteAmiAndAllSnapshots(t testing.TestingT, region string, ami string) { err := DeleteAmiAndAllSnapshotsE(t, region, ami) if err != nil { t.Fatal(err) } } // DeleteAmiAndAllSnapshotsE will delete the given AMI along with all EBS snapshots that backed that AMI func DeleteAmiAndAllSnapshotsE(t testing.TestingT, region string, ami string) error { snapshots, err := GetEbsSnapshotsForAmiE(t, region, ami) if err != nil { return err } err = DeleteAmiE(t, region, ami) if err != nil { return err } for _, snapshot := range snapshots { err = DeleteEbsSnapshotE(t, region, snapshot) if err != nil { return err } } return nil } // GetEbsSnapshotsForAmi retrieves the EBS snapshots which back the given AMI func GetEbsSnapshotsForAmi(t testing.TestingT, region string, ami string) []string { snapshots, err := GetEbsSnapshotsForAmiE(t, region, ami) if err != nil { t.Fatal(err) } return snapshots } // GetEbsSnapshotsForAmiE retrieves the EBS snapshots which back the given AMI func GetEbsSnapshotsForAmiE(t testing.TestingT, region string, ami string) ([]string, error) { logger.Default.Logf(t, "Retrieving EBS snapshots backing AMI %s", ami) ec2Client, err := NewEc2ClientE(t, region) if err != nil { return nil, err } images, err := ec2Client.DescribeImages(context.Background(), &ec2.DescribeImagesInput{ ImageIds: []string{ ami, }, }) if err != nil { return nil, err } var snapshots []string for _, image := range images.Images { for _, mapping := range image.BlockDeviceMappings { if mapping.Ebs != nil && mapping.Ebs.SnapshotId != nil { snapshots = append(snapshots, aws.ToString(mapping.Ebs.SnapshotId)) } } } return snapshots, err } // GetMostRecentAmiId gets the ID of the most recent AMI in the given region that has the given owner and matches the given filters. Each // filter should correspond to the name and values of a filter supported by DescribeImagesInput: // https://docs.aws.amazon.com/sdk-for-go/api/service/ec2/#DescribeImagesInput func GetMostRecentAmiId(t testing.TestingT, region string, ownerId string, filters map[string][]string) string { amiID, err := GetMostRecentAmiIdE(t, region, ownerId, filters) if err != nil { t.Fatal(err) } return amiID } // GetMostRecentAmiIdE gets the ID of the most recent AMI in the given region that has the given owner and matches the given filters. Each // filter should correspond to the name and values of a filter supported by DescribeImagesInput: // https://docs.aws.amazon.com/sdk-for-go/api/service/ec2/#DescribeImagesInput func GetMostRecentAmiIdE(t testing.TestingT, region string, ownerId string, filters map[string][]string) (string, error) { ec2Client, err := NewEc2ClientE(t, region) if err != nil { return "", err } var ec2Filters []types.Filter for name, values := range filters { ec2Filters = append(ec2Filters, types.Filter{Name: aws.String(name), Values: values}) } input := ec2.DescribeImagesInput{ Filters: ec2Filters, IncludeDeprecated: aws.Bool(true), Owners: []string{ownerId}, } out, err := ec2Client.DescribeImages(context.Background(), &input) if err != nil { return "", err } if len(out.Images) == 0 { return "", NoImagesFound{Region: region, OwnerId: ownerId, Filters: filters} } mostRecentImage := mostRecentAMI(out.Images) return aws.ToString(mostRecentImage.ImageId), nil } // Image sorting code borrowed from: https://github.com/hashicorp/packer/blob/7f4112ba229309cfc0ebaa10ded2abdfaf1b22c8/builder/amazon/common/step_source_ami_info.go type imageSort []types.Image func (a imageSort) Len() int { return len(a) } func (a imageSort) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a imageSort) Less(i, j int) bool { iTime, _ := time.Parse(time.RFC3339, *a[i].CreationDate) jTime, _ := time.Parse(time.RFC3339, *a[j].CreationDate) return iTime.Unix() < jTime.Unix() } // mostRecentAMI returns the most recent AMI out of a slice of images. func mostRecentAMI(images []types.Image) types.Image { sortedImages := images sort.Sort(imageSort(sortedImages)) return sortedImages[len(sortedImages)-1] } // GetUbuntu1404Ami gets the ID of the most recent Ubuntu 14.04 HVM x86_64 EBS GP2 AMI in the given region. func GetUbuntu1404Ami(t testing.TestingT, region string) string { amiID, err := GetUbuntu1404AmiE(t, region) if err != nil { t.Fatal(err) } return amiID } // GetUbuntu1404AmiE gets the ID of the most recent Ubuntu 14.04 HVM x86_64 EBS GP2 AMI in the given region. func GetUbuntu1404AmiE(t testing.TestingT, region string) (string, error) { filters := map[string][]string{ "name": {"*ubuntu-trusty-14.04-amd64-server-*"}, "virtualization-type": {"hvm"}, "architecture": {"x86_64"}, "root-device-type": {"ebs"}, "block-device-mapping.volume-type": {"gp2"}, } return GetMostRecentAmiIdE(t, region, CanonicalAccountId, filters) } // GetUbuntu1604Ami gets the ID of the most recent Ubuntu 16.04 HVM x86_64 EBS GP2 AMI in the given region. func GetUbuntu1604Ami(t testing.TestingT, region string) string { amiID, err := GetUbuntu1604AmiE(t, region) if err != nil { t.Fatal(err) } return amiID } // GetUbuntu1604AmiE gets the ID of the most recent Ubuntu 16.04 HVM x86_64 EBS GP2 AMI in the given region. func GetUbuntu1604AmiE(t testing.TestingT, region string) (string, error) { filters := map[string][]string{ "name": {"*ubuntu-xenial-16.04-amd64-server-*"}, "virtualization-type": {"hvm"}, "architecture": {"x86_64"}, "root-device-type": {"ebs"}, "block-device-mapping.volume-type": {"gp2"}, } return GetMostRecentAmiIdE(t, region, CanonicalAccountId, filters) } // GetUbuntu2004Ami gets the ID of the most recent Ubuntu 20.04 HVM x86_64 EBS GP2 AMI in the given region. func GetUbuntu2004Ami(t testing.TestingT, region string) string { amiID, err := GetUbuntu2004AmiE(t, region) if err != nil { t.Fatal(err) } return amiID } // GetUbuntu2004AmiE gets the ID of the most recent Ubuntu 20.04 HVM x86_64 EBS GP2 AMI in the given region. func GetUbuntu2004AmiE(t testing.TestingT, region string) (string, error) { filters := map[string][]string{ "name": {"*ubuntu-focal-20.04-amd64-server-*"}, "virtualization-type": {"hvm"}, "architecture": {"x86_64"}, "root-device-type": {"ebs"}, "block-device-mapping.volume-type": {"gp2"}, } return GetMostRecentAmiIdE(t, region, CanonicalAccountId, filters) } // GetUbuntu2204Ami gets the ID of the most recent Ubuntu 22.04 HVM x86_64 EBS GP2 AMI in the given region. func GetUbuntu2204Ami(t testing.TestingT, region string) string { amiID, err := GetUbuntu2204AmiE(t, region) if err != nil { t.Fatal(err) } return amiID } // GetUbuntu2204AmiE gets the ID of the most recent Ubuntu 22.04 HVM x86_64 EBS GP2 AMI in the given region. func GetUbuntu2204AmiE(t testing.TestingT, region string) (string, error) { filters := map[string][]string{ "name": {"*ubuntu-jammy-22.04-amd64-server-*"}, "virtualization-type": {"hvm"}, "architecture": {"x86_64"}, "root-device-type": {"ebs"}, "block-device-mapping.volume-type": {"gp2"}, } return GetMostRecentAmiIdE(t, region, CanonicalAccountId, filters) } // GetCentos7Ami returns a CentOS 7 public AMI from the given region. // WARNING: you may have to accept the terms & conditions of this AMI in AWS MarketPlace for your AWS Account before // you can successfully launch the AMI. func GetCentos7Ami(t testing.TestingT, region string) string { amiID, err := GetCentos7AmiE(t, region) if err != nil { t.Fatal(err) } return amiID } // GetCentos7AmiE returns a CentOS 7 public AMI from the given region. // WARNING: you may have to accept the terms & conditions of this AMI in AWS MarketPlace for your AWS Account before // you can successfully launch the AMI. func GetCentos7AmiE(t testing.TestingT, region string) (string, error) { filters := map[string][]string{ "name": {"*CentOS Linux 7 x86_64 HVM EBS*"}, "virtualization-type": {"hvm"}, "architecture": {"x86_64"}, "root-device-type": {"ebs"}, "block-device-mapping.volume-type": {"gp2"}, } return GetMostRecentAmiIdE(t, region, CentOsAccountId, filters) } // GetAmazonLinuxAmi returns an Amazon Linux AMI HVM, SSD Volume Type public AMI for the given region. func GetAmazonLinuxAmi(t testing.TestingT, region string) string { amiID, err := GetAmazonLinuxAmiE(t, region) if err != nil { t.Fatal(err) } return amiID } // GetAmazonLinuxAmiE returns an Amazon Linux AMI HVM, SSD Volume Type public AMI for the given region. func GetAmazonLinuxAmiE(t testing.TestingT, region string) (string, error) { filters := map[string][]string{ "name": {"*amzn2-ami-hvm-*-x86_64*"}, "virtualization-type": {"hvm"}, "architecture": {"x86_64"}, "root-device-type": {"ebs"}, "block-device-mapping.volume-type": {"gp2"}, } return GetMostRecentAmiIdE(t, region, AmazonAccountId, filters) } // GetEcsOptimizedAmazonLinuxAmi returns an Amazon ECS-Optimized Amazon Linux AMI for the given region. This AMI is useful for running an ECS cluster. func GetEcsOptimizedAmazonLinuxAmi(t testing.TestingT, region string) string { amiID, err := GetEcsOptimizedAmazonLinuxAmiE(t, region) if err != nil { t.Fatal(err) } return amiID } // GetEcsOptimizedAmazonLinuxAmiE returns an Amazon ECS-Optimized Amazon Linux AMI for the given region. This AMI is useful for running an ECS cluster. func GetEcsOptimizedAmazonLinuxAmiE(t testing.TestingT, region string) (string, error) { filters := map[string][]string{ "name": {"*amzn-ami*amazon-ecs-optimized*"}, "virtualization-type": {"hvm"}, "architecture": {"x86_64"}, "root-device-type": {"ebs"}, "block-device-mapping.volume-type": {"gp2"}, } return GetMostRecentAmiIdE(t, region, AmazonAccountId, filters) } // NoImagesFound is an error that occurs if no images were found. type NoImagesFound struct { Region string OwnerId string Filters map[string][]string } func (err NoImagesFound) Error() string { return fmt.Sprintf("No AMIs found in %s for owner ID %s and filters: %v", err.Region, err.OwnerId, err.Filters) } ================================================ FILE: modules/aws/ami_test.go ================================================ package aws import ( "testing" "github.com/stretchr/testify/assert" ) func TestGetUbuntu1404AmiReturnsSomeAmi(t *testing.T) { t.Parallel() amiID := GetUbuntu1404Ami(t, "us-east-1") assert.Regexp(t, "^ami-[[:alnum:]]+$", amiID) } func TestGetUbuntu1604AmiReturnsSomeAmi(t *testing.T) { t.Parallel() amiID := GetUbuntu1604Ami(t, "us-west-1") assert.Regexp(t, "^ami-[[:alnum:]]+$", amiID) } func TestGetUbuntu2004AmiReturnsSomeAmi(t *testing.T) { t.Parallel() amiID := GetUbuntu2004Ami(t, "us-west-1") assert.Regexp(t, "^ami-[[:alnum:]]+$", amiID) } func TestGetUbuntu2204AmiReturnsSomeAmi(t *testing.T) { t.Parallel() amiID := GetUbuntu2204Ami(t, "us-west-1") assert.Regexp(t, "^ami-[[:alnum:]]+$", amiID) } func TestGetCentos7AmiReturnsSomeAmi(t *testing.T) { t.Parallel() amiID := GetCentos7Ami(t, "eu-west-1") assert.Regexp(t, "^ami-[[:alnum:]]+$", amiID) } func TestGetAmazonLinuxAmiReturnsSomeAmi(t *testing.T) { t.Parallel() amiID := GetAmazonLinuxAmi(t, "ap-southeast-1") assert.Regexp(t, "^ami-[[:alnum:]]+$", amiID) } func TestGetEcsOptimizedAmazonLinuxAmiEReturnsSomeAmi(t *testing.T) { t.Parallel() amiID := GetEcsOptimizedAmazonLinuxAmi(t, "us-east-2") assert.Regexp(t, "^ami-[[:alnum:]]+$", amiID) } ================================================ FILE: modules/aws/asg.go ================================================ package aws import ( "context" "fmt" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/autoscaling" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/testing" ) type AsgCapacityInfo struct { MinCapacity int64 MaxCapacity int64 CurrentCapacity int64 DesiredCapacity int64 } // GetCapacityInfoForAsg returns the capacity info for the queried asg as a struct, AsgCapacityInfo. func GetCapacityInfoForAsg(t testing.TestingT, asgName string, awsRegion string) AsgCapacityInfo { capacityInfo, err := GetCapacityInfoForAsgE(t, asgName, awsRegion) require.NoError(t, err) return capacityInfo } // GetCapacityInfoForAsgE returns the capacity info for the queried asg as a struct, AsgCapacityInfo. func GetCapacityInfoForAsgE(t testing.TestingT, asgName string, awsRegion string) (AsgCapacityInfo, error) { asgClient, err := NewAsgClientE(t, awsRegion) if err != nil { return AsgCapacityInfo{}, err } input := autoscaling.DescribeAutoScalingGroupsInput{AutoScalingGroupNames: []string{asgName}} output, err := asgClient.DescribeAutoScalingGroups(context.Background(), &input) if err != nil { return AsgCapacityInfo{}, err } groups := output.AutoScalingGroups if len(groups) == 0 { return AsgCapacityInfo{}, NewNotFoundError("ASG", asgName, awsRegion) } capacityInfo := AsgCapacityInfo{ MinCapacity: int64(*groups[0].MinSize), MaxCapacity: int64(*groups[0].MaxSize), DesiredCapacity: int64(*groups[0].DesiredCapacity), CurrentCapacity: int64(len(groups[0].Instances)), } return capacityInfo, nil } // GetInstanceIdsForAsg gets the IDs of EC2 Instances in the given ASG. func GetInstanceIdsForAsg(t testing.TestingT, asgName string, awsRegion string) []string { ids, err := GetInstanceIdsForAsgE(t, asgName, awsRegion) if err != nil { t.Fatal(err) } return ids } // GetInstanceIdsForAsgE gets the IDs of EC2 Instances in the given ASG. func GetInstanceIdsForAsgE(t testing.TestingT, asgName string, awsRegion string) ([]string, error) { asgClient, err := NewAsgClientE(t, awsRegion) if err != nil { return nil, err } input := autoscaling.DescribeAutoScalingGroupsInput{AutoScalingGroupNames: []string{asgName}} output, err := asgClient.DescribeAutoScalingGroups(context.Background(), &input) if err != nil { return nil, err } var instanceIDs []string for _, asg := range output.AutoScalingGroups { for _, instance := range asg.Instances { instanceIDs = append(instanceIDs, aws.ToString(instance.InstanceId)) } } return instanceIDs, nil } // WaitForCapacity waits for the currently set desired capacity to be reached on the ASG func WaitForCapacity( t testing.TestingT, asgName string, region string, maxRetries int, sleepBetweenRetries time.Duration, ) { err := WaitForCapacityE(t, asgName, region, maxRetries, sleepBetweenRetries) require.NoError(t, err) } // WaitForCapacityE waits for the currently set desired capacity to be reached on the ASG func WaitForCapacityE( t testing.TestingT, asgName string, region string, maxRetries int, sleepBetweenRetries time.Duration, ) error { msg, err := retry.DoWithRetryE( t, fmt.Sprintf("Waiting for ASG %s to reach desired capacity.", asgName), maxRetries, sleepBetweenRetries, func() (string, error) { capacityInfo, err := GetCapacityInfoForAsgE(t, asgName, region) if err != nil { return "", err } if capacityInfo.CurrentCapacity != capacityInfo.DesiredCapacity { return "", NewAsgCapacityNotMetError(asgName, capacityInfo.DesiredCapacity, capacityInfo.CurrentCapacity) } return fmt.Sprintf("ASG %s is now at desired capacity %d", asgName, capacityInfo.DesiredCapacity), nil }, ) logger.Default.Logf(t, "%s", msg) return err } // NewAsgClient creates an Auto Scaling Group client. func NewAsgClient(t testing.TestingT, region string) *autoscaling.Client { client, err := NewAsgClientE(t, region) if err != nil { t.Fatal(err) } return client } // NewAsgClientE creates an Auto Scaling Group client. func NewAsgClientE(t testing.TestingT, region string) (*autoscaling.Client, error) { sess, err := NewAuthenticatedSession(region) if err != nil { return nil, err } return autoscaling.NewFromConfig(*sess), nil } ================================================ FILE: modules/aws/asg_test.go ================================================ package aws import ( "context" "fmt" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/autoscaling" autoscalingTypes "github.com/aws/aws-sdk-go-v2/service/autoscaling/types" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terratest/modules/random" ) func TestGetCapacityInfoForAsg(t *testing.T) { t.Parallel() uniqueID := random.UniqueId() asgName := fmt.Sprintf("%s-%s", t.Name(), uniqueID) region := GetRandomStableRegion(t, []string{}, []string{}) defer deleteAutoScalingGroup(t, asgName, region) createTestAutoScalingGroup(t, asgName, region, 2) WaitForCapacity(t, asgName, region, 40, 15*time.Second) capacityInfo := GetCapacityInfoForAsg(t, asgName, region) assert.Equal(t, capacityInfo.DesiredCapacity, int64(2)) assert.Equal(t, capacityInfo.CurrentCapacity, int64(2)) assert.Equal(t, capacityInfo.MinCapacity, int64(1)) assert.Equal(t, capacityInfo.MaxCapacity, int64(3)) } func TestGetInstanceIdsForAsg(t *testing.T) { t.Parallel() uniqueID := random.UniqueId() asgName := fmt.Sprintf("%s-%s", t.Name(), uniqueID) region := GetRandomStableRegion(t, []string{}, []string{}) defer deleteAutoScalingGroup(t, asgName, region) createTestAutoScalingGroup(t, asgName, region, 1) WaitForCapacity(t, asgName, region, 40, 15*time.Second) instanceIds := GetInstanceIdsForAsg(t, asgName, region) assert.Equal(t, len(instanceIds), 1) } func createTestAutoScalingGroup(t *testing.T, name string, region string, desiredCount int32) { azs := GetAvailabilityZones(t, region) ec2Client := NewEc2Client(t, region) imageID := GetAmazonLinuxAmi(t, region) template, err := ec2Client.CreateLaunchTemplate(context.Background(), &ec2.CreateLaunchTemplateInput{ LaunchTemplateData: &types.RequestLaunchTemplateData{ ImageId: aws.String(imageID), InstanceType: types.InstanceType(GetRecommendedInstanceType(t, region, []string{"t2.micro, t3.micro", "t2.small", "t3.small"})), }, LaunchTemplateName: aws.String(name), }) require.NoError(t, err) asgClient := NewAsgClient(t, region) param := &autoscaling.CreateAutoScalingGroupInput{ AutoScalingGroupName: &name, LaunchTemplate: &autoscalingTypes.LaunchTemplateSpecification{ LaunchTemplateId: template.LaunchTemplate.LaunchTemplateId, Version: aws.String("$Latest"), }, AvailabilityZones: azs, DesiredCapacity: aws.Int32(desiredCount), MinSize: aws.Int32(1), MaxSize: aws.Int32(3), } _, err = asgClient.CreateAutoScalingGroup(context.Background(), param) require.NoError(t, err) waiter := autoscaling.NewGroupExistsWaiter(asgClient) err = waiter.Wait(context.Background(), &autoscaling.DescribeAutoScalingGroupsInput{ AutoScalingGroupNames: []string{name}, }, 42*time.Minute) require.NoError(t, err) } func createTestEC2Instance(t *testing.T, region string, name string) types.Instance { ec2Client := NewEc2Client(t, region) imageID := GetAmazonLinuxAmi(t, region) params := &ec2.RunInstancesInput{ ImageId: aws.String(imageID), InstanceType: types.InstanceType(GetRecommendedInstanceType(t, region, []string{"t2.micro, t3.micro", "t2.small", "t3.small"})), MinCount: aws.Int32(1), MaxCount: aws.Int32(1), } runResult, err := ec2Client.RunInstances(context.Background(), params) require.NoError(t, err) require.NotEqual(t, len(runResult.Instances), 0) waiter := ec2.NewInstanceExistsWaiter(ec2Client) err = waiter.Wait( context.Background(), &ec2.DescribeInstancesInput{ Filters: []types.Filter{ { Name: aws.String("instance-id"), Values: []string{*runResult.Instances[0].InstanceId}, }, }, }, 42*time.Minute, ) require.NoError(t, err) // Add test tag to the created instance _, err = ec2Client.CreateTags(context.Background(), &ec2.CreateTagsInput{ Resources: []string{*runResult.Instances[0].InstanceId}, Tags: []types.Tag{ { Key: aws.String("Name"), Value: aws.String(name), }, }, }) require.NoError(t, err) // EC2 Instance must be in a running before this function returns runningWaiter := ec2.NewInstanceRunningWaiter(ec2Client) err = runningWaiter.Wait(context.Background(), &ec2.DescribeInstancesInput{ Filters: []types.Filter{ { Name: aws.String("instance-id"), Values: []string{*runResult.Instances[0].InstanceId}, }, }, }, 42*time.Minute) require.NoError(t, err) return runResult.Instances[0] } func terminateEc2InstancesByName(t *testing.T, region string, names []string) { for _, name := range names { instanceIds := GetEc2InstanceIdsByTag(t, region, "Name", name) for _, instanceId := range instanceIds { TerminateInstance(t, region, instanceId) } } } func deleteAutoScalingGroup(t *testing.T, name string, region string) { // We have to scale ASG down to 0 before we can delete it scaleAsgToZero(t, name, region) asgClient := NewAsgClient(t, region) input := &autoscaling.DeleteAutoScalingGroupInput{AutoScalingGroupName: aws.String(name)} _, err := asgClient.DeleteAutoScalingGroup(context.Background(), input) require.NoError(t, err) waiter := autoscaling.NewGroupNotExistsWaiter(asgClient) err = waiter.Wait(context.Background(), &autoscaling.DescribeAutoScalingGroupsInput{ AutoScalingGroupNames: []string{name}, }, 40*time.Minute) require.NoError(t, err) ec2Client := NewEc2Client(t, region) _, err = ec2Client.DeleteLaunchTemplate(context.Background(), &ec2.DeleteLaunchTemplateInput{ LaunchTemplateName: aws.String(name), }) require.NoError(t, err) } func scaleAsgToZero(t *testing.T, name string, region string) { asgClient := NewAsgClient(t, region) input := &autoscaling.UpdateAutoScalingGroupInput{ AutoScalingGroupName: aws.String(name), DesiredCapacity: aws.Int32(0), MinSize: aws.Int32(0), MaxSize: aws.Int32(0), } _, err := asgClient.UpdateAutoScalingGroup(context.Background(), input) require.NoError(t, err) WaitForCapacity(t, name, region, 40, 15*time.Second) // There is an eventual consistency bug where even though the ASG is scaled down, AWS sometimes still views a // scaling activity so we add a 5-second pause here to work around it. time.Sleep(5 * time.Second) } ================================================ FILE: modules/aws/auth.go ================================================ package aws import ( "context" "fmt" "os" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/credentials/stscreds" "github.com/aws/aws-sdk-go-v2/service/iam" "github.com/aws/aws-sdk-go-v2/service/iam/types" "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/pquerna/otp/totp" ) const ( AuthAssumeRoleEnvVar = "TERRATEST_IAM_ROLE" // OS environment variable name through which Assume Role ARN may be passed for authentication ) // NewAuthenticatedSession creates an AWS Config following to standard AWS authentication workflow. // If AuthAssumeIamRoleEnvVar environment variable is set, assumes IAM role specified in it. func NewAuthenticatedSession(region string) (*aws.Config, error) { if assumeRoleArn, ok := os.LookupEnv(AuthAssumeRoleEnvVar); ok { return NewAuthenticatedSessionFromRole(region, assumeRoleArn) } else { return NewAuthenticatedSessionFromDefaultCredentials(region) } } // NewAuthenticatedSessionFromDefaultCredentials gets an AWS Config, checking that the user has credentials properly configured in their environment. func NewAuthenticatedSessionFromDefaultCredentials(region string) (*aws.Config, error) { cfg, err := config.LoadDefaultConfig(context.Background(), config.WithRegion(region)) if err != nil { return nil, CredentialsError{UnderlyingErr: err} } return &cfg, nil } // NewAuthenticatedSessionFromRole returns a new AWS Config after assuming the // role whose ARN is provided in roleARN. If the credentials are not properly // configured in the underlying environment, an error is returned. func NewAuthenticatedSessionFromRole(region string, roleARN string) (*aws.Config, error) { cfg, err := NewAuthenticatedSessionFromDefaultCredentials(region) if err != nil { return nil, err } client := sts.NewFromConfig(*cfg) roleProvider := stscreds.NewAssumeRoleProvider(client, roleARN) retrieve, err := roleProvider.Retrieve(context.Background()) if err != nil { return nil, CredentialsError{UnderlyingErr: err} } return &aws.Config{ Region: region, Credentials: aws.NewCredentialsCache(credentials.StaticCredentialsProvider{ Value: retrieve, }), }, nil } // CreateAwsSessionWithCreds creates a new AWS Config using explicit credentials. This is useful if you want to create an IAM User dynamically and // create an AWS Config authenticated as the new IAM User. func CreateAwsSessionWithCreds(region string, accessKeyID string, secretAccessKey string) (*aws.Config, error) { return &aws.Config{ Region: region, Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(accessKeyID, secretAccessKey, "")), }, nil } // CreateAwsSessionWithMfa creates a new AWS Config authenticated using an MFA token retrieved using the given STS client and MFA Device. func CreateAwsSessionWithMfa(region string, stsClient *sts.Client, mfaDevice *types.VirtualMFADevice) (*aws.Config, error) { tokenCode, err := GetTimeBasedOneTimePassword(mfaDevice) if err != nil { return nil, err } output, err := stsClient.GetSessionToken(context.Background(), &sts.GetSessionTokenInput{ SerialNumber: mfaDevice.SerialNumber, TokenCode: aws.String(tokenCode), }) if err != nil { return nil, err } accessKeyID := *output.Credentials.AccessKeyId secretAccessKey := *output.Credentials.SecretAccessKey sessionToken := *output.Credentials.SessionToken return &aws.Config{ Region: region, Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(accessKeyID, secretAccessKey, sessionToken)), }, nil } // GetTimeBasedOneTimePassword gets a One-Time Password from the given mfaDevice. Per the RFC 6238 standard, this value will be different every 30 seconds. func GetTimeBasedOneTimePassword(mfaDevice *types.VirtualMFADevice) (string, error) { base32StringSeed := string(mfaDevice.Base32StringSeed) otp, err := totp.GenerateCode(base32StringSeed, time.Now()) if err != nil { return "", err } return otp, nil } // ReadPasswordPolicyMinPasswordLength returns the minimal password length. func ReadPasswordPolicyMinPasswordLength(iamClient *iam.Client) (int, error) { output, err := iamClient.GetAccountPasswordPolicy(context.Background(), &iam.GetAccountPasswordPolicyInput{}) if err != nil { return -1, err } return int(*output.PasswordPolicy.MinimumPasswordLength), nil } // CredentialsError is an error that occurs because AWS credentials can't be found. type CredentialsError struct { UnderlyingErr error } func (err CredentialsError) Error() string { return fmt.Sprintf("Error finding AWS credentials. Did you set the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables or configure an AWS profile? Underlying error: %v", err.UnderlyingErr) } ================================================ FILE: modules/aws/aws.go ================================================ // Package aws allows to interact with resources on Amazon Web Services. package aws ================================================ FILE: modules/aws/cloudwatch.go ================================================ package aws import ( "context" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs" "github.com/gruntwork-io/terratest/modules/testing" ) // GetCloudWatchLogEntries returns the CloudWatch log messages in the given region for the given log stream and log group. func GetCloudWatchLogEntries(t testing.TestingT, awsRegion string, logStreamName string, logGroupName string) []string { out, err := GetCloudWatchLogEntriesE(t, awsRegion, logStreamName, logGroupName) if err != nil { t.Fatal(err) } return out } // GetCloudWatchLogEntriesE returns the CloudWatch log messages in the given region for the given log stream and log group. func GetCloudWatchLogEntriesE(t testing.TestingT, awsRegion string, logStreamName string, logGroupName string) ([]string, error) { client, err := NewCloudWatchLogsClientE(t, awsRegion) if err != nil { return nil, err } output, err := client.GetLogEvents(context.Background(), &cloudwatchlogs.GetLogEventsInput{ LogGroupName: aws.String(logGroupName), LogStreamName: aws.String(logStreamName), }) if err != nil { return nil, err } var entries []string for _, event := range output.Events { entries = append(entries, *event.Message) } return entries, nil } // NewCloudWatchLogsClient creates a new CloudWatch Logs client. func NewCloudWatchLogsClient(t testing.TestingT, region string) *cloudwatchlogs.Client { client, err := NewCloudWatchLogsClientE(t, region) if err != nil { t.Fatal(err) } return client } // NewCloudWatchLogsClientE creates a new CloudWatch Logs client. func NewCloudWatchLogsClientE(t testing.TestingT, region string) (*cloudwatchlogs.Client, error) { sess, err := NewAuthenticatedSession(region) if err != nil { return nil, err } return cloudwatchlogs.NewFromConfig(*sess), nil } ================================================ FILE: modules/aws/dynamodb.go ================================================ package aws import ( "context" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // GetDynamoDbTableTags fetches resource tags of a specified dynamoDB table. This will fail the test if there are any errors func GetDynamoDbTableTags(t testing.TestingT, region string, tableName string) []types.Tag { tags, err := GetDynamoDbTableTagsE(t, region, tableName) require.NoError(t, err) return tags } // GetDynamoDbTableTagsE fetches resource tags of a specified dynamoDB table. func GetDynamoDbTableTagsE(t testing.TestingT, region string, tableName string) ([]types.Tag, error) { table, err := GetDynamoDBTableE(t, region, tableName) if err != nil { return nil, err } client, err := NewDynamoDBClientE(t, region) if err != nil { return nil, err } out, err := client.ListTagsOfResource(context.Background(), &dynamodb.ListTagsOfResourceInput{ ResourceArn: table.TableArn, }) if err != nil { return nil, err } return out.Tags, err } // GetDynamoDBTableTimeToLive fetches information about the TTL configuration of a specified dynamoDB table. This will fail the test if there are any errors. func GetDynamoDBTableTimeToLive(t testing.TestingT, region string, tableName string) *types.TimeToLiveDescription { ttl, err := GetDynamoDBTableTimeToLiveE(t, region, tableName) require.NoError(t, err) return ttl } // GetDynamoDBTableTimeToLiveE fetches information about the TTL configuration of a specified dynamoDB table. func GetDynamoDBTableTimeToLiveE(t testing.TestingT, region string, tableName string) (*types.TimeToLiveDescription, error) { client, err := NewDynamoDBClientE(t, region) if err != nil { return nil, err } out, err := client.DescribeTimeToLive(context.Background(), &dynamodb.DescribeTimeToLiveInput{ TableName: aws.String(tableName), }) if err != nil { return nil, err } return out.TimeToLiveDescription, err } // GetDynamoDBTable fetches information about the specified dynamoDB table. This will fail the test if there are any errors. func GetDynamoDBTable(t testing.TestingT, region string, tableName string) *types.TableDescription { table, err := GetDynamoDBTableE(t, region, tableName) require.NoError(t, err) return table } // GetDynamoDBTableE fetches information about the specified dynamoDB table. func GetDynamoDBTableE(t testing.TestingT, region string, tableName string) (*types.TableDescription, error) { client, err := NewDynamoDBClientE(t, region) if err != nil { return nil, err } out, err := client.DescribeTable(context.Background(), &dynamodb.DescribeTableInput{ TableName: aws.String(tableName), }) if err != nil { return nil, err } return out.Table, err } // NewDynamoDBClient creates a DynamoDB client. func NewDynamoDBClient(t testing.TestingT, region string) *dynamodb.Client { client, err := NewDynamoDBClientE(t, region) require.NoError(t, err) return client } // NewDynamoDBClientE creates a DynamoDB client. func NewDynamoDBClientE(t testing.TestingT, region string) (*dynamodb.Client, error) { sess, err := NewAuthenticatedSession(region) if err != nil { return nil, err } return dynamodb.NewFromConfig(*sess), nil } ================================================ FILE: modules/aws/ebs.go ================================================ package aws import ( "context" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/testing" ) // DeleteEbsSnapshot deletes the given EBS snapshot func DeleteEbsSnapshot(t testing.TestingT, region string, snapshot string) { err := DeleteEbsSnapshotE(t, region, snapshot) if err != nil { t.Fatal(err) } } // DeleteEbsSnapshotE deletes the given EBS snapshot func DeleteEbsSnapshotE(t testing.TestingT, region string, snapshot string) error { logger.Default.Logf(t, "Deleting EBS snapshot %s", snapshot) ec2Client, err := NewEc2ClientE(t, region) if err != nil { return err } _, err = ec2Client.DeleteSnapshot(context.Background(), &ec2.DeleteSnapshotInput{ SnapshotId: aws.String(snapshot), }) return err } ================================================ FILE: modules/aws/ec2-files.go ================================================ package aws import ( "os" "path/filepath" "github.com/gruntwork-io/terratest/modules/files" "github.com/gruntwork-io/terratest/modules/ssh" "github.com/gruntwork-io/terratest/modules/testing" "github.com/hashicorp/go-multierror" ) // RemoteFileSpecification describes which files you want to copy from your instances type RemoteFileSpecification struct { AsgNames []string //ASGs where our instances will be RemotePathToFileFilter map[string][]string //A map of the files to fetch, where the keys are directories on the remote host and the values are filters for what files to fetch from the directory. The filters support bash-style wildcards. UseSudo bool SshUser string KeyPair *Ec2Keypair LocalDestinationDir string //base path where to store downloaded artifacts locally. The final path of each resource will include the ip of the host and the name of the immediate parent folder. } // FetchContentsOfFileFromInstance looks up the public IP address of the EC2 Instance with the given ID, connects to // the Instance via SSH using the given username and Key Pair, fetches the contents of the file at the given path // (using sudo if useSudo is true), and returns the contents of that file as a string. func FetchContentsOfFileFromInstance(t testing.TestingT, awsRegion string, sshUserName string, keyPair *Ec2Keypair, instanceID string, useSudo bool, filePath string) string { out, err := FetchContentsOfFileFromInstanceE(t, awsRegion, sshUserName, keyPair, instanceID, useSudo, filePath) if err != nil { t.Fatal(err) } return out } // FetchContentsOfFileFromInstanceE looks up the public IP address of the EC2 Instance with the given ID, connects to // the Instance via SSH using the given username and Key Pair, fetches the contents of the file at the given path // (using sudo if useSudo is true), and returns the contents of that file as a string. func FetchContentsOfFileFromInstanceE(t testing.TestingT, awsRegion string, sshUserName string, keyPair *Ec2Keypair, instanceID string, useSudo bool, filePath string) (string, error) { publicIp, err := GetPublicIpOfEc2InstanceE(t, instanceID, awsRegion) if err != nil { return "", err } host := ssh.Host{ SshUserName: sshUserName, SshKeyPair: keyPair.KeyPair, Hostname: publicIp, } return ssh.FetchContentsOfFileE(t, host, useSudo, filePath) } // FetchContentsOfFilesFromInstance looks up the public IP address of the EC2 Instance with the given ID, connects to // the Instance via SSH using the given username and Key Pair, fetches the contents of the files at the given paths // (using sudo if useSudo is true), and returns a map from file path to the contents of that file as a string. func FetchContentsOfFilesFromInstance(t testing.TestingT, awsRegion string, sshUserName string, keyPair *Ec2Keypair, instanceID string, useSudo bool, filePaths ...string) map[string]string { out, err := FetchContentsOfFilesFromInstanceE(t, awsRegion, sshUserName, keyPair, instanceID, useSudo, filePaths...) if err != nil { t.Fatal(err) } return out } // FetchContentsOfFilesFromInstanceE looks up the public IP address of the EC2 Instance with the given ID, connects to // the Instance via SSH using the given username and Key Pair, fetches the contents of the files at the given paths // (using sudo if useSudo is true), and returns a map from file path to the contents of that file as a string. func FetchContentsOfFilesFromInstanceE(t testing.TestingT, awsRegion string, sshUserName string, keyPair *Ec2Keypair, instanceID string, useSudo bool, filePaths ...string) (map[string]string, error) { publicIp, err := GetPublicIpOfEc2InstanceE(t, instanceID, awsRegion) if err != nil { return nil, err } host := ssh.Host{ SshUserName: sshUserName, SshKeyPair: keyPair.KeyPair, Hostname: publicIp, } return ssh.FetchContentsOfFilesE(t, host, useSudo, filePaths...) } // FetchContentsOfFileFromAsg looks up the EC2 Instances in the given ASG, looks up the public IPs of those EC2 // Instances, connects to each Instance via SSH using the given username and Key Pair, fetches the contents of the file // at the given path (using sudo if useSudo is true), and returns a map from Instance ID to the contents of that file // as a string. func FetchContentsOfFileFromAsg(t testing.TestingT, awsRegion string, sshUserName string, keyPair *Ec2Keypair, asgName string, useSudo bool, filePath string) map[string]string { out, err := FetchContentsOfFileFromAsgE(t, awsRegion, sshUserName, keyPair, asgName, useSudo, filePath) if err != nil { t.Fatal(err) } return out } // FetchContentsOfFileFromAsgE looks up the EC2 Instances in the given ASG, looks up the public IPs of those EC2 // Instances, connects to each Instance via SSH using the given username and Key Pair, fetches the contents of the file // at the given path (using sudo if useSudo is true), and returns a map from Instance ID to the contents of that file // as a string. func FetchContentsOfFileFromAsgE(t testing.TestingT, awsRegion string, sshUserName string, keyPair *Ec2Keypair, asgName string, useSudo bool, filePath string) (map[string]string, error) { instanceIDs, err := GetInstanceIdsForAsgE(t, asgName, awsRegion) if err != nil { return nil, err } instanceIdToContents := map[string]string{} for _, instanceID := range instanceIDs { contents, err := FetchContentsOfFileFromInstanceE(t, awsRegion, sshUserName, keyPair, instanceID, useSudo, filePath) if err != nil { return nil, err } instanceIdToContents[instanceID] = contents } return instanceIdToContents, err } // FetchContentsOfFilesFromAsg looks up the EC2 Instances in the given ASG, looks up the public IPs of those EC2 // Instances, connects to each Instance via SSH using the given username and Key Pair, fetches the contents of the files // at the given paths (using sudo if useSudo is true), and returns a map from Instance ID to a map of file path to the // contents of that file as a string. func FetchContentsOfFilesFromAsg(t testing.TestingT, awsRegion string, sshUserName string, keyPair *Ec2Keypair, asgName string, useSudo bool, filePaths ...string) map[string]map[string]string { out, err := FetchContentsOfFilesFromAsgE(t, awsRegion, sshUserName, keyPair, asgName, useSudo, filePaths...) if err != nil { t.Fatal(err) } return out } // FetchContentsOfFilesFromAsgE looks up the EC2 Instances in the given ASG, looks up the public IPs of those EC2 // Instances, connects to each Instance via SSH using the given username and Key Pair, fetches the contents of the files // at the given paths (using sudo if useSudo is true), and returns a map from Instance ID to a map of file path to the // contents of that file as a string. func FetchContentsOfFilesFromAsgE(t testing.TestingT, awsRegion string, sshUserName string, keyPair *Ec2Keypair, asgName string, useSudo bool, filePaths ...string) (map[string]map[string]string, error) { instanceIDs, err := GetInstanceIdsForAsgE(t, asgName, awsRegion) if err != nil { return nil, err } instanceIdToFilePathToContents := map[string]map[string]string{} for _, instanceID := range instanceIDs { contents, err := FetchContentsOfFilesFromInstanceE(t, awsRegion, sshUserName, keyPair, instanceID, useSudo, filePaths...) if err != nil { return nil, err } instanceIdToFilePathToContents[instanceID] = contents } return instanceIdToFilePathToContents, err } // FetchFilesFromInstance looks up the EC2 Instances in the given ASG, looks up the public IPs of those EC2 // Instances, connects to each Instance via SSH using the given username and Key Pair, downloads the files // matching filenameFilters at the given remoteDirectory (using sudo if useSudo is true), and stores the files locally // at localDirectory// func FetchFilesFromInstance(t testing.TestingT, awsRegion string, sshUserName string, keyPair *Ec2Keypair, instanceID string, useSudo bool, remoteDirectory string, localDirectory string, filenameFilters []string) { err := FetchFilesFromInstanceE(t, awsRegion, sshUserName, keyPair, instanceID, useSudo, remoteDirectory, localDirectory, filenameFilters) if err != nil { t.Fatal(err) } } // FetchFilesFromInstanceE looks up the EC2 Instances in the given ASG, looks up the public IPs of those EC2 // Instances, connects to each Instance via SSH using the given username and Key Pair, downloads the files // matching filenameFilters at the given remoteDirectory (using sudo if useSudo is true), and stores the files locally // at localDirectory// func FetchFilesFromInstanceE(t testing.TestingT, awsRegion string, sshUserName string, keyPair *Ec2Keypair, instanceID string, useSudo bool, remoteDirectory string, localDirectory string, filenameFilters []string) error { publicIp, err := GetPublicIpOfEc2InstanceE(t, instanceID, awsRegion) if err != nil { return err } host := ssh.Host{ Hostname: publicIp, SshUserName: sshUserName, SshKeyPair: keyPair.KeyPair, } finalLocalDestDir := filepath.Join(localDirectory, publicIp, filepath.Base(remoteDirectory)) if !files.FileExists(finalLocalDestDir) { os.MkdirAll(finalLocalDestDir, 0755) } scpOptions := ssh.ScpDownloadOptions{ RemoteHost: host, RemoteDir: remoteDirectory, LocalDir: finalLocalDestDir, FileNameFilters: filenameFilters, } return ssh.ScpDirFromE(t, scpOptions, useSudo) } // FetchFilesFromAsgs looks up the EC2 Instances in all the ASGs given in the RemoteFileSpecification, // looks up the public IPs of those EC2 Instances, connects to each Instance via SSH using the given // username and Key Pair, downloads the files matching filenameFilters at the given // remoteDirectory (using sudo if useSudo is true), and stores the files locally at // localDirectory// func FetchFilesFromAsgs(t testing.TestingT, awsRegion string, spec RemoteFileSpecification) { err := FetchFilesFromAsgsE(t, awsRegion, spec) if err != nil { t.Fatal(err) } } // FetchFilesFromAsgsE looks up the EC2 Instances in all the ASGs given in the RemoteFileSpecification, // looks up the public IPs of those EC2 Instances, connects to each Instance via SSH using the given // username and Key Pair, downloads the files matching filenameFilters at the given // remoteDirectory (using sudo if useSudo is true), and stores the files locally at // localDirectory// func FetchFilesFromAsgsE(t testing.TestingT, awsRegion string, spec RemoteFileSpecification) error { var errorsOccurred = new(multierror.Error) for _, curAsg := range spec.AsgNames { for curRemoteDir, fileFilters := range spec.RemotePathToFileFilter { instanceIDs, err := GetInstanceIdsForAsgE(t, curAsg, awsRegion) if err != nil { errorsOccurred = multierror.Append(errorsOccurred, err) } else { for _, instanceID := range instanceIDs { err = FetchFilesFromInstanceE(t, awsRegion, spec.SshUser, spec.KeyPair, instanceID, spec.UseSudo, curRemoteDir, spec.LocalDestinationDir, fileFilters) if err != nil { errorsOccurred = multierror.Append(errorsOccurred, err) } } } } } return errorsOccurred.ErrorOrNil() } ================================================ FILE: modules/aws/ec2-syslog.go ================================================ package aws import ( "context" "encoding/base64" "fmt" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/testing" ) // GetSyslogForInstance (Deprecated) See the FetchContentsOfFileFromInstance method for a more powerful solution. // // GetSyslogForInstance gets the syslog for the Instance with the given ID in the given region. This should be available ~1 minute after an // Instance boots and is very useful for debugging boot-time issues, such as an error in User Data. func GetSyslogForInstance(t testing.TestingT, instanceID string, awsRegion string) string { out, err := GetSyslogForInstanceE(t, instanceID, awsRegion) if err != nil { t.Fatal(err) } return out } // GetSyslogForInstanceE (Deprecated) See the FetchContentsOfFileFromInstanceE method for a more powerful solution. // // GetSyslogForInstanceE gets the syslog for the Instance with the given ID in the given region. This should be available ~1 minute after an // Instance boots and is very useful for debugging boot-time issues, such as an error in User Data. func GetSyslogForInstanceE(t testing.TestingT, instanceID string, region string) (string, error) { description := fmt.Sprintf("Fetching syslog for Instance %s in %s", instanceID, region) maxRetries := 120 timeBetweenRetries := 5 * time.Second logger.Default.Logf(t, "%s", description) client, err := NewEc2ClientE(t, region) if err != nil { return "", err } input := ec2.GetConsoleOutputInput{ InstanceId: aws.String(instanceID), } syslogB64, err := retry.DoWithRetryE(t, description, maxRetries, timeBetweenRetries, func() (string, error) { out, err := client.GetConsoleOutput(context.Background(), &input) if err != nil { return "", err } syslog := aws.ToString(out.Output) if syslog == "" { return "", fmt.Errorf("syslog is not yet available for instance %s in %s", instanceID, region) } return syslog, nil }) if err != nil { return "", err } syslogBytes, err := base64.StdEncoding.DecodeString(syslogB64) if err != nil { return "", err } return string(syslogBytes), nil } // GetSyslogForInstancesInAsg (Deprecated) See the FetchContentsOfFilesFromAsg method for a more powerful solution. // // GetSyslogForInstancesInAsg gets the syslog for each of the Instances in the given ASG in the given region. These logs should be available ~1 // minute after the Instance boots and are very useful for debugging boot-time issues, such as an error in User Data. // Returns a map of Instance ID -> Syslog for that Instance. func GetSyslogForInstancesInAsg(t testing.TestingT, asgName string, awsRegion string) map[string]string { out, err := GetSyslogForInstancesInAsgE(t, asgName, awsRegion) if err != nil { t.Fatal(err) } return out } // GetSyslogForInstancesInAsgE (Deprecated) See the FetchContentsOfFilesFromAsgE method for a more powerful solution. // // GetSyslogForInstancesInAsgE gets the syslog for each of the Instances in the given ASG in the given region. These logs should be available ~1 // minute after the Instance boots and are very useful for debugging boot-time issues, such as an error in User Data. // Returns a map of Instance ID -> Syslog for that Instance. func GetSyslogForInstancesInAsgE(t testing.TestingT, asgName string, awsRegion string) (map[string]string, error) { logger.Default.Logf(t, "Fetching syslog for each Instance in ASG %s in %s", asgName, awsRegion) instanceIDs, err := GetEc2InstanceIdsByTagE(t, awsRegion, "aws:autoscaling:groupName", asgName) if err != nil { return nil, err } logs := map[string]string{} for _, id := range instanceIDs { syslog, err := GetSyslogForInstanceE(t, id, awsRegion) if err != nil { return nil, err } logs[id] = syslog } return logs, nil } ================================================ FILE: modules/aws/ec2.go ================================================ package aws import ( "context" "fmt" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // GetPrivateIpOfEc2Instance gets the private IP address of the given EC2 Instance in the given region. func GetPrivateIpOfEc2Instance(t testing.TestingT, instanceID string, awsRegion string) string { ip, err := GetPrivateIpOfEc2InstanceE(t, instanceID, awsRegion) require.NoError(t, err) return ip } // GetPrivateIpOfEc2InstanceE gets the private IP address of the given EC2 Instance in the given region. func GetPrivateIpOfEc2InstanceE(t testing.TestingT, instanceID string, awsRegion string) (string, error) { ips, err := GetPrivateIpsOfEc2InstancesE(t, []string{instanceID}, awsRegion) if err != nil { return "", err } ip, containsIP := ips[instanceID] if !containsIP { return "", IpForEc2InstanceNotFound{InstanceId: instanceID, AwsRegion: awsRegion, Type: "private"} } return ip, nil } // GetPrivateIpsOfEc2Instances gets the private IP address of the given EC2 Instance in the given region. Returns a map of instance ID to IP address. func GetPrivateIpsOfEc2Instances(t testing.TestingT, instanceIDs []string, awsRegion string) map[string]string { ips, err := GetPrivateIpsOfEc2InstancesE(t, instanceIDs, awsRegion) require.NoError(t, err) return ips } // GetPrivateIpsOfEc2InstancesE gets the private IP address of the given EC2 Instance in the given region. Returns a map of instance ID to IP address. func GetPrivateIpsOfEc2InstancesE(t testing.TestingT, instanceIDs []string, awsRegion string) (map[string]string, error) { ec2Client := NewEc2Client(t, awsRegion) // TODO: implement pagination for cases that extend beyond limit (1000 instances) input := ec2.DescribeInstancesInput{InstanceIds: instanceIDs} output, err := ec2Client.DescribeInstances(context.Background(), &input) if err != nil { return nil, err } ips := map[string]string{} for _, reservation := range output.Reservations { for _, instance := range reservation.Instances { ips[aws.ToString(instance.InstanceId)] = aws.ToString(instance.PrivateIpAddress) } } return ips, nil } // GetPrivateHostnameOfEc2Instance gets the private IP address of the given EC2 Instance in the given region. func GetPrivateHostnameOfEc2Instance(t testing.TestingT, instanceID string, awsRegion string) string { ip, err := GetPrivateHostnameOfEc2InstanceE(t, instanceID, awsRegion) require.NoError(t, err) return ip } // GetPrivateHostnameOfEc2InstanceE gets the private IP address of the given EC2 Instance in the given region. func GetPrivateHostnameOfEc2InstanceE(t testing.TestingT, instanceID string, awsRegion string) (string, error) { hostnames, err := GetPrivateHostnamesOfEc2InstancesE(t, []string{instanceID}, awsRegion) if err != nil { return "", err } hostname, containsHostname := hostnames[instanceID] if !containsHostname { return "", HostnameForEc2InstanceNotFound{InstanceId: instanceID, AwsRegion: awsRegion, Type: "private"} } return hostname, nil } // GetPrivateHostnamesOfEc2Instances gets the private IP address of the given EC2 Instance in the given region. Returns a map of instance ID to IP address. func GetPrivateHostnamesOfEc2Instances(t testing.TestingT, instanceIDs []string, awsRegion string) map[string]string { ips, err := GetPrivateHostnamesOfEc2InstancesE(t, instanceIDs, awsRegion) require.NoError(t, err) return ips } // GetPrivateHostnamesOfEc2InstancesE gets the private IP address of the given EC2 Instance in the given region. Returns a map of instance ID to IP address. func GetPrivateHostnamesOfEc2InstancesE(t testing.TestingT, instanceIDs []string, awsRegion string) (map[string]string, error) { ec2Client, err := NewEc2ClientE(t, awsRegion) if err != nil { return nil, err } // TODO: implement pagination for cases that extend beyond limit (1000 instances) input := ec2.DescribeInstancesInput{InstanceIds: instanceIDs} output, err := ec2Client.DescribeInstances(context.Background(), &input) if err != nil { return nil, err } hostnames := map[string]string{} for _, reservation := range output.Reservations { for _, instance := range reservation.Instances { hostnames[aws.ToString(instance.InstanceId)] = aws.ToString(instance.PrivateDnsName) } } return hostnames, nil } // GetPublicIpOfEc2Instance gets the public IP address of the given EC2 Instance in the given region. func GetPublicIpOfEc2Instance(t testing.TestingT, instanceID string, awsRegion string) string { ip, err := GetPublicIpOfEc2InstanceE(t, instanceID, awsRegion) require.NoError(t, err) return ip } // GetPublicIpOfEc2InstanceE gets the public IP address of the given EC2 Instance in the given region. func GetPublicIpOfEc2InstanceE(t testing.TestingT, instanceID string, awsRegion string) (string, error) { ips, err := GetPublicIpsOfEc2InstancesE(t, []string{instanceID}, awsRegion) if err != nil { return "", err } ip, containsIP := ips[instanceID] if !containsIP { return "", IpForEc2InstanceNotFound{InstanceId: instanceID, AwsRegion: awsRegion, Type: "public"} } return ip, nil } // GetPublicIpsOfEc2Instances gets the public IP address of the given EC2 Instance in the given region. Returns a map of instance ID to IP address. func GetPublicIpsOfEc2Instances(t testing.TestingT, instanceIDs []string, awsRegion string) map[string]string { ips, err := GetPublicIpsOfEc2InstancesE(t, instanceIDs, awsRegion) require.NoError(t, err) return ips } // GetPublicIpsOfEc2InstancesE gets the public IP address of the given EC2 Instance in the given region. Returns a map of instance ID to IP address. func GetPublicIpsOfEc2InstancesE(t testing.TestingT, instanceIDs []string, awsRegion string) (map[string]string, error) { ec2Client := NewEc2Client(t, awsRegion) // TODO: implement pagination for cases that extend beyond limit (1000 instances) input := ec2.DescribeInstancesInput{InstanceIds: instanceIDs} output, err := ec2Client.DescribeInstances(context.Background(), &input) if err != nil { return nil, err } ips := map[string]string{} for _, reservation := range output.Reservations { for _, instance := range reservation.Instances { ips[aws.ToString(instance.InstanceId)] = aws.ToString(instance.PublicIpAddress) } } return ips, nil } // GetEc2InstanceIdsByTag returns all the IDs of EC2 instances in the given region with the given tag. func GetEc2InstanceIdsByTag(t testing.TestingT, region string, tagName string, tagValue string) []string { out, err := GetEc2InstanceIdsByTagE(t, region, tagName, tagValue) require.NoError(t, err) return out } // GetEc2InstanceIdsByTagE returns all the IDs of EC2 instances in the given region with the given tag. func GetEc2InstanceIdsByTagE(t testing.TestingT, region string, tagName string, tagValue string) ([]string, error) { ec2Filters := map[string][]string{ fmt.Sprintf("tag:%s", tagName): {tagValue}, } return GetEc2InstanceIdsByFiltersE(t, region, ec2Filters) } // GetEc2InstanceIdsByFilters returns all the IDs of EC2 instances in the given region which match to EC2 filter list // as per https://docs.aws.amazon.com/sdk-for-go/api/service/ec2/#DescribeInstancesInput. func GetEc2InstanceIdsByFilters(t testing.TestingT, region string, ec2Filters map[string][]string) []string { out, err := GetEc2InstanceIdsByFiltersE(t, region, ec2Filters) require.NoError(t, err) return out } // GetEc2InstanceIdsByFiltersE returns all the IDs of EC2 instances in the given region which match to EC2 filter list // as per https://docs.aws.amazon.com/sdk-for-go/api/service/ec2/#DescribeInstancesInput. func GetEc2InstanceIdsByFiltersE(t testing.TestingT, region string, ec2Filters map[string][]string) ([]string, error) { client, err := NewEc2ClientE(t, region) if err != nil { return nil, err } var ec2FilterList []types.Filter for name, values := range ec2Filters { ec2FilterList = append(ec2FilterList, types.Filter{Name: aws.String(name), Values: values}) } // TODO: implement pagination for cases that extend beyond limit (1000 instances) output, err := client.DescribeInstances(context.Background(), &ec2.DescribeInstancesInput{Filters: ec2FilterList}) if err != nil { return nil, err } var instanceIDs []string for _, reservation := range output.Reservations { for _, instance := range reservation.Instances { instanceIDs = append(instanceIDs, *instance.InstanceId) } } return instanceIDs, err } // GetTagsForEc2Instance returns all the tags for the given EC2 Instance. func GetTagsForEc2Instance(t testing.TestingT, region string, instanceID string) map[string]string { tags, err := GetTagsForEc2InstanceE(t, region, instanceID) require.NoError(t, err) return tags } // GetTagsForEc2InstanceE returns all the tags for the given EC2 Instance. func GetTagsForEc2InstanceE(t testing.TestingT, region string, instanceID string) (map[string]string, error) { client, err := NewEc2ClientE(t, region) if err != nil { return nil, err } input := ec2.DescribeTagsInput{ Filters: []types.Filter{ { Name: aws.String("resource-type"), Values: []string{"instance"}, }, { Name: aws.String("resource-id"), Values: []string{instanceID}, }, }, } out, err := client.DescribeTags(context.Background(), &input) if err != nil { return nil, err } tags := map[string]string{} for _, tag := range out.Tags { tags[aws.ToString(tag.Key)] = aws.ToString(tag.Value) } return tags, nil } // DeleteAmi deletes the given AMI in the given region. func DeleteAmi(t testing.TestingT, region string, imageID string) { require.NoError(t, DeleteAmiE(t, region, imageID)) } // DeleteAmiE deletes the given AMI in the given region. func DeleteAmiE(t testing.TestingT, region string, imageID string) error { logger.Default.Logf(t, "Deregistering AMI %s", imageID) client, err := NewEc2ClientE(t, region) if err != nil { return err } _, err = client.DeregisterImage(context.Background(), &ec2.DeregisterImageInput{ImageId: aws.String(imageID)}) return err } // AddTagsToResource adds the tags to the given taggable AWS resource such as EC2, AMI or VPC. func AddTagsToResource(t testing.TestingT, region string, resource string, tags map[string]string) { require.NoError(t, AddTagsToResourceE(t, region, resource, tags)) } // AddTagsToResourceE adds the tags to the given taggable AWS resource such as EC2, AMI or VPC. func AddTagsToResourceE(t testing.TestingT, region string, resource string, tags map[string]string) error { client, err := NewEc2ClientE(t, region) if err != nil { return err } var awsTags []types.Tag for key, value := range tags { awsTags = append(awsTags, types.Tag{ Key: aws.String(key), Value: aws.String(value), }) } _, err = client.CreateTags(context.Background(), &ec2.CreateTagsInput{ Resources: []string{resource}, Tags: awsTags, }) return err } // TerminateInstance terminates the EC2 instance with the given ID in the given region. func TerminateInstance(t testing.TestingT, region string, instanceID string) { require.NoError(t, TerminateInstanceE(t, region, instanceID)) } // TerminateInstanceE terminates the EC2 instance with the given ID in the given region. func TerminateInstanceE(t testing.TestingT, region string, instanceID string) error { logger.Default.Logf(t, "Terminating Instance %s", instanceID) client, err := NewEc2ClientE(t, region) if err != nil { return err } _, err = client.TerminateInstances(context.Background(), &ec2.TerminateInstancesInput{ InstanceIds: []string{ instanceID, }, }) return err } // GetAmiPubliclyAccessible returns whether the AMI is publicly accessible or not func GetAmiPubliclyAccessible(t testing.TestingT, awsRegion string, amiID string) bool { output, err := GetAmiPubliclyAccessibleE(t, awsRegion, amiID) require.NoError(t, err) return output } // GetAmiPubliclyAccessibleE returns whether the AMI is publicly accessible or not func GetAmiPubliclyAccessibleE(t testing.TestingT, awsRegion string, amiID string) (bool, error) { launchPermissions, err := GetLaunchPermissionsForAmiE(t, awsRegion, amiID) if err != nil { return false, err } for _, launchPermission := range launchPermissions { if string(launchPermission.Group) == "all" { return true, nil } } return false, nil } // GetAccountsWithLaunchPermissionsForAmi returns list of accounts that the AMI is shared with func GetAccountsWithLaunchPermissionsForAmi(t testing.TestingT, awsRegion string, amiID string) []string { output, err := GetAccountsWithLaunchPermissionsForAmiE(t, awsRegion, amiID) require.NoError(t, err) return output } // GetAccountsWithLaunchPermissionsForAmiE returns list of accounts that the AMI is shared with func GetAccountsWithLaunchPermissionsForAmiE(t testing.TestingT, awsRegion string, amiID string) ([]string, error) { var accountIDs []string launchPermissions, err := GetLaunchPermissionsForAmiE(t, awsRegion, amiID) if err != nil { return accountIDs, err } for _, launchPermission := range launchPermissions { if aws.ToString(launchPermission.UserId) != "" { accountIDs = append(accountIDs, aws.ToString(launchPermission.UserId)) } } return accountIDs, nil } // GetLaunchPermissionsForAmiE returns launchPermissions as configured in AWS func GetLaunchPermissionsForAmiE(t testing.TestingT, awsRegion string, amiID string) ([]types.LaunchPermission, error) { client := NewEc2Client(t, awsRegion) input := &ec2.DescribeImageAttributeInput{ Attribute: types.ImageAttributeNameLaunchPermission, ImageId: aws.String(amiID), } output, err := client.DescribeImageAttribute(context.Background(), input) if err != nil { return []types.LaunchPermission{}, err } return output.LaunchPermissions, nil } // GetRecommendedInstanceType takes in a list of EC2 instance types (e.g., "t2.micro", "t3.micro") and returns the // first instance type in the list that is available in all Availability Zones (AZs) in the given region. If there's no // instance available in all AZs, this function exits with an error. This is useful because certain instance types, // such as t2.micro, are not available in some of the newer AZs, while t3.micro is not available in some of the older // AZs, and if you have code that needs to run on a "small" instance across all AZs in many different regions, you can // use this function to automatically figure out which instance type you should use. // This function will fail the test if there is an error. func GetRecommendedInstanceType(t testing.TestingT, region string, instanceTypeOptions []string) string { out, err := GetRecommendedInstanceTypeE(t, region, instanceTypeOptions) require.NoError(t, err) return out } // GetRecommendedInstanceTypeE takes in a list of EC2 instance types (e.g., "t2.micro", "t3.micro") and returns the // first instance type in the list that is available in all Availability Zones (AZs) in the given region. If there's no // instance available in all AZs, this function exits with an error. This is useful because certain instance types, // such as t2.micro, are not available in some of the newer AZs, while t3.micro is not available in some of the older // AZs. If you have code that needs to run on a "small" instance across all AZs in many different regions, you can // use this function to automatically figure out which instance type you should use. func GetRecommendedInstanceTypeE(t testing.TestingT, region string, instanceTypeOptions []string) (string, error) { client, err := NewEc2ClientE(t, region) if err != nil { return "", err } return GetRecommendedInstanceTypeWithClientE(t, client, instanceTypeOptions) } // GetRecommendedInstanceTypeWithClientE takes in a list of EC2 instance types (e.g., "t2.micro", "t3.micro") and returns the // first instance type in the list that is available in all Availability Zones (AZs) in the given region. If there's no // instance available in all AZs, this function exits with an error. This is useful because certain instance types, // such as t2.micro, are not available in some of the newer AZs, while t3.micro is not available in some of the older // AZs. If you have code that needs to run on a "small" instance across all AZs in many different regions, you can // use this function to automatically figure out which instance type you should use. // This function expects an authenticated EC2 client from the AWS SDK Go library. func GetRecommendedInstanceTypeWithClientE(t testing.TestingT, ec2Client *ec2.Client, instanceTypeOptions []string) (string, error) { availabilityZones, err := getAllAvailabilityZonesE(ec2Client) if err != nil { return "", err } instanceTypeOfferings, err := getInstanceTypeOfferingsE(ec2Client, instanceTypeOptions) if err != nil { return "", err } return pickRecommendedInstanceTypeE(availabilityZones, instanceTypeOfferings, instanceTypeOptions) } // pickRecommendedInstanceTypeE returns the first instance type from instanceTypeOptions that is available in all the // AZs in availabilityZones based on the availability data in instanceTypeOfferings. If none of the instance types are // available in all AZs, this function returns an error. func pickRecommendedInstanceTypeE(availabilityZones []string, instanceTypeOfferings []types.InstanceTypeOffering, instanceTypeOptions []string) (string, error) { // O(n^3) for the win! for _, instanceType := range instanceTypeOptions { if instanceTypeExistsInAllAzs(instanceType, availabilityZones, instanceTypeOfferings) { return instanceType, nil } } return "", NoInstanceTypeError{InstanceTypeOptions: instanceTypeOptions, Azs: availabilityZones} } // instanceTypeExistsInAllAzs returns true if the given instance type exists in all the given availabilityZones based // on the availability data in instanceTypeOfferings func instanceTypeExistsInAllAzs(instanceType string, availabilityZones []string, instanceTypeOfferings []types.InstanceTypeOffering) bool { if len(availabilityZones) == 0 || len(instanceTypeOfferings) == 0 { return false } for _, az := range availabilityZones { if !hasOffering(instanceTypeOfferings, az, instanceType) { return false } } return true } // hasOffering returns true if the given availability zone and instance type are one of the offerings in // instanceTypeOfferings func hasOffering(instanceTypeOfferings []types.InstanceTypeOffering, availabilityZone string, instanceType string) bool { for _, offering := range instanceTypeOfferings { if string(offering.InstanceType) == instanceType && aws.ToString(offering.Location) == availabilityZone { return true } } return false } // getInstanceTypeOfferingsE returns the instance types from the given list that are available in the region configured // in the given EC2 client func getInstanceTypeOfferingsE(client *ec2.Client, instanceTypeOptions []string) ([]types.InstanceTypeOffering, error) { input := ec2.DescribeInstanceTypeOfferingsInput{ LocationType: types.LocationTypeAvailabilityZone, Filters: []types.Filter{ { Name: aws.String("instance-type"), Values: instanceTypeOptions, }, }, } out, err := client.DescribeInstanceTypeOfferings(context.Background(), &input) if err != nil { return nil, err } return out.InstanceTypeOfferings, nil } // getAllAvailabilityZonesE returns all the available AZs in the region configured in the given EC2 client func getAllAvailabilityZonesE(client *ec2.Client) ([]string, error) { input := ec2.DescribeAvailabilityZonesInput{ Filters: []types.Filter{ { Name: aws.String("state"), Values: []string{"available"}, }, }, } out, err := client.DescribeAvailabilityZones(context.Background(), &input) if err != nil { return nil, err } var azs []string for _, az := range out.AvailabilityZones { azs = append(azs, aws.ToString(az.ZoneName)) } return azs, nil } // NewEc2Client creates an EC2 client. func NewEc2Client(t testing.TestingT, region string) *ec2.Client { client, err := NewEc2ClientE(t, region) require.NoError(t, err) return client } // NewEc2ClientE creates an EC2 client. func NewEc2ClientE(t testing.TestingT, region string) (*ec2.Client, error) { sess, err := NewAuthenticatedSession(region) if err != nil { return nil, err } return ec2.NewFromConfig(*sess), nil } ================================================ FILE: modules/aws/ec2_test.go ================================================ package aws import ( "fmt" "strings" "testing" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/gruntwork-io/terratest/modules/random" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestGetEc2InstanceIdsByTag(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) ids, err := GetEc2InstanceIdsByTagE(t, region, "Name", fmt.Sprintf("nonexistent-%s", random.UniqueId())) require.NoError(t, err) assert.Equal(t, 0, len(ids)) } func TestGetEc2InstanceIdsByFilters(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) filters := map[string][]string{ "instance-state-name": {"running", "shutting-down"}, "tag:Name": {fmt.Sprintf("nonexistent-%s", random.UniqueId())}, } ids, err := GetEc2InstanceIdsByFiltersE(t, region, filters) require.NoError(t, err) assert.Equal(t, 0, len(ids)) } func TestGetRecommendedInstanceType(t *testing.T) { t.Parallel() testCases := []struct { region string instanceTypeOptions []string }{ {"eu-west-1", []string{"t2.micro", "t3.micro"}}, {"ap-northeast-2", []string{"t2.micro", "t3.micro"}}, {"us-east-1", []string{"t2.large", "t3.large"}}, } for _, testCase := range testCases { // The following is necessary to make sure testCase's values don't get updated due to concurrency within the // scope of t.Run(..) below. https://golang.org/doc/faq#closures_and_goroutines testCase := testCase t.Run(fmt.Sprintf("%s-%s", testCase.region, strings.Join(testCase.instanceTypeOptions, "-")), func(t *testing.T) { t.Parallel() instanceType := GetRecommendedInstanceType(t, testCase.region, testCase.instanceTypeOptions) // We could hard-code the expected result (e.g., as of July 2020, we expect eu-west-1 to return t2.micro // and ap-northeast-2 to return t3.micro), but the result will likely change over time, so to avoid a // brittle test, we simply check that we get _one_ result. Combined with the unit test below, this hopefully // is enough to be confident this function works correctly. assert.Contains(t, testCase.instanceTypeOptions, instanceType) }) } } func TestPickRecommendedInstanceTypeHappyPath(t *testing.T) { testCases := []struct { name string availabilityZones []string instanceTypeOfferings []types.InstanceTypeOffering instanceTypeOptions []string expected string }{ { "One AZ, one instance type, available in one offering", []string{"us-east-1a"}, offerings(map[string][]string{"us-east-1a": {"t2.micro"}}), []string{"t2.micro"}, "t2.micro", }, { "Three AZs, one instance type, available in all three offerings", []string{"us-east-1a", "us-east-1b", "us-east-1c"}, offerings(map[string][]string{"us-east-1a": {"t2.micro"}, "us-east-1b": {"t2.micro"}, "us-east-1c": {"t2.micro"}}), []string{"t2.micro"}, "t2.micro", }, { "Three AZs, two instance types, first one available in all three offerings, the other not available at all", []string{"us-east-1a", "us-east-1b", "us-east-1c"}, offerings(map[string][]string{"us-east-1a": {"t2.micro"}, "us-east-1b": {"t2.micro"}, "us-east-1c": {"t2.micro"}}), []string{"t2.micro", "t3.micro"}, "t2.micro", }, { "Three AZs, two instance types, first one available in all three offerings, the other only available in one offering in an unrequested AZ", []string{"us-east-1a", "us-east-1b", "us-east-1c"}, offerings(map[string][]string{"us-east-1a": {"t2.micro"}, "us-east-1b": {"t2.micro"}, "us-east-1c": {"t2.micro"}, "us-east-1d": {"t3.micro"}}), []string{"t2.micro", "t3.micro"}, "t2.micro", }, { "Three AZs, two instance types, first one available in all three offerings, the other one available in only two offerings", []string{"us-east-1a", "us-east-1b", "us-east-1c"}, offerings(map[string][]string{"us-east-1a": {"t2.micro", "t3.micro"}, "us-east-1b": {"t2.micro"}, "us-east-1c": {"t2.micro"}}), []string{"t2.micro", "t3.micro"}, "t2.micro", }, { "Three AZs, three instance types, first one available in two offerings, second in all three offerings, third in two offerings", []string{"us-east-1a", "us-east-1b", "us-east-1c"}, offerings(map[string][]string{"us-east-1a": {"t2.micro", "t3.micro", "t3.small"}, "us-east-1b": {"t3.micro"}, "us-east-1c": {"t2.micro", "t3.micro", "t3.small"}}), []string{"t2.micro", "t3.micro", "t3.small"}, "t3.micro", }, } for _, testCase := range testCases { // The following is necessary to make sure testCase's values don't get updated due to concurrency within the // scope of t.Run(..) below. https://golang.org/doc/faq#closures_and_goroutines testCase := testCase t.Run(testCase.name, func(t *testing.T) { t.Parallel() actual, err := pickRecommendedInstanceTypeE(testCase.availabilityZones, testCase.instanceTypeOfferings, testCase.instanceTypeOptions) assert.NoError(t, err) assert.Equal(t, testCase.expected, actual) }) } } func TestPickRecommendedInstanceTypeErrors(t *testing.T) { testCases := []struct { name string availabilityZones []string instanceTypeOfferings []types.InstanceTypeOffering instanceTypeOptions []string }{ { "All params nil", nil, nil, nil, }, { "No AZs, one instance type, no offerings", nil, nil, []string{"t2.micro"}, }, { "One AZ, one instance type, no offerings", []string{"us-east-1a"}, nil, []string{"t2.micro"}, }, { "Two AZs, one instance type, available in only one offering", []string{"us-east-1a", "us-east-1b"}, offerings(map[string][]string{"us-east-1a": {"t2.micro"}}), []string{"t2.micro"}, }, { "Three AZs, two instance types, each available in only two of the three offerings", []string{"us-east-1a", "us-east-1b", "us-east-1c"}, offerings(map[string][]string{"us-east-1a": {"t2.micro"}, "us-east-1b": {"t2.micro", "t3.micro"}, "us-east-1c": {"t3.micro"}}), []string{"t2.micro", "t3.micro"}, }, } for _, testCase := range testCases { // The following is necessary to make sure testCase's values don't // get updated due to concurrency within the scope of t.Run(..) below testCase := testCase t.Run(testCase.name, func(t *testing.T) { t.Parallel() _, err := pickRecommendedInstanceTypeE(testCase.availabilityZones, testCase.instanceTypeOfferings, testCase.instanceTypeOptions) assert.EqualError(t, err, NoInstanceTypeError{Azs: testCase.availabilityZones, InstanceTypeOptions: testCase.instanceTypeOptions}.Error()) }) } } func offerings(offerings map[string][]string) []types.InstanceTypeOffering { var out []types.InstanceTypeOffering for az, instanceTypes := range offerings { for _, instanceType := range instanceTypes { offering := types.InstanceTypeOffering{ InstanceType: types.InstanceType(instanceType), Location: aws.String(az), LocationType: types.LocationTypeAvailabilityZone, } out = append(out, offering) } } return out } ================================================ FILE: modules/aws/ecr.go ================================================ package aws import ( "context" goerrors "errors" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ecr" "github.com/aws/aws-sdk-go-v2/service/ecr/types" "github.com/gruntwork-io/go-commons/errors" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // CreateECRRepo creates a new ECR Repository. This will fail the test and stop execution if there is an error. func CreateECRRepo(t testing.TestingT, region string, name string) *types.Repository { repo, err := CreateECRRepoE(t, region, name) require.NoError(t, err) return repo } // CreateECRRepoE creates a new ECR Repository. func CreateECRRepoE(t testing.TestingT, region string, name string) (*types.Repository, error) { client := NewECRClient(t, region) resp, err := client.CreateRepository(context.Background(), &ecr.CreateRepositoryInput{RepositoryName: aws.String(name)}) if err != nil { return nil, err } return resp.Repository, nil } // GetECRRepo gets an ECR repository by name. This will fail the test and stop execution if there is an error. // An error occurs if a repository with the given name does not exist in the given region. func GetECRRepo(t testing.TestingT, region string, name string) *types.Repository { repo, err := GetECRRepoE(t, region, name) require.NoError(t, err) return repo } // GetECRRepoE gets an ECR Repository by name. // An error occurs if a repository with the given name does not exist in the given region. func GetECRRepoE(t testing.TestingT, region string, name string) (*types.Repository, error) { client := NewECRClient(t, region) repositoryNames := []string{name} resp, err := client.DescribeRepositories(context.Background(), &ecr.DescribeRepositoriesInput{RepositoryNames: repositoryNames}) if err != nil { return nil, err } if len(resp.Repositories) != 1 { return nil, errors.WithStackTrace(goerrors.New("an unexpected condition occurred. Please file an issue at github.com/gruntwork-io/terratest")) } return &resp.Repositories[0], nil } // DeleteECRRepo will force delete the ECR repo by deleting all images prior to deleting the ECR repository. // This will fail the test and stop execution if there is an error. func DeleteECRRepo(t testing.TestingT, region string, repo *types.Repository) { err := DeleteECRRepoE(t, region, repo) require.NoError(t, err) } // DeleteECRRepoE will force delete the ECR repo by deleting all images prior to deleting the ECR repository. func DeleteECRRepoE(t testing.TestingT, region string, repo *types.Repository) error { client := NewECRClient(t, region) resp, err := client.ListImages(context.Background(), &ecr.ListImagesInput{RepositoryName: repo.RepositoryName}) if err != nil { return err } if len(resp.ImageIds) > 0 { _, err = client.BatchDeleteImage(context.Background(), &ecr.BatchDeleteImageInput{ RepositoryName: repo.RepositoryName, ImageIds: resp.ImageIds, }) if err != nil { return err } } _, err = client.DeleteRepository(context.Background(), &ecr.DeleteRepositoryInput{RepositoryName: repo.RepositoryName}) if err != nil { return err } return nil } // NewECRClient returns a client for the Elastic Container Registry. This will fail the test and // stop execution if there is an error. func NewECRClient(t testing.TestingT, region string) *ecr.Client { sess, err := NewECRClientE(t, region) require.NoError(t, err) return sess } // NewECRClientE returns a client for the Elastic Container Registry. func NewECRClientE(t testing.TestingT, region string) (*ecr.Client, error) { sess, err := NewAuthenticatedSession(region) if err != nil { return nil, err } return ecr.NewFromConfig(*sess), nil } // GetECRRepoLifecyclePolicy gets the policies for the given ECR repository. // This will fail the test and stop execution if there is an error. func GetECRRepoLifecyclePolicy(t testing.TestingT, region string, repo *types.Repository) string { policy, err := GetECRRepoLifecyclePolicyE(t, region, repo) require.NoError(t, err) return policy } // GetECRRepoLifecyclePolicyE gets the policies for the given ECR repository. func GetECRRepoLifecyclePolicyE(t testing.TestingT, region string, repo *types.Repository) (string, error) { client := NewECRClient(t, region) resp, err := client.GetLifecyclePolicy(context.Background(), &ecr.GetLifecyclePolicyInput{RepositoryName: repo.RepositoryName}) if err != nil { return "", err } return *resp.LifecyclePolicyText, nil } // PutECRRepoLifecyclePolicy puts the given policy for the given ECR repository. // This will fail the test and stop execution if there is an error. func PutECRRepoLifecyclePolicy(t testing.TestingT, region string, repo *types.Repository, policy string) { err := PutECRRepoLifecyclePolicyE(t, region, repo, policy) require.NoError(t, err) } // PutECRRepoLifecyclePolicyE puts the given policy for the given ECR repository. func PutECRRepoLifecyclePolicyE(t testing.TestingT, region string, repo *types.Repository, policy string) error { logger.Default.Logf(t, "Applying policy for repository %s in %s", *repo.RepositoryName, region) client, err := NewECRClientE(t, region) if err != nil { return err } input := &ecr.PutLifecyclePolicyInput{ RepositoryName: repo.RepositoryName, LifecyclePolicyText: aws.String(policy), } _, err = client.PutLifecyclePolicy(context.Background(), input) return err } // GetECRRepoPolicy gets the permissions for the given ECR repository. // This will fail the test and stop execution if there is an error. func GetECRRepoPolicy(t testing.TestingT, region string, repo *types.Repository) string { policy, err := GetECRRepoPolicyE(t, region, repo) require.NoError(t, err) return policy } // GetECRRepoPolicyE gets the policies for the given ECR repository. func GetECRRepoPolicyE(t testing.TestingT, region string, repo *types.Repository) (string, error) { client := NewECRClient(t, region) resp, err := client.GetRepositoryPolicy(context.Background(), &ecr.GetRepositoryPolicyInput{RepositoryName: repo.RepositoryName}) if err != nil { return "", err } return *resp.PolicyText, nil } // PutECRRepoPolicy puts the given policy for the given ECR repository. // This will fail the test and stop execution if there is an error. func PutECRRepoPolicy(t testing.TestingT, region string, repo *types.Repository, policy string) { err := PutECRRepoPolicyE(t, region, repo, policy) require.NoError(t, err) } // PutECRRepoPolicyE puts the given policy for the given ECR repository. func PutECRRepoPolicyE(t testing.TestingT, region string, repo *types.Repository, policy string) error { logger.Default.Logf(t, "Applying repo policy for repository %s in %s", *repo.RepositoryName, region) client, err := NewECRClientE(t, region) if err != nil { return err } input := &ecr.SetRepositoryPolicyInput{ PolicyText: &policy, RepositoryName: repo.RepositoryName, } _, err = client.SetRepositoryPolicy(context.Background(), input) return err } ================================================ FILE: modules/aws/ecr_test.go ================================================ package aws import ( "fmt" "strings" "testing" "github.com/aws/aws-sdk-go-v2/aws" "github.com/gruntwork-io/terratest/modules/random" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestEcrRepo(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) ecrRepoName := fmt.Sprintf("terratest%s", strings.ToLower(random.UniqueId())) repo1, err := CreateECRRepoE(t, region, ecrRepoName) defer DeleteECRRepo(t, region, repo1) require.NoError(t, err) assert.Equal(t, ecrRepoName, aws.ToString(repo1.RepositoryName)) repo2, err := GetECRRepoE(t, region, ecrRepoName) require.NoError(t, err) assert.Equal(t, ecrRepoName, aws.ToString(repo2.RepositoryName)) } func TestGetEcrRepoLifecyclePolicyError(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) ecrRepoName := fmt.Sprintf("terratest%s", strings.ToLower(random.UniqueId())) repo1, err := CreateECRRepoE(t, region, ecrRepoName) defer DeleteECRRepo(t, region, repo1) require.NoError(t, err) assert.Equal(t, ecrRepoName, aws.ToString(repo1.RepositoryName)) _, err = GetECRRepoLifecyclePolicyE(t, region, repo1) require.Error(t, err) } func TestCanSetECRRepoLifecyclePolicyWithSingleRule(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) ecrRepoName := fmt.Sprintf("terratest%s", strings.ToLower(random.UniqueId())) repo1, err := CreateECRRepoE(t, region, ecrRepoName) defer DeleteECRRepo(t, region, repo1) require.NoError(t, err) lifecyclePolicy := `{ "rules": [ { "rulePriority": 1, "description": "Expire images older than 14 days", "selection": { "tagStatus": "untagged", "countType": "sinceImagePushed", "countUnit": "days", "countNumber": 14 }, "action": { "type": "expire" } } ] }` err = PutECRRepoLifecyclePolicyE(t, region, repo1, lifecyclePolicy) require.NoError(t, err) policy := GetECRRepoLifecyclePolicy(t, region, repo1) assert.JSONEq(t, lifecyclePolicy, policy) } func TestCanSetRepositoryPolicyWithSimplePolicy(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) ecrRepoName := fmt.Sprintf("terratest%s", strings.ToLower(random.UniqueId())) repo, err := CreateECRRepoE(t, region, ecrRepoName) defer DeleteECRRepo(t, region, repo) require.NoError(t, err) repositoryPolicy := ` { "Version": "2012-10-17", "Statement": [ { "Sid": "AllowPushPull", "Effect": "Allow", "Principal": { "AWS": "*" }, "Action": "ecr:*" } ] }` err = PutECRRepoPolicyE(t, region, repo, repositoryPolicy) require.NoError(t, err) policy := GetECRRepoPolicy(t, region, repo) assert.JSONEq(t, repositoryPolicy, policy) } ================================================ FILE: modules/aws/ecs.go ================================================ package aws import ( "context" "fmt" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ecs" "github.com/aws/aws-sdk-go-v2/service/ecs/types" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // GetEcsCluster fetches information about specified ECS cluster. func GetEcsCluster(t testing.TestingT, region string, name string) *types.Cluster { cluster, err := GetEcsClusterE(t, region, name) require.NoError(t, err) return cluster } // GetEcsClusterE fetches information about specified ECS cluster. func GetEcsClusterE(t testing.TestingT, region string, name string) (*types.Cluster, error) { return GetEcsClusterWithIncludeE(t, region, name, []types.ClusterField{}) } // GetEcsClusterWithInclude fetches extended information about specified ECS cluster. // The `include` parameter specifies a list of `ecs.ClusterField*` constants, such as `ecs.ClusterFieldTags`. func GetEcsClusterWithInclude(t testing.TestingT, region string, name string, include []types.ClusterField) *types.Cluster { clusterInfo, err := GetEcsClusterWithIncludeE(t, region, name, include) require.NoError(t, err) return clusterInfo } // GetEcsClusterWithIncludeE fetches extended information about specified ECS cluster. // The `include` parameter specifies a list of `ecs.ClusterField*` constants, such as `ecs.ClusterFieldTags`. func GetEcsClusterWithIncludeE(t testing.TestingT, region string, name string, include []types.ClusterField) (*types.Cluster, error) { client, err := NewEcsClientE(t, region) if err != nil { return nil, err } input := &ecs.DescribeClustersInput{ Clusters: []string{ name, }, Include: include, } output, err := client.DescribeClusters(context.Background(), input) if err != nil { return nil, err } numClusters := len(output.Clusters) if numClusters != 1 { return nil, fmt.Errorf("expected to find 1 ECS cluster named '%s' in region '%v', but found '%d'", name, region, numClusters) } return &output.Clusters[0], nil } // GetDefaultEcsClusterE fetches information about default ECS cluster. func GetDefaultEcsClusterE(t testing.TestingT, region string) (*types.Cluster, error) { return GetEcsClusterE(t, region, "default") } // GetDefaultEcsCluster fetches information about default ECS cluster. func GetDefaultEcsCluster(t testing.TestingT, region string) *types.Cluster { return GetEcsCluster(t, region, "default") } // CreateEcsCluster creates ECS cluster in the given region under the given name. func CreateEcsCluster(t testing.TestingT, region string, name string) *types.Cluster { cluster, err := CreateEcsClusterE(t, region, name) require.NoError(t, err) return cluster } // CreateEcsClusterE creates ECS cluster in the given region under the given name. func CreateEcsClusterE(t testing.TestingT, region string, name string) (*types.Cluster, error) { client := NewEcsClient(t, region) cluster, err := client.CreateCluster(context.Background(), &ecs.CreateClusterInput{ ClusterName: aws.String(name), }) if err != nil { return nil, err } return cluster.Cluster, nil } func DeleteEcsCluster(t testing.TestingT, region string, cluster *types.Cluster) { err := DeleteEcsClusterE(t, region, cluster) require.NoError(t, err) } // DeleteEcsClusterE deletes existing ECS cluster in the given region. func DeleteEcsClusterE(t testing.TestingT, region string, cluster *types.Cluster) error { client := NewEcsClient(t, region) _, err := client.DeleteCluster(context.Background(), &ecs.DeleteClusterInput{ Cluster: aws.String(*cluster.ClusterName), }) return err } // GetEcsService fetches information about specified ECS service. func GetEcsService(t testing.TestingT, region string, clusterName string, serviceName string) *types.Service { service, err := GetEcsServiceE(t, region, clusterName, serviceName) require.NoError(t, err) return service } // GetEcsServiceE fetches information about specified ECS service. func GetEcsServiceE(t testing.TestingT, region string, clusterName string, serviceName string) (*types.Service, error) { output, err := NewEcsClient(t, region).DescribeServices(context.Background(), &ecs.DescribeServicesInput{ Cluster: aws.String(clusterName), Services: []string{ serviceName, }, }) if err != nil { return nil, err } numServices := len(output.Services) if numServices != 1 { return nil, fmt.Errorf( "expected to find 1 ECS service named '%s' in cluster '%s' in region '%v', but found '%d'", serviceName, clusterName, region, numServices) } return &output.Services[0], nil } // GetEcsTaskDefinition fetches information about specified ECS task definition. func GetEcsTaskDefinition(t testing.TestingT, region string, taskDefinition string) *types.TaskDefinition { task, err := GetEcsTaskDefinitionE(t, region, taskDefinition) require.NoError(t, err) return task } // GetEcsTaskDefinitionE fetches information about specified ECS task definition. func GetEcsTaskDefinitionE(t testing.TestingT, region string, taskDefinition string) (*types.TaskDefinition, error) { output, err := NewEcsClient(t, region).DescribeTaskDefinition(context.Background(), &ecs.DescribeTaskDefinitionInput{ TaskDefinition: aws.String(taskDefinition), }) if err != nil { return nil, err } return output.TaskDefinition, nil } // NewEcsClient creates en ECS client. func NewEcsClient(t testing.TestingT, region string) *ecs.Client { client, err := NewEcsClientE(t, region) require.NoError(t, err) return client } // NewEcsClientE creates an ECS client. func NewEcsClientE(t testing.TestingT, region string) (*ecs.Client, error) { sess, err := NewAuthenticatedSession(region) if err != nil { return nil, err } return ecs.NewFromConfig(*sess), nil } ================================================ FILE: modules/aws/ecs_test.go ================================================ package aws import ( "context" "testing" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ecs" "github.com/aws/aws-sdk-go-v2/service/ecs/types" "github.com/gruntwork-io/terratest/modules/random" "github.com/stretchr/testify/assert" ) func TestEcsCluster(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) c1, err := CreateEcsClusterE(t, region, "terratest") defer DeleteEcsCluster(t, region, c1) assert.Nil(t, err) assert.Equal(t, "terratest", *c1.ClusterName) c2, err := GetEcsClusterE(t, region, *c1.ClusterName) assert.Nil(t, err) assert.Equal(t, "terratest", *c2.ClusterName) } func TestEcsClusterWithInclude(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) clusterName := "terratest-" + random.UniqueId() tags := []types.Tag{{ Key: aws.String("test-tag"), Value: aws.String("hello-world"), }} client := NewEcsClient(t, region) c1, err := client.CreateCluster(context.Background(), &ecs.CreateClusterInput{ ClusterName: aws.String(clusterName), Tags: tags, }) assert.NoError(t, err) defer DeleteEcsCluster(t, region, c1.Cluster) assert.Equal(t, clusterName, aws.ToString(c1.Cluster.ClusterName)) c2, err := GetEcsClusterWithIncludeE(t, region, clusterName, []types.ClusterField{types.ClusterFieldTags}) assert.NoError(t, err) assert.Equal(t, clusterName, aws.ToString(c2.ClusterName)) assert.Equal(t, tags, c2.Tags) assert.Empty(t, c2.Statistics) c3, err := GetEcsClusterWithIncludeE(t, region, clusterName, []types.ClusterField{types.ClusterFieldStatistics}) assert.NoError(t, err) assert.Equal(t, clusterName, aws.ToString(c3.ClusterName)) assert.NotEmpty(t, c3.Statistics) assert.Empty(t, c3.Tags) } ================================================ FILE: modules/aws/errors.go ================================================ package aws import ( "fmt" ) // IpForEc2InstanceNotFound is an error that occurs when the IP for an EC2 instance is not found. type IpForEc2InstanceNotFound struct { InstanceId string AwsRegion string Type string } func (err IpForEc2InstanceNotFound) Error() string { return fmt.Sprintf("Could not find a %s IP address for EC2 Instance %s in %s", err.Type, err.InstanceId, err.AwsRegion) } // HostnameForEc2InstanceNotFound is an error that occurs when the IP for an EC2 instance is not found. type HostnameForEc2InstanceNotFound struct { InstanceId string AwsRegion string Type string } func (err HostnameForEc2InstanceNotFound) Error() string { return fmt.Sprintf("Could not find a %s hostname for EC2 Instance %s in %s", err.Type, err.InstanceId, err.AwsRegion) } // NotFoundError is returned when an expected object is not found type NotFoundError struct { objectType string objectID string region string } func (err NotFoundError) Error() string { return fmt.Sprintf("Object of type %s with id %s not found in region %s", err.objectType, err.objectID, err.region) } func NewNotFoundError(objectType string, objectID string, region string) NotFoundError { return NotFoundError{objectType, objectID, region} } // AsgCapacityNotMetError is returned when the ASG capacity is not yet at the desired capacity. type AsgCapacityNotMetError struct { asgName string desiredCapacity int64 currentCapacity int64 } func (err AsgCapacityNotMetError) Error() string { return fmt.Sprintf( "ASG %s not yet at desired capacity %d (current %d)", err.asgName, err.desiredCapacity, err.currentCapacity, ) } func NewAsgCapacityNotMetError(asgName string, desiredCapacity int64, currentCapacity int64) AsgCapacityNotMetError { return AsgCapacityNotMetError{asgName, desiredCapacity, currentCapacity} } // BucketVersioningNotEnabledError is returned when an S3 bucket that should have versioning does not have it applied type BucketVersioningNotEnabledError struct { s3BucketName string awsRegion string versioningStatus string } func (err BucketVersioningNotEnabledError) Error() string { return fmt.Sprintf( "Versioning status for bucket %s in the %s region is %s", err.s3BucketName, err.awsRegion, err.versioningStatus, ) } func NewBucketVersioningNotEnabledError(s3BucketName string, awsRegion string, versioningStatus string) BucketVersioningNotEnabledError { return BucketVersioningNotEnabledError{s3BucketName: s3BucketName, awsRegion: awsRegion, versioningStatus: versioningStatus} } // NoBucketPolicyError is returned when an S3 bucket that should have a policy applied does not type NoBucketPolicyError struct { s3BucketName string awsRegion string bucketPolicy string } func (err NoBucketPolicyError) Error() string { return fmt.Sprintf( "The policy for bucket %s in the %s region does not have a policy attached.", err.s3BucketName, err.awsRegion, ) } func NewNoBucketPolicyError(s3BucketName string, awsRegion string, bucketPolicy string) NoBucketPolicyError { return NoBucketPolicyError{s3BucketName: s3BucketName, awsRegion: awsRegion, bucketPolicy: bucketPolicy} } // NoInstanceTypeError is returned when none of the given instance type options are available in all AZs in a region type NoInstanceTypeError struct { InstanceTypeOptions []string Azs []string } func (err NoInstanceTypeError) Error() string { return fmt.Sprintf( "None of the given instance types (%v) is available in all the AZs in this region (%v).", err.InstanceTypeOptions, err.Azs, ) } // NoRdsInstanceTypeError is returned when none of the given instance types are avaiable for the region, database engine, and database engine combination given type NoRdsInstanceTypeError struct { InstanceTypeOptions []string DatabaseEngine string DatabaseEngineVersion string } func (err NoRdsInstanceTypeError) Error() string { return fmt.Sprintf( "None of the given RDS instance types (%v) is available in this region for database engine (%v) of version (%v).", err.InstanceTypeOptions, err.DatabaseEngine, err.DatabaseEngineVersion, ) } ================================================ FILE: modules/aws/iam.go ================================================ package aws import ( "context" "fmt" "net/url" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/iam" "github.com/aws/aws-sdk-go-v2/service/iam/types" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/testing" ) // GetIamCurrentUserName gets the username for the current IAM user. func GetIamCurrentUserName(t testing.TestingT) string { out, err := GetIamCurrentUserNameE(t) if err != nil { t.Fatal(err) } return out } // GetIamCurrentUserNameE gets the username for the current IAM user. func GetIamCurrentUserNameE(t testing.TestingT) (string, error) { iamClient, err := NewIamClientE(t, defaultRegion) if err != nil { return "", err } resp, err := iamClient.GetUser(context.Background(), &iam.GetUserInput{}) if err != nil { return "", err } return *resp.User.UserName, nil } // GetIamCurrentUserArn gets the ARN for the current IAM user. func GetIamCurrentUserArn(t testing.TestingT) string { out, err := GetIamCurrentUserArnE(t) if err != nil { t.Fatal(err) } return out } // GetIamCurrentUserArnE gets the ARN for the current IAM user. func GetIamCurrentUserArnE(t testing.TestingT) (string, error) { iamClient, err := NewIamClientE(t, defaultRegion) if err != nil { return "", err } resp, err := iamClient.GetUser(context.Background(), &iam.GetUserInput{}) if err != nil { return "", err } return *resp.User.Arn, nil } // GetIamPolicyDocument gets the most recent policy (JSON) document for an IAM policy. func GetIamPolicyDocument(t testing.TestingT, region string, policyARN string) string { out, err := GetIamPolicyDocumentE(t, region, policyARN) if err != nil { t.Fatal(err) } return out } // GetIamPolicyDocumentE gets the most recent policy (JSON) document for an IAM policy. func GetIamPolicyDocumentE(t testing.TestingT, region string, policyARN string) (string, error) { iamClient, err := NewIamClientE(t, region) if err != nil { return "", err } versions, err := iamClient.ListPolicyVersions(context.Background(), &iam.ListPolicyVersionsInput{ PolicyArn: &policyARN, }) if err != nil { return "", err } var defaultVersion string for _, version := range versions.Versions { if version.IsDefaultVersion == true { defaultVersion = *version.VersionId } } document, err := iamClient.GetPolicyVersion(context.Background(), &iam.GetPolicyVersionInput{ PolicyArn: aws.String(policyARN), VersionId: aws.String(defaultVersion), }) if err != nil { return "", err } unescapedDocument := document.PolicyVersion.Document if unescapedDocument == nil { return "", fmt.Errorf("no policy document found for policy %s", policyARN) } escapedDocument, err := url.QueryUnescape(*unescapedDocument) if err != nil { return "", err } return escapedDocument, nil } // CreateMfaDevice creates an MFA device using the given IAM client. func CreateMfaDevice(t testing.TestingT, iamClient *iam.Client, deviceName string) *types.VirtualMFADevice { mfaDevice, err := CreateMfaDeviceE(t, iamClient, deviceName) if err != nil { t.Fatal(err) } return mfaDevice } // CreateMfaDeviceE creates an MFA device using the given IAM client. func CreateMfaDeviceE(t testing.TestingT, iamClient *iam.Client, deviceName string) (*types.VirtualMFADevice, error) { logger.Default.Logf(t, "Creating an MFA device called %s", deviceName) output, err := iamClient.CreateVirtualMFADevice(context.Background(), &iam.CreateVirtualMFADeviceInput{ VirtualMFADeviceName: aws.String(deviceName), }) if err != nil { return nil, err } if err := EnableMfaDeviceE(t, iamClient, output.VirtualMFADevice); err != nil { return nil, err } return output.VirtualMFADevice, nil } // EnableMfaDevice enables a newly created MFA Device by supplying the first two one-time passwords, so that it can be used for future // logins by the given IAM User. func EnableMfaDevice(t testing.TestingT, iamClient *iam.Client, mfaDevice *types.VirtualMFADevice) { err := EnableMfaDeviceE(t, iamClient, mfaDevice) if err != nil { t.Fatal(err) } } // EnableMfaDeviceE enables a newly created MFA Device by supplying the first two one-time passwords, so that it can be used for future // logins by the given IAM User. func EnableMfaDeviceE(t testing.TestingT, iamClient *iam.Client, mfaDevice *types.VirtualMFADevice) error { logger.Default.Logf(t, "Enabling MFA device %s", aws.ToString(mfaDevice.SerialNumber)) iamUserName, err := GetIamCurrentUserArnE(t) if err != nil { return err } authCode1, err := GetTimeBasedOneTimePassword(mfaDevice) if err != nil { return err } logger.Default.Logf(t, "Waiting 30 seconds for a new MFA Token to be generated...") time.Sleep(30 * time.Second) authCode2, err := GetTimeBasedOneTimePassword(mfaDevice) if err != nil { return err } _, err = iamClient.EnableMFADevice(context.Background(), &iam.EnableMFADeviceInput{ AuthenticationCode1: aws.String(authCode1), AuthenticationCode2: aws.String(authCode2), SerialNumber: mfaDevice.SerialNumber, UserName: aws.String(iamUserName), }) if err != nil { return err } logger.Log(t, "Waiting for MFA Device enablement to propagate.") time.Sleep(10 * time.Second) return nil } // NewIamClient creates a new IAM client. func NewIamClient(t testing.TestingT, region string) *iam.Client { client, err := NewIamClientE(t, region) if err != nil { t.Fatal(err) } return client } // NewIamClientE creates a new IAM client. func NewIamClientE(t testing.TestingT, region string) (*iam.Client, error) { sess, err := NewAuthenticatedSession(region) if err != nil { return nil, err } return iam.NewFromConfig(*sess), nil } ================================================ FILE: modules/aws/iam_test.go ================================================ package aws import ( "context" "strings" "testing" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/iam" "github.com/gruntwork-io/terratest/modules/random" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestGetIamCurrentUserName(t *testing.T) { t.Parallel() username := GetIamCurrentUserName(t) assert.NotEmpty(t, username) } func TestGetIamCurrentUserArn(t *testing.T) { t.Parallel() username := GetIamCurrentUserArn(t) assert.Regexp(t, "^arn:aws:iam::[0-9]{12}:user/.+$", username) } func TestGetIAMPolicyDocument(t *testing.T) { t.Parallel() region := GetRandomRegion(t, nil, nil) t.Run("Exists", func(t *testing.T) { iamClient, err := NewIamClientE(t, region) require.NoError(t, err) policyDocument := `{ "Version": "2012-10-17", "Statement": [ { "Sid": "Stmt1530709892083", "Action": "*", "Effect": "Allow", "Resource": "*" } ] }` input := &iam.CreatePolicyInput{ PolicyName: aws.String(strings.ToLower(random.UniqueId())), PolicyDocument: aws.String(policyDocument), } policy, err := iamClient.CreatePolicy(context.Background(), input) require.NoError(t, err) t.Cleanup(func() { t.Log("Deleting IAM Policy Document") _, err := iamClient.DeletePolicy(context.Background(), &iam.DeletePolicyInput{ PolicyArn: policy.Policy.Arn, }) require.NoError(t, err) }) p := GetIamPolicyDocument(t, region, *policy.Policy.Arn) t.Log("Retrieved Policy Document:", p) assert.JSONEq(t, policyDocument, p) }) t.Run("DoesNotExist", func(t *testing.T) { _, err := GetIamPolicyDocumentE(t, region, "arn:aws:iam::1234567890:policy/does-not-exist") require.Error(t, err) }) } ================================================ FILE: modules/aws/keypair.go ================================================ package aws import ( "context" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/ssh" "github.com/gruntwork-io/terratest/modules/testing" ) // Ec2Keypair is an EC2 key pair. type Ec2Keypair struct { *ssh.KeyPair Name string // The name assigned in AWS to the EC2 Key Pair Region string // The AWS region where the EC2 Key Pair lives } // CreateAndImportEC2KeyPair generates a public/private KeyPair and import it into EC2 in the given region under the given name. func CreateAndImportEC2KeyPair(t testing.TestingT, region string, name string) *Ec2Keypair { keyPair, err := CreateAndImportEC2KeyPairE(t, region, name) if err != nil { t.Fatal(err) } return keyPair } // CreateAndImportEC2KeyPairE generates a public/private KeyPair and import it into EC2 in the given region under the given name. func CreateAndImportEC2KeyPairE(t testing.TestingT, region string, name string) (*Ec2Keypair, error) { keyPair, err := ssh.GenerateRSAKeyPairE(t, 2048) if err != nil { return nil, err } return ImportEC2KeyPairE(t, region, name, keyPair) } // ImportEC2KeyPair creates a Key Pair in EC2 by importing an existing public key. func ImportEC2KeyPair(t testing.TestingT, region string, name string, keyPair *ssh.KeyPair) *Ec2Keypair { ec2KeyPair, err := ImportEC2KeyPairE(t, region, name, keyPair) if err != nil { t.Fatal(err) } return ec2KeyPair } // ImportEC2KeyPairE creates a Key Pair in EC2 by importing an existing public key. func ImportEC2KeyPairE(t testing.TestingT, region string, name string, keyPair *ssh.KeyPair) (*Ec2Keypair, error) { logger.Default.Logf(t, "Creating new Key Pair in EC2 region %s named %s", region, name) client, err := NewEc2ClientE(t, region) if err != nil { return nil, err } params := &ec2.ImportKeyPairInput{ KeyName: aws.String(name), PublicKeyMaterial: []byte(keyPair.PublicKey), } _, err = client.ImportKeyPair(context.Background(), params) if err != nil { return nil, err } return &Ec2Keypair{Name: name, Region: region, KeyPair: keyPair}, nil } // DeleteEC2KeyPair deletes an EC2 key pair. func DeleteEC2KeyPair(t testing.TestingT, keyPair *Ec2Keypair) { err := DeleteEC2KeyPairE(t, keyPair) if err != nil { t.Fatal(err) } } // DeleteEC2KeyPairE deletes an EC2 key pair. func DeleteEC2KeyPairE(t testing.TestingT, keyPair *Ec2Keypair) error { logger.Default.Logf(t, "Deleting Key Pair in EC2 region %s named %s", keyPair.Region, keyPair.Name) client, err := NewEc2ClientE(t, keyPair.Region) if err != nil { return err } params := &ec2.DeleteKeyPairInput{ KeyName: aws.String(keyPair.Name), } _, err = client.DeleteKeyPair(context.Background(), params) return err } ================================================ FILE: modules/aws/keypair_test.go ================================================ package aws import ( "context" "fmt" "strings" "testing" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/gruntwork-io/terratest/modules/random" "github.com/stretchr/testify/assert" ) func TestCreateImportAndDeleteEC2KeyPair(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) uniqueID := random.UniqueId() name := fmt.Sprintf("test-key-pair-%s", uniqueID) keyPair := CreateAndImportEC2KeyPair(t, region, name) defer deleteKeyPair(t, keyPair) assert.True(t, keyPairExists(t, keyPair)) assert.Equal(t, name, keyPair.Name) assert.Equal(t, region, keyPair.Region) assert.Contains(t, keyPair.PublicKey, "ssh-rsa") assert.Contains(t, keyPair.PrivateKey, "-----BEGIN RSA PRIVATE KEY-----") } func keyPairExists(t *testing.T, keyPair *Ec2Keypair) bool { client := NewEc2Client(t, keyPair.Region) input := ec2.DescribeKeyPairsInput{ KeyNames: []string{keyPair.Name}, } out, err := client.DescribeKeyPairs(context.Background(), &input) if err != nil { if strings.Contains(err.Error(), "InvalidKeyPair.NotFound") { return false } t.Fatal(err) } return len(out.KeyPairs) == 1 } func deleteKeyPair(t *testing.T, keyPair *Ec2Keypair) { DeleteEC2KeyPair(t, keyPair) assert.False(t, keyPairExists(t, keyPair)) } ================================================ FILE: modules/aws/kms.go ================================================ package aws import ( "context" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/kms" "github.com/gruntwork-io/terratest/modules/testing" ) // GetCmkArn gets the ARN of a KMS Customer Master Key (CMK) in the given region with the given ID. The ID can be an alias, such // as "alias/my-cmk". func GetCmkArn(t testing.TestingT, region string, cmkID string) string { out, err := GetCmkArnE(t, region, cmkID) if err != nil { t.Fatal(err) } return out } // GetCmkArnE gets the ARN of a KMS Customer Master Key (CMK) in the given region with the given ID. The ID can be an alias, such // as "alias/my-cmk". func GetCmkArnE(t testing.TestingT, region string, cmkID string) (string, error) { kmsClient, err := NewKmsClientE(t, region) if err != nil { return "", err } result, err := kmsClient.DescribeKey(context.Background(), &kms.DescribeKeyInput{ KeyId: aws.String(cmkID), }) if err != nil { return "", err } return *result.KeyMetadata.Arn, nil } // NewKmsClient creates a KMS client. func NewKmsClient(t testing.TestingT, region string) *kms.Client { client, err := NewKmsClientE(t, region) if err != nil { t.Fatal(err) } return client } // NewKmsClientE creates a KMS client. func NewKmsClientE(t testing.TestingT, region string) (*kms.Client, error) { sess, err := NewAuthenticatedSession(region) if err != nil { return nil, err } return kms.NewFromConfig(*sess), nil } ================================================ FILE: modules/aws/lambda.go ================================================ package aws import ( "context" "encoding/json" "errors" "fmt" "github.com/aws/aws-sdk-go-v2/service/lambda" "github.com/aws/aws-sdk-go-v2/service/lambda/types" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) type InvocationTypeOption string const ( InvocationTypeRequestResponse InvocationTypeOption = "RequestResponse" InvocationTypeDryRun = "DryRun" ) func (itype *InvocationTypeOption) Value() (string, error) { if itype != nil { switch *itype { case InvocationTypeRequestResponse, InvocationTypeDryRun: return string(*itype), nil default: msg := fmt.Sprintf("LambdaOptions.InvocationType, if specified, must either be \"%s\" or \"%s\"", InvocationTypeRequestResponse, InvocationTypeDryRun) return "", errors.New(msg) } } return string(InvocationTypeRequestResponse), nil } // LambdaOptions contains additional parameters for InvokeFunctionWithParams(). // It contains a subset of the fields found in the lambda.InvokeInput struct. type LambdaOptions struct { // InvocationType can be one of InvocationTypeOption values: // * InvocationTypeRequestResponse (default) - Invoke the function // synchronously. Keep the connection open until the function // returns a response or times out. // * InvocationTypeDryRun - Validate parameter values and verify // that the user or role has permission to invoke the function. InvocationType *InvocationTypeOption // Lambda function input; will be converted to JSON. Payload interface{} } // LambdaOutput contains the output from InvokeFunctionWithParams(). The // fields may or may not have a value depending on the invocation type and // whether an error occurred or not. type LambdaOutput struct { // The response from the function, or an error object. Payload []byte // The HTTP status code for a successful request is in the 200 range. // For RequestResponse invocation type, the status code is 200. // For the DryRun invocation type, the status code is 204. StatusCode int32 } // InvokeFunction invokes a lambda function. func InvokeFunction(t testing.TestingT, region, functionName string, payload interface{}) []byte { out, err := InvokeFunctionE(t, region, functionName, payload) require.NoError(t, err) return out } // InvokeFunctionE invokes a lambda function. func InvokeFunctionE(t testing.TestingT, region, functionName string, payload interface{}) ([]byte, error) { lambdaClient, err := NewLambdaClientE(t, region) if err != nil { return nil, err } invokeInput := &lambda.InvokeInput{ FunctionName: &functionName, } if payload != nil { payloadJson, err := json.Marshal(payload) if err != nil { return nil, err } invokeInput.Payload = payloadJson } out, err := lambdaClient.Invoke(context.Background(), invokeInput) require.NoError(t, err) if out.FunctionError != nil { return out.Payload, &FunctionError{Message: *out.FunctionError, StatusCode: out.StatusCode, Payload: out.Payload} } return out.Payload, nil } // InvokeFunctionWithParams invokes a lambda function using parameters // supplied in the LambdaOptions struct and returns values in a LambdaOutput // struct. Checks for failure using "require". func InvokeFunctionWithParams(t testing.TestingT, region, functionName string, input *LambdaOptions) *LambdaOutput { out, err := InvokeFunctionWithParamsE(t, region, functionName, input) require.NoError(t, err) return out } // InvokeFunctionWithParamsE invokes a lambda function using parameters // supplied in the LambdaOptions struct. Returns the status code and payload // in a LambdaOutput struct and the error. A non-nil error will either reflect // a problem with the parameters supplied to this function or an error returned // by the Lambda. func InvokeFunctionWithParamsE(t testing.TestingT, region, functionName string, input *LambdaOptions) (*LambdaOutput, error) { lambdaClient, err := NewLambdaClientE(t, region) if err != nil { return nil, err } // Verify the InvocationType is one of the allowed values and report // an error if it's not. By default, the InvocationType will be // "RequestResponse". invocationType, err := input.InvocationType.Value() if err != nil { return nil, err } invokeInput := &lambda.InvokeInput{ FunctionName: &functionName, InvocationType: types.InvocationType(invocationType), } if input.Payload != nil { payloadJson, err := json.Marshal(input.Payload) if err != nil { return nil, err } invokeInput.Payload = payloadJson } out, err := lambdaClient.Invoke(context.Background(), invokeInput) if err != nil { return nil, err } // As this function supports different invocation types, it must // then support different combinations of output other than just // payload. lambdaOutput := LambdaOutput{ Payload: out.Payload, StatusCode: out.StatusCode, } if out.FunctionError != nil { return &lambdaOutput, errors.New(*out.FunctionError) } return &lambdaOutput, nil } type FunctionError struct { Message string StatusCode int32 Payload []byte } func (err *FunctionError) Error() string { return fmt.Sprintf("%q error with status code %d invoking lambda function: %q", err.Message, err.StatusCode, err.Payload) } // NewLambdaClient creates a new Lambda client. func NewLambdaClient(t testing.TestingT, region string) *lambda.Client { client, err := NewLambdaClientE(t, region) require.NoError(t, err) return client } // NewLambdaClientE creates a new Lambda client. func NewLambdaClientE(t testing.TestingT, region string) (*lambda.Client, error) { sess, err := NewAuthenticatedSession(region) if err != nil { return nil, err } return lambda.NewFromConfig(*sess), nil } ================================================ FILE: modules/aws/lambda_test.go ================================================ package aws import ( "testing" "github.com/stretchr/testify/require" ) func TestFunctionError(t *testing.T) { t.Parallel() // assert that the error message contains all the components of the error, in a readable form err := &FunctionError{Message: "message", StatusCode: 123, Payload: []byte("payload")} require.Contains(t, err.Error(), "message") require.Contains(t, err.Error(), "123") require.Contains(t, err.Error(), "payload") } ================================================ FILE: modules/aws/rds.go ================================================ package aws import ( "context" "database/sql" "fmt" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/rds" "github.com/aws/aws-sdk-go-v2/service/rds/types" _ "github.com/go-sql-driver/mysql" "github.com/gruntwork-io/terratest/modules/testing" _ "github.com/jackc/pgx/v5/stdlib" "github.com/stretchr/testify/require" ) // GetAddressOfRdsInstance gets the address of the given RDS Instance in the given region. func GetAddressOfRdsInstance(t testing.TestingT, dbInstanceID string, awsRegion string) string { address, err := GetAddressOfRdsInstanceE(t, dbInstanceID, awsRegion) if err != nil { t.Fatal(err) } return address } // GetAddressOfRdsInstanceE gets the address of the given RDS Instance in the given region. func GetAddressOfRdsInstanceE(t testing.TestingT, dbInstanceID string, awsRegion string) (string, error) { dbInstance, err := GetRdsInstanceDetailsE(t, dbInstanceID, awsRegion) if err != nil { return "", err } return aws.ToString(dbInstance.Endpoint.Address), nil } // GetPortOfRdsInstance gets the port of the given RDS Instance in the given region. func GetPortOfRdsInstance(t testing.TestingT, dbInstanceID string, awsRegion string) int32 { port, err := GetPortOfRdsInstanceE(t, dbInstanceID, awsRegion) if err != nil { t.Fatal(err) } return port } // GetPortOfRdsInstanceE gets the port of the given RDS Instance in the given region. func GetPortOfRdsInstanceE(t testing.TestingT, dbInstanceID string, awsRegion string) (int32, error) { dbInstance, err := GetRdsInstanceDetailsE(t, dbInstanceID, awsRegion) if err != nil { return -1, err } return *dbInstance.Endpoint.Port, nil } // GetWhetherSchemaExistsInRdsMySqlInstance checks whether the specified schema/table name exists in the RDS instance func GetWhetherSchemaExistsInRdsMySqlInstance(t testing.TestingT, dbUrl string, dbPort int32, dbUsername string, dbPassword string, expectedSchemaName string) bool { output, err := GetWhetherSchemaExistsInRdsMySqlInstanceE(t, dbUrl, dbPort, dbUsername, dbPassword, expectedSchemaName) if err != nil { t.Fatal(err) } return output } // GetWhetherSchemaExistsInRdsMySqlInstanceE checks whether the specified schema/table name exists in the RDS instance func GetWhetherSchemaExistsInRdsMySqlInstanceE(t testing.TestingT, dbUrl string, dbPort int32, dbUsername string, dbPassword string, expectedSchemaName string) (bool, error) { connectionString := fmt.Sprintf("%s:%s@tcp(%s:%d)/", dbUsername, dbPassword, dbUrl, dbPort) db, connErr := sql.Open("mysql", connectionString) if connErr != nil { return false, connErr } defer db.Close() var ( schemaName string ) sqlStatement := "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME=?;" row := db.QueryRow(sqlStatement, expectedSchemaName) scanErr := row.Scan(&schemaName) if scanErr != nil { return false, scanErr } return true, nil } // GetWhetherSchemaExistsInRdsPostgresInstance checks whether the specified schema/table name exists in the RDS instance func GetWhetherSchemaExistsInRdsPostgresInstance(t testing.TestingT, dbUrl string, dbPort int32, dbUsername string, dbPassword string, expectedSchemaName string) bool { output, err := GetWhetherSchemaExistsInRdsPostgresInstanceE(t, dbUrl, dbPort, dbUsername, dbPassword, expectedSchemaName) if err != nil { t.Fatal(err) } return output } // GetWhetherSchemaExistsInRdsPostgresInstanceE checks whether the specified schema/table name exists in the RDS instance func GetWhetherSchemaExistsInRdsPostgresInstanceE(t testing.TestingT, dbUrl string, dbPort int32, dbUsername string, dbPassword string, expectedSchemaName string) (bool, error) { connectionString := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s", dbUrl, dbPort, dbUsername, dbPassword, expectedSchemaName) db, connErr := sql.Open("pgx", connectionString) if connErr != nil { return false, connErr } defer db.Close() var ( schemaName string ) sqlStatement := `SELECT "catalog_name" FROM "information_schema"."schemata" where catalog_name=$1` row := db.QueryRow(sqlStatement, expectedSchemaName) scanErr := row.Scan(&schemaName) if scanErr != nil { return false, scanErr } return true, nil } // GetParameterValueForParameterOfRdsInstance gets the value of the parameter name specified for the RDS instance in the given region. func GetParameterValueForParameterOfRdsInstance(t testing.TestingT, parameterName string, dbInstanceID string, awsRegion string) string { parameterValue, err := GetParameterValueForParameterOfRdsInstanceE(t, parameterName, dbInstanceID, awsRegion) if err != nil { t.Fatal(err) } return parameterValue } // GetParameterValueForParameterOfRdsInstanceE gets the value of the parameter name specified for the RDS instance in the given region. func GetParameterValueForParameterOfRdsInstanceE(t testing.TestingT, parameterName string, dbInstanceID string, awsRegion string) (string, error) { output := GetAllParametersOfRdsInstance(t, dbInstanceID, awsRegion) for _, parameter := range output { if aws.ToString(parameter.ParameterName) == parameterName { return aws.ToString(parameter.ParameterValue), nil } } return "", ParameterForDbInstanceNotFound{ParameterName: parameterName, DbInstanceID: dbInstanceID, AwsRegion: awsRegion} } // GetOptionSettingForOfRdsInstance gets the value of the option name in the option group specified for the RDS instance in the given region. func GetOptionSettingForOfRdsInstance(t testing.TestingT, optionName string, optionSettingName string, dbInstanceID, awsRegion string) string { optionValue, err := GetOptionSettingForOfRdsInstanceE(t, optionName, optionSettingName, dbInstanceID, awsRegion) if err != nil { t.Fatal(err) } return optionValue } // GetOptionSettingForOfRdsInstanceE gets the value of the option name in the option group specified for the RDS instance in the given region. func GetOptionSettingForOfRdsInstanceE(t testing.TestingT, optionName string, optionSettingName string, dbInstanceID, awsRegion string) (string, error) { optionGroupName := GetOptionGroupNameOfRdsInstance(t, dbInstanceID, awsRegion) options := GetOptionsOfOptionGroup(t, optionGroupName, awsRegion) for _, option := range options { if aws.ToString(option.OptionName) == optionName { for _, optionSetting := range option.OptionSettings { if aws.ToString(optionSetting.Name) == optionSettingName { return aws.ToString(optionSetting.Value), nil } } } } return "", OptionGroupOptionSettingForDbInstanceNotFound{OptionName: optionName, OptionSettingName: optionSettingName, DbInstanceID: dbInstanceID, AwsRegion: awsRegion} } // GetOptionGroupNameOfRdsInstance gets the name of the option group associated with the RDS instance func GetOptionGroupNameOfRdsInstance(t testing.TestingT, dbInstanceID string, awsRegion string) string { dbInstance, err := GetOptionGroupNameOfRdsInstanceE(t, dbInstanceID, awsRegion) if err != nil { t.Fatal(err) } return dbInstance } // GetOptionGroupNameOfRdsInstanceE gets the name of the option group associated with the RDS instance func GetOptionGroupNameOfRdsInstanceE(t testing.TestingT, dbInstanceID string, awsRegion string) (string, error) { dbInstance, err := GetRdsInstanceDetailsE(t, dbInstanceID, awsRegion) if err != nil { return "", err } return aws.ToString(dbInstance.OptionGroupMemberships[0].OptionGroupName), nil } // GetOptionsOfOptionGroup gets the options of the option group specified func GetOptionsOfOptionGroup(t testing.TestingT, optionGroupName string, awsRegion string) []types.Option { output, err := GetOptionsOfOptionGroupE(t, optionGroupName, awsRegion) if err != nil { t.Fatal(err) } return output } // GetOptionsOfOptionGroupE gets the options of the option group specified func GetOptionsOfOptionGroupE(t testing.TestingT, optionGroupName string, awsRegion string) ([]types.Option, error) { rdsClient := NewRdsClient(t, awsRegion) input := rds.DescribeOptionGroupsInput{OptionGroupName: aws.String(optionGroupName)} output, err := rdsClient.DescribeOptionGroups(context.Background(), &input) if err != nil { return []types.Option{}, err } return output.OptionGroupsList[0].Options, nil } // GetAllParametersOfRdsInstance gets all the parameters defined in the parameter group for the RDS instance in the given region. func GetAllParametersOfRdsInstance(t testing.TestingT, dbInstanceID string, awsRegion string) []types.Parameter { parameters, err := GetAllParametersOfRdsInstanceE(t, dbInstanceID, awsRegion) if err != nil { t.Fatal(err) } return parameters } // GetAllParametersOfRdsInstanceE gets all the parameters defined in the parameter group for the RDS instance in the given region. func GetAllParametersOfRdsInstanceE(t testing.TestingT, dbInstanceID string, awsRegion string) ([]types.Parameter, error) { dbInstance, dbInstanceErr := GetRdsInstanceDetailsE(t, dbInstanceID, awsRegion) if dbInstanceErr != nil { return []types.Parameter{}, dbInstanceErr } parameterGroupName := aws.ToString(dbInstance.DBParameterGroups[0].DBParameterGroupName) rdsClient := NewRdsClient(t, awsRegion) input := rds.DescribeDBParametersInput{DBParameterGroupName: aws.String(parameterGroupName)} var allParameters []types.Parameter for { output, err := rdsClient.DescribeDBParameters(context.Background(), &input) if err != nil { return []types.Parameter{}, err } allParameters = append(allParameters, output.Parameters...) if output.Marker == nil { break } input.Marker = output.Marker } return allParameters, nil } // GetRdsInstanceDetailsE gets the details of a single DB instance whose identifier is passed. func GetRdsInstanceDetailsE(t testing.TestingT, dbInstanceID string, awsRegion string) (*types.DBInstance, error) { rdsClient := NewRdsClient(t, awsRegion) input := rds.DescribeDBInstancesInput{DBInstanceIdentifier: aws.String(dbInstanceID)} output, err := rdsClient.DescribeDBInstances(context.Background(), &input) if err != nil { return nil, err } return &output.DBInstances[0], nil } // NewRdsClient creates an RDS client. func NewRdsClient(t testing.TestingT, region string) *rds.Client { client, err := NewRdsClientE(t, region) if err != nil { t.Fatal(err) } return client } // NewRdsClientE creates an RDS client. func NewRdsClientE(t testing.TestingT, region string) (*rds.Client, error) { sess, err := NewAuthenticatedSession(region) if err != nil { return nil, err } return rds.NewFromConfig(*sess), nil } // GetRecommendedRdsInstanceType takes in a list of RDS instance types (e.g., "db.t2.micro", "db.t3.micro") and returns the // first instance type in the list that is available in the given region and for the given database engine type. // If none of the instances provided are available for your combination of region and database engine, this function will exit with an error. func GetRecommendedRdsInstanceType(t testing.TestingT, region string, engine string, engineVersion string, instanceTypeOptions []string) string { out, err := GetRecommendedRdsInstanceTypeE(t, region, engine, engineVersion, instanceTypeOptions) require.NoError(t, err) return out } // GetRecommendedRdsInstanceTypeE takes in a list of RDS instance types (e.g., "db.t2.micro", "db.t3.micro") and returns the // first instance type in the list that is available in the given region and for the given database engine type. // If none of the instances provided are available for your combination of region and database engine, this function will return an error. func GetRecommendedRdsInstanceTypeE(t testing.TestingT, region string, engine string, engineVersion string, instanceTypeOptions []string) (string, error) { client, err := NewRdsClientE(t, region) if err != nil { return "", err } return GetRecommendedRdsInstanceTypeWithClientE(t, client, engine, engineVersion, instanceTypeOptions) } // GetRecommendedRdsInstanceTypeWithClientE takes in a list of RDS instance types (e.g., "db.t2.micro", "db.t3.micro") and returns the // first instance type in the list that is available in the given region and for the given database engine type. // If none of the instances provided are available for your combination of region and database engine, this function will return an error. // This function expects an authenticated RDS client from the AWS SDK Go library. func GetRecommendedRdsInstanceTypeWithClientE(t testing.TestingT, rdsClient *rds.Client, engine string, engineVersion string, instanceTypeOptions []string) (string, error) { for _, instanceTypeOption := range instanceTypeOptions { instanceTypeExists, err := instanceTypeExistsForEngineAndRegionE(rdsClient, engine, engineVersion, instanceTypeOption) if err != nil { return "", err } if instanceTypeExists { return instanceTypeOption, nil } } return "", NoRdsInstanceTypeError{InstanceTypeOptions: instanceTypeOptions, DatabaseEngine: engine, DatabaseEngineVersion: engineVersion} } // instanceTypeExistsForEngineAndRegionE returns a boolean that represents whether the provided instance type (e.g. db.t2.micro) exists for the given region and db engine type // This function will return an error if the RDS AWS SDK call fails. func instanceTypeExistsForEngineAndRegionE(client *rds.Client, engine string, engineVersion string, instanceType string) (bool, error) { input := rds.DescribeOrderableDBInstanceOptionsInput{ Engine: aws.String(engine), EngineVersion: aws.String(engineVersion), DBInstanceClass: aws.String(instanceType), } out, err := client.DescribeOrderableDBInstanceOptions(context.Background(), &input) if err != nil { return false, err } if len(out.OrderableDBInstanceOptions) > 0 { return true, nil } return false, nil } // GetValidEngineVersion returns a string containing a valid RDS engine version for the provided region and engine type. // This function will fail the test if no valid engine is found. func GetValidEngineVersion(t testing.TestingT, region string, engine string, majorVersion string) string { out, err := GetValidEngineVersionE(t, region, engine, majorVersion) require.NoError(t, err) return out } // GetValidEngineVersionE returns a string containing a valid RDS engine version or an error if no valid version is found. func GetValidEngineVersionE(t testing.TestingT, region string, engine string, majorVersion string) (string, error) { client, err := NewRdsClientE(t, region) if err != nil { return "", err } input := rds.DescribeDBEngineVersionsInput{ Engine: aws.String(engine), EngineVersion: aws.String(majorVersion), } out, err := client.DescribeDBEngineVersions(context.Background(), &input) if err != nil || len(out.DBEngineVersions) == 0 { return "", err } return *out.DBEngineVersions[0].EngineVersion, nil } // ParameterForDbInstanceNotFound is an error that occurs when the parameter group specified is not found for the DB instance type ParameterForDbInstanceNotFound struct { ParameterName string DbInstanceID string AwsRegion string } func (err ParameterForDbInstanceNotFound) Error() string { return fmt.Sprintf("Could not find a parameter %s in parameter group of database %s in %s", err.ParameterName, err.DbInstanceID, err.AwsRegion) } // OptionGroupOptionSettingForDbInstanceNotFound is an error that occurs when the option setting specified is not found in the option group of the DB instance type OptionGroupOptionSettingForDbInstanceNotFound struct { OptionName string OptionSettingName string DbInstanceID string AwsRegion string } func (err OptionGroupOptionSettingForDbInstanceNotFound) Error() string { return fmt.Sprintf("Could not find a option setting %s in option name %s of database %s in %s", err.OptionName, err.OptionSettingName, err.DbInstanceID, err.AwsRegion) } ================================================ FILE: modules/aws/rds_test.go ================================================ package aws import ( "fmt" "testing" "github.com/stretchr/testify/assert" ) func TestGetRecommendedRdsInstanceTypeHappyPath(t *testing.T) { type TestingScenerios struct { name string region string databaseEngine string engineMajorVersion string instanceTypes []string expected string } testingScenerios := []TestingScenerios{ { name: "US region, mysql, first offering available", region: "us-east-2", databaseEngine: "mysql", engineMajorVersion: "8.0", instanceTypes: []string{"db.t4g.micro", "db.t4g.small"}, expected: "db.t4g.micro", }, { name: "EU region, postgres, 2nd offering available based on region", region: "eu-north-1", databaseEngine: "postgres", engineMajorVersion: "13", instanceTypes: []string{"db.t2.micro", "db.m5.large"}, expected: "db.m5.large", }, { name: "US region, oracle-ee, 2nd offering available based on db type", region: "us-west-2", databaseEngine: "oracle-ee", engineMajorVersion: "19", instanceTypes: []string{"db.m5d.xlarge", "db.m5.large"}, expected: "db.m5d.xlarge", }, { name: "US region, oracle-ee, 2nd offering available based on db engine version", region: "us-west-2", databaseEngine: "oracle-ee", engineMajorVersion: "19", instanceTypes: []string{"db.t3.micro", "db.t3.small"}, expected: "db.t3.small", }, } for _, scenerio := range testingScenerios { scenerio := scenerio t.Run(scenerio.name, func(t *testing.T) { t.Parallel() engineVersion := GetValidEngineVersion(t, scenerio.region, scenerio.databaseEngine, scenerio.engineMajorVersion) actual, err := GetRecommendedRdsInstanceTypeE(t, scenerio.region, scenerio.databaseEngine, engineVersion, scenerio.instanceTypes) assert.NoError(t, err) assert.Equal(t, scenerio.expected, actual) }) } } func TestGetRecommendedRdsInstanceTypeErrors(t *testing.T) { type TestingScenerios struct { name string region string databaseEngine string databaseEngineVersion string instanceTypes []string } testingScenerios := []TestingScenerios{ { name: "All empty", region: "", databaseEngine: "", databaseEngineVersion: "", instanceTypes: nil, }, { name: "No engine, version, or instance type", region: "us-east-2", databaseEngine: "", databaseEngineVersion: "", instanceTypes: nil, }, { name: "No instance types or version", region: "us-east-2", databaseEngine: "mysql", databaseEngineVersion: "", instanceTypes: nil, }, { name: "No engine version", region: "us-east-2", databaseEngine: "mysql", databaseEngineVersion: "", instanceTypes: []string{"db.t3.small"}, }, { name: "Invalid instance types", region: "us-east-2", databaseEngine: "mysql", databaseEngineVersion: "", instanceTypes: []string{"db.nonexistent.type", "db.fake.instance"}, }, { name: "Instance type not available for engine", region: "us-east-2", databaseEngine: "mysql", databaseEngineVersion: "", instanceTypes: []string{"db.x2iedn.metal"}, }, { name: "No instance type available for engine", region: "us-east-1", databaseEngine: "oracle-ee", databaseEngineVersion: "19.0.0.0.ru-2024-04.rur-2024-04.r1", instanceTypes: []string{"db.r5a.large"}, }, { name: "No instance type available for engine version", region: "us-east-1", databaseEngine: "oracle-ee", databaseEngineVersion: "19.0.0.0.ru-2021-01.rur-2021-01.r1", instanceTypes: []string{"db.t3.micro"}, }, } for _, scenerio := range testingScenerios { scenerio := scenerio t.Run(scenerio.name, func(t *testing.T) { t.Parallel() _, err := GetRecommendedRdsInstanceTypeE(t, scenerio.region, scenerio.databaseEngine, scenerio.databaseEngineVersion, scenerio.instanceTypes) fmt.Println(err) assert.EqualError(t, err, NoRdsInstanceTypeError{InstanceTypeOptions: scenerio.instanceTypes, DatabaseEngine: scenerio.databaseEngine, DatabaseEngineVersion: scenerio.databaseEngineVersion}.Error()) }) } } ================================================ FILE: modules/aws/region.go ================================================ package aws import ( "context" "fmt" "os" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ssm" "github.com/gruntwork-io/terratest/modules/collections" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/testing" ) // You can set this environment variable to force Terratest to use a specific region rather than a random one. This is // convenient when iterating locally. const regionOverrideEnvVarName = "TERRATEST_REGION" // AWS API calls typically require an AWS region. We typically require the user to set one explicitly, but in some // cases, this doesn't make sense (e.g., for fetching the list of regions in an account), so for those cases, we use // this region as a default. const defaultRegion = "us-east-1" // Reference for launch dates: https://aws.amazon.com/about-aws/global-infrastructure/ var stableRegions = []string{ "us-east-1", // Launched 2006 "us-east-2", // Launched 2016 "us-west-1", // Launched 2009 "us-west-2", // Launched 2011 "ca-central-1", // Launched 2016 "sa-east-1", // Launched 2011 "eu-west-1", // Launched 2007 "eu-west-2", // Launched 2016 "eu-west-3", // Launched 2017 "eu-central-1", // Launched 2014 "ap-southeast-1", // Launched 2010 "ap-southeast-2", // Launched 2012 "ap-northeast-1", // Launched 2011 "ap-northeast-2", // Launched 2016 "ap-south-1", // Launched 2016 "eu-north-1", // Launched 2018 } // GetRandomStableRegion gets a randomly chosen AWS region that is considered stable. Like GetRandomRegion, you can // further restrict the stable region list using approvedRegions and forbiddenRegions. We consider stable regions to be // those that have been around for at least 1 year. // Note that regions in the approvedRegions list that are not considered stable are ignored. func GetRandomStableRegion(t testing.TestingT, approvedRegions []string, forbiddenRegions []string) string { regionsToPickFrom := stableRegions if len(approvedRegions) > 0 { regionsToPickFrom = collections.ListIntersection(regionsToPickFrom, approvedRegions) } if len(forbiddenRegions) > 0 { regionsToPickFrom = collections.ListSubtract(regionsToPickFrom, forbiddenRegions) } return GetRandomRegion(t, regionsToPickFrom, nil) } // GetRandomRegion gets a randomly chosen AWS region. If approvedRegions is not empty, this will be a region from the approvedRegions // list; otherwise, this method will fetch the latest list of regions from the AWS APIs and pick one of those. If // forbiddenRegions is not empty, this method will make sure the returned region is not in the forbiddenRegions list. func GetRandomRegion(t testing.TestingT, approvedRegions []string, forbiddenRegions []string) string { region, err := GetRandomRegionE(t, approvedRegions, forbiddenRegions) if err != nil { t.Fatal(err) } return region } // GetRandomRegionE gets a randomly chosen AWS region. If approvedRegions is not empty, this will be a region from the approvedRegions // list; otherwise, this method will fetch the latest list of regions from the AWS APIs and pick one of those. If // forbiddenRegions is not empty, this method will make sure the returned region is not in the forbiddenRegions list. func GetRandomRegionE(t testing.TestingT, approvedRegions []string, forbiddenRegions []string) (string, error) { regionFromEnvVar := os.Getenv(regionOverrideEnvVarName) if regionFromEnvVar != "" { logger.Default.Logf(t, "Using AWS region %s from environment variable %s", regionFromEnvVar, regionOverrideEnvVarName) return regionFromEnvVar, nil } regionsToPickFrom := approvedRegions if len(regionsToPickFrom) == 0 { allRegions, err := GetAllAwsRegionsE(t) if err != nil { return "", err } regionsToPickFrom = allRegions } regionsToPickFrom = collections.ListSubtract(regionsToPickFrom, forbiddenRegions) region := random.RandomString(regionsToPickFrom) logger.Default.Logf(t, "Using region %s", region) return region, nil } // GetAllAwsRegions gets the list of AWS regions available in this account. func GetAllAwsRegions(t testing.TestingT) []string { out, err := GetAllAwsRegionsE(t) if err != nil { t.Fatal(err) } return out } // GetAllAwsRegionsE gets the list of AWS regions available in this account. func GetAllAwsRegionsE(t testing.TestingT) ([]string, error) { logger.Default.Logf(t, "Looking up all AWS regions available in this account") ec2Client, err := NewEc2ClientE(t, defaultRegion) if err != nil { return nil, err } out, err := ec2Client.DescribeRegions(context.Background(), &ec2.DescribeRegionsInput{}) if err != nil { return nil, err } var regions []string for _, region := range out.Regions { regions = append(regions, aws.ToString(region.RegionName)) } return regions, nil } // GetAvailabilityZones gets the Availability Zones for a given AWS region. Note that for certain regions (e.g. us-east-1), different AWS // accounts have access to different availability zones. func GetAvailabilityZones(t testing.TestingT, region string) []string { out, err := GetAvailabilityZonesE(t, region) if err != nil { t.Fatal(err) } return out } // GetAvailabilityZonesE gets the Availability Zones for a given AWS region. Note that for certain regions (e.g. us-east-1), different AWS // accounts have access to different availability zones. func GetAvailabilityZonesE(t testing.TestingT, region string) ([]string, error) { logger.Default.Logf(t, "Looking up all availability zones available in this account for region %s", region) ec2Client, err := NewEc2ClientE(t, region) if err != nil { return nil, err } resp, err := ec2Client.DescribeAvailabilityZones(context.Background(), &ec2.DescribeAvailabilityZonesInput{}) if err != nil { return nil, err } var out []string for _, availabilityZone := range resp.AvailabilityZones { out = append(out, aws.ToString(availabilityZone.ZoneName)) } return out, nil } // GetRegionsForService gets all AWS regions in which a service is available. func GetRegionsForService(t testing.TestingT, serviceName string) []string { out, err := GetRegionsForServiceE(t, serviceName) if err != nil { t.Fatal(err) } return out } // GetRegionsForServiceE gets all AWS regions in which a service is available and returns errors. // See https://docs.aws.amazon.com/systems-manager/latest/userguide/parameter-store-public-parameters-global-infrastructure.html func GetRegionsForServiceE(t testing.TestingT, serviceName string) ([]string, error) { // These values are available in any region, defaulting to us-east-1 since it's the oldest ssmClient, err := NewSsmClientE(t, "us-east-1") if err != nil { return nil, err } paramPath := "/aws/service/global-infrastructure/services/%s/regions" resp, err := ssmClient.GetParametersByPath(context.Background(), &ssm.GetParametersByPathInput{ Path: aws.String(fmt.Sprintf(paramPath, serviceName)), }) if err != nil { return nil, err } var availableRegions []string for _, p := range resp.Parameters { availableRegions = append(availableRegions, *p.Value) } return availableRegions, nil } // GetRandomRegionForService retrieves a list of AWS regions in which a service is available // Then returns one region randomly from the list func GetRandomRegionForService(t testing.TestingT, serviceName string) string { availableRegions, err := GetRegionsForServiceE(t, serviceName) if err != nil { t.Fatal(err) } return GetRandomRegion(t, availableRegions, nil) } ================================================ FILE: modules/aws/region_test.go ================================================ package aws import ( "fmt" "testing" "github.com/stretchr/testify/assert" ) func TestGetRandomRegion(t *testing.T) { t.Parallel() randomRegion := GetRandomRegion(t, nil, nil) assertLooksLikeRegionName(t, randomRegion) } func TestGetRandomRegionExcludesForbiddenRegions(t *testing.T) { t.Parallel() approvedRegions := []string{"ca-central-1", "us-east-1", "us-east-2", "us-west-1", "us-west-2", "eu-west-1", "eu-west-2", "eu-central-1", "ap-southeast-1", "ap-northeast-1", "ap-northeast-2", "ap-south-1"} forbiddenRegions := []string{"us-west-2", "ap-northeast-2"} for i := 0; i < 1000; i++ { randomRegion := GetRandomRegion(t, approvedRegions, forbiddenRegions) assert.NotContains(t, forbiddenRegions, randomRegion) } } func TestGetAllAwsRegions(t *testing.T) { t.Parallel() regions := GetAllAwsRegions(t) // The typical account had access to 15 regions as of April, 2018: https://aws.amazon.com/about-aws/global-infrastructure/ assert.True(t, len(regions) >= 15, "Number of regions: %d", len(regions)) for _, region := range regions { assertLooksLikeRegionName(t, region) } } func assertLooksLikeRegionName(t *testing.T, regionName string) { assert.Regexp(t, "[a-z]{2}-[a-z]+?-[[:digit:]]+", regionName) } func TestGetAvailabilityZones(t *testing.T) { t.Parallel() randomRegion := GetRandomStableRegion(t, nil, nil) azs := GetAvailabilityZones(t, randomRegion) // Every AWS account has access to different AZs, so he best we can do is make sure we get at least one back assert.True(t, len(azs) > 1) for _, az := range azs { assert.Regexp(t, fmt.Sprintf("^%s[a-z]$", randomRegion), az) } } func TestGetRandomRegionForService(t *testing.T) { t.Parallel() serviceName := "apigatewayv2" regionsForService, _ := GetRegionsForServiceE(t, serviceName) randomRegionForService := GetRandomRegionForService(t, serviceName) assert.Contains(t, regionsForService, randomRegionForService) } ================================================ FILE: modules/aws/route53.go ================================================ package aws import ( "context" "fmt" "strings" "testing" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/route53" "github.com/aws/aws-sdk-go-v2/service/route53/types" "github.com/stretchr/testify/require" ) // GetRoute53Record returns a Route 53 Record func GetRoute53Record(t *testing.T, hostedZoneID, recordName, recordType, awsRegion string) *types.ResourceRecordSet { r, err := GetRoute53RecordE(t, hostedZoneID, recordName, recordType, awsRegion) require.NoError(t, err) return r } // GetRoute53RecordE returns a Route 53 Record func GetRoute53RecordE(t *testing.T, hostedZoneID, recordName, recordType, awsRegion string) (*types.ResourceRecordSet, error) { route53Client, err := NewRoute53ClientE(t, awsRegion) if err != nil { return nil, err } o, err := route53Client.ListResourceRecordSets(context.Background(), &route53.ListResourceRecordSetsInput{ HostedZoneId: &hostedZoneID, StartRecordName: &recordName, StartRecordType: types.RRType(recordType), MaxItems: aws.Int32(1), }) if err != nil { return nil, err } for _, record := range o.ResourceRecordSets { if strings.EqualFold(recordName+".", *record.Name) { return &record, nil } } return nil, fmt.Errorf("record not found") } // NewRoute53Client creates a route 53 client. func NewRoute53Client(t *testing.T, region string) *route53.Client { c, err := NewRoute53ClientE(t, region) require.NoError(t, err) return c } // NewRoute53ClientE creates a route 53 client. func NewRoute53ClientE(t *testing.T, region string) (*route53.Client, error) { sess, err := NewAuthenticatedSession(region) if err != nil { return nil, err } return route53.NewFromConfig(*sess), nil } ================================================ FILE: modules/aws/route53_test.go ================================================ package aws import ( "context" "fmt" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/route53" "github.com/aws/aws-sdk-go-v2/service/route53/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestRoute53Record(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) c, err := NewRoute53ClientE(t, region) require.NoError(t, err) domain := fmt.Sprintf("terratest%dexample.com", time.Now().UnixNano()) hostedZone, err := c.CreateHostedZone(context.Background(), &route53.CreateHostedZoneInput{ Name: aws.String(domain), CallerReference: aws.String(fmt.Sprint(time.Now().UnixNano())), }) require.NoError(t, err) t.Cleanup(func() { _, err := c.DeleteHostedZone(context.Background(), &route53.DeleteHostedZoneInput{ Id: hostedZone.HostedZone.Id, }) require.NoError(t, err) }) recordName := fmt.Sprintf("record.%s", domain) resourceRecordSet := &types.ResourceRecordSet{ Name: &recordName, Type: types.RRTypeA, TTL: aws.Int64(60), ResourceRecords: []types.ResourceRecord{ { Value: aws.String("127.0.0.1"), }, }, } _, err = c.ChangeResourceRecordSets(context.Background(), &route53.ChangeResourceRecordSetsInput{ HostedZoneId: hostedZone.HostedZone.Id, ChangeBatch: &types.ChangeBatch{ Changes: []types.Change{ { Action: types.ChangeActionCreate, ResourceRecordSet: resourceRecordSet, }, }, }, }) require.NoError(t, err) t.Cleanup(func() { _, err := c.ChangeResourceRecordSets(context.Background(), &route53.ChangeResourceRecordSetsInput{ HostedZoneId: hostedZone.HostedZone.Id, ChangeBatch: &types.ChangeBatch{ Changes: []types.Change{ { Action: types.ChangeActionDelete, ResourceRecordSet: resourceRecordSet, }, }, }, }) require.NoError(t, err) }) t.Run("ExistingRecord", func(t *testing.T) { route53Record := GetRoute53Record(t, *hostedZone.HostedZone.Id, recordName, string(resourceRecordSet.Type), region) require.NotNil(t, route53Record) assert.Equal(t, recordName+".", *route53Record.Name) assert.Equal(t, resourceRecordSet.Type, route53Record.Type) assert.Equal(t, "127.0.0.1", *route53Record.ResourceRecords[0].Value) }) t.Run("NotExistRecord", func(t *testing.T) { route53Record, err := GetRoute53RecordE(t, *hostedZone.HostedZone.Id, "ne"+recordName, "A", region) assert.Error(t, err) assert.Nil(t, route53Record) }) } ================================================ FILE: modules/aws/s3.go ================================================ package aws import ( "bytes" "context" "fmt" "io" "strings" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/feature/s3/manager" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // FindS3BucketWithTag finds the name of the S3 bucket in the given region with the given tag key=value. func FindS3BucketWithTag(t testing.TestingT, awsRegion string, key string, value string) string { bucket, err := FindS3BucketWithTagE(t, awsRegion, key, value) require.NoError(t, err) return bucket } // FindS3BucketWithTagE finds the name of the S3 bucket in the given region with the given tag key=value. func FindS3BucketWithTagE(t testing.TestingT, awsRegion string, key string, value string) (string, error) { s3Client, err := NewS3ClientE(t, awsRegion) if err != nil { return "", err } resp, err := s3Client.ListBuckets(context.Background(), &s3.ListBucketsInput{}) if err != nil { return "", err } for _, bucket := range resp.Buckets { tagResponse, err := s3Client.GetBucketTagging(context.Background(), &s3.GetBucketTaggingInput{Bucket: bucket.Name}) if err != nil { if strings.Contains(err.Error(), "NoSuchBucket") { // Occasionally, the ListBuckets call will return a bucket that has been deleted by S3 // but hasn't yet been actually removed from the backend. Listing tags on that bucket // will return this error. If the bucket has been deleted, it can't be the one to find, // so just ignore this error, and keep checking the other buckets. continue } if !strings.Contains(err.Error(), "AuthorizationHeaderMalformed") && !strings.Contains(err.Error(), "BucketRegionError") && !strings.Contains(err.Error(), "NoSuchTagSet") { return "", err } } for _, tag := range tagResponse.TagSet { if *tag.Key == key && *tag.Value == value { logger.Default.Logf(t, "Found S3 bucket %s with tag %s=%s", *bucket.Name, key, value) return *bucket.Name, nil } } } return "", nil } // GetS3BucketTags fetches the given bucket's tags and returns them as a string map of strings. func GetS3BucketTags(t testing.TestingT, awsRegion string, bucket string) map[string]string { tags, err := GetS3BucketTagsE(t, awsRegion, bucket) require.NoError(t, err) return tags } // GetS3BucketTagsE fetches the given bucket's tags and returns them as a string map of strings. func GetS3BucketTagsE(t testing.TestingT, awsRegion string, bucket string) (map[string]string, error) { s3Client, err := NewS3ClientE(t, awsRegion) if err != nil { return nil, err } out, err := s3Client.GetBucketTagging(context.Background(), &s3.GetBucketTaggingInput{ Bucket: &bucket, }) if err != nil { return nil, err } tags := map[string]string{} for _, tag := range out.TagSet { tags[aws.ToString(tag.Key)] = aws.ToString(tag.Value) } return tags, nil } // GetS3ObjectContents fetches the contents of the object in the given bucket with the given key and return it as a string. func GetS3ObjectContents(t testing.TestingT, awsRegion string, bucket string, key string) string { contents, err := GetS3ObjectContentsE(t, awsRegion, bucket, key) require.NoError(t, err) return contents } // GetS3ObjectContentsE fetches the contents of the object in the given bucket with the given key and return it as a string. func GetS3ObjectContentsE(t testing.TestingT, awsRegion string, bucket string, key string) (string, error) { s3Client, err := NewS3ClientE(t, awsRegion) if err != nil { return "", err } res, err := s3Client.GetObject(context.Background(), &s3.GetObjectInput{ Bucket: &bucket, Key: &key, }) if err != nil { return "", err } buf := new(bytes.Buffer) _, err = buf.ReadFrom(res.Body) if err != nil { return "", err } contents := buf.String() logger.Default.Logf(t, "Read contents from s3://%s/%s", bucket, key) return contents, nil } // PutS3ObjectContents puts the contents of the object in the given bucket with the given key. func PutS3ObjectContents(t testing.TestingT, awsRegion string, bucket string, key string, body io.Reader) { err := PutS3ObjectContentsE(t, awsRegion, bucket, key, body) require.NoError(t, err) } // PutS3ObjectContents puts the contents of the object in the given bucket with the given key. func PutS3ObjectContentsE(t testing.TestingT, awsRegion string, bucket string, key string, body io.Reader) error { s3Client, err := NewS3ClientE(t, awsRegion) if err != nil { return fmt.Errorf("failed to instantiate s3 client: %w", err) } params := &s3.PutObjectInput{ Bucket: aws.String(bucket), Key: aws.String(key), Body: body, } _, err = s3Client.PutObject(context.Background(), params) return err } // CreateS3Bucket creates an S3 bucket in the given region with the given name. Note that S3 bucket names must be globally unique. func CreateS3Bucket(t testing.TestingT, region string, name string) { err := CreateS3BucketE(t, region, name) require.NoError(t, err) } // CreateS3BucketE creates an S3 bucket in the given region with the given name. Note that S3 bucket names must be globally unique. func CreateS3BucketE(t testing.TestingT, region string, name string) error { logger.Default.Logf(t, "Creating bucket %s in %s", name, region) s3Client, err := NewS3ClientE(t, region) if err != nil { return err } params := &s3.CreateBucketInput{ Bucket: aws.String(name), ObjectOwnership: types.ObjectOwnershipObjectWriter, } if region != "us-east-1" { params.CreateBucketConfiguration = &types.CreateBucketConfiguration{ LocationConstraint: types.BucketLocationConstraint(region), } } _, err = s3Client.CreateBucket(context.Background(), params) return err } // PutS3BucketPolicy applies an IAM resource policy to a given S3 bucket to create its bucket policy func PutS3BucketPolicy(t testing.TestingT, region string, bucketName string, policyJSONString string) { err := PutS3BucketPolicyE(t, region, bucketName, policyJSONString) require.NoError(t, err) } // PutS3BucketPolicyE applies an IAM resource policy to a given S3 bucket to create its bucket policy func PutS3BucketPolicyE(t testing.TestingT, region string, bucketName string, policyJSONString string) error { logger.Default.Logf(t, "Applying bucket policy for bucket %s in %s", bucketName, region) s3Client, err := NewS3ClientE(t, region) if err != nil { return err } input := &s3.PutBucketPolicyInput{ Bucket: aws.String(bucketName), Policy: aws.String(policyJSONString), } _, err = s3Client.PutBucketPolicy(context.Background(), input) return err } // PutS3BucketVersioning creates an S3 bucket versioning configuration in the given region against the given bucket name, WITHOUT requiring MFA to remove versioning. func PutS3BucketVersioning(t testing.TestingT, region string, bucketName string) { err := PutS3BucketVersioningE(t, region, bucketName) require.NoError(t, err) } // PutS3BucketVersioningE creates an S3 bucket versioning configuration in the given region against the given bucket name, WITHOUT requiring MFA to remove versioning. func PutS3BucketVersioningE(t testing.TestingT, region string, bucketName string) error { logger.Default.Logf(t, "Creating bucket versioning configuration for bucket %s in %s", bucketName, region) s3Client, err := NewS3ClientE(t, region) if err != nil { return err } input := &s3.PutBucketVersioningInput{ Bucket: aws.String(bucketName), VersioningConfiguration: &types.VersioningConfiguration{ MFADelete: types.MFADeleteDisabled, Status: types.BucketVersioningStatusEnabled, }, } _, err = s3Client.PutBucketVersioning(context.Background(), input) return err } // DeleteS3Bucket destroys the S3 bucket in the given region with the given name. func DeleteS3Bucket(t testing.TestingT, region string, name string) { err := DeleteS3BucketE(t, region, name) require.NoError(t, err) } // DeleteS3BucketE destroys the S3 bucket in the given region with the given name. func DeleteS3BucketE(t testing.TestingT, region string, name string) error { logger.Default.Logf(t, "Deleting bucket %s in %s", region, name) s3Client, err := NewS3ClientE(t, region) if err != nil { return err } params := &s3.DeleteBucketInput{ Bucket: aws.String(name), } _, err = s3Client.DeleteBucket(context.Background(), params) return err } // EmptyS3Bucket removes the contents of an S3 bucket in the given region with the given name. func EmptyS3Bucket(t testing.TestingT, region string, name string) { err := EmptyS3BucketE(t, region, name) require.NoError(t, err) } // EmptyS3BucketE removes the contents of an S3 bucket in the given region with the given name. func EmptyS3BucketE(t testing.TestingT, region string, name string) error { logger.Default.Logf(t, "Emptying bucket %s in %s", name, region) s3Client, err := NewS3ClientE(t, region) if err != nil { return err } params := &s3.ListObjectVersionsInput{ Bucket: aws.String(name), } for { // Requesting a batch of objects from s3 bucket bucketObjects, err := s3Client.ListObjectVersions(context.Background(), params) if err != nil { return err } // Checks if the bucket is already empty if len((*bucketObjects).Versions) == 0 { logger.Default.Logf(t, "Bucket %s is already empty", name) return nil } // creating an array of pointers of ObjectIdentifier objectsToDelete := make([]types.ObjectIdentifier, 0, 1000) for _, object := range (*bucketObjects).Versions { obj := types.ObjectIdentifier{ Key: object.Key, VersionId: object.VersionId, } objectsToDelete = append(objectsToDelete, obj) } for _, object := range (*bucketObjects).DeleteMarkers { obj := types.ObjectIdentifier{ Key: object.Key, VersionId: object.VersionId, } objectsToDelete = append(objectsToDelete, obj) } // Creating JSON payload for bulk delete deleteArray := types.Delete{Objects: objectsToDelete} deleteParams := &s3.DeleteObjectsInput{ Bucket: aws.String(name), Delete: &deleteArray, } // Running the Bulk delete job (limit 1000) _, err = s3Client.DeleteObjects(context.Background(), deleteParams) if err != nil { return err } if *(*bucketObjects).IsTruncated { // if there are more objects in the bucket, IsTruncated = true // params.Marker = (*deleteParams).Delete.Objects[len((*deleteParams).Delete.Objects)-1].Key params.KeyMarker = bucketObjects.NextKeyMarker logger.Default.Logf(t, "Requesting next batch | %s", *(params.KeyMarker)) } else { // if all objects in the bucket have been cleaned up. break } } logger.Default.Logf(t, "Bucket %s is now empty", name) return err } // GetS3BucketLoggingTarget fetches the given bucket's logging target bucket and returns it as a string func GetS3BucketLoggingTarget(t testing.TestingT, awsRegion string, bucket string) string { loggingTarget, err := GetS3BucketLoggingTargetE(t, awsRegion, bucket) require.NoError(t, err) return loggingTarget } // GetS3BucketLoggingTargetE fetches the given bucket's logging target bucket and returns it as the following string: // `TargetBucket` of the `LoggingEnabled` property for an S3 bucket func GetS3BucketLoggingTargetE(t testing.TestingT, awsRegion string, bucket string) (string, error) { s3Client, err := NewS3ClientE(t, awsRegion) if err != nil { return "", err } res, err := s3Client.GetBucketLogging(context.Background(), &s3.GetBucketLoggingInput{ Bucket: &bucket, }) if err != nil { return "", err } if res.LoggingEnabled == nil { return "", S3AccessLoggingNotEnabledErr{bucket, awsRegion} } return aws.ToString(res.LoggingEnabled.TargetBucket), nil } // GetS3BucketLoggingTargetPrefix fetches the given bucket's logging object prefix and returns it as a string func GetS3BucketLoggingTargetPrefix(t testing.TestingT, awsRegion string, bucket string) string { loggingObjectTargetPrefix, err := GetS3BucketLoggingTargetPrefixE(t, awsRegion, bucket) require.NoError(t, err) return loggingObjectTargetPrefix } // GetS3BucketLoggingTargetPrefixE fetches the given bucket's logging object prefix and returns it as the following string: // `TargetPrefix` of the `LoggingEnabled` property for an S3 bucket func GetS3BucketLoggingTargetPrefixE(t testing.TestingT, awsRegion string, bucket string) (string, error) { s3Client, err := NewS3ClientE(t, awsRegion) if err != nil { return "", err } res, err := s3Client.GetBucketLogging(context.Background(), &s3.GetBucketLoggingInput{ Bucket: &bucket, }) if err != nil { return "", err } if res.LoggingEnabled == nil { return "", S3AccessLoggingNotEnabledErr{bucket, awsRegion} } return aws.ToString(res.LoggingEnabled.TargetPrefix), nil } // GetS3BucketVersioning fetches the given bucket's versioning configuration status and returns it as a string func GetS3BucketVersioning(t testing.TestingT, awsRegion string, bucket string) string { versioningStatus, err := GetS3BucketVersioningE(t, awsRegion, bucket) require.NoError(t, err) return versioningStatus } // GetS3BucketVersioningE fetches the given bucket's versioning configuration status and returns it as a string func GetS3BucketVersioningE(t testing.TestingT, awsRegion string, bucket string) (string, error) { s3Client, err := NewS3ClientE(t, awsRegion) if err != nil { return "", err } res, err := s3Client.GetBucketVersioning(context.Background(), &s3.GetBucketVersioningInput{ Bucket: &bucket, }) if err != nil { return "", err } return string(res.Status), nil } // GetS3BucketPolicy fetches the given bucket's resource policy and returns it as a string func GetS3BucketPolicy(t testing.TestingT, awsRegion string, bucket string) string { bucketPolicy, err := GetS3BucketPolicyE(t, awsRegion, bucket) require.NoError(t, err) return bucketPolicy } // GetS3BucketPolicyE fetches the given bucket's resource policy and returns it as a string func GetS3BucketPolicyE(t testing.TestingT, awsRegion string, bucket string) (string, error) { s3Client, err := NewS3ClientE(t, awsRegion) if err != nil { return "", err } res, err := s3Client.GetBucketPolicy(context.Background(), &s3.GetBucketPolicyInput{ Bucket: &bucket, }) if err != nil { return "", err } return aws.ToString(res.Policy), nil } func GetS3BucketOwnershipControls(t testing.TestingT, awsRegion, bucket string) []string { rules, err := GetS3BucketOwnershipControlsE(t, awsRegion, bucket) require.NoError(t, err) return rules } func GetS3BucketOwnershipControlsE(t testing.TestingT, awsRegion, bucket string) ([]string, error) { s3Client, err := NewS3ClientE(t, awsRegion) if err != nil { return nil, err } out, err := s3Client.GetBucketOwnershipControls(context.Background(), &s3.GetBucketOwnershipControlsInput{ Bucket: &bucket, }) if err != nil { return nil, err } rules := make([]string, 0, len(out.OwnershipControls.Rules)) for _, rule := range out.OwnershipControls.Rules { rules = append(rules, string(rule.ObjectOwnership)) } return rules, nil } // AssertS3BucketExists checks if the given S3 bucket exists in the given region and fail the test if it does not. func AssertS3BucketExists(t testing.TestingT, region string, name string) { err := AssertS3BucketExistsE(t, region, name) require.NoError(t, err) } // AssertS3BucketExistsE checks if the given S3 bucket exists in the given region and return an error if it does not. func AssertS3BucketExistsE(t testing.TestingT, region string, name string) error { s3Client, err := NewS3ClientE(t, region) if err != nil { return err } params := &s3.HeadBucketInput{ Bucket: aws.String(name), } _, err = s3Client.HeadBucket(context.Background(), params) return err } // AssertS3BucketVersioningExists checks if the given S3 bucket has a versioning configuration enabled and returns an error if it does not. func AssertS3BucketVersioningExists(t testing.TestingT, region string, bucketName string) { err := AssertS3BucketVersioningExistsE(t, region, bucketName) require.NoError(t, err) } // AssertS3BucketVersioningExistsE checks if the given S3 bucket has a versioning configuration enabled and returns an error if it does not. func AssertS3BucketVersioningExistsE(t testing.TestingT, region string, bucketName string) error { status, err := GetS3BucketVersioningE(t, region, bucketName) if err != nil { return err } if status == "Enabled" { return nil } return NewBucketVersioningNotEnabledError(bucketName, region, status) } // AssertS3BucketPolicyExists checks if the given S3 bucket has a resource policy attached and returns an error if it does not func AssertS3BucketPolicyExists(t testing.TestingT, region string, bucketName string) { err := AssertS3BucketPolicyExistsE(t, region, bucketName) require.NoError(t, err) } // AssertS3BucketPolicyExistsE checks if the given S3 bucket has a resource policy attached and returns an error if it does not func AssertS3BucketPolicyExistsE(t testing.TestingT, region string, bucketName string) error { policy, err := GetS3BucketPolicyE(t, region, bucketName) if err != nil { return err } if policy == "" { return NewNoBucketPolicyError(bucketName, region, policy) } return nil } // NewS3Client creates an S3 client. func NewS3Client(t testing.TestingT, region string) *s3.Client { client, err := NewS3ClientE(t, region) require.NoError(t, err) return client } // NewS3ClientE creates an S3 client. func NewS3ClientE(t testing.TestingT, region string) (*s3.Client, error) { sess, err := NewAuthenticatedSession(region) if err != nil { return nil, err } return s3.NewFromConfig(*sess), nil } // NewS3Uploader creates an S3 Uploader. func NewS3Uploader(t testing.TestingT, region string) *manager.Uploader { uploader, err := NewS3UploaderE(t, region) require.NoError(t, err) return uploader } // NewS3UploaderE creates an S3 Uploader. func NewS3UploaderE(t testing.TestingT, region string) (*manager.Uploader, error) { sess, err := NewAuthenticatedSession(region) if err != nil { return nil, err } return manager.NewUploader(s3.NewFromConfig(*sess)), nil } // S3AccessLoggingNotEnabledErr is a custom error that occurs when acess logging hasn't been enabled on the S3 Bucket type S3AccessLoggingNotEnabledErr struct { OriginBucket string Region string } func (err S3AccessLoggingNotEnabledErr) Error() string { return fmt.Sprintf("Server Access Logging hasn't been enabled for S3 Bucket %s in region %s", err.OriginBucket, err.Region) } ================================================ FILE: modules/aws/s3_test.go ================================================ // Integration tests that validate S3-related code in AWS. package aws import ( "bytes" "context" "fmt" "math/rand" "strconv" "strings" "testing" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/random" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestCreateAndDestroyS3Bucket(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) id := random.UniqueId() logger.Default.Logf(t, "Random values selected. Region = %s, Id = %s\n", region, id) s3BucketName := "gruntwork-terratest-" + strings.ToLower(id) CreateS3Bucket(t, region, s3BucketName) DeleteS3Bucket(t, region, s3BucketName) } func TestAssertS3BucketExistsNoFalseNegative(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) s3BucketName := "gruntwork-terratest-" + strings.ToLower(random.UniqueId()) logger.Default.Logf(t, "Random values selected. Region = %s, s3BucketName = %s\n", region, s3BucketName) CreateS3Bucket(t, region, s3BucketName) defer DeleteS3Bucket(t, region, s3BucketName) AssertS3BucketExists(t, region, s3BucketName) } func TestAssertS3BucketExistsNoFalsePositive(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) s3BucketName := "gruntwork-terratest-" + strings.ToLower(random.UniqueId()) logger.Default.Logf(t, "Random values selected. Region = %s, s3BucketName = %s\n", region, s3BucketName) // We elect not to create the S3 bucket to confirm that our function correctly reports it doesn't exist. // aws.CreateS3Bucket(region, s3BucketName) err := AssertS3BucketExistsE(t, region, s3BucketName) if err == nil { t.Fatalf("Function claimed that S3 Bucket '%s' exists, but in fact it does not.", s3BucketName) } } func TestAssertS3BucketVersioningEnabled(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) s3BucketName := "gruntwork-terratest-" + strings.ToLower(random.UniqueId()) logger.Default.Logf(t, "Random values selected. Region = %s, s3BucketName = %s\n", region, s3BucketName) CreateS3Bucket(t, region, s3BucketName) defer DeleteS3Bucket(t, region, s3BucketName) PutS3BucketVersioning(t, region, s3BucketName) AssertS3BucketVersioningExists(t, region, s3BucketName) } func TestEmptyS3Bucket(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) id := random.UniqueId() logger.Default.Logf(t, "Random values selected. Region = %s, Id = %s\n", region, id) s3BucketName := "gruntwork-terratest-" + strings.ToLower(id) CreateS3Bucket(t, region, s3BucketName) defer DeleteS3Bucket(t, region, s3BucketName) s3Client, err := NewS3ClientE(t, region) if err != nil { t.Fatal(err) } testEmptyBucket(t, s3Client, region, s3BucketName) } func TestEmptyS3BucketVersioned(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) id := random.UniqueId() logger.Default.Logf(t, "Random values selected. Region = %s, Id = %s\n", region, id) s3BucketName := "gruntwork-terratest-" + strings.ToLower(id) CreateS3Bucket(t, region, s3BucketName) defer DeleteS3Bucket(t, region, s3BucketName) s3Client, err := NewS3ClientE(t, region) if err != nil { t.Fatal(err) } versionInput := &s3.PutBucketVersioningInput{ Bucket: aws.String(s3BucketName), VersioningConfiguration: &types.VersioningConfiguration{ MFADelete: types.MFADeleteDisabled, Status: types.BucketVersioningStatusEnabled, }, } _, err = s3Client.PutBucketVersioning(context.Background(), versionInput) if err != nil { t.Fatal(err) } testEmptyBucket(t, s3Client, region, s3BucketName) } func TestAssertS3BucketPolicyExists(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) id := random.UniqueId() logger.Default.Logf(t, "Random values selected. Region = %s, Id = %s\n", region, id) s3BucketName := "gruntwork-terratest-" + strings.ToLower(id) exampleBucketPolicy := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":{"AWS":["*"]},"Action":"s3:Get*","Resource":"arn:aws:s3:::%s/*","Condition":{"Bool":{"aws:SecureTransport":"false"}}}]}`, s3BucketName) CreateS3Bucket(t, region, s3BucketName) defer DeleteS3Bucket(t, region, s3BucketName) PutS3BucketPolicy(t, region, s3BucketName, exampleBucketPolicy) AssertS3BucketPolicyExists(t, region, s3BucketName) } func TestGetS3BucketTags(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) id := random.UniqueId() logger.Default.Logf(t, "Random values selected. Region = %s, Id = %s\n", region, id) s3BucketName := "gruntwork-terratest-" + strings.ToLower(id) CreateS3Bucket(t, region, s3BucketName) defer DeleteS3Bucket(t, region, s3BucketName) s3Client, err := NewS3ClientE(t, region) if err != nil { t.Fatal(err) } _, err = s3Client.PutBucketTagging(context.Background(), &s3.PutBucketTaggingInput{ Bucket: &s3BucketName, Tagging: &types.Tagging{ TagSet: []types.Tag{ { Key: aws.String("Key1"), Value: aws.String("Value1"), }, { Key: aws.String("Key2"), Value: aws.String("Value2"), }, }, }, }) if err != nil { t.Fatal(err) } actualTags := GetS3BucketTags(t, region, s3BucketName) assert.True(t, actualTags["Key1"] == "Value1") assert.True(t, actualTags["Key2"] == "Value2") assert.True(t, actualTags["NonExistentKey"] == "") } func testEmptyBucket(t *testing.T, s3Client *s3.Client, region string, s3BucketName string) { expectedFileCount := rand.Intn(1000) logger.Default.Logf(t, "Uploading %s files to bucket %s", strconv.Itoa(expectedFileCount), s3BucketName) deleted := 0 // Upload expectedFileCount files for i := 1; i <= expectedFileCount; i++ { key := fmt.Sprintf("test-%s", strconv.Itoa(i)) body := strings.NewReader("This is the body") params := &s3.PutObjectInput{ Bucket: aws.String(s3BucketName), Key: &key, Body: body, } uploader := NewS3Uploader(t, region) _, err := uploader.Upload(context.Background(), params) if err != nil { t.Fatal(err) } // Delete the first 10 files to be able to test if all files, including delete markers are deleted if i < 10 { _, err := s3Client.DeleteObject(context.Background(), &s3.DeleteObjectInput{ Bucket: aws.String(s3BucketName), Key: aws.String(key), }) if err != nil { t.Fatal(err) } deleted++ } if i != 0 && i%100 == 0 { logger.Default.Logf(t, "Uploaded %s files to bucket %s successfully", strconv.Itoa(i), s3BucketName) } } logger.Default.Logf(t, "Uploaded %s files to bucket %s successfully", strconv.Itoa(expectedFileCount), s3BucketName) // verify bucket contains 1 file now listObjectsParams := &s3.ListObjectsV2Input{ Bucket: aws.String(s3BucketName), } logger.Default.Logf(t, "Verifying %s files were uploaded to bucket %s", strconv.Itoa(expectedFileCount), s3BucketName) actualCount := 0 for { bucketObjects, err := s3Client.ListObjectsV2(context.Background(), listObjectsParams) if err != nil { t.Fatal(err) } pageLength := len((*bucketObjects).Contents) actualCount += pageLength if !*bucketObjects.IsTruncated { break } listObjectsParams.ContinuationToken = bucketObjects.NextContinuationToken } require.Equal(t, expectedFileCount-deleted, actualCount) // empty bucket logger.Default.Logf(t, "Emptying bucket %s", s3BucketName) EmptyS3Bucket(t, region, s3BucketName) // verify the bucket is empty bucketObjects, err := s3Client.ListObjectsV2(context.Background(), listObjectsParams) if err != nil { t.Fatal(err) } require.Equal(t, 0, len((*bucketObjects).Contents)) } func TestGetS3BucketOwnershipControls(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) id := random.UniqueId() logger.Default.Logf(t, "Random values selected. Region = %s, Id = %s\n", region, id) s3BucketName := "gruntwork-terratest-" + strings.ToLower(id) CreateS3Bucket(t, region, s3BucketName) t.Cleanup(func() { DeleteS3Bucket(t, region, s3BucketName) }) t.Run("Exist", func(t *testing.T) { s3Client, err := NewS3ClientE(t, region) require.NoError(t, err) _, err = s3Client.PutBucketOwnershipControls(context.Background(), &s3.PutBucketOwnershipControlsInput{ Bucket: &s3BucketName, OwnershipControls: &types.OwnershipControls{ Rules: []types.OwnershipControlsRule{ { ObjectOwnership: types.ObjectOwnershipBucketOwnerEnforced, }, }, }, }) require.NoError(t, err) t.Cleanup(func() { _, err := s3Client.DeleteBucketOwnershipControls(context.Background(), &s3.DeleteBucketOwnershipControlsInput{ Bucket: &s3BucketName, }) require.NoError(t, err) }) controls := GetS3BucketOwnershipControls(t, region, s3BucketName) assert.Equal(t, 1, len(controls)) assert.Equal(t, string(types.ObjectOwnershipBucketOwnerEnforced), controls[0]) }) t.Run("NotExist", func(t *testing.T) { _, err := GetS3BucketOwnershipControlsE(t, region, s3BucketName) assert.Error(t, err) }) } func TestS3ObjectContents(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) id := random.UniqueId() logger.Default.Logf(t, "Random values selected. Region = %s, Id = %s\n", region, id) s3BucketName := "gruntwork-terratest-" + strings.ToLower(id) CreateS3Bucket(t, region, s3BucketName) defer DeleteS3Bucket(t, region, s3BucketName) defer EmptyS3BucketE(t, region, s3BucketName) key := fmt.Sprintf("content-%s", id) body := make([]byte, 1024) rand.Read(body) PutS3ObjectContentsE(t, region, s3BucketName, key, bytes.NewReader(body)) storedBody := GetS3ObjectContents(t, region, s3BucketName, key) assert.Equal(t, body, []byte(storedBody)) } ================================================ FILE: modules/aws/secretsmanager.go ================================================ package aws import ( "context" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/secretsmanager" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // CreateSecretStringWithDefaultKey creates a new secret in Secrets Manager using the default "aws/secretsmanager" KMS key and returns the secret ARN func CreateSecretStringWithDefaultKey(t testing.TestingT, awsRegion, description, name, secretString string) string { arn, err := CreateSecretStringWithDefaultKeyE(t, awsRegion, description, name, secretString) require.NoError(t, err) return arn } // CreateSecretStringWithDefaultKeyE creates a new secret in Secrets Manager using the default "aws/secretsmanager" KMS key and returns the secret ARN func CreateSecretStringWithDefaultKeyE(t testing.TestingT, awsRegion, description, name, secretString string) (string, error) { logger.Default.Logf(t, "Creating new secret in secrets manager named %s", name) client := NewSecretsManagerClient(t, awsRegion) secret, err := client.CreateSecret(context.Background(), &secretsmanager.CreateSecretInput{ Description: aws.String(description), Name: aws.String(name), SecretString: aws.String(secretString), }) if err != nil { return "", err } return aws.ToString(secret.ARN), nil } // GetSecretValue takes the friendly name or ARN of a secret and returns the plaintext value func GetSecretValue(t testing.TestingT, awsRegion, id string) string { secret, err := GetSecretValueE(t, awsRegion, id) require.NoError(t, err) return secret } // GetSecretValueE takes the friendly name or ARN of a secret and returns the plaintext value func GetSecretValueE(t testing.TestingT, awsRegion, id string) (string, error) { logger.Default.Logf(t, "Getting value of secret with ID %s", id) client := NewSecretsManagerClient(t, awsRegion) secret, err := client.GetSecretValue(context.Background(), &secretsmanager.GetSecretValueInput{ SecretId: aws.String(id), }) if err != nil { return "", err } return aws.ToString(secret.SecretString), nil } // PutSecretString updates a secret in Secrets Manager to a new string value func PutSecretString(t testing.TestingT, awsRegion, id string, secretString string) { err := PutSecretStringE(t, awsRegion, id, secretString) require.NoError(t, err) } // PutSecretStringE updates a secret in Secrets Manager to a new string value func PutSecretStringE(t testing.TestingT, awsRegion, id string, secretString string) error { logger.Default.Logf(t, "Updating secret with ID %s", id) client := NewSecretsManagerClient(t, awsRegion) _, err := client.PutSecretValue(context.Background(), &secretsmanager.PutSecretValueInput{ SecretId: aws.String(id), SecretString: aws.String(secretString), }) return err } // DeleteSecret deletes a secret. If forceDelete is true, the secret will be deleted after a short delay. If forceDelete is false, the secret will be deleted after a 30-day recovery window. func DeleteSecret(t testing.TestingT, awsRegion, id string, forceDelete bool) { err := DeleteSecretE(t, awsRegion, id, forceDelete) require.NoError(t, err) } // DeleteSecretE deletes a secret. If forceDelete is true, the secret will be deleted after a short delay. If forceDelete is false, the secret will be deleted after a 30-day recovery window. func DeleteSecretE(t testing.TestingT, awsRegion, id string, forceDelete bool) error { logger.Default.Logf(t, "Deleting secret with ID %s", id) client := NewSecretsManagerClient(t, awsRegion) _, err := client.DeleteSecret(context.Background(), &secretsmanager.DeleteSecretInput{ ForceDeleteWithoutRecovery: aws.Bool(forceDelete), SecretId: aws.String(id), }) return err } // NewSecretsManagerClient creates a new SecretsManager client. func NewSecretsManagerClient(t testing.TestingT, region string) *secretsmanager.Client { client, err := NewSecretsManagerClientE(t, region) require.NoError(t, err) return client } // NewSecretsManagerClientE creates a new SecretsManager client. func NewSecretsManagerClientE(t testing.TestingT, region string) (*secretsmanager.Client, error) { sess, err := NewAuthenticatedSession(region) if err != nil { return nil, err } return secretsmanager.NewFromConfig(*sess), nil } ================================================ FILE: modules/aws/secretsmanager_test.go ================================================ package aws import ( "testing" "github.com/gruntwork-io/terratest/modules/random" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestSecretsManagerMethods(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) name := random.UniqueId() description := "This is just a secrets manager test description." secretOriginalValue := "This is the secret value." secretUpdatedValue := "This is the NEW secret value." secretARN := CreateSecretStringWithDefaultKey(t, region, description, name, secretOriginalValue) defer deleteSecret(t, region, secretARN) storedValue := GetSecretValue(t, region, secretARN) assert.Equal(t, secretOriginalValue, storedValue) PutSecretString(t, region, secretARN, secretUpdatedValue) storedValueAfterUpdate := GetSecretValue(t, region, secretARN) assert.Equal(t, secretUpdatedValue, storedValueAfterUpdate) } func deleteSecret(t *testing.T, region, id string) { DeleteSecret(t, region, id, true) _, err := GetSecretValueE(t, region, id) require.Error(t, err) } ================================================ FILE: modules/aws/sns.go ================================================ package aws import ( "context" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/sns" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/testing" ) // CreateSnsTopic creates an SNS Topic and return the ARN. func CreateSnsTopic(t testing.TestingT, region string, snsTopicName string) string { out, err := CreateSnsTopicE(t, region, snsTopicName) if err != nil { t.Fatal(err) } return out } // CreateSnsTopicE creates an SNS Topic and return the ARN. func CreateSnsTopicE(t testing.TestingT, region string, snsTopicName string) (string, error) { logger.Default.Logf(t, "Creating SNS topic %s in %s", snsTopicName, region) snsClient, err := NewSnsClientE(t, region) if err != nil { return "", err } createTopicInput := &sns.CreateTopicInput{ Name: &snsTopicName, } output, err := snsClient.CreateTopic(context.Background(), createTopicInput) if err != nil { return "", err } return aws.ToString(output.TopicArn), err } // DeleteSNSTopic deletes an SNS Topic. func DeleteSNSTopic(t testing.TestingT, region string, snsTopicArn string) { err := DeleteSNSTopicE(t, region, snsTopicArn) if err != nil { t.Fatal(err) } } // DeleteSNSTopicE deletes an SNS Topic. func DeleteSNSTopicE(t testing.TestingT, region string, snsTopicArn string) error { logger.Default.Logf(t, "Deleting SNS topic %s in %s", snsTopicArn, region) snsClient, err := NewSnsClientE(t, region) if err != nil { return err } deleteTopicInput := &sns.DeleteTopicInput{ TopicArn: aws.String(snsTopicArn), } _, err = snsClient.DeleteTopic(context.Background(), deleteTopicInput) return err } // NewSnsClient creates a new SNS client. func NewSnsClient(t testing.TestingT, region string) *sns.Client { client, err := NewSnsClientE(t, region) if err != nil { t.Fatal(err) } return client } // NewSnsClientE creates a new SNS client. func NewSnsClientE(t testing.TestingT, region string) (*sns.Client, error) { sess, err := NewAuthenticatedSession(region) if err != nil { return nil, err } return sns.NewFromConfig(*sess), nil } ================================================ FILE: modules/aws/sns_test.go ================================================ package aws import ( "context" "fmt" "strings" "testing" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/sns" "github.com/gruntwork-io/terratest/modules/random" "github.com/stretchr/testify/assert" ) func TestCreateAndDeleteSnsTopic(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) uniqueID := random.UniqueId() name := fmt.Sprintf("test-sns-topic-%s", uniqueID) arn := CreateSnsTopic(t, region, name) defer deleteTopic(t, region, arn) assert.True(t, snsTopicExists(t, region, arn)) } func snsTopicExists(t *testing.T, region string, arn string) bool { snsClient := NewSnsClient(t, region) input := sns.GetTopicAttributesInput{TopicArn: aws.String(arn)} if _, err := snsClient.GetTopicAttributes(context.Background(), &input); err != nil { if strings.Contains(err.Error(), "NotFound") { return false } t.Fatal(err) } return true } func deleteTopic(t *testing.T, region string, arn string) { DeleteSNSTopic(t, region, arn) assert.False(t, snsTopicExists(t, region, arn)) } ================================================ FILE: modules/aws/sqs.go ================================================ package aws import ( "context" "fmt" "strconv" "strings" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/sqs" "github.com/aws/aws-sdk-go-v2/service/sqs/types" "github.com/google/uuid" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/testing" ) // CreateRandomQueue creates a new SQS queue with a random name that starts with the given prefix and return the queue URL. func CreateRandomQueue(t testing.TestingT, awsRegion string, prefix string) string { url, err := CreateRandomQueueE(t, awsRegion, prefix) if err != nil { t.Fatal(err) } return url } // CreateRandomQueueE creates a new SQS queue with a random name that starts with the given prefix and return the queue URL. func CreateRandomQueueE(t testing.TestingT, awsRegion string, prefix string) (string, error) { logger.Default.Logf(t, "Creating randomly named SQS queue with prefix %s", prefix) sqsClient, err := NewSqsClientE(t, awsRegion) if err != nil { return "", err } channel, err := uuid.NewUUID() if err != nil { return "", err } channelName := fmt.Sprintf("%s-%s", prefix, channel.String()) queue, err := sqsClient.CreateQueue(context.Background(), &sqs.CreateQueueInput{ QueueName: aws.String(channelName), }) if err != nil { return "", err } return aws.ToString(queue.QueueUrl), nil } // CreateRandomFifoQueue creates a new FIFO SQS queue with a random name that starts with the given prefix and return the queue URL. func CreateRandomFifoQueue(t testing.TestingT, awsRegion string, prefix string) string { url, err := CreateRandomFifoQueueE(t, awsRegion, prefix) if err != nil { t.Fatal(err) } return url } // CreateRandomFifoQueueE creates a new FIFO SQS queue with a random name that starts with the given prefix and return the queue URL. func CreateRandomFifoQueueE(t testing.TestingT, awsRegion string, prefix string) (string, error) { logger.Default.Logf(t, "Creating randomly named FIFO SQS queue with prefix %s", prefix) sqsClient, err := NewSqsClientE(t, awsRegion) if err != nil { return "", err } channel, err := uuid.NewUUID() if err != nil { return "", err } channelName := fmt.Sprintf("%s-%s.fifo", prefix, channel.String()) queue, err := sqsClient.CreateQueue(context.Background(), &sqs.CreateQueueInput{ QueueName: aws.String(channelName), Attributes: map[string]string{ "ContentBasedDeduplication": "true", "FifoQueue": "true", }, }) if err != nil { return "", err } return aws.ToString(queue.QueueUrl), nil } // DeleteQueue deletes the SQS queue with the given URL. func DeleteQueue(t testing.TestingT, awsRegion string, queueURL string) { err := DeleteQueueE(t, awsRegion, queueURL) if err != nil { t.Fatal(err) } } // DeleteQueueE deletes the SQS queue with the given URL. func DeleteQueueE(t testing.TestingT, awsRegion string, queueURL string) error { logger.Default.Logf(t, "Deleting SQS Queue %s", queueURL) sqsClient, err := NewSqsClientE(t, awsRegion) if err != nil { return err } _, err = sqsClient.DeleteQueue(context.Background(), &sqs.DeleteQueueInput{ QueueUrl: aws.String(queueURL), }) return err } // DeleteMessageFromQueue deletes the message with the given receipt from the SQS queue with the given URL. func DeleteMessageFromQueue(t testing.TestingT, awsRegion string, queueURL string, receipt string) { err := DeleteMessageFromQueueE(t, awsRegion, queueURL, receipt) if err != nil { t.Fatal(err) } } // DeleteMessageFromQueueE deletes the message with the given receipt from the SQS queue with the given URL. func DeleteMessageFromQueueE(t testing.TestingT, awsRegion string, queueURL string, receipt string) error { logger.Default.Logf(t, "Deleting message from queue %s (%s)", queueURL, receipt) sqsClient, err := NewSqsClientE(t, awsRegion) if err != nil { return err } _, err = sqsClient.DeleteMessage(context.Background(), &sqs.DeleteMessageInput{ ReceiptHandle: &receipt, QueueUrl: &queueURL, }) return err } // SendMessageToQueue sends the given message to the SQS queue with the given URL. func SendMessageToQueue(t testing.TestingT, awsRegion string, queueURL string, message string) { err := SendMessageToQueueE(t, awsRegion, queueURL, message) if err != nil { t.Fatal(err) } } // SendMessageToQueueE sends the given message to the SQS queue with the given URL. func SendMessageToQueueE(t testing.TestingT, awsRegion string, queueURL string, message string) error { logger.Default.Logf(t, "Sending message %s to queue %s", message, queueURL) sqsClient, err := NewSqsClientE(t, awsRegion) if err != nil { return err } res, err := sqsClient.SendMessage(context.Background(), &sqs.SendMessageInput{ MessageBody: &message, QueueUrl: &queueURL, }) if err != nil { if strings.Contains(err.Error(), "AWS.SimpleQueueService.NonExistentQueue") { logger.Default.Logf(t, "WARN: Client has stopped listening on queue %s", queueURL) return nil } return err } logger.Default.Logf(t, "Message id %s sent to queue %s", aws.ToString(res.MessageId), queueURL) return nil } // SendMessageFifoToQueue sends the given message to the FIFO SQS queue with the given URL. func SendMessageFifoToQueue(t testing.TestingT, awsRegion string, queueURL string, message string, messageGroupID string) { err := SendMessageToFifoQueueE(t, awsRegion, queueURL, message, messageGroupID) if err != nil { t.Fatal(err) } } // SendMessageToFifoQueueE sends the given message to the FIFO SQS queue with the given URL. func SendMessageToFifoQueueE(t testing.TestingT, awsRegion string, queueURL string, message string, messageGroupID string) error { logger.Default.Logf(t, "Sending message %s to queue %s", message, queueURL) sqsClient, err := NewSqsClientE(t, awsRegion) if err != nil { return err } res, err := sqsClient.SendMessage(context.Background(), &sqs.SendMessageInput{ MessageBody: &message, QueueUrl: &queueURL, MessageGroupId: &messageGroupID, }) if err != nil { if strings.Contains(err.Error(), "AWS.SimpleQueueService.NonExistentQueue") { logger.Default.Logf(t, "WARN: Client has stopped listening on queue %s", queueURL) return nil } return err } logger.Default.Logf(t, "Message id %s sent to queue %s", aws.ToString(res.MessageId), queueURL) return nil } // QueueMessageResponse contains a queue message. type QueueMessageResponse struct { ReceiptHandle string MessageBody string Error error } // WaitForQueueMessage waits to receive a message from on the queueURL. Since the API only allows us to wait a max 20 seconds for a new // message to arrive, we must loop TIMEOUT/20 number of times to be able to wait for a total of TIMEOUT seconds func WaitForQueueMessage(t testing.TestingT, awsRegion string, queueURL string, timeout int) QueueMessageResponse { sqsClient, err := NewSqsClientE(t, awsRegion) if err != nil { return QueueMessageResponse{Error: err} } cycles := timeout cycleLength := 1 if timeout >= 20 { cycleLength = 20 cycles = timeout / cycleLength } for i := 0; i < cycles; i++ { logger.Default.Logf(t, "Waiting for message on %s (%ss)", queueURL, strconv.Itoa(i*cycleLength)) result, err := sqsClient.ReceiveMessage(context.Background(), &sqs.ReceiveMessageInput{ QueueUrl: aws.String(queueURL), MessageSystemAttributeNames: []types.MessageSystemAttributeName{types.MessageSystemAttributeNameSentTimestamp}, MaxNumberOfMessages: int32(1), MessageAttributeNames: []string{"All"}, WaitTimeSeconds: int32(cycleLength), }) if err != nil { return QueueMessageResponse{Error: err} } if len(result.Messages) > 0 { logger.Default.Logf(t, "Message %s received on %s", *result.Messages[0].MessageId, queueURL) return QueueMessageResponse{ReceiptHandle: *result.Messages[0].ReceiptHandle, MessageBody: *result.Messages[0].Body} } } return QueueMessageResponse{Error: ReceiveMessageTimeout{QueueUrl: queueURL, TimeoutSec: timeout}} } // NewSqsClient creates a new SQS client. func NewSqsClient(t testing.TestingT, region string) *sqs.Client { client, err := NewSqsClientE(t, region) if err != nil { t.Fatal(err) } return client } // NewSqsClientE creates a new SQS client. func NewSqsClientE(t testing.TestingT, region string) (*sqs.Client, error) { sess, err := NewAuthenticatedSession(region) if err != nil { return nil, err } return sqs.NewFromConfig(*sess), nil } // ReceiveMessageTimeout is an error that occurs if receiving a message times out. type ReceiveMessageTimeout struct { QueueUrl string TimeoutSec int } func (err ReceiveMessageTimeout) Error() string { return fmt.Sprintf("Failed to receive messages on %s within %s seconds", err.QueueUrl, strconv.Itoa(err.TimeoutSec)) } ================================================ FILE: modules/aws/sqs_test.go ================================================ package aws import ( "context" "fmt" "strings" "testing" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/sqs" "github.com/gruntwork-io/terratest/modules/random" "github.com/stretchr/testify/assert" ) func TestSqsQueueMethods(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) uniqueID := random.UniqueId() namePrefix := fmt.Sprintf("sqs-queue-test-%s", uniqueID) url := CreateRandomQueue(t, region, namePrefix) defer deleteQueue(t, region, url) assert.True(t, queueExists(t, region, url)) message := fmt.Sprintf("test-message-%s", uniqueID) timeoutSec := 20 SendMessageToQueue(t, region, url, message) firstResponse := WaitForQueueMessage(t, region, url, timeoutSec) assert.NoError(t, firstResponse.Error) assert.Equal(t, message, firstResponse.MessageBody) DeleteMessageFromQueue(t, region, url, firstResponse.ReceiptHandle) secondResponse := WaitForQueueMessage(t, region, url, timeoutSec) assert.Error(t, secondResponse.Error, ReceiveMessageTimeout{QueueUrl: url, TimeoutSec: timeoutSec}) } func TestFifoSqsQueueMethods(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) uniqueID := random.UniqueId() namePrefix := fmt.Sprintf("sqs-queue-test-%s", uniqueID) fifoMessageGroupID := "g1" url := CreateRandomFifoQueue(t, region, namePrefix) defer deleteQueue(t, region, url) assert.True(t, queueExists(t, region, url)) message := fmt.Sprintf("test-message-%s", uniqueID) timeoutSec := 20 SendMessageFifoToQueue(t, region, url, message, fifoMessageGroupID) firstResponse := WaitForQueueMessage(t, region, url, timeoutSec) assert.NoError(t, firstResponse.Error) assert.Equal(t, message, firstResponse.MessageBody) DeleteMessageFromQueue(t, region, url, firstResponse.ReceiptHandle) secondResponse := WaitForQueueMessage(t, region, url, timeoutSec) assert.Error(t, secondResponse.Error, ReceiveMessageTimeout{QueueUrl: url, TimeoutSec: timeoutSec}) } func queueExists(t *testing.T, region string, url string) bool { sqsClient := NewSqsClient(t, region) input := sqs.GetQueueAttributesInput{QueueUrl: aws.String(url)} if _, err := sqsClient.GetQueueAttributes(context.Background(), &input); err != nil { if strings.Contains(err.Error(), "NonExistentQueue") { return false } t.Fatal(err) } return true } func deleteQueue(t *testing.T, region string, url string) { DeleteQueue(t, region, url) assert.False(t, queueExists(t, region, url)) } ================================================ FILE: modules/aws/ssm.go ================================================ package aws import ( "context" "errors" "fmt" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ssm" "github.com/aws/aws-sdk-go-v2/service/ssm/types" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // GetParameter retrieves the latest version of SSM Parameter at keyName with decryption. func GetParameter(t testing.TestingT, awsRegion string, keyName string) string { keyValue, err := GetParameterE(t, awsRegion, keyName) require.NoError(t, err) return keyValue } // GetParameterE retrieves the latest version of SSM Parameter at keyName with decryption. func GetParameterE(t testing.TestingT, awsRegion string, keyName string) (string, error) { ssmClient, err := NewSsmClientE(t, awsRegion) if err != nil { return "", err } return GetParameterWithClientE(t, ssmClient, keyName) } // GetParameterWithClientE retrieves the latest version of SSM Parameter at keyName with decryption with the ability to provide the SSM client. func GetParameterWithClientE(t testing.TestingT, client *ssm.Client, keyName string) (string, error) { resp, err := client.GetParameter(context.Background(), &ssm.GetParameterInput{Name: aws.String(keyName), WithDecryption: aws.Bool(true)}) if err != nil { return "", err } parameter := *resp.Parameter return *parameter.Value, nil } // PutParameter creates new version of SSM Parameter at keyName with keyValue as SecureString. func PutParameter(t testing.TestingT, awsRegion string, keyName string, keyDescription string, keyValue string) int64 { version, err := PutParameterE(t, awsRegion, keyName, keyDescription, keyValue) require.NoError(t, err) return version } // PutParameterE creates new version of SSM Parameter at keyName with keyValue as SecureString. func PutParameterE(t testing.TestingT, awsRegion string, keyName string, keyDescription string, keyValue string) (int64, error) { ssmClient, err := NewSsmClientE(t, awsRegion) if err != nil { return 0, err } return PutParameterWithClientE(t, ssmClient, keyName, keyDescription, keyValue) } // PutParameterWithClientE creates new version of SSM Parameter at keyName with keyValue as SecureString with the ability to provide the SSM client. func PutParameterWithClientE(t testing.TestingT, client *ssm.Client, keyName string, keyDescription string, keyValue string) (int64, error) { resp, err := client.PutParameter(context.Background(), &ssm.PutParameterInput{ Name: aws.String(keyName), Description: aws.String(keyDescription), Value: aws.String(keyValue), Type: types.ParameterTypeSecureString, }) if err != nil { return 0, err } return resp.Version, nil } // DeleteParameter deletes all versions of SSM Parameter at keyName. func DeleteParameter(t testing.TestingT, awsRegion string, keyName string) { err := DeleteParameterE(t, awsRegion, keyName) require.NoError(t, err) } // DeleteParameterE deletes all versions of SSM Parameter at keyName. func DeleteParameterE(t testing.TestingT, awsRegion string, keyName string) error { ssmClient, err := NewSsmClientE(t, awsRegion) if err != nil { return err } return DeleteParameterWithClientE(t, ssmClient, keyName) } // DeleteParameterWithClientE deletes all versions of SSM Parameter at keyName with the ability to provide the SSM client. func DeleteParameterWithClientE(t testing.TestingT, client *ssm.Client, keyName string) error { _, err := client.DeleteParameter(context.Background(), &ssm.DeleteParameterInput{Name: aws.String(keyName)}) if err != nil { return err } return nil } // NewSsmClient creates an SSM client. func NewSsmClient(t testing.TestingT, region string) *ssm.Client { client, err := NewSsmClientE(t, region) require.NoError(t, err) return client } // NewSsmClientE creates an SSM client. func NewSsmClientE(t testing.TestingT, region string) (*ssm.Client, error) { sess, err := NewAuthenticatedSession(region) if err != nil { return nil, err } return ssm.NewFromConfig(*sess), nil } // WaitForSsmInstanceE waits until the instance get registered to the SSM inventory. func WaitForSsmInstanceE(t testing.TestingT, awsRegion, instanceID string, timeout time.Duration) error { client, err := NewSsmClientE(t, awsRegion) if err != nil { return err } return WaitForSsmInstanceWithClientE(t, client, instanceID, timeout) } // WaitForSsmInstanceWithClientE waits until the instance get registered to the SSM inventory with the ability to provide the SSM client. func WaitForSsmInstanceWithClientE(t testing.TestingT, client *ssm.Client, instanceID string, timeout time.Duration) error { timeBetweenRetries := 2 * time.Second maxRetries := int(timeout.Seconds() / timeBetweenRetries.Seconds()) description := fmt.Sprintf("Waiting for %s to appear in the SSM inventory", instanceID) input := &ssm.GetInventoryInput{ Filters: []types.InventoryFilter{ { Key: aws.String("AWS:InstanceInformation.InstanceId"), Type: types.InventoryQueryOperatorTypeEqual, Values: []string{instanceID}, }, }, } _, err := retry.DoWithRetryE(t, description, maxRetries, timeBetweenRetries, func() (string, error) { resp, err := client.GetInventory(context.Background(), input) if err != nil { return "", err } if len(resp.Entities) != 1 { return "", fmt.Errorf("%s is not in the SSM inventory", instanceID) } return "", nil }) return err } // WaitForSsmInstance waits until the instance get registered to the SSM inventory. func WaitForSsmInstance(t testing.TestingT, awsRegion, instanceID string, timeout time.Duration) { err := WaitForSsmInstanceE(t, awsRegion, instanceID, timeout) require.NoError(t, err) } // CheckSsmCommand checks that you can run the given command on the given instance through AWS SSM. func CheckSsmCommand(t testing.TestingT, awsRegion, instanceID, command string, timeout time.Duration) *CommandOutput { return CheckSsmCommandWithDocument(t, awsRegion, instanceID, command, "AWS-RunShellScript", timeout) } // CommandOutput contains the result of the SSM command. type CommandOutput struct { Stdout string Stderr string ExitCode int64 } // CheckSsmCommandE checks that you can run the given command on the given instance through AWS SSM. Returns the result and an error if one occurs. func CheckSsmCommandE(t testing.TestingT, awsRegion, instanceID, command string, timeout time.Duration) (*CommandOutput, error) { return CheckSsmCommandWithDocumentE(t, awsRegion, instanceID, command, "AWS-RunShellScript", timeout) } // CheckSSMCommandWithClientE checks that you can run the given command on the given instance through AWS SSM with the ability to provide the SSM client. Returns the result and an error if one occurs. func CheckSSMCommandWithClientE(t testing.TestingT, client *ssm.Client, instanceID, command string, timeout time.Duration) (*CommandOutput, error) { return CheckSSMCommandWithClientWithDocumentE(t, client, instanceID, command, "AWS-RunShellScript", timeout) } // CheckSsmCommandWithDocument checks that you can run the given command on the given instance through AWS SSM with specified Command Doc type. func CheckSsmCommandWithDocument(t testing.TestingT, awsRegion, instanceID, command string, commandDocName string, timeout time.Duration) *CommandOutput { result, err := CheckSsmCommandWithDocumentE(t, awsRegion, instanceID, command, commandDocName, timeout) require.NoErrorf(t, err, "failed to execute '%s' on %s (%v):]\n stdout: %#v\n stderr: %#v", command, instanceID, err, result.Stdout, result.Stderr) return result } // CheckSsmCommandWithDocumentE checks that you can run the given command on the given instance through AWS SSM with specified Command Doc type. Returns the result and an error if one occurs. func CheckSsmCommandWithDocumentE(t testing.TestingT, awsRegion, instanceID, command string, commandDocName string, timeout time.Duration) (*CommandOutput, error) { logger.Default.Logf(t, "Running command '%s' on EC2 instance with ID '%s'", command, instanceID) // Now that we know the instance in the SSM inventory, we can send the command client, err := NewSsmClientE(t, awsRegion) if err != nil { return nil, err } return CheckSSMCommandWithClientWithDocumentE(t, client, instanceID, command, commandDocName, timeout) } // CheckSSMCommandWithClientWithDocumentE checks that you can run the given command on the given instance through AWS SSM with the ability to provide the SSM client with specified Command Doc type. Returns the result and an error if one occurs. func CheckSSMCommandWithClientWithDocumentE(t testing.TestingT, client *ssm.Client, instanceID, command string, commandDocName string, timeout time.Duration) (*CommandOutput, error) { timeBetweenRetries := 2 * time.Second maxRetries := int(timeout.Seconds() / timeBetweenRetries.Seconds()) resp, err := client.SendCommand( context.Background(), &ssm.SendCommandInput{ Comment: aws.String("Terratest SSM"), DocumentName: aws.String(commandDocName), InstanceIds: []string{instanceID}, Parameters: map[string][]string{ "commands": {command}, }, }, ) if err != nil { return nil, err } // Wait for the result description := "Waiting for the result of the command" retryableErrors := map[string]string{ "InvocationDoesNotExist": "InvocationDoesNotExist", "bad status: Pending": "bad status: Pending", "bad status: InProgress": "bad status: InProgress", "bad status: Delayed": "bad status: Delayed", } result := &CommandOutput{} _, err = retry.DoWithRetryableErrorsE(t, description, retryableErrors, maxRetries, timeBetweenRetries, func() (string, error) { resp, err := client.GetCommandInvocation(context.Background(), &ssm.GetCommandInvocationInput{ CommandId: resp.Command.CommandId, InstanceId: &instanceID, }) if err != nil { return "", err } result.Stderr = aws.ToString(resp.StandardErrorContent) result.Stdout = aws.ToString(resp.StandardOutputContent) result.ExitCode = int64(resp.ResponseCode) status := resp.Status if status == types.CommandInvocationStatusSuccess { return "", nil } if status == types.CommandInvocationStatusFailed { return "", fmt.Errorf("%s", aws.ToString(resp.StatusDetails)) } return "", fmt.Errorf("bad status: %s", status) }) if err != nil { var actualErr retry.FatalError if errors.As(err, &actualErr) { return result, actualErr.Underlying } return result, fmt.Errorf("unexpected error: %v", err) } return result, nil } ================================================ FILE: modules/aws/ssm_test.go ================================================ package aws import ( "fmt" "testing" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/random" "github.com/stretchr/testify/assert" ) func TestParameterIsFound(t *testing.T) { t.Parallel() expectedName := fmt.Sprintf("test-name-%s", random.UniqueId()) awsRegion := GetRandomRegion(t, nil, nil) expectedValue := fmt.Sprintf("test-value-%s", random.UniqueId()) expectedDescription := fmt.Sprintf("test-description-%s", random.UniqueId()) version := PutParameter(t, awsRegion, expectedName, expectedDescription, expectedValue) logger.Default.Logf(t, "Created parameter with version %d", version) keyValue := GetParameter(t, awsRegion, expectedName) logger.Default.Logf(t, "Found key with name %s", expectedName) assert.Equal(t, expectedValue, keyValue) } func TestParameterIsDeleted(t *testing.T) { expectedName := fmt.Sprintf("test-name-%s", random.UniqueId()) awsRegion := GetRandomRegion(t, nil, nil) expectedValue := fmt.Sprintf("test-value-%s", random.UniqueId()) expectedDescription := fmt.Sprintf("test-description-%s", random.UniqueId()) version := PutParameter(t, awsRegion, expectedName, expectedDescription, expectedValue) logger.Default.Logf(t, "Created parameter with version %d", version) DeleteParameter(t, awsRegion, expectedName) logger.Default.Logf(t, "Deleted paramter %s", expectedName) actualValue, err := GetParameterE(t, awsRegion, expectedName) assert.Equal(t, actualValue, "") assert.Error(t, err) } ================================================ FILE: modules/aws/vpc.go ================================================ package aws import ( "context" "fmt" "strconv" "strings" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // Vpc is an Amazon Virtual Private Cloud. type Vpc struct { Id string // The ID of the VPC Name string // The name of the VPC Subnets []Subnet // A list of subnets in the VPC Tags map[string]string // The tags associated with the VPC CidrBlock *string // The primary IPv4 CIDR block for the VPC. CidrAssociations []*string // Information about the IPv4 CIDR blocks associated with the VPC. Ipv6CidrAssociations []*string // Information about the IPv6 CIDR blocks associated with the VPC. } // Subnet is a subnet in an availability zone. type Subnet struct { Id string // The ID of the Subnet AvailabilityZone string // The Availability Zone the subnet is in DefaultForAz bool // If the subnet is default for the Availability Zone Tags map[string]string // The tags associated with the subnet CidrBlock string // The CIDR block associated with the subnet } const vpcIDFilterName = "vpc-id" const defaultForAzFilterName = "default-for-az" const resourceTypeFilterName = "resource-type" const resourceIdFilterName = "resource-id" const vpcResourceTypeFilterValue = "vpc" const subnetResourceTypeFilterValue = "subnet" const isDefaultFilterName = "isDefault" const isDefaultFilterValue = "true" const defaultVPCName = "Default" // GetDefaultVpc fetches information about the default VPC in the given region. func GetDefaultVpc(t testing.TestingT, region string) *Vpc { vpc, err := GetDefaultVpcE(t, region) require.NoError(t, err) return vpc } // GetDefaultVpcE fetches information about the default VPC in the given region. func GetDefaultVpcE(t testing.TestingT, region string) (*Vpc, error) { defaultVpcFilter := types.Filter{Name: aws.String(isDefaultFilterName), Values: []string{isDefaultFilterValue}} vpcs, err := GetVpcsE(t, []types.Filter{defaultVpcFilter}, region) numVpcs := len(vpcs) if numVpcs != 1 { return nil, fmt.Errorf("expected to find one default VPC in region %s but found %s", region, strconv.Itoa(numVpcs)) } return vpcs[0], err } // GetVpcById fetches information about a VPC with given ID in the given region. func GetVpcById(t testing.TestingT, vpcId string, region string) *Vpc { vpc, err := GetVpcByIdE(t, vpcId, region) require.NoError(t, err) return vpc } // GetVpcByIdE fetches information about a VPC with given ID in the given region. func GetVpcByIdE(t testing.TestingT, vpcId string, region string) (*Vpc, error) { vpcIdFilter := types.Filter{Name: aws.String(vpcIDFilterName), Values: []string{vpcId}} vpcs, err := GetVpcsE(t, []types.Filter{vpcIdFilter}, region) numVpcs := len(vpcs) if numVpcs != 1 { return nil, fmt.Errorf("expected to find one VPC with ID %s in region %s but found %s", vpcId, region, strconv.Itoa(numVpcs)) } return vpcs[0], err } // GetVpcsE fetches information about VPCs from given regions limited by filters func GetVpcsE(t testing.TestingT, filters []types.Filter, region string) ([]*Vpc, error) { client, err := NewEc2ClientE(t, region) if err != nil { return nil, err } vpcs, err := client.DescribeVpcs(context.Background(), &ec2.DescribeVpcsInput{Filters: filters}) if err != nil { return nil, err } numVpcs := len(vpcs.Vpcs) retVal := make([]*Vpc, numVpcs) for i, vpc := range vpcs.Vpcs { vpcIdFilter := generateVpcIdFilter(aws.ToString(vpc.VpcId)) subnets, err := GetSubnetsForVpcE(t, region, []types.Filter{vpcIdFilter}) if err != nil { return nil, err } tags, err := GetTagsForVpcE(t, aws.ToString(vpc.VpcId), region) if err != nil { return nil, err } // cidr block associations var cidrBlockAssociations = func() (list []*string) { for _, cidr := range vpc.CidrBlockAssociationSet { list = append(list, cidr.CidrBlock) } return }() // ipv6 cidr block associations var Ipv6CidrAssociations = func() (list []*string) { for _, cidr := range vpc.Ipv6CidrBlockAssociationSet { list = append(list, cidr.Ipv6CidrBlock) } return }() retVal[i] = &Vpc{ Id: aws.ToString(vpc.VpcId), Name: FindVpcName(vpc), Subnets: subnets, Tags: tags, CidrBlock: vpc.CidrBlock, CidrAssociations: cidrBlockAssociations, Ipv6CidrAssociations: Ipv6CidrAssociations, } } return retVal, nil } // FindVpcName extracts the VPC name from its tags (if any). Fall back to "Default" if it's the default VPC or empty string // otherwise. func FindVpcName(vpc types.Vpc) string { for _, tag := range vpc.Tags { if *tag.Key == "Name" { return *tag.Value } } if *vpc.IsDefault { return defaultVPCName } return "" } // GetSubnetsForVpc gets the subnets in the specified VPC. func GetSubnetsForVpc(t testing.TestingT, vpcID string, region string) []Subnet { vpcIDFilter := generateVpcIdFilter(vpcID) subnets, err := GetSubnetsForVpcE(t, region, []types.Filter{vpcIDFilter}) if err != nil { t.Fatal(err) } return subnets } // GetAzDefaultSubnetsForVpc gets the default az subnets in the specified VPC. func GetAzDefaultSubnetsForVpc(t testing.TestingT, vpcID string, region string) []Subnet { vpcIDFilter := generateVpcIdFilter(vpcID) defaultForAzFilter := types.Filter{ Name: aws.String(defaultForAzFilterName), Values: []string{"true"}, } subnets, err := GetSubnetsForVpcE(t, region, []types.Filter{vpcIDFilter, defaultForAzFilter}) if err != nil { t.Fatal(err) } return subnets } // generateVpcIdFilter is a helper method to generate vpc id filter func generateVpcIdFilter(vpcID string) types.Filter { return types.Filter{Name: aws.String(vpcIDFilterName), Values: []string{vpcID}} } // GetSubnetsForVpcE gets the subnets in the specified VPC. func GetSubnetsForVpcE(t testing.TestingT, region string, filters []types.Filter) ([]Subnet, error) { client, err := NewEc2ClientE(t, region) if err != nil { return nil, err } subnetOutput, err := client.DescribeSubnets(context.Background(), &ec2.DescribeSubnetsInput{Filters: filters}) if err != nil { return nil, err } var subnets []Subnet for _, ec2Subnet := range subnetOutput.Subnets { subnetTags := GetTagsForSubnet(t, *ec2Subnet.SubnetId, region) subnet := Subnet{Id: aws.ToString(ec2Subnet.SubnetId), AvailabilityZone: aws.ToString(ec2Subnet.AvailabilityZone), DefaultForAz: aws.ToBool(ec2Subnet.DefaultForAz), Tags: subnetTags, CidrBlock: aws.ToString(ec2Subnet.CidrBlock)} subnets = append(subnets, subnet) } return subnets, nil } // GetTagsForVpc gets the tags for the specified VPC. func GetTagsForVpc(t testing.TestingT, vpcID string, region string) map[string]string { tags, err := GetTagsForVpcE(t, vpcID, region) require.NoError(t, err) return tags } // GetTagsForVpcE gets the tags for the specified VPC. func GetTagsForVpcE(t testing.TestingT, vpcID string, region string) (map[string]string, error) { client, err := NewEc2ClientE(t, region) require.NoError(t, err) vpcResourceTypeFilter := types.Filter{Name: aws.String(resourceTypeFilterName), Values: []string{vpcResourceTypeFilterValue}} vpcResourceIdFilter := types.Filter{Name: aws.String(resourceIdFilterName), Values: []string{vpcID}} tagsOutput, err := client.DescribeTags(context.Background(), &ec2.DescribeTagsInput{Filters: []types.Filter{vpcResourceTypeFilter, vpcResourceIdFilter}}) require.NoError(t, err) tags := map[string]string{} for _, tag := range tagsOutput.Tags { tags[aws.ToString(tag.Key)] = aws.ToString(tag.Value) } return tags, nil } // GetDefaultSubnetIDsForVpc gets the ids of the subnets that are the default subnet for the AvailabilityZone func GetDefaultSubnetIDsForVpc(t testing.TestingT, vpc Vpc) []string { subnetIDs, err := GetDefaultSubnetIDsForVpcE(t, vpc) require.NoError(t, err) return subnetIDs } // GetDefaultSubnetIDsForVpcE gets the ids of the subnets that are the default subnet for the AvailabilityZone func GetDefaultSubnetIDsForVpcE(t testing.TestingT, vpc Vpc) ([]string, error) { if vpc.Name != defaultVPCName { // You cannot create a default subnet in a nondefault VPC // https://docs.aws.amazon.com/vpc/latest/userguide/default-vpc.html return nil, fmt.Errorf("only default VPCs have default subnets but VPC with id %s is not default VPC", vpc.Id) } var subnetIDs []string numSubnets := len(vpc.Subnets) if numSubnets == 0 { return nil, fmt.Errorf("expected to find at least one subnet in vpc with ID %s but found zero", vpc.Id) } for _, subnet := range vpc.Subnets { if subnet.DefaultForAz { subnetIDs = append(subnetIDs, subnet.Id) } } return subnetIDs, nil } // GetTagsForSubnet gets the tags for the specified subnet. func GetTagsForSubnet(t testing.TestingT, subnetId string, region string) map[string]string { tags, err := GetTagsForSubnetE(t, subnetId, region) require.NoError(t, err) return tags } // GetTagsForSubnetE gets the tags for the specified subnet. func GetTagsForSubnetE(t testing.TestingT, subnetId string, region string) (map[string]string, error) { client, err := NewEc2ClientE(t, region) require.NoError(t, err) subnetResourceTypeFilter := types.Filter{Name: aws.String(resourceTypeFilterName), Values: []string{subnetResourceTypeFilterValue}} subnetResourceIdFilter := types.Filter{Name: aws.String(resourceIdFilterName), Values: []string{subnetId}} tagsOutput, err := client.DescribeTags(context.Background(), &ec2.DescribeTagsInput{Filters: []types.Filter{subnetResourceTypeFilter, subnetResourceIdFilter}}) require.NoError(t, err) tags := map[string]string{} for _, tag := range tagsOutput.Tags { tags[aws.ToString(tag.Key)] = aws.ToString(tag.Value) } return tags, nil } // IsPublicSubnet returns True if the subnet identified by the given id in the provided region is public. func IsPublicSubnet(t testing.TestingT, subnetId string, region string) bool { isPublic, err := IsPublicSubnetE(t, subnetId, region) require.NoError(t, err) return isPublic } // IsPublicSubnetE returns True if the subnet identified by the given id in the provided region is public. func IsPublicSubnetE(t testing.TestingT, subnetId string, region string) (bool, error) { subnetIdFilterName := "association.subnet-id" subnetIdFilter := types.Filter{ Name: &subnetIdFilterName, Values: []string{subnetId}, } client, err := NewEc2ClientE(t, region) if err != nil { return false, err } rts, err := client.DescribeRouteTables(context.Background(), &ec2.DescribeRouteTablesInput{Filters: []types.Filter{subnetIdFilter}}) if err != nil { return false, err } if len(rts.RouteTables) == 0 { // Subnets not explicitly associated with any route table are implicitly associated with the main route table rts, err = getImplicitRouteTableForSubnetE(t, subnetId, region) if err != nil { return false, err } } for _, rt := range rts.RouteTables { for _, r := range rt.Routes { if strings.HasPrefix(aws.ToString(r.GatewayId), "igw-") { return true, nil } } } return false, nil } func getImplicitRouteTableForSubnetE(t testing.TestingT, subnetId string, region string) (*ec2.DescribeRouteTablesOutput, error) { mainRouteFilterName := "association.main" mainRouteFilterValue := "true" subnetFilterName := "subnet-id" client, err := NewEc2ClientE(t, region) if err != nil { return nil, err } subnetFilter := types.Filter{ Name: &subnetFilterName, Values: []string{subnetId}, } subnetOutput, err := client.DescribeSubnets(context.Background(), &ec2.DescribeSubnetsInput{Filters: []types.Filter{subnetFilter}}) if err != nil { return nil, err } numSubnets := len(subnetOutput.Subnets) if numSubnets != 1 { return nil, fmt.Errorf("expected to find one subnet with id %s but found %s", subnetId, strconv.Itoa(numSubnets)) } mainRouteFilter := types.Filter{ Name: &mainRouteFilterName, Values: []string{mainRouteFilterValue}, } vpcFilter := types.Filter{ Name: aws.String(vpcIDFilterName), Values: []string{*subnetOutput.Subnets[0].VpcId}, } return client.DescribeRouteTables(context.Background(), &ec2.DescribeRouteTablesInput{Filters: []types.Filter{mainRouteFilter, vpcFilter}}) } // GetRandomPrivateCidrBlock gets a random CIDR block from the range of acceptable private IP addresses per RFC 1918 // (https://tools.ietf.org/html/rfc1918#section-3) // The routingPrefix refers to the "/28" in 1.2.3.4/28. // Note that, as written, this function will return a subset of all valid ranges. Since we will probably use this function // mostly for generating random CIDR ranges for VPCs and Subnets, having comprehensive set coverage is not essential. func GetRandomPrivateCidrBlock(routingPrefix int) string { var o1, o2, o3, o4 int switch routingPrefix { case 32: o1 = random.RandomInt([]int{10, 172, 192}) switch o1 { case 10: o2 = random.Random(0, 255) o3 = random.Random(0, 255) o4 = random.Random(0, 255) case 172: o2 = random.Random(16, 31) o3 = random.Random(0, 255) o4 = random.Random(0, 255) case 192: o2 = 168 o3 = random.Random(0, 255) o4 = random.Random(0, 255) } case 31, 30, 29, 28, 27, 26, 25: fallthrough case 24: o1 = random.RandomInt([]int{10, 172, 192}) switch o1 { case 10: o2 = random.Random(0, 255) o3 = random.Random(0, 255) o4 = 0 case 172: o2 = 16 o3 = 0 o4 = 0 case 192: o2 = 168 o3 = 0 o4 = 0 } case 23, 22, 21, 20, 19: fallthrough case 18: o1 = random.RandomInt([]int{10, 172, 192}) switch o1 { case 10: o2 = 0 o3 = 0 o4 = 0 case 172: o2 = 16 o3 = 0 o4 = 0 case 192: o2 = 168 o3 = 0 o4 = 0 } } return fmt.Sprintf("%d.%d.%d.%d/%d", o1, o2, o3, o4, routingPrefix) } // GetFirstTwoOctets gets the first two octets from a CIDR block. func GetFirstTwoOctets(cidrBlock string) string { ipAddr := strings.Split(cidrBlock, "/")[0] octets := strings.Split(ipAddr, ".") return octets[0] + "." + octets[1] } ================================================ FILE: modules/aws/vpc_test.go ================================================ package aws import ( "context" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" ) func TestGetDefaultVpc(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) vpc := GetDefaultVpc(t, region) assert.NotEmpty(t, vpc.Name) assert.True(t, len(vpc.Subnets) > 0) assert.Regexp(t, "^vpc-[[:alnum:]]+$", vpc.Id) } func TestGetVpcById(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) vpc := createVpc(t, region) defer deleteVpc(t, *vpc.VpcId, region) vpcTest := GetVpcById(t, *vpc.VpcId, region) assert.Equal(t, *vpc.VpcId, vpcTest.Id) assert.NotEmpty(t, vpcTest.CidrAssociations) } func TestGetVpcsE(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) azs := GetAvailabilityZones(t, region) isDefaultFilterName := "isDefault" isDefaultFilterValue := "true" defaultVpcFilter := types.Filter{Name: &isDefaultFilterName, Values: []string{isDefaultFilterValue}} vpcs, _ := GetVpcsE(t, []types.Filter{defaultVpcFilter}, region) require.Equal(t, len(vpcs), 1) assert.NotEmpty(t, vpcs[0].Name) // the default VPC has by default one subnet per availability zone // https://docs.aws.amazon.com/vpc/latest/userguide/default-vpc.html assert.True(t, len(vpcs[0].Subnets) >= len(azs)) } func TestGetFirstTwoOctets(t *testing.T) { t.Parallel() firstTwo := GetFirstTwoOctets("10.100.0.0/28") if firstTwo != "10.100" { t.Errorf("Received: %s, Expected: 10.100", firstTwo) } } func TestIsPublicSubnet(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) vpc := createVpc(t, region) defer deleteVpc(t, *vpc.VpcId, region) routeTable := createRouteTable(t, *vpc.VpcId, region) subnet := createSubnet(t, *vpc.VpcId, *routeTable.RouteTableId, region) assert.False(t, IsPublicSubnet(t, *subnet.SubnetId, region)) createPublicRoute(t, *vpc.VpcId, *routeTable.RouteTableId, region) assert.True(t, IsPublicSubnet(t, *subnet.SubnetId, region)) } func TestGetDefaultSubnetIDsForVpc(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) defaultVpc := GetDefaultVpc(t, region) defaultSubnetIDs := GetDefaultSubnetIDsForVpc(t, *defaultVpc) assert.NotEmpty(t, defaultSubnetIDs) availabilityZones := []string{} for _, id := range defaultSubnetIDs { // default subnets are by default public // https://docs.aws.amazon.com/vpc/latest/userguide/default-vpc.html assert.True(t, IsPublicSubnet(t, id, region)) for _, subnet := range defaultVpc.Subnets { if id == subnet.Id { availabilityZones = append(availabilityZones, subnet.AvailabilityZone) } } } // only one default subnet is allowed per AZ uniqueAZs := map[string]bool{} for _, az := range availabilityZones { uniqueAZs[az] = true } assert.Equal(t, len(defaultSubnetIDs), len(uniqueAZs)) } func TestGetTagsForVpc(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) vpc := createVpc(t, region) defer deleteVpc(t, *vpc.VpcId, region) noTags := GetTagsForVpc(t, *vpc.VpcId, region) assert.True(t, len(vpc.Tags) == 0) assert.True(t, len(noTags) == 0) testTags := make(map[string]string) testTags["TagKey1"] = "TagValue1" testTags["TagKey2"] = "TagValue2" AddTagsToResource(t, region, *vpc.VpcId, testTags) vpcWithTags := GetVpcById(t, *vpc.VpcId, region) tags := GetTagsForVpc(t, *vpc.VpcId, region) assert.True(t, len(vpcWithTags.Tags) == len(testTags)) assert.True(t, len(tags) == len(testTags)) } func TestGetTagsForSubnet(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) vpc := createVpc(t, region) defer deleteVpc(t, *vpc.VpcId, region) routeTable := createRouteTable(t, *vpc.VpcId, region) subnet := createSubnet(t, *vpc.VpcId, *routeTable.RouteTableId, region) noTags := GetTagsForSubnet(t, *subnet.SubnetId, region) assert.True(t, len(subnet.Tags) == 0) assert.True(t, len(noTags) == 0) testTags := make(map[string]string) testTags["TagKey1"] = "TagValue1" testTags["TagKey2"] = "TagValue2" AddTagsToResource(t, region, *subnet.SubnetId, testTags) subnetWithTags := GetSubnetsForVpc(t, *vpc.VpcId, region)[0] tags := GetTagsForSubnet(t, *subnet.SubnetId, region) assert.True(t, len(subnetWithTags.Tags) == len(testTags)) assert.True(t, len(tags) == len(testTags)) assert.True(t, testTags["TagKey1"] == "TagValue1") assert.True(t, testTags["TagKey2"] == "TagValue2") } func TestGetDefaultAzSubnets(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) vpc := GetDefaultVpc(t, region) // Note: cannot know exact list of default azs aheard of time, but we know that // it must be greater than 0 for default vpc. subnets := GetAzDefaultSubnetsForVpc(t, vpc.Id, region) assert.NotZero(t, len(subnets)) } func createPublicRoute(t *testing.T, vpcId string, routeTableId string, region string) { ec2Client := NewEc2Client(t, region) createIGWOut, igerr := ec2Client.CreateInternetGateway(context.Background(), &ec2.CreateInternetGatewayInput{}) require.NoError(t, igerr) _, aigerr := ec2Client.AttachInternetGateway(context.Background(), &ec2.AttachInternetGatewayInput{ InternetGatewayId: createIGWOut.InternetGateway.InternetGatewayId, VpcId: aws.String(vpcId), }) require.NoError(t, aigerr) _, err := ec2Client.CreateRoute(context.Background(), &ec2.CreateRouteInput{ RouteTableId: aws.String(routeTableId), DestinationCidrBlock: aws.String("0.0.0.0/0"), GatewayId: createIGWOut.InternetGateway.InternetGatewayId, }) require.NoError(t, err) } func createRouteTable(t *testing.T, vpcId string, region string) types.RouteTable { ec2Client := NewEc2Client(t, region) createRouteTableOutput, err := ec2Client.CreateRouteTable(context.Background(), &ec2.CreateRouteTableInput{ VpcId: aws.String(vpcId), }) require.NoError(t, err) return *createRouteTableOutput.RouteTable } func createSubnet(t *testing.T, vpcId string, routeTableId string, region string) types.Subnet { ec2Client := NewEc2Client(t, region) createSubnetOutput, err := ec2Client.CreateSubnet(context.Background(), &ec2.CreateSubnetInput{ CidrBlock: aws.String("10.10.1.0/24"), VpcId: aws.String(vpcId), }) require.NoError(t, err) _, err = ec2Client.AssociateRouteTable(context.Background(), &ec2.AssociateRouteTableInput{ RouteTableId: aws.String(routeTableId), SubnetId: aws.String(*createSubnetOutput.Subnet.SubnetId), }) require.NoError(t, err) return *createSubnetOutput.Subnet } func createVpc(t *testing.T, region string) types.Vpc { ec2Client := NewEc2Client(t, region) createVpcOutput, err := ec2Client.CreateVpc(context.Background(), &ec2.CreateVpcInput{ CidrBlock: aws.String("10.10.0.0/16"), }) require.NoError(t, err) return *createVpcOutput.Vpc } func deleteRouteTables(t *testing.T, vpcId string, region string) { ec2Client := NewEc2Client(t, region) vpcIDFilterName := "vpc-id" vpcIDFilter := types.Filter{Name: &vpcIDFilterName, Values: []string{vpcId}} // "You can't delete the main route table." mainRTFilterName := "association.main" mainRTFilterValue := "false" notMainRTFilter := types.Filter{Name: &mainRTFilterName, Values: []string{mainRTFilterValue}} filters := []types.Filter{vpcIDFilter, notMainRTFilter} rtOutput, err := ec2Client.DescribeRouteTables(context.Background(), &ec2.DescribeRouteTablesInput{Filters: filters}) require.NoError(t, err) for _, rt := range rtOutput.RouteTables { // "You must disassociate the route table from any subnets before you can delete it." for _, assoc := range rt.Associations { _, disassocErr := ec2Client.DisassociateRouteTable(context.Background(), &ec2.DisassociateRouteTableInput{ AssociationId: assoc.RouteTableAssociationId, }) require.NoError(t, disassocErr) } _, err := ec2Client.DeleteRouteTable(context.Background(), &ec2.DeleteRouteTableInput{ RouteTableId: rt.RouteTableId, }) require.NoError(t, err) } } func deleteSubnets(t *testing.T, vpcId string, region string) { ec2Client := NewEc2Client(t, region) vpcIDFilterName := "vpc-id" vpcIDFilter := types.Filter{Name: &vpcIDFilterName, Values: []string{vpcId}} subnetsOutput, err := ec2Client.DescribeSubnets(context.Background(), &ec2.DescribeSubnetsInput{Filters: []types.Filter{vpcIDFilter}}) require.NoError(t, err) for _, subnet := range subnetsOutput.Subnets { _, err := ec2Client.DeleteSubnet(context.Background(), &ec2.DeleteSubnetInput{ SubnetId: subnet.SubnetId, }) require.NoError(t, err) } } func deleteInternetGateways(t *testing.T, vpcId string, region string) { ec2Client := NewEc2Client(t, region) vpcIDFilterName := "attachment.vpc-id" vpcIDFilter := types.Filter{Name: &vpcIDFilterName, Values: []string{vpcId}} igwOutput, err := ec2Client.DescribeInternetGateways(context.Background(), &ec2.DescribeInternetGatewaysInput{Filters: []types.Filter{vpcIDFilter}}) require.NoError(t, err) for _, igw := range igwOutput.InternetGateways { _, detachErr := ec2Client.DetachInternetGateway(context.Background(), &ec2.DetachInternetGatewayInput{ InternetGatewayId: igw.InternetGatewayId, VpcId: aws.String(vpcId), }) require.NoError(t, detachErr) _, err := ec2Client.DeleteInternetGateway(context.Background(), &ec2.DeleteInternetGatewayInput{ InternetGatewayId: igw.InternetGatewayId, }) require.NoError(t, err) } } func deleteVpc(t *testing.T, vpcId string, region string) { ec2Client := NewEc2Client(t, region) deleteRouteTables(t, vpcId, region) deleteSubnets(t, vpcId, region) deleteInternetGateways(t, vpcId, region) _, err := ec2Client.DeleteVpc(context.Background(), &ec2.DeleteVpcInput{ VpcId: aws.String(vpcId), }) require.NoError(t, err) } ================================================ FILE: modules/azure/actiongroup.go ================================================ package azure import ( "context" "testing" "github.com/Azure/azure-sdk-for-go/profiles/preview/preview/monitor/mgmt/insights" "github.com/stretchr/testify/require" ) // GetActionGroupResource gets the ActionGroupResource. // ruleName - required to find the ActionGroupResource. // resGroupName - use an empty string if you have the AZURE_RES_GROUP_NAME environment variable set // subscriptionId - use an empty string if you have the ARM_SUBSCRIPTION_ID environment variable set func GetActionGroupResource(t *testing.T, ruleName string, resGroupName string, subscriptionID string) *insights.ActionGroupResource { actionGroupResource, err := GetActionGroupResourceE(ruleName, resGroupName, subscriptionID) require.NoError(t, err) return actionGroupResource } // GetActionGroupResourceE gets the ActionGroupResource with Error details on error. // ruleName - required to find the ActionGroupResource. // resGroupName - use an empty string if you have the AZURE_RES_GROUP_NAME environment variable set // subscriptionId - use an empty string if you have the ARM_SUBSCRIPTION_ID environment variable set func GetActionGroupResourceE(ruleName string, resGroupName string, subscriptionID string) (*insights.ActionGroupResource, error) { rgName, err := getTargetAzureResourceGroupName(resGroupName) if err != nil { return nil, err } client, err := CreateActionGroupClient(subscriptionID) if err != nil { return nil, err } actionGroup, err := client.Get(context.Background(), rgName, ruleName) if err != nil { return nil, err } return &actionGroup, nil } // TODO: remove in next version func getActionGroupClient(subscriptionID string) (*insights.ActionGroupsClient, error) { subID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } metricAlertsClient := insights.NewActionGroupsClient(subID) authorizer, err := NewAuthorizer() if err != nil { return nil, err } metricAlertsClient.Authorizer = *authorizer return &metricAlertsClient, nil } ================================================ FILE: modules/azure/actiongroup_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package azure import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) /* The below tests are currently stubbed out, with the expectation that they will throw errors. If/when methods to create and delete network resources are added, these tests can be extended. */ func TestGetActionGroupResourceEWithMissingResourceGroupName(t *testing.T) { t.Parallel() ruleName := "Hello" resGroupName := "" subscriptionID := "" _, err := GetActionGroupResourceE(ruleName, resGroupName, subscriptionID) require.Error(t, err) } func TestGetActionGroupResourceEWithInvalidResourceGroupName(t *testing.T) { t.Parallel() ruleName := "" resGroupName := "Hello" subscriptionID := "" _, err := GetActionGroupResourceE(ruleName, resGroupName, subscriptionID) require.Error(t, err) } func TestGetActionGroupClient(t *testing.T) { t.Parallel() subscriptionID := "" client, err := getActionGroupClient(subscriptionID) require.NoError(t, err) assert.NotEmpty(t, *client) } ================================================ FILE: modules/azure/aks.go ================================================ package azure import ( "context" "github.com/Azure/azure-sdk-for-go/services/containerservice/mgmt/2019-11-01/containerservice" "github.com/gruntwork-io/terratest/modules/testing" ) // GetManagedClustersClientE is a helper function that will setup an Azure ManagedClusters client on your behalf func GetManagedClustersClientE(subscriptionID string) (*containerservice.ManagedClustersClient, error) { // Create a cluster client client, err := CreateManagedClustersClientE(subscriptionID) if err != nil { return nil, err } // setup authorizer authorizer, err := NewAuthorizer() if err != nil { return nil, err } client.Authorizer = *authorizer return &client, nil } // GetManagedClusterE will return ManagedCluster func GetManagedClusterE(t testing.TestingT, resourceGroupName, clusterName, subscriptionID string) (*containerservice.ManagedCluster, error) { subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } client, err := GetManagedClustersClientE(subscriptionID) if err != nil { return nil, err } managedCluster, err := client.Get(context.Background(), resourceGroupName, clusterName) if err != nil { return nil, err } return &managedCluster, nil } ================================================ FILE: modules/azure/appService.go ================================================ package azure import ( "context" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2" "github.com/stretchr/testify/require" ) // AppExists indicates whether the specified application exists. // This function would fail the test if there is an error. func AppExists(t *testing.T, appName string, resourceGroupName string, subscriptionID string) bool { exists, err := AppExistsE(appName, resourceGroupName, subscriptionID) require.NoError(t, err) return exists } // AppExistsE indicates whether the specified application exists. func AppExistsE(appName string, resourceGroupName string, subscriptionID string) (bool, error) { _, err := GetAppServiceE(appName, resourceGroupName, subscriptionID) if err != nil { if ResourceNotFoundErrorExists(err) { return false, nil } return false, err } return true, nil } // GetAppService gets the App service object // This function would fail the test if there is an error. func GetAppService(t *testing.T, appName string, resGroupName string, subscriptionID string) *armappservice.Site { site, err := GetAppServiceE(appName, resGroupName, subscriptionID) require.NoError(t, err) return site } // GetAppServiceE gets the App service object func GetAppServiceE(appName string, resGroupName string, subscriptionID string) (*armappservice.Site, error) { rgName, err := getTargetAzureResourceGroupName(resGroupName) if err != nil { return nil, err } client, err := GetAppServiceClientE(subscriptionID) if err != nil { return nil, err } resp, err := client.Get(context.Background(), rgName, appName, nil) if err != nil { return nil, err } return &resp.Site, nil } // GetAppServiceClientE creates and returns an App Service web apps client func GetAppServiceClientE(subscriptionID string) (*armappservice.WebAppsClient, error) { clientFactory, err := getArmAppServiceClientFactory(subscriptionID) if err != nil { return nil, err } return clientFactory.NewWebAppsClient(), nil } ================================================ FILE: modules/azure/appService_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package azure import ( "testing" "github.com/stretchr/testify/require" ) /* The below tests are currently stubbed out, with the expectation that they will throw errors. If/when CRUD methods are introduced for Azure MySQL server and database, these tests can be extended */ func TestAppExistsE(t *testing.T) { t.Parallel() resGroupName := "" appName := "" subscriptionID := "" _, err := AppExistsE(appName, resGroupName, subscriptionID) require.Error(t, err) } func TestGetAppServiceE(t *testing.T) { t.Parallel() resGroupName := "" appName := "" subscriptionID := "" _, err := GetAppServiceE(appName, resGroupName, subscriptionID) require.Error(t, err) } func TestGetAppServiceClientE(t *testing.T) { t.Parallel() subscriptionID := "" _, err := GetAppServiceClientE(subscriptionID) require.NoError(t, err) } ================================================ FILE: modules/azure/authorizer.go ================================================ package azure import ( "os" "github.com/Azure/go-autorest/autorest" az "github.com/Azure/go-autorest/autorest/azure" "github.com/Azure/go-autorest/autorest/azure/auth" ) const ( // AuthFromEnvClient is an env variable supported by the Azure SDK AuthFromEnvClient = "AZURE_CLIENT_ID" // AuthFromEnvTenant is an env variable supported by the Azure SDK AuthFromEnvTenant = "AZURE_TENANT_ID" // AuthFromFile is an env variable supported by the Azure SDK AuthFromFile = "AZURE_AUTH_LOCATION" ) // NewAuthorizer creates an Azure authorizer adhering to standard auth mechanisms provided by the Azure Go SDK // See Azure Go Auth docs here: https://docs.microsoft.com/en-us/go/azure/azure-sdk-go-authorization func NewAuthorizer() (*autorest.Authorizer, error) { // Carry out env var lookups _, clientIDExists := os.LookupEnv(AuthFromEnvClient) _, tenantIDExists := os.LookupEnv(AuthFromEnvTenant) _, fileAuthSet := os.LookupEnv(AuthFromFile) // Execute logic to return an authorizer from the correct method if clientIDExists && tenantIDExists { authorizer, err := auth.NewAuthorizerFromEnvironment() return &authorizer, err } else if fileAuthSet { authorizer, err := auth.NewAuthorizerFromFile(az.PublicCloud.ResourceManagerEndpoint) return &authorizer, err } else { authorizer, err := auth.NewAuthorizerFromCLI() return &authorizer, err } } ================================================ FILE: modules/azure/availabilityset.go ================================================ package azure import ( "context" "strings" "github.com/Azure/azure-sdk-for-go/services/compute/mgmt/2019-07-01/compute" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // AvailabilitySetExists indicates whether the specified Azure Availability Set exists. // This function would fail the test if there is an error. func AvailabilitySetExists(t testing.TestingT, avsName string, resGroupName string, subscriptionID string) bool { exists, err := AvailabilitySetExistsE(t, avsName, resGroupName, subscriptionID) require.NoError(t, err) return exists } // AvailabilitySetExistsE indicates whether the specified Azure Availability Set exists func AvailabilitySetExistsE(t testing.TestingT, avsName string, resGroupName string, subscriptionID string) (bool, error) { _, err := GetAvailabilitySetE(t, avsName, resGroupName, subscriptionID) if err != nil { if ResourceNotFoundErrorExists(err) { return false, nil } return false, err } return true, nil } // CheckAvailabilitySetContainsVM checks if the Virtual Machine is contained in the Availability Set VMs. // This function would fail the test if there is an error. func CheckAvailabilitySetContainsVM(t testing.TestingT, vmName string, avsName string, resGroupName string, subscriptionID string) bool { success, err := CheckAvailabilitySetContainsVME(t, vmName, avsName, resGroupName, subscriptionID) require.NoError(t, err) return success } // CheckAvailabilitySetContainsVME checks if the Virtual Machine is contained in the Availability Set VMs func CheckAvailabilitySetContainsVME(t testing.TestingT, vmName string, avsName string, resGroupName string, subscriptionID string) (bool, error) { client, err := CreateAvailabilitySetClientE(subscriptionID) if err != nil { return false, err } // Get the Availability Set avs, err := client.Get(context.Background(), resGroupName, avsName) if err != nil { return false, err } // Check if the VM is found in the AVS VM collection and return true for _, vm := range *avs.VirtualMachines { // VM IDs are always ALL CAPS in this property so ignoring case if strings.EqualFold(vmName, GetNameFromResourceID(*vm.ID)) { return true, nil } } return false, NewNotFoundError("Virtual Machine", vmName, avsName) } // GetAvailabilitySetVMNamesInCaps gets a list of VM names in the specified Azure Availability Set. // This function would fail the test if there is an error. func GetAvailabilitySetVMNamesInCaps(t testing.TestingT, avsName string, resGroupName string, subscriptionID string) []string { vms, err := GetAvailabilitySetVMNamesInCapsE(t, avsName, resGroupName, subscriptionID) require.NoError(t, err) return vms } // GetAvailabilitySetVMNamesInCapsE gets a list of VM names in the specified Azure Availability Set func GetAvailabilitySetVMNamesInCapsE(t testing.TestingT, avsName string, resGroupName string, subscriptionID string) ([]string, error) { client, err := CreateAvailabilitySetClientE(subscriptionID) if err != nil { return nil, err } avs, err := client.Get(context.Background(), resGroupName, avsName) if err != nil { return nil, err } vms := []string{} // Get the names for all VMs in the Availability Set for _, vm := range *avs.VirtualMachines { // IDs are returned in ALL CAPS for this property if vmName := GetNameFromResourceID(*vm.ID); len(vmName) > 0 { vms = append(vms, vmName) } } return vms, nil } // GetAvailabilitySetFaultDomainCount gets the Fault Domain Count for the specified Azure Availability Set. // This function would fail the test if there is an error. func GetAvailabilitySetFaultDomainCount(t testing.TestingT, avsName string, resGroupName string, subscriptionID string) int32 { avsFaultDomainCount, err := GetAvailabilitySetFaultDomainCountE(t, avsName, resGroupName, subscriptionID) require.NoError(t, err) return avsFaultDomainCount } // GetAvailabilitySetFaultDomainCountE gets the Fault Domain Count for the specified Azure Availability Set func GetAvailabilitySetFaultDomainCountE(t testing.TestingT, avsName string, resGroupName string, subscriptionID string) (int32, error) { avs, err := GetAvailabilitySetE(t, avsName, resGroupName, subscriptionID) if err != nil { return -1, err } return *avs.PlatformFaultDomainCount, nil } // GetAvailabilitySetE gets an Availability Set in the specified Azure Resource Group func GetAvailabilitySetE(t testing.TestingT, avsName string, resGroupName string, subscriptionID string) (*compute.AvailabilitySet, error) { // Validate resource group name and subscription ID resGroupName, err := getTargetAzureResourceGroupName(resGroupName) if err != nil { return nil, err } // Get the client reference client, err := CreateAvailabilitySetClientE(subscriptionID) if err != nil { return nil, err } // Get the Availability Set avs, err := client.Get(context.Background(), resGroupName, avsName) if err != nil { return nil, err } return &avs, nil } // GetAvailabilitySetClientE gets a new Availability Set client in the specified Azure Subscription // TODO: remove in next version func GetAvailabilitySetClientE(subscriptionID string) (*compute.AvailabilitySetsClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Get the Availability Set client client := compute.NewAvailabilitySetsClient(subscriptionID) // Create an authorizer authorizer, err := NewAuthorizer() if err != nil { return nil, err } client.Authorizer = *authorizer return &client, nil } ================================================ FILE: modules/azure/availabilityset_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package azure import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) /* The below tests are currently stubbed out, with the expectation that they will throw errors. If/when methods to create and delete network resources are added, these tests can be extended. */ func TestCreateAvailabilitySetClientE(t *testing.T) { t.Parallel() subscriptionID := "" client, err := CreateAvailabilitySetClientE(subscriptionID) require.NoError(t, err) assert.NotEmpty(t, *client) } func TestGetAvailabilitySetE(t *testing.T) { t.Parallel() avsName := "" rgName := "" subscriptionID := "" _, err := GetAvailabilitySetE(t, avsName, rgName, subscriptionID) require.Error(t, err) } func TestCheckAvailabilitySetContainsVME(t *testing.T) { t.Parallel() vmName := "" avsName := "" rgName := "" subscriptionID := "" _, err := CheckAvailabilitySetContainsVME(t, vmName, avsName, rgName, subscriptionID) require.Error(t, err) } func TestGetAvailabilitySetVMNamesInCapsE(t *testing.T) { t.Parallel() avsName := "" rgName := "" subscriptionID := "" _, err := GetAvailabilitySetVMNamesInCapsE(t, avsName, rgName, subscriptionID) require.Error(t, err) } func TestGetAvailabilitySetFaultDomainCountE(t *testing.T) { t.Parallel() avsName := "" rgName := "" subscriptionID := "" _, err := GetAvailabilitySetFaultDomainCountE(t, avsName, rgName, subscriptionID) require.Error(t, err) } func TestAvailabilitySetExistsE(t *testing.T) { t.Parallel() avsName := "" rgName := "" subscriptionID := "" _, err := AvailabilitySetExistsE(t, avsName, rgName, subscriptionID) require.Error(t, err) } ================================================ FILE: modules/azure/azure.go ================================================ // Package `azure` allows users to interact with resources on the Microsoft Azure platform package azure ================================================ FILE: modules/azure/client_factory.go ================================================ /* This file implements an Azure client factory that automatically handles setting up Base URI values for sovereign cloud support. Note the list of clients below is not initially exhaustive; rather, additional clients will be added as-needed. */ package azure // snippet-tag-start::client_factory_example.imports import ( "fmt" "os" "reflect" "strings" "github.com/Azure/azure-sdk-for-go/profiles/latest/frontdoor/mgmt/frontdoor" "github.com/Azure/azure-sdk-for-go/profiles/latest/privatedns/mgmt/privatedns" "github.com/Azure/azure-sdk-for-go/profiles/latest/resources/mgmt/resources" "github.com/Azure/azure-sdk-for-go/profiles/preview/cosmos-db/mgmt/documentdb" "github.com/Azure/azure-sdk-for-go/profiles/preview/preview/monitor/mgmt/insights" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appcontainers/armappcontainers/v3" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/datafactory/armdatafactory/v9" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/mysql/armmysql" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresql" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/synapse/armsynapse" "github.com/Azure/azure-sdk-for-go/services/compute/mgmt/2019-07-01/compute" "github.com/Azure/azure-sdk-for-go/services/containerinstance/mgmt/2018-10-01/containerinstance" "github.com/Azure/azure-sdk-for-go/services/containerregistry/mgmt/2019-05-01/containerregistry" "github.com/Azure/azure-sdk-for-go/services/containerservice/mgmt/2019-11-01/containerservice" "github.com/Azure/azure-sdk-for-go/services/network/mgmt/2019-09-01/network" "github.com/Azure/azure-sdk-for-go/services/resources/mgmt/2019-06-01/subscriptions" "github.com/Azure/azure-sdk-for-go/services/storage/mgmt/2019-06-01/storage" autorestAzure "github.com/Azure/go-autorest/autorest/azure" ) // snippet-tag-end::client_factory_example.imports const ( // AzureEnvironmentEnvName is the name of the Azure environment to use. Set to one of the following: // // "AzureChinaCloud": ChinaCloud // "AzureGermanCloud": GermanCloud // "AzurePublicCloud": PublicCloud // "AzureUSGovernmentCloud": USGovernmentCloud // "AzureStackCloud": Azure stack AzureEnvironmentEnvName = "AZURE_ENVIRONMENT" // ResourceManagerEndpointName is the name of the ResourceManagerEndpoint field in the Environment struct. ResourceManagerEndpointName = "ResourceManagerEndpoint" ) // ClientType describes the type of client a module can create. type ClientType int // CreateSubscriptionsClientE returns a virtual machines client instance configured with the correct BaseURI depending on // the Azure environment that is currently setup (or "Public", if none is setup). func CreateSubscriptionsClientE() (subscriptions.Client, error) { // Lookup environment URI baseURI, err := getBaseURI() if err != nil { return subscriptions.Client{}, err } // Create correct client based on type passed return subscriptions.NewClientWithBaseURI(baseURI), nil } // snippet-tag-start::client_factory_example.CreateClient // CreateVirtualMachinesClientE returns a virtual machines client instance configured with the correct BaseURI depending on // the Azure environment that is currently setup (or "Public", if none is setup). func CreateVirtualMachinesClientE(subscriptionID string) (*compute.VirtualMachinesClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Lookup environment URI baseURI, err := getBaseURI() if err != nil { return nil, err } // Create correct client based on type passed vmClient := compute.NewVirtualMachinesClientWithBaseURI(baseURI, subscriptionID) return &vmClient, nil } // snippet-tag-end::client_factory_example.CreateClient // CreateManagedClustersClientE returns a virtual machines client instance configured with the correct BaseURI depending on // the Azure environment that is currently setup (or "Public", if none is setup). func CreateManagedClustersClientE(subscriptionID string) (containerservice.ManagedClustersClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return containerservice.ManagedClustersClient{}, err } // Lookup environment URI baseURI, err := getBaseURI() if err != nil { return containerservice.ManagedClustersClient{}, err } // Create correct client based on type passed return containerservice.NewManagedClustersClientWithBaseURI(baseURI, subscriptionID), nil } // CreateCosmosDBAccountClientE is a helper function that will setup a CosmosDB account client with the correct BaseURI depending on // the Azure environment that is currently setup (or "Public", if none is setup). func CreateCosmosDBAccountClientE(subscriptionID string) (*documentdb.DatabaseAccountsClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Lookup environment URI baseURI, err := getBaseURI() if err != nil { return nil, err } // Create a CosmosDB client cosmosClient := documentdb.NewDatabaseAccountsClientWithBaseURI(baseURI, subscriptionID) return &cosmosClient, nil } // CreateCosmosDBSQLClientE is a helper function that will setup a CosmosDB SQL client with the correct BaseURI depending on // the Azure environment that is currently setup (or "Public", if none is setup). func CreateCosmosDBSQLClientE(subscriptionID string) (*documentdb.SQLResourcesClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Lookup environment URI baseURI, err := getBaseURI() if err != nil { return nil, err } // Create a CosmosDB client cosmosClient := documentdb.NewSQLResourcesClientWithBaseURI(baseURI, subscriptionID) return &cosmosClient, nil } // getArmKeyVaultClientFactory gets an arm keyvault client factory func getArmKeyVaultClientFactory(subscriptionID string) (*armkeyvault.ClientFactory, error) { targetSubscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } clientCloudConfig, err := getClientCloudConfig() if err != nil { return nil, err } cred, err := azidentity.NewDefaultAzureCredential(&azidentity.DefaultAzureCredentialOptions{ ClientOptions: azcore.ClientOptions{ Cloud: clientCloudConfig, }, }) if err != nil { return nil, err } return armkeyvault.NewClientFactory(targetSubscriptionID, cred, &arm.ClientOptions{ ClientOptions: policy.ClientOptions{ Cloud: clientCloudConfig, }, }) } // getArmPostgreSQLClientFactory gets an arm postgresql client factory func getArmPostgreSQLClientFactory(subscriptionID string) (*armpostgresql.ClientFactory, error) { targetSubscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } clientCloudConfig, err := getClientCloudConfig() if err != nil { return nil, err } cred, err := azidentity.NewDefaultAzureCredential(&azidentity.DefaultAzureCredentialOptions{ ClientOptions: azcore.ClientOptions{ Cloud: clientCloudConfig, }, }) if err != nil { return nil, err } return armpostgresql.NewClientFactory(targetSubscriptionID, cred, &arm.ClientOptions{ ClientOptions: policy.ClientOptions{ Cloud: clientCloudConfig, }, }) } // CreateStorageAccountClientE creates a storage account client. func CreateStorageAccountClientE(subscriptionID string) (*storage.AccountsClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Lookup environment URI baseURI, err := getBaseURI() if err != nil { return nil, err } storageAccountClient := storage.NewAccountsClientWithBaseURI(baseURI, subscriptionID) authorizer, err := NewAuthorizer() if err != nil { return nil, err } storageAccountClient.Authorizer = *authorizer return &storageAccountClient, nil } // CreateStorageBlobContainerClientE creates a storage container client. func CreateStorageBlobContainerClientE(subscriptionID string) (*storage.BlobContainersClient, error) { subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Lookup environment URI baseURI, err := getBaseURI() if err != nil { return nil, err } blobContainerClient := storage.NewBlobContainersClientWithBaseURI(baseURI, subscriptionID) authorizer, err := NewAuthorizer() if err != nil { return nil, err } blobContainerClient.Authorizer = *authorizer return &blobContainerClient, nil } // CreateStorageFileSharesClientE creates a storage file share client. func CreateStorageFileSharesClientE(subscriptionID string) (*storage.FileSharesClient, error) { subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Lookup environment URI baseURI, err := getBaseURI() if err != nil { return nil, err } fileShareClient := storage.NewFileSharesClientWithBaseURI(baseURI, subscriptionID) authorizer, err := NewAuthorizer() if err != nil { return nil, err } fileShareClient.Authorizer = *authorizer return &fileShareClient, nil } // CreateAvailabilitySetClientE creates a new Availability Set client in the specified Azure Subscription func CreateAvailabilitySetClientE(subscriptionID string) (*compute.AvailabilitySetsClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Lookup environment URI baseURI, err := getBaseURI() if err != nil { return nil, err } // Get the Availability Set client client := compute.NewAvailabilitySetsClientWithBaseURI(baseURI, subscriptionID) // Create an authorizer authorizer, err := NewAuthorizer() if err != nil { return nil, err } client.Authorizer = *authorizer return &client, nil } // CreateResourceGroupClientE gets a resource group client in a subscription func CreateResourceGroupClientE(subscriptionID string) (*resources.GroupsClient, error) { subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Lookup environment URI baseURI, err := getBaseURI() if err != nil { return nil, err } resourceGroupClient := resources.NewGroupsClientWithBaseURI(baseURI, subscriptionID) authorizer, err := NewAuthorizer() if err != nil { return nil, err } resourceGroupClient.Authorizer = *authorizer return &resourceGroupClient, nil } // CreateSQLServerClient is a helper function that will create and setup a sql server client func CreateSQLServerClient(subscriptionID string) (*armsql.ServersClient, error) { clientFactory, err := getArmSQLClientFactory(subscriptionID) if err != nil { return nil, err } return clientFactory.NewServersClient(), nil } // CreateSQLMangedInstanceClient is a helper function that will create and setup a sql managed instance client func CreateSQLMangedInstanceClient(subscriptionID string) (*armsql.ManagedInstancesClient, error) { clientFactory, err := getArmSQLClientFactory(subscriptionID) if err != nil { return nil, err } return clientFactory.NewManagedInstancesClient(), nil } // CreateSQLMangedDatabasesClient is a helper function that will create and setup a sql managed databases client func CreateSQLMangedDatabasesClient(subscriptionID string) (*armsql.ManagedDatabasesClient, error) { clientFactory, err := getArmSQLClientFactory(subscriptionID) if err != nil { return nil, err } return clientFactory.NewManagedDatabasesClient(), nil } // getArmSQLClientFactory gets an arm sql client factory func getArmSQLClientFactory(subscriptionID string) (*armsql.ClientFactory, error) { targetSubscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } clientCloudConfig, err := getClientCloudConfig() if err != nil { return nil, err } cred, err := azidentity.NewDefaultAzureCredential(&azidentity.DefaultAzureCredentialOptions{ ClientOptions: azcore.ClientOptions{ Cloud: clientCloudConfig, }, }) if err != nil { return nil, err } return armsql.NewClientFactory(targetSubscriptionID, cred, &arm.ClientOptions{ ClientOptions: policy.ClientOptions{ Cloud: clientCloudConfig, }, }) } // CreateDatabaseClient is a helper function that will create and setup a SQL DB client func CreateDatabaseClient(subscriptionID string) (*armsql.DatabasesClient, error) { clientFactory, err := getArmSQLClientFactory(subscriptionID) if err != nil { return nil, err } return clientFactory.NewDatabasesClient(), nil } // CreateMySQLServerClientE is a helper function that will setup a mysql server client. func CreateMySQLServerClientE(subscriptionID string) (*armmysql.ServersClient, error) { clientFactory, err := getArmMySQLClientFactory(subscriptionID) if err != nil { return nil, err } return clientFactory.NewServersClient(), nil } // getArmMySQLClientFactory gets an arm mysql client factory func getArmMySQLClientFactory(subscriptionID string) (*armmysql.ClientFactory, error) { targetSubscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } clientCloudConfig, err := getClientCloudConfig() if err != nil { return nil, err } cred, err := azidentity.NewDefaultAzureCredential(&azidentity.DefaultAzureCredentialOptions{ ClientOptions: azcore.ClientOptions{ Cloud: clientCloudConfig, }, }) if err != nil { return nil, err } return armmysql.NewClientFactory(targetSubscriptionID, cred, &arm.ClientOptions{ ClientOptions: policy.ClientOptions{ Cloud: clientCloudConfig, }, }) } // CreateDisksClientE returns a new Disks client in the specified Azure Subscription func CreateDisksClientE(subscriptionID string) (*compute.DisksClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Lookup environment URI baseURI, err := getBaseURI() if err != nil { return nil, err } // Get the Disks client client := compute.NewDisksClientWithBaseURI(baseURI, subscriptionID) // Create an authorizer authorizer, err := NewAuthorizer() if err != nil { return nil, err } client.Authorizer = *authorizer return &client, nil } func CreateActionGroupClient(subscriptionID string) (*insights.ActionGroupsClient, error) { subID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Lookup environment URI baseURI, err := getBaseURI() if err != nil { return nil, err } metricAlertsClient := insights.NewActionGroupsClientWithBaseURI(baseURI, subID) authorizer, err := NewAuthorizer() if err != nil { return nil, err } metricAlertsClient.Authorizer = *authorizer return &metricAlertsClient, nil } // CreateVMInsightsClientE gets a VM Insights client func CreateVMInsightsClientE(subscriptionID string) (*insights.VMInsightsClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Lookup environment URI baseURI, err := getBaseURI() if err != nil { return nil, err } client := insights.NewVMInsightsClientWithBaseURI(baseURI, subscriptionID) authorizer, err := NewAuthorizer() if err != nil { return nil, err } client.Authorizer = *authorizer return &client, nil } // CreateActivityLogAlertsClientE gets an Action Groups client in the specified Azure Subscription func CreateActivityLogAlertsClientE(subscriptionID string) (*insights.ActivityLogAlertsClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Lookup environment URI baseURI, err := getBaseURI() if err != nil { return nil, err } // Get the Action Groups client client := insights.NewActivityLogAlertsClientWithBaseURI(baseURI, subscriptionID) // Create an authorizer authorizer, err := NewAuthorizer() if err != nil { return nil, err } client.Authorizer = *authorizer return &client, nil } // CreateDiagnosticsSettingsClientE returns a diagnostics settings client func CreateDiagnosticsSettingsClientE(subscriptionID string) (*insights.DiagnosticSettingsClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Lookup environment URI baseURI, err := getBaseURI() if err != nil { return nil, err } client := insights.NewDiagnosticSettingsClientWithBaseURI(baseURI, subscriptionID) authorizer, err := NewAuthorizer() if err != nil { return nil, err } client.Authorizer = *authorizer return &client, nil } // CreateNsgDefaultRulesClientE returns an NSG default (platform) rules client instance configured with the // correct BaseURI depending on the Azure environment that is currently setup (or "Public", if none is setup). func CreateNsgDefaultRulesClientE(subscriptionID string) (*network.DefaultSecurityRulesClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Lookup environment URI baseURI, err := getEnvironmentEndpointE(ResourceManagerEndpointName) if err != nil { return nil, err } // Create new client nsgClient := network.NewDefaultSecurityRulesClientWithBaseURI(baseURI, subscriptionID) return &nsgClient, nil } // CreateNsgCustomRulesClientE returns an NSG custom (user) rules client instance configured with the // correct BaseURI depending on the Azure environment that is currently setup (or "Public", if none is setup). func CreateNsgCustomRulesClientE(subscriptionID string) (*network.SecurityRulesClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Lookup environment URI baseURI, err := getEnvironmentEndpointE(ResourceManagerEndpointName) if err != nil { return nil, err } // Create new client nsgClient := network.NewSecurityRulesClientWithBaseURI(baseURI, subscriptionID) return &nsgClient, nil } // CreateNewNetworkInterfacesClientE returns an NIC client instance configured with the // correct BaseURI depending on the Azure environment that is currently setup (or "Public", if none is setup). func CreateNewNetworkInterfacesClientE(subscriptionID string) (*network.InterfacesClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Lookup environment URI baseURI, err := getEnvironmentEndpointE(ResourceManagerEndpointName) if err != nil { return nil, err } // create client nicClient := network.NewInterfacesClientWithBaseURI(baseURI, subscriptionID) return &nicClient, nil } // CreateNewNetworkInterfaceIPConfigurationClientE returns an NIC IP configuration client instance configured with the // correct BaseURI depending on the Azure environment that is currently setup (or "Public", if none is setup). func CreateNewNetworkInterfaceIPConfigurationClientE(subscriptionID string) (*network.InterfaceIPConfigurationsClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Lookup environment URI baseURI, err := getEnvironmentEndpointE(ResourceManagerEndpointName) if err != nil { return nil, err } // create client ipConfigClient := network.NewInterfaceIPConfigurationsClientWithBaseURI(baseURI, subscriptionID) return &ipConfigClient, nil } // CreatePublicIPAddressesClientE returns a public IP address client instance configured with the correct BaseURI depending on // the Azure environment that is currently setup (or "Public", if none is setup). func CreatePublicIPAddressesClientE(subscriptionID string) (*network.PublicIPAddressesClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Lookup environment URI baseURI, err := getEnvironmentEndpointE(ResourceManagerEndpointName) if err != nil { return nil, err } // Create client client := network.NewPublicIPAddressesClientWithBaseURI(baseURI, subscriptionID) return &client, nil } // CreateLoadBalancerClientE returns a load balancer client instance configured with the correct BaseURI depending on // the Azure environment that is currently setup (or "Public", if none is setup). func CreateLoadBalancerClientE(subscriptionID string) (*network.LoadBalancersClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Lookup environment URI baseURI, err := getEnvironmentEndpointE(ResourceManagerEndpointName) if err != nil { return nil, err } // create LB client client := network.NewLoadBalancersClientWithBaseURI(baseURI, subscriptionID) return &client, nil } // CreateNewSubnetClientE returns a Subnet client instance configured with the // correct BaseURI depending on the Azure environment that is currently setup (or "Public", if none is setup). func CreateNewSubnetClientE(subscriptionID string) (*network.SubnetsClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Lookup environment URI baseURI, err := getEnvironmentEndpointE(ResourceManagerEndpointName) if err != nil { return nil, err } // create client subnetClient := network.NewSubnetsClientWithBaseURI(baseURI, subscriptionID) return &subnetClient, nil } // CreateNewVirtualNetworkClientE returns a Virtual Network client instance configured with the // correct BaseURI depending on the Azure environment that is currently setup (or "Public", if none is setup). func CreateNewVirtualNetworkClientE(subscriptionID string) (*network.VirtualNetworksClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Lookup environment URI baseURI, err := getEnvironmentEndpointE(ResourceManagerEndpointName) if err != nil { return nil, err } // create client vnetClient := network.NewVirtualNetworksClientWithBaseURI(baseURI, subscriptionID) return &vnetClient, nil } // CreateAppServiceClientE returns an App service client instance configured with the // correct BaseURI depending on the Azure environment that is currently setup (or "Public", if none is setup). func CreateAppServiceClientE(subscriptionID string) (*armappservice.WebAppsClient, error) { clientFactory, err := getArmAppServiceClientFactory(subscriptionID) if err != nil { return nil, err } return clientFactory.NewWebAppsClient(), nil } // getArmAppServiceClientFactory gets an arm app service client factory func getArmAppServiceClientFactory(subscriptionID string) (*armappservice.ClientFactory, error) { targetSubscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } clientCloudConfig, err := getClientCloudConfig() if err != nil { return nil, err } cred, err := azidentity.NewDefaultAzureCredential(&azidentity.DefaultAzureCredentialOptions{ ClientOptions: azcore.ClientOptions{ Cloud: clientCloudConfig, }, }) if err != nil { return nil, err } return armappservice.NewClientFactory(targetSubscriptionID, cred, &arm.ClientOptions{ ClientOptions: policy.ClientOptions{ Cloud: clientCloudConfig, }, }) } // CreateContainerRegistryClientE returns an ACR client instance configured with the // correct BaseURI depending on the Azure environment that is currently setup (or "Public", if none is setup). func CreateContainerRegistryClientE(subscriptionID string) (*containerregistry.RegistriesClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Lookup environment URI baseURI, err := getEnvironmentEndpointE(ResourceManagerEndpointName) if err != nil { return nil, err } // create client registryClient := containerregistry.NewRegistriesClientWithBaseURI(baseURI, subscriptionID) return ®istryClient, nil } // CreateContainerInstanceClientE returns an ACI client instance configured with the // correct BaseURI depending on the Azure environment that is currently setup (or "Public", if none is setup). func CreateContainerInstanceClientE(subscriptionID string) (*containerinstance.ContainerGroupsClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Lookup environment URI baseURI, err := getEnvironmentEndpointE(ResourceManagerEndpointName) if err != nil { return nil, err } // create client instanceClient := containerinstance.NewContainerGroupsClientWithBaseURI(baseURI, subscriptionID) return &instanceClient, nil } // CreateFrontDoorClientE returns an AFD client instance configured with the // correct BaseURI depending on the Azure environment that is currently setup (or "Public", if none is setup). func CreateFrontDoorClientE(subscriptionID string) (*frontdoor.FrontDoorsClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Lookup environment URI baseURI, err := getEnvironmentEndpointE(ResourceManagerEndpointName) if err != nil { return nil, err } // create client client := frontdoor.NewFrontDoorsClientWithBaseURI(baseURI, subscriptionID) return &client, nil } // CreateFrontDoorFrontendEndpointClientE returns an AFD Frontend Endpoints client instance configured with the // correct BaseURI depending on the Azure environment that is currently setup (or "Public", if none is setup). func CreateFrontDoorFrontendEndpointClientE(subscriptionID string) (*frontdoor.FrontendEndpointsClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Lookup environment URI baseURI, err := getEnvironmentEndpointE(ResourceManagerEndpointName) if err != nil { return nil, err } // create client client := frontdoor.NewFrontendEndpointsClientWithBaseURI(baseURI, subscriptionID) return &client, nil } // CreateSynapseWorkspaceClientE is a helper function that will setup a synapse workspace client. func CreateSynapseWorkspaceClientE(subscriptionID string) (*armsynapse.WorkspacesClient, error) { clientFactory, err := getArmSynapseClientFactory(subscriptionID) if err != nil { return nil, err } return clientFactory.NewWorkspacesClient(), nil } // CreateSynapseSqlPoolClientE is a helper function that will setup a synapse sql pool client. func CreateSynapseSqlPoolClientE(subscriptionID string) (*armsynapse.SQLPoolsClient, error) { clientFactory, err := getArmSynapseClientFactory(subscriptionID) if err != nil { return nil, err } return clientFactory.NewSQLPoolsClient(), nil } // getArmSynapseClientFactory gets an arm synapse client factory func getArmSynapseClientFactory(subscriptionID string) (*armsynapse.ClientFactory, error) { targetSubscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } clientCloudConfig, err := getClientCloudConfig() if err != nil { return nil, err } cred, err := azidentity.NewDefaultAzureCredential(&azidentity.DefaultAzureCredentialOptions{ ClientOptions: azcore.ClientOptions{ Cloud: clientCloudConfig, }, }) if err != nil { return nil, err } return armsynapse.NewClientFactory(targetSubscriptionID, cred, &arm.ClientOptions{ ClientOptions: policy.ClientOptions{ Cloud: clientCloudConfig, }, }) } // CreateDataFactoriesClientE is a helper function that will setup a data factory client. func CreateDataFactoriesClientE(subscriptionID string) (*armdatafactory.FactoriesClient, error) { clientFactory, err := getArmDataFactoryClientFactory(subscriptionID) if err != nil { return nil, err } return clientFactory.NewFactoriesClient(), nil } // CreatePrivateDnsZonesClientE is a helper function that will setup a private DNS zone client. func CreatePrivateDnsZonesClientE(subscriptionID string) (*privatedns.PrivateZonesClient, error) { // Validate Azure subscription ID subID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Lookup environment URI baseURI, err := getBaseURI() if err != nil { return nil, err } // Create a private DNS zone client privateZonesClient := privatedns.NewPrivateZonesClientWithBaseURI(baseURI, subID) // Create an authorizer authorizer, err := NewAuthorizer() if err != nil { return nil, err } // Attach authorizer to the client privateZonesClient.Authorizer = *authorizer return &privateZonesClient, nil } func CreateManagedEnvironmentsClientE(subscriptionID string) (*armappcontainers.ManagedEnvironmentsClient, error) { clientFactory, err := getArmAppContainersClientFactory(subscriptionID) if err != nil { return nil, err } client := clientFactory.NewManagedEnvironmentsClient() return client, nil } func CreateResourceGroupClientV2E(subscriptionID string) (*armresources.ResourceGroupsClient, error) { clientFactory, err := getArmResourcesClientFactory(subscriptionID) if err != nil { return nil, err } return clientFactory.NewResourceGroupsClient(), nil } func CreateContainerAppsClientE(subscriptionID string) (*armappcontainers.ContainerAppsClient, error) { clientFactory, err := getArmAppContainersClientFactory(subscriptionID) if err != nil { return nil, err } client := clientFactory.NewContainerAppsClient() return client, nil } func CreateContainerAppJobsClientE(subscriptionID string) (*armappcontainers.JobsClient, error) { clientFactory, err := getArmAppContainersClientFactory(subscriptionID) if err != nil { return nil, err } client := clientFactory.NewJobsClient() return client, nil } // GetKeyVaultURISuffixE returns the proper KeyVault URI suffix for the configured Azure environment. // This function would fail the test if there is an error. func GetKeyVaultURISuffixE() (string, error) { envName := getDefaultEnvironmentName() env, err := autorestAzure.EnvironmentFromName(envName) if err != nil { return "", err } return env.KeyVaultDNSSuffix, nil } // getDefaultEnvironmentName returns either a configured Azure environment name, or the public default func getDefaultEnvironmentName() string { envName, exists := os.LookupEnv(AzureEnvironmentEnvName) if exists && len(envName) > 0 { return envName } return autorestAzure.PublicCloud.Name } // getEnvironmentEndpointE returns the endpoint identified by the endpoint name parameter. func getEnvironmentEndpointE(endpointName string) (string, error) { envName := getDefaultEnvironmentName() env, err := autorestAzure.EnvironmentFromName(envName) if err != nil { return "", err } return getFieldValue(&env, endpointName), nil } // getFieldValue gets the field identified by the field parameter from the passed Environment struct func getFieldValue(env *autorestAzure.Environment, field string) string { structValue := reflect.ValueOf(env) fieldVal := reflect.Indirect(structValue).FieldByName(field) return fieldVal.String() } // getBaseURI gets the base URI endpoint. func getBaseURI() (string, error) { // Lookup environment URI baseURI, err := getEnvironmentEndpointE(ResourceManagerEndpointName) if err != nil { return "", err } return baseURI, nil } // getArmResourcesClientFactory gets an arm resources client factory func getArmResourcesClientFactory(subscriptionID string) (*armresources.ClientFactory, error) { targetSubscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } clientCloudConfig, err := getClientCloudConfig() if err != nil { return nil, err } cred, err := azidentity.NewDefaultAzureCredential(&azidentity.DefaultAzureCredentialOptions{ ClientOptions: azcore.ClientOptions{ Cloud: clientCloudConfig, }, }) if err != nil { return nil, err } return armresources.NewClientFactory(targetSubscriptionID, cred, &arm.ClientOptions{ ClientOptions: policy.ClientOptions{ Cloud: clientCloudConfig, }, }) } // getArmAppContainersClientFactory gets an arm app containers client factory func getArmAppContainersClientFactory(subscriptionID string) (*armappcontainers.ClientFactory, error) { targetSubscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } clientCloudConfig, err := getClientCloudConfig() if err != nil { return nil, err } cred, err := azidentity.NewDefaultAzureCredential(&azidentity.DefaultAzureCredentialOptions{ ClientOptions: azcore.ClientOptions{ Cloud: clientCloudConfig, }, }) if err != nil { return nil, err } return armappcontainers.NewClientFactory(targetSubscriptionID, cred, &arm.ClientOptions{ ClientOptions: policy.ClientOptions{ Cloud: clientCloudConfig, }, }) } // getArmDataFactoryClientFactory gets an arm data factory client factory func getArmDataFactoryClientFactory(subscriptionID string) (*armdatafactory.ClientFactory, error) { targetSubscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } clientCloudConfig, err := getClientCloudConfig() if err != nil { return nil, err } cred, err := azidentity.NewDefaultAzureCredential(&azidentity.DefaultAzureCredentialOptions{ ClientOptions: azcore.ClientOptions{ Cloud: clientCloudConfig, }, }) if err != nil { return nil, err } return armdatafactory.NewClientFactory(targetSubscriptionID, cred, &arm.ClientOptions{ ClientOptions: policy.ClientOptions{ Cloud: clientCloudConfig, }, }) } func getClientCloudConfig() (cloud.Configuration, error) { envName := getDefaultEnvironmentName() switch strings.ToUpper(envName) { case "AZURECHINACLOUD": return cloud.AzureChina, nil case "AZUREUSGOVERNMENTCLOUD": return cloud.AzureGovernment, nil case "AZUREPUBLICCLOUD": return cloud.AzurePublic, nil case "AZURESTACKCLOUD": env, err := autorestAzure.EnvironmentFromName(envName) if err != nil { return cloud.Configuration{}, err } c := cloud.Configuration{ ActiveDirectoryAuthorityHost: env.ActiveDirectoryEndpoint, Services: map[cloud.ServiceName]cloud.ServiceConfiguration{ cloud.ResourceManager: { Audience: env.TokenAudience, Endpoint: env.ResourceManagerEndpoint, }, }, } return c, nil default: return cloud.Configuration{}, fmt.Errorf("no cloud environment matching the name: %s. "+ "Available values are: "+ "AzurePublicCloud (default), "+ "AzureUSGovernmentCloud, "+ "AzureChinaCloud or "+ "AzureStackCloud", envName) } } ================================================ FILE: modules/azure/client_factory_test.go ================================================ //go:build azure // +build azure // This file contains unit tests for the client factory implementation(s). package azure import ( "os" "reflect" "testing" autorest "github.com/Azure/go-autorest/autorest/azure" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // Local consts for this file only const govCloudEnvName = "AzureUSGovernmentCloud" const publicCloudEnvName = "AzurePublicCloud" const chinaCloudEnvName = "AzureChinaCloud" const germanyCloudEnvName = "AzureGermanCloud" func TestDefaultEnvIsPublicWhenNotSet(t *testing.T) { // save any current env value and restore on exit originalEnv := os.Getenv(AzureEnvironmentEnvName) defer os.Setenv(AzureEnvironmentEnvName, originalEnv) // Set env var to missing value os.Setenv(AzureEnvironmentEnvName, "") // get the default env := getDefaultEnvironmentName() // Make sure it's public cloud assert.Equal(t, autorest.PublicCloud.Name, env) } func TestDefaultEnvSetToGov(t *testing.T) { // save any current env value and restore on exit originalEnv := os.Getenv(AzureEnvironmentEnvName) defer os.Setenv(AzureEnvironmentEnvName, originalEnv) // Set env var to gov os.Setenv(AzureEnvironmentEnvName, govCloudEnvName) // get the default env := getDefaultEnvironmentName() // Make sure it's public cloud assert.Equal(t, autorest.USGovernmentCloud.Name, env) } func TestSubscriptionClientBaseURISetCorrectly(t *testing.T) { var cases = []struct { CaseName string EnvironmentName string ExpectedBaseURI string }{ {"GovCloud/SubscriptionClient", govCloudEnvName, autorest.USGovernmentCloud.ResourceManagerEndpoint}, {"PublicCloud/SubscriptionClient", publicCloudEnvName, autorest.PublicCloud.ResourceManagerEndpoint}, {"ChinaCloud/SubscriptionClient", chinaCloudEnvName, autorest.ChinaCloud.ResourceManagerEndpoint}, {"GermanCloud/SubscriptionClient", germanyCloudEnvName, autorest.GermanCloud.ResourceManagerEndpoint}, } // save any current env value and restore on exit currentEnv := os.Getenv(AzureEnvironmentEnvName) defer os.Setenv(AzureEnvironmentEnvName, currentEnv) for _, tt := range cases { // The following is necessary to make sure testCase's values don't // get updated due to concurrency within the scope of t.Run(..) below tt := tt t.Run(tt.CaseName, func(t *testing.T) { // Override env setting os.Setenv(AzureEnvironmentEnvName, tt.EnvironmentName) // Get a VM client client, err := CreateSubscriptionsClientE() require.NoError(t, err) // Check for correct ARM URI assert.Equal(t, tt.ExpectedBaseURI, client.BaseURI) }) } } // snippet-tag-start::client_factory_example.UnitTest func TestVMClientBaseURISetCorrectly(t *testing.T) { var cases = []struct { CaseName string EnvironmentName string ExpectedBaseURI string }{ {"GovCloud/VMClient", govCloudEnvName, autorest.USGovernmentCloud.ResourceManagerEndpoint}, {"PublicCloud/VMClient", publicCloudEnvName, autorest.PublicCloud.ResourceManagerEndpoint}, {"ChinaCloud/VMClient", chinaCloudEnvName, autorest.ChinaCloud.ResourceManagerEndpoint}, {"GermanCloud/VMClient", germanyCloudEnvName, autorest.GermanCloud.ResourceManagerEndpoint}, } // save any current env value and restore on exit currentEnv := os.Getenv(AzureEnvironmentEnvName) defer os.Setenv(AzureEnvironmentEnvName, currentEnv) for _, tt := range cases { // The following is necessary to make sure testCase's values don't // get updated due to concurrency within the scope of t.Run(..) below tt := tt t.Run(tt.CaseName, func(t *testing.T) { // Override env setting os.Setenv(AzureEnvironmentEnvName, tt.EnvironmentName) // Get a VM client client, err := CreateVirtualMachinesClientE("") require.NoError(t, err) // Check for correct ARM URI assert.Equal(t, tt.ExpectedBaseURI, client.BaseURI) }) } } // snippet-tag-end::client_factory_example.UnitTest func TestManagedClustersClientBaseURISetCorrectly(t *testing.T) { var cases = []struct { CaseName string EnvironmentName string ExpectedBaseURI string }{ {"GovCloud/ManagedClustersClient", govCloudEnvName, autorest.USGovernmentCloud.ResourceManagerEndpoint}, {"PublicCloud/ManagedClustersClient", publicCloudEnvName, autorest.PublicCloud.ResourceManagerEndpoint}, {"ChinaCloud/ManagedClustersClient", chinaCloudEnvName, autorest.ChinaCloud.ResourceManagerEndpoint}, {"GermanCloud/ManagedClustersClient", germanyCloudEnvName, autorest.GermanCloud.ResourceManagerEndpoint}, } // save any current env value and restore on exit currentEnv := os.Getenv(AzureEnvironmentEnvName) defer os.Setenv(AzureEnvironmentEnvName, currentEnv) for _, tt := range cases { // The following is necessary to make sure testCase's values don't // get updated due to concurrency within the scope of t.Run(..) below tt := tt t.Run(tt.CaseName, func(t *testing.T) { // Override env setting os.Setenv(AzureEnvironmentEnvName, tt.EnvironmentName) // Get a VM client client, err := CreateManagedClustersClientE("") require.NoError(t, err) // Check for correct ARM URI assert.Equal(t, tt.ExpectedBaseURI, client.BaseURI) }) } } func TestCosmosDBAccountClientBaseURISetCorrectly(t *testing.T) { var cases = []struct { CaseName string EnvironmentName string ExpectedBaseURI string }{ {"GovCloud/CosmosDBAccountClient", govCloudEnvName, autorest.USGovernmentCloud.ResourceManagerEndpoint}, {"PublicCloud/CosmosDBAccountClient", publicCloudEnvName, autorest.PublicCloud.ResourceManagerEndpoint}, {"ChinaCloud/CosmosDBAccountClient", chinaCloudEnvName, autorest.ChinaCloud.ResourceManagerEndpoint}, {"GermanCloud/CosmosDBAccountClient", germanyCloudEnvName, autorest.GermanCloud.ResourceManagerEndpoint}, } // save any current env value and restore on exit currentEnv := os.Getenv(AzureEnvironmentEnvName) defer os.Setenv(AzureEnvironmentEnvName, currentEnv) for _, tt := range cases { // The following is necessary to make sure testCase's values don't // get updated due to concurrency within the scope of t.Run(..) below tt := tt t.Run(tt.CaseName, func(t *testing.T) { // Override env setting os.Setenv(AzureEnvironmentEnvName, tt.EnvironmentName) // Get a VM client client, err := CreateCosmosDBAccountClientE("") require.NoError(t, err) // Check for correct ARM URI assert.Equal(t, tt.ExpectedBaseURI, client.BaseURI) }) } } func TestCosmosDBSQLClientBaseURISetCorrectly(t *testing.T) { var cases = []struct { CaseName string EnvironmentName string ExpectedBaseURI string }{ {"GovCloud/CosmosDBAccountClient", govCloudEnvName, autorest.USGovernmentCloud.ResourceManagerEndpoint}, {"PublicCloud/CosmosDBAccountClient", publicCloudEnvName, autorest.PublicCloud.ResourceManagerEndpoint}, {"ChinaCloud/CosmosDBAccountClient", chinaCloudEnvName, autorest.ChinaCloud.ResourceManagerEndpoint}, {"GermanCloud/CosmosDBAccountClient", germanyCloudEnvName, autorest.GermanCloud.ResourceManagerEndpoint}, } // save any current env value and restore on exit currentEnv := os.Getenv(AzureEnvironmentEnvName) defer os.Setenv(AzureEnvironmentEnvName, currentEnv) for _, tt := range cases { // The following is necessary to make sure testCase's values don't // get updated due to concurrency within the scope of t.Run(..) below tt := tt t.Run(tt.CaseName, func(t *testing.T) { // Override env setting os.Setenv(AzureEnvironmentEnvName, tt.EnvironmentName) // Get a VM client client, err := CreateCosmosDBSQLClientE("") require.NoError(t, err) // Check for correct ARM URI assert.Equal(t, tt.ExpectedBaseURI, client.BaseURI) }) } } func TestPublicIPAddressesClientBaseURISetCorrectly(t *testing.T) { var cases = []struct { CaseName string EnvironmentName string ExpectedBaseURI string }{ {"GovCloud/CosmosDBAccountClient", govCloudEnvName, autorest.USGovernmentCloud.ResourceManagerEndpoint}, {"PublicCloud/CosmosDBAccountClient", publicCloudEnvName, autorest.PublicCloud.ResourceManagerEndpoint}, {"ChinaCloud/CosmosDBAccountClient", chinaCloudEnvName, autorest.ChinaCloud.ResourceManagerEndpoint}, {"GermanCloud/CosmosDBAccountClient", germanyCloudEnvName, autorest.GermanCloud.ResourceManagerEndpoint}, } // save any current env value and restore on exit currentEnv := os.Getenv(AzureEnvironmentEnvName) defer os.Setenv(AzureEnvironmentEnvName, currentEnv) for _, tt := range cases { // The following is necessary to make sure testCase's values don't // get updated due to concurrency within the scope of t.Run(..) below tt := tt t.Run(tt.CaseName, func(t *testing.T) { // Override env setting os.Setenv(AzureEnvironmentEnvName, tt.EnvironmentName) // Get a VM client client, err := CreatePublicIPAddressesClientE("") require.NoError(t, err) // Check for correct ARM URI assert.Equal(t, tt.ExpectedBaseURI, client.BaseURI) }) } } func TestLoadBalancerClientBaseURISetCorrectly(t *testing.T) { var cases = []struct { CaseName string EnvironmentName string ExpectedBaseURI string }{ {"GovCloud/CosmosDBAccountClient", govCloudEnvName, autorest.USGovernmentCloud.ResourceManagerEndpoint}, {"PublicCloud/CosmosDBAccountClient", publicCloudEnvName, autorest.PublicCloud.ResourceManagerEndpoint}, {"ChinaCloud/CosmosDBAccountClient", chinaCloudEnvName, autorest.ChinaCloud.ResourceManagerEndpoint}, {"GermanCloud/CosmosDBAccountClient", germanyCloudEnvName, autorest.GermanCloud.ResourceManagerEndpoint}, } // save any current env value and restore on exit currentEnv := os.Getenv(AzureEnvironmentEnvName) defer os.Setenv(AzureEnvironmentEnvName, currentEnv) for _, tt := range cases { // The following is necessary to make sure testCase's values don't // get updated due to concurrency within the scope of t.Run(..) below tt := tt t.Run(tt.CaseName, func(t *testing.T) { // Override env setting os.Setenv(AzureEnvironmentEnvName, tt.EnvironmentName) // Get a VM client client, err := CreateLoadBalancerClientE("") require.NoError(t, err) // Check for correct ARM URI assert.Equal(t, tt.ExpectedBaseURI, client.BaseURI) }) } } func TestFrontDoorClientBaseURISetCorrectly(t *testing.T) { var cases = []struct { CaseName string EnvironmentName string ExpectedBaseURI string }{ {"GovCloud/FrontDoorClient", govCloudEnvName, autorest.USGovernmentCloud.ResourceManagerEndpoint}, {"PublicCloud/FrontDoorClient", publicCloudEnvName, autorest.PublicCloud.ResourceManagerEndpoint}, {"ChinaCloud/FrontDoorClient", chinaCloudEnvName, autorest.ChinaCloud.ResourceManagerEndpoint}, {"GermanCloud/FrontDoorClient", germanyCloudEnvName, autorest.GermanCloud.ResourceManagerEndpoint}, } // save any current env value and restore on exit currentEnv := os.Getenv(AzureEnvironmentEnvName) defer os.Setenv(AzureEnvironmentEnvName, currentEnv) for _, tt := range cases { // The following is necessary to make sure testCase's values don't // get updated due to concurrency within the scope of t.Run(..) below tt := tt t.Run(tt.CaseName, func(t *testing.T) { // Override env setting os.Setenv(AzureEnvironmentEnvName, tt.EnvironmentName) // Get a Front Door client client, err := CreateFrontDoorClientE("") require.NoError(t, err) // Check for correct ARM URI assert.Equal(t, tt.ExpectedBaseURI, client.BaseURI) }) } } func TestFrontDoorFrontendEndpointClientBaseURISetCorrectly(t *testing.T) { var cases = []struct { CaseName string EnvironmentName string ExpectedBaseURI string }{ {"GovCloud/FrontDoorClient", govCloudEnvName, autorest.USGovernmentCloud.ResourceManagerEndpoint}, {"PublicCloud/FrontDoorClient", publicCloudEnvName, autorest.PublicCloud.ResourceManagerEndpoint}, {"ChinaCloud/FrontDoorClient", chinaCloudEnvName, autorest.ChinaCloud.ResourceManagerEndpoint}, {"GermanCloud/FrontDoorClient", germanyCloudEnvName, autorest.GermanCloud.ResourceManagerEndpoint}, } // save any current env value and restore on exit currentEnv := os.Getenv(AzureEnvironmentEnvName) defer os.Setenv(AzureEnvironmentEnvName, currentEnv) for _, tt := range cases { // The following is necessary to make sure testCase's values don't // get updated due to concurrency within the scope of t.Run(..) below tt := tt t.Run(tt.CaseName, func(t *testing.T) { // Override env setting os.Setenv(AzureEnvironmentEnvName, tt.EnvironmentName) // Get a AFD frontend endpoint client client, err := CreateFrontDoorFrontendEndpointClientE("") require.NoError(t, err) // Check for correct ARM URI assert.Equal(t, tt.ExpectedBaseURI, client.BaseURI) }) } } func TestCreateManagedEnvironmentsClientEEndpointURISetCorrectly(t *testing.T) { var cases = []struct { CaseName string EnvironmentName string ExpectedBaseURI string ExpectErr bool }{ {"Default/ManagedEnvironmentsClient", publicCloudEnvName, autorest.PublicCloud.ResourceManagerEndpoint, false}, {"PublicCloud/ManagedEnvironmentsClient", publicCloudEnvName, autorest.PublicCloud.ResourceManagerEndpoint, false}, {"GovCloud/ManagedEnvironmentsClient", govCloudEnvName, autorest.USGovernmentCloud.ResourceManagerEndpoint, false}, {"ChinaCloud/ManagedEnvironmentsClient", chinaCloudEnvName, autorest.ChinaCloud.ResourceManagerEndpoint, false}, {"GermanCloud/ManagedEnvironmentsClient", germanyCloudEnvName, autorest.GermanCloud.ResourceManagerEndpoint, true}, //GermanCloud is deleted as of 2021-10-21 https://learn.microsoft.com/en-us/previous-versions/azure/germany/germany-welcome } // save any current env value and restore on exit currentEnv := os.Getenv(AzureEnvironmentEnvName) defer os.Setenv(AzureEnvironmentEnvName, currentEnv) for _, tt := range cases { // The following is necessary to make sure testCase's values don't // get updated due to concurrency within the scope of t.Run(..) below tt := tt t.Run(tt.CaseName, func(t *testing.T) { // Override env setting if tt.EnvironmentName != "" { os.Setenv(AzureEnvironmentEnvName, tt.EnvironmentName) } else { os.Unsetenv(AzureEnvironmentEnvName) } // Get a ManagedEnvironmentsClient client client, err := CreateManagedEnvironmentsClientE("") if tt.ExpectErr { require.Error(t, err) } else { require.NoError(t, err) require.NotNil(t, client) // Not ideal, but to get the base URI we need to access the internal field field := reflect.ValueOf(client).Elem().FieldByName("internal").Elem().FieldByName("ep") assert.Equal(t, field.String()+"/", tt.ExpectedBaseURI) } }) } } func TestCreateContainerAppsClientEEndpointURISetCorrectly(t *testing.T) { var cases = []struct { CaseName string EnvironmentName string ExpectedBaseURI string ExpectErr bool }{ {"Default/ContainerAppsClient", "", autorest.PublicCloud.ResourceManagerEndpoint, false}, {"PublicCloud/ContainerAppsClient", publicCloudEnvName, autorest.PublicCloud.ResourceManagerEndpoint, false}, {"GovCloud/ContainerAppsClient", govCloudEnvName, autorest.USGovernmentCloud.ResourceManagerEndpoint, false}, {"ChinaCloud/ContainerAppsClient", chinaCloudEnvName, autorest.ChinaCloud.ResourceManagerEndpoint, false}, {"GermanCloud/ContainerAppsClient", germanyCloudEnvName, autorest.GermanCloud.ResourceManagerEndpoint, true}, //GermanCloud is deleted as of 2021-10-21 https://learn.microsoft.com/en-us/previous-versions/azure/germany/germany-welcome } // save any current env value and restore on exit currentEnv := os.Getenv(AzureEnvironmentEnvName) defer os.Setenv(AzureEnvironmentEnvName, currentEnv) for _, tt := range cases { // The following is necessary to make sure testCase's values don't // get updated due to concurrency within the scope of t.Run(..) below tt := tt t.Run(tt.CaseName, func(t *testing.T) { // Override env setting if tt.EnvironmentName != "" { os.Setenv(AzureEnvironmentEnvName, tt.EnvironmentName) } else { os.Unsetenv(AzureEnvironmentEnvName) } // Get a ManagedEnvironmentsClient client client, err := CreateContainerAppsClientE("") if tt.ExpectErr { require.Error(t, err) } else { require.NoError(t, err) require.NotNil(t, client) // Not ideal, but to get the base URI we need to access the internal field field := reflect.ValueOf(client).Elem().FieldByName("internal").Elem().FieldByName("ep") assert.Equal(t, field.String()+"/", tt.ExpectedBaseURI) } }) } } func TestCreateContainerAppJobsClientEEndpointURISetCorrectly(t *testing.T) { var cases = []struct { CaseName string EnvironmentName string ExpectedBaseURI string ExpectErr bool }{ {"Default/ContainerAppsClient", "", autorest.PublicCloud.ResourceManagerEndpoint, false}, {"PublicCloud/ContainerAppsClient", publicCloudEnvName, autorest.PublicCloud.ResourceManagerEndpoint, false}, {"GovCloud/ContainerAppsClient", govCloudEnvName, autorest.USGovernmentCloud.ResourceManagerEndpoint, false}, {"ChinaCloud/ContainerAppsClient", chinaCloudEnvName, autorest.ChinaCloud.ResourceManagerEndpoint, false}, {"GermanCloud/ContainerAppsClient", germanyCloudEnvName, autorest.GermanCloud.ResourceManagerEndpoint, true}, //GermanCloud is deleted as of 2021-10-21 https://learn.microsoft.com/en-us/previous-versions/azure/germany/germany-welcome } // save any current env value and restore on exit currentEnv := os.Getenv(AzureEnvironmentEnvName) defer os.Setenv(AzureEnvironmentEnvName, currentEnv) for _, tt := range cases { // The following is necessary to make sure testCase's values don't // get updated due to concurrency within the scope of t.Run(..) below tt := tt t.Run(tt.CaseName, func(t *testing.T) { // Override env setting if tt.EnvironmentName != "" { os.Setenv(AzureEnvironmentEnvName, tt.EnvironmentName) } else { os.Unsetenv(AzureEnvironmentEnvName) } // Get a ManagedEnvironmentsClient client client, err := CreateContainerAppJobsClientE("") if tt.ExpectErr { require.Error(t, err) } else { require.NoError(t, err) require.NotNil(t, client) // Not ideal, but to get the base URI we need to access the internal field field := reflect.ValueOf(client).Elem().FieldByName("internal").Elem().FieldByName("ep") assert.Equal(t, field.String()+"/", tt.ExpectedBaseURI) } }) } } ================================================ FILE: modules/azure/common.go ================================================ package azure import ( "os" ) const ( // AzureSubscriptionID is an optional env variable supported by the `azurerm` Terraform provider to // designate a target Azure subscription ID AzureSubscriptionID = "ARM_SUBSCRIPTION_ID" // AzureResGroupName is an optional env variable custom to Terratest to designate a target Azure resource group AzureResGroupName = "AZURE_RES_GROUP_NAME" ) // GetTargetAzureSubscription is a helper function to find the correct target Azure Subscription ID, // with provided arguments taking precedence over environment variables func GetTargetAzureSubscription(subscriptionID string) (string, error) { return getTargetAzureSubscription(subscriptionID) } func getTargetAzureSubscription(subscriptionID string) (string, error) { if subscriptionID == "" { if id, exists := os.LookupEnv(AzureSubscriptionID); exists { return id, nil } return "", SubscriptionIDNotFound{} } return subscriptionID, nil } // GetTargetAzureResourceGroupName is a helper function to find the correct target Azure Resource Group name, // with provided arguments taking precedence over environment variables func GetTargetAzureResourceGroupName(resourceGroupName string) (string, error) { return getTargetAzureResourceGroupName(resourceGroupName) } func getTargetAzureResourceGroupName(resourceGroupName string) (string, error) { if resourceGroupName == "" { if name, exists := os.LookupEnv(AzureResGroupName); exists { return name, nil } return "", ResourceGroupNameNotFound{} } return resourceGroupName, nil } // safePtrToString converts a string pointer to a non-pointer string value, or to "" if the pointer is nil. func safePtrToString(raw *string) string { if raw == nil { return "" } return *raw } // safePtrToInt32 converts a int32 pointer to a non-pointer int32 value, or to 0 if the pointer is nil. func safePtrToInt32(raw *int32) int32 { if raw == nil { return 0 } return *raw } // safePtrToList converts a []string pointer to a non-pointer []string value, or to initialization of an empty slice if the pointer is nil. func safePtrToList(raw *[]string) []string { if raw == nil { return []string{} } return *raw } ================================================ FILE: modules/azure/common_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package azure import ( "os" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestGetTargetAzureSubscription(t *testing.T) { t.Parallel() //Check that ARM_SUBSCRIPTION_ID env variable is set, CI requires this value to run all test. require.NotEmpty(t, os.Getenv(AzureSubscriptionID), "ARM_SUBSCRIPTION_ID environment variable not set.") type args struct { subID string } tests := []struct { name string args args want string wantErr bool }{ {name: "subIDProvidedAsArg", args: args{subID: "test"}, want: "test", wantErr: false}, {name: "subIDNotProvidedFallbackToEnv", args: args{subID: ""}, want: os.Getenv(AzureSubscriptionID), wantErr: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := GetTargetAzureSubscription(tt.args.subID) if tt.wantErr { require.Error(t, err) } else { require.Equal(t, tt.want, got) } }) } } func TestGetTargetAzureResourceGroupName(t *testing.T) { t.Parallel() type args struct { rgName string } tests := []struct { name string args args want string wantErr bool }{ {name: "rgNameProvidedAsArg", args: args{rgName: "test"}, want: "test", wantErr: false}, {name: "rgNameNotProvided", args: args{rgName: ""}, want: "", wantErr: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := GetTargetAzureResourceGroupName(tt.args.rgName) if tt.wantErr { require.Error(t, err) } else { require.Equal(t, tt.want, got) } }) } } func TestSafePtrToString(t *testing.T) { // When given a nil, should always return an empty string var nilPtr *string = nil nilResult := safePtrToString(nilPtr) assert.Equal(t, "", nilResult) // When given a string, should just de-ref and return stringPtr := "Test" stringResult := safePtrToString(&stringPtr) assert.Equal(t, "Test", stringResult) } func TestSafePtrToInt32(t *testing.T) { // When given a nil, should always return an zero value int32 var nilPtr *int32 = nil nilResult := safePtrToInt32(nilPtr) assert.Equal(t, int32(0), nilResult) // When given a string, should just de-ref and return intPtr := int32(42) intResult := safePtrToInt32(&intPtr) assert.Equal(t, int32(42), intResult) } ================================================ FILE: modules/azure/compute.go ================================================ package azure import ( "context" "github.com/Azure/azure-sdk-for-go/services/compute/mgmt/2019-07-01/compute" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // GetVirtualMachineClient is a helper function that will setup an Azure Virtual Machine client on your behalf. func GetVirtualMachineClient(t testing.TestingT, subscriptionID string) *compute.VirtualMachinesClient { vmClient, err := GetVirtualMachineClientE(subscriptionID) require.NoError(t, err) return vmClient } // GetVirtualMachineClientE is a helper function that will setup an Azure Virtual Machine client on your behalf. func GetVirtualMachineClientE(subscriptionID string) (*compute.VirtualMachinesClient, error) { // snippet-tag-start::client_factory_example.helper // Create a VM client vmClient, err := CreateVirtualMachinesClientE(subscriptionID) if err != nil { return nil, err } // snippet-tag-end::client_factory_example.helper // Create an authorizer authorizer, err := NewAuthorizer() if err != nil { return nil, err } // Attach authorizer to the client vmClient.Authorizer = *authorizer return vmClient, nil } // VirtualMachineExists indicates whether the specified Azure Virtual Machine exists. // This function would fail the test if there is an error. func VirtualMachineExists(t testing.TestingT, vmName string, resGroupName string, subscriptionID string) bool { exists, err := VirtualMachineExistsE(vmName, resGroupName, subscriptionID) require.NoError(t, err) return exists } // VirtualMachineExistsE indicates whether the specified Azure Virtual Machine exists. func VirtualMachineExistsE(vmName string, resGroupName string, subscriptionID string) (bool, error) { // Get VM Object _, err := GetVirtualMachineE(vmName, resGroupName, subscriptionID) if err != nil { if ResourceNotFoundErrorExists(err) { return false, nil } return false, err } return true, nil } // GetVirtualMachineNics gets a list of Network Interface names for a specified Azure Virtual Machine. // This function would fail the test if there is an error. func GetVirtualMachineNics(t testing.TestingT, vmName string, resGroupName string, subscriptionID string) []string { nicList, err := GetVirtualMachineNicsE(vmName, resGroupName, subscriptionID) require.NoError(t, err) return nicList } // GetVirtualMachineNicsE gets a list of Network Interface names for a specified Azure Virtual Machine. func GetVirtualMachineNicsE(vmName string, resGroupName string, subscriptionID string) ([]string, error) { // Get VM Object vm, err := GetVirtualMachineE(vmName, resGroupName, subscriptionID) if err != nil { return nil, err } // Get VM NIC(s); value always present, no nil checks needed. vmNICs := *vm.NetworkProfile.NetworkInterfaces nics := make([]string, len(vmNICs)) for i, nic := range vmNICs { // Get ID from resource string. nicName, err := GetNameFromResourceIDE(*nic.ID) if err == nil { nics[i] = nicName } } return nics, nil } // GetVirtualMachineManagedDisks gets the list of Managed Disk names of the specified Azure Virtual Machine. // This function would fail the test if there is an error. func GetVirtualMachineManagedDisks(t testing.TestingT, vmName string, resGroupName string, subscriptionID string) []string { diskNames, err := GetVirtualMachineManagedDisksE(vmName, resGroupName, subscriptionID) require.NoError(t, err) return diskNames } // GetVirtualMachineManagedDisksE gets the list of Managed Disk names of the specified Azure Virtual Machine. func GetVirtualMachineManagedDisksE(vmName string, resGroupName string, subscriptionID string) ([]string, error) { // Get VM Object vm, err := GetVirtualMachineE(vmName, resGroupName, subscriptionID) if err != nil { return nil, err } // Get VM attached Disks; value always present even if no disks attached, no nil check needed. vmDisks := *vm.StorageProfile.DataDisks // Get the Names of the attached Managed Disks diskNames := make([]string, len(vmDisks)) for i, v := range vmDisks { // Disk names are required, no nil check needed. diskNames[i] = *v.Name } return diskNames, nil } // GetVirtualMachineOSDiskName gets the OS Disk name of the specified Azure Virtual Machine. // This function would fail the test if there is an error. func GetVirtualMachineOSDiskName(t testing.TestingT, vmName string, resGroupName string, subscriptionID string) string { osDiskName, err := GetVirtualMachineOSDiskNameE(vmName, resGroupName, subscriptionID) require.NoError(t, err) return osDiskName } // GetVirtualMachineOSDiskNameE gets the OS Disk name of the specified Azure Virtual Machine. func GetVirtualMachineOSDiskNameE(vmName string, resGroupName string, subscriptionID string) (string, error) { // Get VM Object vm, err := GetVirtualMachineE(vmName, resGroupName, subscriptionID) if err != nil { return "", err } return *vm.StorageProfile.OsDisk.Name, nil } // GetVirtualMachineAvailabilitySetID gets the Availability Set ID of the specified Azure Virtual Machine. // This function would fail the test if there is an error. func GetVirtualMachineAvailabilitySetID(t testing.TestingT, vmName string, resGroupName string, subscriptionID string) string { avsID, err := GetVirtualMachineAvailabilitySetIDE(vmName, resGroupName, subscriptionID) require.NoError(t, err) return avsID } // GetVirtualMachineAvailabilitySetIDE gets the Availability Set ID of the specified Azure Virtual Machine. func GetVirtualMachineAvailabilitySetIDE(vmName string, resGroupName string, subscriptionID string) (string, error) { // Get VM Object vm, err := GetVirtualMachineE(vmName, resGroupName, subscriptionID) if err != nil { return "", err } // Virtual Machine has no associated Availability Set if vm.AvailabilitySet == nil { return "", nil } // Get ID from resource string avs, err := GetNameFromResourceIDE(*vm.AvailabilitySet.ID) if err != nil { return "", err } return avs, nil } // VMImage represents the storage image for the specified Azure Virtual Machine. type VMImage struct { Publisher string Offer string SKU string Version string } // GetVirtualMachineImage gets the Image of the specified Azure Virtual Machine. // This function would fail the test if there is an error. func GetVirtualMachineImage(t testing.TestingT, vmName string, resGroupName string, subscriptionID string) VMImage { vmImage, err := GetVirtualMachineImageE(vmName, resGroupName, subscriptionID) require.NoError(t, err) return vmImage } // GetVirtualMachineImageE gets the Image of the specified Azure Virtual Machine. func GetVirtualMachineImageE(vmName string, resGroupName string, subscriptionID string) (VMImage, error) { var vmImage VMImage // Get VM Object vm, err := GetVirtualMachineE(vmName, resGroupName, subscriptionID) if err != nil { return vmImage, err } // Populate VM Image; values always present, no nil checks needed vmImage.Publisher = *vm.StorageProfile.ImageReference.Publisher vmImage.Offer = *vm.StorageProfile.ImageReference.Offer vmImage.SKU = *vm.StorageProfile.ImageReference.Sku vmImage.Version = *vm.StorageProfile.ImageReference.Version return vmImage, nil } // GetSizeOfVirtualMachine gets the Size Type of the specified Azure Virtual Machine. // This function would fail the test if there is an error. func GetSizeOfVirtualMachine(t testing.TestingT, vmName string, resGroupName string, subscriptionID string) compute.VirtualMachineSizeTypes { size, err := GetSizeOfVirtualMachineE(vmName, resGroupName, subscriptionID) require.NoError(t, err) return size } // GetSizeOfVirtualMachineE gets the Size Type of the specified Azure Virtual Machine. func GetSizeOfVirtualMachineE(vmName string, resGroupName string, subscriptionID string) (compute.VirtualMachineSizeTypes, error) { // Get VM Object vm, err := GetVirtualMachineE(vmName, resGroupName, subscriptionID) if err != nil { return "", err } return vm.VirtualMachineProperties.HardwareProfile.VMSize, nil } // GetVirtualMachineTags gets the Tags of the specified Virtual Machine as a map. // This function would fail the test if there is an error. func GetVirtualMachineTags(t testing.TestingT, vmName string, resGroupName string, subscriptionID string) map[string]string { tags, err := GetVirtualMachineTagsE(vmName, resGroupName, subscriptionID) require.NoError(t, err) return tags } // GetVirtualMachineTagsE gets the Tags of the specified Virtual Machine as a map. func GetVirtualMachineTagsE(vmName string, resGroupName string, subscriptionID string) (map[string]string, error) { // Setup a blank map to populate and return tags := make(map[string]string) // Get VM Object vm, err := GetVirtualMachineE(vmName, resGroupName, subscriptionID) if err != nil { return tags, err } // Range through existing tags and populate above map accordingly for k, v := range vm.Tags { tags[k] = *v } return tags, nil } // ***************************************************** // // Get multiple Virtual Machines from a Resource Group // ***************************************************** // // ListVirtualMachinesForResourceGroup gets a list of all Virtual Machine names in the specified Resource Group. // This function would fail the test if there is an error. func ListVirtualMachinesForResourceGroup(t testing.TestingT, resGroupName string, subscriptionID string) []string { vms, err := ListVirtualMachinesForResourceGroupE(resGroupName, subscriptionID) require.NoError(t, err) return vms } // ListVirtualMachinesForResourceGroupE gets a list of all Virtual Machine names in the specified Resource Group. func ListVirtualMachinesForResourceGroupE(resourceGroupName string, subscriptionID string) ([]string, error) { var vmDetails []string vmClient, err := GetVirtualMachineClientE(subscriptionID) if err != nil { return nil, err } vms, err := vmClient.List(context.Background(), resourceGroupName) if err != nil { return nil, err } for _, v := range vms.Values() { vmDetails = append(vmDetails, *v.Name) } return vmDetails, nil } // GetVirtualMachinesForResourceGroup gets all Virtual Machine objects in the specified Resource Group. Each // VM Object represents the entire set of VM compute properties accessible by using the VM name as the map key. // This function would fail the test if there is an error. func GetVirtualMachinesForResourceGroup(t testing.TestingT, resGroupName string, subscriptionID string) map[string]compute.VirtualMachineProperties { vms, err := GetVirtualMachinesForResourceGroupE(resGroupName, subscriptionID) require.NoError(t, err) return vms } // GetVirtualMachinesForResourceGroupE gets all Virtual Machine objects in the specified Resource Group. Each // VM Object represents the entire set of VM compute properties accessible by using the VM name as the map key. func GetVirtualMachinesForResourceGroupE(resourceGroupName string, subscriptionID string) (map[string]compute.VirtualMachineProperties, error) { // Create VM Client vmClient, err := GetVirtualMachineClientE(subscriptionID) if err != nil { return nil, err } // Get the list of VMs in the Resource Group vms, err := vmClient.List(context.Background(), resourceGroupName) if err != nil { return nil, err } // Get the VMs in the Resource Group. vmDetails := make(map[string]compute.VirtualMachineProperties, len(vms.Values())) for _, v := range vms.Values() { // VM name and machine properties are required for each VM, no nil check required. vmDetails[*v.Name] = *v.VirtualMachineProperties } return vmDetails, nil } // ******************************************************************** // // Get VM using Instance and Instance property get, reducing SKD calls // ******************************************************************** // // Instance of the VM type Instance struct { *compute.VirtualMachine } // GetVirtualMachineInstanceSize gets the size of the Virtual Machine. func (vm *Instance) GetVirtualMachineInstanceSize() compute.VirtualMachineSizeTypes { return vm.VirtualMachineProperties.HardwareProfile.VMSize } // *********************** // // Get the base VM Object // *********************** // // GetVirtualMachine gets a Virtual Machine in the specified Azure Resource Group. // This function would fail the test if there is an error. func GetVirtualMachine(t testing.TestingT, vmName string, resGroupName string, subscriptionID string) *compute.VirtualMachine { vm, err := GetVirtualMachineE(vmName, resGroupName, subscriptionID) require.NoError(t, err) return vm } // GetVirtualMachineE gets a Virtual Machine in the specified Azure Resource Group. func GetVirtualMachineE(vmName string, resGroupName string, subscriptionID string) (*compute.VirtualMachine, error) { // Validate resource group name and subscription ID resGroupName, err := getTargetAzureResourceGroupName(resGroupName) if err != nil { return nil, err } // Get the client reference client, err := GetVirtualMachineClientE(subscriptionID) if err != nil { return nil, err } vm, err := client.Get(context.Background(), resGroupName, vmName, compute.InstanceView) if err != nil { return nil, err } return &vm, nil } ================================================ FILE: modules/azure/compute_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package azure import ( "testing" "github.com/stretchr/testify/require" ) /* The below tests are currently stubbed out, with the expectation that they will throw errors. If/when CRUD methods are introduced for Azure Virtual Machines, these tests can be extended. */ func TestGetVirtualMachineE(t *testing.T) { t.Parallel() vmName := "" rgName := "" subID := "" _, err := GetVirtualMachineE(vmName, rgName, subID) require.Error(t, err) } func TestListVirtualMachinesForResourceGroupE(t *testing.T) { t.Parallel() rgName := "" subID := "" _, err := ListVirtualMachinesForResourceGroupE(rgName, subID) require.Error(t, err) } func TestGetVirtualMachinesForResourceGroupE(t *testing.T) { t.Parallel() rgName := "" subID := "" _, err := GetVirtualMachinesForResourceGroupE(rgName, subID) require.Error(t, err) } func TestGetVirtualMachineTagsE(t *testing.T) { t.Parallel() vmName := "" rgName := "" subID := "" _, err := GetVirtualMachineTagsE(vmName, rgName, subID) require.Error(t, err) } func TestGetSizeOfVirtualMachineE(t *testing.T) { t.Parallel() vmName := "" rgName := "" subID := "" _, err := GetSizeOfVirtualMachineE(vmName, rgName, subID) require.Error(t, err) } func TestGetVirtualMachineImageE(t *testing.T) { t.Parallel() vmName := "" rgName := "" subID := "" _, err := GetVirtualMachineImageE(vmName, rgName, subID) require.Error(t, err) } func TestGetVirtualMachineAvailabilitySetIDE(t *testing.T) { t.Parallel() vmName := "" rgName := "" subID := "" _, err := GetVirtualMachineAvailabilitySetIDE(vmName, rgName, subID) require.Error(t, err) } func TestGetVirtualMachineOSDiskNameE(t *testing.T) { t.Parallel() vmName := "" rgName := "" subID := "" _, err := GetVirtualMachineOSDiskNameE(vmName, rgName, subID) require.Error(t, err) } func TestGetVirtualMachineManagedDisksE(t *testing.T) { t.Parallel() vmName := "" rgName := "" subID := "" _, err := GetVirtualMachineManagedDisksE(vmName, rgName, subID) require.Error(t, err) } func TestGetVirtualMachineNicsE(t *testing.T) { t.Parallel() vmName := "" rgName := "" subID := "" _, err := GetVirtualMachineNicsE(vmName, rgName, subID) require.Error(t, err) } func TestVirtualMachineExistsE(t *testing.T) { t.Parallel() vmName := "" rgName := "" subID := "" _, err := VirtualMachineExistsE(vmName, rgName, subID) require.Error(t, err) } ================================================ FILE: modules/azure/container_apps.go ================================================ package azure import ( "context" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appcontainers/armappcontainers/v3" "github.com/stretchr/testify/require" ) // ManagedEnvironmentExists indicates whether the specified Managed Environment exists. // This function would fail the test if there is an error. func ManagedEnvironmentExists(t *testing.T, environmentName string, resourceGroupName string, subscriptionID string) bool { exists, err := ManagedEnvironmentExistsE(environmentName, resourceGroupName, subscriptionID) require.NoError(t, err) return exists } // ManagedEnvironmentExistsE indicates whether the specified Managed Environment exists. func ManagedEnvironmentExistsE(environmentName string, resourceGroupName string, subscriptionID string) (bool, error) { client, err := CreateManagedEnvironmentsClientE(subscriptionID) if err != nil { return false, err } _, err = client.Get(context.Background(), resourceGroupName, environmentName, nil) if err != nil { return false, err } return true, nil } // GetManagedEnvironment gets the Managed Environment object // This function would fail the test if there is an error. func GetManagedEnvironment(t *testing.T, environmentName string, resourceGroupName string, subscriptionID string) *armappcontainers.ManagedEnvironment { env, err := GetManagedEnvironmentE(environmentName, resourceGroupName, subscriptionID) require.NoError(t, err) return env } // GetManagedEnvironmentE gets the Managed Environment object func GetManagedEnvironmentE(environmentName string, resourceGroupName string, subscriptionID string) (*armappcontainers.ManagedEnvironment, error) { client, err := CreateManagedEnvironmentsClientE(subscriptionID) if err != nil { return nil, err } env, err := client.Get(context.Background(), resourceGroupName, environmentName, nil) if err != nil { return nil, err } return &env.ManagedEnvironment, nil } // ContainerAppExists indicates whether the Container App exists for the subscription. // This function would fail the test if there is an error. func ContainerAppExists(t *testing.T, containerAppName string, resourceGroupName string, subscriptionID string) bool { exists, err := ContainerAppExistsE(containerAppName, resourceGroupName, subscriptionID) require.NoError(t, err) return exists } // ContainerAppExistsE indicates whether the Container App exists for the subscription. func ContainerAppExistsE(containerAppName string, resourceGroupName string, subscriptionID string) (bool, error) { client, err := CreateContainerAppsClientE(subscriptionID) if err != nil { return false, err } _, err = client.Get(context.Background(), resourceGroupName, containerAppName, nil) if err != nil { return false, err } return true, nil } // GetContainerApp gets the Container App object // This function would fail the test if there is an error. func GetContainerApp(t *testing.T, containerAppName string, resourceGroupName string, subscriptionID string) *armappcontainers.ContainerApp { app, err := GetContainerAppE(containerAppName, resourceGroupName, subscriptionID) require.NoError(t, err) return app } // GetContainerAppE gets the Container App object func GetContainerAppE(environmentName string, resourceGroupName string, subscriptionID string) (*armappcontainers.ContainerApp, error) { client, err := CreateContainerAppsClientE(subscriptionID) if err != nil { return nil, err } app, err := client.Get(context.Background(), resourceGroupName, environmentName, nil) if err != nil { return nil, err } return &app.ContainerApp, nil } // ContainerAppJobExists indicates whether the Container App Job exists for the subscription. // This function would fail the test if there is an error. func ContainerAppJobExists(t *testing.T, containerAppName string, resourceGroupName string, subscriptionID string) bool { exists, err := ContainerAppJobExistsE(containerAppName, resourceGroupName, subscriptionID) require.NoError(t, err) return exists } // ContainerAppJobExistsE indicates whether the Container App Job exists for the subscription. func ContainerAppJobExistsE(containerAppName string, resourceGroupName string, subscriptionID string) (bool, error) { client, err := CreateContainerAppJobsClientE(subscriptionID) if err != nil { return false, err } _, err = client.Get(context.Background(), resourceGroupName, containerAppName, nil) if err != nil { return false, err } return true, nil } // GetContainerAppJob gets the Container App Job object // This function would fail the test if there is an error. func GetContainerAppJob(t *testing.T, containerAppName string, resourceGroupName string, subscriptionID string) *armappcontainers.Job { app, err := GetContainerAppJobE(containerAppName, resourceGroupName, subscriptionID) require.NoError(t, err) return app } // GetContainerAppJobE gets the Container App Job object func GetContainerAppJobE(environmentName string, resourceGroupName string, subscriptionID string) (*armappcontainers.Job, error) { client, err := CreateContainerAppJobsClientE(subscriptionID) if err != nil { return nil, err } app, err := client.Get(context.Background(), resourceGroupName, environmentName, nil) if err != nil { return nil, err } return &app.Job, nil } ================================================ FILE: modules/azure/container_apps_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package azure import ( "testing" "github.com/stretchr/testify/require" ) /* The below tests are currently stubbed out, with the expectation that they will throw errors. If/when CRUD methods are introduced for Azure Virtual Machines, these tests can be extended. */ func TestManagedEnvironmentExists(t *testing.T) { t.Parallel() environmentName := "" resourceGroupName := "" subscriptionID := "" _, err := ManagedEnvironmentExistsE(environmentName, resourceGroupName, subscriptionID) require.Error(t, err) } func TestGetManagedEnvironmentE(t *testing.T) { t.Parallel() environmentName := "" resourceGroupName := "" subscriptionID := "" _, err := GetManagedEnvironmentE(environmentName, resourceGroupName, subscriptionID) require.Error(t, err) } func TestContainerAppExists(t *testing.T) { t.Parallel() environmentName := "" resourceGroupName := "" subscriptionID := "" _, err := ContainerAppExistsE(environmentName, resourceGroupName, subscriptionID) require.Error(t, err) } func TestGetContainerAppE(t *testing.T) { t.Parallel() environmentName := "" resourceGroupName := "" subscriptionID := "" _, err := GetContainerAppE(environmentName, resourceGroupName, subscriptionID) require.Error(t, err) } func TestContainerAppJobExists(t *testing.T) { t.Parallel() environmentName := "" resourceGroupName := "" subscriptionID := "" _, err := ContainerAppJobExistsE(environmentName, resourceGroupName, subscriptionID) require.Error(t, err) } func TestGetContainerJobAppE(t *testing.T) { t.Parallel() environmentName := "" resourceGroupName := "" subscriptionID := "" _, err := GetContainerAppJobE(environmentName, resourceGroupName, subscriptionID) require.Error(t, err) } ================================================ FILE: modules/azure/containers.go ================================================ package azure import ( "context" "testing" "github.com/Azure/azure-sdk-for-go/services/containerinstance/mgmt/2018-10-01/containerinstance" "github.com/Azure/azure-sdk-for-go/services/containerregistry/mgmt/2019-05-01/containerregistry" "github.com/stretchr/testify/require" ) // ContainerRegistryExists indicates whether the specified container registry exists. // This function would fail the test if there is an error. func ContainerRegistryExists(t *testing.T, registryName string, resourceGroupName string, subscriptionID string) bool { exists, err := ContainerRegistryExistsE(registryName, resourceGroupName, subscriptionID) require.NoError(t, err) return exists } // ContainerRegistryExistsE indicates whether the specified container registry exists. func ContainerRegistryExistsE(registryName string, resourceGroupName string, subscriptionID string) (bool, error) { _, err := GetContainerRegistryE(registryName, resourceGroupName, subscriptionID) if err != nil { if ResourceNotFoundErrorExists(err) { return false, nil } return false, err } return true, nil } // GetContainerRegistry gets the container registry object // This function would fail the test if there is an error. func GetContainerRegistry(t *testing.T, registryName string, resGroupName string, subscriptionID string) *containerregistry.Registry { resource, err := GetContainerRegistryE(registryName, resGroupName, subscriptionID) require.NoError(t, err) return resource } // GetContainerRegistryE gets the container registry object func GetContainerRegistryE(registryName string, resGroupName string, subscriptionID string) (*containerregistry.Registry, error) { rgName, err := getTargetAzureResourceGroupName(resGroupName) if err != nil { return nil, err } client, err := GetContainerRegistryClientE(subscriptionID) if err != nil { return nil, err } resource, err := client.Get(context.Background(), rgName, registryName) if err != nil { return nil, err } return &resource, nil } // GetContainerRegistryClientE is a helper function that will setup an Azure Container Registry client on your behalf func GetContainerRegistryClientE(subscriptionID string) (*containerregistry.RegistriesClient, error) { // Create an ACR client registryClient, err := CreateContainerRegistryClientE(subscriptionID) if err != nil { return nil, err } // Create an authorizer authorizer, err := NewAuthorizer() if err != nil { return nil, err } // Attach authorizer to the client registryClient.Authorizer = *authorizer return registryClient, nil } // ContainerInstanceExists indicates whether the specified container instance exists. // This function would fail the test if there is an error. func ContainerInstanceExists(t *testing.T, instanceName string, resourceGroupName string, subscriptionID string) bool { exists, err := ContainerInstanceExistsE(instanceName, resourceGroupName, subscriptionID) require.NoError(t, err) return exists } // ContainerInstanceExistsE indicates whether the specified container instance exists. func ContainerInstanceExistsE(instanceName string, resourceGroupName string, subscriptionID string) (bool, error) { _, err := GetContainerInstanceE(instanceName, resourceGroupName, subscriptionID) if err != nil { if ResourceNotFoundErrorExists(err) { return false, nil } return false, err } return true, nil } // GetContainerInstance gets the container instance object // This function would fail the test if there is an error. func GetContainerInstance(t *testing.T, instanceName string, resGroupName string, subscriptionID string) *containerinstance.ContainerGroup { instance, err := GetContainerInstanceE(instanceName, resGroupName, subscriptionID) require.NoError(t, err) return instance } // GetContainerInstanceE gets the container instance object func GetContainerInstanceE(instanceName string, resGroupName string, subscriptionID string) (*containerinstance.ContainerGroup, error) { rgName, err := getTargetAzureResourceGroupName(resGroupName) if err != nil { return nil, err } client, err := GetContainerInstanceClientE(subscriptionID) if err != nil { return nil, err } instance, err := client.Get(context.Background(), rgName, instanceName) if err != nil { return nil, err } return &instance, nil } // GetContainerInstanceClientE is a helper function that will setup an Azure Container Instance client on your behalf func GetContainerInstanceClientE(subscriptionID string) (*containerinstance.ContainerGroupsClient, error) { // Create an ACI client instanceClient, err := CreateContainerInstanceClientE(subscriptionID) if err != nil { return nil, err } // Create an authorizer authorizer, err := NewAuthorizer() if err != nil { return nil, err } // Attach authorizer to the client instanceClient.Authorizer = *authorizer return instanceClient, nil } ================================================ FILE: modules/azure/containers_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package azure import ( "testing" "github.com/stretchr/testify/require" ) /* The below tests are currently stubbed out, with the expectation that they will throw errors. If/when CRUD methods are introduced for Azure MySQL server and database, these tests can be extended */ func TestContainerRegistryExistsE(t *testing.T) { t.Parallel() resGroupName := "" registryName := "" subscriptionID := "" _, err := ContainerRegistryExistsE(registryName, resGroupName, subscriptionID) require.Error(t, err) } func TestGetContainerRegistryE(t *testing.T) { t.Parallel() resGroupName := "" registryName := "" subscriptionID := "" _, err := GetContainerRegistryE(registryName, resGroupName, subscriptionID) require.Error(t, err) } func TestGetContainerRegistryClientE(t *testing.T) { t.Parallel() subscriptionID := "" _, err := GetContainerRegistryClientE(subscriptionID) require.NoError(t, err) } func TestContainerInstanceExistsE(t *testing.T) { t.Parallel() resGroupName := "" instanceName := "" subscriptionID := "" _, err := ContainerInstanceExistsE(instanceName, resGroupName, subscriptionID) require.Error(t, err) } func TestGetContainerInstanceE(t *testing.T) { t.Parallel() resGroupName := "" instanceName := "" subscriptionID := "" _, err := GetContainerInstanceE(instanceName, resGroupName, subscriptionID) require.Error(t, err) } func TestGetContainerInstanceClientE(t *testing.T) { t.Parallel() subscriptionID := "" _, err := GetContainerInstanceClientE(subscriptionID) require.NoError(t, err) } ================================================ FILE: modules/azure/cosmosdb.go ================================================ package azure import ( "context" "github.com/Azure/azure-sdk-for-go/profiles/preview/cosmos-db/mgmt/documentdb" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // GetCosmosDBAccountClientE is a helper function that will setup a CosmosDB account client. func GetCosmosDBAccountClientE(subscriptionID string) (*documentdb.DatabaseAccountsClient, error) { // Create a CosmosDB client cosmosClient, err := CreateCosmosDBAccountClientE(subscriptionID) if err != nil { return nil, err } // Create an authorizer authorizer, err := NewAuthorizer() if err != nil { return nil, err } // Attach authorizer to the client cosmosClient.Authorizer = *authorizer return cosmosClient, nil } // GetCosmosDBAccountClient is a helper function that will setup a CosmosDB account client. This function would fail the test if there is an error. func GetCosmosDBAccountClient(t testing.TestingT, subscriptionID string) *documentdb.DatabaseAccountsClient { cosmosDBAccount, err := GetCosmosDBAccountClientE(subscriptionID) require.NoError(t, err) return cosmosDBAccount } // GetCosmosDBAccount is a helper function that gets the database account. This function would fail the test if there is an error. func GetCosmosDBAccount(t testing.TestingT, subscriptionID string, resourceGroupName string, accountName string) *documentdb.DatabaseAccountGetResults { cosmosDBAccount, err := GetCosmosDBAccountE(t, subscriptionID, resourceGroupName, accountName) require.NoError(t, err) return cosmosDBAccount } // GetCosmosDBAccountE is a helper function that gets the database account. func GetCosmosDBAccountE(t testing.TestingT, subscriptionID string, resourceGroupName string, accountName string) (*documentdb.DatabaseAccountGetResults, error) { // Create a CosmosDB client cosmosClient, err := GetCosmosDBAccountClientE(subscriptionID) if err != nil { return nil, err } // Get the corresponding database account cosmosDBAccount, err := cosmosClient.Get(context.Background(), resourceGroupName, accountName) if err != nil { return nil, err } //Return DB return &cosmosDBAccount, nil } // GetCosmosDBSQLClientE is a helper function that will setup a CosmosDB SQL client. func GetCosmosDBSQLClientE(subscriptionID string) (*documentdb.SQLResourcesClient, error) { // Create a CosmosDB client cosmosClient, err := CreateCosmosDBSQLClientE(subscriptionID) if err != nil { return nil, err } // Create an authorizer authorizer, err := NewAuthorizer() if err != nil { return nil, err } // Attach authorizer to the client cosmosClient.Authorizer = *authorizer return cosmosClient, nil } // GetCosmosDBSQLClient is a helper function that will setup a CosmosDB SQL client. This function would fail the test if there is an error. func GetCosmosDBSQLClient(t testing.TestingT, subscriptionID string) *documentdb.SQLResourcesClient { cosmosClient, err := GetCosmosDBSQLClientE(subscriptionID) require.NoError(t, err) return cosmosClient } // GetCosmosDBSQLDatabase is a helper function that gets a SQL database. This function would fail the test if there is an error. func GetCosmosDBSQLDatabase(t testing.TestingT, subscriptionID string, resourceGroupName string, accountName string, databaseName string) *documentdb.SQLDatabaseGetResults { cosmosSQLDB, err := GetCosmosDBSQLDatabaseE(t, subscriptionID, resourceGroupName, accountName, databaseName) require.NoError(t, err) return cosmosSQLDB } // GetCosmosDBSQLDatabaseE is a helper function that gets a SQL database. func GetCosmosDBSQLDatabaseE(t testing.TestingT, subscriptionID string, resourceGroupName string, accountName string, databaseName string) (*documentdb.SQLDatabaseGetResults, error) { // Create a CosmosDB client cosmosClient, err := GetCosmosDBSQLClientE(subscriptionID) if err != nil { return nil, err } // Get the corresponding database cosmosSQLDB, err := cosmosClient.GetSQLDatabase(context.Background(), resourceGroupName, accountName, databaseName) if err != nil { return nil, err } //Return DB return &cosmosSQLDB, nil } // GetCosmosDBSQLContainer is a helper function that gets a SQL container. This function would fail the test if there is an error. func GetCosmosDBSQLContainer(t testing.TestingT, subscriptionID string, resourceGroupName string, accountName string, databaseName string, containerName string) *documentdb.SQLContainerGetResults { cosmosSQLContainer, err := GetCosmosDBSQLContainerE(t, subscriptionID, resourceGroupName, accountName, databaseName, containerName) require.NoError(t, err) return cosmosSQLContainer } // GetCosmosDBSQLContainerE is a helper function that gets a SQL container. func GetCosmosDBSQLContainerE(t testing.TestingT, subscriptionID string, resourceGroupName string, accountName string, databaseName string, containerName string) (*documentdb.SQLContainerGetResults, error) { // Create a CosmosDB client cosmosClient, err := GetCosmosDBSQLClientE(subscriptionID) if err != nil { return nil, err } // Get the corresponding SQL container cosmosSQLContainer, err := cosmosClient.GetSQLContainer(context.Background(), resourceGroupName, accountName, databaseName, containerName) if err != nil { return nil, err } //Return container return &cosmosSQLContainer, nil } // GetCosmosDBSQLDatabaseThroughput is a helper function that gets a SQL database throughput configuration. This function would fail the test if there is an error. func GetCosmosDBSQLDatabaseThroughput(t testing.TestingT, subscriptionID string, resourceGroupName string, accountName string, databaseName string) *documentdb.ThroughputSettingsGetResults { cosmosSQLDBThroughput, err := GetCosmosDBSQLDatabaseThroughputE(t, subscriptionID, resourceGroupName, accountName, databaseName) require.NoError(t, err) return cosmosSQLDBThroughput } // GetCosmosDBSQLDatabaseThroughputE is a helper function that gets a SQL database throughput configuration. func GetCosmosDBSQLDatabaseThroughputE(t testing.TestingT, subscriptionID string, resourceGroupName string, accountName string, databaseName string) (*documentdb.ThroughputSettingsGetResults, error) { // Create a CosmosDB client cosmosClient, err := GetCosmosDBSQLClientE(subscriptionID) if err != nil { return nil, err } // Get the corresponding database throughput config cosmosSQLDBThroughput, err := cosmosClient.GetSQLDatabaseThroughput(context.Background(), resourceGroupName, accountName, databaseName) if err != nil { return nil, err } //Return throughput config return &cosmosSQLDBThroughput, nil } // GetCosmosDBSQLContainerThroughput is a helper function that gets a SQL container throughput configuration. This function would fail the test if there is an error. func GetCosmosDBSQLContainerThroughput(t testing.TestingT, subscriptionID string, resourceGroupName string, accountName string, databaseName string, containerName string) *documentdb.ThroughputSettingsGetResults { cosmosSQLCtrThroughput, err := GetCosmosDBSQLContainerThroughputE(t, subscriptionID, resourceGroupName, accountName, databaseName, containerName) require.NoError(t, err) return cosmosSQLCtrThroughput } // GetCosmosDBSQLContainerThroughputE is a helper function that gets a SQL container throughput configuration. func GetCosmosDBSQLContainerThroughputE(t testing.TestingT, subscriptionID string, resourceGroupName string, accountName string, databaseName string, containerName string) (*documentdb.ThroughputSettingsGetResults, error) { // Create a CosmosDB client cosmosClient, err := GetCosmosDBSQLClientE(subscriptionID) if err != nil { return nil, err } // Get the corresponding container throughput config cosmosSQLCtrThroughput, err := cosmosClient.GetSQLContainerThroughput(context.Background(), resourceGroupName, accountName, databaseName, containerName) if err != nil { return nil, err } //Return throughput config return &cosmosSQLCtrThroughput, nil } ================================================ FILE: modules/azure/datafactory.go ================================================ package azure import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/datafactory/armdatafactory/v9" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // DataFactoryExists indicates whether the Data Factory exists for the subscription. // This function would fail the test if there is an error. func DataFactoryExists(t testing.TestingT, dataFactoryName string, resourceGroupName string, subscriptionID string) bool { exists, err := DataFactoryExistsE(dataFactoryName, resourceGroupName, subscriptionID) require.NoError(t, err) return exists } // DataFactoryExistsE indicates whether the specified Data Factory exists and may return an error. func DataFactoryExistsE(dataFactoryName string, resourceGroupName string, subscriptionID string) (bool, error) { _, err := GetDataFactoryE(subscriptionID, resourceGroupName, dataFactoryName) if err != nil { if ResourceNotFoundErrorExists(err) { return false, nil } return false, err } return true, nil } // GetDataFactory is a helper function that gets the data factory. // This function would fail the test if there is an error. func GetDataFactory(t testing.TestingT, resGroupName string, factoryName string, subscriptionID string) *armdatafactory.Factory { factory, err := GetDataFactoryE(subscriptionID, resGroupName, factoryName) require.NoError(t, err) return factory } // GetDataFactoryE is a helper function that gets the data factory. func GetDataFactoryE(subscriptionID string, resGroupName string, factoryName string) (*armdatafactory.Factory, error) { // Create a datafactory client datafactoryClient, err := CreateDataFactoriesClientE(subscriptionID) if err != nil { return nil, err } // Get the corresponding data factory resp, err := datafactoryClient.Get(context.Background(), resGroupName, factoryName, nil) if err != nil { return nil, err } // Return data factory return &resp.Factory, nil } ================================================ FILE: modules/azure/datafactory_test.go ================================================ package azure import ( "testing" "github.com/stretchr/testify/require" ) /* The below tests are currently stubbed out, with the expectation that they will throw errors. If/when CRUD methods are introduced for Azure Synapse, these tests can be extended */ func TestDataFactoryExists(t *testing.T) { t.Parallel() dataFactoryName := "" resourceGroupName := "" subscriptionID := "" exists, err := DataFactoryExistsE(dataFactoryName, resourceGroupName, subscriptionID) require.False(t, exists) require.Error(t, err) } func TestGetDataFactoryE(t *testing.T) { t.Parallel() resGroupName := "" subscriptionID := "" dataFactoryName := "" _, err := GetDataFactoryE(subscriptionID, resGroupName, dataFactoryName) require.Error(t, err) } ================================================ FILE: modules/azure/disk.go ================================================ package azure import ( "context" "github.com/Azure/azure-sdk-for-go/services/compute/mgmt/2019-07-01/compute" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // DiskExists indicates whether the specified Azure Managed Disk exists // This function would fail the test if there is an error. func DiskExists(t testing.TestingT, diskName string, resGroupName string, subscriptionID string) bool { exists, err := DiskExistsE(diskName, resGroupName, subscriptionID) require.NoError(t, err) return exists } // DiskExistsE indicates whether the specified Azure Managed Disk exists in the specified Azure Resource Group func DiskExistsE(diskName string, resGroupName string, subscriptionID string) (bool, error) { // Get the Disk object _, err := GetDiskE(diskName, resGroupName, subscriptionID) if err != nil { if ResourceNotFoundErrorExists(err) { return false, nil } return false, err } return true, nil } // GetDisk returns a Disk in the specified Azure Resource Group // This function would fail the test if there is an error. func GetDisk(t testing.TestingT, diskName string, resGroupName string, subscriptionID string) *compute.Disk { disk, err := GetDiskE(diskName, resGroupName, subscriptionID) require.NoError(t, err) return disk } // GetDiskE returns a Disk in the specified Azure Resource Group func GetDiskE(diskName string, resGroupName string, subscriptionID string) (*compute.Disk, error) { // Validate resource group name and subscription ID resGroupName, err := getTargetAzureResourceGroupName(resGroupName) if err != nil { return nil, err } // Get the client reference client, err := CreateDisksClientE(subscriptionID) if err != nil { return nil, err } // Get the Disk disk, err := client.Get(context.Background(), resGroupName, diskName) if err != nil { return nil, err } return &disk, nil } // GetDiskClientE returns a new Disk client in the specified Azure Subscription // TODO: remove in next major/minor version func GetDiskClientE(subscriptionID string) (*compute.DisksClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Get the Disk client client := compute.NewDisksClient(subscriptionID) // Create an authorizer authorizer, err := NewAuthorizer() if err != nil { return nil, err } client.Authorizer = *authorizer return &client, nil } ================================================ FILE: modules/azure/disk_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package azure import ( "testing" "github.com/stretchr/testify/require" ) func TestGetDiskE(t *testing.T) { t.Parallel() diskName := "" rgName := "" subID := "" _, err := GetDiskE(diskName, rgName, subID) require.Error(t, err) } ================================================ FILE: modules/azure/enums.go ================================================ package azure // LoadBalancerIPType enumerator for types Public, Private or No IP. type LoadBalancerIPType string // LoadBalancerIPType values const ( PublicIP LoadBalancerIPType = "PublicIP" PrivateIP LoadBalancerIPType = "PrivateIP" NoIP LoadBalancerIPType = "NoIP" ) ================================================ FILE: modules/azure/errors.go ================================================ package azure import ( "fmt" "github.com/Azure/go-autorest/autorest" "github.com/Azure/go-autorest/autorest/azure" ) // SubscriptionIDNotFound is an error that occurs when the Azure Subscription ID could not be found or was not provided type SubscriptionIDNotFound struct{} func (err SubscriptionIDNotFound) Error() string { return fmt.Sprintf("Could not find an Azure Subscription ID in expected environment variable %s and one was not provided for this test.", AzureSubscriptionID) } // ResourceGroupNameNotFound is an error that occurs when the target Azure Resource Group name could not be found or was not provided type ResourceGroupNameNotFound struct{} func (err ResourceGroupNameNotFound) Error() string { return fmt.Sprintf("Could not find an Azure Resource Group name in expected environment variable %s and one was not provided for this test.", AzureResGroupName) } // FailedToParseError is returned when an object cannot be parsed type FailedToParseError struct { objectType string objectID string } func (err FailedToParseError) Error() string { return fmt.Sprintf("Failed to parse %s with ID %s", err.objectType, err.objectID) } // NewFailedToParseError creates a new not found error when an expected object is not found in the search space func NewFailedToParseError(objectType string, objectID string) FailedToParseError { return FailedToParseError{objectType, objectID} } // NotFoundError is returned when an expected object is not found in the search space type NotFoundError struct { objectType string objectID string searchSpace string } func (err NotFoundError) Error() string { var objIDMsg string if err.objectID != "Any" { objIDMsg = fmt.Sprintf(" with id %s", err.objectID) } return fmt.Sprintf("Object of type %s%s not found in %s", err.objectType, objIDMsg, err.searchSpace) } // NewNotFoundError creates a new not found error when an expected object is not found in the search space func NewNotFoundError(objectType string, objectID string, region string) NotFoundError { return NotFoundError{objectType, objectID, region} } // ResourceNotFoundErrorExists checks the Service Error Code for the 'Resource Not Found' error func ResourceNotFoundErrorExists(err error) bool { if err != nil { if autorestError, ok := err.(autorest.DetailedError); ok { if requestError, ok := autorestError.Original.(*azure.RequestError); ok { return (requestError.ServiceError.Code == "ResourceNotFound") } } } return false } ================================================ FILE: modules/azure/frontdoor.go ================================================ package azure import ( "context" "github.com/Azure/azure-sdk-for-go/profiles/latest/frontdoor/mgmt/frontdoor" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // FrontDoorExists indicates whether the Front Door exists for the subscription. // This function would fail the test if there is an error. func FrontDoorExists(t testing.TestingT, frontDoorName string, resourceGroupName string, subscriptionID string) bool { exists, err := FrontDoorExistsE(frontDoorName, resourceGroupName, subscriptionID) require.NoError(t, err) return exists } // GetFrontDoor gets a Front Door by name if it exists for the subscription. // This function would fail the test if there is an error. func GetFrontDoor(t testing.TestingT, frontDoorName string, resourceGroupName string, subscriptionID string) *frontdoor.FrontDoor { fd, err := GetFrontDoorE(frontDoorName, resourceGroupName, subscriptionID) require.NoError(t, err) return fd } // FrontDoorFrontendEndpointExists indicates whether the frontend endpoint exists for the provided Front Door. // This function would fail the test if there is an error. func FrontDoorFrontendEndpointExists(t testing.TestingT, endpointName string, frontDoorName string, resourceGroupName string, subscriptionID string) bool { exists, err := FrontDoorFrontendEndpointExistsE(endpointName, frontDoorName, resourceGroupName, subscriptionID) require.NoError(t, err) return exists } // GetFrontDoorFrontendEndpoint gets a frontend endpoint by name for the provided Front Door if it exists for the subscription. // This function would fail the test if there is an error. func GetFrontDoorFrontendEndpoint(t testing.TestingT, endpointName string, frontDoorName string, resourceGroupName string, subscriptionID string) *frontdoor.FrontendEndpoint { ep, err := GetFrontDoorFrontendEndpointE(endpointName, frontDoorName, resourceGroupName, subscriptionID) require.NoError(t, err) return ep } // FrontDoorExistsE indicates whether the specified Front Door exists and may return an error. func FrontDoorExistsE(frontDoorName string, resourceGroupName string, subscriptionID string) (bool, error) { _, err := GetFrontDoorE(frontDoorName, resourceGroupName, subscriptionID) if err != nil { if ResourceNotFoundErrorExists(err) { return false, nil } return false, err } return true, nil } // FrontDoorFrontendEndpointExistsE indicates whether the specified endpoint exists for the provided Front Door and may return an error. func FrontDoorFrontendEndpointExistsE(endpointName string, frontDoorName string, resourceGroupName string, subscriptionID string) (bool, error) { _, err := GetFrontDoorFrontendEndpointE(endpointName, frontDoorName, resourceGroupName, subscriptionID) if err != nil { if ResourceNotFoundErrorExists(err) { return false, nil } return false, err } return true, nil } // GetFrontDoorE gets the specified Front Door if it exists and may return an error. func GetFrontDoorE(frontDoorName, resourceGroupName, subscriptionID string) (*frontdoor.FrontDoor, error) { client, err := GetFrontDoorClientE(subscriptionID) if err != nil { return nil, err } fd, err := client.Get(context.Background(), resourceGroupName, frontDoorName) if err != nil { return nil, err } return &fd, nil } // GetFrontDoorFrontendEndpointE gets the specified Frontend Endpoint for the provided Front Door if it exists and may return an error. func GetFrontDoorFrontendEndpointE(endpointName, frontDoorName, resourceGroupName, subscriptionID string) (*frontdoor.FrontendEndpoint, error) { client, err := GetFrontDoorFrontendEndpointClientE(subscriptionID) if err != nil { return nil, err } endpoint, err := client.Get(context.Background(), resourceGroupName, frontDoorName, endpointName) if err != nil { return nil, err } return &endpoint, nil } // GetFrontDoorClientE return a front door client; otherwise error. func GetFrontDoorClientE(subscriptionID string) (*frontdoor.FrontDoorsClient, error) { client, err := CreateFrontDoorClientE(subscriptionID) if err != nil { return nil, err } authorizer, err := NewAuthorizer() if err != nil { return nil, err } client.Authorizer = *authorizer return client, nil } // GetFrontDoorFrontendEndpointClientE returns a front door frontend endpoints client; otherwise error. func GetFrontDoorFrontendEndpointClientE(subscriptionID string) (*frontdoor.FrontendEndpointsClient, error) { client, err := CreateFrontDoorFrontendEndpointClientE(subscriptionID) if err != nil { return nil, err } authorizer, err := NewAuthorizer() if err != nil { return nil, err } client.Authorizer = *authorizer return client, nil } ================================================ FILE: modules/azure/frontdoor_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package azure import ( "testing" "github.com/stretchr/testify/require" ) /* The below tests are currently stubbed out, with the expectation that they will throw errors. If/when methods to create and delete front door are added, these tests can be extended. */ func TestFrontDoorExists(t *testing.T) { t.Parallel() frontDoorName := "TestFrontDoor" resourceGroupName := "TestResourceGroup" subscriptionID := "" exists, err := FrontDoorExistsE(frontDoorName, resourceGroupName, subscriptionID) require.False(t, exists) require.Error(t, err) } func TestGetFrontDoor(t *testing.T) { t.Parallel() frontDoorName := "TestFrontDoor" resourceGroupName := "TestResourceGroup" subscriptionID := "" instance, err := GetFrontDoorE(frontDoorName, resourceGroupName, subscriptionID) require.Nil(t, instance) require.Error(t, err) } func TestFrontDoorFrontendEndpointExists(t *testing.T) { t.Parallel() endpointName := "TestFrontendEndpoint" frontDoorName := "TestFrontDoor" resourceGroupName := "TestResourceGroup" subscriptionID := "" endpoint, err := FrontDoorFrontendEndpointExistsE(endpointName, frontDoorName, resourceGroupName, subscriptionID) require.False(t, endpoint) require.Error(t, err) } func TestGetFrontDoorFrontendEndpoint(t *testing.T) { t.Parallel() endpointName := "TestFrontendEndpoint" frontDoorName := "TestFrontDoor" resourceGroupName := "TestResourceGroup" subscriptionID := "" endpoint, err := GetFrontDoorFrontendEndpointE(endpointName, frontDoorName, resourceGroupName, subscriptionID) require.Nil(t, endpoint) require.Error(t, err) } ================================================ FILE: modules/azure/keyvault.go ================================================ package azure import ( "context" "fmt" "testing" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault" "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates" "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys" "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets" "github.com/stretchr/testify/require" ) // NewAzureCredentialE creates a new Azure credential using DefaultAzureCredential. func NewAzureCredentialE() (*azidentity.DefaultAzureCredential, error) { return azidentity.NewDefaultAzureCredential(nil) } // KeyVaultSecretExists indicates whether a key vault secret exists; otherwise false // This function would fail the test if there is an error. func KeyVaultSecretExists(t *testing.T, keyVaultName string, secretName string) bool { result, err := KeyVaultSecretExistsE(keyVaultName, secretName) require.NoError(t, err) return result } // KeyVaultKeyExists indicates whether a key vault key exists; otherwise false. // This function would fail the test if there is an error. func KeyVaultKeyExists(t *testing.T, keyVaultName string, keyName string) bool { result, err := KeyVaultKeyExistsE(keyVaultName, keyName) require.NoError(t, err) return result } // KeyVaultCertificateExists indicates whether a key vault certificate exists; otherwise false. // This function would fail the test if there is an error. func KeyVaultCertificateExists(t *testing.T, keyVaultName string, certificateName string) bool { result, err := KeyVaultCertificateExistsE(keyVaultName, certificateName) require.NoError(t, err) return result } // KeyVaultCertificateExistsE indicates whether a certificate exists in key vault; otherwise false. func KeyVaultCertificateExistsE(keyVaultName, certificateName string) (bool, error) { client, err := GetKeyVaultCertificatesClientE(keyVaultName) if err != nil { return false, err } pager := client.NewListCertificatePropertiesVersionsPager(certificateName, nil) if pager.More() { _, err := pager.NextPage(context.Background()) if err != nil { if ResourceNotFoundErrorExists(err) { return false, nil } return false, err } return true, nil } return false, nil } // KeyVaultKeyExistsE indicates whether a key exists in the key vault; otherwise false. func KeyVaultKeyExistsE(keyVaultName, keyName string) (bool, error) { client, err := GetKeyVaultKeysClientE(keyVaultName) if err != nil { return false, err } pager := client.NewListKeyPropertiesVersionsPager(keyName, nil) if pager.More() { _, err := pager.NextPage(context.Background()) if err != nil { if ResourceNotFoundErrorExists(err) { return false, nil } return false, err } return true, nil } return false, nil } // KeyVaultSecretExistsE indicates whether a secret exists in the key vault; otherwise false. func KeyVaultSecretExistsE(keyVaultName, secretName string) (bool, error) { client, err := GetKeyVaultSecretsClientE(keyVaultName) if err != nil { return false, err } pager := client.NewListSecretPropertiesVersionsPager(secretName, nil) if pager.More() { _, err := pager.NextPage(context.Background()) if err != nil { if ResourceNotFoundErrorExists(err) { return false, nil } return false, err } return true, nil } return false, nil } // GetKeyVaultSecretsClientE creates a KeyVault secrets client. func GetKeyVaultSecretsClientE(keyVaultName string) (*azsecrets.Client, error) { keyVaultSuffix, err := GetKeyVaultURISuffixE() if err != nil { return nil, err } vaultURL := fmt.Sprintf("https://%s.%s", keyVaultName, keyVaultSuffix) cred, err := NewAzureCredentialE() if err != nil { return nil, err } return azsecrets.NewClient(vaultURL, cred, nil) } // GetKeyVaultKeysClientE creates a KeyVault keys client. func GetKeyVaultKeysClientE(keyVaultName string) (*azkeys.Client, error) { keyVaultSuffix, err := GetKeyVaultURISuffixE() if err != nil { return nil, err } vaultURL := fmt.Sprintf("https://%s.%s", keyVaultName, keyVaultSuffix) cred, err := NewAzureCredentialE() if err != nil { return nil, err } return azkeys.NewClient(vaultURL, cred, nil) } // GetKeyVaultCertificatesClientE creates a KeyVault certificates client. func GetKeyVaultCertificatesClientE(keyVaultName string) (*azcertificates.Client, error) { keyVaultSuffix, err := GetKeyVaultURISuffixE() if err != nil { return nil, err } vaultURL := fmt.Sprintf("https://%s.%s", keyVaultName, keyVaultSuffix) cred, err := NewAzureCredentialE() if err != nil { return nil, err } return azcertificates.NewClient(vaultURL, cred, nil) } // GetKeyVault is a helper function that gets the keyvault management object. // This function would fail the test if there is an error. func GetKeyVault(t *testing.T, resGroupName string, keyVaultName string, subscriptionID string) *armkeyvault.Vault { keyVault, err := GetKeyVaultE(t, resGroupName, keyVaultName, subscriptionID) require.NoError(t, err) return keyVault } // GetKeyVaultE is a helper function that gets the keyvault management object. func GetKeyVaultE(t *testing.T, resGroupName string, keyVaultName string, subscriptionID string) (*armkeyvault.Vault, error) { // Create a key vault management client vaultClient, err := GetKeyVaultManagementClientE(subscriptionID) if err != nil { return nil, err } // Get the corresponding vault resp, err := vaultClient.Get(context.Background(), resGroupName, keyVaultName, nil) if err != nil { return nil, err } return &resp.Vault, nil } // GetKeyVaultManagementClientE is a helper function that will setup a key vault management client func GetKeyVaultManagementClientE(subscriptionID string) (*armkeyvault.VaultsClient, error) { clientFactory, err := getArmKeyVaultClientFactory(subscriptionID) if err != nil { return nil, err } return clientFactory.NewVaultsClient(), nil } ================================================ FILE: modules/azure/keyvault_test.go ================================================ package azure import ( "testing" "github.com/stretchr/testify/require" ) /* The below tests are currently stubbed out, with the expectation that they will throw errors. If/when methods to create and delete key vault resources are added, these tests can be extended. */ func TestKeyVaultSecretExists(t *testing.T) { t.Parallel() testKeyVaultName := "fakeKeyVault" testKeyVaultSecretName := "fakeSecretName" _, err := KeyVaultSecretExistsE(testKeyVaultName, testKeyVaultSecretName) require.Error(t, err) } func TestKeyVaultKeyExists(t *testing.T) { t.Parallel() testKeyVaultName := "fakeKeyVault" testKeyVaultKeyName := "fakeKeyName" _, err := KeyVaultKeyExistsE(testKeyVaultName, testKeyVaultKeyName) require.Error(t, err) } func TestKeyVaultCertificateExists(t *testing.T) { t.Parallel() testKeyVaultName := "fakeKeyVault" testKeyVaultCertName := "fakeCertName" _, err := KeyVaultCertificateExistsE(testKeyVaultName, testKeyVaultCertName) require.Error(t, err) } func TestGetKeyVault(t *testing.T) { t.Parallel() resGroupName := "" keyVaultName := "" subscriptionID := "" _, err := GetKeyVaultE(t, resGroupName, keyVaultName, subscriptionID) require.Error(t, err) } ================================================ FILE: modules/azure/loadbalancer.go ================================================ package azure import ( "context" "github.com/Azure/azure-sdk-for-go/services/network/mgmt/2019-09-01/network" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // LoadBalancerExists indicates whether the specified Load Balancer exists. // This function would fail the test if there is an error. func LoadBalancerExists(t testing.TestingT, loadBalancerName string, resourceGroupName string, subscriptionID string) bool { exists, err := LoadBalancerExistsE(loadBalancerName, resourceGroupName, subscriptionID) require.NoError(t, err) return exists } // LoadBalancerExistsE indicates whether the specified Load Balancer exists. func LoadBalancerExistsE(loadBalancerName string, resourceGroupName string, subscriptionID string) (bool, error) { _, err := GetLoadBalancerE(loadBalancerName, resourceGroupName, subscriptionID) if err != nil { if ResourceNotFoundErrorExists(err) { return false, nil } return false, err } return true, nil } // GetLoadBalancerFrontendIPConfigNames gets a list of the Frontend IP Configuration Names for the Load Balancer. // This function would fail the test if there is an error. func GetLoadBalancerFrontendIPConfigNames(t testing.TestingT, loadBalancerName string, resourceGroupName string, subscriptionID string) []string { configName, err := GetLoadBalancerFrontendIPConfigNamesE(loadBalancerName, resourceGroupName, subscriptionID) require.NoError(t, err) return configName } // GetLoadBalancerFrontendIPConfigNamesE gets a list of the Frontend IP Configuration Names for the Load Balancer. func GetLoadBalancerFrontendIPConfigNamesE(loadBalancerName string, resourceGroupName string, subscriptionID string) ([]string, error) { lb, err := GetLoadBalancerE(loadBalancerName, resourceGroupName, subscriptionID) if err != nil { return nil, err } // Get the Frontend IP Configurations lbProps := lb.LoadBalancerPropertiesFormat feConfigs := *lbProps.FrontendIPConfigurations if len(feConfigs) == 0 { // No Frontend IP Configuration present return nil, nil } // Get the names of the Frontend IP Configurations present configNames := make([]string, len(feConfigs)) for i, config := range feConfigs { configNames[i] = *config.Name } return configNames, nil } // GetIPOfLoadBalancerFrontendIPConfig gets the IP and LoadBalancerIPType for the specified Load Balancer Frontend IP Configuration. // This function would fail the test if there is an error. func GetIPOfLoadBalancerFrontendIPConfig(t testing.TestingT, feConfigName string, loadBalancerName string, resourceGroupName string, subscriptionID string) (ipAddress string, publicOrPrivate LoadBalancerIPType) { ipAddress, ipType, err := GetIPOfLoadBalancerFrontendIPConfigE(feConfigName, loadBalancerName, resourceGroupName, subscriptionID) require.NoError(t, err) return ipAddress, ipType } // GetIPOfLoadBalancerFrontendIPConfigE gets the IP and LoadBalancerIPType for the specified Load Balancer Frontend IP Configuration. func GetIPOfLoadBalancerFrontendIPConfigE(feConfigName string, loadBalancerName string, resourceGroupName string, subscriptionID string) (ipAddress string, publicOrPrivate LoadBalancerIPType, err1 error) { // Get the specified Load Balancer Frontend Config feConfig, err := GetLoadBalancerFrontendIPConfigE(feConfigName, loadBalancerName, resourceGroupName, subscriptionID) if err != nil { return "", NoIP, err } // Get the Properties of the Frontend Configuration feProps := *feConfig.FrontendIPConfigurationPropertiesFormat // Check for the Public Type Frontend Config if feProps.PublicIPAddress != nil { // Get PublicIPAddress resource name from the Load Balancer Frontend Configuration pipName := GetNameFromResourceID(*feProps.PublicIPAddress.ID) // Get the Public IP of the PublicIPAddress ipValue, err := GetIPOfPublicIPAddressByNameE(pipName, resourceGroupName, subscriptionID) if err != nil { return "", NoIP, err } return ipValue, PublicIP, nil } // Return the Private IP as there are no other option available return *feProps.PrivateIPAddress, PrivateIP, nil } // GetLoadBalancerFrontendIPConfig gets the specified Load Balancer Frontend IP Configuration network resource. // This function would fail the test if there is an error. func GetLoadBalancerFrontendIPConfig(t testing.TestingT, feConfigName string, loadBalancerName string, resourceGroupName string, subscriptionID string) *network.FrontendIPConfiguration { lbFEConfig, err := GetLoadBalancerFrontendIPConfigE(feConfigName, loadBalancerName, resourceGroupName, subscriptionID) require.NoError(t, err) return lbFEConfig } // GetLoadBalancerFrontendIPConfigE gets the specified Load Balancer Frontend IP Configuration network resource. func GetLoadBalancerFrontendIPConfigE(feConfigName string, loadBalancerName string, resourceGroupName string, subscriptionID string) (*network.FrontendIPConfiguration, error) { // Validate Azure Resource Group Name resourceGroupName, err := getTargetAzureResourceGroupName(resourceGroupName) if err != nil { return nil, err } // Get the client reference client, err := GetLoadBalancerFrontendIPConfigClientE(subscriptionID) if err != nil { return nil, err } // Get the Load Balancer Frontend IP Configuration lbc, err := client.Get(context.Background(), resourceGroupName, loadBalancerName, feConfigName) if err != nil { return nil, err } return &lbc, nil } // GetLoadBalancerFrontendIPConfigClientE gets a new Load Balancer Frontend IP Configuration client in the specified Azure Subscription. func GetLoadBalancerFrontendIPConfigClientE(subscriptionID string) (*network.LoadBalancerFrontendIPConfigurationsClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Get the Load Balancer Frontend Configuration client client := network.NewLoadBalancerFrontendIPConfigurationsClient(subscriptionID) // Create an authorizer authorizer, err := NewAuthorizer() if err != nil { return nil, err } client.Authorizer = *authorizer return &client, nil } // GetLoadBalancer gets a Load Balancer network resource in the specified Azure Resource Group. // This function would fail the test if there is an error. func GetLoadBalancer(t testing.TestingT, loadBalancerName string, resourceGroupName string, subscriptionID string) *network.LoadBalancer { lb, err := GetLoadBalancerE(loadBalancerName, resourceGroupName, subscriptionID) require.NoError(t, err) return lb } // GetLoadBalancerE gets a Load Balancer network resource in the specified Azure Resource Group. func GetLoadBalancerE(loadBalancerName string, resourceGroupName string, subscriptionID string) (*network.LoadBalancer, error) { // Validate Azure Resource Group Name resourceGroupName, err := getTargetAzureResourceGroupName(resourceGroupName) if err != nil { return nil, err } // Get the client reference client, err := GetLoadBalancerClientE(subscriptionID) if err != nil { return nil, err } // Get the Load Balancer lb, err := client.Get(context.Background(), resourceGroupName, loadBalancerName, "") if err != nil { return nil, err } return &lb, nil } // GetLoadBalancerClientE gets a new Load Balancer client in the specified Azure Subscription. func GetLoadBalancerClientE(subscriptionID string) (*network.LoadBalancersClient, error) { // Get the Load Balancer client client, err := CreateLoadBalancerClientE(subscriptionID) if err != nil { return nil, err } // Create an authorizer authorizer, err := NewAuthorizer() if err != nil { return nil, err } client.Authorizer = *authorizer return client, nil } ================================================ FILE: modules/azure/loadbalancer_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package azure import ( "testing" "github.com/stretchr/testify/require" ) /* The below tests are currently stubbed out, with the expectation that they will throw errors. If/when methods can be mocked or Create/Delete APIs are added, these tests can be extended. */ func TestLoadBalancerExistsE(t *testing.T) { t.Parallel() loadBalancerName := "" resourceGroupName := "" subscriptionID := "" _, err := LoadBalancerExistsE(loadBalancerName, resourceGroupName, subscriptionID) require.Error(t, err) } func TestGetLoadBalancerE(t *testing.T) { t.Parallel() loadBalancerName := "" resourceGroupName := "" subscriptionID := "" _, err := GetLoadBalancerE(loadBalancerName, resourceGroupName, subscriptionID) require.Error(t, err) } ================================================ FILE: modules/azure/loganalytics.go ================================================ package azure import ( "context" "fmt" "github.com/Azure/azure-sdk-for-go/services/preview/operationalinsights/mgmt/2020-03-01-preview/operationalinsights" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // LogAnalyticsWorkspaceExists indicates whether the operatonal insights workspaces exists. // This function would fail the test if there is an error. func LogAnalyticsWorkspaceExists(t testing.TestingT, workspaceName string, resourceGroupName string, subscriptionID string) bool { exists, err := LogAnalyticsWorkspaceExistsE(workspaceName, resourceGroupName, subscriptionID) require.NoError(t, err) return exists } // GetLogAnalyticsWorkspace gets an operational insights workspace if it exists in a subscription. // This function would fail the test if there is an error. func GetLogAnalyticsWorkspace(t testing.TestingT, workspaceName string, resourceGroupName string, subscriptionID string) *operationalinsights.Workspace { ws, err := GetLogAnalyticsWorkspaceE(workspaceName, resourceGroupName, subscriptionID) require.NoError(t, err) return ws } // GetLogAnalyticsWorkspaceE gets an operational insights workspace if it exists in a subscription. func GetLogAnalyticsWorkspaceE(workspaceName, resoureGroupName, subscriptionID string) (*operationalinsights.Workspace, error) { client, err := GetLogAnalyticsWorkspacesClientE(subscriptionID) if err != nil { return nil, err } ws, err := client.Get(context.Background(), resoureGroupName, workspaceName) if err != nil { return nil, err } return &ws, nil } // LogAnalyticsWorkspaceExistsE indicates whether the operatonal insights workspaces exists and may return an error. func LogAnalyticsWorkspaceExistsE(workspaceName string, resourceGroupName string, subscriptionID string) (bool, error) { _, err := GetLogAnalyticsWorkspaceE(workspaceName, resourceGroupName, subscriptionID) if err != nil { if ResourceNotFoundErrorExists(err) { return false, nil } return false, err } return true, nil } // GetLogAnalyticsWorkspacesClientE return workspaces client; otherwise error. func GetLogAnalyticsWorkspacesClientE(subscriptionID string) (*operationalinsights.WorkspacesClient, error) { subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { fmt.Println("Workspace client error getting subscription") return nil, err } client := operationalinsights.NewWorkspacesClient(subscriptionID) authorizer, err := NewAuthorizer() if err != nil { fmt.Println("authorizer error") return nil, err } client.Authorizer = *authorizer return &client, nil } ================================================ FILE: modules/azure/loganalytics_test.go ================================================ package azure import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) /* The below tests are currently stubbed out, with the expectation that they will throw errors. If/when methods to create and delete log analytics resources are added, these tests can be extended. */ func TestLogAnalyticsWorkspace(t *testing.T) { t.Parallel() _, err := LogAnalyticsWorkspaceExistsE("fake", "", "") assert.Error(t, err, "Workspace") } func TestGetLogAnalyticsWorkspaceE(t *testing.T) { t.Parallel() workspaceName := "" resourceGroupName := "" subscriptionID := "" _, err := GetLogAnalyticsWorkspaceE(workspaceName, resourceGroupName, subscriptionID) require.Error(t, err) } ================================================ FILE: modules/azure/monitor.go ================================================ package azure import ( "context" "github.com/Azure/azure-sdk-for-go/profiles/preview/preview/monitor/mgmt/insights" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // DiagnosticSettingsResourceExists indicates whether the diagnostic settings resource exists // This function would fail the test if there is an error. func DiagnosticSettingsResourceExists(t testing.TestingT, diagnosticSettingsResourceName string, resourceURI string, subscriptionID string) bool { exists, err := DiagnosticSettingsResourceExistsE(diagnosticSettingsResourceName, resourceURI, subscriptionID) require.NoError(t, err) return exists } // DiagnosticSettingsResourceExistsE indicates whether the diagnostic settings resource exists func DiagnosticSettingsResourceExistsE(diagnosticSettingsResourceName string, resourceURI string, subscriptionID string) (bool, error) { _, err := GetDiagnosticsSettingsResourceE(diagnosticSettingsResourceName, resourceURI, subscriptionID) if err != nil { if ResourceNotFoundErrorExists(err) { return false, nil } return false, err } return true, nil } // GetDiagnosticsSettingsResource gets the diagnostics settings for a specified resource // This function would fail the test if there is an error. func GetDiagnosticsSettingsResource(t testing.TestingT, name string, resourceURI string, subscriptionID string) *insights.DiagnosticSettingsResource { resource, err := GetDiagnosticsSettingsResourceE(name, resourceURI, subscriptionID) require.NoError(t, err) return resource } // GetDiagnosticsSettingsResourceE gets the diagnostics settings for a specified resource func GetDiagnosticsSettingsResourceE(name string, resourceURI string, subscriptionID string) (*insights.DiagnosticSettingsResource, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } client, err := CreateDiagnosticsSettingsClientE(subscriptionID) if err != nil { return nil, err } settings, err := client.Get(context.Background(), resourceURI, name) if err != nil { return nil, err } return &settings, nil } // GetDiagnosticsSettingsClientE returns a diagnostics settings client // TODO: delete in next version func GetDiagnosticsSettingsClientE(subscriptionID string) (*insights.DiagnosticSettingsClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } client := insights.NewDiagnosticSettingsClient(subscriptionID) authorizer, err := NewAuthorizer() if err != nil { return nil, err } client.Authorizer = *authorizer return &client, nil } // GetVMInsightsOnboardingStatus get diagnostics VM onboarding status // This function would fail the test if there is an error. func GetVMInsightsOnboardingStatus(t testing.TestingT, resourceURI string, subscriptionID string) *insights.VMInsightsOnboardingStatus { status, err := GetVMInsightsOnboardingStatusE(t, resourceURI, subscriptionID) require.NoError(t, err) return status } // GetVMInsightsOnboardingStatusE get diagnostics VM onboarding status func GetVMInsightsOnboardingStatusE(t testing.TestingT, resourceURI string, subscriptionID string) (*insights.VMInsightsOnboardingStatus, error) { client, err := CreateVMInsightsClientE(subscriptionID) if err != nil { return nil, err } status, err := client.GetOnboardingStatus(context.Background(), resourceURI) if err != nil { return nil, err } return &status, nil } // GetVMInsightsClientE gets a VM Insights client // TODO: delete in next version func GetVMInsightsClientE(t testing.TestingT, subscriptionID string) (*insights.VMInsightsClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } client := insights.NewVMInsightsClient(subscriptionID) authorizer, err := NewAuthorizer() if err != nil { return nil, err } client.Authorizer = *authorizer return &client, nil } // GetActivityLogAlertResource gets a Action Group in the specified Azure Resource Group // This function would fail the test if there is an error. func GetActivityLogAlertResource(t testing.TestingT, activityLogAlertName string, resGroupName string, subscriptionID string) *insights.ActivityLogAlertResource { activityLogAlertResource, err := GetActivityLogAlertResourceE(activityLogAlertName, resGroupName, subscriptionID) require.NoError(t, err) return activityLogAlertResource } // GetActivityLogAlertResourceE gets a Action Group in the specified Azure Resource Group func GetActivityLogAlertResourceE(activityLogAlertName string, resGroupName string, subscriptionID string) (*insights.ActivityLogAlertResource, error) { // Validate resource group name and subscription ID _, err := getTargetAzureResourceGroupName(resGroupName) if err != nil { return nil, err } // Get the client reference client, err := CreateActivityLogAlertsClientE(subscriptionID) if err != nil { return nil, err } // Get the Action Group activityLogAlertResource, err := client.Get(context.Background(), resGroupName, activityLogAlertName) if err != nil { return nil, err } return &activityLogAlertResource, nil } // GetActivityLogAlertsClientE gets an Action Groups client in the specified Azure Subscription // TODO: delete in next version func GetActivityLogAlertsClientE(subscriptionID string) (*insights.ActivityLogAlertsClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Get the Action Groups client client := insights.NewActivityLogAlertsClient(subscriptionID) // Create an authorizer authorizer, err := NewAuthorizer() if err != nil { return nil, err } client.Authorizer = *authorizer return &client, nil } ================================================ FILE: modules/azure/monitor_test.go ================================================ //go:build azure // +build azure package azure import ( "testing" "github.com/stretchr/testify/require" ) func TestDiagnosticsSettingsResourceExists(t *testing.T) { t.Parallel() diagnosticsSettingResourceName := "fakename" resGroupName := "fakeresgroup" subscriptionID := "fakesubid" _, err := DiagnosticSettingsResourceExistsE(diagnosticsSettingResourceName, resGroupName, subscriptionID) require.Error(t, err) } ================================================ FILE: modules/azure/mysql.go ================================================ package azure import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/mysql/armmysql" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // GetMYSQLServerClientE is a helper function that will setup a mysql server client. func GetMYSQLServerClientE(subscriptionID string) (*armmysql.ServersClient, error) { clientFactory, err := getArmMySQLClientFactory(subscriptionID) if err != nil { return nil, err } return clientFactory.NewServersClient(), nil } // GetMYSQLServer is a helper function that gets the server. // This function would fail the test if there is an error. func GetMYSQLServer(t testing.TestingT, resGroupName string, serverName string, subscriptionID string) *armmysql.Server { mysqlServer, err := GetMYSQLServerE(t, subscriptionID, resGroupName, serverName) require.NoError(t, err) return mysqlServer } // GetMYSQLServerE is a helper function that gets the server. func GetMYSQLServerE(t testing.TestingT, subscriptionID string, resGroupName string, serverName string) (*armmysql.Server, error) { // Create a MySQL Server client mysqlClient, err := CreateMySQLServerClientE(subscriptionID) if err != nil { return nil, err } // Get the corresponding server resp, err := mysqlClient.Get(context.Background(), resGroupName, serverName, nil) if err != nil { return nil, err } return &resp.Server, nil } // GetMYSQLDBClientE is a helper function that will setup a mysql DB client. func GetMYSQLDBClientE(subscriptionID string) (*armmysql.DatabasesClient, error) { clientFactory, err := getArmMySQLClientFactory(subscriptionID) if err != nil { return nil, err } return clientFactory.NewDatabasesClient(), nil } // GetMYSQLDB is a helper function that gets the database. // This function would fail the test if there is an error. func GetMYSQLDB(t testing.TestingT, resGroupName string, serverName string, dbName string, subscriptionID string) *armmysql.Database { database, err := GetMYSQLDBE(t, subscriptionID, resGroupName, serverName, dbName) require.NoError(t, err) return database } // GetMYSQLDBE is a helper function that gets the database. func GetMYSQLDBE(t testing.TestingT, subscriptionID string, resGroupName string, serverName string, dbName string) (*armmysql.Database, error) { // Create a MySQL db client mysqldbClient, err := GetMYSQLDBClientE(subscriptionID) if err != nil { return nil, err } // Get the corresponding db resp, err := mysqldbClient.Get(context.Background(), resGroupName, serverName, dbName, nil) if err != nil { return nil, err } return &resp.Database, nil } // ListMySQLDB is a helper function that gets all databases per server. func ListMySQLDB(t testing.TestingT, resGroupName string, serverName string, subscriptionID string) []*armmysql.Database { dblist, err := ListMySQLDBE(t, subscriptionID, resGroupName, serverName) require.NoError(t, err) return dblist } // ListMySQLDBE is a helper function that gets all databases per server. func ListMySQLDBE(t testing.TestingT, subscriptionID string, resGroupName string, serverName string) ([]*armmysql.Database, error) { // Create a MySQL db client mysqldbClient, err := GetMYSQLDBClientE(subscriptionID) if err != nil { return nil, err } // Get the databases using pager pager := mysqldbClient.NewListByServerPager(resGroupName, serverName, nil) var databases []*armmysql.Database for pager.More() { page, err := pager.NextPage(context.Background()) if err != nil { return nil, err } databases = append(databases, page.Value...) } return databases, nil } ================================================ FILE: modules/azure/mysql_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package azure import ( "testing" "github.com/stretchr/testify/require" ) /* The below tests are currently stubbed out, with the expectation that they will throw errors. If/when CRUD methods are introduced for Azure MySQL server and database, these tests can be extended */ func TestGetMYSQLServerE(t *testing.T) { t.Parallel() resGroupName := "" serverName := "" subscriptionID := "" _, err := GetMYSQLServerE(t, subscriptionID, resGroupName, serverName) require.Error(t, err) } func TestGetMYSQLDBE(t *testing.T) { t.Parallel() resGroupName := "" serverName := "" subscriptionID := "" dbName := "" _, err := GetMYSQLDBE(t, subscriptionID, resGroupName, serverName, dbName) require.Error(t, err) } ================================================ FILE: modules/azure/networkinterface.go ================================================ package azure import ( "context" "github.com/Azure/azure-sdk-for-go/services/network/mgmt/2019-09-01/network" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // NetworkInterfaceExists indicates whether the specified Azure Network Interface exists. // This function would fail the test if there is an error. func NetworkInterfaceExists(t testing.TestingT, nicName string, resGroupName string, subscriptionID string) bool { exists, err := NetworkInterfaceExistsE(nicName, resGroupName, subscriptionID) require.NoError(t, err) return exists } // NetworkInterfaceExistsE indicates whether the specified Azure Network Interface exists. func NetworkInterfaceExistsE(nicName string, resGroupName string, subscriptionID string) (bool, error) { // Get the Network Interface _, err := GetNetworkInterfaceE(nicName, resGroupName, subscriptionID) if err != nil { if ResourceNotFoundErrorExists(err) { return false, nil } return false, err } return true, nil } // GetNetworkInterfacePrivateIPs gets a list of the Private IPs of a Network Interface configs. // This function would fail the test if there is an error. func GetNetworkInterfacePrivateIPs(t testing.TestingT, nicName string, resGroupName string, subscriptionID string) []string { IPs, err := GetNetworkInterfacePrivateIPsE(nicName, resGroupName, subscriptionID) require.NoError(t, err) return IPs } // GetNetworkInterfacePrivateIPsE gets a list of the Private IPs of a Network Interface configs. func GetNetworkInterfacePrivateIPsE(nicName string, resGroupName string, subscriptionID string) ([]string, error) { var privateIPs []string // Get the Network Interface client nic, err := GetNetworkInterfaceE(nicName, resGroupName, subscriptionID) if err != nil { return privateIPs, err } // Get the Private IPs from each configuration for _, IPConfiguration := range *nic.IPConfigurations { privateIPs = append(privateIPs, *IPConfiguration.PrivateIPAddress) } return privateIPs, nil } // GetNetworkInterfacePublicIPs returns a list of all the Public IPs found in the Network Interface configurations. // This function would fail the test if there is an error. func GetNetworkInterfacePublicIPs(t testing.TestingT, nicName string, resGroupName string, subscriptionID string) []string { IPs, err := GetNetworkInterfacePublicIPsE(nicName, resGroupName, subscriptionID) require.NoError(t, err) return IPs } // GetNetworkInterfacePublicIPsE returns a list of all the Public IPs found in the Network Interface configurations. func GetNetworkInterfacePublicIPsE(nicName string, resGroupName string, subscriptionID string) ([]string, error) { var publicIPs []string // Get the Network Interface client nic, err := GetNetworkInterfaceE(nicName, resGroupName, subscriptionID) if err != nil { return publicIPs, err } // Get the Public IPs from each configuration available for _, IPConfiguration := range *nic.IPConfigurations { // Iterate each config, for successful configurations check for a Public Address reference. // Not failing on errors as this is an optimistic accumulator. nicConfig, err := GetNetworkInterfaceConfigurationE(nicName, *IPConfiguration.Name, resGroupName, subscriptionID) if err == nil { if nicConfig.PublicIPAddress != nil { publicAddressID := GetNameFromResourceID(*nicConfig.PublicIPAddress.ID) publicIP, err := GetIPOfPublicIPAddressByNameE(publicAddressID, resGroupName, subscriptionID) if err == nil { publicIPs = append(publicIPs, publicIP) } } } } return publicIPs, nil } // GetNetworkInterfaceConfigurationE gets a Network Interface Configuration in the specified Azure Resource Group. func GetNetworkInterfaceConfigurationE(nicName string, nicConfigName string, resGroupName string, subscriptionID string) (*network.InterfaceIPConfiguration, error) { // Validate Azure Resource Group resGroupName, err := getTargetAzureResourceGroupName(resGroupName) if err != nil { return nil, err } // Get the client reference client, err := GetNetworkInterfaceConfigurationClientE(subscriptionID) if err != nil { return nil, err } // Get the Network Interface nicConfig, err := client.Get(context.Background(), resGroupName, nicName, nicConfigName) if err != nil { return nil, err } return &nicConfig, nil } // GetNetworkInterfaceConfigurationClientE creates a new Network Interface Configuration client in the specified Azure Subscription. func GetNetworkInterfaceConfigurationClientE(subscriptionID string) (*network.InterfaceIPConfigurationsClient, error) { // Create a new client from client factory client, err := CreateNewNetworkInterfaceIPConfigurationClientE(subscriptionID) if err != nil { return nil, err } // Create an authorizer authorizer, err := NewAuthorizer() if err != nil { return nil, err } client.Authorizer = *authorizer return client, nil } // GetNetworkInterfaceE gets a Network Interface in the specified Azure Resource Group. func GetNetworkInterfaceE(nicName string, resGroupName string, subscriptionID string) (*network.Interface, error) { // Validate Azure Resource Group resGroupName, err := getTargetAzureResourceGroupName(resGroupName) if err != nil { return nil, err } // Get the client reference client, err := GetNetworkInterfaceClientE(subscriptionID) if err != nil { return nil, err } // Get the Network Interface nic, err := client.Get(context.Background(), resGroupName, nicName, "") if err != nil { return nil, err } return &nic, nil } // GetNetworkInterfaceClientE creates a new Network Interface client in the specified Azure Subscription. func GetNetworkInterfaceClientE(subscriptionID string) (*network.InterfacesClient, error) { // Create new NIC client from client factory client, err := CreateNewNetworkInterfacesClientE(subscriptionID) if err != nil { return nil, err } // Create an authorizer authorizer, err := NewAuthorizer() if err != nil { return nil, err } client.Authorizer = *authorizer return client, nil } ================================================ FILE: modules/azure/networkinterface_test.go ================================================ //go:build azure || (azureslim && network) // +build azure azureslim,network // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package azure import ( "testing" "github.com/stretchr/testify/require" ) /* The below tests are currently stubbed out, with the expectation that they will throw errors. If/when methods can be mocked or Create/Delete APIs are added, these tests can be extended. */ func TestGetNetworkInterfaceE(t *testing.T) { t.Parallel() nicName := "" rgName := "" subID := "" _, err := GetNetworkInterfaceE(nicName, rgName, subID) require.Error(t, err) } func TestGetNetworkInterfacePrivateIPsE(t *testing.T) { t.Parallel() nicName := "" rgName := "" subID := "" _, err := GetNetworkInterfacePrivateIPsE(nicName, rgName, subID) require.Error(t, err) } func TestGetNetworkInterfacePublicIPsE(t *testing.T) { t.Parallel() nicName := "" rgName := "" subID := "" _, err := GetNetworkInterfacePublicIPsE(nicName, rgName, subID) require.Error(t, err) } func TestNetworkInterfaceExistsE(t *testing.T) { t.Parallel() nicName := "" rgName := "" subID := "" _, err := NetworkInterfaceExistsE(nicName, rgName, subID) require.Error(t, err) } ================================================ FILE: modules/azure/nsg.go ================================================ package azure import ( "context" "fmt" "strconv" "strings" "testing" "github.com/Azure/azure-sdk-for-go/services/network/mgmt/2019-09-01/network" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // NsgRuleSummaryList holds a collection of NsgRuleSummary rules type NsgRuleSummaryList struct { SummarizedRules []NsgRuleSummary } // NsgRuleSummary is a string-based (non-pointer) summary of an NSG rule with several helper methods attached // to help with verification of rule configuration. type NsgRuleSummary struct { Name string Description string Protocol string SourcePortRange string SourcePortRanges []string DestinationPortRange string DestinationPortRanges []string SourceAddressPrefix string SourceAdresssPrefixes []string DestinationAddressPrefix string DestinationAddressPrefixes []string Access string Priority int32 Direction string } // GetDefaultNsgRulesClient returns a rules client which can be used to read the list of *default* security rules // defined on an network security group. Note that the "default" rules are those provided implicitly // by the Azure platform. // This function would fail the test if there is an error. func GetDefaultNsgRulesClient(t *testing.T, subscriptionID string) network.DefaultSecurityRulesClient { client, err := GetDefaultNsgRulesClientE(subscriptionID) require.NoError(t, err) return client } // GetDefaultNsgRulesClientE returns a rules client which can be used to read the list of *default* security rules // defined on an network security group. Note that the "default" rules are those provided implicitly // by the Azure platform. func GetDefaultNsgRulesClientE(subscriptionID string) (network.DefaultSecurityRulesClient, error) { // Get new default client from client factory nsgClient, err := CreateNsgDefaultRulesClientE(subscriptionID) if err != nil { return network.DefaultSecurityRulesClient{}, err } // Get an authorizer auth, err := NewAuthorizer() if err != nil { return network.DefaultSecurityRulesClient{}, err } nsgClient.Authorizer = *auth return *nsgClient, nil } // GetCustomNsgRulesClient returns a rules client which can be used to read the list of *custom* security rules // defined on an network security group. Note that the "custom" rules are those defined by // end users. // This function would fail the test if there is an error. func GetCustomNsgRulesClient(t *testing.T, subscriptionID string) network.SecurityRulesClient { client, err := GetCustomNsgRulesClientE(subscriptionID) require.NoError(t, err) return client } // GetCustomNsgRulesClientE returns a rules client which can be used to read the list of *custom* security rules // defined on an network security group. Note that the "custom" rules are those defined by // end users. func GetCustomNsgRulesClientE(subscriptionID string) (network.SecurityRulesClient, error) { // Get new custom rules client from client factory nsgClient, err := CreateNsgCustomRulesClientE(subscriptionID) if err != nil { return network.SecurityRulesClient{}, err } // Get an authorizer auth, err := NewAuthorizer() if err != nil { return network.SecurityRulesClient{}, err } nsgClient.Authorizer = *auth return *nsgClient, nil } // GetAllNSGRules returns an NsgRuleSummaryList instance containing the combined "default" and "custom" rules from a network // security group. // This function would fail the test if there is an error. func GetAllNSGRules(t *testing.T, resourceGroupName, nsgName, subscriptionID string) NsgRuleSummaryList { results, err := GetAllNSGRulesE(resourceGroupName, nsgName, subscriptionID) require.NoError(t, err) return results } // GetAllNSGRulesE returns an NsgRuleSummaryList instance containing the combined "default" and "custom" rules from a network // security group. func GetAllNSGRulesE(resourceGroupName, nsgName, subscriptionID string) (NsgRuleSummaryList, error) { defaultRulesClient, err := GetDefaultNsgRulesClientE(subscriptionID) if err != nil { return NsgRuleSummaryList{}, err } // Get a client instance customRulesClient, err := GetCustomNsgRulesClientE(subscriptionID) if err != nil { return NsgRuleSummaryList{}, err } // Read all default (platform) rules. defaultRuleList, err := defaultRulesClient.ListComplete(context.Background(), resourceGroupName, nsgName) if err != nil { return NsgRuleSummaryList{}, err } // Read any custom (user provided) rules customRuleList, err := customRulesClient.ListComplete(context.Background(), resourceGroupName, nsgName) if err != nil { return NsgRuleSummaryList{}, err } // Convert the default list to our summary type boundDefaultRules, err := bindRuleList(defaultRuleList) if err != nil { return NsgRuleSummaryList{}, err } // Convert the custom list to our summary type boundCustomRules, err := bindRuleList(customRuleList) if err != nil { return NsgRuleSummaryList{}, err } // Join the summarized lists and wrap in NsgRuleSummaryList struct allRules := append(boundDefaultRules, boundCustomRules...) ruleList := NsgRuleSummaryList{} ruleList.SummarizedRules = allRules return ruleList, nil } // bindRuleList takes a raw list of security rules from the SDK and converts them into a string-based // summary struct. func bindRuleList(source network.SecurityRuleListResultIterator) ([]NsgRuleSummary, error) { rules := make([]NsgRuleSummary, 0) for source.NotDone() { v := source.Value() rules = append(rules, convertToNsgRuleSummary(v.Name, v.SecurityRulePropertiesFormat)) err := source.NextWithContext(context.Background()) if err != nil { return []NsgRuleSummary{}, err } } return rules, nil } // convertToNsgRuleSummary converts the raw SDK security rule type into a summarized struct, flattening the // rules properties and name into a single, string-based struct. func convertToNsgRuleSummary(name *string, rule *network.SecurityRulePropertiesFormat) NsgRuleSummary { summary := NsgRuleSummary{} summary.Description = safePtrToString(rule.Description) summary.Name = safePtrToString(name) summary.Protocol = string(rule.Protocol) summary.SourcePortRange = safePtrToString(rule.SourcePortRange) summary.SourcePortRanges = safePtrToList(rule.SourcePortRanges) summary.DestinationPortRange = safePtrToString(rule.DestinationPortRange) summary.DestinationPortRanges = safePtrToList(rule.DestinationPortRanges) summary.SourceAddressPrefix = safePtrToString(rule.SourceAddressPrefix) summary.SourceAdresssPrefixes = safePtrToList(rule.SourceAddressPrefixes) summary.DestinationAddressPrefix = safePtrToString(rule.DestinationAddressPrefix) summary.DestinationAddressPrefixes = safePtrToList(rule.DestinationAddressPrefixes) summary.Access = string(rule.Access) summary.Priority = safePtrToInt32(rule.Priority) summary.Direction = string(rule.Direction) return summary } // FindRuleByName looks for a matching rule by name within the current collection of rules. func (summarizedRules *NsgRuleSummaryList) FindRuleByName(name string) NsgRuleSummary { for _, r := range summarizedRules.SummarizedRules { if r.Name == name { return r } } return NsgRuleSummary{} } // AllowsDestinationPort checks to see if the rule allows a specific destination port. This is helpful when verifying // that a given rule is configured properly for a given port. func (summarizedRule *NsgRuleSummary) AllowsDestinationPort(t *testing.T, port string) bool { allowed, err := portRangeAllowsPort(summarizedRule.DestinationPortRange, port) assert.NoError(t, err) return allowed && (summarizedRule.Access == "Allow") } // AllowsSourcePort checks to see if the rule allows a specific source port. This is helpful when verifying // that a given rule is configured properly for a given port. func (summarizedRule *NsgRuleSummary) AllowsSourcePort(t *testing.T, port string) bool { allowed, err := portRangeAllowsPort(summarizedRule.SourcePortRange, port) assert.NoError(t, err) return allowed && (summarizedRule.Access == "Allow") } // portRangeAllowsPort is the internal implementation of AllowsSourcePort and AllowsDestinationPort. func portRangeAllowsPort(portRange string, port string) (bool, error) { if portRange == "*" { return true, nil } // Decode the provided port range low, high, parseErr := parsePortRangeString(portRange) if parseErr != nil { return false, parseErr } // Decode user-provided port portAsInt, parseErr := strconv.ParseInt(port, 10, 16) if (parseErr != nil) && (port != "*") { return false, parseErr } // If the user wants to check "all", make sure we parsed input range to include all ports. if (port == "*") && (low == 0) && (high == 65535) { return true, nil } // Evaluate and return return ((uint16(portAsInt) >= low) && (uint16(portAsInt) <= high)), nil } // parsePortRangeString decodes a range string ("2-100") or a single digit ("22") and returns // a tuple in [low, hi] form. Note that if a single digit is supplied, both members of the // return tuple will be the same value (e.g., "22" returns (22, 22)) func parsePortRangeString(rangeString string) (uint16, uint16, error) { // An asterisk means all ports if rangeString == "*" { return uint16(0), uint16(65535), nil } // Check for range string that contains hyphen separator if !strings.Contains(rangeString, "-") { val, parseErr := strconv.ParseInt(rangeString, 10, 16) if parseErr != nil { return 0, 0, parseErr } return uint16(val), uint16(val), nil } // Split the range into parts and validate parts := strings.Split(rangeString, "-") if len(parts) != 2 { return 0, 0, fmt.Errorf("Invalid port range specified; must be of the format '{low port}-{high port}'") } // Assume the low port is listed first; parse it lowVal, parseErr := strconv.ParseInt(parts[0], 10, 16) if parseErr != nil { return 0, 0, parseErr } // Assume the hi port is listed first; parse it highVal, parseErr := strconv.ParseInt(parts[1], 10, 16) if parseErr != nil { return 0, 0, parseErr } // Normalize ordering in the case that low and hi were reversed. // This should _never_ happen, as the Azure API's won't allow it, but // we shouldn't fail if it's the case. if lowVal > highVal { temp := lowVal lowVal = highVal highVal = temp } // Return values return uint16(lowVal), uint16(highVal), nil } ================================================ FILE: modules/azure/nsg_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package azure import ( "fmt" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/Azure/azure-sdk-for-go/services/network/mgmt/2019-09-01/network" ) func TestPortRangeParsing(t *testing.T) { var cases = []struct { portRange string expectedLo int expectedHi int expectsError bool }{ {"22", 22, 22, false}, {"22-80", 22, 80, false}, {"*", 0, 65535, false}, {"*-*", 0, 0, true}, {"22-", 0, 0, true}, {"-80", 0, 0, true}, {"-", 0, 0, true}, {"80-22", 22, 80, false}, } for _, tt := range cases { t.Run(tt.portRange, func(t *testing.T) { lo, hi, err := parsePortRangeString(tt.portRange) if !tt.expectsError { require.NoError(t, err) } assert.Equal(t, tt.expectedLo, int(lo)) assert.Equal(t, tt.expectedHi, int(hi)) }) } } func TestNsgRuleSummaryConversion(t *testing.T) { // Quick test to make sure the safe nil handling is working name := "test name" sdkStruct := network.SecurityRulePropertiesFormat{} // Verify the nil values were correctly defaulted to "" without a panic result := convertToNsgRuleSummary(&name, &sdkStruct) assert.Equal(t, "", result.Description) assert.Equal(t, "", result.SourcePortRange) assert.Equal(t, "", result.DestinationPortRange) assert.Equal(t, "", result.SourceAddressPrefix) assert.Equal(t, "", result.DestinationAddressPrefix) assert.Equal(t, int32(0), result.Priority) } func TestAllowSourcePort(t *testing.T) { var cases = []struct { CaseName string SourcePortRange string Access string TestPort string Result bool }{ {"22 allowed", "22", "Allow", "22", true}, {"22 denied", "22", "Deny", "22", false}, {"22 doesn't allow 80", "22", "Allow", "80", false}, {"Any allows any", "*", "Allow", "*", true}, {"Allows a range of ports", "80-90", "Allow", "80", true}, {"Allows a range of ports", "80-90", "Allow", "85", true}, {"Allows a range of ports", "80-90", "Allow", "90", true}, {"Blocks a range of ports", "80-90", "Deny", "80", false}, {"Blocks a range of ports", "80-90", "Deny", "85", false}, {"Blocks a range of ports", "80-90", "Deny", "90", false}, } for _, tt := range cases { t.Run(tt.CaseName, func(t *testing.T) { summary := NsgRuleSummary{} summary.SourcePortRange = tt.SourcePortRange summary.Access = tt.Access result := summary.AllowsSourcePort(t, tt.TestPort) assert.Equal(t, tt.Result, result) }) } } func TestAllowDestinationPort(t *testing.T) { var cases = []struct { CaseName string SourcePortRange string Access string TestPort string Result bool }{ {"22 allowed", "22", "Allow", "22", true}, {"22 denied", "22", "Deny", "22", false}, {"22 doesn't allow 80", "22", "Allow", "80", false}, {"Any allows any", "*", "Allow", "*", true}, {"Allows a range of ports", "80-90", "Allow", "80", true}, {"Allows a range of ports", "80-90", "Allow", "85", true}, {"Allows a range of ports", "80-90", "Allow", "90", true}, {"Blocks a range of ports", "80-90", "Deny", "80", false}, {"Blocks a range of ports", "80-90", "Deny", "85", false}, {"Blocks a range of ports", "80-90", "Deny", "90", false}, } for _, tt := range cases { t.Run(tt.CaseName, func(t *testing.T) { summary := NsgRuleSummary{} summary.DestinationPortRange = tt.SourcePortRange summary.Access = tt.Access result := summary.AllowsDestinationPort(t, tt.TestPort) assert.Equal(t, tt.Result, result) }) } } func TestFindSummarizedRule(t *testing.T) { var cases = []struct { SearchString string Result bool }{ {"rule_1", true}, {"rule_2", true}, {"rule_3", true}, {"rule_4", true}, {"rule_5", true}, {"rule_6", false}, {"", false}, {"foo", false}, } ruleList := NsgRuleSummaryList{} rules := make([]NsgRuleSummary, 0) // Create some base rules for i := 1; i <= 5; i++ { rule := NsgRuleSummary{} rule.Name = fmt.Sprintf("rule_%d", i) rules = append(rules, rule) } ruleList.SummarizedRules = rules for _, tt := range cases { t.Run(tt.SearchString, func(t *testing.T) { match := ruleList.FindRuleByName(tt.SearchString) if tt.Result { assert.Equal(t, tt.SearchString, match.Name) } else { assert.Equal(t, match, NsgRuleSummary{}) } }) } } ================================================ FILE: modules/azure/postgresql.go ================================================ package azure import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresql" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // GetPostgreSQLServerClientE is a helper function that will setup a postgresql server client. func GetPostgreSQLServerClientE(subscriptionID string) (*armpostgresql.ServersClient, error) { clientFactory, err := getArmPostgreSQLClientFactory(subscriptionID) if err != nil { return nil, err } return clientFactory.NewServersClient(), nil } // GetPostgreSQLServer is a helper function that gets the server. // This function would fail the test if there is an error. func GetPostgreSQLServer(t testing.TestingT, resGroupName string, serverName string, subscriptionID string) *armpostgresql.Server { postgresqlServer, err := GetPostgreSQLServerE(t, subscriptionID, resGroupName, serverName) require.NoError(t, err) return postgresqlServer } // GetPostgreSQLServerE is a helper function that gets the server. func GetPostgreSQLServerE(t testing.TestingT, subscriptionID string, resGroupName string, serverName string) (*armpostgresql.Server, error) { // Create a postgresql Server client postgresqlClient, err := GetPostgreSQLServerClientE(subscriptionID) if err != nil { return nil, err } // Get the corresponding server resp, err := postgresqlClient.Get(context.Background(), resGroupName, serverName, nil) if err != nil { return nil, err } return &resp.Server, nil } // GetPostgreSQLDBClientE is a helper function that will setup a postgresql DB client. func GetPostgreSQLDBClientE(subscriptionID string) (*armpostgresql.DatabasesClient, error) { clientFactory, err := getArmPostgreSQLClientFactory(subscriptionID) if err != nil { return nil, err } return clientFactory.NewDatabasesClient(), nil } // GetPostgreSQLDB is a helper function that gets the database. // This function would fail the test if there is an error. func GetPostgreSQLDB(t testing.TestingT, resGroupName string, serverName string, dbName string, subscriptionID string) *armpostgresql.Database { database, err := GetPostgreSQLDBE(t, subscriptionID, resGroupName, serverName, dbName) require.NoError(t, err) return database } // GetPostgreSQLDBE is a helper function that gets the database. func GetPostgreSQLDBE(t testing.TestingT, subscriptionID string, resGroupName string, serverName string, dbName string) (*armpostgresql.Database, error) { // Create a postgresql db client postgresqldbClient, err := GetPostgreSQLDBClientE(subscriptionID) if err != nil { return nil, err } // Get the corresponding db resp, err := postgresqldbClient.Get(context.Background(), resGroupName, serverName, dbName, nil) if err != nil { return nil, err } return &resp.Database, nil } // ListPostgreSQLDB is a helper function that gets all databases per server. func ListPostgreSQLDB(t testing.TestingT, subscriptionID string, resGroupName string, serverName string) []*armpostgresql.Database { dblist, err := ListPostgreSQLDBE(t, subscriptionID, resGroupName, serverName) require.NoError(t, err) return dblist } // ListPostgreSQLDBE is a helper function that gets all databases per server. func ListPostgreSQLDBE(t testing.TestingT, subscriptionID string, resGroupName string, serverName string) ([]*armpostgresql.Database, error) { // Create a postgresql db client postgresqldbClient, err := GetPostgreSQLDBClientE(subscriptionID) if err != nil { return nil, err } // Get the databases using pager pager := postgresqldbClient.NewListByServerPager(resGroupName, serverName, nil) var databases []*armpostgresql.Database for pager.More() { page, err := pager.NextPage(context.Background()) if err != nil { return nil, err } databases = append(databases, page.Value...) } return databases, nil } ================================================ FILE: modules/azure/postgresql_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package azure import ( "testing" "github.com/stretchr/testify/require" ) /* The below tests are currently stubbed out, with the expectation that they will throw errors. If/when CRUD methods are introduced for Azure PostgreSQL server and database, these tests can be extended */ func TestGetPostgreSQLServerE(t *testing.T) { t.Parallel() resGroupName := "" serverName := "" subscriptionID := "" _, err := GetPostgreSQLServerE(t, subscriptionID, resGroupName, serverName) require.Error(t, err) } func TestGetPostgreSQLDBE(t *testing.T) { t.Parallel() resGroupName := "" serverName := "" subscriptionID := "" dbName := "" _, err := GetPostgreSQLDBE(t, subscriptionID, resGroupName, serverName, dbName) require.Error(t, err) } ================================================ FILE: modules/azure/privatednszone.go ================================================ package azure import ( "context" "github.com/Azure/azure-sdk-for-go/profiles/latest/privatedns/mgmt/privatedns" ) // PrivateDNSZoneExistsE indicates whether the specified private DNS zone exists. func PrivateDNSZoneExistsE(zoneName string, resourceGroupName string, subscriptionID string) (bool, error) { _, err := GetPrivateDNSZoneE(zoneName, resourceGroupName, subscriptionID) if err != nil { if ResourceNotFoundErrorExists(err) { return false, nil } return false, err } return true, nil } // GetPrivateDNSZoneE gets the private DNS zone object func GetPrivateDNSZoneE(zoneName string, resGroupName string, subscriptionID string) (*privatedns.PrivateZone, error) { rgName, err := getTargetAzureResourceGroupName(resGroupName) if err != nil { return nil, err } client, err := CreatePrivateDnsZonesClientE(subscriptionID) if err != nil { return nil, err } zone, err := client.Get(context.Background(), rgName, zoneName) if err != nil { return nil, err } return &zone, nil } ================================================ FILE: modules/azure/privatednszone_test.go ================================================ package azure import ( "testing" "github.com/stretchr/testify/require" ) /* The below tests are currently stubbed out, with the expectation that they will throw errors. If/when CRUD methods are introduced for Azure Synapse, these tests can be extended */ func TestPrivateDNSZoneExists(t *testing.T) { t.Parallel() zoneName := "" resourceGroupName := "" subscriptionID := "" exists, err := PrivateDNSZoneExistsE(zoneName, resourceGroupName, subscriptionID) require.False(t, exists) require.Error(t, err) } func TestPrivateDNSZoneExistsE(t *testing.T) { t.Parallel() resGroupName := "" subscriptionID := "" zoneName := "" _, err := GetPrivateDNSZoneE(subscriptionID, resGroupName, zoneName) require.Error(t, err) } ================================================ FILE: modules/azure/publicaddress.go ================================================ package azure import ( "context" "github.com/Azure/azure-sdk-for-go/services/network/mgmt/2019-09-01/network" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // PublicAddressExists indicates whether the specified AzurePublic Address exists. // This function would fail the test if there is an error. func PublicAddressExists(t testing.TestingT, publicAddressName string, resGroupName string, subscriptionID string) bool { exists, err := PublicAddressExistsE(publicAddressName, resGroupName, subscriptionID) require.NoError(t, err) return exists } // PublicAddressExistsE indicates whether the specified AzurePublic Address exists. func PublicAddressExistsE(publicAddressName string, resGroupName string, subscriptionID string) (bool, error) { // Get the Public Address _, err := GetPublicIPAddressE(publicAddressName, resGroupName, subscriptionID) if err != nil { if ResourceNotFoundErrorExists(err) { return false, nil } return false, err } return true, nil } // GetIPOfPublicIPAddressByName gets the Public IP of the Public IP Address specified. // This function would fail the test if there is an error. func GetIPOfPublicIPAddressByName(t testing.TestingT, publicAddressName string, resGroupName string, subscriptionID string) string { IP, err := GetIPOfPublicIPAddressByNameE(publicAddressName, resGroupName, subscriptionID) require.NoError(t, err) return IP } // GetIPOfPublicIPAddressByNameE gets the Public IP of the Public IP Address specified. func GetIPOfPublicIPAddressByNameE(publicAddressName string, resGroupName string, subscriptionID string) (string, error) { // Create a NIC client pip, err := GetPublicIPAddressE(publicAddressName, resGroupName, subscriptionID) if err != nil { return "", err } return *pip.IPAddress, nil } // CheckPublicDNSNameAvailability checks whether a Domain Name in the cloudapp.azure.com zone // is available for use. This function would fail the test if there is an error. func CheckPublicDNSNameAvailability(t testing.TestingT, location string, domainNameLabel string, subscriptionID string) bool { available, err := CheckPublicDNSNameAvailabilityE(location, domainNameLabel, subscriptionID) if err != nil { return false } return available } // CheckPublicDNSNameAvailabilityE checks whether a Domain Name in the cloudapp.azure.com zone is available for use. func CheckPublicDNSNameAvailabilityE(location string, domainNameLabel string, subscriptionID string) (bool, error) { client, err := GetPublicIPAddressClientE(subscriptionID) if err != nil { return false, err } res, err := client.CheckDNSNameAvailability(context.Background(), location, domainNameLabel) if err != nil { return false, err } return *res.Available, nil } // GetPublicIPAddressE gets a Public IP Addresses in the specified Azure Resource Group. func GetPublicIPAddressE(publicIPAddressName string, resGroupName string, subscriptionID string) (*network.PublicIPAddress, error) { // Validate resource group name and subscription ID resGroupName, err := getTargetAzureResourceGroupName(resGroupName) if err != nil { return nil, err } // Get the client reference client, err := GetPublicIPAddressClientE(subscriptionID) if err != nil { return nil, err } // Get the Public IP Address pip, err := client.Get(context.Background(), resGroupName, publicIPAddressName, "") if err != nil { return nil, err } return &pip, nil } // GetPublicIPAddressClientE creates a Public IP Addresses client in the specified Azure Subscription. func GetPublicIPAddressClientE(subscriptionID string) (*network.PublicIPAddressesClient, error) { // Get the Public IP Address client from clientfactory client, err := CreatePublicIPAddressesClientE(subscriptionID) if err != nil { return nil, err } // Create an authorizer authorizer, err := NewAuthorizer() if err != nil { return nil, err } client.Authorizer = *authorizer return client, nil } ================================================ FILE: modules/azure/publicaddress_test.go ================================================ //go:build azure || (azureslim && network) // +build azure azureslim,network // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package azure import ( "testing" "github.com/stretchr/testify/require" ) /* The below tests are currently stubbed out, with the expectation that they will throw errors. If/when methods can be mocked or Create/Delete APIs are added, these tests can be extended. */ func TestGetPublicIPAddressE(t *testing.T) { t.Parallel() paName := "" rgName := "" subID := "" _, err := GetPublicIPAddressE(paName, rgName, subID) require.Error(t, err) } func TestCheckPublicDNSNameAvailabilityE(t *testing.T) { t.Parallel() location := "" domain := "" subID := "" _, err := CheckPublicDNSNameAvailabilityE(location, domain, subID) require.Error(t, err) } func TestGetIPOfPublicIPAddressByNameE(t *testing.T) { t.Parallel() paName := "" rgName := "" subID := "" _, err := GetIPOfPublicIPAddressByNameE(paName, rgName, subID) require.Error(t, err) } func TestPublicAddressExistsE(t *testing.T) { t.Parallel() paName := "" rgName := "" subID := "" _, err := PublicAddressExistsE(paName, rgName, subID) require.Error(t, err) } ================================================ FILE: modules/azure/recoveryservices.go ================================================ package azure import ( "context" "fmt" "testing" "github.com/Azure/azure-sdk-for-go/services/recoveryservices/mgmt/2016-06-01/recoveryservices" "github.com/Azure/azure-sdk-for-go/services/recoveryservices/mgmt/2020-02-02/backup" "github.com/stretchr/testify/require" ) // RecoveryServicesVaultExists indicates whether a recovery services vault exists; otherwise false. // This function would fail the test if there is an error. func RecoveryServicesVaultExists(t *testing.T, vaultName, resourceGroupName, subscriptionID string) bool { exists, err := RecoveryServicesVaultExistsE(vaultName, resourceGroupName, subscriptionID) require.NoError(t, err) return exists } // GetRecoveryServicesVaultBackupPolicyList returns a list of backup policies for the given vault. // This function would fail the test if there is an error. func GetRecoveryServicesVaultBackupPolicyList(t *testing.T, vaultName, resourceGroupName, subscriptionID string) map[string]backup.ProtectionPolicyResource { list, err := GetRecoveryServicesVaultBackupPolicyListE(vaultName, resourceGroupName, subscriptionID) require.NoError(t, err) return list } // GetRecoveryServicesVaultBackupProtectedVMList returns a list of protected VM's on the given vault/policy. // This function would fail the test if there is an error. func GetRecoveryServicesVaultBackupProtectedVMList(t *testing.T, policyName, vaultName, resourceGroupName, subscriptionID string) map[string]backup.AzureIaaSComputeVMProtectedItem { list, err := GetRecoveryServicesVaultBackupProtectedVMListE(policyName, vaultName, resourceGroupName, subscriptionID) require.NoError(t, err) return list } // RecoveryServicesVaultExistsE indicates whether a recovery services vault exists; otherwise false or error. func RecoveryServicesVaultExistsE(vaultName, resourceGroupName, subscriptionID string) (bool, error) { _, err := GetRecoveryServicesVaultE(vaultName, resourceGroupName, subscriptionID) if err != nil { if ResourceNotFoundErrorExists(err) { return false, nil } return false, err } return true, nil } // GetRecoveryServicesVaultE returns a vault instance. func GetRecoveryServicesVaultE(vaultName, resourceGroupName, subscriptionID string) (*recoveryservices.Vault, error) { subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } resourceGroupName, err2 := getTargetAzureResourceGroupName((resourceGroupName)) if err2 != nil { return nil, err2 } client := recoveryservices.NewVaultsClient(subscriptionID) // setup auth and create request params authorizer, err := NewAuthorizer() if err != nil { return nil, err } client.Authorizer = *authorizer vault, err := client.Get(context.Background(), resourceGroupName, vaultName) if err != nil { return nil, err } return &vault, nil } // GetRecoveryServicesVaultBackupPolicyListE returns a list of backup policies for the given vault. func GetRecoveryServicesVaultBackupPolicyListE(vaultName, resourceGroupName, subscriptionID string) (map[string]backup.ProtectionPolicyResource, error) { subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } resourceGroupName, err2 := getTargetAzureResourceGroupName(resourceGroupName) if err2 != nil { return nil, err2 } client := backup.NewPoliciesClient(subscriptionID) // setup authorizer authorizer, err := NewAuthorizer() if err != nil { return nil, err } client.Authorizer = *authorizer listIter, err := client.ListComplete(context.Background(), vaultName, resourceGroupName, "") if err != nil { return nil, err } policyMap := make(map[string]backup.ProtectionPolicyResource) for listIter.NotDone() { v := listIter.Value() policyMap[*v.Name] = v err := listIter.NextWithContext(context.Background()) if err != nil { return nil, err } } return policyMap, nil } // GetRecoveryServicesVaultBackupProtectedVMListE returns a list of protected VM's on the given vault/policy. func GetRecoveryServicesVaultBackupProtectedVMListE(policyName, vaultName, resourceGroupName, subscriptionID string) (map[string]backup.AzureIaaSComputeVMProtectedItem, error) { subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } resourceGroupName, err = getTargetAzureResourceGroupName(resourceGroupName) if err != nil { return nil, err } client := backup.NewProtectedItemsGroupClient(subscriptionID) // setup authorizer authorizer, err := NewAuthorizer() if err != nil { return nil, err } client.Authorizer = *authorizer // Build a filter string to narrow down results to just VM's filter := fmt.Sprintf("backupManagementType eq 'AzureIaasVM' and itemType eq 'VM' and policyName eq '%s'", policyName) listIter, err := client.ListComplete(context.Background(), vaultName, resourceGroupName, filter, "") if err != nil { return nil, err } // Prep the return container vmList := make(map[string]backup.AzureIaaSComputeVMProtectedItem) // First iterator check for listIter.NotDone() { currentVM, _ := listIter.Value().Properties.AsAzureIaaSComputeVMProtectedItem() vmList[*currentVM.FriendlyName] = *currentVM err := listIter.NextWithContext(context.Background()) if err != nil { return nil, err } } return vmList, nil } ================================================ FILE: modules/azure/recoveryservices_test.go ================================================ package azure import ( "testing" "github.com/stretchr/testify/require" ) /* The below tests are currently stubbed out, with the expectation that they will throw errors. If/when methods to create and delete recovery services resources are added, these tests can be extended. */ func TestRecoveryServicesVaultName(t *testing.T) { _, err := GetRecoveryServicesVaultE("", "", "") require.Error(t, err, "vault") } func TestRecoveryServicesVaultExists(t *testing.T) { _, err := RecoveryServicesVaultExistsE("", "", "") require.Error(t, err, "vault exists") } func TestRecoveryServicesVaultBackupPolicyList(t *testing.T) { _, err := GetRecoveryServicesVaultBackupPolicyListE("", "", "") require.Error(t, err, "Backup policy list not faulted") } func TestRecoveryServicesVaultBackupProtectedVMList(t *testing.T) { _, err := GetRecoveryServicesVaultBackupProtectedVMListE("", "", "", "") require.Error(t, err, "Backup policy protected vm list not faulted") } ================================================ FILE: modules/azure/region.go ================================================ package azure import ( "context" "github.com/gruntwork-io/terratest/modules/collections" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/testing" ) // Reference for region list: https://azure.microsoft.com/en-us/global-infrastructure/locations/ var stableRegions = []string{ // Americas "centralus", "eastus", "eastus2", "northcentralus", "southcentralus", "westcentralus", "westus", "westus2", "canadacentral", "canadaeast", "brazilsouth", // Europe "northeurope", "westeurope", "francecentral", "francesouth", "uksouth", "ukwest", // "germanycentral", // Shows as active on Azure website, but not from API // "germanynortheast", // Shows as active on Azure website, but not from API // Asia Pacific "eastasia", "southeastasia", "australiacentral", "australiacentral2", "australiaeast", "australiasoutheast", "chinaeast", "chinaeast2", "chinanorth", "chinanorth2", "centralindia", "southindia", "westindia", "japaneast", "japanwest", "koreacentral", "koreasouth", // Middle East and Africa "southafricanorth", "southafricawest", "uaecentral", "uaenorth", } // GetRandomStableRegion gets a randomly chosen Azure region that is considered stable. Like GetRandomRegion, you can // further restrict the stable region list using approvedRegions and forbiddenRegions. We consider stable regions to be // those that have been around for at least 1 year. // Note that regions in the approvedRegions list that are not considered stable are ignored. func GetRandomStableRegion(t testing.TestingT, approvedRegions []string, forbiddenRegions []string, subscriptionID string) string { regionsToPickFrom := stableRegions if len(approvedRegions) > 0 { regionsToPickFrom = collections.ListIntersection(regionsToPickFrom, approvedRegions) } if len(forbiddenRegions) > 0 { regionsToPickFrom = collections.ListSubtract(regionsToPickFrom, forbiddenRegions) } return GetRandomRegion(t, regionsToPickFrom, nil, subscriptionID) } // GetRandomRegion gets a randomly chosen Azure region. If approvedRegions is not empty, this will be a region from the approvedRegions // list; otherwise, this method will fetch the latest list of regions from the Azure APIs and pick one of those. If // forbiddenRegions is not empty, this method will make sure the returned region is not in the forbiddenRegions list. func GetRandomRegion(t testing.TestingT, approvedRegions []string, forbiddenRegions []string, subscriptionID string) string { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { t.Fatal(err) } region, err := GetRandomRegionE(t, approvedRegions, forbiddenRegions, subscriptionID) if err != nil { t.Fatal(err) } return region } // GetRandomRegionE gets a randomly chosen Azure region. If approvedRegions is not empty, this will be a region from the approvedRegions // list; otherwise, this method will fetch the latest list of regions from the Azure APIs and pick one of those. If // forbiddenRegions is not empty, this method will make sure the returned region is not in the forbiddenRegions list func GetRandomRegionE(t testing.TestingT, approvedRegions []string, forbiddenRegions []string, subscriptionID string) (string, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return "", err } regionsToPickFrom := approvedRegions if len(regionsToPickFrom) == 0 { allRegions, err := GetAllAzureRegionsE(t, subscriptionID) if err != nil { return "", err } regionsToPickFrom = allRegions } regionsToPickFrom = collections.ListSubtract(regionsToPickFrom, forbiddenRegions) region := random.RandomString(regionsToPickFrom) return region, nil } // GetAllAzureRegions gets the list of Azure regions available in this subscription. func GetAllAzureRegions(t testing.TestingT, subscriptionID string) []string { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { t.Fatal(err) } // Get list of Azure locations out, err := GetAllAzureRegionsE(t, subscriptionID) if err != nil { t.Fatal(err) } return out } // GetAllAzureRegionsE gets the list of Azure regions available in this subscription func GetAllAzureRegionsE(t testing.TestingT, subscriptionID string) ([]string, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } // Setup Subscription client subscriptionClient, err := GetSubscriptionClientE() if err != nil { return nil, err } // Get list of Azure locations out, err := subscriptionClient.ListLocations(context.Background(), subscriptionID) if err != nil { return nil, err } // Populate a return slice regions := []string{} for _, region := range *out.Value { regions = append(regions, *region.Name) } return regions, nil } ================================================ FILE: modules/azure/region_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package azure import ( "testing" "github.com/stretchr/testify/assert" ) func TestGetRandomRegion(t *testing.T) { t.Parallel() randomRegion := GetRandomRegion(t, nil, nil, "") assertLooksLikeRegionName(t, randomRegion) } func TestGetRandomRegionExcludesForbiddenRegions(t *testing.T) { t.Parallel() approvedRegions := []string{"canadacentral", "eastus", "eastus2", "westus", "westus2", "westeurope", "northeurope", "uksouth", "southeastasia", "eastasia", "japaneast", "australiacentral"} forbiddenRegions := []string{"westus2", "japaneast"} for i := 0; i < 48; i++ { randomRegion := GetRandomRegion(t, approvedRegions, forbiddenRegions, "") assert.NotContains(t, forbiddenRegions, randomRegion) } } func TestGetAllAzureRegions(t *testing.T) { t.Parallel() regions := GetAllAzureRegions(t, "") // The typical subscription had access to 30+ live regions as of // July 2019: https://azure.microsoft.com/en-us/global-infrastructure/regions/ assert.True(t, len(regions) >= 30, "Number of regions: %d", len(regions)) for _, region := range regions { assertLooksLikeRegionName(t, region) } } func assertLooksLikeRegionName(t *testing.T, regionName string) { assert.Regexp(t, "[a-z]", regionName) } ================================================ FILE: modules/azure/resourcegroup.go ================================================ package azure import ( "context" "fmt" "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/services/resources/mgmt/2020-10-01/resources" "github.com/Azure/go-autorest/autorest" "github.com/Azure/go-autorest/autorest/azure" "github.com/stretchr/testify/require" ) // ResourceGroupExists indicates whether a resource group exists within a subscription; otherwise false // This function would fail the test if there is an error. func ResourceGroupExists(t *testing.T, resourceGroupName string, subscriptionID string) bool { result, err := ResourceGroupExistsE(resourceGroupName, subscriptionID) require.NoError(t, err) return result } // ResourceGroupExistsE indicates whether a resource group exists within a subscription func ResourceGroupExistsE(resourceGroupName, subscriptionID string) (bool, error) { exists, err := GetResourceGroupE(resourceGroupName, subscriptionID) if err != nil { if resourceGroupNotFoundError(err) { return false, nil } return false, err } return exists, nil } // GetResourceGroupE gets a resource group within a subscription func GetResourceGroupE(resourceGroupName, subscriptionID string) (bool, error) { rg, err := GetAResourceGroupE(resourceGroupName, subscriptionID) if err != nil { return false, err } return (resourceGroupName == *rg.Name), nil } // GetResourceGroupClientE gets a resource group client in a subscription // TODO: remove in next version func GetResourceGroupClientE(subscriptionID string) (*resources.GroupsClient, error) { subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } resourceGroupClient := resources.NewGroupsClient(subscriptionID) authorizer, err := NewAuthorizer() if err != nil { return nil, err } resourceGroupClient.Authorizer = *authorizer return &resourceGroupClient, nil } // GetAResourceGroup returns a resource group within a subscription // This function would fail the test if there is an error. func GetAResourceGroup(t *testing.T, resourceGroupName string, subscriptionID string) *resources.Group { rg, err := GetAResourceGroupE(resourceGroupName, subscriptionID) require.NoError(t, err) return rg } // GetAResourceGroupE gets a resource group within a subscription func GetAResourceGroupE(resourceGroupName, subscriptionID string) (*resources.Group, error) { client, err := CreateResourceGroupClientE(subscriptionID) if err != nil { return nil, err } rg, err := client.Get(context.Background(), resourceGroupName) if err != nil { return nil, err } return &rg, nil } // ListResourceGroupsByTag returns a resource group list within a subscription based on a tag key // This function would fail the test if there is an error. func ListResourceGroupsByTag(t *testing.T, tag, subscriptionID string) []resources.Group { rg, err := ListResourceGroupsByTagE(tag, subscriptionID) require.NoError(t, err) return rg } // ListResourceGroupsByTagE returns a resource group list within a subscription based on a tag key func ListResourceGroupsByTagE(tag string, subscriptionID string) ([]resources.Group, error) { client, err := CreateResourceGroupClientE(subscriptionID) if err != nil { return nil, err } rg, err := client.List(context.Background(), fmt.Sprintf("tagName eq '%s'", tag), nil) if err != nil { return nil, err } return rg.Values(), nil } func resourceGroupNotFoundError(err error) bool { if err != nil { if autorestError, ok := err.(autorest.DetailedError); ok { if requestError, ok := autorestError.Original.(*azure.RequestError); ok { return (requestError.ServiceError.Code == "ResourceGroupNotFound") } } if azcoreErr, ok := err.(*azcore.ResponseError); ok { return azcoreErr.ErrorCode == "ResourceGroupNotFound" } } return false } ================================================ FILE: modules/azure/resourcegroup_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package azure import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) /* The below tests are currently stubbed out, with the expectation that they will throw errors. If/when methods to create and delete resource groups are added, these tests can be extended. */ func TestResourceGroupExists(t *testing.T) { t.Parallel() resourceGroupName := "fakeResourceGroupName" exists, err := ResourceGroupExistsE(resourceGroupName, "") assert.NoError(t, err) require.False(t, exists) } func TestGetAResourceGroup(t *testing.T) { t.Parallel() resourceGroupName := "fakeResourceGroupName" _, err := GetAResourceGroupE(resourceGroupName, "") require.Error(t, err) } ================================================ FILE: modules/azure/resourcegroupv2.go ================================================ package azure import ( "context" "fmt" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" "github.com/stretchr/testify/require" ) // ResourceGroupExists indicates whether a resource group exists within a subscription; otherwise false // This function would fail the test if there is an error. func ResourceGroupExistsV2(t *testing.T, resourceGroupName string, subscriptionID string) bool { result, err := ResourceGroupExistsV2E(resourceGroupName, subscriptionID) require.NoError(t, err) return result } // ResourceGroupExistsE indicates whether a resource group exists within a subscription func ResourceGroupExistsV2E(resourceGroupName, subscriptionID string) (bool, error) { exists, err := GetResourceGroupV2E(resourceGroupName, subscriptionID) if err != nil { if resourceGroupNotFoundError(err) { return false, nil } return false, err } return exists, nil } // GetResourceGroupE gets a resource group within a subscription func GetResourceGroupV2E(resourceGroupName, subscriptionID string) (bool, error) { rg, err := GetAResourceGroupV2E(resourceGroupName, subscriptionID) if err != nil { return false, err } return (resourceGroupName == *rg.Name), nil } // GetAResourceGroup returns a resource group within a subscription // This function would fail the test if there is an error. func GetAResourceGroupV2(t *testing.T, resourceGroupName string, subscriptionID string) *armresources.ResourceGroup { rg, err := GetAResourceGroupV2E(resourceGroupName, subscriptionID) require.NoError(t, err) return rg } // GetAResourceGroupE gets a resource group within a subscription func GetAResourceGroupV2E(resourceGroupName, subscriptionID string) (*armresources.ResourceGroup, error) { client, err := CreateResourceGroupClientV2E(subscriptionID) if err != nil { return nil, err } rg, err := client.Get(context.Background(), resourceGroupName, &armresources.ResourceGroupsClientGetOptions{}) if err != nil { return nil, err } return &rg.ResourceGroup, nil } // ListResourceGroupsByTag returns a resource group list within a subscription based on a tag key // This function would fail the test if there is an error. func ListResourceGroupsByTagV2(t *testing.T, tag, subscriptionID string) []*armresources.ResourceGroup { rg, err := ListResourceGroupsByTagV2E(tag, subscriptionID) require.NoError(t, err) return rg } // ListResourceGroupsByTagE returns a resource group list within a subscription based on a tag key func ListResourceGroupsByTagV2E(tag string, subscriptionID string) (rg []*armresources.ResourceGroup, err error) { client, err := CreateResourceGroupClientV2E(subscriptionID) if err != nil { return nil, err } filter := fmt.Sprintf("tagName eq '%s'", tag) pager := client.NewListPager(&armresources.ResourceGroupsClientListOptions{ Filter: &filter, }) ctx := context.Background() for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, err } rg = append(rg, page.ResourceGroupListResult.Value...) } return } ================================================ FILE: modules/azure/resourcegroupv2_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package azure import ( "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) /* The below tests are currently stubbed out, with the expectation that they will throw errors. If/when methods to create and delete resource groups are added, these tests can be extended. */ func TestResourceGroupExistsV2(t *testing.T) { t.Parallel() resourceGroupName := "fakeResourceGroupName" exists, err := ResourceGroupExistsV2E(resourceGroupName, "") assert.NoError(t, err) assert.False(t, exists) } func TestGetAResourceGroupV2(t *testing.T) { t.Parallel() resourceGroupName := "fakeResourceGroupName" _, err := GetAResourceGroupV2E(resourceGroupName, "") errAzure := &azcore.ResponseError{} require.ErrorAs(t, err, &errAzure) assert.Equal(t, errAzure.StatusCode, 404) } ================================================ FILE: modules/azure/resourceid.go ================================================ package azure import "github.com/gruntwork-io/terratest/modules/collections" // GetNameFromResourceID gets the Name from an Azure Resource ID. func GetNameFromResourceID(resourceID string) string { id, err := GetNameFromResourceIDE(resourceID) if err != nil { return "" } return id } // GetNameFromResourceIDE gets the Name from an Azure Resource ID. // This function would fail the test if there is an error. func GetNameFromResourceIDE(resourceID string) (string, error) { id, err := collections.GetSliceLastValueE(resourceID, "/") if err != nil { return "", err } return id, nil } ================================================ FILE: modules/azure/resourceid_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package azure import ( "testing" "github.com/stretchr/testify/assert" ) func TestGetNameFromResourceID(t *testing.T) { t.Parallel() // set slice variables sliceSource := "this/is/a/long/slash/separated/string/ResourceID" sliceResult := "ResourceID" sliceNotFound := "noresourcepresent" // verify success resultSuccess := GetNameFromResourceID(sliceSource) assert.Equal(t, sliceResult, resultSuccess) // verify error when seperator not found resultBadSeperator := GetNameFromResourceID(sliceNotFound) assert.Equal(t, "", resultBadSeperator) } ================================================ FILE: modules/azure/servicebus.go ================================================ package azure import ( "context" "testing" "github.com/Azure/azure-sdk-for-go/services/servicebus/mgmt/2017-04-01/servicebus" "github.com/stretchr/testify/require" ) func serviceBusNamespaceClientE(subscriptionID string) (*servicebus.NamespacesClient, error) { authorizer, err := NewAuthorizer() if err != nil { return nil, err } nsClient := servicebus.NewNamespacesClient(subscriptionID) nsClient.Authorizer = *authorizer return &nsClient, nil } func serviceBusTopicClientE(subscriptionID string) (*servicebus.TopicsClient, error) { authorizer, err := NewAuthorizer() if err != nil { return nil, err } tClient := servicebus.NewTopicsClient(subscriptionID) tClient.Authorizer = *authorizer return &tClient, nil } func serviceBusSubscriptionsClientE(subscriptionID string) (*servicebus.SubscriptionsClient, error) { authorizer, err := NewAuthorizer() if err != nil { return nil, err } sClient := servicebus.NewSubscriptionsClient(subscriptionID) sClient.Authorizer = *authorizer return &sClient, nil } // ListServiceBusNamespaceE list all SB namespaces in all resource groups in the given subscription ID. func ListServiceBusNamespaceE(subscriptionID string) ([]servicebus.SBNamespace, error) { nsClient, err := serviceBusNamespaceClientE(subscriptionID) if err != nil { return nil, err } iteratorSBNamespace, err := nsClient.ListComplete(context.Background()) if err != nil { return nil, err } results := make([]servicebus.SBNamespace, 0) for iteratorSBNamespace.NotDone() { results = append(results, iteratorSBNamespace.Value()) if err := iteratorSBNamespace.Next(); err != nil { return nil, err } } return results, nil } // ListServiceBusNamespace - list all SB namespaces in all resource groups in the given subscription ID. This function would fail the test if there is an error. func ListServiceBusNamespace(t *testing.T, subscriptionID string) []servicebus.SBNamespace { results, err := ListServiceBusNamespaceE(subscriptionID) require.NoError(t, err) return results } // ListServiceBusNamespaceNamesE list names of all SB namespaces in all resource groups in the given subscription ID. func ListServiceBusNamespaceNamesE(subscriptionID string) ([]string, error) { sbNamespace, err := ListServiceBusNamespaceE(subscriptionID) if err != nil { return nil, err } results := BuildNamespaceNamesList(sbNamespace) return results, nil } // BuildNamespaceNamesList helper method to build namespace name list func BuildNamespaceNamesList(sbNamespace []servicebus.SBNamespace) []string { results := []string{} for _, namespace := range sbNamespace { results = append(results, *namespace.Name) } return results } // BuildNamespaceIdsList helper method to build namespace id list func BuildNamespaceIdsList(sbNamespace []servicebus.SBNamespace) []string { results := []string{} for _, namespace := range sbNamespace { results = append(results, *namespace.ID) } return results } // ListServiceBusNamespaceNames list names of all SB namespaces in all resource groups in the given subscription ID. This function would fail the test if there is an error. func ListServiceBusNamespaceNames(t *testing.T, subscriptionID string) []string { results, err := ListServiceBusNamespaceNamesE(subscriptionID) require.NoError(t, err) return results } // ListServiceBusNamespaceIDsE list IDs of all SB namespaces in all resource groups in the given subscription ID. func ListServiceBusNamespaceIDsE(subscriptionID string) ([]string, error) { sbNamespace, err := ListServiceBusNamespaceE(subscriptionID) if err != nil { return nil, err } results := BuildNamespaceIdsList(sbNamespace) return results, nil } // ListServiceBusNamespaceIDs list IDs of all SB namespaces in all resource groups in the given subscription ID. This function would fail the test if there is an error. func ListServiceBusNamespaceIDs(t *testing.T, subscriptionID string) []string { results, err := ListServiceBusNamespaceIDsE(subscriptionID) require.NoError(t, err) return results } // ListServiceBusNamespaceByResourceGroupE list all SB namespaces in the given resource group. func ListServiceBusNamespaceByResourceGroupE(subscriptionID string, resourceGroup string) ([]servicebus.SBNamespace, error) { nsClient, err := serviceBusNamespaceClientE(subscriptionID) if err != nil { return nil, err } iteratorSBNamespace, err := nsClient.ListByResourceGroupComplete(context.Background(), resourceGroup) if err != nil { return nil, err } results := make([]servicebus.SBNamespace, 0) for iteratorSBNamespace.NotDone() { results = append(results, iteratorSBNamespace.Value()) if err := iteratorSBNamespace.Next(); err != nil { return nil, err } } return results, nil } // ListServiceBusNamespaceByResourceGroup list all SB namespaces in the given resource group. This function would fail the test if there is an error. func ListServiceBusNamespaceByResourceGroup(t *testing.T, subscriptionID string, resourceGroup string) []servicebus.SBNamespace { results, err := ListServiceBusNamespaceByResourceGroupE(subscriptionID, resourceGroup) require.NoError(t, err) return results } // ListServiceBusNamespaceNamesByResourceGroupE list names of all SB namespaces in the given resource group. This function would fail the test if there is an error. func ListServiceBusNamespaceNamesByResourceGroupE(subscriptionID string, resourceGroup string) ([]string, error) { sbNamespace, err := ListServiceBusNamespaceByResourceGroupE(subscriptionID, resourceGroup) if err != nil { return nil, err } results := BuildNamespaceNamesList(sbNamespace) return results, nil } // ListServiceBusNamespaceNamesByResourceGroup list names of all SB namespaces in the given resource group. func ListServiceBusNamespaceNamesByResourceGroup(t *testing.T, subscriptionID string, resourceGroup string) []string { results, err := ListServiceBusNamespaceNamesByResourceGroupE(subscriptionID, resourceGroup) require.NoError(t, err) return results } // ListServiceBusNamespaceIDsByResourceGroupE list IDs of all SB namespaces in the given resource group. func ListServiceBusNamespaceIDsByResourceGroupE(subscriptionID string, resourceGroup string) ([]string, error) { sbNamespace, err := ListServiceBusNamespaceByResourceGroupE(subscriptionID, resourceGroup) if err != nil { return nil, err } results := BuildNamespaceIdsList(sbNamespace) return results, nil } // ListServiceBusNamespaceIDsByResourceGroup list IDs of all SB namespaces in the given resource group. This function would fail the test if there is an error. func ListServiceBusNamespaceIDsByResourceGroup(t *testing.T, subscriptionID string, resourceGroup string) []string { results, err := ListServiceBusNamespaceIDsByResourceGroupE(subscriptionID, resourceGroup) require.NoError(t, err) return results } // ListNamespaceAuthRulesE - authenticate namespace client and enumerates all values to get list of authorization rules for the given namespace name, // automatically crossing page boundaries as required. func ListNamespaceAuthRulesE(subscriptionID string, namespace string, resourceGroup string) ([]string, error) { nsClient, err := serviceBusNamespaceClientE(subscriptionID) if err != nil { return nil, err } iteratorNamespaceRules, err := nsClient.ListAuthorizationRulesComplete( context.Background(), resourceGroup, namespace) if err != nil { return nil, err } results := []string{} for iteratorNamespaceRules.NotDone() { results = append(results, *(iteratorNamespaceRules.Value()).Name) if err := iteratorNamespaceRules.Next(); err != nil { return nil, err } } return results, nil } // ListNamespaceAuthRules - authenticate namespace client and enumerates all values to get list of authorization rules for the given namespace name, // automatically crossing page boundaries as required. This function would fail the test if there is an error. func ListNamespaceAuthRules(t *testing.T, subscriptionID string, namespace string, resourceGroup string) []string { results, err := ListNamespaceAuthRulesE(subscriptionID, namespace, resourceGroup) require.NoError(t, err) return results } // ListNamespaceTopicsE - authenticate topic client and enumerates all values, automatically crossing page boundaries as required. func ListNamespaceTopicsE(subscriptionID string, namespace string, resourceGroup string) ([]servicebus.SBTopic, error) { tClient, err := serviceBusTopicClientE(subscriptionID) if err != nil { return nil, err } iteratorTopics, err := tClient.ListByNamespaceComplete(context.Background(), resourceGroup, namespace, nil, nil) if err != nil { return nil, err } results := make([]servicebus.SBTopic, 0) for iteratorTopics.NotDone() { results = append(results, iteratorTopics.Value()) if err := iteratorTopics.Next(); err != nil { return nil, err } } return results, nil } // ListNamespaceTopics - authenticate topic client and enumerates all values, automatically crossing page boundaries as required. This function would fail the test if there is an error. func ListNamespaceTopics(t *testing.T, subscriptionID string, namespace string, resourceGroup string) []servicebus.SBTopic { results, err := ListNamespaceTopicsE(subscriptionID, namespace, resourceGroup) require.NoError(t, err) return results } // ListTopicSubscriptionsE - authenticate subscriptions client and enumerates all values, automatically crossing page boundaries as required. func ListTopicSubscriptionsE(subscriptionID string, namespace string, resourceGroup string, topicName string) ([]servicebus.SBSubscription, error) { sClient, err := serviceBusSubscriptionsClientE(subscriptionID) if err != nil { return nil, err } iteratorSubscription, err := sClient.ListByTopicComplete(context.Background(), resourceGroup, namespace, topicName, nil, nil) if err != nil { return nil, err } results := make([]servicebus.SBSubscription, 0) for iteratorSubscription.NotDone() { results = append(results, iteratorSubscription.Value()) if err := iteratorSubscription.Next(); err != nil { return nil, err } } return results, nil } // ListTopicSubscriptions - authenticate subscriptions client and enumerates all values, automatically crossing page boundaries as required. This function would fail the test if there is an error. func ListTopicSubscriptions(t *testing.T, subscriptionID string, namespace string, resourceGroup string, topicName string) []servicebus.SBSubscription { results, err := ListTopicSubscriptionsE(subscriptionID, namespace, resourceGroup, topicName) require.NoError(t, err) return results } // ListTopicSubscriptionsNameE - authenticate subscriptions client and enumerates all values to get list of subscriptions for the given topic name, // automatically crossing page boundaries as required. func ListTopicSubscriptionsNameE(subscriptionID string, namespace string, resourceGroup string, topicName string) ([]string, error) { sClient, err := serviceBusSubscriptionsClientE(subscriptionID) if err != nil { return nil, err } iteratorSubscription, err := sClient.ListByTopicComplete(context.Background(), resourceGroup, namespace, topicName, nil, nil) if err != nil { return nil, err } results := []string{} for iteratorSubscription.NotDone() { results = append(results, *(iteratorSubscription.Value()).Name) if err := iteratorSubscription.Next(); err != nil { return nil, err } } return results, nil } // ListTopicSubscriptionsName - authenticate subscriptions client and enumerates all values to get list of subscriptions for the given topic name, // automatically crossing page boundaries as required. This function would fail the test if there is an error. func ListTopicSubscriptionsName(t *testing.T, subscriptionID string, namespace string, resourceGroup string, topicName string) []string { results, err := ListTopicSubscriptionsNameE(subscriptionID, namespace, resourceGroup, topicName) require.NoError(t, err) return results } // ListTopicAuthRulesE - authenticate topic client and enumerates all values to get list of authorization rules for the given topic name, // automatically crossing page boundaries as required. func ListTopicAuthRulesE(subscriptionID string, namespace string, resourceGroup string, topicName string) ([]string, error) { tClient, err := serviceBusTopicClientE(subscriptionID) if err != nil { return nil, err } iteratorTopicsRules, err := tClient.ListAuthorizationRulesComplete( context.Background(), resourceGroup, namespace, topicName) if err != nil { return nil, err } results := []string{} for iteratorTopicsRules.NotDone() { results = append(results, *(iteratorTopicsRules.Value()).Name) if err := iteratorTopicsRules.Next(); err != nil { return nil, err } } return results, nil } // ListTopicAuthRules - authenticate topic client and enumerates all values to get list of authorization rules for the given topic name, // automatically crossing page boundaries as required. This function would fail the test if there is an error. func ListTopicAuthRules(t *testing.T, subscriptionID string, namespace string, resourceGroup string, topicName string) []string { results, err := ListTopicAuthRulesE(subscriptionID, namespace, resourceGroup, topicName) require.NoError(t, err) return results } ================================================ FILE: modules/azure/servicebus_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package azure import ( "testing" "github.com/stretchr/testify/require" ) /* The below tests are currently stubbed out, with the expectation that they will throw errors. These tests can be extended. */ func TestListServiceBusNamespaceNamesE(t *testing.T) { t.Parallel() subscriptionID := "" _, err := ListServiceBusNamespaceNamesE(subscriptionID) require.Error(t, err) } func TestListServiceBusNamespaceIDsByResourceGroupE(t *testing.T) { t.Parallel() subscriptionID := "" resourceGroup := "" _, err := ListServiceBusNamespaceIDsByResourceGroupE(subscriptionID, resourceGroup) require.Error(t, err) } func TestListNamespaceAuthRulesE(t *testing.T) { t.Parallel() subscriptionID := "" namespace := "" resourceGroup := "" _, err := ListNamespaceAuthRulesE(subscriptionID, namespace, resourceGroup) require.Error(t, err) } func TestListNamespaceTopicsE(t *testing.T) { t.Parallel() subscriptionID := "" namespace := "" resourceGroup := "" _, err := ListNamespaceTopicsE(subscriptionID, namespace, resourceGroup) require.Error(t, err) } func TestListTopicAuthRulesE(t *testing.T) { t.Parallel() subscriptionID := "" namespace := "" resourceGroup := "" topicName := "" _, err := ListTopicAuthRulesE(subscriptionID, namespace, resourceGroup, topicName) require.Error(t, err) } func TestListTopicSubscriptionsNameE(t *testing.T) { t.Parallel() subscriptionID := "" namespace := "" resourceGroup := "" topicName := "" _, err := ListTopicSubscriptionsNameE(subscriptionID, namespace, resourceGroup, topicName) require.Error(t, err) } ================================================ FILE: modules/azure/sql.go ================================================ package azure import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // GetSQLServerClient is a helper function that will setup a sql server client func GetSQLServerClient(subscriptionID string) (*armsql.ServersClient, error) { return CreateSQLServerClient(subscriptionID) } // GetSQLServer is a helper function that gets the sql server object. // This function would fail the test if there is an error. func GetSQLServer(t testing.TestingT, resGroupName string, serverName string, subscriptionID string) *armsql.Server { sqlServer, err := GetSQLServerE(t, subscriptionID, resGroupName, serverName) require.NoError(t, err) return sqlServer } // GetSQLServerE is a helper function that gets the sql server object. func GetSQLServerE(t testing.TestingT, subscriptionID string, resGroupName string, serverName string) (*armsql.Server, error) { // Create a SQL Server client sqlClient, err := CreateSQLServerClient(subscriptionID) if err != nil { return nil, err } // Get the corresponding server resp, err := sqlClient.Get(context.Background(), resGroupName, serverName, nil) if err != nil { return nil, err } return &resp.Server, nil } // GetDatabaseClient is a helper function that will setup a sql DB client func GetDatabaseClient(subscriptionID string) (*armsql.DatabasesClient, error) { return CreateDatabaseClient(subscriptionID) } // ListSQLServerDatabases is a helper function that gets a list of databases on a sql server func ListSQLServerDatabases(t testing.TestingT, resGroupName string, serverName string, subscriptionID string) []*armsql.Database { dbList, err := ListSQLServerDatabasesE(t, resGroupName, serverName, subscriptionID) require.NoError(t, err) return dbList } // ListSQLServerDatabasesE is a helper function that gets a list of databases on a sql server func ListSQLServerDatabasesE(t testing.TestingT, resGroupName string, serverName string, subscriptionID string) ([]*armsql.Database, error) { // Create a SQL db client sqlClient, err := CreateDatabaseClient(subscriptionID) if err != nil { return nil, err } // Get the databases using pager pager := sqlClient.NewListByServerPager(resGroupName, serverName, nil) var databases []*armsql.Database for pager.More() { page, err := pager.NextPage(context.Background()) if err != nil { return nil, err } databases = append(databases, page.Value...) } return databases, nil } // GetSQLDatabase is a helper function that gets the sql db. // This function would fail the test if there is an error. func GetSQLDatabase(t testing.TestingT, resGroupName string, serverName string, dbName string, subscriptionID string) *armsql.Database { database, err := GetSQLDatabaseE(t, subscriptionID, resGroupName, serverName, dbName) require.NoError(t, err) return database } // GetSQLDatabaseE is a helper function that gets the sql db. func GetSQLDatabaseE(t testing.TestingT, subscriptionID string, resGroupName string, serverName string, dbName string) (*armsql.Database, error) { // Create a SQL db client sqlClient, err := CreateDatabaseClient(subscriptionID) if err != nil { return nil, err } // Get the corresponding DB resp, err := sqlClient.Get(context.Background(), resGroupName, serverName, dbName, nil) if err != nil { return nil, err } return &resp.Database, nil } ================================================ FILE: modules/azure/sql_managedinstance.go ================================================ package azure import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // SQLManagedInstanceExists indicates whether the SQL Managed Instance exists for the subscription. // This function would fail the test if there is an error. func SQLManagedInstanceExists(t testing.TestingT, managedInstanceName string, resourceGroupName string, subscriptionID string) bool { exists, err := SQLManagedInstanceExistsE(managedInstanceName, resourceGroupName, subscriptionID) require.NoError(t, err) return exists } // SQLManagedInstanceExistsE indicates whether the specified SQL Managed Instance exists and may return an error. func SQLManagedInstanceExistsE(managedInstanceName string, resourceGroupName string, subscriptionID string) (bool, error) { _, err := GetManagedInstanceE(subscriptionID, resourceGroupName, managedInstanceName) if err != nil { if ResourceNotFoundErrorExists(err) { return false, nil } return false, err } return true, nil } // GetManagedInstance is a helper function that gets the sql managed instance object. // This function would fail the test if there is an error. func GetManagedInstance(t testing.TestingT, resGroupName string, managedInstanceName string, subscriptionID string) *armsql.ManagedInstance { managedInstance, err := GetManagedInstanceE(subscriptionID, resGroupName, managedInstanceName) require.NoError(t, err) return managedInstance } // GetManagedInstanceDatabase is a helper function that gets the sql managed database object. // This function would fail the test if there is an error. func GetManagedInstanceDatabase(t testing.TestingT, resGroupName string, managedInstanceName string, databaseName string, subscriptionID string) *armsql.ManagedDatabase { managedDatabase, err := GetManagedInstanceDatabaseE(t, subscriptionID, resGroupName, managedInstanceName, databaseName) require.NoError(t, err) return managedDatabase } // GetManagedInstanceE is a helper function that gets the sql managed instance object. func GetManagedInstanceE(subscriptionID string, resGroupName string, managedInstanceName string) (*armsql.ManagedInstance, error) { // Create a SQL Managed Instance client sqlmiClient, err := CreateSQLMangedInstanceClient(subscriptionID) if err != nil { return nil, err } // Get the corresponding managed instance resp, err := sqlmiClient.Get(context.Background(), resGroupName, managedInstanceName, nil) if err != nil { return nil, err } return &resp.ManagedInstance, nil } // GetManagedInstanceDatabaseE is a helper function that gets the sql managed database object. func GetManagedInstanceDatabaseE(t testing.TestingT, subscriptionID string, resGroupName string, managedInstanceName string, databaseName string) (*armsql.ManagedDatabase, error) { // Create a SQL MI db client sqlmiDbClient, err := CreateSQLMangedDatabasesClient(subscriptionID) if err != nil { return nil, err } // Get the corresponding database resp, err := sqlmiDbClient.Get(context.Background(), resGroupName, managedInstanceName, databaseName, nil) if err != nil { return nil, err } return &resp.ManagedDatabase, nil } ================================================ FILE: modules/azure/sql_managedinstance_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package azure import ( "testing" "github.com/stretchr/testify/require" ) /* The below tests are currently stubbed out, with the expectation that they will throw errors. If/when CRUD methods are introduced for Azure SQL DB, these tests can be extended */ func TestSQLManagedInstanceExists(t *testing.T) { t.Parallel() managedInstanceName := "" resourceGroupName := "" subscriptionID := "" exists, err := SQLManagedInstanceExistsE(managedInstanceName, resourceGroupName, subscriptionID) require.False(t, exists) require.Error(t, err) } func TestGetManagedInstanceE(t *testing.T) { t.Parallel() resGroupName := "" managedInstanceName := "" subscriptionID := "" _, err := GetManagedInstanceE(subscriptionID, resGroupName, managedInstanceName) require.Error(t, err) } func TestGetManagedInstanceDatabasesE(t *testing.T) { t.Parallel() resGroupName := "" managedInstanceName := "" databaseName := "" subscriptionID := "" _, err := GetManagedInstanceDatabaseE(t, subscriptionID, resGroupName, managedInstanceName, databaseName) require.Error(t, err) } ================================================ FILE: modules/azure/sql_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package azure import ( "testing" "github.com/stretchr/testify/require" ) /* The below tests are currently stubbed out, with the expectation that they will throw errors. If/when CRUD methods are introduced for Azure SQL DB, these tests can be extended */ func TestGetSQLServerE(t *testing.T) { t.Parallel() resGroupName := "" serverName := "" subscriptionID := "" _, err := GetSQLServerE(t, resGroupName, serverName, subscriptionID) require.Error(t, err) } func TestGetSQLDatabaseE(t *testing.T) { t.Parallel() resGroupName := "" serverName := "" dbName := "" subscriptionID := "" _, err := GetSQLDatabaseE(t, resGroupName, serverName, dbName, subscriptionID) require.Error(t, err) } ================================================ FILE: modules/azure/storage.go ================================================ package azure import ( "context" "fmt" "testing" "github.com/Azure/azure-sdk-for-go/services/storage/mgmt/2019-06-01/storage" "github.com/Azure/go-autorest/autorest/azure" "github.com/stretchr/testify/require" ) // StorageAccountExists indicates whether the storage account name exactly matches; otherwise false. // This function would fail the test if there is an error. func StorageAccountExists(t *testing.T, storageAccountName string, resourceGroupName string, subscriptionID string) bool { result, err := StorageAccountExistsE(storageAccountName, resourceGroupName, subscriptionID) require.NoError(t, err) return result } // StorageBlobContainerExists returns true if the container name exactly matches; otherwise false // This function would fail the test if there is an error. func StorageBlobContainerExists(t *testing.T, containerName string, storageAccountName string, resourceGroupName string, subscriptionID string) bool { result, err := StorageBlobContainerExistsE(containerName, storageAccountName, resourceGroupName, subscriptionID) require.NoError(t, err) return result } // StorageFileShareExists returns true if the file share name exactly matches; otherwise false // This function would fail the test if there is an error. func StorageFileShareExists(t *testing.T, fileSahreName string, storageAccountName string, resourceGroupName string, subscriptionID string) bool { result, err := StorageFileShareExistsE(t, fileSahreName, storageAccountName, resourceGroupName, subscriptionID) require.NoError(t, err) return result } // StorageFileShareExists returns true if the file share name exactly matches; otherwise false func StorageFileShareExistsE(t *testing.T, fileSahreName string, storageAccountName string, resourceGroupName string, subscriptionID string) (bool, error) { _, err := GetStorageFileShareE(fileSahreName, storageAccountName, resourceGroupName, subscriptionID) if err != nil { if ResourceNotFoundErrorExists(err) { return false, nil } return false, err } return true, nil } // GetStorageBlobContainerPublicAccess indicates whether a storage container has public access; otherwise false. // This function would fail the test if there is an error. func GetStorageBlobContainerPublicAccess(t *testing.T, containerName string, storageAccountName string, resourceGroupName string, subscriptionID string) bool { result, err := GetStorageBlobContainerPublicAccessE(containerName, storageAccountName, resourceGroupName, subscriptionID) require.NoError(t, err) return result } // GetStorageAccountKind returns one of Storage, StorageV2, BlobStorage, FileStorage, or BlockBlobStorage. // This function would fail the test if there is an error. func GetStorageAccountKind(t *testing.T, storageAccountName string, resourceGroupName string, subscriptionID string) string { result, err := GetStorageAccountKindE(storageAccountName, resourceGroupName, subscriptionID) require.NoError(t, err) return result } // GetStorageAccountSkuTier returns the storage account sku tier as Standard or Premium. // This function would fail the test if there is an error. func GetStorageAccountSkuTier(t *testing.T, storageAccountName string, resourceGroupName string, subscriptionID string) string { result, err := GetStorageAccountSkuTierE(storageAccountName, resourceGroupName, subscriptionID) require.NoError(t, err) return result } // GetStorageDNSString builds and returns the storage account dns string if the storage account exists. // This function would fail the test if there is an error. func GetStorageDNSString(t *testing.T, storageAccountName string, resourceGroupName string, subscriptionID string) string { result, err := GetStorageDNSStringE(storageAccountName, resourceGroupName, subscriptionID) require.NoError(t, err) return result } // StorageAccountExistsE indicates whether the storage account name exists; otherwise false. func StorageAccountExistsE(storageAccountName, resourceGroupName, subscriptionID string) (bool, error) { _, err := GetStorageAccountE(storageAccountName, resourceGroupName, subscriptionID) if err != nil { if ResourceNotFoundErrorExists(err) { return false, nil } return false, err } return true, nil } // GetStorageAccountE gets a storage account; otherwise error. See https://docs.microsoft.com/rest/api/storagerp/storageaccounts/getproperties for more information. func GetStorageAccountE(storageAccountName, resourceGroupName, subscriptionID string) (*storage.Account, error) { subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } resourceGroupName, err2 := getTargetAzureResourceGroupName((resourceGroupName)) if err2 != nil { return nil, err2 } storageAccount, err3 := GetStorageAccountPropertyE(storageAccountName, resourceGroupName, subscriptionID) if err3 != nil { return nil, err3 } return storageAccount, nil } // StorageBlobContainerExistsE returns true if the container name exists; otherwise false. func StorageBlobContainerExistsE(containerName, storageAccountName, resourceGroupName, subscriptionID string) (bool, error) { _, err := GetStorageBlobContainerE(containerName, storageAccountName, resourceGroupName, subscriptionID) if err != nil { if ResourceNotFoundErrorExists(err) { return false, nil } return false, err } return true, nil } // GetStorageBlobContainerPublicAccessE indicates whether a storage container has public access; otherwise false. func GetStorageBlobContainerPublicAccessE(containerName, storageAccountName, resourceGroupName, subscriptionID string) (bool, error) { container, err := GetStorageBlobContainerE(containerName, storageAccountName, resourceGroupName, subscriptionID) if err != nil { if ResourceNotFoundErrorExists(err) { return false, nil } return false, err } return (string(container.PublicAccess) != "None"), nil } // GetStorageAccountKindE returns one of Storage, StorageV2, BlobStorage, FileStorage, or BlockBlobStorage. func GetStorageAccountKindE(storageAccountName, resourceGroupName, subscriptionID string) (string, error) { storageAccount, err := GetStorageAccountPropertyE(storageAccountName, resourceGroupName, subscriptionID) if err != nil { return "", err } return string(storageAccount.Kind), nil } // GetStorageAccountSkuTierE returns the storage account sku tier as Standard or Premium. func GetStorageAccountSkuTierE(storageAccountName, resourceGroupName, subscriptionID string) (string, error) { storageAccount, err := GetStorageAccountPropertyE(storageAccountName, resourceGroupName, subscriptionID) if err != nil { return "", err } return string(storageAccount.Sku.Tier), nil } // GetStorageBlobContainerE returns Blob container client. func GetStorageBlobContainerE(containerName, storageAccountName, resourceGroupName, subscriptionID string) (*storage.BlobContainer, error) { subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } resourceGroupName, err2 := getTargetAzureResourceGroupName((resourceGroupName)) if err2 != nil { return nil, err2 } client, err := CreateStorageBlobContainerClientE(subscriptionID) if err != nil { return nil, err } container, err := client.Get(context.Background(), resourceGroupName, storageAccountName, containerName) if err != nil { return nil, err } return &container, nil } // GetStorageAccountPropertyE returns StorageAccount properties. func GetStorageAccountPropertyE(storageAccountName, resourceGroupName, subscriptionID string) (*storage.Account, error) { subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } resourceGroupName, err2 := getTargetAzureResourceGroupName((resourceGroupName)) if err2 != nil { return nil, err2 } client, err := CreateStorageAccountClientE(subscriptionID) if err != nil { return nil, err } account, err := client.GetProperties(context.Background(), resourceGroupName, storageAccountName, "") if err != nil { return nil, err } return &account, nil } // GetStorageFileShare returns specified file share. This function would fail the test if there is an error. func GetStorageFileShare(t *testing.T, fileShareName, storageAccountName, resourceGroupName, subscriptionID string) *storage.FileShare { fileSahre, err := GetStorageFileShareE(fileShareName, storageAccountName, resourceGroupName, subscriptionID) require.NoError(t, err) return fileSahre } // GetStorageFileSharesE returns specified file share. func GetStorageFileShareE(fileShareName, storageAccountName, resourceGroupName, subscriptionID string) (*storage.FileShare, error) { resourceGroupName, err2 := getTargetAzureResourceGroupName(resourceGroupName) if err2 != nil { return nil, err2 } client, err := CreateStorageFileSharesClientE(subscriptionID) if err != nil { return nil, err } fileShare, err := client.Get(context.Background(), resourceGroupName, storageAccountName, fileShareName, "stats") if err != nil { return nil, err } return &fileShare, nil } // GetStorageAccountClientE creates a storage account client. // TODO: remove in next version func GetStorageAccountClientE(subscriptionID string) (*storage.AccountsClient, error) { // Validate Azure subscription ID subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } storageAccountClient := storage.NewAccountsClient(subscriptionID) authorizer, err := NewAuthorizer() if err != nil { return nil, err } storageAccountClient.Authorizer = *authorizer return &storageAccountClient, nil } // GetStorageBlobContainerClientE creates a storage container client. // TODO: remove in next version func GetStorageBlobContainerClientE(subscriptionID string) (*storage.BlobContainersClient, error) { subscriptionID, err := getTargetAzureSubscription(subscriptionID) if err != nil { return nil, err } blobContainerClient := storage.NewBlobContainersClient(subscriptionID) authorizer, err := NewAuthorizer() if err != nil { return nil, err } blobContainerClient.Authorizer = *authorizer return &blobContainerClient, nil } // GetStorageURISuffixE returns the proper storage URI suffix for the configured Azure environment. func GetStorageURISuffixE() (string, error) { envName := "AzurePublicCloud" env, err := azure.EnvironmentFromName(envName) if err != nil { return "", err } return env.StorageEndpointSuffix, nil } // GetStorageAccountPrimaryBlobEndpointE gets the storage account blob endpoint as URI string. func GetStorageAccountPrimaryBlobEndpointE(storageAccountName, resourceGroupName, subscriptionID string) (string, error) { storageAccount, err := GetStorageAccountPropertyE(storageAccountName, resourceGroupName, subscriptionID) if err != nil { return "", err } return *storageAccount.AccountProperties.PrimaryEndpoints.Blob, nil } // GetStorageDNSStringE builds and returns the storage account dns string if the storage account exists. func GetStorageDNSStringE(storageAccountName, resourceGroupName, subscriptionID string) (string, error) { retval, err := StorageAccountExistsE(storageAccountName, resourceGroupName, subscriptionID) if err != nil { return "", err } if retval { storageSuffix, err2 := GetStorageURISuffixE() if err2 != nil { return "", err2 } return fmt.Sprintf("https://%s.blob.%s/", storageAccountName, storageSuffix), nil } return "", NewNotFoundError("storage account", storageAccountName, "") } ================================================ FILE: modules/azure/storage_test.go ================================================ package azure import ( "testing" "github.com/stretchr/testify/require" ) /* The below tests are currently stubbed out, with the expectation that they will throw errors. If/when methods to create and delete storage accounts are added, these tests can be extended. */ func TestStorageAccountExists(t *testing.T) { _, err := StorageAccountExistsE("", "", "") require.Error(t, err) } func TestStorageBlobContainerExists(t *testing.T) { _, err := StorageBlobContainerExistsE("", "", "", "") require.Error(t, err) } func TestStorageBlobContainerPublicAccess(t *testing.T) { _, err := GetStorageBlobContainerPublicAccessE("", "", "", "") require.Error(t, err) } func TestGetStorageAccountKind(t *testing.T) { _, err := GetStorageAccountKindE("", "", "") require.Error(t, err) } func TestGetStorageAccountSkuTier(t *testing.T) { _, err := GetStorageAccountSkuTierE("", "", "") require.Error(t, err) } func TestGetStorageDNSString(t *testing.T) { _, err := GetStorageDNSStringE("", "", "") require.Error(t, err) } ================================================ FILE: modules/azure/subscription.go ================================================ package azure import ( "github.com/Azure/azure-sdk-for-go/services/resources/mgmt/2019-06-01/subscriptions" ) // GetSubscriptionClientE is a helper function that will setup an Azure Subscription client on your behalf func GetSubscriptionClientE() (*subscriptions.Client, error) { // Create a Subscription client client, err := CreateSubscriptionsClientE() if err != nil { return nil, err } // Create an authorizer authorizer, err := NewAuthorizer() if err != nil { return nil, err } // Attach authorizer to the client client.Authorizer = *authorizer return &client, nil } ================================================ FILE: modules/azure/synapse.go ================================================ package azure import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/synapse/armsynapse" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // GetSynapseWorkspace is a helper function that gets the synapse workspace. // This function would fail the test if there is an error. func GetSynapseWorkspace(t testing.TestingT, resGroupName string, workspaceName string, subscriptionID string) *armsynapse.Workspace { Workspace, err := GetSynapseWorkspaceE(t, subscriptionID, resGroupName, workspaceName) require.NoError(t, err) return Workspace } // GetSynapseSqlPool is a helper function that gets the synapse sql pool. // This function would fail the test if there is an error. func GetSynapseSqlPool(t testing.TestingT, resGroupName string, workspaceName string, sqlPoolName string, subscriptionID string) *armsynapse.SQLPool { SQLPool, err := GetSynapseSqlPoolE(t, subscriptionID, resGroupName, workspaceName, sqlPoolName) require.NoError(t, err) return SQLPool } // GetSynapseWorkspaceE is a helper function that gets the workspace. func GetSynapseWorkspaceE(t testing.TestingT, subscriptionID string, resGroupName string, workspaceName string) (*armsynapse.Workspace, error) { // Create a synapse client synapseClient, err := CreateSynapseWorkspaceClientE(subscriptionID) if err != nil { return nil, err } // Get the corresponding synapse workspace resp, err := synapseClient.Get(context.Background(), resGroupName, workspaceName, nil) if err != nil { return nil, err } return &resp.Workspace, nil } // GetSynapseSqlPoolE is a helper function that gets the synapse sql pool. func GetSynapseSqlPoolE(t testing.TestingT, subscriptionID string, resGroupName string, workspaceName string, sqlPoolName string) (*armsynapse.SQLPool, error) { // Create a synapse sql pool client synapseSqlPoolClient, err := CreateSynapseSqlPoolClientE(subscriptionID) if err != nil { return nil, err } // Get the corresponding synapse sql pool resp, err := synapseSqlPoolClient.Get(context.Background(), resGroupName, workspaceName, sqlPoolName, nil) if err != nil { return nil, err } return &resp.SQLPool, nil } ================================================ FILE: modules/azure/synapse_test.go ================================================ package azure import ( "testing" "github.com/stretchr/testify/require" ) /* The below tests are currently stubbed out, with the expectation that they will throw errors. If/when CRUD methods are introduced for Azure Synapse, these tests can be extended */ func TestGetSynapseWorkspaceE(t *testing.T) { t.Parallel() resGroupName := "" subscriptionID := "" workspaceName := "" _, err := GetSynapseWorkspaceE(t, subscriptionID, resGroupName, workspaceName) require.Error(t, err) } func TestGetSynapseSqlPoolE(t *testing.T) { t.Parallel() resGroupName := "" subscriptionID := "" workspaceName := "" sqlPoolName := "" _, err := GetSynapseSqlPoolE(t, subscriptionID, resGroupName, workspaceName, sqlPoolName) require.Error(t, err) } ================================================ FILE: modules/azure/virtualnetwork.go ================================================ package azure import ( "context" "net" "github.com/Azure/azure-sdk-for-go/services/network/mgmt/2019-09-01/network" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // VirtualNetworkExists indicates whether the specified Azure Virtual Network exists. // This function would fail the test if there is an error. func VirtualNetworkExists(t testing.TestingT, vnetName string, resGroupName string, subscriptionID string) bool { exists, err := VirtualNetworkExistsE(vnetName, resGroupName, subscriptionID) require.NoError(t, err) return exists } // VirtualNetworkExistsE indicates whether the specified Azure Virtual Network exists. func VirtualNetworkExistsE(vnetName string, resGroupName string, subscriptionID string) (bool, error) { // Get the Virtual Network _, err := GetVirtualNetworkE(vnetName, resGroupName, subscriptionID) if err != nil { if ResourceNotFoundErrorExists(err) { return false, nil } return false, err } return true, nil } // SubnetExists indicates whether the specified Azure Virtual Network Subnet exists. // This function would fail the test if there is an error. func SubnetExists(t testing.TestingT, subnetName string, vnetName string, resGroupName string, subscriptionID string) bool { exists, err := SubnetExistsE(subnetName, vnetName, resGroupName, subscriptionID) require.NoError(t, err) return exists } // SubnetExistsE indicates whether the specified Azure Virtual Network Subnet exists. func SubnetExistsE(subnetName string, vnetName string, resGroupName string, subscriptionID string) (bool, error) { // Get the Subnet _, err := GetSubnetE(subnetName, vnetName, resGroupName, subscriptionID) if err != nil { if ResourceNotFoundErrorExists(err) { return false, nil } return false, err } return true, nil } // CheckSubnetContainsIP checks if the Private IP is contined in the Subnet Address Range. // This function would fail the test if there is an error. func CheckSubnetContainsIP(t testing.TestingT, IP string, subnetName string, vnetName string, resGroupName string, subscriptionID string) bool { inRange, err := CheckSubnetContainsIPE(IP, subnetName, vnetName, resGroupName, subscriptionID) require.NoError(t, err) return inRange } // CheckSubnetContainsIPE checks if the Private IP is contined in the Subnet Address Range. func CheckSubnetContainsIPE(ipAddress string, subnetName string, vnetName string, resGroupName string, subscriptionID string) (bool, error) { // Convert the IP to a net IP address ip := net.ParseIP(ipAddress) if ip == nil { return false, NewFailedToParseError("IP Address", ipAddress) } // Get Subnet subnet, err := GetSubnetE(subnetName, vnetName, resGroupName, subscriptionID) if err != nil { return false, err } // Get Subnet IP range, this required field is never nil therefore no exception handling required. subnetPrefix := *subnet.AddressPrefix // Check if the IP is in the Subnet Range using the net package _, ipNet, err := net.ParseCIDR(subnetPrefix) if err != nil { return false, NewFailedToParseError("Subnet Range", subnetPrefix) } return ipNet.Contains(ip), nil } // GetVirtualNetworkSubnets gets all Subnet names and their respective address prefixes in the // specified Virtual Network. This function would fail the test if there is an error. func GetVirtualNetworkSubnets(t testing.TestingT, vnetName string, resGroupName string, subscriptionID string) map[string]string { subnets, err := GetVirtualNetworkSubnetsE(vnetName, resGroupName, subscriptionID) require.NoError(t, err) return subnets } // GetVirtualNetworkSubnetsE gets all Subnet names and their respective address prefixes in the specified Virtual Network. // Returning both the name and prefix together helps reduce calls for these frequently accessed properties. func GetVirtualNetworkSubnetsE(vnetName string, resGroupName string, subscriptionID string) (map[string]string, error) { subNetDetails := map[string]string{} client, err := GetSubnetClientE(subscriptionID) if err != nil { return subNetDetails, err } subnets, err := client.List(context.Background(), resGroupName, vnetName) if err != nil { return subNetDetails, err } for _, v := range subnets.Values() { subnetName := v.Name subNetAddressPrefix := v.AddressPrefix subNetDetails[string(*subnetName)] = string(*subNetAddressPrefix) } return subNetDetails, nil } // GetVirtualNetworkDNSServerIPs gets a list of all Virtual Network DNS server IPs. // This function would fail the test if there is an error. func GetVirtualNetworkDNSServerIPs(t testing.TestingT, vnetName string, resGroupName string, subscriptionID string) []string { vnetDNSIPs, err := GetVirtualNetworkDNSServerIPsE(vnetName, resGroupName, subscriptionID) require.NoError(t, err) return vnetDNSIPs } // GetVirtualNetworkDNSServerIPsE gets a list of all Virtual Network DNS server IPs with Error. func GetVirtualNetworkDNSServerIPsE(vnetName string, resGroupName string, subscriptionID string) ([]string, error) { // Get Virtual Network vnet, err := GetVirtualNetworkE(vnetName, resGroupName, subscriptionID) if err != nil { return nil, err } return *vnet.DhcpOptions.DNSServers, nil } // GetSubnetE gets a subnet. func GetSubnetE(subnetName string, vnetName string, resGroupName string, subscriptionID string) (*network.Subnet, error) { // Validate Azure Resource Group resGroupName, err := getTargetAzureResourceGroupName(resGroupName) if err != nil { return nil, err } // Get the client reference client, err := GetSubnetClientE(subscriptionID) if err != nil { return nil, err } // Get the Subnet subnet, err := client.Get(context.Background(), resGroupName, vnetName, subnetName, "") if err != nil { return nil, err } return &subnet, nil } // GetSubnetClientE creates a subnet client. func GetSubnetClientE(subscriptionID string) (*network.SubnetsClient, error) { // Create a new Subnet client from client factory client, err := CreateNewSubnetClientE(subscriptionID) if err != nil { return nil, err } // Create an authorizer authorizer, err := NewAuthorizer() if err != nil { return nil, err } client.Authorizer = *authorizer return client, nil } // GetVirtualNetworkE gets Virtual Network in the specified Azure Resource Group. func GetVirtualNetworkE(vnetName string, resGroupName string, subscriptionID string) (*network.VirtualNetwork, error) { // Validate Azure Resource Group resGroupName, err := getTargetAzureResourceGroupName(resGroupName) if err != nil { return nil, err } // Get the client reference client, err := GetVirtualNetworksClientE(subscriptionID) if err != nil { return nil, err } // Get the Virtual Network vnet, err := client.Get(context.Background(), resGroupName, vnetName, "") if err != nil { return nil, err } return &vnet, nil } // GetVirtualNetworksClientE creates a virtual network client in the specified Azure Subscription. func GetVirtualNetworksClientE(subscriptionID string) (*network.VirtualNetworksClient, error) { // Create a new Virtual Network client from client factory client, err := CreateNewVirtualNetworkClientE(subscriptionID) if err != nil { return nil, err } // Create an authorizer authorizer, err := NewAuthorizer() if err != nil { return nil, err } client.Authorizer = *authorizer return client, nil } ================================================ FILE: modules/azure/virtualnetwork_test.go ================================================ //go:build azure || (azureslim && network) // +build azure azureslim,network // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package azure import ( "testing" "github.com/stretchr/testify/require" ) /* The below tests are currently stubbed out, with the expectation that they will throw errors. If/when methods can be mocked or Create/Delete APIs are added, these tests can be extended. */ func TestGetVirtualNetworkE(t *testing.T) { t.Parallel() vnetName := "" rgName := "" subID := "" _, err := GetVirtualNetworkE(vnetName, rgName, subID) require.Error(t, err) } func TestGetSubnetE(t *testing.T) { t.Parallel() subnetName := "" vnetName := "" rgName := "" subID := "" _, err := GetSubnetE(subnetName, vnetName, rgName, subID) require.Error(t, err) } func TestGetVirtualNetworkDNSServerIPsE(t *testing.T) { t.Parallel() vnetName := "" rgName := "" subID := "" _, err := GetVirtualNetworkDNSServerIPsE(vnetName, rgName, subID) require.Error(t, err) } func TestGetVirtualNetworkSubnetsE(t *testing.T) { t.Parallel() vnetName := "" rgName := "" subID := "" _, err := GetVirtualNetworkSubnetsE(vnetName, rgName, subID) require.Error(t, err) } func TestCheckSubnetContainsIPE(t *testing.T) { t.Parallel() ipAddress := "" subnetName := "" vnetName := "" rgName := "" subID := "" _, err := CheckSubnetContainsIPE(ipAddress, subnetName, vnetName, rgName, subID) require.Error(t, err) } func TestSubnetExistsE(t *testing.T) { t.Parallel() subnetName := "" vnetName := "" rgName := "" subID := "" _, err := SubnetExistsE(subnetName, vnetName, rgName, subID) require.Error(t, err) } func TestVirtualNetworkExistsE(t *testing.T) { t.Parallel() vnetName := "" rgName := "" subID := "" _, err := VirtualNetworkExistsE(vnetName, rgName, subID) require.Error(t, err) } ================================================ FILE: modules/collections/collections.go ================================================ // Package collections allows to interact with lists of things. package collections ================================================ FILE: modules/collections/errors.go ================================================ package collections // SliceValueNotFoundError is returned when a provided values file input is not found on the host path. type SliceValueNotFoundError struct { sourceString string } func (err SliceValueNotFoundError) Error() string { return "Could not resolve requested slice value from string " + err.sourceString } // NewSliceValueNotFoundError creates a new slice found error func NewSliceValueNotFoundError(sourceString string) SliceValueNotFoundError { return SliceValueNotFoundError{sourceString} } ================================================ FILE: modules/collections/lists.go ================================================ package collections import "slices" // ListIntersection returns all the items in both list1 and list2. Note that this will dedup the items so that the // output is more predictable. Otherwise, the end list depends on which list was used as the base. func ListIntersection[T comparable](list1 []T, list2 []T) []T { out := []T{} // Only need to iterate list1, because we want items in both lists, not union. for _, item := range list1 { if slices.Contains(list2, item) && !slices.Contains(out, item) { out = append(out, item) } } return out } // ListSubtract removes all the items in list2 from list1. func ListSubtract[T comparable](list1 []T, list2 []T) []T { out := []T{} for _, item := range list1 { if !slices.Contains(list2, item) { out = append(out, item) } } return out } // ListContains returns true if the given list of strings (haystack) contains the given string (needle). // // Deprecated: Use slices.Contains instead. func ListContains(haystack []string, needle string) bool { return slices.Contains(haystack, needle) } ================================================ FILE: modules/collections/lists_test.go ================================================ package collections_test import ( "slices" "testing" "github.com/gruntwork-io/terratest/modules/collections" "github.com/stretchr/testify/assert" ) func TestListContains(t *testing.T) { t.Parallel() testCases := []struct { description string element string list []string expected bool }{ {"empty list, empty element", "", []string{}, false}, {"empty list, non-empty element", "foo", []string{}, false}, {"list with 1 item, element matches", "foo", []string{"foo"}, true}, {"list with 1 item, element doesn't match", "foo", []string{"bar"}, false}, {"list with 3 items, element matches", "foo", []string{"bar", "foo", "baz"}, true}, {"list with 3 items, element doesn't match", "nope", []string{"bar", "foo", "baz"}, false}, {"list with 3 items, empty element", "", []string{"bar", "foo", "baz"}, false}, } for _, testCase := range testCases { t.Run(testCase.description, func(t *testing.T) { t.Parallel() actual := slices.Contains(testCase.list, testCase.element) assert.Equal(t, testCase.expected, actual) }) } } func TestSubtract(t *testing.T) { t.Parallel() testCases := []struct { description string list1 []string list2 []string expected []string }{ {"empty list, empty list", []string{}, []string{}, []string{}}, {"empty list, non-empty list", []string{}, []string{"foo"}, []string{}}, {"non-empty list, empty list", []string{"foo"}, []string{}, []string{"foo"}}, {"list with 1 item, list with no matches", []string{"foo"}, []string{"bar"}, []string{"foo"}}, {"list with 1 item, list with 1 match", []string{"foo"}, []string{"foo"}, []string{}}, {"list with 1 item, list with multiple matches and non-matches", []string{"foo"}, []string{"foo", "bar", "foo"}, []string{}}, {"list with multiple items, list with no matches", []string{"foo", "bar", "baz"}, []string{"abc", "def"}, []string{"foo", "bar", "baz"}}, {"list with multiple items, list with 1 match", []string{"foo", "bar", "baz"}, []string{"abc", "foo", "def"}, []string{"bar", "baz"}}, {"list with multiple items, list with multiple matches", []string{"foo", "bar", "baz", "foo", "bar", "baz"}, []string{"abc", "foo", "baz"}, []string{"bar", "bar"}}, } for _, testCase := range testCases { t.Run(testCase.description, func(t *testing.T) { t.Parallel() actual := collections.ListSubtract(testCase.list1, testCase.list2) assert.Equal(t, testCase.expected, actual) }) } } func TestIntersection(t *testing.T) { t.Parallel() testCases := []struct { description string list1 []string list2 []string expected []string }{ {"empty list, empty list", []string{}, []string{}, []string{}}, {"empty list, non-empty list", []string{}, []string{"foo"}, []string{}}, {"non-empty list, empty list", []string{"foo"}, []string{}, []string{}}, {"list with 1 item, list with no matches", []string{"foo"}, []string{"bar"}, []string{}}, {"list with 1 item, list with 1 match", []string{"foo"}, []string{"foo"}, []string{"foo"}}, {"list with 1 item, list with multiple matches and non-matches", []string{"foo"}, []string{"foo", "bar", "foo"}, []string{"foo"}}, {"list with multiple items, list with no matches", []string{"foo", "bar", "baz"}, []string{"abc", "def"}, []string{}}, {"list with multiple items, list with 1 match", []string{"foo", "bar", "baz"}, []string{"abc", "foo", "def"}, []string{"foo"}}, {"list with multiple items, list with multiple matches", []string{"foo", "bar", "baz", "foo", "bar", "baz"}, []string{"abc", "foo", "baz"}, []string{"foo", "baz"}}, } for _, testCase := range testCases { t.Run(testCase.description, func(t *testing.T) { t.Parallel() actual := collections.ListIntersection(testCase.list1, testCase.list2) assert.Equal(t, testCase.expected, actual) }) } } ================================================ FILE: modules/collections/stringslicevalue.go ================================================ package collections import ( "strings" ) // GetSliceLastValueE will take a source string and returns the last value when split by the separator char. func GetSliceLastValueE(source string, separator string) (string, error) { if len(source) > 0 && len(separator) > 0 && strings.Contains(source, separator) { tmp := strings.Split(source, separator) return tmp[len(tmp)-1], nil } return "", NewSliceValueNotFoundError(source) } // GetSliceIndexValueE will take a source string and returns the value at the given index when split by // the separator char. func GetSliceIndexValueE(source string, separator string, index int) (string, error) { if len(source) > 0 && len(separator) > 0 && strings.Contains(source, separator) && index >= 0 { tmp := strings.Split(source, separator) if index >= len(tmp) { return "", NewSliceValueNotFoundError(source) } return tmp[index], nil } return "", NewSliceValueNotFoundError(source) } ================================================ FILE: modules/collections/stringslicevalue_test.go ================================================ package collections_test import ( "fmt" "testing" "github.com/gruntwork-io/terratest/modules/collections" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestGetSliceLastValue(t *testing.T) { t.Parallel() var testCases = []struct { testName string sliceSource string sliceSeperator string expectedReturn string expectedError bool }{ {testName: "longSlice", sliceSource: "this/is/a/long/slash/separated/string/success", sliceSeperator: "/", expectedReturn: "success", expectedError: false}, {testName: "shortendSlice", sliceSource: "this/is/a/long/slash/separated", sliceSeperator: "/", expectedReturn: "separated", expectedError: false}, {testName: "dashSlice", sliceSource: "this-is-a-long-dash-separated-string-success", sliceSeperator: "-", expectedReturn: "success", expectedError: false}, {testName: "seperatorNotPresent", sliceSource: "this-is-a-long-dash-separated-string-success", sliceSeperator: "/", expectedReturn: "", expectedError: true}, {testName: "sourceNoSeperator", sliceSource: "noslicepresent", sliceSeperator: "/", expectedReturn: "", expectedError: true}, {testName: "emptyStrings", sliceSource: "", sliceSeperator: "", expectedReturn: "", expectedError: true}, } for _, tc := range testCases { testFor := tc // necessary range capture t.Run(testFor.testName, func(t *testing.T) { t.Parallel() actualReturn, err := collections.GetSliceLastValueE(testFor.sliceSource, testFor.sliceSeperator) switch testFor.expectedError { case true: require.Error(t, err) case false: require.NoError(t, err) } assert.Equal(t, testFor.expectedReturn, actualReturn) }) } } func TestGetSliceIndexValue(t *testing.T) { t.Parallel() var testCases = []struct { expectedReturn string sliceIndex int expectedError bool }{ {expectedReturn: "", sliceIndex: -1, expectedError: true}, {expectedReturn: "this", sliceIndex: 0, expectedError: false}, {expectedReturn: "slash", sliceIndex: 4, expectedError: false}, {expectedReturn: "success", sliceIndex: 7, expectedError: false}, {expectedReturn: "", sliceIndex: 10, expectedError: true}, } sliceSource := "this/is/a/long/slash/separated/string/success" sliceSeperator := "/" for _, tc := range testCases { testFor := tc // necessary range capture t.Run(fmt.Sprintf("Index_%v", testFor.sliceIndex), func(t *testing.T) { t.Parallel() actualReturn, err := collections.GetSliceIndexValueE(sliceSource, sliceSeperator, testFor.sliceIndex) switch testFor.expectedError { case true: require.Error(t, err) case false: require.NoError(t, err) } assert.Equal(t, testFor.expectedReturn, actualReturn) }) } } ================================================ FILE: modules/database/database.go ================================================ package database import ( "database/sql" "fmt" "testing" // Microsoft SQL Database Driver _ "github.com/denisenkom/go-mssqldb" // PostgreSQL Database Driver _ "github.com/lib/pq" // MySQL Database Driver _ "github.com/go-sql-driver/mysql" ) const ( _databaseTypeMSSQL = "mssql" _databaseTypePostgres = "postgres" _databaseTypeMySQL = "mysql" _postgresConnStr = "host=%s port=%s user=%s password=%s dbname=%s sslmode=disable" _mssqlConnStr = "server = %s; port = %s; user id = %s; password = %s; database = %s" _mysqlConnStr = "%s:%s@tcp(%s:%s)/%s?allowNativePasswords=true" ) // DBConfig using server name, user name, password and database name type DBConfig struct { Host string Port string User string Password string Database string } // DBConnection connects to the database using database configuration and database type, i.e. mssql, and then return the database. If there's any error, fail the test. func DBConnection(t *testing.T, dbType string, dbConfig DBConfig) *sql.DB { db, err := DBConnectionE(t, dbType, dbConfig) if err != nil { t.Fatal(err) } return db } // DBConnectionE connects to the database using database configuration and database type, i.e. mssql. Return the database or an error. func DBConnectionE(t *testing.T, dbType string, dbConfig DBConfig) (*sql.DB, error) { config := "" switch dbType { case _databaseTypeMSSQL: config = fmt.Sprintf(_mssqlConnStr, dbConfig.Host, dbConfig.Port, dbConfig.User, dbConfig.Password, dbConfig.Database) case _databaseTypePostgres: config = fmt.Sprintf(_postgresConnStr, dbConfig.Host, dbConfig.Port, dbConfig.User, dbConfig.Password, dbConfig.Database) case _databaseTypeMySQL: config = fmt.Sprintf(_mysqlConnStr, dbConfig.User, dbConfig.Password, dbConfig.Host, dbConfig.Port, dbConfig.Database) default: return nil, DBUnknown{dbType: dbType} } db, err := sql.Open(dbType, config) if err != nil { return nil, err } err = db.Ping() if err != nil { return nil, err } return db, nil } // DBExecution executes specific SQL commands, i.e. insertion. If there's any error, fail the test. func DBExecution(t *testing.T, db *sql.DB, command string) { _, err := DBExecutionE(t, db, command) if err != nil { t.Fatal(err) } } // DBExecutionE executes specific SQL commands, i.e. insertion. Return the result or an error. func DBExecutionE(t *testing.T, db *sql.DB, command string) (sql.Result, error) { result, err := db.Exec(command) if err != nil { return nil, err } return result, nil } // DBQuery queries from database, i.e. selection, and then return the result. If there's any error, fail the test. func DBQuery(t *testing.T, db *sql.DB, command string) *sql.Rows { rows, err := DBQueryE(t, db, command) if err != nil { t.Fatal(err) } return rows } // DBQueryE queries from database, i.e. selection. Return the result or an error. func DBQueryE(t *testing.T, db *sql.DB, command string) (*sql.Rows, error) { rows, err := db.Query(command) if err != nil { return nil, err } return rows, nil } // DBQueryWithValidation queries from database and validate whether the result is the same as expected text. If there's any error, fail the test. func DBQueryWithValidation(t *testing.T, db *sql.DB, command string, expected string) { err := DBQueryWithValidationE(t, db, command, expected) if err != nil { t.Fatal(err) } } // DBQueryWithValidationE queries from database and validate whether the result is the same as expected text. If not, return an error. func DBQueryWithValidationE(t *testing.T, db *sql.DB, command string, expected string) error { return DBQueryWithCustomValidationE(t, db, command, func(rows *sql.Rows) bool { var name string for rows.Next() { err := rows.Scan(&name) if err != nil { t.Fatal(err) } if name != expected { return false } } return true }) } // DBQueryWithCustomValidation queries from database and validate whether the result meets the requirement. If there's any error, fail the test. func DBQueryWithCustomValidation(t *testing.T, db *sql.DB, command string, validateResponse func(*sql.Rows) bool) { err := DBQueryWithCustomValidationE(t, db, command, validateResponse) if err != nil { t.Fatal(err) } } // DBQueryWithCustomValidationE queries from database and validate whether the result meets the requirement. If not, return an error. func DBQueryWithCustomValidationE(t *testing.T, db *sql.DB, command string, validateResponse func(*sql.Rows) bool) error { rows, err := DBQueryE(t, db, command) if err != nil { return err } defer rows.Close() if !validateResponse(rows) { return ValidationFunctionFailed{command: command} } return nil } // ValidationFunctionFailed is an error that occurs if the validation function fails. type ValidationFunctionFailed struct { command string } func (err ValidationFunctionFailed) Error() string { return fmt.Sprintf("Validation failed for command: %s.", err.command) } // DBUnknown is an error that occurs if the given database type is unknown or not supported. type DBUnknown struct { dbType string } func (err DBUnknown) Error() string { return fmt.Sprintf("Database unknown or not supported: %s. We only support mssql, postgres and mysql.", err.dbType) } ================================================ FILE: modules/dns-helper/dns_helper.go ================================================ // Package dns_helper contains helpers to interact with the Domain Name System. package dns_helper import ( "fmt" "net" "reflect" "sort" "strings" "time" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/testing" "github.com/miekg/dns" "github.com/stretchr/testify/require" ) // DNSFindNameservers tries to find the NS record for the given FQDN, iterating down the domain hierarchy // until it founds the NS records and returns it. Fails if there's any error or no NS record is found up to the apex domain. func DNSFindNameservers(t testing.TestingT, fqdn string, resolvers []string) []string { nameservers, err := DNSFindNameserversE(t, fqdn, resolvers) require.NoError(t, err) return nameservers } // DNSFindNameserversE tries to find the NS record for the given FQDN, iterating down the domain hierarchy // until it founds the NS records and returns it. Returns the last error if the apex domain is reached with no result. func DNSFindNameserversE(t testing.TestingT, fqdn string, resolvers []string) ([]string, error) { var lookupFunc func(domain string) ([]string, error) if resolvers == nil { lookupFunc = func(domain string) ([]string, error) { var nameservers []string res, err := net.LookupNS(domain) for _, ns := range res { nameservers = append(nameservers, ns.Host) } return nameservers, err } } else { lookupFunc = func(domain string) ([]string, error) { var nameservers []string res, err := DNSLookupE(t, DNSQuery{"NS", domain}, resolvers) for _, r := range res { if r.Type == "NS" { nameservers = append(nameservers, r.Value) } } return nameservers, err } } parts := strings.Split(fqdn, ".") var domain string for i := range parts[:len(parts)-1] { domain = strings.Join(parts[i:], ".") res, err := lookupFunc(domain) if len(res) > 0 { var nameservers []string for _, ns := range res { nameservers = append(nameservers, strings.TrimSuffix(ns, ".")) } logger.Default.Logf(t, "FQDN %s belongs to domain %s, found NS record: %s", fqdn, domain, nameservers) return nameservers, nil } if err != nil { logger.Default.Logf(t, "%s", err.Error()) } } err := &NSNotFoundError{fqdn, domain} return nil, err } // DNSLookupAuthoritative gets authoritative answers for the specified record and type. // If resolvers are defined, uses them instead of the default system ones to find the authoritative nameservers. // Fails on any error from DNSLookupAuthoritativeE. func DNSLookupAuthoritative(t testing.TestingT, query DNSQuery, resolvers []string) DNSAnswers { res, err := DNSLookupAuthoritativeE(t, query, resolvers) require.NoError(t, err) return res } // DNSLookupAuthoritativeE gets authoritative answers for the specified record and type. // If resolvers are defined, uses them instead of the default system ones to find the authoritative nameservers. // Returns NotFoundError when no answer found in any authoritative nameserver. // Returns any underlying error from individual lookups. func DNSLookupAuthoritativeE(t testing.TestingT, query DNSQuery, resolvers []string) (DNSAnswers, error) { nameservers, err := DNSFindNameserversE(t, query.Name, resolvers) if err != nil { return nil, err } return DNSLookupE(t, query, nameservers) } // DNSLookupAuthoritativeWithRetry repeatedly gets authoritative answers for the specified record and type // until ANY of the authoritative nameservers found replies with non-empty answer matching the expectedAnswers, // or until max retries has been exceeded. // If resolvers are defined, uses them instead of the default system ones to find the authoritative nameservers. // Fails on any error from DNSLookupAuthoritativeWithRetryE. func DNSLookupAuthoritativeWithRetry(t testing.TestingT, query DNSQuery, resolvers []string, maxRetries int, sleepBetweenRetries time.Duration) DNSAnswers { res, err := DNSLookupAuthoritativeWithRetryE(t, query, resolvers, maxRetries, sleepBetweenRetries) require.NoError(t, err) return res } // DNSLookupAuthoritativeWithRetryE repeatedly gets authoritative answers for the specified record and type // until ANY of the authoritative nameservers found replies with non-empty answer matching the expectedAnswers, // or until max retries has been exceeded. // If resolvers are defined, uses them instead of the default system ones to find the authoritative nameservers. func DNSLookupAuthoritativeWithRetryE(t testing.TestingT, query DNSQuery, resolvers []string, maxRetries int, sleepBetweenRetries time.Duration) (DNSAnswers, error) { res, err := retry.DoWithRetryInterfaceE( t, fmt.Sprintf("DNSLookupAuthoritativeE %s record for %s using authoritative nameservers", query.Type, query.Name), maxRetries, sleepBetweenRetries, func() (interface{}, error) { return DNSLookupAuthoritativeE(t, query, resolvers) }) return res.(DNSAnswers), err } // DNSLookupAuthoritativeAll gets authoritative answers for the specified record and type. // All the authoritative nameservers found must give the same answers. // If resolvers are defined, uses them instead of the default system ones to find the authoritative nameservers. // Fails on any error from DNSLookupAuthoritativeAllE. func DNSLookupAuthoritativeAll(t testing.TestingT, query DNSQuery, resolvers []string) DNSAnswers { res, err := DNSLookupAuthoritativeAllE(t, query, resolvers) require.NoError(t, err) return res } // DNSLookupAuthoritativeAllE gets authoritative answers for the specified record and type. // All the authoritative nameservers found must give the same answers. // If resolvers are defined, uses them instead of the default system ones to find the authoritative nameservers. // Returns InconsistentAuthoritativeError when any authoritative nameserver gives a different answer. // Returns any underlying error. func DNSLookupAuthoritativeAllE(t testing.TestingT, query DNSQuery, resolvers []string) (DNSAnswers, error) { nameservers, err := DNSFindNameserversE(t, query.Name, resolvers) if err != nil { return nil, err } var answers DNSAnswers for _, ns := range nameservers { res, err := DNSLookupE(t, query, []string{ns}) if err != nil { return nil, err } if len(answers) > 0 { if !reflect.DeepEqual(answers, res) { err := &InconsistentAuthoritativeError{Query: query, Answers: res, Nameserver: ns, PreviousAnswers: answers} return nil, err } } else { answers = res } } return answers, nil } // DNSLookupAuthoritativeAllWithRetry repeatedly sends DNS requests for the specified record and type, // until ALL authoritative nameservers reply with the exact same non-empty answers or until max retries has been exceeded. // If defined, uses the given resolvers instead of the default system ones to find the authoritative nameservers. // Fails when max retries has been exceeded. func DNSLookupAuthoritativeAllWithRetry(t testing.TestingT, query DNSQuery, resolvers []string, maxRetries int, sleepBetweenRetries time.Duration) { _, err := DNSLookupAuthoritativeAllWithRetryE(t, query, resolvers, maxRetries, sleepBetweenRetries) require.NoError(t, err) } // DNSLookupAuthoritativeAllWithRetryE repeatedly sends DNS requests for the specified record and type, // until ALL authoritative nameservers reply with the exact same non-empty answers or until max retries has been exceeded. // If defined, uses the given resolvers instead of the default system ones to find the authoritative nameservers. func DNSLookupAuthoritativeAllWithRetryE(t testing.TestingT, query DNSQuery, resolvers []string, maxRetries int, sleepBetweenRetries time.Duration) (DNSAnswers, error) { res, err := retry.DoWithRetryInterfaceE( t, fmt.Sprintf("DNSLookupAuthoritativeAllE %s record for %s using authoritative nameservers", query.Type, query.Name), maxRetries, sleepBetweenRetries, func() (interface{}, error) { return DNSLookupAuthoritativeAllE(t, query, resolvers) }) return res.(DNSAnswers), err } // DNSLookupAuthoritativeAllWithValidation gets authoritative answers for the specified record and type. // All the authoritative nameservers found must give the same answers and match the expectedAnswers. // If resolvers are defined, uses them instead of the default system ones to find the authoritative nameservers. // Fails on any underlying error from DNSLookupAuthoritativeAllWithValidationE. func DNSLookupAuthoritativeAllWithValidation(t testing.TestingT, query DNSQuery, resolvers []string, expectedAnswers DNSAnswers) { err := DNSLookupAuthoritativeAllWithValidationE(t, query, resolvers, expectedAnswers) require.NoError(t, err) } // DNSLookupAuthoritativeAllWithValidationE gets authoritative answers for the specified record and type. // All the authoritative nameservers found must give the same answers and match the expectedAnswers. // If resolvers are defined, uses them instead of the default system ones to find the authoritative nameservers. // Returns ValidationError when expectedAnswers differ from the obtained ones. // Returns any underlying error from DNSLookupAuthoritativeAllE. func DNSLookupAuthoritativeAllWithValidationE(t testing.TestingT, query DNSQuery, resolvers []string, expectedAnswers DNSAnswers) error { expectedAnswers.Sort() answers, err := DNSLookupAuthoritativeAllE(t, query, resolvers) if err != nil { return err } if !reflect.DeepEqual(answers, expectedAnswers) { err := &ValidationError{Query: query, Answers: answers, ExpectedAnswers: expectedAnswers} return err } return nil } // DNSLookupAuthoritativeAllWithValidationRetry repeatedly gets authoritative answers for the specified record and type // until ALL the authoritative nameservers found give the same answers and match the expectedAnswers, // or until max retries has been exceeded. // If resolvers are defined, uses them instead of the default system ones to find the authoritative nameservers. // Fails when max retries has been exceeded. func DNSLookupAuthoritativeAllWithValidationRetry(t testing.TestingT, query DNSQuery, resolvers []string, expectedAnswers DNSAnswers, maxRetries int, sleepBetweenRetries time.Duration) { err := DNSLookupAuthoritativeAllWithValidationRetryE(t, query, resolvers, expectedAnswers, maxRetries, sleepBetweenRetries) require.NoError(t, err) } // DNSLookupAuthoritativeAllWithValidationRetryE repeatedly gets authoritative answers for the specified record and type // until ALL the authoritative nameservers found give the same answers and match the expectedAnswers, // or until max retries has been exceeded. // If resolvers are defined, uses them instead of the default system ones to find the authoritative nameservers. func DNSLookupAuthoritativeAllWithValidationRetryE(t testing.TestingT, query DNSQuery, resolvers []string, expectedAnswers DNSAnswers, maxRetries int, sleepBetweenRetries time.Duration) error { _, err := retry.DoWithRetryInterfaceE( t, fmt.Sprintf("DNSLookupAuthoritativeAllWithValidationRetryE %s record for %s using authoritative nameservers", query.Type, query.Name), maxRetries, sleepBetweenRetries, func() (interface{}, error) { return nil, DNSLookupAuthoritativeAllWithValidationE(t, query, resolvers, expectedAnswers) }) return err } // DNSLookup sends a DNS query for the specified record and type using the given resolvers. // Fails on any error. // Supported record types: A, AAAA, CNAME, MX, NS, TXT func DNSLookup(t testing.TestingT, query DNSQuery, resolvers []string) DNSAnswers { res, err := DNSLookupE(t, query, resolvers) require.NoError(t, err) return res } // DNSLookupE sends a DNS query for the specified record and type using the given resolvers. // Returns QueryTypeError when record type is not supported. // Returns any underlying error. // Supported record types: A, AAAA, CNAME, MX, NS, TXT func DNSLookupE(t testing.TestingT, query DNSQuery, resolvers []string) (DNSAnswers, error) { if len(resolvers) == 0 { err := &NoResolversError{} return nil, err } var dnsAnswers DNSAnswers var err error for _, resolver := range resolvers { dnsAnswers, err = dnsLookup(t, query, resolver) if err == nil { return dnsAnswers, nil } } return nil, err } // dnsLookup sends a DNS query for the specified record and type using the given resolver. // Returns DNSAnswers to the DNSQuery. // If no records found, returns NotFoundError. func dnsLookup(t testing.TestingT, query DNSQuery, resolver string) (DNSAnswers, error) { switch query.Type { case "A", "AAAA", "CNAME", "MX", "NS", "TXT": default: err := &QueryTypeError{query.Type} return nil, err } qType, ok := dns.StringToType[strings.ToUpper(query.Type)] if !ok { err := &QueryTypeError{query.Type} return nil, err } if strings.LastIndex(resolver, ":") <= strings.LastIndex(resolver, "]") { resolver += ":53" } c := new(dns.Client) m := new(dns.Msg) m.SetQuestion(dns.Fqdn(query.Name), qType) in, _, err := c.Exchange(m, resolver) if err != nil { logger.Default.Logf(t, "Error sending DNS query %s: %s", query, err) return nil, err } if len(in.Answer) == 0 { err := &NotFoundError{query, resolver} return nil, err } var dnsAnswers DNSAnswers for _, a := range in.Answer { switch at := a.(type) { case *dns.A: dnsAnswers = append(dnsAnswers, DNSAnswer{"A", at.A.String()}) case *dns.AAAA: dnsAnswers = append(dnsAnswers, DNSAnswer{"AAAA", at.AAAA.String()}) case *dns.CNAME: dnsAnswers = append(dnsAnswers, DNSAnswer{"CNAME", at.Target}) case *dns.NS: dnsAnswers = append(dnsAnswers, DNSAnswer{"NS", at.Ns}) case *dns.MX: dnsAnswers = append(dnsAnswers, DNSAnswer{"MX", fmt.Sprintf("%d %s", at.Preference, at.Mx)}) case *dns.TXT: for _, txt := range at.Txt { dnsAnswers = append(dnsAnswers, DNSAnswer{"TXT", fmt.Sprintf(`"%s"`, txt)}) } } } dnsAnswers.Sort() return dnsAnswers, nil } // DNSQuery type type DNSQuery struct { Type, Name string } // DNSAnswer type type DNSAnswer struct { Type, Value string } func (a DNSAnswer) String() string { return fmt.Sprintf("%s %s", a.Type, a.Value) } // DNSAnswers type type DNSAnswers []DNSAnswer // Sort sorts the answers by type and value func (a DNSAnswers) Sort() { sort.Slice(a, func(i, j int) bool { return a[i].Type < a[j].Type || a[i].Value < a[j].Value }) } ================================================ FILE: modules/dns-helper/dns_helper_test.go ================================================ package dns_helper import ( "testing" "time" "github.com/gruntwork-io/terratest/modules/retry" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // These are the current public nameservers for gruntwork.io domain // They should be updated whenever they change to pass the tests // relying on the public DNS infrastructure var publicDomainNameservers = []string{ "ns-1499.awsdns-59.org", "ns-190.awsdns-23.com", "ns-1989.awsdns-56.co.uk", "ns-853.awsdns-42.net", } var testDNSDatabase = dnsDatabase{ DNSQuery{"A", "a." + testDomain}: DNSAnswers{ {"A", "2.2.2.2"}, {"A", "1.1.1.1"}, }, DNSQuery{"AAAA", "aaaa." + testDomain}: DNSAnswers{ {"AAAA", "2001:db8::aaaa"}, }, DNSQuery{"CNAME", "terratest." + testDomain}: DNSAnswers{ {"CNAME", "gruntwork-io.github.io."}, }, DNSQuery{"CNAME", "cname1." + testDomain}: DNSAnswers{ {"CNAME", "cname2." + testDomain + "."}, }, DNSQuery{"A", "cname1." + testDomain}: DNSAnswers{ {"CNAME", "cname2." + testDomain + "."}, {"CNAME", "cname3." + testDomain + "."}, {"CNAME", "cname4." + testDomain + "."}, {"CNAME", "cnamefinal." + testDomain + "."}, {"A", "1.1.1.1"}, }, DNSQuery{"TXT", "txt." + testDomain}: DNSAnswers{ {"TXT", `"This is a text."`}, }, DNSQuery{"MX", testDomain}: DNSAnswers{ {"MX", "10 mail." + testDomain + "."}, }, } // Lookup should succeed in finding the nameservers of the public domain // Uses system resolver config func TestOkDNSFindNameservers(t *testing.T) { t.Parallel() fqdn := "terratest.gruntwork.io" expectedNameservers := publicDomainNameservers nameservers, err := DNSFindNameserversE(t, fqdn, nil) require.NoError(t, err) require.ElementsMatch(t, nameservers, expectedNameservers) } // Lookup should fail because of inexistent domain // Uses system resolver config func TestErrorDNSFindNameservers(t *testing.T) { t.Parallel() fqdn := "this.domain.doesnt.exist" nameservers, err := DNSFindNameserversE(t, fqdn, nil) require.Error(t, err) require.Nil(t, nameservers) } // Lookup should succeed with answers from just one authoritative nameserver // Uses system resolver config to lookup a public domain and its public nameservers func TestOkTerratestDNSLookupAuthoritative(t *testing.T) { t.Parallel() dnsQuery := DNSQuery{"CNAME", "terratest." + testDomain} expected := DNSAnswers{{"CNAME", "gruntwork-io.github.io."}} res, err := DNSLookupAuthoritativeE(t, dnsQuery, nil) require.NoError(t, err) require.ElementsMatch(t, res, expected) } // *********************************** // Tests that use local dnsTestServers // Lookup should succeed with answers from just one authoritative nameserver func TestOkLocalDNSLookupAuthoritative(t *testing.T) { t.Parallel() s1, s2 := setupTestDNSServers(t) defer shutDownServers(t, s1, s2) for dnsQuery, expected := range testDNSDatabase { s1.AddEntryToDNSDatabase(dnsQuery, expected) res, err := DNSLookupAuthoritativeE(t, dnsQuery, []string{s1.Address(), s2.Address()}) require.NoError(t, err) require.ElementsMatch(t, res, expected) } } // Lookup should fail because of missing answers from all authoritative nameservers func TestErrorLocalDNSLookupAuthoritative(t *testing.T) { t.Parallel() s1, s2 := setupTestDNSServers(t) defer shutDownServers(t, s1, s2) dnsQuery := DNSQuery{"A", "txt." + testDomain} _, err := DNSLookupAuthoritativeE(t, dnsQuery, []string{s1.Address(), s2.Address()}) if _, ok := err.(*NotFoundError); !ok { t.Errorf("unexpected error, got %q", err) } } // Lookup should succeed with consistent answers from all authoritative nameservers func TestOkLocalDNSLookupAuthoritativeAll(t *testing.T) { t.Parallel() s1, s2 := setupTestDNSServers(t) defer shutDownServers(t, s1, s2) for dnsQuery, expected := range testDNSDatabase { s1.AddEntryToDNSDatabase(dnsQuery, expected) s2.AddEntryToDNSDatabase(dnsQuery, expected) res, err := DNSLookupAuthoritativeE(t, dnsQuery, []string{s1.Address(), s2.Address()}) require.NoError(t, err) require.ElementsMatch(t, res, expected) } } // Lookup should fail because of missing answers from all authoritative nameservers func TestError1DNSLookupAuthoritativeAll(t *testing.T) { t.Parallel() s1, s2 := setupTestDNSServers(t) defer shutDownServers(t, s1, s2) dnsQuery := DNSQuery{"A", "txt." + testDomain} _, err := DNSLookupAuthoritativeAllE(t, dnsQuery, []string{s1.Address(), s2.Address()}) if _, ok := err.(*NotFoundError); !ok { t.Errorf("unexpected error, got %q", err) } } // Lookup should fail because of missing answers from one authoritative nameserver func TestError2DNSLookupAuthoritativeAll(t *testing.T) { t.Parallel() s1, s2 := setupTestDNSServers(t) defer shutDownServers(t, s1, s2) dnsQuery := DNSQuery{"A", "a." + testDomain} s1.AddEntryToDNSDatabase(dnsQuery, DNSAnswers{{"A", "1.1.1.1"}}) _, err := DNSLookupAuthoritativeAllE(t, dnsQuery, []string{s1.Address(), s2.Address()}) if _, ok := err.(*NotFoundError); !ok { t.Errorf("unexpected error, got %q", err) } } // Lookup should fail because of inconsistent answers from authoritative nameservers func TestError3DNSLookupAuthoritativeAll(t *testing.T) { t.Parallel() s1, s2 := setupTestDNSServers(t) defer shutDownServers(t, s1, s2) dnsQuery := DNSQuery{"A", "a." + testDomain} s1.AddEntryToDNSDatabase(dnsQuery, DNSAnswers{{"A", "1.1.1.1"}}) s2.AddEntryToDNSDatabase(dnsQuery, DNSAnswers{{"A", "2.2.2.2"}}) _, err := DNSLookupAuthoritativeAllE(t, dnsQuery, []string{s1.Address(), s2.Address()}) if _, ok := err.(*InconsistentAuthoritativeError); !ok { t.Errorf("unexpected error, got %q", err) } } // Lookup should fail because of inexistent domain func TestError4DNSLookupAuthoritativeAll(t *testing.T) { t.Parallel() s1, s2 := setupTestDNSServers(t) defer shutDownServers(t, s1, s2) dnsQuery := DNSQuery{"A", "this.domain.doesnt.exist"} _, err := DNSLookupAuthoritativeAllE(t, dnsQuery, []string{s1.Address(), s2.Address()}) if _, ok := err.(*NSNotFoundError); !ok { t.Errorf("unexpected error, got %q", err) } } // First lookups should fail because of missing answers from all authoritative nameservers // Retry lookups should succeed with answers from just one authoritative nameserver func TestOkDNSLookupAuthoritativeWithRetry(t *testing.T) { t.Parallel() s1, s2 := setupTestDNSServersRetry(t) defer shutDownServers(t, s1, s2) dnsQuery := DNSQuery{"A", "a." + testDomain} expectedRes := DNSAnswers{{"A", "1.1.1.1"}, {"A", "2.2.2.2"}} s1.AddEntryToDNSDatabaseRetry(dnsQuery, expectedRes) res, err := DNSLookupAuthoritativeWithRetryE(t, dnsQuery, []string{s1.Address(), s2.Address()}, 5, time.Second) require.NoError(t, err) require.ElementsMatch(t, res, expectedRes) } // First lookups should fail because of missing answers from all authoritative nameservers // Retry lookups should fail because of missing answers from all authoritative nameservers func TestErrorDNSLookupAuthoritativeWithRetry(t *testing.T) { t.Parallel() s1, s2 := setupTestDNSServersRetry(t) defer shutDownServers(t, s1, s2) dnsQuery := DNSQuery{"A", "txt." + testDomain} _, err := DNSLookupAuthoritativeWithRetryE(t, dnsQuery, []string{s1.Address(), s2.Address()}, 5, time.Second) require.Error(t, err) if _, ok := err.(retry.MaxRetriesExceeded); !ok { t.Errorf("unexpected error, got %q", err) } } // First lookups should fail because of missing answers from one authoritative nameservers // Retry lookups should succeed with consistent answers func TestOkDNSLookupAuthoritativeAllWithRetryNotfound(t *testing.T) { t.Parallel() s1, s2 := setupTestDNSServersRetry(t) defer shutDownServers(t, s1, s2) dnsQuery := DNSQuery{"A", "a." + testDomain} expectedRes := DNSAnswers{{"A", "1.1.1.1"}, {"A", "2.2.2.2"}} s1.AddEntryToDNSDatabase(dnsQuery, expectedRes) s1.AddEntryToDNSDatabaseRetry(dnsQuery, expectedRes) s2.AddEntryToDNSDatabaseRetry(dnsQuery, expectedRes) res, err := DNSLookupAuthoritativeAllWithRetryE(t, dnsQuery, []string{s1.Address(), s2.Address()}, 5, time.Second) require.NoError(t, err) require.ElementsMatch(t, res, expectedRes) } // First lookups should fail because of inconsistent answers from authoritative nameservers // Retry lookups should succeed with consistent answers func TestOkDNSLookupAuthoritativeAllWithRetryInconsistent(t *testing.T) { t.Parallel() s1, s2 := setupTestDNSServersRetry(t) defer shutDownServers(t, s1, s2) dnsQuery := DNSQuery{"A", "a." + testDomain} expectedRes := DNSAnswers{{"A", "1.1.1.1"}, {"A", "2.2.2.2"}} s1.AddEntryToDNSDatabase(dnsQuery, expectedRes) s2.AddEntryToDNSDatabase(dnsQuery, DNSAnswers{{"A", "2.2.2.2"}}) s1.AddEntryToDNSDatabaseRetry(dnsQuery, expectedRes) s2.AddEntryToDNSDatabaseRetry(dnsQuery, expectedRes) res, err := DNSLookupAuthoritativeAllWithRetryE(t, dnsQuery, []string{s1.Address(), s2.Address()}, 5, time.Second) require.NoError(t, err) require.ElementsMatch(t, res, expectedRes) } // First lookups should fail because of missing answer from one authoritative nameserver // Retry lookups should fail because of inconsistent answers from authoritative nameservers func TestErrorDNSLookupAuthoritativeAllWithRetry(t *testing.T) { t.Parallel() s1, s2 := setupTestDNSServersRetry(t) defer shutDownServers(t, s1, s2) dnsQuery := DNSQuery{"A", "a." + testDomain} s1.AddEntryToDNSDatabase(dnsQuery, DNSAnswers{{"A", "2.2.2.2"}}) s1.AddEntryToDNSDatabaseRetry(dnsQuery, DNSAnswers{{"A", "1.1.1.1"}, {"A", "2.2.2.2"}}) s2.AddEntryToDNSDatabaseRetry(dnsQuery, DNSAnswers{{"A", "1.1.1.1"}}) _, err := DNSLookupAuthoritativeAllWithRetryE(t, dnsQuery, []string{s1.Address(), s2.Address()}, 5, time.Second) require.Error(t, err) if _, ok := err.(retry.MaxRetriesExceeded); !ok { t.Errorf("unexpected error, got %q", err) } } // Lookup should succeed with consistent and validated replies func TestOkDNSLookupAuthoritativeAllWithValidation(t *testing.T) { t.Parallel() s1, s2 := setupTestDNSServers(t) defer shutDownServers(t, s1, s2) dnsQuery := DNSQuery{"A", "a." + testDomain} expectedRes := DNSAnswers{{"A", "1.1.1.1"}, {"A", "2.2.2.2"}} s1.AddEntryToDNSDatabase(dnsQuery, expectedRes) s2.AddEntryToDNSDatabase(dnsQuery, expectedRes) err := DNSLookupAuthoritativeAllWithValidationE(t, dnsQuery, []string{s1.Address(), s2.Address()}, expectedRes) require.NoError(t, err) } // Lookup should fail because of missing answers from all authoritative nameservers func TestErrorDNSLookupAuthoritativeAllWithValidation(t *testing.T) { t.Parallel() s1, s2 := setupTestDNSServers(t) defer shutDownServers(t, s1, s2) dnsQuery := DNSQuery{"A", "a." + testDomain} expectedRes := DNSAnswers{{"A", "1.1.1.1"}, {"A", "2.2.2.2"}} err := DNSLookupAuthoritativeAllWithValidationE(t, dnsQuery, []string{s1.Address(), s2.Address()}, expectedRes) require.Error(t, err) if _, ok := err.(*NotFoundError); !ok { t.Errorf("unexpected error, got %q", err) } } // Lookup should fail because of missing answers from one authoritative nameservers func TestError2DNSLookupAuthoritativeAllWithValidation(t *testing.T) { t.Parallel() s1, s2 := setupTestDNSServers(t) defer shutDownServers(t, s1, s2) dnsQuery := DNSQuery{"A", "a." + testDomain} expectedRes := DNSAnswers{{"A", "1.1.1.1"}, {"A", "2.2.2.2"}} s1.AddEntryToDNSDatabase(dnsQuery, expectedRes) err := DNSLookupAuthoritativeAllWithValidationE(t, dnsQuery, []string{s1.Address(), s2.Address()}, expectedRes) require.Error(t, err) if _, ok := err.(*NotFoundError); !ok { t.Errorf("unexpected error, got %q", err) } } // Lookup should fail because of inconsistent authoritative replies func TestError3DNSLookupAuthoritativeAllWithValidation(t *testing.T) { t.Parallel() s1, s2 := setupTestDNSServers(t) defer shutDownServers(t, s1, s2) dnsQuery := DNSQuery{"A", "a." + testDomain} expectedRes := DNSAnswers{{"A", "1.1.1.1"}, {"A", "2.2.2.2"}} s1.AddEntryToDNSDatabase(dnsQuery, expectedRes) s2.AddEntryToDNSDatabase(dnsQuery, DNSAnswers{{"A", "2.2.2.2"}}) err := DNSLookupAuthoritativeAllWithValidationE(t, dnsQuery, []string{s1.Address(), s2.Address()}, expectedRes) require.Error(t, err) if _, ok := err.(*InconsistentAuthoritativeError); !ok { t.Errorf("unexpected error, got %q", err) } } // First lookups should fail because of missing answers from all authoritative nameservers // Retry lookups should succeed with consistent and validated replies func TestOkDNSLookupAuthoritativeAllWithValidationRetry(t *testing.T) { t.Parallel() s1, s2 := setupTestDNSServersRetry(t) defer shutDownServers(t, s1, s2) dnsQuery := DNSQuery{"A", "a." + testDomain} expectedRes := DNSAnswers{{"A", "1.1.1.1"}, {"A", "2.2.2.2"}} s1.AddEntryToDNSDatabaseRetry(dnsQuery, expectedRes) s2.AddEntryToDNSDatabaseRetry(dnsQuery, expectedRes) err := DNSLookupAuthoritativeAllWithValidationRetryE(t, dnsQuery, []string{s1.Address(), s2.Address()}, expectedRes, 5, time.Second) require.NoError(t, err) } // First lookups should fail because of missing answer from one authoritative nameserver // Retry lookups should succeed with consistent and validated replies func TestOk2DNSLookupAuthoritativeAllWithValidationRetry(t *testing.T) { t.Parallel() s1, s2 := setupTestDNSServersRetry(t) defer shutDownServers(t, s1, s2) dnsQuery := DNSQuery{"A", "a." + testDomain} expectedRes := DNSAnswers{{"A", "1.1.1.1"}, {"A", "2.2.2.2"}} s1.AddEntryToDNSDatabase(dnsQuery, DNSAnswers{{"A", "2.2.2.2"}}) s1.AddEntryToDNSDatabaseRetry(dnsQuery, expectedRes) s2.AddEntryToDNSDatabaseRetry(dnsQuery, expectedRes) err := DNSLookupAuthoritativeAllWithValidationRetryE(t, dnsQuery, []string{s1.Address(), s2.Address()}, expectedRes, 5, time.Second) require.NoError(t, err) } // First lookups should fail because of inconsistent authoritative replies // Retry lookups should succeed with consistent and validated replies func TestOk3DNSLookupAuthoritativeAllWithValidationRetry(t *testing.T) { t.Parallel() s1, s2 := setupTestDNSServersRetry(t) defer shutDownServers(t, s1, s2) dnsQuery := DNSQuery{"A", "a." + testDomain} expectedRes := DNSAnswers{{"A", "1.1.1.1"}, {"A", "2.2.2.2"}} s1.AddEntryToDNSDatabase(dnsQuery, expectedRes) s2.AddEntryToDNSDatabase(dnsQuery, DNSAnswers{{"A", "2.2.2.2"}}) s1.AddEntryToDNSDatabaseRetry(dnsQuery, expectedRes) s2.AddEntryToDNSDatabaseRetry(dnsQuery, expectedRes) err := DNSLookupAuthoritativeAllWithValidationRetryE(t, dnsQuery, []string{s1.Address(), s2.Address()}, expectedRes, 5, time.Second) require.NoError(t, err) } // First lookups should fail because of inconsistent authoritative replies // Retry lookups should fail also because of inconsistent authoritative replies func TestErrorDNSLookupAuthoritativeAllWithValidationRetry(t *testing.T) { t.Parallel() s1, s2 := setupTestDNSServersRetry(t) defer shutDownServers(t, s1, s2) dnsQuery := DNSQuery{"A", "a." + testDomain} expectedRes := DNSAnswers{{"A", "1.1.1.1"}, {"A", "2.2.2.2"}} s1.AddEntryToDNSDatabase(dnsQuery, expectedRes) s2.AddEntryToDNSDatabase(dnsQuery, DNSAnswers{{"A", "2.2.2.2"}}) s1.AddEntryToDNSDatabaseRetry(dnsQuery, expectedRes) s2.AddEntryToDNSDatabaseRetry(dnsQuery, DNSAnswers{{"A", "2.2.2.2"}}) err := DNSLookupAuthoritativeAllWithValidationRetryE(t, dnsQuery, []string{s1.Address(), s2.Address()}, expectedRes, 5, time.Second) if _, ok := err.(retry.MaxRetriesExceeded); !ok { t.Errorf("unexpected error, got %q", err) } } func shutDownServers(t *testing.T, s1, s2 *dnsTestServer) { err := s1.Server.Shutdown() assert.NoError(t, err) err = s2.Server.Shutdown() assert.NoError(t, err) } ================================================ FILE: modules/dns-helper/dns_local_server.go ================================================ package dns_helper import ( "fmt" "log" "net" "strings" "testing" "time" "github.com/miekg/dns" ) var testDomain = "gruntwork.io" // dnsDatabase stores a collection of DNSQuery with their respective DNSAnswers, to be used by a local dnsTestServer type dnsDatabase map[DNSQuery]DNSAnswers // dnsTestServer helper for testing this package using local DNS nameservers with test records type dnsTestServer struct { Server *dns.Server DNSDatabase dnsDatabase DNSDatabaseRetry dnsDatabase } // newDNSTestServer returns a new instance of dnsTestServer func newDNSTestServer(server *dns.Server) *dnsTestServer { return &dnsTestServer{Server: server, DNSDatabase: make(dnsDatabase), DNSDatabaseRetry: make(dnsDatabase)} } // Address returns the host:port string of the server listener func (s *dnsTestServer) Address() string { return s.Server.PacketConn.LocalAddr().String() } // AddEntryToDNSDatabase adds DNSAnswers to the DNSQuery in the database of the server func (s *dnsTestServer) AddEntryToDNSDatabase(q DNSQuery, a DNSAnswers) { s.DNSDatabase[q] = append(s.DNSDatabase[q], a...) } // AddEntryToDNSDatabaseRetry adds DNSAnswers to the DNSQuery in the database used when retrying func (s *dnsTestServer) AddEntryToDNSDatabaseRetry(q DNSQuery, a DNSAnswers) { s.DNSDatabaseRetry[q] = append(s.DNSDatabaseRetry[q], a...) } // setupTestDNSServers runs and returns 2x local dnsTestServer, initialized with NS records for the testDomain pointing to themselves // it uses a handler that will send replies stored in their internal DNSDatabase func setupTestDNSServers(t *testing.T) (s1, s2 *dnsTestServer) { s1 = runTestDNSServer(t, "0") s2 = runTestDNSServer(t, "0") q := DNSQuery{"NS", testDomain} a := DNSAnswers{{"NS", s1.Address() + "."}, {"NS", s2.Address() + "."}} s1.AddEntryToDNSDatabase(q, a) s2.AddEntryToDNSDatabase(q, a) s1.Server.Handler.(*dns.ServeMux).HandleFunc(testDomain+".", func(w dns.ResponseWriter, r *dns.Msg) { stdDNSHandler(t, w, r, s1, false) }) s2.Server.Handler.(*dns.ServeMux).HandleFunc(testDomain+".", func(w dns.ResponseWriter, r *dns.Msg) { stdDNSHandler(t, w, r, s2, true) }) return s1, s2 } // setupTestDNSServersRetry runs and returns 2x local dnsTestServer, initialized with NS records for the testDomain pointing to themselves // it uses a handler that will send replies stored in their internal DNSDatabase, and then switch to their DNSDatabaseRetry after some time func setupTestDNSServersRetry(t *testing.T) (s1, s2 *dnsTestServer) { s1 = runTestDNSServer(t, "0") s2 = runTestDNSServer(t, "0") q := DNSQuery{"NS", testDomain} a := DNSAnswers{{"NS", s1.Address() + "."}, {"NS", s2.Address() + "."}} s1.AddEntryToDNSDatabase(q, a) s2.AddEntryToDNSDatabase(q, a) s1.AddEntryToDNSDatabaseRetry(q, a) s2.AddEntryToDNSDatabaseRetry(q, a) s1.Server.Handler.(*dns.ServeMux).HandleFunc(testDomain+".", func(w dns.ResponseWriter, r *dns.Msg) { retryDNSHandler(t, w, r, s1, false) }) s2.Server.Handler.(*dns.ServeMux).HandleFunc(testDomain+".", func(w dns.ResponseWriter, r *dns.Msg) { retryDNSHandler(t, w, r, s2, true) }) return s1, s2 } // runTestDNSServer starts and returns a new dnsTestServer listening in localhost and the given UDP port func runTestDNSServer(t *testing.T, port string) *dnsTestServer { listener, err := net.ListenPacket("udp", "127.0.0.1:"+port) if err != nil { t.Fatal(err) } mux := dns.NewServeMux() server := &dns.Server{PacketConn: listener, Net: "udp", Handler: mux} go func() { if err := server.ActivateAndServe(); err != nil { log.Printf("Error in local DNS server: %s", err) } }() return newDNSTestServer(server) } // doDNSAnswer sends replies to the DNS question from client, using the dnsDatabase to lookup the answers to the query // when invertAnswers is true, reverses the order of the answers from the dnsDatabase, useful to simulate realistic nameservers behaviours func doDNSAnswer(t *testing.T, w dns.ResponseWriter, r *dns.Msg, d dnsDatabase, invertAnswers bool) { m := new(dns.Msg) m.SetReply(r) m.Authoritative = true q := m.Question[0] qtype := dns.TypeToString[q.Qtype] answers := d[DNSQuery{qtype, strings.TrimSuffix(q.Name, ".")}] var seen = make(map[DNSAnswer]bool) for _, r := range answers { if seen[r] { continue } seen[r] = true rr, err := dns.NewRR(fmt.Sprintf("%s %s", q.Name, r.String())) if err != nil { t.Fatalf("err: %s", err) } m.Answer = append(m.Answer, rr) } if invertAnswers { for i, j := 0, len(m.Answer)-1; i < j; i, j = i+1, j-1 { m.Answer[i], m.Answer[j] = m.Answer[j], m.Answer[i] } } w.WriteMsg(m) } // stdDNSHandler uses the internal DNSDatabase to send answers to DNS queries func stdDNSHandler(t *testing.T, w dns.ResponseWriter, r *dns.Msg, s *dnsTestServer, invertAnswers bool) { doDNSAnswer(t, w, r, s.DNSDatabase, invertAnswers) } var startTime = time.Now() // retryDNSHandler uses the internal DNSDatabase to send answers to DNS queries, and switches // to using the internal DNSDatabaseRetry after 3 seconds from startup func retryDNSHandler(t *testing.T, w dns.ResponseWriter, r *dns.Msg, s *dnsTestServer, invertAnswers bool) { if time.Now().Sub(startTime).Seconds() > 3 { doDNSAnswer(t, w, r, s.DNSDatabaseRetry, invertAnswers) } else { doDNSAnswer(t, w, r, s.DNSDatabase, invertAnswers) } } ================================================ FILE: modules/dns-helper/errors.go ================================================ package dns_helper import "fmt" // NoResolversError is an error that occurs if no resolvers have been set for DNSLookupE type NoResolversError struct{} func (err NoResolversError) Error() string { return "No resolvers set for DNSLookupE call" } // QueryTypeError is an error that occurs if the DNS query type is not supported type QueryTypeError struct { Type string } func (err QueryTypeError) Error() string { return fmt.Sprintf("Wrong DNS query type: %s", err.Type) } // NotFoundError is an error that occurs if no answer found type NotFoundError struct { Query DNSQuery Nameserver string } func (err NotFoundError) Error() string { return fmt.Sprintf("No %s record found for %s querying nameserver %s", err.Query.Type, err.Query.Name, err.Nameserver) } // InconsistentAuthoritativeError is an error that occurs if an authoritative answer is different from another type InconsistentAuthoritativeError struct { Query DNSQuery Answers DNSAnswers Nameserver string PreviousAnswers DNSAnswers } func (err InconsistentAuthoritativeError) Error() string { return fmt.Sprintf("Inconsistent authoritative answer from %s to DNS query %s. Got: %s Previous: %s", err.Nameserver, err.Query, err.Answers, err.PreviousAnswers) } // NSNotFoundError is an error that occurs if no NS records found type NSNotFoundError struct { FQDN string Nameserver string } func (err NSNotFoundError) Error() string { return fmt.Sprintf("No NS record found for %s up to apex domain %s", err.FQDN, err.Nameserver) } // MaxRetriesExceeded is an error that occurs when the maximum amount of retries is exceeded. type MaxRetriesExceeded struct { Description string MaxRetries int } func (err MaxRetriesExceeded) Error() string { return fmt.Sprintf("'%s' unsuccessful after %d retries", err.Description, err.MaxRetries) } // ValidationError is an error that occurs when answers validation fails type ValidationError struct { Query DNSQuery Answers DNSAnswers ExpectedAnswers DNSAnswers } func (err ValidationError) Error() string { return fmt.Sprintf("Unexpected answer to DNS query %s. Got: %s Expected: %s", err.Query, err.Answers, err.ExpectedAnswers) } ================================================ FILE: modules/docker/build.go ================================================ package docker import ( "os" "path/filepath" "strings" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/shell" "github.com/gruntwork-io/terratest/modules/testing" "github.com/hashicorp/go-multierror" "github.com/stretchr/testify/require" ) // BuildOptions defines options that can be passed to the 'docker build' command. type BuildOptions struct { // Tags for the Docker image Tags []string // Build args to pass the 'docker build' command BuildArgs []string // Target build arg to pass to the 'docker build' command Target string // All architectures to target in a multiarch build. Configuring this variable will cause terratest to use docker // buildx to construct multiarch images. // You can read more about multiarch docker builds in the official documentation for buildx: // https://docs.docker.com/buildx/working-with-buildx/ // NOTE: This list does not automatically include the current platform. For example, if you are building images on // an Apple Silicon based MacBook, and you configure this variable to []string{"linux/amd64"} to build an amd64 // image, the buildx command will not automatically include linux/arm64 - you must include that explicitly. Architectures []string // Whether or not to push images directly to the registry on build. Note that for multiarch images (Architectures is // not empty), this must be true to ensure availability of all architectures - only the image for the current // platform will be loaded into the daemon (due to a limitation of the docker daemon), so you won't be able to run a // `docker push` command later to push the multiarch image. // See https://github.com/moby/moby/pull/38738 for more info on the limitation of multiarch images in docker daemon. Push bool // Whether or not to load the image into the docker daemon at the end of a multiarch build so that it can be used // locally. Note that this is only used when Architectures is set, and assumes the current architecture is already // included in the Architectures list. Load bool // Custom CLI options that will be passed as-is to the 'docker build' command. This is an "escape hatch" that allows // Terratest to not have to support every single command-line option offered by the 'docker build' command, and // solely focus on the most important ones. OtherOptions []string // Whether ot not to enable buildkit. You can find more information about buildkit here https://docs.docker.com/build/buildkit/#getting-started. EnableBuildKit bool // Additional environment variables to pass in when running docker build command. Env map[string]string // Set a logger that should be used. See the logger package for more info. Logger *logger.Logger } // Build runs the 'docker build' command at the given path with the given options and fails the test if there are any // errors. func Build(t testing.TestingT, path string, options *BuildOptions) { require.NoError(t, BuildE(t, path, options)) } // BuildE runs the 'docker build' command at the given path with the given options and returns any errors. func BuildE(t testing.TestingT, path string, options *BuildOptions) error { options.Logger.Logf(t, "Running 'docker build' in %s", path) env := make(map[string]string) if options.Env != nil { env = options.Env } if options.EnableBuildKit { env["DOCKER_BUILDKIT"] = "1" } cmd := shell.Command{ Command: "docker", Args: formatDockerBuildArgs(path, options), Logger: options.Logger, Env: env, } if err := shell.RunCommandE(t, cmd); err != nil { return err } // For non multiarch images, we need to call docker push for each tag since build does not have a push option like // buildx. if len(options.Architectures) == 0 && options.Push { var errorsOccurred = new(multierror.Error) for _, tag := range options.Tags { if err := PushE(t, options.Logger, tag); err != nil { options.Logger.Logf(t, "ERROR: error pushing tag %s", tag) errorsOccurred = multierror.Append(errorsOccurred, err) } } return errorsOccurred.ErrorOrNil() } // For multiarch images, if a load is requested call the load command to export the built image into the daemon. if len(options.Architectures) > 0 && options.Load { loadCmd := shell.Command{ Command: "docker", Args: formatDockerBuildxLoadArgs(path, options), Logger: options.Logger, } return shell.RunCommandE(t, loadCmd) } return nil } // GitCloneAndBuild builds a new Docker image from a given Git repo. This function will clone the given repo at the // specified ref, and call the docker build command on the cloned repo from the given relative path (relative to repo // root). This will fail the test if there are any errors. func GitCloneAndBuild( t testing.TestingT, repo string, ref string, path string, dockerBuildOpts *BuildOptions, ) { require.NoError(t, GitCloneAndBuildE(t, repo, ref, path, dockerBuildOpts)) } // GitCloneAndBuildE builds a new Docker image from a given Git repo. This function will clone the given repo at the // specified ref, and call the docker build command on the cloned repo from the given relative path (relative to repo // root). func GitCloneAndBuildE( t testing.TestingT, repo string, ref string, path string, dockerBuildOpts *BuildOptions, ) error { workingDir, err := os.MkdirTemp("", "") if err != nil { return err } defer os.RemoveAll(workingDir) cloneCmd := shell.Command{ Command: "git", Args: []string{"clone", repo, workingDir}, } if err := shell.RunCommandE(t, cloneCmd); err != nil { return err } checkoutCmd := shell.Command{ Command: "git", Args: []string{"checkout", ref}, WorkingDir: workingDir, } if err := shell.RunCommandE(t, checkoutCmd); err != nil { return err } contextPath := filepath.Join(workingDir, path) if err := BuildE(t, contextPath, dockerBuildOpts); err != nil { return err } return nil } // formatDockerBuildArgs formats the arguments for the 'docker build' command. func formatDockerBuildArgs(path string, options *BuildOptions) []string { args := []string{} if len(options.Architectures) > 0 { args = append( args, "buildx", "build", "--platform", strings.Join(options.Architectures, ","), ) if options.Push { args = append(args, "--push") } } else { args = append(args, "build") } return append(args, formatDockerBuildBaseArgs(path, options)...) } // formatDockerBuildxLoadArgs formats the arguments for calling load on the 'docker buildx' command. func formatDockerBuildxLoadArgs(path string, options *BuildOptions) []string { args := []string{ "buildx", "build", "--load", } return append(args, formatDockerBuildBaseArgs(path, options)...) } // formatDockerBuildBaseArgs formats the common args for the build command, both for `build` and `buildx`. func formatDockerBuildBaseArgs(path string, options *BuildOptions) []string { args := []string{} for _, tag := range options.Tags { args = append(args, "--tag", tag) } for _, arg := range options.BuildArgs { args = append(args, "--build-arg", arg) } if len(options.Target) > 0 { args = append(args, "--target", options.Target) } args = append(args, options.OtherOptions...) args = append(args, path) return args } ================================================ FILE: modules/docker/build_test.go ================================================ package docker import ( "fmt" "strings" "testing" "github.com/gruntwork-io/terratest/modules/git" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/random" "github.com/stretchr/testify/require" ) func TestBuild(t *testing.T) { t.Parallel() tag := "gruntwork-io/test-image:v1" text := "Hello, World!" options := &BuildOptions{ Tags: []string{tag}, BuildArgs: []string{fmt.Sprintf("text=%s", text)}, } Build(t, "../../test/fixtures/docker", options) out := Run(t, tag, &RunOptions{Remove: true}) require.Contains(t, out, text) } func TestBuildWithBuildKit(t *testing.T) { t.Parallel() tag := "gruntwork-io/test-image-with-buildkit:v1" testToken := "testToken" options := &BuildOptions{ Tags: []string{tag}, EnableBuildKit: true, OtherOptions: []string{"--secret", fmt.Sprintf("id=github-token,env=%s", "GITHUB_OAUTH_TOKEN")}, Env: map[string]string{"GITHUB_OAUTH_TOKEN": testToken}, } Build(t, "../../test/fixtures/docker-with-buildkit", options) out := Run(t, tag, &RunOptions{Remove: false}) require.Contains(t, out, testToken) } func TestBuildMultiArch(t *testing.T) { t.Parallel() tag := "gruntwork-io/test-image:v1" text := "Hello, World!" options := &BuildOptions{ Tags: []string{tag}, BuildArgs: []string{fmt.Sprintf("text=%s", text)}, Architectures: []string{"linux/arm64", "linux/amd64"}, Load: true, } Build(t, "../../test/fixtures/docker", options) out := Run(t, tag, &RunOptions{Remove: true}) require.Contains(t, out, text) } func TestBuildWithTarget(t *testing.T) { t.Parallel() tag := "gruntwork-io/test-image:target1" text := "Hello, World!" text1 := "Hello, World! This is build target 1!" options := &BuildOptions{ Tags: []string{tag}, BuildArgs: []string{fmt.Sprintf("text=%s", text), fmt.Sprintf("text1=%s", text1)}, Target: "step1", } Build(t, "../../test/fixtures/docker", options) out := Run(t, tag, &RunOptions{Remove: true}) require.Contains(t, out, text1) } func TestGitCloneAndBuild(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) imageTag := "gruntwork-io-foo-test:" + uniqueID text := "Hello, World!" buildOpts := &BuildOptions{ Tags: []string{imageTag}, BuildArgs: []string{fmt.Sprintf("text=%s", text)}, } gitBranchName := git.GetCurrentBranchName(t) if gitBranchName == "" { logger.Logf(t, "WARNING: git.GetCurrentBranchName returned an empty string; falling back to main") gitBranchName = "main" } GitCloneAndBuild(t, "git@github.com:gruntwork-io/terratest.git", gitBranchName, "test/fixtures/docker", buildOpts) out := Run(t, imageTag, &RunOptions{Remove: true}) require.Contains(t, out, text) } ================================================ FILE: modules/docker/docker.go ================================================ // Package docker allows to interact with Docker and docker compose resources. package docker ================================================ FILE: modules/docker/docker_compose.go ================================================ package docker import ( "regexp" "strings" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/shell" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" "gotest.tools/v3/icmd" ) // Options are Docker options. type Options struct { WorkingDir string EnvVars map[string]string // Whether ot not to enable buildkit. You can find more information about buildkit here https://docs.docker.com/build/buildkit/#getting-started. EnableBuildKit bool // Set a logger that should be used. See the logger package for more info. Logger *logger.Logger ProjectName string } // RunDockerCompose runs docker compose with the given arguments and options and return stdout/stderr. func RunDockerCompose(t testing.TestingT, options *Options, args ...string) string { out, err := runDockerComposeE(t, false, options, args...) if err != nil { t.Fatal(err) } return out } // RunDockerComposeAndGetStdout runs docker compose with the given arguments and options and returns only stdout. func RunDockerComposeAndGetStdOut(t testing.TestingT, options *Options, args ...string) string { out, err := runDockerComposeE(t, true, options, args...) require.NoError(t, err) return out } // RunDockerComposeE runs docker compose with the given arguments and options and return stdout/stderr. func RunDockerComposeE(t testing.TestingT, options *Options, args ...string) (string, error) { return runDockerComposeE(t, false, options, args...) } func runDockerComposeE(t testing.TestingT, stdout bool, options *Options, args ...string) (string, error) { var cmd shell.Command projectName := options.ProjectName if len(projectName) <= 0 { projectName = strings.ToLower(t.Name()) } dockerComposeVersionCmd := icmd.Command("docker", "compose", "version") result := icmd.RunCmd(dockerComposeVersionCmd) if options.EnableBuildKit { if options.EnvVars == nil { options.EnvVars = make(map[string]string) } options.EnvVars["DOCKER_BUILDKIT"] = "1" options.EnvVars["COMPOSE_DOCKER_CLI_BUILD"] = "1" } if result.ExitCode == 0 { cmd = shell.Command{ Command: "docker", Args: append([]string{"compose", "--project-name", generateValidDockerComposeProjectName(projectName)}, args...), WorkingDir: options.WorkingDir, Env: options.EnvVars, Logger: options.Logger, } } else { cmd = shell.Command{ Command: "docker-compose", // We append --project-name to ensure containers from multiple different tests using Docker Compose don't end // up in the same project and end up conflicting with each other. Args: append([]string{"--project-name", generateValidDockerComposeProjectName(projectName)}, args...), WorkingDir: options.WorkingDir, Env: options.EnvVars, Logger: options.Logger, } } if stdout { return shell.RunCommandAndGetStdOut(t, cmd), nil } return shell.RunCommandAndGetOutputE(t, cmd) } // Note: docker-compose command doesn't like lower case or special characters, other than -. func generateValidDockerComposeProjectName(str string) string { lower_str := strings.ToLower(str) return regexp.MustCompile(`[^a-zA-Z0-9 ]+`).ReplaceAllString(lower_str, "-") } ================================================ FILE: modules/docker/docker_compose_test.go ================================================ package docker import ( "testing" "github.com/stretchr/testify/require" ) func TestDockerComposeWithBuildKit(t *testing.T) { t.Parallel() testToken := "testToken" dockerOptions := &Options{ // Directory where docker-compose.yml lives WorkingDir: "../../test/fixtures/docker-compose-with-buildkit", // Configure the port the web app will listen on and the text it will return using environment variables EnvVars: map[string]string{ "GITHUB_OAUTH_TOKEN": testToken, }, EnableBuildKit: true, } out := RunDockerCompose(t, dockerOptions, "build", "--no-cache") out = RunDockerCompose(t, dockerOptions, "up") require.Contains(t, out, testToken) } func TestDockerComposeWithCustomProjectName(t *testing.T) { t.Parallel() tests := []struct { name string options *Options expected string }{ { name: "Testing ", options: &Options{ WorkingDir: "../../test/fixtures/docker-compose-with-custom-project-name", }, expected: "testdockercomposewithcustomprojectname", }, { name: "Testing", options: &Options{ WorkingDir: "../../test/fixtures/docker-compose-with-custom-project-name", ProjectName: "testingProjectName", }, expected: "testingprojectname", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Log(test.name) output := RunDockerCompose(t, test.options, "up", "-d") defer RunDockerCompose(t, test.options, "down", "--remove-orphans", "--timeout", "2") require.Contains(t, output, test.expected) }) } } ================================================ FILE: modules/docker/host.go ================================================ package docker import ( "os" "strings" ) // GetDockerHost returns the name or address of the host on which the Docker engine is running. func GetDockerHost() string { return getDockerHostFromEnv(os.Environ()) } func getDockerHostFromEnv(env []string) string { // Parses the DOCKER_HOST environment variable to find the address // // For valid formats see: // https://github.com/docker/cli/blob/6916b427a0b07e8581d121967633235ced6db9a1/opts/hosts.go#L69 var dockerUrl []string for _, item := range env { envVar := strings.Split(item, "=") if len(envVar) == 2 && envVar[0] == "DOCKER_HOST" { dockerUrl = strings.Split(envVar[1], ":") break } } if len(dockerUrl) < 2 { // DOCKER_HOST was empty, not present or not a valid URL return "localhost" } switch dockerUrl[0] { case "tcp", "ssh", "fd": return strings.TrimPrefix(dockerUrl[1], "//") default: // if DOCKER_HOST is not in one of the formats listed above, return default return "localhost" } } ================================================ FILE: modules/docker/host_test.go ================================================ package docker import ( "fmt" "testing" "github.com/stretchr/testify/assert" ) func TestGetDockerHostFromEnv(t *testing.T) { t.Parallel() tests := []struct { Input string Expected string }{ { "unix:///var/run/docker.sock", "localhost", }, { "npipe:////./pipe/docker_engine", "localhost", }, { "tcp://1.2.3.4:1234", "1.2.3.4", }, { "tcp://1.2.3.4", "1.2.3.4", }, { "ssh://1.2.3.4:22", "1.2.3.4", }, { "fd://1.2.3.4:1234", "1.2.3.4", }, { "", "localhost", }, { "invalidValue", "localhost", }, { "invalid::value::with::semicolons", "localhost", }, } for _, test := range tests { t.Run(fmt.Sprintf("GetDockerHostFromEnv: %s", test.Input), func(t *testing.T) { t.Parallel() testEnv := []string{ "FOO=bar", fmt.Sprintf("DOCKER_HOST=%s", test.Input), "BAR=baz", } host := getDockerHostFromEnv(testEnv) assert.Equal(t, test.Expected, host) }) } t.Run("GetDockerHostFromEnv: DOCKER_HOST unset", func(t *testing.T) { t.Parallel() testEnv := []string{ "FOO=bar", "BAR=baz", } host := getDockerHostFromEnv(testEnv) assert.Equal(t, "localhost", host) }) } ================================================ FILE: modules/docker/images.go ================================================ package docker import ( "bufio" "encoding/json" "fmt" "slices" "strings" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/shell" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // Image represents a docker image, and exports all the fields that the docker images command returns for the // image. type Image struct { // ID is the image ID in docker, and can be used to identify the image in place of the repo and tag. ID string // Repository is the image repository. Repository string // Tag is the image tag wichin the repository. Tag string // CreatedAt represents a timestamp for when the image was created. CreatedAt string // CreatedSince is a diff between when the image was created to now. CreatedSince string // SharedSize is the amount of space that an image shares with another one (i.e. their common data). SharedSize string // UniqueSize is the amount of space that is only used by a given image. UniqueSize string // VirtualSize is the total size of the image, combining SharedSize and UniqueSize. VirtualSize string // Containers represents the list of containers that are using the image. Containers string // Digest is the hash digest of the image, if requested. Digest string } func (image Image) String() string { return fmt.Sprintf("%s:%s", image.Repository, image.Tag) } // DeleteImage removes a docker image using the Docker CLI. This will fail the test if there is an error. func DeleteImage(t testing.TestingT, img string, logger *logger.Logger) { require.NoError(t, DeleteImageE(t, img, logger)) } // DeleteImageE removes a docker image using the Docker CLI. func DeleteImageE(t testing.TestingT, img string, logger *logger.Logger) error { cmd := shell.Command{ Command: "docker", Args: []string{"rmi", img}, Logger: logger, } return shell.RunCommandE(t, cmd) } // ListImages calls docker images using the Docker CLI to list the available images on the local docker daemon. func ListImages(t testing.TestingT, logger *logger.Logger) []Image { out, err := ListImagesE(t, logger) require.NoError(t, err) return out } // ListImagesE calls docker images using the Docker CLI to list the available images on the local docker daemon. func ListImagesE(t testing.TestingT, logger *logger.Logger) ([]Image, error) { cmd := shell.Command{ Command: "docker", Args: []string{"images", "--format", "{{ json . }}"}, Logger: logger, } out, err := shell.RunCommandAndGetOutputE(t, cmd) if err != nil { return nil, err } // Parse and return the list of image objects. images := []Image{} scanner := bufio.NewScanner(strings.NewReader(out)) for scanner.Scan() { line := scanner.Text() var image Image err := json.Unmarshal([]byte(line), &image) if err != nil { return nil, err } images = append(images, image) } return images, nil } // DoesImageExist lists the images in the docker daemon and returns true if the given image label (repo:tag) exists. // This will fail the test if there is an error. func DoesImageExist(t testing.TestingT, imgLabel string, logger *logger.Logger) bool { images := ListImages(t, logger) imageTags := []string{} for _, image := range images { imageTags = append(imageTags, image.String()) } return slices.Contains(imageTags, imgLabel) } ================================================ FILE: modules/docker/images_test.go ================================================ package docker import ( "fmt" "strings" "testing" "github.com/gruntwork-io/terratest/modules/random" "github.com/stretchr/testify/assert" ) func TestListImagesAndDeleteImage(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) repo := "gruntwork-io/test-image" tag := fmt.Sprintf("v1-%s", uniqueID) img := fmt.Sprintf("%s:%s", repo, tag) options := &BuildOptions{ Tags: []string{img}, } Build(t, "../../test/fixtures/docker", options) assert.True(t, DoesImageExist(t, img, nil)) DeleteImage(t, img, nil) assert.False(t, DoesImageExist(t, img, nil)) } ================================================ FILE: modules/docker/inspect.go ================================================ package docker import ( "encoding/json" "fmt" "strconv" "strings" "testing" "time" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/shell" "github.com/stretchr/testify/require" ) // ContainerInspect defines the output of the Inspect method, with the options returned by 'docker inspect' // converted into a more friendly and testable interface type ContainerInspect struct { // ID of the inspected container ID string // Name of the inspected container Name string // time.Time that the container was created Created time.Time // String representing the container's status Status string // Whether the container is currently running or not Running bool // Container's exit code ExitCode uint8 // String with the container's error message, if there is any Error string // Ports exposed by the container Ports []Port // Volume bindings made to the container Binds []VolumeBind // Health check Health HealthCheck } // Port represents a single port mapping exported by the container type Port struct { HostPort uint16 ContainerPort uint16 Protocol string } // VolumeBind represents a single volume binding made to the container type VolumeBind struct { Source string Destination string } // HealthCheck represents the current health history of the container type HealthCheck struct { // Health check status Status string // Current count of failing health checks FailingStreak uint8 // Log of failures Log []HealthLog } // HealthLog represents the output of a single Health check of the container type HealthLog struct { // Start time of health check Start string // End time of health check End string // Exit code of health check ExitCode uint8 // Output of health check Output string } // inspectOutput defines options that will be returned by 'docker inspect', in JSON format. // Not all options are included here, only the ones that we might need type inspectOutput struct { Id string Created string Name string State struct { Health HealthCheck Status string Running bool ExitCode uint8 Error string } NetworkSettings struct { Ports map[string][]struct { HostIp string HostPort string } } HostConfig struct { Binds []string } } // Inspect runs the 'docker inspect {container id}' command and returns a ContainerInspect // struct, converted from the output JSON, along with any errors func Inspect(t *testing.T, id string) *ContainerInspect { out, err := InspectE(t, id) require.NoError(t, err) return out } // InspectE runs the 'docker inspect {container id}' command and returns a ContainerInspect // struct, converted from the output JSON, along with any errors func InspectE(t *testing.T, id string) (*ContainerInspect, error) { cmd := shell.Command{ Command: "docker", Args: []string{"container", "inspect", id}, // inspect is a short-running command, don't print the output. Logger: logger.Discard, } out, err := shell.RunCommandAndGetStdOutE(t, cmd) if err != nil { return nil, err } var containers []inspectOutput err = json.Unmarshal([]byte(out), &containers) if err != nil { return nil, err } if len(containers) == 0 { return nil, fmt.Errorf("no container found with ID %s", id) } container := containers[0] return transformContainer(t, container) } // transformContainerPorts converts 'docker inspect' output JSON into a more friendly and testable format func transformContainer(t *testing.T, container inspectOutput) (*ContainerInspect, error) { name := strings.TrimLeft(container.Name, "/") ports, err := transformContainerPorts(container) if err != nil { return nil, err } volumes := transformContainerVolumes(container) created, err := time.Parse(time.RFC3339Nano, container.Created) if err != nil { return nil, err } inspect := ContainerInspect{ ID: container.Id, Name: name, Created: created, Status: container.State.Status, Running: container.State.Running, ExitCode: container.State.ExitCode, Error: container.State.Error, Ports: ports, Binds: volumes, Health: HealthCheck{ Status: container.State.Health.Status, FailingStreak: container.State.Health.FailingStreak, Log: container.State.Health.Log, }, } return &inspect, nil } // transformContainerPorts converts Docker's ports from the following json into a more testable format // // { // "80/tcp": [ // { // "HostIp": "" // "HostPort": "8080" // } // ] // } func transformContainerPorts(container inspectOutput) ([]Port, error) { var ports []Port cPorts := container.NetworkSettings.Ports for key, portBinding := range cPorts { split := strings.Split(key, "/") containerPort, err := strconv.ParseUint(split[0], 10, 16) if err != nil { return nil, err } var protocol string if len(split) > 1 { protocol = split[1] } for _, port := range portBinding { hostPort, err := strconv.ParseUint(port.HostPort, 10, 16) if err != nil { return nil, err } ports = append(ports, Port{ HostPort: uint16(hostPort), ContainerPort: uint16(containerPort), Protocol: protocol, }) } } return ports, nil } // GetExposedHostPort returns an exposed host port according to requested container port. Returns 0 if the requested port is not exposed. func (inspectOutput ContainerInspect) GetExposedHostPort(containerPort uint16) uint16 { for _, port := range inspectOutput.Ports { if port.ContainerPort == containerPort { return port.HostPort } } return uint16(0) } // transformContainerVolumes converts Docker's volume bindings from the // format "/foo/bar:/foo/baz" into a more testable one func transformContainerVolumes(container inspectOutput) []VolumeBind { binds := container.HostConfig.Binds volumes := make([]VolumeBind, 0, len(binds)) for _, bind := range binds { var source, dest string split := strings.Split(bind, ":") // Considering it as an unbound volume dest = split[0] if len(split) == 2 { source = split[0] dest = split[1] } volumes = append(volumes, VolumeBind{ Source: source, Destination: dest, }) } return volumes } ================================================ FILE: modules/docker/inspect_test.go ================================================ package docker import ( "fmt" "testing" "time" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/shell" "github.com/stretchr/testify/require" ) const dockerInspectTestImage = "nginx:1.17-alpine" func TestInspect(t *testing.T) { t.Parallel() // append timestamp to container name to allow running tests in parallel name := "inspect-test-" + random.UniqueId() // running the container detached to allow inspection while it is running options := &RunOptions{ Detach: true, Name: name, } id := RunAndGetID(t, dockerInspectTestImage, options) defer removeContainer(t, id) c := Inspect(t, id) require.Equal(t, id, c.ID) require.Equal(t, name, c.Name) require.IsType(t, time.Time{}, c.Created) require.Equal(t, true, c.Running) } func TestInspectWithExposedPort(t *testing.T) { t.Parallel() // choosing an unique high port to avoid conflict on test machines port := 13031 options := &RunOptions{ Detach: true, OtherOptions: []string{fmt.Sprintf("-p=%d:80", port)}, } id := RunAndGetID(t, dockerInspectTestImage, options) defer removeContainer(t, id) c := Inspect(t, id) require.NotEmptyf(t, c.Ports, "Container's exposed ports should not be empty") require.EqualValues(t, 80, c.Ports[0].ContainerPort) require.EqualValues(t, port, c.Ports[0].HostPort) } func TestInspectWithRandomExposedPort(t *testing.T) { t.Parallel() var expectedPort uint16 = 80 var unexpectedPort uint16 = 1234 options := &RunOptions{ Detach: true, OtherOptions: []string{"-P"}, } id := RunAndGetID(t, dockerInspectTestImage, options) defer removeContainer(t, id) c := Inspect(t, id) require.NotEmptyf(t, c.Ports, "Container's exposed ports should not be empty") require.NotEqualf(t, uint16(0), c.GetExposedHostPort(expectedPort), fmt.Sprintf("There are no exposed port %d!", expectedPort)) require.Equalf(t, uint16(0), c.GetExposedHostPort(unexpectedPort), fmt.Sprintf("There is an unexpected exposed port %d!", unexpectedPort)) } func TestInspectWithHostVolume(t *testing.T) { t.Parallel() c := runWithVolume(t, "/tmp:/foo/bar") require.NotEmptyf(t, c.Binds, "Container's host volumes should not be empty") require.Equal(t, "/tmp", c.Binds[0].Source) require.Equal(t, "/foo/bar", c.Binds[0].Destination) } func TestInspectWithAnonymousVolume(t *testing.T) { t.Parallel() c := runWithVolume(t, "/foo/bar") require.Empty(t, c.Binds, "Container's host volumes be empty when using an anonymous volume") } func TestInspectWithNamedVolume(t *testing.T) { t.Parallel() c := runWithVolume(t, "foobar:/foo/bar") require.NotEmptyf(t, c.Binds, "Container's host volumes should not be empty") require.Equal(t, "foobar", c.Binds[0].Source) require.Equal(t, "/foo/bar", c.Binds[0].Destination) } func TestInspectWithInvalidContainerID(t *testing.T) { t.Parallel() _, err := InspectE(t, "This is not a valid container ID") require.Error(t, err) } func TestInspectWithUnknownContainerID(t *testing.T) { t.Parallel() _, err := InspectE(t, "abcde123456") require.Error(t, err) } func TestInspectReturnsCorrectHealthCheckWhenStarting(t *testing.T) { t.Parallel() c := runWithHealthCheck(t, "service nginx status", time.Second, 0) require.Equal(t, "starting", c.Health.Status) require.Equal(t, uint8(0), c.Health.FailingStreak) require.Emptyf(t, c.Health.Log, "Mising log of health check runs") } func TestInspectReturnsCorrectHealthCheckWhenUnhealthy(t *testing.T) { t.Parallel() c := runWithHealthCheck(t, "service nginx status", time.Second, 5*time.Second) require.Equal(t, "unhealthy", c.Health.Status) require.NotEqual(t, uint8(0), c.Health.FailingStreak) require.NotEmptyf(t, c.Health.Log, "Mising log of health check runs") require.Equal(t, uint8(0x7f), c.Health.Log[0].ExitCode) require.Equal(t, "/bin/sh: service nginx status: not found\n", c.Health.Log[0].Output) } func runWithHealthCheck(t *testing.T, check string, frequency time.Duration, delay time.Duration) *ContainerInspect { // append timestamp to container name to allow running tests in parallel name := "inspect-test-" + random.UniqueId() // running the container detached to allow inspection while it is running options := &RunOptions{ Detach: true, Name: name, OtherOptions: []string{ fmt.Sprintf("--health-cmd='%s'", check), fmt.Sprintf("--health-interval=%s", frequency), }, } id := RunAndGetID(t, dockerInspectTestImage, options) defer removeContainer(t, id) time.Sleep(delay) return Inspect(t, id) } func runWithVolume(t *testing.T, volume string) *ContainerInspect { options := &RunOptions{ Detach: true, Volumes: []string{volume}, } id := RunAndGetID(t, dockerInspectTestImage, options) defer removeContainer(t, id) return Inspect(t, id) } func removeContainer(t *testing.T, id string) { cmd := shell.Command{ Command: "docker", Args: []string{"container", "rm", "--force", id}, } shell.RunCommand(t, cmd) } ================================================ FILE: modules/docker/push.go ================================================ package docker import ( "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/shell" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // Push runs the 'docker push' command to push the given tag. This will fail the test if there are any errors. func Push(t testing.TestingT, logger *logger.Logger, tag string) { require.NoError(t, PushE(t, logger, tag)) } // PushE runs the 'docker push' command to push the given tag. func PushE(t testing.TestingT, logger *logger.Logger, tag string) error { logger.Logf(t, "Running 'docker push' for tag %s", tag) cmd := shell.Command{ Command: "docker", Args: []string{"push", tag}, Logger: logger, } return shell.RunCommandE(t, cmd) } ================================================ FILE: modules/docker/run.go ================================================ package docker import ( "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/shell" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // RunOptions defines options that can be passed to the 'docker run' command. type RunOptions struct { // Override the default COMMAND of the Docker image Command []string // If set to true, pass the --detach flag to 'docker run' to run the container in the background Detach bool // Override the default ENTRYPOINT of the Docker image Entrypoint string // Set environment variables EnvironmentVariables []string // If set to true, pass the --init flag to 'docker run' to run an init inside the container that forwards signals // and reaps processes Init bool // Assign a name to the container Name string // Set platform Platform string // If set to true, pass the --privileged flag to 'docker run' to give extended privileges to the container Privileged bool // If set to true, pass the --rm flag to 'docker run' to automatically remove the container when it exits Remove bool // If set to true, pass the -tty flag to 'docker run' to allocate a pseudo-TTY Tty bool // Username or UID User string // Bind mount these volume(s) when running the container Volumes []string // Custom CLI options that will be passed as-is to the 'docker run' command. This is an "escape hatch" that allows // Terratest to not have to support every single command-line option offered by the 'docker run' command, and // solely focus on the most important ones. OtherOptions []string // Set a logger that should be used. See the logger package for more info. Logger *logger.Logger } // Run runs the 'docker run' command on the given image with the given options and return stdout/stderr. This method // fails the test if there are any errors. func Run(t testing.TestingT, image string, options *RunOptions) string { out, err := RunE(t, image, options) require.NoError(t, err) return out } // RunE runs the 'docker run' command on the given image with the given options and return stdout/stderr, or any error. func RunE(t testing.TestingT, image string, options *RunOptions) (string, error) { options.Logger.Logf(t, "Running 'docker run' on image '%s'", image) args, err := formatDockerRunArgs(image, options) if err != nil { return "", err } cmd := shell.Command{ Command: "docker", Args: args, Logger: options.Logger, } return shell.RunCommandAndGetOutputE(t, cmd) } // RunAndGetID runs the 'docker run' command on the given image with the given options and returns the container ID // that is returned in stdout. This method fails the test if there are any errors. func RunAndGetID(t testing.TestingT, image string, options *RunOptions) string { out, err := RunAndGetIDE(t, image, options) require.NoError(t, err) return out } // RunAndGetIDE runs the 'docker run' command on the given image with the given options and returns the container ID // that is returned in stdout, or any error. func RunAndGetIDE(t testing.TestingT, image string, options *RunOptions) (string, error) { options.Logger.Logf(t, "Running 'docker run' on image '%s', returning stdout", image) args, err := formatDockerRunArgs(image, options) if err != nil { return "", err } cmd := shell.Command{ Command: "docker", Args: args, Logger: options.Logger, } return shell.RunCommandAndGetStdOutE(t, cmd) } // formatDockerRunArgs formats the arguments for the 'docker run' command. func formatDockerRunArgs(image string, options *RunOptions) ([]string, error) { args := []string{"run"} if options.Detach { args = append(args, "--detach") } if options.Entrypoint != "" { args = append(args, "--entrypoint", options.Entrypoint) } for _, envVar := range options.EnvironmentVariables { args = append(args, "--env", envVar) } if options.Init { args = append(args, "--init") } if options.Name != "" { args = append(args, "--name", options.Name) } if options.Platform != "" { args = append(args, "--platform", options.Platform) } if options.Privileged { args = append(args, "--privileged") } if options.Remove { args = append(args, "--rm") } if options.Tty { args = append(args, "--tty") } if options.User != "" { args = append(args, "--user", options.User) } for _, volume := range options.Volumes { args = append(args, "--volume", volume) } args = append(args, options.OtherOptions...) args = append(args, image) args = append(args, options.Command...) return args, nil } ================================================ FILE: modules/docker/run_test.go ================================================ package docker import ( "testing" "github.com/stretchr/testify/require" ) func TestRun(t *testing.T) { t.Parallel() options := &RunOptions{ Command: []string{"-c", `echo "Hello, $NAME!"`}, Entrypoint: "sh", EnvironmentVariables: []string{"NAME=World"}, Remove: true, } out := Run(t, "alpine:3.7", options) require.Contains(t, out, "Hello, World!") } ================================================ FILE: modules/docker/stop.go ================================================ package docker import ( "strconv" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/shell" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // StopOptions defines the options that can be passed to the 'docker stop' command type StopOptions struct { // Seconds to wait for stop before killing the container (default 10) Time int // Set a logger that should be used. See the logger package for more info. Logger *logger.Logger } // Stop runs the 'docker stop' command for the given containers and return the stdout/stderr. This method fails // the test if there are any errors func Stop(t testing.TestingT, containers []string, options *StopOptions) string { out, err := StopE(t, containers, options) require.NoError(t, err) return out } // StopE runs the 'docker stop' command for the given containers and returns any errors. func StopE(t testing.TestingT, containers []string, options *StopOptions) (string, error) { options.Logger.Logf(t, "Running 'docker stop' on containers '%s'", containers) args, err := formatDockerStopArgs(containers, options) if err != nil { return "", err } cmd := shell.Command{ Command: "docker", Args: args, Logger: options.Logger, } return shell.RunCommandAndGetOutputE(t, cmd) } // formatDockerStopArgs formats the arguments for the 'docker stop' command func formatDockerStopArgs(containers []string, options *StopOptions) ([]string, error) { args := []string{"stop"} if options.Time != 0 { args = append(args, "--time", strconv.Itoa(options.Time)) } args = append(args, containers...) return args, nil } ================================================ FILE: modules/docker/stop_test.go ================================================ package docker import ( "crypto/tls" "fmt" "strconv" "strings" "testing" "time" http_helper "github.com/gruntwork-io/terratest/modules/http-helper" "github.com/gruntwork-io/terratest/modules/shell" "github.com/stretchr/testify/require" ) func TestStop(t *testing.T) { t.Parallel() // appending timestamp to container name to run tests in parallel name := "test-nginx" + strconv.FormatInt(time.Now().UnixNano(), 10) // choosing a unique port since 80 may not fly well on test machines port := "13030" host := GetDockerHost() testURL := fmt.Sprintf("http://%s:%s", host, port) // for testing the stopping of a docker container // we got to run a container first and then stop it runOpts := &RunOptions{ Detach: true, Name: name, Remove: true, OtherOptions: []string{"-p", port + ":80"}, } Run(t, "nginx:1.17-alpine", runOpts) // verify nginx is running http_helper.HttpGetWithRetryWithCustomValidation(t, testURL, &tls.Config{}, 60, 2*time.Second, verifyNginxIsUp) // try to stop it now out := Stop(t, []string{name}, &StopOptions{}) require.Contains(t, out, name) // verify nginx is down // run a docker ps with name filter command := shell.Command{ Command: "docker", Args: []string{"ps", "-q", "--filter", "name=" + name}, } output := shell.RunCommandAndGetStdOut(t, command) require.Empty(t, output) } func verifyNginxIsUp(statusCode int, body string) bool { return statusCode == 200 && strings.Contains(body, "nginx!") } ================================================ FILE: modules/environment/environment.go ================================================ // Package environment provides utility functions for interacting with the OS environment (e.g environment variables). package environment ================================================ FILE: modules/environment/envvar.go ================================================ package environment import ( "os" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // GetFirstNonEmptyEnvVarOrFatal returns the first non-empty environment variable from envVarNames, or throws a fatal func GetFirstNonEmptyEnvVarOrFatal(t testing.TestingT, envVarNames []string) string { value := GetFirstNonEmptyEnvVarOrEmptyString(t, envVarNames) if value == "" { t.Fatalf("All of the following env vars %v are empty. At least one must be non-empty.", envVarNames) } return value } // GetFirstNonEmptyEnvVarOrEmptyString returns the first non-empty environment variable from envVarNames, or returns the // empty string func GetFirstNonEmptyEnvVarOrEmptyString(t testing.TestingT, envVarNames []string) string { for _, name := range envVarNames { if value := os.Getenv(name); value != "" { return value } } return "" } // RequireEnvVar fails the test if the specified environment variable is not defined or is blank. func RequireEnvVar(t testing.TestingT, envVarName string) { require.NotEmptyf(t, os.Getenv(envVarName), "Environment variable %s must be set for this test.", envVarName) } ================================================ FILE: modules/environment/envvar_test.go ================================================ package environment_test import ( "testing" "github.com/gruntwork-io/terratest/modules/environment" "github.com/stretchr/testify/assert" ) // MockT is used to test that the function under test will fail the test under certain circumstances. type MockT struct { Failed bool } func (t *MockT) Fail() { t.Failed = true } func (t *MockT) FailNow() { t.Failed = true } func (t *MockT) Error(args ...any) { t.Failed = true } func (t *MockT) Errorf(format string, args ...any) { t.Failed = true } func (t *MockT) Fatal(args ...any) { t.Failed = true } func (t *MockT) Fatalf(format string, args ...any) { t.Failed = true } func (t *MockT) Name() string { return "mockT" } // End MockT var envvarList = []string{ "TERRATEST_TEST_ENVIRONMENT", "TERRATESTTESTENVIRONMENT", "TERRATESTENVIRONMENT", } //nolint:paralleltest // These tests manipulate env vars and cannot run in parallel. func TestGetFirstNonEmptyEnvVarOrEmptyStringChecksInOrder(t *testing.T) { t.Setenv("TERRATESTTESTENVIRONMENT", "test") t.Setenv("TERRATESTENVIRONMENT", "circleCI") value := environment.GetFirstNonEmptyEnvVarOrEmptyString(t, envvarList) assert.Equal(t, "test", value) } //nolint:paralleltest // These tests manipulate env vars and cannot run in parallel. func TestGetFirstNonEmptyEnvVarOrEmptyStringReturnsEmpty(t *testing.T) { value := environment.GetFirstNonEmptyEnvVarOrEmptyString(t, envvarList) assert.Empty(t, value) } //nolint:paralleltest // These tests manipulate env vars and cannot run in parallel. func TestRequireEnvVarFails(t *testing.T) { envVarName := "TERRATESTTESTENVIRONMENT" mockT := new(MockT) // Make sure the check fails when env var is not set environment.RequireEnvVar(mockT, envVarName) assert.True(t, mockT.Failed) } //nolint:paralleltest // These tests manipulate env vars and cannot run in parallel. func TestRequireEnvVarPasses(t *testing.T) { envVarName := "TERRATESTTESTENVIRONMENT" // Make sure the check passes when env var is set t.Setenv(envVarName, "test") environment.RequireEnvVar(t, envVarName) } ================================================ FILE: modules/files/errors.go ================================================ package files import "fmt" // DirNotFoundError is an error that occurs if a directory doesn't exist type DirNotFoundError struct { Directory string } func (err DirNotFoundError) Error() string { return fmt.Sprintf("Directory was not found: \"%s\"", err.Directory) } ================================================ FILE: modules/files/files.go ================================================ // Package files allows to interact with files on a file system. package files import ( "errors" "io/fs" "os" "path/filepath" "strings" "github.com/mattn/go-zglob" ) const defaultDirPermissions = 0o755 // FileExists returns true if the given file exists. func FileExists(path string) bool { _, err := os.Stat(path) return err == nil } // FileExistsE returns true if the given file exists // It will return an error if os.Stat error is not an ErrNotExist func FileExistsE(path string) (bool, error) { _, err := os.Stat(path) if err != nil && !errors.Is(err, fs.ErrNotExist) { return false, err } return err == nil, nil } // IsExistingFile returns true if the path exists and is a file. func IsExistingFile(path string) bool { fileInfo, err := os.Stat(path) return err == nil && !fileInfo.IsDir() } // IsExistingDir returns true if the path exists and is a directory func IsExistingDir(path string) bool { fileInfo, err := os.Stat(path) return err == nil && fileInfo.IsDir() } // CopyTerraformFolderToDest creates a copy of the given folder and all its contents in a specified folder with a unique name and the given prefix. // This is useful when running multiple tests in parallel against the same set of Terraform files to ensure the // tests don't overwrite each other's .terraform working directory and terraform.tfstate files. This method returns // the path to the dest folder with the copied contents. Hidden files and folders (with the exception of the `.terraform-version` files used // by the [mise tool](https://github.com/jdx/mise) and `.terraform.lock.hcl` used by Terraform to lock providers versions), Terraform state // files, and terraform.tfvars files are not copied to this temp folder, as you typically don't want them interfering with your tests. // This method is useful when running through a build tool so the files are copied to a destination that is cleaned on each run of the pipeline. func CopyTerraformFolderToDest(folderPath string, destRootFolder string, tempFolderPrefix string) (string, error) { filter := func(path string) bool { if PathIsTerraformVersionFile(path) || PathIsTerraformLockFile(path) { return true } if PathContainsHiddenFileOrFolder(path) || PathContainsTerraformStateOrVars(path) { return false } return true } destFolder, err := CopyFolderToDest(folderPath, destRootFolder, tempFolderPrefix, filter) if err != nil { return "", err } return destFolder, nil } // CopyTerraformFolderToTemp calls CopyTerraformFolderToDest, passing os.TempDir() as the root destination folder. func CopyTerraformFolderToTemp(folderPath string, tempFolderPrefix string) (string, error) { return CopyTerraformFolderToDest(folderPath, os.TempDir(), tempFolderPrefix) } // CopyTerragruntFolderToDest creates a copy of the given folder and all its contents in a specified folder with a unique name and the given prefix. // Since terragrunt uses tfvars files to specify modules, they are copied to the directory as well. // Terraform state files are excluded as well as .terragrunt-cache to avoid overwriting contents. func CopyTerragruntFolderToDest(folderPath string, destRootFolder string, tempFolderPrefix string) (string, error) { filter := func(path string) bool { return !PathContainsHiddenFileOrFolder(path) && !PathContainsTerraformState(path) } destFolder, err := CopyFolderToDest(folderPath, destRootFolder, tempFolderPrefix, filter) if err != nil { return "", err } return destFolder, nil } // CopyTerragruntFolderToTemp calls CopyTerragruntFolderToDest, passing os.TempDir() as the root destination folder. func CopyTerragruntFolderToTemp(folderPath string, tempFolderPrefix string) (string, error) { return CopyTerragruntFolderToDest(folderPath, os.TempDir(), tempFolderPrefix) } // CopyFolderToDest creates a copy of the given folder and all its filtered contents in a temp folder // with a unique name and the given prefix. func CopyFolderToDest(folderPath string, destRootFolder string, tempFolderPrefix string, filter func(path string) bool) (string, error) { destRootExists, err := FileExistsE(destRootFolder) if err != nil { return "", err } if !destRootExists { return "", DirNotFoundError{Directory: destRootFolder} } exists, err := FileExistsE(folderPath) if err != nil { return "", err } if !exists { return "", DirNotFoundError{Directory: folderPath} } tmpDir, err := os.MkdirTemp(destRootFolder, tempFolderPrefix) if err != nil { return "", err } // Inside of the temp folder, we create a subfolder that preserves the name of the folder we're copying from. absFolderPath, err := filepath.Abs(folderPath) if err != nil { return "", err } folderName := filepath.Base(absFolderPath) destFolder := filepath.Join(tmpDir, folderName) if err := os.MkdirAll(destFolder, defaultDirPermissions); err != nil { return "", err } if err := CopyFolderContentsWithFilter(folderPath, destFolder, filter); err != nil { return "", err } return destFolder, nil } // CopyFolderToTemp calls CopyFolderToDest, passing os.TempDir() as the root destination folder. func CopyFolderToTemp(folderPath string, tempFolderPrefix string, filter func(path string) bool) (string, error) { return CopyFolderToDest(folderPath, os.TempDir(), tempFolderPrefix, filter) } // CopyFolderContents copies all the files and folders within the given source folder to the destination folder. func CopyFolderContents(source string, destination string) error { return CopyFolderContentsWithFilter(source, destination, func(path string) bool { return true }) } // CopyFolderContentsWithFilter copies the files and folders within the given source folder that pass the given filter (return true) to the // destination folder. func CopyFolderContentsWithFilter(source string, destination string, filter func(path string) bool) error { files, err := os.ReadDir(source) if err != nil { return err } for _, file := range files { src := filepath.Join(source, file.Name()) dest := filepath.Join(destination, file.Name()) f, _ := file.Info() if !filter(src) { continue } switch { case file.IsDir(): if err := os.MkdirAll(dest, f.Mode()); err != nil { return err } if err := CopyFolderContentsWithFilter(src, dest, filter); err != nil { return err } case isSymLink(f): if err := copySymlink(src, dest); err != nil { return err } default: if err := CopyFile(src, dest); err != nil { return err } } } return nil } // PathContainsTerraformStateOrVars returns true if the path corresponds to a Terraform state file or .tfvars/.tfvars.json file. func PathContainsTerraformStateOrVars(path string) bool { filename := filepath.Base(path) return filename == "terraform.tfstate" || filename == "terraform.tfstate.backup" || filename == "terraform.tfvars" || filename == "terraform.tfvars.json" } // PathContainsTerraformState returns true if the path corresponds to a Terraform state file. func PathContainsTerraformState(path string) bool { filename := filepath.Base(path) return filename == "terraform.tfstate" || filename == "terraform.tfstate.backup" } // PathContainsHiddenFileOrFolder returns true if the given path contains a hidden file or folder. func PathContainsHiddenFileOrFolder(path string) bool { for pathPart := range strings.SplitSeq(path, string(filepath.Separator)) { if strings.HasPrefix(pathPart, ".") && pathPart != "." && pathPart != ".." { return true } } return false } // PathIsTerraformVersionFile returns true if the given path is the special '.terraform-version' file used by the [mise](https://github.com/jdx/mise) tool. func PathIsTerraformVersionFile(path string) bool { return filepath.Base(path) == ".terraform-version" } // PathIsTerraformLockFile return true if the given path is the special '.terraform.lock.hcl' file used by Terraform to lock providers versions func PathIsTerraformLockFile(path string) bool { return filepath.Base(path) == ".terraform.lock.hcl" } // CopyFile copies a file from source to destination. func CopyFile(source string, destination string) error { contents, err := os.ReadFile(source) if err != nil { return err } return WriteFileWithSamePermissions(source, destination, contents) } // WriteFileWithSamePermissions writes a file to the given destination with the given contents using the same permissions as the file at source. func WriteFileWithSamePermissions(source string, destination string, contents []byte) error { fileInfo, err := os.Stat(source) if err != nil { return err } return os.WriteFile(destination, contents, fileInfo.Mode()) } // isSymLink returns true if the given file is a symbolic link // Per https://stackoverflow.com/a/18062079/2308858 func isSymLink(file os.FileInfo) bool { return file.Mode()&os.ModeSymlink != 0 } // copySymlink copies the source symbolic link to the given destination. func copySymlink(source string, destination string) error { symlinkPath, err := os.Readlink(source) if err != nil { return err } return os.Symlink(symlinkPath, destination) } // FindTerraformSourceFilesInDir given a directory path, finds all the terraform source files contained in it. This will // recursively search subdirectories, but will ignore any hidden files (which in turn ignores terraform data dirs like // .terraform folder). func FindTerraformSourceFilesInDir(dirPath string) ([]string, error) { pattern := dirPath + "/**/*.tf" matches, err := zglob.Glob(pattern) if err != nil { return nil, err } tfFiles := []string{} for _, match := range matches { // Don't include hidden .terraform directories when finding paths to validate if !PathContainsHiddenFileOrFolder(match) { tfFiles = append(tfFiles, match) } } return tfFiles, nil } ================================================ FILE: modules/files/files_test.go ================================================ package files_test import ( "fmt" "os" "os/exec" "path/filepath" "testing" "github.com/gruntwork-io/terratest/modules/files" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const copyFolderContentsFixtureRoot = "../../test/fixtures/copy-folder-contents" func TestFileExists(t *testing.T) { t.Parallel() currentFile, err := filepath.Abs(os.Args[0]) require.NoError(t, err) assert.True(t, files.FileExists(currentFile)) assert.False(t, files.FileExists("/not/a/real/path")) } func TestIsExistingFile(t *testing.T) { t.Parallel() currentFile, err := filepath.Abs(os.Args[0]) require.NoError(t, err) currentFileDir := filepath.Dir(currentFile) assert.True(t, files.IsExistingFile(currentFile)) assert.False(t, files.IsExistingFile("/not/a/real/path")) assert.False(t, files.IsExistingFile(currentFileDir)) } func TestIsExistingDir(t *testing.T) { t.Parallel() currentFile, err := filepath.Abs(os.Args[0]) require.NoError(t, err) currentFileDir := filepath.Dir(currentFile) assert.False(t, files.IsExistingDir(currentFile)) assert.False(t, files.IsExistingDir("/not/a/real/path")) assert.True(t, files.IsExistingDir(currentFileDir)) } func TestCopyFolderToDest(t *testing.T) { t.Parallel() tempFolderPrefix := "someprefix" destFolder := os.TempDir() tmpDir := t.TempDir() filter := func(path string) bool { return !files.PathContainsHiddenFileOrFolder(path) && !files.PathContainsTerraformState(path) } folder, err := files.CopyFolderToDest("/not/a/real/path", destFolder, tempFolderPrefix, filter) require.Error(t, err) assert.False(t, files.FileExists(folder)) folder, err = files.CopyFolderToDest(tmpDir, destFolder, tempFolderPrefix, filter) assert.DirExists(t, folder) assert.NoError(t, err) } func TestCopyFolderContents(t *testing.T) { t.Parallel() originalDir := filepath.Join(copyFolderContentsFixtureRoot, "original") expectedDir := filepath.Join(copyFolderContentsFixtureRoot, "full-copy") tmpDir := t.TempDir() err := files.CopyFolderContents(originalDir, tmpDir) require.NoError(t, err) requireDirectoriesEqual(t, expectedDir, tmpDir) } func TestCopyFolderContentsWithHiddenFilesFilter(t *testing.T) { t.Parallel() originalDir := filepath.Join(copyFolderContentsFixtureRoot, "original") expectedDir := filepath.Join(copyFolderContentsFixtureRoot, "no-hidden-files") tmpDir := t.TempDir() err := files.CopyFolderContentsWithFilter(originalDir, tmpDir, func(path string) bool { return !files.PathContainsHiddenFileOrFolder(path) }) require.NoError(t, err) requireDirectoriesEqual(t, expectedDir, tmpDir) } // Test copying a folder that contains symlinks func TestCopyFolderContentsWithSymLinks(t *testing.T) { t.Parallel() originalDir := filepath.Join(copyFolderContentsFixtureRoot, "symlinks") expectedDir := filepath.Join(copyFolderContentsFixtureRoot, "symlinks") tmpDir := t.TempDir() err := files.CopyFolderContentsWithFilter(originalDir, tmpDir, func(path string) bool { return !files.PathContainsHiddenFileOrFolder(path) }) require.NoError(t, err) requireDirectoriesEqual(t, expectedDir, tmpDir) } // Test copying a folder that contains symlinks that point to a non-existent file func TestCopyFolderContentsWithBrokenSymLinks(t *testing.T) { t.Parallel() // Creating broken symlink pathToFile := filepath.Join(copyFolderContentsFixtureRoot, "symlinks-broken/nonexistent-folder/bar.txt") pathToSymlink := filepath.Join(copyFolderContentsFixtureRoot, "symlinks-broken/bar.txt") defer func() { if err := os.Remove(pathToSymlink); err != nil { t.Fatal(fmt.Errorf("failed to remove link: %w", err)) } }() if err := os.Symlink(pathToFile, pathToSymlink); err != nil { t.Fatal(fmt.Errorf("failed to create broken link for test: %w", err)) } // Test copying folder originalDir := filepath.Join(copyFolderContentsFixtureRoot, "symlinks-broken") tmpDir := t.TempDir() err := files.CopyFolderContentsWithFilter(originalDir, tmpDir, func(path string) bool { return !files.PathContainsHiddenFileOrFolder(path) }) require.NoError(t, err) // This requireDirectoriesEqual command uses GNU diff under the hood, but unfortunately we cannot instruct diff to // compare symlinks in two directories without attempting to dereference any symlinks until diff version 3.3.0. // Because many environments are still using diff < 3.3.0, we disregard this test for now. // Per https://unix.stackexchange.com/a/119406/129208 // requireDirectoriesEqual(t, expectedDir, tmpDir) fmt.Println("Test completed without error, however due to a limitation in GNU diff < 3.3.0, directories have not been compared for equivalency.") } func TestCopyTerraformFolderToTemp(t *testing.T) { t.Parallel() originalDir := filepath.Join(copyFolderContentsFixtureRoot, "original") expectedDir := filepath.Join(copyFolderContentsFixtureRoot, "no-hidden-files-no-terraform-files") tmpDir, err := files.CopyTerraformFolderToTemp(originalDir, "TestCopyTerraformFolderToTemp") require.NoError(t, err) requireDirectoriesEqual(t, expectedDir, tmpDir) } func TestCopyTerraformFolderToDest(t *testing.T) { t.Parallel() originalDir := filepath.Join(copyFolderContentsFixtureRoot, "original") expectedDir := filepath.Join(copyFolderContentsFixtureRoot, "no-hidden-files-no-terraform-files") destFolder := os.TempDir() tmpDir, err := files.CopyTerraformFolderToDest(originalDir, destFolder, "TestCopyTerraformFolderToTemp") require.NoError(t, err) requireDirectoriesEqual(t, expectedDir, tmpDir) } func TestCopyTerragruntFolderToTemp(t *testing.T) { t.Parallel() originalDir := filepath.Join(copyFolderContentsFixtureRoot, "terragrunt-files") expectedDir := filepath.Join(copyFolderContentsFixtureRoot, "no-state-files") tmpDir, err := files.CopyTerragruntFolderToTemp(originalDir, t.Name()) require.NoError(t, err) requireDirectoriesEqual(t, expectedDir, tmpDir) } func TestCopyTerragruntFolderToDest(t *testing.T) { t.Parallel() originalDir := filepath.Join(copyFolderContentsFixtureRoot, "terragrunt-files") expectedDir := filepath.Join(copyFolderContentsFixtureRoot, "no-state-files") destFolder := os.TempDir() tmpDir, err := files.CopyTerragruntFolderToDest(originalDir, destFolder, t.Name()) require.NoError(t, err) requireDirectoriesEqual(t, expectedDir, tmpDir) } func TestPathContainsTerraformStateOrVars(t *testing.T) { t.Parallel() var data = []struct { desc string path string contains bool }{ {"contains tfvars", "./folder/terraform.tfvars", true}, {"contains tfvars.json", "./folder/hello/terraform.tfvars.json", true}, {"contains state", "./folder/hello/helloagain/terraform.tfstate", true}, {"contains state backup", "./folder/hey/terraform.tfstate.backup", true}, {"does not contain any", "./folder/salut/terraform.json", false}, } for _, tt := range data { t.Run(tt.desc, func(t *testing.T) { t.Parallel() result := files.PathContainsTerraformStateOrVars(tt.path) if result != tt.contains { if tt.contains { t.Errorf("Expected %s to contain Terraform related file", tt.path) } else { t.Errorf("Expected %s to not contain Terraform related file", tt.path) } } }) } } // Diffing two directories to ensure they have the exact same files, contents, etc and showing exactly what's different // takes a lot of code. Why waste time on that when this functionality is already nicely implemented in the Unix/Linux // "diff" command? We shell out to that command at test time. func requireDirectoriesEqual(t *testing.T, folderWithExpectedContents string, folderWithActualContents string) { t.Helper() cmd := exec.CommandContext(t.Context(), "diff", "-r", "-u", folderWithExpectedContents, folderWithActualContents) bytes, err := cmd.Output() output := string(bytes) require.NoError(t, err, "diff command exited with an error. This likely means the contents of %s and %s are different. Here is the output of the diff command:\n%s", folderWithExpectedContents, folderWithActualContents, output) } ================================================ FILE: modules/gcp/cloudbuild.go ================================================ package gcp import ( "context" "fmt" cloudbuild "cloud.google.com/go/cloudbuild/apiv1/v2" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" "google.golang.org/api/iterator" cloudbuildpb "google.golang.org/genproto/googleapis/devtools/cloudbuild/v1" ) // CreateBuild creates a new build blocking until the operation is complete. func CreateBuild(t testing.TestingT, projectID string, build *cloudbuildpb.Build) *cloudbuildpb.Build { out, err := CreateBuildE(t, projectID, build) require.NoError(t, err) return out } // CreateBuildE creates a new build blocking until the operation is complete. func CreateBuildE(t testing.TestingT, projectID string, build *cloudbuildpb.Build) (*cloudbuildpb.Build, error) { ctx := context.Background() service, err := NewCloudBuildServiceE(t) if err != nil { return nil, err } req := &cloudbuildpb.CreateBuildRequest{ ProjectId: projectID, Build: build, } op, err := service.CreateBuild(ctx, req) if err != nil { return nil, fmt.Errorf("CreateBuildE.CreateBuild(%s) got error: %v", projectID, err) } resp, err := op.Wait(ctx) if err != nil { return nil, fmt.Errorf("CreateBuildE.Wait(%s) got error: %v", projectID, err) } return resp, nil } // GetBuild gets the given build. func GetBuild(t testing.TestingT, projectID string, buildID string) *cloudbuildpb.Build { out, err := GetBuildE(t, projectID, buildID) require.NoError(t, err) return out } // GetBuildE gets the given build. func GetBuildE(t testing.TestingT, projectID string, buildID string) (*cloudbuildpb.Build, error) { ctx := context.Background() service, err := NewCloudBuildServiceE(t) if err != nil { return nil, err } req := &cloudbuildpb.GetBuildRequest{ ProjectId: projectID, Id: buildID, } resp, err := service.GetBuild(ctx, req) if err != nil { return nil, fmt.Errorf("GetBuildE.GetBuild(%s, %s) got error: %v", projectID, buildID, err) } return resp, nil } // GetBuilds gets the list of builds for a given project. func GetBuilds(t testing.TestingT, projectID string) []*cloudbuildpb.Build { out, err := GetBuildsE(t, projectID) require.NoError(t, err) return out } // GetBuildsE gets the list of builds for a given project. func GetBuildsE(t testing.TestingT, projectID string) ([]*cloudbuildpb.Build, error) { ctx := context.Background() service, err := NewCloudBuildServiceE(t) if err != nil { return nil, err } req := &cloudbuildpb.ListBuildsRequest{ ProjectId: projectID, } it := service.ListBuilds(ctx, req) builds := []*cloudbuildpb.Build{} for { resp, err := it.Next() if err == iterator.Done { break } if err != nil { return nil, fmt.Errorf("GetBuildsE.ListBuilds(%s) got error: %v", projectID, err) } builds = append(builds, resp) } return builds, nil } // GetBuildsForTrigger gets a list of builds for a specific cloud build trigger. func GetBuildsForTrigger(t testing.TestingT, projectID string, triggerID string) []*cloudbuildpb.Build { out, err := GetBuildsForTriggerE(t, projectID, triggerID) require.NoError(t, err) return out } // GetBuildsForTriggerE gets a list of builds for a specific cloud build trigger. func GetBuildsForTriggerE(t testing.TestingT, projectID string, triggerID string) ([]*cloudbuildpb.Build, error) { builds, err := GetBuildsE(t, projectID) if err != nil { return nil, fmt.Errorf("GetBuildsE.ListBuilds(%s) got error: %v", projectID, err) } filteredBuilds := []*cloudbuildpb.Build{} for _, build := range builds { if build.GetBuildTriggerId() == triggerID { filteredBuilds = append(filteredBuilds, build) } } return filteredBuilds, nil } // NewCloudBuildService creates a new Cloud Build service, which is used to make Cloud Build API calls. func NewCloudBuildService(t testing.TestingT) *cloudbuild.Client { service, err := NewCloudBuildServiceE(t) require.NoError(t, err) return service } // NewCloudBuildServiceE creates a new Cloud Build service, which is used to make Cloud Build API calls. func NewCloudBuildServiceE(t testing.TestingT) (*cloudbuild.Client, error) { ctx := context.Background() service, err := cloudbuild.NewClient(ctx, withOptions()...) if err != nil { return nil, err } return service, nil } ================================================ FILE: modules/gcp/cloudbuild_test.go ================================================ //go:build gcp // +build gcp // NOTE: We use build tags to differentiate GCP testing for better isolation and parallelism when executing our tests. package gcp import ( "archive/tar" "bytes" "compress/gzip" "fmt" "strings" "testing" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/random" "github.com/stretchr/testify/require" cloudbuildpb "google.golang.org/genproto/googleapis/devtools/cloudbuild/v1" ) func TestCreateBuild(t *testing.T) { t.Parallel() // This test performs the following steps: // // 1. Creates a tarball with a single Dockerfile // 2. Creates a GCS bucket // 3. Uploads the tarball to the GCS Bucket // 4. Triggers a build using the Cloud Build API // 5. Attempts to untag and delete all pushed Build images (best-effort cleanup) // 6. Deletes the GCS bucket // Create and add some files to the archive. tarball := createSampleAppTarball(t) // Create GCS bucket projectID := GetGoogleProjectIDFromEnvVar(t) id := random.UniqueId() gsBucketName := "cloud-build-terratest-" + strings.ToLower(id) sampleAppPath := "docker-example.tar.gz" imagePath := fmt.Sprintf("gcr.io/%s/test-image-%s", projectID, strings.ToLower(id)) logger.Logf(t, "Random values selected Bucket Name = %s\n", gsBucketName) CreateStorageBucket(t, projectID, gsBucketName, nil) defer DeleteStorageBucket(t, gsBucketName) // Write the compressed archive to the storage bucket objectURL := WriteBucketObject(t, gsBucketName, sampleAppPath, tarball, "application/gzip") logger.Logf(t, "Got URL: %s", objectURL) // Create a new build build := &cloudbuildpb.Build{ Source: &cloudbuildpb.Source{ Source: &cloudbuildpb.Source_StorageSource{ StorageSource: &cloudbuildpb.StorageSource{ Bucket: gsBucketName, Object: sampleAppPath, }, }, }, Steps: []*cloudbuildpb.BuildStep{{ Name: "gcr.io/cloud-builders/docker", Args: []string{"build", "-t", imagePath, "."}, }}, Images: []string{imagePath}, } // CreateBuild blocks until the build is complete b := CreateBuild(t, projectID, build) // Attempt to delete the pushed build images (best-effort cleanup). // Note: GCR (gcr.io) has been deprecated in favor of Artifact Registry. // The cleanup may fail due to permission changes, but this doesn't affect // the validity of the Cloud Build test itself. // We could just use the `b` struct above, but we want to explicitly test // the `GetBuild` method. b2 := GetBuild(t, projectID, b.GetId()) for _, image := range b2.GetImages() { if err := DeleteGCRRepoE(t, image); err != nil { logger.Logf(t, "Warning: Failed to delete image %s (this may be expected due to GCR deprecation): %v", image, err) } } // Empty the storage bucket so we can delete it defer EmptyStorageBucket(t, gsBucketName) } func createSampleAppTarball(t *testing.T) *bytes.Reader { var buf bytes.Buffer tw := tar.NewWriter(&buf) file := `FROM busybox:latest MAINTAINER Rob Morgan (rob@gruntwork.io) ` hdr := &tar.Header{ Name: "Dockerfile", Mode: 0600, Size: int64(len(file)), } err := tw.WriteHeader(hdr) require.NoError(t, err) _, werr := tw.Write([]byte(file)) require.NoError(t, werr) cerr := tw.Close() require.NoError(t, cerr) // gzip the tar archive var zbuf bytes.Buffer gzw := gzip.NewWriter(&zbuf) _, gwerr := gzw.Write(buf.Bytes()) require.NoError(t, gwerr) gcerr := gzw.Close() require.NoError(t, gcerr) // return the compressed buffer return bytes.NewReader(zbuf.Bytes()) } ================================================ FILE: modules/gcp/compute.go ================================================ package gcp import ( "context" "fmt" "net/http" "path" "strings" "time" "github.com/gruntwork-io/terratest/modules/retry" "google.golang.org/api/compute/v1" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/testing" "golang.org/x/oauth2" "golang.org/x/oauth2/google" ) // Corresponds to a GCP Compute Instance (https://cloud.google.com/compute/docs/instances/) type Instance struct { projectID string *compute.Instance } // Corresponds to a GCP Image (https://cloud.google.com/compute/docs/images) type Image struct { projectID string *compute.Image } // Corresponds to a GCP Zonal Instance Group (https://cloud.google.com/compute/docs/instance-groups/) type ZonalInstanceGroup struct { projectID string *compute.InstanceGroup } // Corresponds to a GCP Regional Instance Group (https://cloud.google.com/compute/docs/instance-groups/) type RegionalInstanceGroup struct { projectID string *compute.InstanceGroup } type InstanceGroup interface { GetInstanceIds(t testing.TestingT) []string GetInstanceIdsE(t testing.TestingT) ([]string, error) } // FetchInstance queries GCP to return an instance of the (GCP Compute) Instance type func FetchInstance(t testing.TestingT, projectID string, name string) *Instance { instance, err := FetchInstanceE(t, projectID, name) if err != nil { t.Fatal(err) } return instance } // FetchInstance queries GCP to return an instance of the (GCP Compute) Instance type func FetchInstanceE(t testing.TestingT, projectID string, name string) (*Instance, error) { logger.Default.Logf(t, "Getting Compute Instance %s", name) ctx := context.Background() service, err := NewComputeServiceE(t) if err != nil { t.Fatal(err) } // If we want to fetch an Instance without knowing its Zone, we have to query GCP for all Instances in the project // and match on name. instanceAggregatedList, err := service.Instances.AggregatedList(projectID).Context(ctx).Do() if err != nil { return nil, fmt.Errorf("Instances.AggregatedList(%s) got error: %v", projectID, err) } for _, instanceList := range instanceAggregatedList.Items { for _, instance := range instanceList.Instances { if name == instance.Name { return &Instance{projectID, instance}, nil } } } return nil, fmt.Errorf("Compute Instance %s could not be found in project %s", name, projectID) } // FetchImage queries GCP to return a new instance of the (GCP Compute) Image type func FetchImage(t testing.TestingT, projectID string, name string) *Image { image, err := FetchImageE(t, projectID, name) if err != nil { t.Fatal(err) } return image } // FetchImage queries GCP to return a new instance of the (GCP Compute) Image type func FetchImageE(t testing.TestingT, projectID string, name string) (*Image, error) { logger.Default.Logf(t, "Getting Image %s", name) ctx := context.Background() service, err := NewComputeServiceE(t) if err != nil { return nil, err } req := service.Images.Get(projectID, name) image, err := req.Context(ctx).Do() if err != nil { return nil, err } return &Image{projectID, image}, nil } // FetchRegionalInstanceGroup queries GCP to return a new instance of the Regional Instance Group type func FetchRegionalInstanceGroup(t testing.TestingT, projectID string, region string, name string) *RegionalInstanceGroup { instanceGroup, err := FetchRegionalInstanceGroupE(t, projectID, region, name) if err != nil { t.Fatal(err) } return instanceGroup } // FetchRegionalInstanceGroup queries GCP to return a new instance of the Regional Instance Group type func FetchRegionalInstanceGroupE(t testing.TestingT, projectID string, region string, name string) (*RegionalInstanceGroup, error) { logger.Default.Logf(t, "Getting Regional Instance Group %s", name) ctx := context.Background() service, err := NewComputeServiceE(t) if err != nil { return nil, err } req := service.RegionInstanceGroups.Get(projectID, region, name) instanceGroup, err := req.Context(ctx).Do() if err != nil { return nil, err } return &RegionalInstanceGroup{projectID, instanceGroup}, nil } // FetchZonalInstanceGroup queries GCP to return a new instance of the Zonal Instance Group type func FetchZonalInstanceGroup(t testing.TestingT, projectID string, zone string, name string) *ZonalInstanceGroup { instanceGroup, err := FetchZonalInstanceGroupE(t, projectID, zone, name) if err != nil { t.Fatal(err) } return instanceGroup } // FetchZonalInstanceGroupE queries GCP to return a new instance of the Zonal Instance Group type func FetchZonalInstanceGroupE(t testing.TestingT, projectID string, zone string, name string) (*ZonalInstanceGroup, error) { logger.Default.Logf(t, "Getting Zonal Instance Group %s", name) ctx := context.Background() service, err := NewComputeServiceE(t) if err != nil { return nil, err } req := service.InstanceGroups.Get(projectID, zone, name) instanceGroup, err := req.Context(ctx).Do() if err != nil { return nil, err } return &ZonalInstanceGroup{projectID, instanceGroup}, nil } // GetPublicIP gets the public IP address of the given Compute Instance. func (i *Instance) GetPublicIp(t testing.TestingT) string { ip, err := i.GetPublicIpE(t) if err != nil { t.Fatal(err) } return ip } // GetPublicIpE gets the public IP address of the given Compute Instance. func (i *Instance) GetPublicIpE(t testing.TestingT) (string, error) { // If there are no accessConfigs specified, then this instance will have no external internet access: // https://cloud.google.com/compute/docs/reference/rest/v1/instances. if len(i.NetworkInterfaces[0].AccessConfigs) == 0 { return "", fmt.Errorf("Attempted to get public IP of Compute Instance %s, but that Compute Instance does not have a public IP address", i.Name) } ip := i.NetworkInterfaces[0].AccessConfigs[0].NatIP return ip, nil } // GetLabels returns all the tags for the given Compute Instance. func (i *Instance) GetLabels(t testing.TestingT) map[string]string { return i.Labels } // GetZone returns the Zone in which the Compute Instance is located. func (i *Instance) GetZone(t testing.TestingT) string { return ZoneUrlToZone(i.Zone) } // SetLabels adds the tags to the given Compute Instance. func (i *Instance) SetLabels(t testing.TestingT, labels map[string]string) { err := i.SetLabelsE(t, labels) if err != nil { t.Fatal(err) } } // SetLabelsE adds the tags to the given Compute Instance. func (i *Instance) SetLabelsE(t testing.TestingT, labels map[string]string) error { logger.Default.Logf(t, "Adding labels to instance %s in zone %s", i.Name, i.Zone) ctx := context.Background() service, err := NewComputeServiceE(t) if err != nil { return err } req := compute.InstancesSetLabelsRequest{Labels: labels, LabelFingerprint: i.LabelFingerprint} if _, err := service.Instances.SetLabels(i.projectID, i.GetZone(t), i.Name, &req).Context(ctx).Do(); err != nil { return fmt.Errorf("Instances.SetLabels(%s) got error: %v", i.Name, err) } return nil } // GetMetadata gets the given Compute Instance's metadata func (i *Instance) GetMetadata(t testing.TestingT) []*compute.MetadataItems { return i.Metadata.Items } // SetMetadata sets the given Compute Instance's metadata func (i *Instance) SetMetadata(t testing.TestingT, metadata map[string]string) { err := i.SetMetadataE(t, metadata) if err != nil { t.Fatal(err) } } // SetMetadataE adds the given metadata map to the existing metadata of the given Compute Instance. func (i *Instance) SetMetadataE(t testing.TestingT, metadata map[string]string) error { logger.Default.Logf(t, "Adding metadata to instance %s in zone %s", i.Name, i.Zone) ctx := context.Background() service, err := NewInstancesServiceE(t) if err != nil { return err } metadataItems := newMetadata(t, i.Metadata, metadata) req := service.SetMetadata(i.projectID, i.GetZone(t), i.Name, metadataItems) if _, err := req.Context(ctx).Do(); err != nil { return fmt.Errorf("Instances.SetMetadata(%s) got error: %v", i.Name, err) } return nil } // newMetadata merges new key-value pairs into existing metadata, preserving unmodified items. func newMetadata(t testing.TestingT, oldMetadata *compute.Metadata, kvs map[string]string) *compute.Metadata { itemsMap := make(map[string]*string) if oldMetadata != nil { for _, item := range oldMetadata.Items { itemsMap[item.Key] = item.Value } } for key, val := range kvs { v := val itemsMap[key] = &v } items := make([]*compute.MetadataItems, 0, len(itemsMap)) for key, val := range itemsMap { items = append(items, &compute.MetadataItems{Key: key, Value: val}) } fingerprint := "" if oldMetadata != nil { fingerprint = oldMetadata.Fingerprint } return &compute.Metadata{ Fingerprint: fingerprint, Items: items, } } // Add the given public SSH key to the Compute Instance. Users can SSH in with the given username. func (i *Instance) AddSshKey(t testing.TestingT, username string, publicKey string) { err := i.AddSshKeyE(t, username, publicKey) if err != nil { t.Fatal(err) } } // Add the given public SSH key to the Compute Instance. Users can SSH in with the given username. func (i *Instance) AddSshKeyE(t testing.TestingT, username string, publicKey string) error { logger.Default.Logf(t, "Adding SSH Key to Compute Instance %s for username %s\n", i.Name, username) // We represent the key in the format required per GCP docs (https://cloud.google.com/compute/docs/instances/adding-removing-ssh-keys) publicKeyFormatted := strings.TrimSpace(publicKey) sshKeyFormatted := fmt.Sprintf("%s:%s %s", username, publicKeyFormatted, username) metadata := map[string]string{ "ssh-keys": sshKeyFormatted, } err := i.SetMetadataE(t, metadata) if err != nil { return fmt.Errorf("Failed to add SSH key to Compute Instance: %s", err) } return nil } // DeleteImage deletes the given Compute Image. func (i *Image) DeleteImage(t testing.TestingT) { err := i.DeleteImageE(t) if err != nil { t.Fatal(err) } } // DeleteImageE deletes the given Compute Image. func (i *Image) DeleteImageE(t testing.TestingT) error { logger.Default.Logf(t, "Destroying Image %s", i.Name) ctx := context.Background() service, err := NewComputeServiceE(t) if err != nil { return err } if _, err := service.Images.Delete(i.projectID, i.Name).Context(ctx).Do(); err != nil { return fmt.Errorf("Images.Delete(%s) got error: %v", i.Name, err) } return nil } // GetInstanceIds gets the IDs of Instances in the given Instance Group. func (ig *ZonalInstanceGroup) GetInstanceIds(t testing.TestingT) []string { ids, err := ig.GetInstanceIdsE(t) if err != nil { t.Fatal(err) } return ids } // GetInstanceIdsE gets the IDs of Instances in the given Zonal Instance Group. func (ig *ZonalInstanceGroup) GetInstanceIdsE(t testing.TestingT) ([]string, error) { logger.Default.Logf(t, "Get instances for Zonal Instance Group %s", ig.Name) ctx := context.Background() service, err := NewComputeServiceE(t) if err != nil { return nil, err } requestBody := &compute.InstanceGroupsListInstancesRequest{ InstanceState: "ALL", } instanceIDs := []string{} zone := ZoneUrlToZone(ig.Zone) req := service.InstanceGroups.ListInstances(ig.projectID, zone, ig.Name, requestBody) err = req.Pages(ctx, func(page *compute.InstanceGroupsListInstances) error { for _, instance := range page.Items { // For some reason service.InstanceGroups.ListInstances returns us a collection // with Instance URLs and we need only the Instance ID for the next call. Use // the path functions to chop the Instance ID off the end of the URL. instanceID := path.Base(instance.Instance) instanceIDs = append(instanceIDs, instanceID) } return nil }) if err != nil { return nil, fmt.Errorf("InstanceGroups.ListInstances(%s) got error: %v", ig.Name, err) } return instanceIDs, nil } // GetInstanceIds gets the IDs of Instances in the given Regional Instance Group. func (ig *RegionalInstanceGroup) GetInstanceIds(t testing.TestingT) []string { ids, err := ig.GetInstanceIdsE(t) if err != nil { t.Fatal(err) } return ids } // GetInstanceIdsE gets the IDs of Instances in the given Regional Instance Group. func (ig *RegionalInstanceGroup) GetInstanceIdsE(t testing.TestingT) ([]string, error) { logger.Default.Logf(t, "Get instances for Regional Instance Group %s", ig.Name) ctx := context.Background() service, err := NewComputeServiceE(t) if err != nil { return nil, err } requestBody := &compute.RegionInstanceGroupsListInstancesRequest{ InstanceState: "ALL", } instanceIDs := []string{} region := RegionUrlToRegion(ig.Region) req := service.RegionInstanceGroups.ListInstances(ig.projectID, region, ig.Name, requestBody) err = req.Pages(ctx, func(page *compute.RegionInstanceGroupsListInstances) error { for _, instance := range page.Items { // For some reason service.InstanceGroups.ListInstances returns us a collection // with Instance URLs and we need only the Instance ID for the next call. Use // the path functions to chop the Instance ID off the end of the URL. instanceID := path.Base(instance.Instance) instanceIDs = append(instanceIDs, instanceID) } return nil }) if err != nil { return nil, fmt.Errorf("InstanceGroups.ListInstances(%s) got error: %v", ig.Name, err) } return instanceIDs, nil } // Return a collection of Instance structs from the given Instance Group func (ig *ZonalInstanceGroup) GetInstances(t testing.TestingT, projectId string) []*Instance { return getInstances(t, ig, projectId) } // Return a collection of Instance structs from the given Instance Group func (ig *ZonalInstanceGroup) GetInstancesE(t testing.TestingT, projectId string) ([]*Instance, error) { return getInstancesE(t, ig, projectId) } // Return a collection of Instance structs from the given Instance Group func (ig *RegionalInstanceGroup) GetInstances(t testing.TestingT, projectId string) []*Instance { return getInstances(t, ig, projectId) } // Return a collection of Instance structs from the given Instance Group func (ig *RegionalInstanceGroup) GetInstancesE(t testing.TestingT, projectId string) ([]*Instance, error) { return getInstancesE(t, ig, projectId) } // getInstancesE returns a collection of Instance structs from the given Instance Group func getInstances(t testing.TestingT, ig InstanceGroup, projectId string) []*Instance { instances, err := getInstancesE(t, ig, projectId) if err != nil { t.Fatal(err) } return instances } // getInstancesE returns a collection of Instance structs from the given Instance Group func getInstancesE(t testing.TestingT, ig InstanceGroup, projectId string) ([]*Instance, error) { instanceIds, err := ig.GetInstanceIdsE(t) if err != nil { return nil, fmt.Errorf("Failed to get Instance Group IDs: %s", err) } var instances []*Instance for _, instanceId := range instanceIds { instance, err := FetchInstanceE(t, projectId, instanceId) if err != nil { return nil, fmt.Errorf("Failed to get Instance: %s", err) } instances = append(instances, instance) } return instances, nil } // GetPublicIps returns a slice of the public IPs from the given Instance Group func (ig *ZonalInstanceGroup) GetPublicIps(t testing.TestingT, projectId string) []string { return getPublicIps(t, ig, projectId) } // GetPublicIpsE returns a slice of the public IPs from the given Instance Group func (ig *ZonalInstanceGroup) GetPublicIpsE(t testing.TestingT, projectId string) ([]string, error) { return getPublicIpsE(t, ig, projectId) } // GetPublicIps returns a slice of the public IPs from the given Instance Group func (ig *RegionalInstanceGroup) GetPublicIps(t testing.TestingT, projectId string) []string { return getPublicIps(t, ig, projectId) } // GetPublicIpsE returns a slice of the public IPs from the given Instance Group func (ig *RegionalInstanceGroup) GetPublicIpsE(t testing.TestingT, projectId string) ([]string, error) { return getPublicIpsE(t, ig, projectId) } // getPublicIps a slice of the public IPs from the given Instance Group func getPublicIps(t testing.TestingT, ig InstanceGroup, projectId string) []string { ips, err := getPublicIpsE(t, ig, projectId) if err != nil { t.Fatal(err) } return ips } // getPublicIpsE a slice of the public IPs from the given Instance Group func getPublicIpsE(t testing.TestingT, ig InstanceGroup, projectId string) ([]string, error) { instances, err := getInstancesE(t, ig, projectId) if err != nil { return nil, fmt.Errorf("Failed to get Compute Instances from Instance Group: %s", err) } var ips []string for _, instance := range instances { ip := instance.GetPublicIp(t) ips = append(ips, ip) } return ips, nil } // getRandomInstance returns a randomly selected Instance from the Regional Instance Group func (ig *ZonalInstanceGroup) GetRandomInstance(t testing.TestingT) *Instance { return getRandomInstance(t, ig, ig.Name, ig.Region, ig.Size, ig.projectID) } // getRandomInstanceE returns a randomly selected Instance from the Regional Instance Group func (ig *ZonalInstanceGroup) GetRandomInstanceE(t testing.TestingT) (*Instance, error) { return getRandomInstanceE(t, ig, ig.Name, ig.Region, ig.Size, ig.projectID) } // getRandomInstance returns a randomly selected Instance from the Regional Instance Group func (ig *RegionalInstanceGroup) GetRandomInstance(t testing.TestingT) *Instance { return getRandomInstance(t, ig, ig.Name, ig.Region, ig.Size, ig.projectID) } // getRandomInstanceE returns a randomly selected Instance from the Regional Instance Group func (ig *RegionalInstanceGroup) GetRandomInstanceE(t testing.TestingT) (*Instance, error) { return getRandomInstanceE(t, ig, ig.Name, ig.Region, ig.Size, ig.projectID) } func getRandomInstance(t testing.TestingT, ig InstanceGroup, name string, region string, size int64, projectID string) *Instance { instance, err := getRandomInstanceE(t, ig, name, region, size, projectID) if err != nil { t.Fatal(err) } return instance } func getRandomInstanceE(t testing.TestingT, ig InstanceGroup, name string, region string, size int64, projectID string) (*Instance, error) { instanceIDs := ig.GetInstanceIds(t) if len(instanceIDs) == 0 { return nil, fmt.Errorf("Could not find any instances in Regional Instance Group or Zonal Instance Group %s in Region %s", name, region) } clusterSize := int(size) if len(instanceIDs) != clusterSize { return nil, fmt.Errorf("Expected Regional Instance Group or Zonal Instance Group %s in Region %s to have %d instances, but found %d", name, region, clusterSize, len(instanceIDs)) } randIndex := random.Random(0, clusterSize-1) instanceID := instanceIDs[randIndex] instance := FetchInstance(t, projectID, instanceID) return instance, nil } // NewComputeService creates a new Compute service, which is used to make GCE API calls. func NewComputeService(t testing.TestingT) *compute.Service { client, err := NewComputeServiceE(t) if err != nil { t.Fatal(err) } return client } // NewComputeServiceE creates a new Compute service, which is used to make GCE API calls. func NewComputeServiceE(t testing.TestingT) (*compute.Service, error) { ctx := context.Background() if ts, ok := getStaticTokenSource(); ok { return compute.New(oauth2.NewClient(ctx, ts)) } // Retrieve the Google OAuth token using a retry loop as it can sometimes return an error. // e.g: oauth2: cannot fetch token: Post https://oauth2.googleapis.com/token: net/http: TLS handshake timeout // This is loosely based on https://github.com/kubernetes/kubernetes/blob/7e8de5422cb5ad76dd0c147cf4336220d282e34b/pkg/cloudprovider/providers/gce/gce.go#L831. description := "Attempting to request a Google OAuth2 token" maxRetries := 6 timeBetweenRetries := 10 * time.Second var client *http.Client msg, retryErr := retry.DoWithRetryE(t, description, maxRetries, timeBetweenRetries, func() (string, error) { rawClient, err := google.DefaultClient(ctx, compute.CloudPlatformScope) if err != nil { return "Error retrieving default GCP client", err } client = rawClient return "Successfully retrieved default GCP client", nil }) logger.Default.Logf(t, "%s", msg) if retryErr != nil { return nil, retryErr } return compute.New(client) } // NewInstancesService creates a new InstancesService service, which is used to make a subset of GCE API calls. func NewInstancesService(t testing.TestingT) *compute.InstancesService { client, err := NewInstancesServiceE(t) if err != nil { t.Fatal(err) } return client } // NewInstancesServiceE creates a new InstancesService service, which is used to make a subset of GCE API calls. func NewInstancesServiceE(t testing.TestingT) (*compute.InstancesService, error) { service, err := NewComputeServiceE(t) if err != nil { return nil, fmt.Errorf("Failed to get new Instances Service\n") } return service.Instances, nil } // Return a random, valid name for GCP resources. Many resources in GCP requires lowercase letters only. func RandomValidGcpName() string { id := strings.ToLower(random.UniqueId()) instanceName := fmt.Sprintf("terratest-%s", id) return instanceName } ================================================ FILE: modules/gcp/compute_test.go ================================================ //go:build gcp // +build gcp // NOTE: We use build tags to differentiate GCP testing for better isolation and parallelism when executing our tests. package gcp import ( "context" "fmt" "reflect" "regexp" "testing" "time" "github.com/gruntwork-io/terratest/modules/retry" "github.com/stretchr/testify/assert" "google.golang.org/api/compute/v1" ) const DEFAULT_MACHINE_TYPE = "f1-micro" const DEFAULT_IMAGE_FAMILY_PROJECT_NAME = "ubuntu-os-cloud" const DEFAULT_IMAGE_FAMILY_NAME = "family/ubuntu-2204-lts" // Zones that support running f1-micro instances var ZonesThatSupportF1Micro = []string{"us-central1-a", "us-east1-b", "us-west1-a", "europe-north1-a", "europe-west1-b", "europe-central2-a"} func TestGetPublicIpOfInstance(t *testing.T) { t.Parallel() instanceName := RandomValidGcpName() projectID := GetGoogleProjectIDFromEnvVar(t) zone := GetRandomZone(t, projectID, ZonesThatSupportF1Micro, nil, nil) createComputeInstance(t, projectID, zone, instanceName) defer deleteComputeInstance(t, projectID, zone, instanceName) // Now that our Instance is launched, attempt to query the public IP maxRetries := 10 sleepBetweenRetries := 3 * time.Second ip := retry.DoWithRetry(t, "Read IP address of Compute Instance", maxRetries, sleepBetweenRetries, func() (string, error) { // Consider attempting to connect to the Compute Instance at this IP in the future, but for now, we just call the // the function to ensure we don't have errors instance := FetchInstance(t, projectID, instanceName) ip := instance.GetPublicIp(t) if ip == "" { return "", fmt.Errorf("Got blank IP. Retrying.\n") } return ip, nil }) fmt.Printf("Public IP of Compute Instance %s = %s\n", instanceName, ip) } func TestZoneUrlToZone(t *testing.T) { t.Parallel() testCases := []struct { zoneUrl string expectedZone string }{ {"https://www.googleapis.com/compute/v1/projects/terratest-123456/zones/asia-east1-b", "asia-east1-b"}, {"https://www.googleapis.com/compute/v1/projects/terratest-123456/zones/us-east1-a", "us-east1-a"}, } for _, tc := range testCases { zone := ZoneUrlToZone(tc.zoneUrl) assert.Equal(t, zone, tc.expectedZone, "Zone not extracted successfully from Zone URL") } } func TestGetAndSetLabels(t *testing.T) { t.Parallel() instanceName := RandomValidGcpName() projectID := GetGoogleProjectIDFromEnvVar(t) zone := GetRandomZone(t, projectID, ZonesThatSupportF1Micro, nil, nil) createComputeInstance(t, projectID, zone, instanceName) defer deleteComputeInstance(t, projectID, zone, instanceName) // Now that our Instance is launched, set the labels. Note that in GCP label keys and values can only contain // lowercase letters, numeric characters, underscores and dashes. instance := FetchInstance(t, projectID, instanceName) labelsToWrite := map[string]string{ "context": "terratest", } instance.SetLabels(t, labelsToWrite) // Now attempt to read the labels we just set. maxRetries := 30 sleepBetweenRetries := 3 * time.Second retry.DoWithRetry(t, "Read newly set labels", maxRetries, sleepBetweenRetries, func() (string, error) { instance := FetchInstance(t, projectID, instanceName) labelsFromRead := instance.GetLabels(t) if !reflect.DeepEqual(labelsFromRead, labelsToWrite) { return "", fmt.Errorf("Labels that were written did not match labels that were read. Retrying.\n") } return "", nil }) } // Set custom metadata on a Compute Instance, and then verify it was set as expected func TestGetAndSetMetadata(t *testing.T) { t.Parallel() projectID := GetGoogleProjectIDFromEnvVar(t) instanceName := RandomValidGcpName() zone := GetRandomZone(t, projectID, ZonesThatSupportF1Micro, nil, nil) // Create a new Compute Instance createComputeInstance(t, projectID, zone, instanceName) defer deleteComputeInstance(t, projectID, zone, instanceName) // Set the metadata instance := FetchInstance(t, projectID, instanceName) metadataToWrite := map[string]string{ "foo": "bar", } instance.SetMetadata(t, metadataToWrite) // Now attempt to read the metadata we just set maxRetries := 30 sleepBetweenRetries := 3 * time.Second retry.DoWithRetry(t, "Read newly set metadata", maxRetries, sleepBetweenRetries, func() (string, error) { instance := FetchInstance(t, projectID, instanceName) metadataFromRead := instance.GetMetadata(t) for _, metadataItem := range metadataFromRead { for key, val := range metadataToWrite { if metadataItem.Key == key && *metadataItem.Value == val { return "", nil } } } fmt.Printf("Metadata to write: %+v\nMetadata from read: %+v\n", metadataToWrite, metadataFromRead) return "", fmt.Errorf("Metadata that was written was not found in metadata that was read. Retrying.\n") }) } // Helper function to launch a Compute Instance. This function is useful for quickly iterating on automated tests. But // if you're writing a test that resembles real-world code that Terratest users may write, you should create a Compute // Instance using a Terraform apply, similar to the tests in /test. func createComputeInstance(t *testing.T, projectID string, zone string, name string) { t.Logf("Launching new Compute Instance %s\n", name) // This RegEx was pulled straight from the GCP API error messages that complained when it's not honored validNameExp := `^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$` regEx := regexp.MustCompile(validNameExp) if !regEx.MatchString(name) { t.Fatalf("Invalid Compute Instance name: %s. Must match RegEx %s\n", name, validNameExp) } machineType := DEFAULT_MACHINE_TYPE sourceImageFamilyProjectName := DEFAULT_IMAGE_FAMILY_PROJECT_NAME sourceImageFamilyName := DEFAULT_IMAGE_FAMILY_NAME // Per GCP docs (https://cloud.google.com/compute/docs/reference/rest/v1/instances/setMachineType), the MachineType // is actually specified as a partial URL machineTypeURL := fmt.Sprintf("zones/%s/machineTypes/%s", zone, machineType) sourceImageURL := fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/global/images/%s", sourceImageFamilyProjectName, sourceImageFamilyName) // Based on the properties listed as required at https://cloud.google.com/compute/docs/reference/rest/v1/instances/insert // plus a somewhat painful cycle of add-next-property-try-fix-error-message-repeat. instanceConfig := &compute.Instance{ Name: name, MachineType: machineTypeURL, NetworkInterfaces: []*compute.NetworkInterface{ &compute.NetworkInterface{ AccessConfigs: []*compute.AccessConfig{ &compute.AccessConfig{}, }, }, }, Disks: []*compute.AttachedDisk{ &compute.AttachedDisk{ AutoDelete: true, Boot: true, InitializeParams: &compute.AttachedDiskInitializeParams{ SourceImage: sourceImageURL, }, }, }, } service, err := NewComputeServiceE(t) if err != nil { t.Fatal(err) } // Create the Compute Instance ctx := context.Background() _, err = service.Instances.Insert(projectID, zone, instanceConfig).Context(ctx).Do() if err != nil { t.Fatalf("Error launching new Compute Instance: %s", err) } } // Helper function that destroys the given Compute Instance and all of its attached disks. func deleteComputeInstance(t *testing.T, projectID string, zone string, name string) { t.Logf("Deleting Compute Instance %s\n", name) service, err := NewComputeServiceE(t) if err != nil { t.Fatal(err) } // Delete the Compute Instance ctx := context.Background() _, err = service.Instances.Delete(projectID, zone, name).Context(ctx).Do() if err != nil { t.Fatalf("Error deleting Compute Instance: %s", err) } } // TODO: Add additional automated tests to cover remaining functions in compute.go ================================================ FILE: modules/gcp/compute_unit_test.go ================================================ package gcp import ( "testing" "github.com/stretchr/testify/assert" "google.golang.org/api/compute/v1" ) // TestNewMetadataPreservesExisting is a regression test for issue #1655. // Verifies that existing metadata is preserved when adding new key-value pairs. func TestNewMetadataPreservesExisting(t *testing.T) { t.Parallel() existingVal := "existing-value" oldMetadata := &compute.Metadata{ Fingerprint: "test-fingerprint", Items: []*compute.MetadataItems{{Key: "existing-key", Value: &existingVal}}, } result := newMetadata(t, oldMetadata, map[string]string{"new-key": "new-value"}) // Convert to map for easier assertion got := make(map[string]string) for _, item := range result.Items { got[item.Key] = *item.Value } assert.Equal(t, "test-fingerprint", result.Fingerprint) assert.Equal(t, "existing-value", got["existing-key"], "existing metadata should be preserved") assert.Equal(t, "new-value", got["new-key"], "new metadata should be added") } ================================================ FILE: modules/gcp/gcp.go ================================================ // Package gcp allows interaction with Google Cloud Platform resources. package gcp import ( "google.golang.org/api/option" ) func withOptions() (opts []option.ClientOption) { v, ok := getStaticTokenSource() if ok { opts = append(opts, option.WithTokenSource(v)) } return } ================================================ FILE: modules/gcp/gcr.go ================================================ package gcp import ( "context" "fmt" "github.com/google/go-containerregistry/pkg/authn" gcrname "github.com/google/go-containerregistry/pkg/name" gcrgoogle "github.com/google/go-containerregistry/pkg/v1/google" gcrremote "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // DeleteGCRRepo deletes a GCR repository including all tagged images func DeleteGCRRepo(t testing.TestingT, repo string) { err := DeleteGCRRepoE(t, repo) require.NoError(t, err) } // DeleteGCRRepoE deletes a GCR repository including all tagged images func DeleteGCRRepoE(t testing.TestingT, repo string) error { // create a new auther for the API calls auther, err := newGCRAuther() if err != nil { return fmt.Errorf("Failed to create auther. Got error: %v", err) } gcrrepo, err := gcrname.NewRepository(repo) if err != nil { return fmt.Errorf("Failed to get repo. Got error: %v", err) } logger.Default.Logf(t, "Retrieving Image Digests %s", gcrrepo) tags, err := gcrgoogle.List(gcrrepo, gcrgoogle.WithAuth(auther)) if err != nil { return fmt.Errorf("Failed to list tags for repo %s. Got error: %v", repo, err) } // attempt to delete the latest image tag latestRef := repo + ":latest" logger.Default.Logf(t, "Deleting Image Ref %s", latestRef) if err := DeleteGCRImageRefE(t, latestRef); err != nil { return fmt.Errorf("Failed to delete GCR Image Reference %s. Got error: %v", latestRef, err) } // delete image references sequentially for k := range tags.Manifests { ref := repo + "@" + k logger.Default.Logf(t, "Deleting Image Ref %s", ref) if err := DeleteGCRImageRefE(t, ref); err != nil { return fmt.Errorf("Failed to delete GCR Image Reference %s. Got error: %v", ref, err) } } return nil } // DeleteGCRImageRef deletes a single repo image ref/digest func DeleteGCRImageRef(t testing.TestingT, ref string) { err := DeleteGCRImageRefE(t, ref) require.NoError(t, err) } // DeleteGCRImageRefE deletes a single repo image ref/digest func DeleteGCRImageRefE(t testing.TestingT, ref string) error { name, err := gcrname.ParseReference(ref) if err != nil { return fmt.Errorf("Failed to parse reference %s. Got error: %v", ref, err) } // create a new auther for the API calls auther, err := newGCRAuther() if err != nil { return fmt.Errorf("Failed to create auther. Got error: %v", err) } opts := gcrremote.WithAuth(auther) if err := gcrremote.Delete(name, opts); err != nil { return fmt.Errorf("Failed to delete %s. Got error: %v", name, err) } return nil } func newGCRAuther() (authn.Authenticator, error) { if ts, ok := getStaticTokenSource(); ok { return gcrgoogle.NewTokenSourceAuthenticator(ts), nil } return gcrgoogle.NewEnvAuthenticator(context.Background()) } ================================================ FILE: modules/gcp/oslogin.go ================================================ package gcp import ( "context" "fmt" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" "golang.org/x/oauth2" "golang.org/x/oauth2/google" "google.golang.org/api/compute/v1" "google.golang.org/api/oslogin/v1" ) // ImportSSHKey will import an SSH key to GCP under the provided user identity. // The `user` parameter should be the email address of the user. // The `key` parameter should be the public key of the SSH key being uploaded. // This will fail the test if there is an error. func ImportSSHKey(t testing.TestingT, user, key string) { require.NoErrorf(t, ImportSSHKeyE(t, user, key), "Could not add SSH Key to user %s", user) } // ImportSSHKeyE will import an SSH key to GCP under the provided user identity. // The `user` parameter should be the email address of the user. // The `key` parameter should be the public key of the SSH key being uploaded. func ImportSSHKeyE(t testing.TestingT, user, key string) error { return importProjectSSHKeyE(t, user, key, nil) } // ImportProjectSSHKey will import an SSH key to GCP under the provided user identity. // The `user` parameter should be the email address of the user. // The `key` parameter should be the public key of the SSH key being uploaded. // The `projectID` parameter should be the chosen project ID. // This will fail the test if there is an error. func ImportProjectSSHKey(t testing.TestingT, user, key, projectID string) { require.NoErrorf(t, ImportProjectSSHKeyE(t, user, key, projectID), "Could not add SSH Key to user %s", user) } // ImportProjectSSHKeyE will import an SSH key to GCP under the provided user identity. // The `user` parameter should be the email address of the user. // The `key` parameter should be the public key of the SSH key being uploaded. // The `projectID` parameter should be the chosen project ID. func ImportProjectSSHKeyE(t testing.TestingT, user, key, projectID string) error { return importProjectSSHKeyE(t, user, key, &projectID) } func importProjectSSHKeyE(t testing.TestingT, user, key string, projectID *string) error { logger.Default.Logf(t, "Importing SSH key for user %s", user) ctx := context.Background() service, err := NewOSLoginServiceE(t) if err != nil { return err } parent := fmt.Sprintf("users/%s", user) sshPublicKey := &oslogin.SshPublicKey{ Key: key, } req := service.Users.ImportSshPublicKey(parent, sshPublicKey) if projectID != nil { req = req.ProjectId(*projectID) } _, err = req.Context(ctx).Do() if err != nil { return err } return nil } // DeleteSSHKey will delete an SSH key attached to the provided user identity. // The `user` parameter should be the email address of the user. // The `key` parameter should be the public key of the SSH key that was uploaded. // This will fail the test if there is an error. func DeleteSSHKey(t testing.TestingT, user, key string) { require.NoErrorf(t, DeleteSSHKeyE(t, user, key), "Could not delete SSH Key for user %s", user) } // DeleteSSHKeyE will delete an SSH key attached to the provided user identity. // The `user` parameter should be the email address of the user. // The `key` parameter should be the public key of the SSH key that was uploaded. func DeleteSSHKeyE(t testing.TestingT, user, key string) error { logger.Default.Logf(t, "Deleting SSH key for user %s", user) ctx := context.Background() service, err := NewOSLoginServiceE(t) if err != nil { return err } loginProfile := GetLoginProfile(t, user) for _, v := range loginProfile.SshPublicKeys { if key == v.Key { path := fmt.Sprintf("users/%s/sshPublicKeys/%s", user, v.Fingerprint) _, err = service.Users.SshPublicKeys.Delete(path).Context(ctx).Do() break } } if err != nil { return err } return nil } // GetLoginProfile will retrieve the login profile for a user's Google identity. The login profile is a combination of OS Login + gcloud SSH keys and POSIX // accounts the user will appear as. Generally, this will only be the OS Login key + account, but `gcloud compute ssh` could create temporary keys and profiles. // The `user` parameter should be the email address of the user. // This will fail the test if there is an error. func GetLoginProfile(t testing.TestingT, user string) *oslogin.LoginProfile { profile, err := GetLoginProfileE(t, user) require.NoErrorf(t, err, "Could not get login profile for user %s", user) return profile } // GetLoginProfileE will retrieve the login profile for a user's Google identity. The login profile is a combination of OS Login + gcloud SSH keys and POSIX // accounts the user will appear as. Generally, this will only be the OS Login key + account, but `gcloud compute ssh` could create temporary keys and profiles. // The `user` parameter should be the email address of the user. func GetLoginProfileE(t testing.TestingT, user string) (*oslogin.LoginProfile, error) { logger.Default.Logf(t, "Getting login profile for user %s", user) ctx := context.Background() service, err := NewOSLoginServiceE(t) if err != nil { return nil, err } name := fmt.Sprintf("users/%s", user) profile, err := service.Users.GetLoginProfile(name).Context(ctx).Do() if err != nil { return nil, err } return profile, nil } // NewOSLoginServiceE creates a new OS Login service, which is used to make OS Login API calls. func NewOSLoginServiceE(t testing.TestingT) (*oslogin.Service, error) { ctx := context.Background() if ts, ok := getStaticTokenSource(); ok { return oslogin.New(oauth2.NewClient(ctx, ts)) } client, err := google.DefaultClient(ctx, compute.CloudPlatformScope) if err != nil { return nil, fmt.Errorf("Failed to get default client: %v", err) } service, err := oslogin.New(client) if err != nil { return nil, err } return service, nil } ================================================ FILE: modules/gcp/oslogin_test.go ================================================ //go:build gcp // +build gcp // NOTE: We use build tags to differentiate GCP testing for better isolation and parallelism when executing our tests. package gcp import ( "context" "fmt" "testing" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/ssh" ) // TestOSLogin groups all OS Login tests that mutate SSH keys for the same user. // These tests cannot run in parallel with each other because Google's OS Login API // returns "409: Multiple concurrent mutations" errors when multiple operations // modify the same user's SSH keys simultaneously. // // By grouping them in a single test function with subtests (without t.Parallel()), // we ensure they run sequentially while still allowing other GCP tests to run in parallel. func TestOSLogin(t *testing.T) { t.Parallel() // This test can run in parallel with OTHER GCP tests // Clean up any stale SSH keys from previous test runs to avoid // "Login profile size exceeds 32 KiB" errors. user := GetGoogleIdentityEmailEnvVar(t) purgeAllSSHKeys(t, user) // Subtests run sequentially (no t.Parallel() on subtests) to avoid 409 conflicts t.Run("ImportSSHKey", func(t *testing.T) { keyPair := ssh.GenerateRSAKeyPair(t, 2048) key := keyPair.PublicKey user := GetGoogleIdentityEmailEnvVar(t) defer DeleteSSHKey(t, user, key) ImportSSHKey(t, user, key) }) t.Run("ImportProjectSSHKey", func(t *testing.T) { keyPair := ssh.GenerateRSAKeyPair(t, 2048) key := keyPair.PublicKey user := GetGoogleIdentityEmailEnvVar(t) projectID := GetGoogleProjectIDFromEnvVar(t) defer DeleteSSHKey(t, user, key) ImportProjectSSHKey(t, user, key, projectID) }) t.Run("GetLoginProfile", func(t *testing.T) { user := GetGoogleIdentityEmailEnvVar(t) GetLoginProfile(t, user) }) t.Run("SetOSLoginKey", func(t *testing.T) { keyPair := ssh.GenerateRSAKeyPair(t, 2048) key := keyPair.PublicKey user := GetGoogleIdentityEmailEnvVar(t) defer DeleteSSHKey(t, user, key) ImportSSHKey(t, user, key) loginProfile := GetLoginProfile(t, user) found := false for _, v := range loginProfile.SshPublicKeys { if key == v.Key { found = true } } if found != true { t.Fatalf("Did not find key in login profile for user %s", user) } }) } // purgeAllSSHKeys deletes all SSH keys from the user's OS Login profile. // This prevents "Login profile size exceeds 32 KiB" errors caused by // stale keys accumulating from previous test runs. func purgeAllSSHKeys(t *testing.T, user string) { profile, err := GetLoginProfileE(t, user) if err != nil { t.Logf("Warning: could not get login profile to purge keys: %v", err) return } if len(profile.SshPublicKeys) == 0 { return } logger.Default.Logf(t, "Purging %d stale SSH keys from OS Login profile for user %s", len(profile.SshPublicKeys), user) service, err := NewOSLoginServiceE(t) if err != nil { t.Logf("Warning: could not create OS Login service to purge keys: %v", err) return } ctx := context.Background() for fingerprint := range profile.SshPublicKeys { path := fmt.Sprintf("users/%s/sshPublicKeys/%s", user, fingerprint) if _, err := service.Users.SshPublicKeys.Delete(path).Context(ctx).Do(); err != nil { t.Logf("Warning: could not delete SSH key %s: %v", fingerprint, err) } } } ================================================ FILE: modules/gcp/provider.go ================================================ package gcp import ( "github.com/gruntwork-io/terratest/modules/environment" "github.com/gruntwork-io/terratest/modules/testing" ) var credsEnvVars = []string{ "GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CREDENTIALS", "GOOGLE_CLOUD_KEYFILE_JSON", "GCLOUD_KEYFILE_JSON", "GOOGLE_USE_DEFAULT_CREDENTIALS", } var projectEnvVars = []string{ "GOOGLE_PROJECT", "GOOGLE_CLOUD_PROJECT", "GOOGLE_CLOUD_PROJECT_ID", "GCLOUD_PROJECT", "CLOUDSDK_CORE_PROJECT", } var regionEnvVars = []string{ "GOOGLE_REGION", "GCLOUD_REGION", "CLOUDSDK_COMPUTE_REGION", } var googleIdentityEmailEnvVars = []string{ "GOOGLE_IDENTITY_EMAIL", } // GetGoogleCredentialsFromEnvVar returns the Credentials for use with testing. func GetGoogleCredentialsFromEnvVar(t testing.TestingT) string { return environment.GetFirstNonEmptyEnvVarOrEmptyString(t, credsEnvVars) } // GetGoogleProjectIDFromEnvVar returns the Project Id for use with testing. func GetGoogleProjectIDFromEnvVar(t testing.TestingT) string { return environment.GetFirstNonEmptyEnvVarOrFatal(t, projectEnvVars) } // GetGoogleRegionFromEnvVar returns the Region for use with testing. func GetGoogleRegionFromEnvVar(t testing.TestingT) string { return environment.GetFirstNonEmptyEnvVarOrFatal(t, regionEnvVars) } // GetGoogleIdentityEmailEnvVar returns a Google identity (user) for use with testing. func GetGoogleIdentityEmailEnvVar(t testing.TestingT) string { return environment.GetFirstNonEmptyEnvVarOrFatal(t, googleIdentityEmailEnvVars) } ================================================ FILE: modules/gcp/region.go ================================================ package gcp import ( "context" "os" "strings" "github.com/gruntwork-io/terratest/modules/collections" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/testing" "google.golang.org/api/compute/v1" ) // You can set this environment variable to force Terratest to use a specific Region rather than a random one. This is // convenient when iterating locally. const regionOverrideEnvVarName = "TERRATEST_GCP_REGION" // You can set this environment variable to force Terratest to use a specific Zone rather than a random one. This is // convenient when iterating locally. const zoneOverrideEnvVarName = "TERRATEST_GCP_ZONE" // Some GCP API calls require a GCP Region. We typically require the user to set one explicitly, but in some // cases, this doesn't make sense (e.g., for fetching the list of regions in an account), so for those cases, we use // this Region as a default. const defaultRegion = "us-west1" // Some GCP API calls require a GCP Zone. We typically require the user to set one explicitly, but in some // cases, this doesn't make sense (e.g., for fetching the list of regions in an account), so for those cases, we use // this Zone as a default. const defaultZone = "us-west1-b" // GetRandomRegion gets a randomly chosen GCP Region. If approvedRegions is not empty, this will be a Region from the approvedRegions // list; otherwise, this method will fetch the latest list of regions from the GCP APIs and pick one of those. If // forbiddenRegions is not empty, this method will make sure the returned Region is not in the forbiddenRegions list. func GetRandomRegion(t testing.TestingT, projectID string, approvedRegions []string, forbiddenRegions []string) string { region, err := GetRandomRegionE(t, projectID, approvedRegions, forbiddenRegions) if err != nil { t.Fatal(err) } return region } // GetRandomRegionE gets a randomly chosen GCP Region. If approvedRegions is not empty, this will be a Region from the approvedRegions // list; otherwise, this method will fetch the latest list of regions from the GCP APIs and pick one of those. If // forbiddenRegions is not empty, this method will make sure the returned Region is not in the forbiddenRegions list. func GetRandomRegionE(t testing.TestingT, projectID string, approvedRegions []string, forbiddenRegions []string) (string, error) { regionFromEnvVar := os.Getenv(regionOverrideEnvVarName) if regionFromEnvVar != "" { logger.Default.Logf(t, "Using GCP Region %s from environment variable %s", regionFromEnvVar, regionOverrideEnvVarName) return regionFromEnvVar, nil } regionsToPickFrom := approvedRegions if len(regionsToPickFrom) == 0 { allRegions, err := GetAllGcpRegionsE(t, projectID) if err != nil { return "", err } regionsToPickFrom = allRegions } regionsToPickFrom = collections.ListSubtract(regionsToPickFrom, forbiddenRegions) region := random.RandomString(regionsToPickFrom) logger.Default.Logf(t, "Using Region %s", region) return region, nil } // GetRandomZone gets a randomly chosen GCP Zone. If approvedRegions is not empty, this will be a Zone from the approvedZones // list; otherwise, this method will fetch the latest list of Zones from the GCP APIs and pick one of those. If // forbiddenZones is not empty, this method will make sure the returned Region is not in the forbiddenZones list. func GetRandomZone(t testing.TestingT, projectID string, approvedZones []string, forbiddenZones []string, forbiddenRegions []string) string { zone, err := GetRandomZoneE(t, projectID, approvedZones, forbiddenZones, forbiddenRegions) if err != nil { t.Fatal(err) } return zone } // GetRandomZoneE gets a randomly chosen GCP Zone. If approvedRegions is not empty, this will be a Zone from the approvedZones // list; otherwise, this method will fetch the latest list of Zones from the GCP APIs and pick one of those. If // forbiddenZones is not empty, this method will make sure the returned Region is not in the forbiddenZones list. func GetRandomZoneE(t testing.TestingT, projectID string, approvedZones []string, forbiddenZones []string, forbiddenRegions []string) (string, error) { zoneFromEnvVar := os.Getenv(zoneOverrideEnvVarName) if zoneFromEnvVar != "" { logger.Default.Logf(t, "Using GCP Zone %s from environment variable %s", zoneFromEnvVar, zoneOverrideEnvVarName) return zoneFromEnvVar, nil } zonesToPickFrom := approvedZones if len(zonesToPickFrom) == 0 { allZones, err := GetAllGcpZonesE(t, projectID) if err != nil { return "", err } zonesToPickFrom = allZones } zonesToPickFrom = collections.ListSubtract(zonesToPickFrom, forbiddenZones) var zonesToPickFromFiltered []string for _, zone := range zonesToPickFrom { if !isInRegions(zone, forbiddenRegions) { zonesToPickFromFiltered = append(zonesToPickFromFiltered, zone) } } zone := random.RandomString(zonesToPickFromFiltered) return zone, nil } // GetRandomZoneForRegion gets a randomly chosen GCP Zone in the given Region. func GetRandomZoneForRegion(t testing.TestingT, projectID string, region string) string { zone, err := GetRandomZoneForRegionE(t, projectID, region) if err != nil { t.Fatal(err) } return zone } // GetRandomZoneForRegionE gets a randomly chosen GCP Zone in the given Region. func GetRandomZoneForRegionE(t testing.TestingT, projectID string, region string) (string, error) { zoneFromEnvVar := os.Getenv(zoneOverrideEnvVarName) if zoneFromEnvVar != "" { logger.Default.Logf(t, "Using GCP Zone %s from environment variable %s", zoneFromEnvVar, zoneOverrideEnvVarName) return zoneFromEnvVar, nil } allZones, err := GetAllGcpZonesE(t, projectID) if err != nil { return "", err } zonesToPickFrom := []string{} for _, zone := range allZones { if strings.Contains(zone, region) { zonesToPickFrom = append(zonesToPickFrom, zone) } } zone := random.RandomString(zonesToPickFrom) logger.Default.Logf(t, "Using Zone %s", zone) return zone, nil } // GetAllGcpRegions gets the list of GCP regions available in this account. func GetAllGcpRegions(t testing.TestingT, projectID string) []string { out, err := GetAllGcpRegionsE(t, projectID) if err != nil { t.Fatal(err) } return out } // GetAllGcpRegionsE gets the list of GCP regions available in this account. func GetAllGcpRegionsE(t testing.TestingT, projectID string) ([]string, error) { logger.Default.Logf(t, "Looking up all GCP regions available in this account") // Note that NewComputeServiceE creates a context, but it appears to be empty so we keep the code simpler by // creating a new one here ctx := context.Background() service, err := NewComputeServiceE(t) if err != nil { return nil, err } req := service.Regions.List(projectID) regions := []string{} err = req.Pages(ctx, func(page *compute.RegionList) error { for _, region := range page.Items { regions = append(regions, region.Name) } return nil }) if err != nil { return nil, err } return regions, nil } // GetAllGcpZones gets the list of GCP Zones available in this account. func GetAllGcpZones(t testing.TestingT, projectID string) []string { out, err := GetAllGcpZonesE(t, projectID) if err != nil { t.Fatal(err) } return out } // GetAllGcpZonesE gets the list of GCP Zones available in this account. func GetAllGcpZonesE(t testing.TestingT, projectID string) ([]string, error) { // Note that NewComputeServiceE creates a context, but it appears to be empty so we keep the code simpler by // creating a new one here ctx := context.Background() service, err := NewComputeServiceE(t) if err != nil { return nil, err } req := service.Zones.List(projectID) zones := []string{} err = req.Pages(ctx, func(page *compute.ZoneList) error { for _, zone := range page.Items { zones = append(zones, zone.Name) } return nil }) if err != nil { return nil, err } return zones, nil } // Given a GCP Zone URL formatted like https://www.googleapis.com/compute/v1/projects/project-123456/zones/asia-east1-b, // return "asia-east1-b". // Todo: Improve sanity checking on this function by using a RegEx with capture groups func ZoneUrlToZone(zoneUrl string) string { tokens := strings.Split(zoneUrl, "/") return tokens[len(tokens)-1] } // Given a GCP Zone URL formatted like https://www.googleapis.com/compute/v1/projects/project-123456/regions/southamerica-east1, // return "southamerica-east1". // Todo: Improve sanity checking on this function by using a RegEx with capture groups func RegionUrlToRegion(zoneUrl string) string { tokens := strings.Split(zoneUrl, "/") return tokens[len(tokens)-1] } // Returns true if the given zone is in any of the given regions func isInRegions(zone string, regions []string) bool { for _, region := range regions { if isInRegion(zone, region) { return true } } return false } // Returns true if the given zone is in the given region func isInRegion(zone string, region string) bool { return strings.Contains(zone, region) } ================================================ FILE: modules/gcp/region_test.go ================================================ //go:build gcp // +build gcp // NOTE: We use build tags to differentiate GCP testing for better isolation and parallelism when executing our tests. package gcp import ( "strings" "testing" "github.com/stretchr/testify/assert" ) func TestGetRandomRegion(t *testing.T) { t.Parallel() projectID := GetGoogleProjectIDFromEnvVar(t) randomRegion := GetRandomRegion(t, projectID, nil, nil) assertLooksLikeRegionName(t, randomRegion) } func TestGetRandomZone(t *testing.T) { t.Parallel() projectID := GetGoogleProjectIDFromEnvVar(t) randomZone := GetRandomZone(t, projectID, nil, nil, nil) assertLooksLikeZoneName(t, randomZone) } func TestGetRandomRegionExcludesForbiddenRegions(t *testing.T) { t.Parallel() projectID := GetGoogleProjectIDFromEnvVar(t) approvedRegions := []string{"asia-east1", "asia-northeast1", "asia-south1", "asia-southeast1", "australia-southeast1", "europe-north1", "europe-west1", "europe-west2", "europe-west3", "northamerica-northeast1", "southamerica-east1", "us-central1", "us-east1", "us-east4", "us-west2"} forbiddenRegions := []string{"europe-west4", "us-west1"} for i := 0; i < 1000; i++ { randomRegion := GetRandomRegion(t, projectID, approvedRegions, forbiddenRegions) assert.NotContains(t, forbiddenRegions, randomRegion) } } func TestGetRandomZoneExcludesForbiddenZones(t *testing.T) { t.Parallel() projectID := GetGoogleProjectIDFromEnvVar(t) approvedZones := []string{"us-east1-b", "us-east1-c", "us-east1-d", "us-east4-a", "us-east4-b", "us-east4-c", "us-west2-a", "us-west2-b", "us-west2-c", "us-central1-f", "europe-west2-b"} forbiddenZones := []string{"us-east1-a", "europe-west1-a", "europe-west2-a", "europe-west2-c"} for i := 0; i < 1000; i++ { randomZone := GetRandomZone(t, projectID, approvedZones, forbiddenZones, nil) assert.NotContains(t, forbiddenZones, randomZone) } } func TestGetRandomZoneExcludesForbiddenRegions(t *testing.T) { t.Parallel() projectID := GetGoogleProjectIDFromEnvVar(t) approvedZones := []string{"us-east1-b", "us-east1-c", "us-east1-d", "us-east4-a", "us-east4-b", "us-east4-c", "us-west2-a", "us-west2-b", "us-west2-c", "us-central1-f", "europe-west2-b"} forbiddenRegions := []string{"europe-west2"} for i := 0; i < 1000; i++ { randomZone := GetRandomZone(t, projectID, approvedZones, nil, forbiddenRegions) for _, forbiddenRegion := range forbiddenRegions { assert.True(t, !isInRegion(randomZone, forbiddenRegion), "Expected that selected zone %s would not be in region %s, but it is.", randomZone, forbiddenRegion) } } } func TestGetAllGcpRegions(t *testing.T) { t.Parallel() projectID := GetGoogleProjectIDFromEnvVar(t) regions := GetAllGcpRegions(t, projectID) // The typical account had access to 17 regions as of August, 2018: https://cloud.google.com/compute/docs/regions-zones/ assert.True(t, len(regions) >= 17, "Number of regions: %d", len(regions)) for _, region := range regions { assertLooksLikeRegionName(t, region) } } func TestGetAllGcpZones(t *testing.T) { t.Parallel() projectID := GetGoogleProjectIDFromEnvVar(t) zones := GetAllGcpZones(t, projectID) // The typical account had access to 52 zones as of August, 2018: https://cloud.google.com/compute/docs/regions-zones/ assert.True(t, len(zones) >= 52, "Number of zones: %d", len(zones)) for _, zone := range zones { assertLooksLikeZoneName(t, zone) } } func TestGetRandomZoneForRegion(t *testing.T) { t.Parallel() projectID := GetGoogleProjectIDFromEnvVar(t) regions := []string{ "us-west1", "us-west2", "us-central1", } for _, region := range regions { zone, err := GetRandomZoneForRegionE(t, projectID, region) if err != nil { t.Fatal(err) } assert.True(t, strings.Contains(zone, region), "Expected zone %s to be in region %s", zone, region) } } func TestGetInRegion(t *testing.T) { t.Parallel() testData := []struct { zone string region string expected bool }{ {"us-west2a", "us-west2", true}, {"us-west2b", "us-west2", true}, {"us-west2a", "us-east1", false}, } for _, td := range testData { actual := isInRegion(td.zone, td.region) assert.Equal(t, td.expected, actual, "Expected %t for isInRegion(%s, %s) but got %t", td.expected, td.zone, td.region, actual) } } func TestGetInRegions(t *testing.T) { t.Parallel() testData := []struct { zone string regions []string expected bool }{ {"us-west2a", []string{"us-west2", "us-east1"}, true}, {"us-west2b", []string{"us-west2", "us-east1"}, true}, {"us-west2a", []string{"us-west2", "us-east1"}, true}, {"us-west2a", []string{"us-east1", "europe-west1"}, false}, } for _, td := range testData { actual := isInRegions(td.zone, td.regions) assert.Equal(t, td.expected, actual, "Expected %t for isInRegions(%s, %v) but got %t", td.expected, td.zone, td.regions, actual) } } func assertLooksLikeRegionName(t *testing.T, regionName string) { assert.Regexp(t, "[a-z]+-[a-z]+[[:digit:]]+", regionName) } func assertLooksLikeZoneName(t *testing.T, zoneName string) { assert.Regexp(t, "[a-z]+-[a-z]+[[:digit:]]+-[a-z]{1}", zoneName) } ================================================ FILE: modules/gcp/static_token.go ================================================ package gcp import ( "os" "golang.org/x/oauth2" ) func getStaticTokenSource() (oauth2.TokenSource, bool) { v, ok := os.LookupEnv("GOOGLE_OAUTH_ACCESS_TOKEN") if ok { return oauth2.StaticTokenSource(&oauth2.Token{AccessToken: v}), true } return nil, false } ================================================ FILE: modules/gcp/static_token_test.go ================================================ //go:build gcp // +build gcp package gcp import ( "context" "strings" "testing" "github.com/gruntwork-io/terratest/modules/random" "github.com/stretchr/testify/require" "golang.org/x/oauth2/google" ) func TestStaticTokenClient(t *testing.T) { ctx := context.Background() creds, err := google.FindDefaultCredentials(ctx, "https://www.googleapis.com/auth/cloud-platform") require.NoError(t, err) token, err := creds.TokenSource.Token() require.NoError(t, err) projectID := GetGoogleProjectIDFromEnvVar(t) // we poison the default client instantiation with invalid file so that if it is used, it fails t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "non-existent-credentials.json") _, err = NewCloudBuildServiceE(t) require.Error(t, err) _, err = NewComputeServiceE(t) require.Error(t, err) _, err = newGCRAuther() require.Error(t, err) _, err = NewOSLoginServiceE(t) require.Error(t, err) _, err = newStorageClient() require.Error(t, err) // now we instantiate client with oauth2 token // and run several function to make sure the new client is correctly configured with access token t.Setenv("GOOGLE_OAUTH_ACCESS_TOKEN", token.AccessToken) GetAllGcpRegions(t, projectID) GetBuilds(t, projectID) GetLoginProfile(t, GetGoogleIdentityEmailEnvVar(t)) _, err = newGCRAuther() require.NoError(t, err) bucket := "gruntwork-terratest-" + strings.ToLower(random.UniqueId()) CreateStorageBucket(t, projectID, bucket, nil) defer DeleteStorageBucket(t, bucket) } ================================================ FILE: modules/gcp/storage.go ================================================ package gcp import ( "context" "fmt" "io" "cloud.google.com/go/storage" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/testing" "google.golang.org/api/iterator" ) // CreateStorageBucket creates a Google Cloud bucket with the given BucketAttrs. Note that Google Storage bucket names must be globally unique. func CreateStorageBucket(t testing.TestingT, projectID string, name string, attr *storage.BucketAttrs) { err := CreateStorageBucketE(t, projectID, name, attr) if err != nil { t.Fatal(err) } } // CreateStorageBucketE creates a Google Cloud bucket with the given BucketAttrs. Note that Google Storage bucket names must be globally unique. func CreateStorageBucketE(t testing.TestingT, projectID string, name string, attr *storage.BucketAttrs) error { logger.Default.Logf(t, "Creating bucket %s", name) ctx := context.Background() // Creates a client. client, err := newStorageClient() if err != nil { return err } // Creates a Bucket instance. bucket := client.Bucket(name) // Creates the new bucket. return bucket.Create(ctx, projectID, attr) } // DeleteStorageBucket destroys the Google Storage bucket. func DeleteStorageBucket(t testing.TestingT, name string) { err := DeleteStorageBucketE(t, name) if err != nil { t.Fatal(err) } } // DeleteStorageBucketE destroys the Google Cloud Storage bucket in the given region with the given name. func DeleteStorageBucketE(t testing.TestingT, name string) error { logger.Default.Logf(t, "Deleting bucket %s", name) ctx := context.Background() client, err := newStorageClient() if err != nil { return err } return client.Bucket(name).Delete(ctx) } // ReadBucketObject reads an object from the given Storage Bucket and returns its contents. func ReadBucketObject(t testing.TestingT, bucketName string, filePath string) io.Reader { out, err := ReadBucketObjectE(t, bucketName, filePath) if err != nil { t.Fatal(err) } return out } // ReadBucketObjectE reads an object from the given Storage Bucket and returns its contents. func ReadBucketObjectE(t testing.TestingT, bucketName string, filePath string) (io.Reader, error) { logger.Default.Logf(t, "Reading object from bucket %s using path %s", bucketName, filePath) ctx := context.Background() client, err := newStorageClient() if err != nil { return nil, err } bucket := client.Bucket(bucketName) r, err := bucket.Object(filePath).NewReader(ctx) if err != nil { return nil, err } return r, nil } // WriteBucketObject writes an object to the given Storage Bucket and returns its URL. func WriteBucketObject(t testing.TestingT, bucketName string, filePath string, body io.Reader, contentType string) string { out, err := WriteBucketObjectE(t, bucketName, filePath, body, contentType) if err != nil { t.Fatal(err) } return out } // WriteBucketObjectE writes an object to the given Storage Bucket and returns its URL. func WriteBucketObjectE(t testing.TestingT, bucketName string, filePath string, body io.Reader, contentType string) (string, error) { // set a default content type if contentType == "" { contentType = "application/octet-stream" } logger.Default.Logf(t, "Writing object to bucket %s using path %s and content type %s", bucketName, filePath, contentType) ctx := context.Background() client, err := newStorageClient() if err != nil { return "", err } w := client.Bucket(bucketName).Object(filePath).NewWriter(ctx) w.ContentType = contentType // Don't set any ACL or cache control properties for now //w.ACL = []storage.ACLRule{{Entity: storage.AllAuthenticatedUsers, Role: storage.RoleReader}} // set a default cache control (1 day) //w.CacheControl = "public, max-age=86400" if _, err := io.Copy(w, body); err != nil { return "", err } if err := w.Close(); err != nil { return "", err } const publicURL = "https://storage.googleapis.com/%s/%s" return fmt.Sprintf(publicURL, bucketName, filePath), nil } // EmptyStorageBucket removes the contents of a storage bucket with the given name. func EmptyStorageBucket(t testing.TestingT, name string) { err := EmptyStorageBucketE(t, name) if err != nil { t.Fatal(err) } } // EmptyStorageBucketE removes the contents of a storage bucket with the given name. func EmptyStorageBucketE(t testing.TestingT, name string) error { logger.Default.Logf(t, "Emptying storage bucket %s", name) ctx := context.Background() client, err := newStorageClient() if err != nil { return err } // List all objects in the bucket // // TODO - we should really do a bulk delete call here, but I couldn't find // anything in the SDK. bucket := client.Bucket(name) it := bucket.Objects(ctx, nil) for { objectAttrs, err := it.Next() if err == iterator.Done { break } if err != nil { return err } // purge the object logger.Default.Logf(t, "Deleting storage bucket object %s", objectAttrs.Name) if err := bucket.Object(objectAttrs.Name).Delete(ctx); err != nil { return err } } return nil } // AssertStorageBucketExists checks if the given storage bucket exists and fails the test if it does not. func AssertStorageBucketExists(t testing.TestingT, name string) { err := AssertStorageBucketExistsE(t, name) if err != nil { t.Fatal(err) } } // AssertStorageBucketExistsE checks if the given storage bucket exists and returns an error if it does not. func AssertStorageBucketExistsE(t testing.TestingT, name string) error { logger.Default.Logf(t, "Finding bucket %s", name) ctx := context.Background() // Creates a client. client, err := newStorageClient() if err != nil { return err } // Creates a Bucket instance. bucket := client.Bucket(name) // TODO - the code below attempts to determine whether the storage bucket // exists by making a number of API calls, then attempting to // list the contents of the bucket. It was adapted from Google's own integration // tests and should be improved once the appropriate API call is added. // For more info see: https://github.com/GoogleCloudPlatform/google-cloud-go/blob/de879f7be552d57556875b8aaa383bce9396cc8c/storage/integration_test.go#L1231 if _, err := bucket.Attrs(ctx); err != nil { // ErrBucketNotExist return err } it := bucket.Objects(ctx, nil) if _, err := it.Next(); err == storage.ErrBucketNotExist { return err } return nil } func newStorageClient() (*storage.Client, error) { ctx := context.Background() client, err := storage.NewClient(ctx, withOptions()...) if err != nil { return nil, err } return client, nil } ================================================ FILE: modules/gcp/storage_test.go ================================================ //go:build gcp // +build gcp // NOTE: We use build tags to differentiate GCP testing for better isolation and parallelism when executing our tests. package gcp import ( "bytes" "fmt" "strings" "testing" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/random" "github.com/stretchr/testify/require" ) func TestCreateAndDestroyStorageBucket(t *testing.T) { t.Parallel() projectID := GetGoogleProjectIDFromEnvVar(t) id := random.UniqueId() gsBucketName := "gruntwork-terratest-" + strings.ToLower(id) testFilePath := fmt.Sprintf("test-file-%s.txt", random.UniqueId()) testFileBody := "test file text" logger.Logf(t, "Random values selected Bucket Name = %s, Test Filepath: %s\n", gsBucketName, testFilePath) CreateStorageBucket(t, projectID, gsBucketName, nil) defer DeleteStorageBucket(t, gsBucketName) // Write a test file to the storage bucket objectURL := WriteBucketObject(t, gsBucketName, testFilePath, strings.NewReader(testFileBody), "text/plain") logger.Logf(t, "Got URL: %s", objectURL) // Then verify its contents matches the expected result fileReader := ReadBucketObject(t, gsBucketName, testFilePath) buf := new(bytes.Buffer) buf.ReadFrom(fileReader) result := buf.String() require.Equal(t, testFileBody, result) // Empty the storage bucket so we can delete it defer EmptyStorageBucket(t, gsBucketName) } func TestAssertStorageBucketExistsNoFalseNegative(t *testing.T) { t.Parallel() projectID := GetGoogleProjectIDFromEnvVar(t) id := random.UniqueId() gsBucketName := "gruntwork-terratest-" + strings.ToLower(id) logger.Logf(t, "Random values selected Id = %s\n", id) CreateStorageBucket(t, projectID, gsBucketName, nil) defer DeleteStorageBucket(t, gsBucketName) AssertStorageBucketExists(t, gsBucketName) } func TestAssertStorageBucketExistsNoFalsePositive(t *testing.T) { t.Parallel() id := random.UniqueId() gsBucketName := "gruntwork-terratest-" + strings.ToLower(id) logger.Logf(t, "Random values selected Id = %s\n", id) // Don't create a new storage bucket so we can confirm that our function works as expected. err := AssertStorageBucketExistsE(t, gsBucketName) if err == nil { t.Fatalf("Function claimed that the Storage Bucket '%s' exists, but in fact it does not.", gsBucketName) } } ================================================ FILE: modules/git/git.go ================================================ // Package git allows to interact with Git. package git import ( "context" "os" "os/exec" "strings" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // GetCurrentBranchName retrieves the current branch name or an empty string // in case of detached state. Fails the test if an error occurs. // // Deprecated: Use [GetCurrentBranchNameContext] instead, which supports context // cancellation and accepts an explicit working directory rather than relying on // the process working directory. func GetCurrentBranchName(t testing.TestingT) string { return GetCurrentBranchNameContext(t, context.Background(), "") } // GetCurrentBranchNameContext retrieves the current branch name or an empty // string in case of detached state. The dir parameter specifies the working // directory for the git command; if empty, the process working directory is // used. Fails the test if an error occurs. func GetCurrentBranchNameContext(t testing.TestingT, ctx context.Context, dir string) string { out, err := GetCurrentBranchNameContextE(t, ctx, dir) if err != nil { t.Fatal(err) } return out } // GetCurrentBranchNameE retrieves the current branch name or an empty string // in case of detached state. Uses git branch --show-current, which was // introduced in git v2.22. Falls back to git rev-parse for older versions. // // Deprecated: Use [GetCurrentBranchNameContextE] instead, which supports // context cancellation and accepts an explicit working directory rather than // relying on the process working directory. func GetCurrentBranchNameE(t testing.TestingT) (string, error) { return GetCurrentBranchNameContextE(t, context.Background(), "") } // GetCurrentBranchNameContextE retrieves the current branch name or an empty // string in case of detached state. Uses git branch --show-current, which was // introduced in git v2.22. Falls back to git rev-parse for older versions. // The dir parameter specifies the working directory for the git command; if // empty, the process working directory is used. func GetCurrentBranchNameContextE(t testing.TestingT, ctx context.Context, dir string) (string, error) { cmd := exec.CommandContext(ctx, "git", "branch", "--show-current") cmd.Dir = dir bytes, err := cmd.Output() if err != nil { return GetCurrentBranchNameOldContextE(t, ctx, dir) } name := strings.TrimSpace(string(bytes)) if name == "HEAD" { return "", nil } return name, nil } // GetCurrentBranchNameOldE retrieves the current branch name or an empty // string in case of detached state using git rev-parse --abbrev-ref HEAD. // // Deprecated: Use [GetCurrentBranchNameOldContextE] instead, which supports // context cancellation and accepts an explicit working directory rather than // relying on the process working directory. func GetCurrentBranchNameOldE(t testing.TestingT) (string, error) { return GetCurrentBranchNameOldContextE(t, context.Background(), "") } // GetCurrentBranchNameOldContextE retrieves the current branch name or an // empty string in case of detached state using git rev-parse --abbrev-ref HEAD. // This is a fallback for git versions older than v2.22 that lack // git branch --show-current. The dir parameter specifies the working directory // for the git command; if empty, the process working directory is used. func GetCurrentBranchNameOldContextE(t testing.TestingT, ctx context.Context, dir string) (string, error) { cmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD") cmd.Dir = dir bytes, err := cmd.Output() if err != nil { return "", err } name := strings.TrimSpace(string(bytes)) if name == "HEAD" { return "", nil } return name, nil } // GetCurrentGitRef retrieves the current branch name, lightweight // (non-annotated) tag, or exact tag value if the tag points to the current // commit. Fails the test if an error occurs. // // Deprecated: Use [GetCurrentGitRefContext] instead, which supports context // cancellation and accepts an explicit working directory rather than relying on // the process working directory. func GetCurrentGitRef(t testing.TestingT) string { return GetCurrentGitRefContext(t, context.Background(), "") } // GetCurrentGitRefContext retrieves the current branch name, lightweight // (non-annotated) tag, or exact tag value if the tag points to the current // commit. The dir parameter specifies the working directory for the git // command; if empty, the process working directory is used. Fails the test if // an error occurs. func GetCurrentGitRefContext(t testing.TestingT, ctx context.Context, dir string) string { out, err := GetCurrentGitRefContextE(t, ctx, dir) if err != nil { t.Fatal(err) } return out } // GetCurrentGitRefE retrieves the current branch name, lightweight // (non-annotated) tag, or exact tag value if the tag points to the current // commit. // // Deprecated: Use [GetCurrentGitRefContextE] instead, which supports context // cancellation and accepts an explicit working directory rather than relying on // the process working directory. func GetCurrentGitRefE(t testing.TestingT) (string, error) { return GetCurrentGitRefContextE(t, context.Background(), "") } // GetCurrentGitRefContextE retrieves the current branch name, lightweight // (non-annotated) tag, or exact tag value if the tag points to the current // commit. The dir parameter specifies the working directory for the git // command; if empty, the process working directory is used. func GetCurrentGitRefContextE(t testing.TestingT, ctx context.Context, dir string) (string, error) { out, err := GetCurrentBranchNameContextE(t, ctx, dir) if err != nil { return "", err } if out != "" { return out, nil } out, err = GetTagContextE(t, ctx, dir) if err != nil { return "", err } return out, nil } // GetTagE retrieves the lightweight (non-annotated) tag or exact tag value if // the tag points to the current commit. // // Deprecated: Use [GetTagContextE] instead, which supports context // cancellation and accepts an explicit working directory rather than relying on // the process working directory. func GetTagE(t testing.TestingT) (string, error) { return GetTagContextE(t, context.Background(), "") } // GetTagContextE retrieves the lightweight (non-annotated) tag or exact tag // value if the tag points to the current commit. The dir parameter specifies // the working directory for the git command; if empty, the process working // directory is used. func GetTagContextE(t testing.TestingT, ctx context.Context, dir string) (string, error) { cmd := exec.CommandContext(ctx, "git", "describe", "--tags") cmd.Dir = dir bytes, err := cmd.Output() if err != nil { return "", err } return strings.TrimSpace(string(bytes)), nil } // GetRepoRoot retrieves the path to the root directory of the repo. Fails the // test if there is an error. // // Deprecated: Use [GetRepoRootContext] instead, which supports context // cancellation and accepts an explicit working directory rather than relying on // the process working directory. func GetRepoRoot(t testing.TestingT) string { return GetRepoRootContext(t, context.Background(), "") } // GetRepoRootContext retrieves the path to the root directory of the repo. The // dir parameter specifies the working directory for the git command; if empty, // the process working directory is used. Fails the test if there is an error. func GetRepoRootContext(t testing.TestingT, ctx context.Context, dir string) string { out, err := GetRepoRootContextE(t, ctx, dir) require.NoError(t, err) return out } // GetRepoRootE retrieves the path to the root directory of the repo. // // Deprecated: Use [GetRepoRootContextE] instead, which supports context // cancellation and accepts an explicit working directory rather than relying on // the process working directory. func GetRepoRootE(t testing.TestingT) (string, error) { return GetRepoRootContextE(t, context.Background(), "") } // GetRepoRootContextE retrieves the path to the root directory of the repo. // The dir parameter specifies the working directory for the git command; if // empty, the process working directory is used. func GetRepoRootContextE(t testing.TestingT, ctx context.Context, dir string) (string, error) { if dir == "" { cwd, err := os.Getwd() if err != nil { return "", err } dir = cwd } return GetRepoRootForDirContextE(t, ctx, dir) } // GetRepoRootForDir retrieves the path to the root directory of the repo in // which dir resides. Fails the test if there is an error. // // Deprecated: Use [GetRepoRootForDirContext] instead, which supports context // cancellation. func GetRepoRootForDir(t testing.TestingT, dir string) string { return GetRepoRootForDirContext(t, context.Background(), dir) } // GetRepoRootForDirContext retrieves the path to the root directory of the // repo in which dir resides. Fails the test if there is an error. func GetRepoRootForDirContext(t testing.TestingT, ctx context.Context, dir string) string { out, err := GetRepoRootForDirContextE(t, ctx, dir) require.NoError(t, err) return out } // GetRepoRootForDirE retrieves the path to the root directory of the repo in // which dir resides. // // Deprecated: Use [GetRepoRootForDirContextE] instead, which supports context // cancellation. func GetRepoRootForDirE(t testing.TestingT, dir string) (string, error) { return GetRepoRootForDirContextE(t, context.Background(), dir) } // GetRepoRootForDirContextE retrieves the path to the root directory of the // repo in which dir resides. func GetRepoRootForDirContextE(t testing.TestingT, ctx context.Context, dir string) (string, error) { cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel") cmd.Dir = dir bytes, err := cmd.Output() if err != nil { return "", err } return strings.TrimSpace(string(bytes)), nil } ================================================ FILE: modules/git/git_test.go ================================================ package git_test import ( "os" "os/exec" "path/filepath" "testing" "github.com/gruntwork-io/terratest/modules/git" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) //nolint:paralleltest,tparallel // subtests mutate shared git state (checkout) and must run sequentially func TestGitRefChecks(t *testing.T) { t.Parallel() tmpdir := t.TempDir() gitWorkDir := filepath.Join(tmpdir, "terratest") url := "https://github.com/gruntwork-io/terratest.git" err := exec.CommandContext(t.Context(), "git", "clone", url, gitWorkDir).Run() require.NoError(t, err) t.Run("GetCurrentBranchNameReturnsBranchName", func(t *testing.T) { err := exec.CommandContext(t.Context(), "git", "-C", gitWorkDir, "checkout", "main").Run() require.NoError(t, err) name := git.GetCurrentBranchNameContext(t, t.Context(), gitWorkDir) assert.Equal(t, "main", name) }) t.Run("GetCurrentBranchNameReturnsEmptyForDetachedState", func(t *testing.T) { err := exec.CommandContext(t.Context(), "git", "-C", gitWorkDir, "checkout", "v0.0.1").Run() require.NoError(t, err) name := git.GetCurrentBranchNameContext(t, t.Context(), gitWorkDir) assert.Empty(t, name) }) t.Run("GetCurrentRefReturnsBranchName", func(t *testing.T) { err := exec.CommandContext(t.Context(), "git", "-C", gitWorkDir, "checkout", "main").Run() require.NoError(t, err) name := git.GetCurrentGitRefContext(t, t.Context(), gitWorkDir) assert.Equal(t, "main", name) }) t.Run("GetCurrentRefReturnsTagValue", func(t *testing.T) { err := exec.CommandContext(t.Context(), "git", "-C", gitWorkDir, "checkout", "v0.0.1").Run() require.NoError(t, err) name := git.GetCurrentGitRefContext(t, t.Context(), gitWorkDir) assert.Equal(t, "v0.0.1", name) }) t.Run("GetCurrentRefReturnsLightTagValue", func(t *testing.T) { err := exec.CommandContext(t.Context(), "git", "-C", gitWorkDir, "checkout", "58d3ea8").Run() require.NoError(t, err) name := git.GetCurrentGitRefContext(t, t.Context(), gitWorkDir) assert.Equal(t, "v0.0.1-1-g58d3ea8f", name) }) } func TestGetRepoRoot(t *testing.T) { t.Parallel() cwd, err := os.Getwd() require.NoError(t, err) expectedRepoRoot, err := filepath.Abs(filepath.Join(cwd, "..", "..")) require.NoError(t, err) repoRoot := git.GetRepoRootContext(t, t.Context(), cwd) assert.Equal(t, expectedRepoRoot, repoRoot) } ================================================ FILE: modules/helm/cmd.go ================================================ package helm import ( "slices" "github.com/gruntwork-io/go-commons/errors" "github.com/gruntwork-io/terratest/modules/shell" "github.com/gruntwork-io/terratest/modules/testing" ) // getCommonArgs extracts common helm options. In this case, these are: // - kubeconfig path // - kubeconfig context // - helm home path func getCommonArgs(options *Options, args ...string) []string { if options.KubectlOptions != nil && options.KubectlOptions.ContextName != "" { args = append(args, "--kube-context", options.KubectlOptions.ContextName) } if options.KubectlOptions != nil && options.KubectlOptions.ConfigPath != "" { args = append(args, "--kubeconfig", options.KubectlOptions.ConfigPath) } if options.HomePath != "" { args = append(args, "--home", options.HomePath) } return args } // getNamespaceArgs returns the args to append for the namespace, if set in the helm Options struct. func getNamespaceArgs(options *Options) []string { if options.KubectlOptions != nil && options.KubectlOptions.Namespace != "" { return []string{"--namespace", options.KubectlOptions.Namespace} } return []string{} } // getValuesArgsE computes the args to pass in for setting values func getValuesArgsE(t testing.TestingT, options *Options, args ...string) ([]string, error) { args = append(args, formatSetValuesAsArgs(options.SetValues, "--set")...) args = append(args, formatSetValuesAsArgs(options.SetStrValues, "--set-string")...) args = append(args, formatSetValuesAsArgs(options.SetJsonValues, "--set-json")...) valuesFilesArgs, err := formatValuesFilesAsArgsE(t, options.ValuesFiles) if err != nil { return args, errors.WithStackTrace(err) } args = append(args, valuesFilesArgs...) setFilesArgs, err := formatSetFilesAsArgsE(t, options.SetFiles) if err != nil { return args, errors.WithStackTrace(err) } args = append(args, setFilesArgs...) return args, nil } // RunHelmCommandAndGetOutputE runs helm with the given arguments and options and returns combined, interleaved stdout/stderr. func RunHelmCommandAndGetOutputE(t testing.TestingT, options *Options, cmd string, additionalArgs ...string) (string, error) { helmCmd := prepareHelmCommand(t, options, cmd, additionalArgs...) return shell.RunCommandAndGetOutputE(t, helmCmd) } // RunHelmCommandAndGetStdOutE runs helm with the given arguments and options and returns stdout. func RunHelmCommandAndGetStdOutE(t testing.TestingT, options *Options, cmd string, additionalArgs ...string) (string, error) { helmCmd := prepareHelmCommand(t, options, cmd, additionalArgs...) return shell.RunCommandAndGetStdOutE(t, helmCmd) } // RunHelmCommandAndGetStdOutErrE runs helm with the given arguments and options and returns stdout and stderr separately. func RunHelmCommandAndGetStdOutErrE(t testing.TestingT, options *Options, cmd string, additionalArgs ...string) (string, string, error) { helmCmd := prepareHelmCommand(t, options, cmd, additionalArgs...) return shell.RunCommandAndGetStdOutErrE(t, helmCmd) } func prepareHelmCommand(t testing.TestingT, options *Options, cmd string, additionalArgs ...string) shell.Command { args := []string{cmd} args = getCommonArgs(options, args...) // name space arg only append if it is not there if !slices.Contains(additionalArgs, "--namespace") { args = append(args, getNamespaceArgs(options)...) } args = append(args, additionalArgs...) helmCmd := shell.Command{ Command: "helm", Args: args, WorkingDir: ".", Env: options.EnvVars, Logger: options.Logger, } return helmCmd } ================================================ FILE: modules/helm/cmd_test.go ================================================ package helm import ( "testing" "github.com/gruntwork-io/terratest/modules/k8s" "github.com/gruntwork-io/terratest/modules/logger" "github.com/stretchr/testify/assert" ) func TestPrepareHelmCommand(t *testing.T) { t.Parallel() options := &Options{ KubectlOptions: &k8s.KubectlOptions{ Namespace: "test-namespace", }, EnvVars: map[string]string{"SampleEnv": "test_value"}, Logger: logger.Default, } t.Run("command without additional args", func(t *testing.T) { cmd := prepareHelmCommand(t, options, "install") assert.Equal(t, "helm", cmd.Command) assert.Contains(t, cmd.Args, "install") assert.Contains(t, cmd.Args, "--namespace") assert.Contains(t, cmd.Args, "test-namespace") assert.Equal(t, ".", cmd.WorkingDir) assert.Equal(t, options.EnvVars, cmd.Env) assert.Equal(t, options.Logger, cmd.Logger) }) t.Run("Command with additional args", func(t *testing.T) { cmd := prepareHelmCommand(t, options, "upgrade", "--install", "my-release", "my-chart") assert.Equal(t, "helm", cmd.Command) assert.Contains(t, cmd.Args, "upgrade") assert.Contains(t, cmd.Args, "--install") assert.Contains(t, cmd.Args, "my-release") assert.Contains(t, cmd.Args, "my-chart") assert.Contains(t, cmd.Args, "--namespace") assert.Contains(t, cmd.Args, "test-namespace") }) t.Run("Command with namespace in additional args", func(t *testing.T) { cmd := prepareHelmCommand(t, options, "install", "--namespace", "custom-namespace") assert.Equal(t, "helm", cmd.Command) assert.Contains(t, cmd.Args, "install") assert.Contains(t, cmd.Args, "--namespace") assert.Contains(t, cmd.Args, "custom-namespace") assert.NotContains(t, cmd.Args, "test-namespace") }) } ================================================ FILE: modules/helm/delete.go ================================================ package helm import ( "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // Delete will delete the provided release from Tiller. If you set purge to true, Tiller will delete the release object // as well so that the release name can be reused. This will fail the test if there is an error. func Delete(t testing.TestingT, options *Options, releaseName string, purge bool) { require.NoError(t, DeleteE(t, options, releaseName, purge)) } // DeleteE will delete the provided release from Tiller. If you set purge to true, Tiller will delete the release object // as well so that the release name can be reused. func DeleteE(t testing.TestingT, options *Options, releaseName string, purge bool) error { args := []string{} if !purge { args = append(args, "--keep-history") } if options.ExtraArgs != nil { if deleteArgs, ok := options.ExtraArgs["delete"]; ok { args = append(args, deleteArgs...) } } args = append(args, releaseName) _, err := RunHelmCommandAndGetOutputE(t, options, "delete", args...) return err } ================================================ FILE: modules/helm/errors.go ================================================ package helm import ( "fmt" ) // ValuesFileNotFoundError is returned when a provided values file input is not found on the host path. type ValuesFileNotFoundError struct { Path string } func (err ValuesFileNotFoundError) Error() string { return fmt.Sprintf("Could not resolve values file %s", err.Path) } // SetFileNotFoundError is returned when a provided set file input is not found on the host path. type SetFileNotFoundError struct { Path string } func (err SetFileNotFoundError) Error() string { return fmt.Sprintf("Could not resolve set file path %s", err.Path) } // TemplateFileNotFoundError is returned when a provided template file input is not found in the chart type TemplateFileNotFoundError struct { Path string ChartDir string } func (err TemplateFileNotFoundError) Error() string { return fmt.Sprintf("Could not resolve template file %s relative to chart path %s", err.Path, err.ChartDir) } // ChartNotFoundError is returned when a provided chart dir is not found type ChartNotFoundError struct { Path string } func (err ChartNotFoundError) Error() string { return fmt.Sprintf("Could not chart path %s", err.Path) } ================================================ FILE: modules/helm/format.go ================================================ package helm import ( "fmt" "path/filepath" "github.com/gruntwork-io/go-commons/collections" "github.com/gruntwork-io/go-commons/errors" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terratest/modules/files" "github.com/gruntwork-io/terratest/modules/testing" ) // formatSetValuesAsArgs formats the given values as command line args for helm using the given flag (e.g flags of // the format "--set"/"--set-string"/"--set-json" resulting in args like --set/set-string/set-json key=value...) func formatSetValuesAsArgs(setValues map[string]string, flag string) []string { args := []string{} // To make it easier to test, go through the keys in sorted order keys := collections.Keys(setValues) for _, key := range keys { value := setValues[key] argValue := fmt.Sprintf("%s=%s", key, value) args = append(args, flag, argValue) } return args } // formatValuesFilesAsArgs formats the given list of values file paths as command line args for helm (e.g of the format // -f path). This will fail the test if one of the paths do not exist or the absolute path can not be determined. func formatValuesFilesAsArgs(t testing.TestingT, valuesFiles []string) []string { args, err := formatValuesFilesAsArgsE(t, valuesFiles) require.NoError(t, err) return args } // formatValuesFilesAsArgsE formats the given list of values file paths as command line args for helm (e.g of the format // -f path). This will error if the file does not exist. func formatValuesFilesAsArgsE(t testing.TestingT, valuesFiles []string) ([]string, error) { args := []string{} for _, valuesFilePath := range valuesFiles { // Pass through filepath.Abs to clean the path, and then make sure this file exists absValuesFilePath, err := filepath.Abs(valuesFilePath) if err != nil { return args, errors.WithStackTrace(err) } if !files.FileExists(absValuesFilePath) { return args, errors.WithStackTrace(ValuesFileNotFoundError{valuesFilePath}) } args = append(args, "-f", absValuesFilePath) } return args, nil } // formatSetFilesAsArgs formats the given list of keys and file paths as command line args for helm to set from file // (e.g of the format --set-file key=path). This will fail the test if one of the paths do not exist or the absolute // path can not be determined. func formatSetFilesAsArgs(t testing.TestingT, setFiles map[string]string) []string { args, err := formatSetFilesAsArgsE(t, setFiles) require.NoError(t, err) return args } // formatSetFilesAsArgsE formats the given list of keys and file paths as command line args for helm to set from file // (e.g of the format --set-file key=path) func formatSetFilesAsArgsE(t testing.TestingT, setFiles map[string]string) ([]string, error) { args := []string{} // To make it easier to test, go through the keys in sorted order keys := collections.Keys(setFiles) for _, key := range keys { setFilePath := setFiles[key] // Pass through filepath.Abs to clean the path, and then make sure this file exists absSetFilePath, err := filepath.Abs(setFilePath) if err != nil { return args, errors.WithStackTrace(err) } if !files.FileExists(absSetFilePath) { return args, errors.WithStackTrace(SetFileNotFoundError{setFilePath}) } argValue := fmt.Sprintf("%s=%s", key, absSetFilePath) args = append(args, "--set-file", argValue) } return args, nil } ================================================ FILE: modules/helm/format_test.go ================================================ package helm import ( "fmt" "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestFormatSetValuesAsArgs(t *testing.T) { t.Parallel() testCases := []struct { name string setValues map[string]string setStrValues map[string]string setJsonValues map[string]string expected []string expectedStr []string expectedJson []string }{ { "EmptyValue", map[string]string{}, map[string]string{}, map[string]string{}, []string{}, []string{}, []string{}, }, { "SingleValue", map[string]string{"containerImage": "null"}, map[string]string{"numericString": "123123123123"}, map[string]string{"limits": `{"cpu": 1}`}, []string{"--set", "containerImage=null"}, []string{"--set-string", "numericString=123123123123"}, []string{"--set-json", fmt.Sprintf("limits=%s", `{"cpu": 1}`)}, }, { "MultipleValues", map[string]string{ "containerImage.repository": "nginx", "containerImage.tag": "v1.15.4", }, map[string]string{ "numericString": "123123123123", "otherString": "null", }, map[string]string{ "containerImage": `{"repository": "nginx", "tag": "v1.15.4"}`, "otherString": "{}", }, []string{ "--set", "containerImage.repository=nginx", "--set", "containerImage.tag=v1.15.4", }, []string{ "--set-string", "numericString=123123123123", "--set-string", "otherString=null", }, []string{ "--set-json", fmt.Sprintf("containerImage=%s", `{"repository": "nginx", "tag": "v1.15.4"}`), "--set-json", "otherString={}", }, }, } for _, testCase := range testCases { // Capture the range value and force it into this scope. Otherwise, it is defined outside this block so it can // change when the subtests parallelize and switch contexts. testCase := testCase t.Run(testCase.name, func(t *testing.T) { t.Parallel() assert.Equal(t, formatSetValuesAsArgs(testCase.setValues, "--set"), testCase.expected) assert.Equal(t, formatSetValuesAsArgs(testCase.setStrValues, "--set-string"), testCase.expectedStr) assert.Equal(t, formatSetValuesAsArgs(testCase.setJsonValues, "--set-json"), testCase.expectedJson) }) } } func TestFormatSetFilesAsArgs(t *testing.T) { t.Parallel() paths, err := createTempFiles(2) defer deleteTempFiles(paths) require.NoError(t, err) absPathList := absPaths(t, paths) testCases := []struct { name string setFiles map[string]string expected []string }{ { "EmptyValue", map[string]string{}, []string{}, }, { "SingleValue", map[string]string{"containerImage": paths[0]}, []string{"--set-file", fmt.Sprintf("containerImage=%s", absPathList[0])}, }, { "MultipleValues", map[string]string{ "containerImage.repository": paths[0], "containerImage.tag": paths[1], }, []string{ "--set-file", fmt.Sprintf("containerImage.repository=%s", absPathList[0]), "--set-file", fmt.Sprintf("containerImage.tag=%s", absPathList[1]), }, }, } // We create a subtest group that is NOT parallel, so the main test waits for all the tests to finish. This way, we // don't delete the files until the subtests finish. t.Run("group", func(t *testing.T) { for _, testCase := range testCases { // Capture the range value and force it into this scope. Otherwise, it is defined outside this block so it can // change when the subtests parallelize and switch contexts. testCase := testCase t.Run(testCase.name, func(t *testing.T) { t.Parallel() assert.Equal(t, formatSetFilesAsArgs(t, testCase.setFiles), testCase.expected) }) } }) } func TestFormatValuesFilesAsArgs(t *testing.T) { t.Parallel() paths, err := createTempFiles(2) defer deleteTempFiles(paths) require.NoError(t, err) absPathList := absPaths(t, paths) testCases := []struct { name string valuesFiles []string expected []string }{ { "EmptyValue", []string{}, []string{}, }, { "SingleValue", []string{paths[0]}, []string{"-f", absPathList[0]}, }, { "MultipleValues", paths, []string{ "-f", absPathList[0], "-f", absPathList[1], }, }, } // We create a subtest group that is NOT parallel, so the main test waits for all the tests to finish. This way, we // don't delete the files until the subtests finish. t.Run("group", func(t *testing.T) { for _, testCase := range testCases { // Capture the range value and force it into this scope. Otherwise, it is defined outside this block so it can // change when the subtests parallelize and switch contexts. testCase := testCase t.Run(testCase.name, func(t *testing.T) { t.Parallel() assert.Equal(t, formatValuesFilesAsArgs(t, testCase.valuesFiles), testCase.expected) }) } }) } // createTempFiles will create numFiles temporary files that can pass the abspath checks. func createTempFiles(numFiles int) ([]string, error) { paths := []string{} for i := 0; i < numFiles; i++ { tmpFile, err := os.CreateTemp("", "") defer tmpFile.Close() // We don't use require or t.Fatal here so that we give a chance to delete any temp files that were created // before this error if err != nil { return paths, err } paths = append(paths, tmpFile.Name()) } return paths, nil } // deleteTempFiles will delete all the given temp file paths func deleteTempFiles(paths []string) { for _, path := range paths { os.Remove(path) } } // absPaths will return the absolute paths of each path in the list func absPaths(t *testing.T, paths []string) []string { out := []string{} for _, path := range paths { absPath, err := filepath.Abs(path) require.NoError(t, err) out = append(out, absPath) } return out } ================================================ FILE: modules/helm/helm.go ================================================ // Package helm provides common functionalities for testing helm charts, such as calling out to the helm client. package helm ================================================ FILE: modules/helm/install.go ================================================ package helm import ( "path/filepath" "github.com/gruntwork-io/go-commons/errors" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terratest/modules/files" "github.com/gruntwork-io/terratest/modules/testing" ) // Install will install the selected helm chart with the provided options under the given release name. This will fail // the test if there is an error. func Install(t testing.TestingT, options *Options, chart string, releaseName string) { require.NoError(t, InstallE(t, options, chart, releaseName)) } // InstallE will install the selected helm chart with the provided options under the given release name. func InstallE(t testing.TestingT, options *Options, chart string, releaseName string) error { // If the chart refers to a path, convert to absolute path. Otherwise, pass straight through as it may be a remote // chart. if files.FileExists(chart) { absChartDir, err := filepath.Abs(chart) if err != nil { return errors.WithStackTrace(err) } chart = absChartDir } // build chart dependencies if options.BuildDependencies { if _, err := RunHelmCommandAndGetOutputE(t, options, "dependency", "build", chart); err != nil { return errors.WithStackTrace(err) } } // Now call out to helm install to install the charts with the provided options // Declare err here so that we can update args later var err error args := []string{} if options.ExtraArgs != nil { if installArgs, ok := options.ExtraArgs["install"]; ok { args = append(args, installArgs...) } } if options.Version != "" { args = append(args, "--version", options.Version) } args, err = getValuesArgsE(t, options, args...) if err != nil { return err } args = append(args, releaseName, chart) _, err = RunHelmCommandAndGetOutputE(t, options, "install", args...) return err } ================================================ FILE: modules/helm/install_test.go ================================================ //go:build kubeall || helm // +build kubeall helm // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests, and further differentiate helm // tests. This is done because minikube is heavy and can interfere with docker related tests in terratest. Similarly, // helm can overload the minikube system and thus interfere with the other kubernetes tests. To avoid overloading the // system, we run the kubernetes tests and helm tests separately from the others. package helm import ( "crypto/tls" "fmt" "path/filepath" "strings" "testing" "time" "github.com/stretchr/testify/assert" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" http_helper "github.com/gruntwork-io/terratest/modules/http-helper" "github.com/gruntwork-io/terratest/modules/k8s" "github.com/gruntwork-io/terratest/modules/random" "github.com/stretchr/testify/require" ) const ( remoteChartSource = "https://charts.bitnami.com/bitnami" remoteChartName = "nginx" remoteChartVersion = "22.4.0" ) // Test that we can install a remote chart (e.g bitnami/nginx) func TestRemoteChartInstall(t *testing.T) { t.Parallel() namespaceName := fmt.Sprintf( "%s-%s", strings.ToLower(t.Name()), strings.ToLower(random.UniqueId()), ) // Use default kubectl options to create a new namespace for this test, and then update the namespace for kubectl kubectlOptions := k8s.NewKubectlOptions("", "", namespaceName) defer k8s.DeleteNamespace(t, kubectlOptions, namespaceName) k8s.CreateNamespace(t, kubectlOptions, namespaceName) // Override service type to node port and disable PDB (requires policy/v1 API // which may not be available on older k8s clusters) options := &Options{ KubectlOptions: kubectlOptions, SetValues: map[string]string{ "service.type": "NodePort", "pdb.create": "false", }, Version: remoteChartVersion, } // Add the stable repo under a random name so as not to touch existing repo configs uniqueName := strings.ToLower(fmt.Sprintf("terratest-%s", random.UniqueId())) defer RemoveRepo(t, options, uniqueName) AddRepo(t, options, uniqueName, remoteChartSource) helmChart := fmt.Sprintf("%s/%s", uniqueName, remoteChartName) // Generate a unique release name so we can defer the delete before installing releaseName := fmt.Sprintf( "%s-%s", remoteChartName, strings.ToLower(random.UniqueId()), ) defer Delete(t, options, releaseName, true) // Test if helm.install will return an error if the chart version is incorrect options.Version = "notValidVersion.0.0.0" require.Error(t, InstallE(t, options, helmChart, releaseName)) // Fix chart version and retry install options.Version = remoteChartVersion // Test that passing extra arguments doesn't error, by changing default timeout options.ExtraArgs = map[string][]string{"install": []string{"--timeout", "5m1s"}} options.ExtraArgs["delete"] = []string{"--timeout", "5m1s"} require.NoError(t, InstallE(t, options, helmChart, releaseName)) waitForRemoteChartPods(t, kubectlOptions, releaseName, 1) // Verify service is accessible. Wait for it to become available and then hit the endpoint. serviceName := releaseName k8s.WaitUntilServiceAvailable(t, kubectlOptions, serviceName, 10, 1*time.Second) service := k8s.GetService(t, kubectlOptions, serviceName) endpoint := k8s.GetServiceEndpoint(t, kubectlOptions, service, 80) // Setup a TLS configuration to submit with the helper, a blank struct is acceptable tlsConfig := tls.Config{} http_helper.HttpGetWithRetryWithCustomValidation( t, fmt.Sprintf("http://%s", endpoint), &tlsConfig, 30, 10*time.Second, func(statusCode int, body string) bool { return statusCode == 200 }, ) } // Test deployment of helm chart with dependencies. func TestHelmDependencyInstall(t *testing.T) { t.Parallel() // Path to the helm chart with dependencies which we will test helmChartPath, err := filepath.Abs("../../examples/helm-dependency-example") require.NoError(t, err) // Custom namespace name. namespaceName := fmt.Sprintf("helm-dependency-example-%s", strings.ToLower(random.UniqueId())) // Setup the kubectl config and context. Here we choose to use the defaults, which is: // - HOME/.kube/config for the kubectl config file // - Current context of the kubectl config file kubectlOptions := k8s.NewKubectlOptions("", "", namespaceName) k8s.CreateNamespace(t, kubectlOptions, namespaceName) defer k8s.DeleteNamespace(t, kubectlOptions, namespaceName) // Helm chart deployment options. options := &Options{ KubectlOptions: kubectlOptions, SetValues: map[string]string{ "containerImageRepo": "nginx", "containerImageTag": "1.15.8", "basic.containerImageRepo": "nginx", "basic.containerImageTag": "1.15.8", }, BuildDependencies: true, } // We generate a unique release name so that we can refer to after deployment. // By doing so, we can schedule the delete call here so that at the end of the test, we run // `helm delete RELEASE_NAME` to clean up any resources that were created. releaseName := fmt.Sprintf( "helm-dependency-example-%s", strings.ToLower(random.UniqueId()), ) defer Delete(t, options, releaseName, true) // Deploy the chart using `helm install`. err = InstallE(t, options, helmChartPath, releaseName) assert.NoError(t, err) // Verify that Kubernetes service is available after helm chart deployment. _, err = k8s.GetServiceE(t, kubectlOptions, releaseName) assert.NoError(t, err) } func waitForRemoteChartPods(t *testing.T, kubectlOptions *k8s.KubectlOptions, releaseName string, podCount int) { // Get pod and wait for it to be avaialable // To get the pod, we need to filter it using the labels that the helm chart creates filters := metav1.ListOptions{ LabelSelector: fmt.Sprintf( "app.kubernetes.io/name=%s,app.kubernetes.io/instance=%s", remoteChartName, releaseName, ), } // Use longer timeout (60 retries * 10s = 10 min) to handle slower CI environments // and potential resource contention when multiple helm tests run in parallel k8s.WaitUntilNumPodsCreated(t, kubectlOptions, filters, podCount, 60, 10*time.Second) pods := k8s.ListPods(t, kubectlOptions, filters) for _, pod := range pods { k8s.WaitUntilPodAvailable(t, kubectlOptions, pod.Name, 60, 10*time.Second) } } ================================================ FILE: modules/helm/options.go ================================================ package helm import ( "github.com/gruntwork-io/terratest/modules/k8s" "github.com/gruntwork-io/terratest/modules/logger" ) type Options struct { ValuesFiles []string // List of values files to render. SetValues map[string]string // Values that should be set via the command line. SetStrValues map[string]string // Values that should be set via the command line explicitly as `string` types. SetJsonValues map[string]string // Values that should be set via the command line in JSON format. SetFiles map[string]string // Values that should be set from a file. These should be file paths. Use to avoid logging secrets. KubectlOptions *k8s.KubectlOptions // KubectlOptions to control how to authenticate to kubernetes cluster. `nil` => use defaults. HomePath string // The path to the helm home to use when calling out to helm. Empty string means use default ($HOME/.helm). EnvVars map[string]string // Environment variables to set when running helm Version string // Version of chart Logger *logger.Logger // Set a non-default logger that should be used. See the logger package for more info. Use logger.Discard to not print the output while executing the command. ExtraArgs map[string][]string // Extra arguments to pass to the helm install/upgrade/rollback/delete and helm repo add commands. The key signals the command (e.g., install) while the values are the extra arguments to pass through. BuildDependencies bool // If true, helm dependencies will be built before rendering template, installing or upgrade the chart. SnapshotPath string // The path to the snapshot directory when using snapshot based testing. Empty string means use default ($PWD/__snapshot__). } ================================================ FILE: modules/helm/repo.go ================================================ package helm import ( "github.com/stretchr/testify/require" "github.com/gruntwork-io/terratest/modules/testing" ) // AddRepo will setup the provided helm repository to the local helm client configuration. This will fail the test if // there is an error. func AddRepo(t testing.TestingT, options *Options, repoName string, repoURL string) { require.NoError(t, AddRepoE(t, options, repoName, repoURL)) } // AddRepoE will setup the provided helm repository to the local helm client configuration. func AddRepoE(t testing.TestingT, options *Options, repoName string, repoURL string) error { // Set required args args := []string{"add", repoName, repoURL} // Append helm repo add ExtraArgs if available if options.ExtraArgs != nil { if repoAddArgs, ok := options.ExtraArgs["repoAdd"]; ok { args = append(args, repoAddArgs...) } } _, err := RunHelmCommandAndGetOutputE(t, options, "repo", args...) return err } // RemoveRepo will remove the provided helm repository from the local helm client configuration. This will fail the test // if there is an error. func RemoveRepo(t testing.TestingT, options *Options, repoName string) { require.NoError(t, RemoveRepoE(t, options, repoName)) } // RemoveRepoE will remove the provided helm repository from the local helm client configuration. func RemoveRepoE(t testing.TestingT, options *Options, repoName string) error { _, err := RunHelmCommandAndGetOutputE(t, options, "repo", "remove", repoName) return err } ================================================ FILE: modules/helm/rollback.go ================================================ package helm import ( "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // Rollback will downgrade the release to the specified version. This will fail // the test if there is an error. func Rollback(t testing.TestingT, options *Options, releaseName string, revision string) { require.NoError(t, RollbackE(t, options, releaseName, revision)) } // RollbackE will downgrade the release to the specified version func RollbackE(t testing.TestingT, options *Options, releaseName string, revision string) error { var err error args := []string{} if options.ExtraArgs != nil { if rollbackArgs, ok := options.ExtraArgs["rollback"]; ok { args = append(args, rollbackArgs...) } } args = append(args, releaseName) if revision != "" { args = append(args, revision) } _, err = RunHelmCommandAndGetOutputE(t, options, "rollback", args...) return err } ================================================ FILE: modules/helm/template.go ================================================ package helm import ( "encoding/json" "fmt" "io" "os" "path/filepath" "reflect" "strings" "github.com/gonvenience/ytbx" "github.com/gruntwork-io/go-commons/errors" "github.com/gruntwork-io/terratest/modules/files" "github.com/gruntwork-io/terratest/modules/testing" "github.com/homeport/dyff/pkg/dyff" "github.com/stretchr/testify/require" goyaml "gopkg.in/yaml.v3" ) // RenderTemplate runs `helm template` to render the template given the provided options and returns stdout/stderr from // the template command. If you pass in templateFiles, this will only render those templates. This function will fail // the test if there is an error rendering the template. func RenderTemplate(t testing.TestingT, options *Options, chartDir string, releaseName string, templateFiles []string, extraHelmArgs ...string) string { out, err := RenderTemplateE(t, options, chartDir, releaseName, templateFiles, extraHelmArgs...) require.NoError(t, err) return out } // RenderTemplateE runs `helm template` to render the template given the provided options and returns stdout/stderr from // the template command. If you pass in templateFiles, this will only render those templates. func RenderTemplateE(t testing.TestingT, options *Options, chartDir string, releaseName string, templateFiles []string, extraHelmArgs ...string) (string, error) { // Get render arguments args, err := getRenderArgs(t, options, chartDir, releaseName, templateFiles, extraHelmArgs...) if err != nil { return "", err } // Finally, call out to helm template command return RunHelmCommandAndGetStdOutE(t, options, "template", args...) } // RenderTemplateAndGetStdOutErrE runs `helm template` to render the template given the provided options and returns stdout and stderr separately from // the template command. If you pass in templateFiles, this will only render those templates. func RenderTemplateAndGetStdOutErrE(t testing.TestingT, options *Options, chartDir string, releaseName string, templateFiles []string, extraHelmArgs ...string) (string, string, error) { args, err := getRenderArgs(t, options, chartDir, releaseName, templateFiles, extraHelmArgs...) if err != nil { return "", "", err } // Finally, call out to helm template command return RunHelmCommandAndGetStdOutErrE(t, options, "template", args...) } func getRenderArgs(t testing.TestingT, options *Options, chartDir string, releaseName string, templateFiles []string, extraHelmArgs ...string) ([]string, error) { // First, verify the charts dir exists absChartDir, err := filepath.Abs(chartDir) if err != nil { return nil, errors.WithStackTrace(err) } if !files.FileExists(chartDir) { return nil, errors.WithStackTrace(ChartNotFoundError{chartDir}) } // check chart dependencies if options.BuildDependencies { if _, err := RunHelmCommandAndGetOutputE(t, options, "dependency", "build", chartDir); err != nil { return nil, errors.WithStackTrace(err) } } // Now construct the args // We first construct the template args args := []string{} if options.KubectlOptions != nil && options.KubectlOptions.Namespace != "" { args = append(args, "--namespace", options.KubectlOptions.Namespace) } args, err = getValuesArgsE(t, options, args...) if err != nil { return nil, err } for _, templateFile := range templateFiles { // validate this is a valid template file absTemplateFile := filepath.Join(absChartDir, templateFile) if !strings.HasPrefix(templateFile, "charts") && !files.FileExists(absTemplateFile) { return nil, errors.WithStackTrace(TemplateFileNotFoundError{Path: templateFile, ChartDir: absChartDir}) } // Note: we only get the abs template file path to check it actually exists, but the `helm template` command // expects the relative path from the chart. args = append(args, "--show-only", templateFile) } // deal extraHelmArgs args = append(args, extraHelmArgs...) // ... and add the name and chart at the end as the command expects args = append(args, releaseName, chartDir) return args, nil } // RenderRemoteTemplate runs `helm template` to render a *remote* chart given the provided options and returns stdout/stderr from // the template command. If you pass in templateFiles, this will only render those templates. This function will fail // the test if there is an error rendering the template. func RenderRemoteTemplate(t testing.TestingT, options *Options, chartURL string, releaseName string, templateFiles []string, extraHelmArgs ...string) string { out, err := RenderRemoteTemplateE(t, options, chartURL, releaseName, templateFiles, extraHelmArgs...) require.NoError(t, err) return out } // RenderRemoteTemplateE runs `helm template` to render a *remote* helm chart given the provided options and returns stdout/stderr from // the template command. If you pass in templateFiles, this will only render those templates. func RenderRemoteTemplateE(t testing.TestingT, options *Options, chartURL string, releaseName string, templateFiles []string, extraHelmArgs ...string) (string, error) { // Now construct the args // We first construct the template args args := []string{} if options.KubectlOptions != nil && options.KubectlOptions.Namespace != "" { args = append(args, "--namespace", options.KubectlOptions.Namespace) } args, err := getValuesArgsE(t, options, args...) if err != nil { return "", err } for _, templateFile := range templateFiles { // As the helm command fails if a non valid template is given as input // we do not check if the template file exists or not as we do for local charts // as it would add unecessary networking calls args = append(args, "--show-only", templateFile) } // deal extraHelmArgs args = append(args, extraHelmArgs...) // ... and add the helm chart name, the remote repo and chart URL at the end args = append(args, releaseName, "--repo", chartURL) if options.Version != "" { args = append(args, "--version", options.Version) } // Finally, call out to helm template command return RunHelmCommandAndGetStdOutE(t, options, "template", args...) } // UnmarshalK8SYamls is the same as UnmarshalK8SYamlsE, but will fail the test if there is an error. func UnmarshalK8SYamls[T any](t testing.TestingT, yamlData string, destinationObj *[]T, check func(v T) bool) { require.NoError(t, UnmarshalK8SYamlsE(t, yamlData, destinationObj, check)) } // UnmarshalK8SYamlsE try to unmarshal yaml that contains multiple k8s objects into slice of concrete type. // It requires user to pass `check` function to determine whether the unmarshaled object is valid or not. // It will ignore error or invalid object but if no valid object were found, it will return error. func UnmarshalK8SYamlsE[T any](t testing.TestingT, yamlData string, destinationObj *[]T, check func(v T) bool) error { originalLen := len(*destinationObj) raws := []json.RawMessage{} if err := UnmarshalK8SYamlE(t, yamlData, &raws); err != nil { return err } for _, raw := range raws { var v T err := json.Unmarshal(raw, &v) if err != nil || !check(v) { continue } *destinationObj = append(*destinationObj, v) } if len(*destinationObj) == originalLen { return fmt.Errorf("no matching raw data were found for the concrete type") } return nil } // UnmarshalK8SYaml is the same as UnmarshalK8SYamlE, but will fail the test if there is an error. func UnmarshalK8SYaml(t testing.TestingT, yamlData string, destinationObj interface{}) { require.NoError(t, UnmarshalK8SYamlE(t, yamlData, destinationObj)) } // UnmarshalK8SYamlE can be used to take template outputs and unmarshal them into the corresponding client-go struct. For // example, suppose you render the template into a Deployment object. You can unmarshal the yaml as follows: // // var deployment appsv1.Deployment // UnmarshalK8SYamlE(t, renderedOutput, &deployment) // // At the end of this, the deployment variable will be populated. func UnmarshalK8SYamlE(t testing.TestingT, yamlData string, destinationObj interface{}) error { decoder := goyaml.NewDecoder(strings.NewReader(yamlData)) // Ensure destinationObj is a pointer destVal := reflect.ValueOf(destinationObj) if destVal.Kind() != reflect.Ptr { return fmt.Errorf("destinationObj must be a pointer") } destElem := destVal.Elem() // Handle single object or list as root if destElem.Kind() != reflect.Slice { // Decode only the first document var rawYaml interface{} if err := decoder.Decode(&rawYaml); err != nil { return errors.WithStackTrace(err) } // If the root is an array but destinationObj is a single object, return an error if reflect.TypeOf(rawYaml).Kind() == reflect.Slice { return fmt.Errorf("YAML root is an array, but destinationObj is a single object") } jsonData, err := json.Marshal(rawYaml) if err != nil { return errors.WithStackTrace(err) } if err := json.Unmarshal(jsonData, destinationObj); err != nil { return errors.WithStackTrace(err) } return nil } // Handle multiple YAML documents (destinationObj is a slice) slicePtr := destVal sliceVal := slicePtr.Elem() for { var rawYaml interface{} if err := decoder.Decode(&rawYaml); err != nil { if err == io.EOF { break // No more documents } return errors.WithStackTrace(err) } jsonData, err := json.Marshal(rawYaml) if err != nil { return errors.WithStackTrace(err) } // If root object is a slice, append elements individually if reflect.TypeOf(rawYaml).Kind() == reflect.Slice { var items []json.RawMessage if err := json.Unmarshal(jsonData, &items); err != nil { return errors.WithStackTrace(err) } for _, item := range items { newElem := reflect.New(sliceVal.Type().Elem()) // Create new element if err := json.Unmarshal(item, newElem.Interface()); err != nil { return errors.WithStackTrace(err) } sliceVal.Set(reflect.Append(sliceVal, newElem.Elem())) } } else { newElem := reflect.New(sliceVal.Type().Elem()) // Create new element if err := json.Unmarshal(jsonData, newElem.Interface()); err != nil { return errors.WithStackTrace(err) } sliceVal.Set(reflect.Append(sliceVal, newElem.Elem())) } } return nil } // UpdateSnapshot creates or updates the k8s manifest snapshot of a chart (e.g bitnami/nginx). // It is one of the two functions needed to implement snapshot based testing for helm. // see https://github.com/gruntwork-io/terratest/issues/1377 // A snapshot is used to compare the current manifests of a chart with the previous manifests. // A global diff is run against the two snapshosts and the number of differences is returned. func UpdateSnapshot(t testing.TestingT, options *Options, yamlData string, releaseName string) { require.NoError(t, UpdateSnapshotE(t, options, yamlData, releaseName)) } // UpdateSnapshotE creates or updates the k8s manifest snapshot of a chart (e.g bitnami/nginx). // It is one of the two functions needed to implement snapshot based testing for helm. // see https://github.com/gruntwork-io/terratest/issues/1377 // A snapshot is used to compare the current manifests of a chart with the previous manifests. // A global diff is run against the two snapshosts and the number of differences is returned. // It will failed the test if there is an error while writing the manifests' snapshot in the file system func UpdateSnapshotE(t testing.TestingT, options *Options, yamlData string, releaseName string) error { var snapshotDir = "__snapshot__" if options.SnapshotPath != "" { snapshotDir = options.SnapshotPath } // Create a directory if not exists if !files.FileExists(snapshotDir) { if err := os.Mkdir(snapshotDir, 0755); err != nil { return errors.WithStackTrace(err) } } filename := filepath.Join(snapshotDir, releaseName+".yaml") // Open a file in write mode file, err := os.Create(filename) if err != nil { return errors.WithStackTrace(err) } defer file.Close() // Write the k8s manifest into the file if _, err = file.WriteString(yamlData); err != nil { return errors.WithStackTrace(err) } if options.Logger != nil { options.Logger.Logf(t, "helm chart manifest written into file: %s", filename) } return nil } // DiffAgainstSnapshot compare the current manifests of a chart (e.g bitnami/nginx) // with the previous manifests stored in the snapshot. // see https://github.com/gruntwork-io/terratest/issues/1377 // It returns the number of difference between the two manifests or -1 in case of error // It will fail the test if there is an error while reading or writing the two manifests in the file system func DiffAgainstSnapshot(t testing.TestingT, options *Options, yamlData string, releaseName string) int { numberOfDiffs, err := DiffAgainstSnapshotE(t, options, yamlData, releaseName) require.NoError(t, err) return numberOfDiffs } // DiffAgainstSnapshotE compare the current manifests of a chart (e.g bitnami/nginx) // with the previous manifests stored in the snapshot. // see https://github.com/gruntwork-io/terratest/issues/1377 // It returns the number of difference between the manifests or -1 in case of error func DiffAgainstSnapshotE(t testing.TestingT, options *Options, yamlData string, releaseName string) (int, error) { var snapshotDir = "__snapshot__" if options.SnapshotPath != "" { snapshotDir = options.SnapshotPath } // load the yaml snapshot file snapshot := filepath.Join(snapshotDir, releaseName+".yaml") from, err := ytbx.LoadFile(snapshot) if err != nil { return -1, errors.WithStackTrace(err) } // write the current manifest into a file as `dyff` does not support string input currentManifests := releaseName + ".yaml" file, err := os.Create(currentManifests) if err != nil { return -1, errors.WithStackTrace(err) } if _, err = file.WriteString(yamlData); err != nil { return -1, errors.WithStackTrace(err) } defer file.Close() defer os.Remove(currentManifests) to, err := ytbx.LoadFile(currentManifests) if err != nil { return -1, errors.WithStackTrace(err) } // compare the two manifests using `dyff` compOpt := dyff.KubernetesEntityDetection(false) // create a report report, err := dyff.CompareInputFiles(from, to, compOpt) if err != nil { return -1, errors.WithStackTrace(err) } // write any difference to stdout reportWriter := &dyff.HumanReport{ Report: report, DoNotInspectCerts: false, NoTableStyle: false, OmitHeader: false, UseGoPatchPaths: false, } err = reportWriter.WriteReport(os.Stdout) if err != nil { return -1, errors.WithStackTrace(err) } // return the number of diffs to use in assertion while testing: 0 = no differences return len(reportWriter.Diffs), nil } ================================================ FILE: modules/helm/template_test.go ================================================ //go:build kubeall || helm // +build kubeall helm // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests, and further differentiate helm // tests. This is done because minikube is heavy and can interfere with docker related tests in terratest. Similarly, // helm can overload the minikube system and thus interfere with the other kubernetes tests. To avoid overloading the // system, we run the kubernetes tests and helm tests separately from the others. package helm import ( "fmt" "os" "path/filepath" "regexp" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" "github.com/gruntwork-io/terratest/modules/k8s" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/random" ) // Test that we can render locally a remote chart (e.g bitnami/nginx) func TestRemoteChartRender(t *testing.T) { const ( remoteChartSource = "https://charts.bitnami.com/bitnami" remoteChartName = "nginx" remoteChartVersion = "13.2.24" registry = "registry-1.docker.io" ) t.Parallel() namespaceName := fmt.Sprintf( "%s-%s", strings.ToLower(t.Name()), strings.ToLower(random.UniqueId()), ) releaseName := remoteChartName options := &Options{ SetValues: map[string]string{ "image.repository": remoteChartName, "image.registry": registry, "image.tag": remoteChartVersion, }, KubectlOptions: k8s.NewKubectlOptions("", "", namespaceName), Logger: logger.Discard, Version: remoteChartVersion, } // Run RenderTemplate to render the template and capture the output. Note that we use the version without `E`, since // we want to assert that the template renders without any errors. output := RenderRemoteTemplate(t, options, remoteChartSource, releaseName, []string{"templates/deployment.yaml"}) // Now we use kubernetes/client-go library to render the template output into the Deployment struct. This will // ensure the Deployment resource is rendered correctly. var deployment appsv1.Deployment UnmarshalK8SYaml(t, output, &deployment) // Verify the namespace matches the expected supplied namespace. require.Equal(t, namespaceName, deployment.Namespace) // Finally, we verify the deployment pod template spec is set to the expected container image value expectedContainerImage := registry + "/" + remoteChartName + ":" + remoteChartVersion deploymentContainers := deployment.Spec.Template.Spec.Containers require.Equal(t, len(deploymentContainers), 1) require.Equal(t, expectedContainerImage, deploymentContainers[0].Image) } // Test that we can dump all the manifest locally a remote chart (e.g bitnami/nginx) // so that I can use them later to compare between two versions of the same chart for example func TestRemoteChartRenderDump(t *testing.T) { t.Parallel() renderChartDump(t, "13.2.20", t.TempDir()) } // Test that we can diff all the manifest to a local snapshot using a remote chart (e.g bitnami/nginx) func TestRemoteChartRenderDiff(t *testing.T) { t.Parallel() initialSnapshot := t.TempDir() updatedSnapshot := t.TempDir() renderChartDump(t, "13.2.20", initialSnapshot) output := renderChartDump(t, "13.2.24", updatedSnapshot) options := &Options{ Logger: logger.Default, SnapshotPath: initialSnapshot, } // diff in: spec.initContainers.preserve-logs-symlinks.imag, spec.containers.nginx.image, tls certificates require.Equal(t, 4, DiffAgainstSnapshot(t, options, output, "nginx")) } // render chart dump and return the rendered output func renderChartDump(t *testing.T, remoteChartVersion, snapshotDir string) string { const ( remoteChartSource = "https://charts.bitnami.com/bitnami" remoteChartName = "nginx" // need to set a fix name for the namespace, so it is not flag as a difference namespaceName = "dump-ns" ) releaseName := remoteChartName options := &Options{ SetValues: map[string]string{ "image.repository": remoteChartName, "image.registry": "", "image.tag": remoteChartVersion, }, KubectlOptions: k8s.NewKubectlOptions("", "", namespaceName), Logger: logger.Discard, Version: remoteChartVersion, } // Run RenderTemplate to render the template and capture the output. Note that we use the version without `E`, since // we want to assert that the template renders without any errors. output := RenderRemoteTemplate(t, options, remoteChartSource, releaseName, []string{}) // Now we use kubernetes/client-go library to render the template output into the Deployment struct. This will // ensure the Deployment resource is rendered correctly. var deployment appsv1.Deployment UnmarshalK8SYaml(t, output, &deployment) // Verify the namespace matches the expected supplied namespace. require.Equal(t, namespaceName, deployment.Namespace) // write chart manifest to a local filesystem directory options = &Options{ Logger: logger.Default, SnapshotPath: snapshotDir, } UpdateSnapshot(t, options, output, releaseName) return output } func TestUnmarshall(t *testing.T) { t.Run("Single", func(t *testing.T) { b, err := os.ReadFile("testdata/deployment.yaml") require.NoError(t, err) var deployment appsv1.Deployment UnmarshalK8SYaml(t, string(b), &deployment) assert.Equal(t, deployment.Name, "nginx-deployment") }) t.Run("Multiple", func(t *testing.T) { for _, f := range []string{"testdata/deployments.yaml", "testdata/deployments-array.yaml"} { b, err := os.ReadFile(f) require.NoError(t, err) var deployment []appsv1.Deployment UnmarshalK8SYaml(t, string(b), &deployment) require.Len(t, deployment, 2) assert.Equal(t, deployment[0].Name, "nginx-deployment-1") assert.Equal(t, deployment[1].Name, "nginx-deployment-2") // overwrite for equality check deployment[1].Name = deployment[0].Name assert.Equal(t, deployment[0], deployment[1]) } }) t.Run("Invalid", func(t *testing.T) { b, err := os.ReadFile("testdata/invalid-duplicate.yaml") require.NoError(t, err) var deployment appsv1.Deployment err = UnmarshalK8SYamlE(t, string(b), &deployment) assert.Error(t, err) assert.Regexp(t, regexp.MustCompile(`mapping key ".+" already defined at line \d+`), err.Error()) }) t.Run("LiteralBlock", func(t *testing.T) { b, err := os.ReadFile("testdata/configmap-literalblock.yaml") require.NoError(t, err) var configmap corev1.ConfigMap err = UnmarshalK8SYamlE(t, string(b), &configmap) assert.NoError(t, err) data := `configmap-data-value-1; configmap-data-value-2; ` assert.Equal(t, data, configmap.Data["thisIsSomeDataKey"]) }) } func TestRenderWarning(t *testing.T) { chart, err := filepath.Abs("testdata/deprecated-chart") require.NoError(t, err) stdout, stderr, err := RenderTemplateAndGetStdOutErrE(t, &Options{}, chart, "test", nil) require.NoError(t, err) assert.Contains(t, stderr, "WARNING:") var deployment appsv1.Deployment UnmarshalK8SYaml(t, string(stdout), &deployment) assert.Equal(t, deployment.Name, "nginx-deployment") } func TestRenderMultipleManifests(t *testing.T) { chart, err := filepath.Abs("testdata/multiple-manifests") require.NoError(t, err) out := RenderTemplate(t, &Options{}, chart, "test", []string{}) var configs []corev1.ConfigMap UnmarshalK8SYamlsE(t, out, &configs, func(v corev1.ConfigMap) bool { return v.Kind == "ConfigMap" }) require.Len(t, configs, 1) assert.Equal(t, configs[0].Name, "test-configmap") var deploys []appsv1.Deployment UnmarshalK8SYamlsE(t, out, &deploys, func(v appsv1.Deployment) bool { return v.Kind == "Deployment" }) require.Len(t, deploys, 1) assert.Equal(t, deploys[0].Name, "test-deployment") } ================================================ FILE: modules/helm/testdata/configmap-literalblock.yaml ================================================ apiVersion: v1 kind: ConfigMap metadata: labels: app: release-name name: release-name data: thisIsSomeDataKey: | configmap-data-value-1; configmap-data-value-2; ================================================ FILE: modules/helm/testdata/deployment.yaml ================================================ apiVersion: apps/v1 kind: Deployment metadata: name: nginx-deployment labels: app: nginx spec: replicas: 3 selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - name: nginx image: nginx:latest ports: - containerPort: 80 ================================================ FILE: modules/helm/testdata/deployments-array.yaml ================================================ - apiVersion: apps/v1 kind: Deployment metadata: name: nginx-deployment-1 labels: app: nginx spec: replicas: 3 selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - name: nginx image: nginx:latest ports: - containerPort: 80 - apiVersion: apps/v1 kind: Deployment metadata: name: nginx-deployment-2 labels: app: nginx spec: replicas: 3 selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - name: nginx image: nginx:latest ports: - containerPort: 80 ================================================ FILE: modules/helm/testdata/deployments.yaml ================================================ --- apiVersion: apps/v1 kind: Deployment metadata: name: nginx-deployment-1 labels: app: nginx spec: replicas: 3 selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - name: nginx image: nginx:latest ports: - containerPort: 80 --- apiVersion: apps/v1 kind: Deployment metadata: name: nginx-deployment-2 labels: app: nginx spec: replicas: 3 selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - name: nginx image: nginx:latest ports: - containerPort: 80 ================================================ FILE: modules/helm/testdata/deprecated-chart/Chart.yaml ================================================ name: test version: 0.1.0 apiVersion: v1 deprecated: true ================================================ FILE: modules/helm/testdata/deprecated-chart/templates/deployment.yaml ================================================ apiVersion: apps/v1 kind: Deployment metadata: name: nginx-deployment labels: app: nginx spec: replicas: 3 selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - name: nginx image: nginx:latest ports: - containerPort: 80 ================================================ FILE: modules/helm/testdata/invalid-duplicate.yaml ================================================ apiVersion: apps/v1 kind: Deployment metadata: name: nginx-deployment labels: app: nginx app: nginx2 spec: replicas: 3 selector: matchLabels: app: nginx template: metadata: labels: app: nginx app: nginx2 spec: containers: - name: nginx image: nginx:latest ports: - containerPort: 80 ================================================ FILE: modules/helm/testdata/multiple-manifests/Chart.yaml ================================================ name: test version: 0.1.0 apiVersion: v1 ================================================ FILE: modules/helm/testdata/multiple-manifests/templates/configmap.yaml ================================================ apiVersion: v1 kind: ConfigMap metadata: labels: app: test name: test-configmap data: hello: world foo: bar ================================================ FILE: modules/helm/testdata/multiple-manifests/templates/deployment.yaml ================================================ apiVersion: apps/v1 kind: Deployment metadata: name: test-deployment labels: app: test spec: replicas: 3 selector: matchLabels: app: test template: metadata: labels: app: test spec: containers: - name: nginx image: nginx:latest ports: - containerPort: 80 ================================================ FILE: modules/helm/upgrade.go ================================================ package helm import ( "path/filepath" "github.com/gruntwork-io/go-commons/errors" "github.com/gruntwork-io/terratest/modules/files" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // Upgrade will upgrade the release and chart will be deployed with the lastest configuration. This will fail // the test if there is an error. func Upgrade(t testing.TestingT, options *Options, chart string, releaseName string) { require.NoError(t, UpgradeE(t, options, chart, releaseName)) } // UpgradeE will upgrade the release and chart will be deployed with the lastest configuration. func UpgradeE(t testing.TestingT, options *Options, chart string, releaseName string) error { // If the chart refers to a path, convert to absolute path. Otherwise, pass straight through as it may be a remote // chart. if files.FileExists(chart) { absChartDir, err := filepath.Abs(chart) if err != nil { return errors.WithStackTrace(err) } chart = absChartDir } // build chart dependencies if options.BuildDependencies { if _, err := RunHelmCommandAndGetOutputE(t, options, "dependency", "build", chart); err != nil { return errors.WithStackTrace(err) } } var err error args := []string{} if options.ExtraArgs != nil { if upgradeArgs, ok := options.ExtraArgs["upgrade"]; ok { args = append(args, upgradeArgs...) } } args, err = getValuesArgsE(t, options, args...) if err != nil { return err } args = append(args, "--install", releaseName, chart) if options.Version != "" { args = append(args, "--version", options.Version) } _, err = RunHelmCommandAndGetOutputE(t, options, "upgrade", args...) return err } ================================================ FILE: modules/helm/upgrade_test.go ================================================ //go:build kubeall || helm // +build kubeall helm // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests, and further differentiate helm // tests. This is done because minikube is heavy and can interfere with docker related tests in terratest. Similarly, // helm can overload the minikube system and thus interfere with the other kubernetes tests. To avoid overloading the // system, we run the kubernetes tests and helm tests separately from the others. package helm import ( "crypto/tls" "fmt" "path/filepath" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" http_helper "github.com/gruntwork-io/terratest/modules/http-helper" "github.com/gruntwork-io/terratest/modules/k8s" "github.com/gruntwork-io/terratest/modules/random" ) // Test that we can install, upgrade, and rollback a remote chart (e.g stable/chartmuseum) func TestRemoteChartInstallUpgradeRollback(t *testing.T) { t.Parallel() namespaceName := fmt.Sprintf( "%s-%s", strings.ToLower(t.Name()), strings.ToLower(random.UniqueId()), ) // Use default kubectl options to create a new namespace for this test, and then update the namespace for kubectl kubectlOptions := k8s.NewKubectlOptions("", "", namespaceName) defer k8s.DeleteNamespace(t, kubectlOptions, namespaceName) k8s.CreateNamespace(t, kubectlOptions, namespaceName) // Override service type to node port and disable PDB (requires policy/v1 API // which may not be available on older k8s clusters) options := &Options{ KubectlOptions: kubectlOptions, SetValues: map[string]string{ "service.type": "NodePort", "pdb.create": "false", }, Version: remoteChartVersion, } // Add the stable repo under a random name so as not to touch existing repo configs uniqueName := strings.ToLower(fmt.Sprintf("terratest-%s", random.UniqueId())) defer RemoveRepo(t, options, uniqueName) AddRepo(t, options, uniqueName, remoteChartSource) helmChart := fmt.Sprintf("%s/%s", uniqueName, remoteChartName) // Generate a unique release name so we can defer the delete before installing releaseName := fmt.Sprintf( "%s-%s", remoteChartName, strings.ToLower(random.UniqueId()), ) defer Delete(t, options, releaseName, true) Install(t, options, helmChart, releaseName) waitForRemoteChartPods(t, kubectlOptions, releaseName, 1) // Setting replica count to 2 to check the upgrade functionality. // After successful upgrade, the count of pods should be equal to 2. options.SetValues = map[string]string{ "replicaCount": "2", "service.type": "NodePort", "pdb.create": "false", } // Test that passing extra arguments doesn't error, by changing default timeout options.ExtraArgs = map[string][]string{"upgrade": []string{"--timeout", "5m1s"}} options.ExtraArgs["rollback"] = []string{"--timeout", "5m1s"} Upgrade(t, options, helmChart, releaseName) waitForRemoteChartPods(t, kubectlOptions, releaseName, 2) // Verify service is accessible. Wait for it to become available and then hit the endpoint. serviceName := releaseName k8s.WaitUntilServiceAvailable(t, kubectlOptions, serviceName, 10, 1*time.Second) service := k8s.GetService(t, kubectlOptions, serviceName) endpoint := k8s.GetServiceEndpoint(t, kubectlOptions, service, 80) // Setup a TLS configuration to submit with the helper, a blank struct is acceptable tlsConfig := tls.Config{} http_helper.HttpGetWithRetryWithCustomValidation( t, fmt.Sprintf("http://%s", endpoint), &tlsConfig, 30, 10*time.Second, func(statusCode int, body string) bool { return statusCode == 200 }, ) // Finally, test rollback functionality. When rolling back, we should see the pods go back down to 1. Rollback(t, options, releaseName, "") waitForRemoteChartPods(t, kubectlOptions, releaseName, 1) } // Test deployment of helm chart with dependencies. func TestHelmDependencyUpgrade(t *testing.T) { t.Parallel() // Path to the helm chart with dependencies which we will test helmChartPath, err := filepath.Abs("../../examples/helm-dependency-example") require.NoError(t, err) // Custom namespace name. namespaceName := fmt.Sprintf("helm-dependency-example-%s", strings.ToLower(random.UniqueId())) // Setup the kubectl config and context. Here we choose to use the defaults, which is: // - HOME/.kube/config for the kubectl config file // - Current context of the kubectl config file kubectlOptions := k8s.NewKubectlOptions("", "", namespaceName) k8s.CreateNamespace(t, kubectlOptions, namespaceName) defer k8s.DeleteNamespace(t, kubectlOptions, namespaceName) // Helm chart deployment options. options := &Options{ KubectlOptions: kubectlOptions, SetValues: map[string]string{ "containerImageRepo": "nginx", "containerImageTag": "1.15.8", "basic.containerImageRepo": "nginx", "basic.containerImageTag": "1.15.8", }, BuildDependencies: true, } // We generate a unique release name so that we can refer to after deployment. // By doing so, we can schedule the delete call here so that at the end of the test, we run // `helm delete RELEASE_NAME` to clean up any resources that were created. releaseName := fmt.Sprintf( "helm-dependency-example-%s", strings.ToLower(random.UniqueId()), ) defer Delete(t, options, releaseName, true) // Deploy the chart using `helm install`. err = InstallE(t, options, helmChartPath, releaseName) assert.NoError(t, err) // Verify that upgrade is working as expected. err = UpgradeE(t, options, helmChartPath, releaseName) assert.NoError(t, err) } ================================================ FILE: modules/http-helper/continuous.go ================================================ package http_helper import ( "crypto/tls" "sync" "time" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/testing" ) type GetResponse struct { StatusCode int Body string } // Continuously check the given URL every 1 second until the stopChecking channel receives a signal to stop. // This function will return a sync.WaitGroup that can be used to wait for the checking to stop, and a read only channel // to stream the responses for each check. // Note that the channel has a buffer of 1000, after which it will start to drop the send events func ContinuouslyCheckUrl( t testing.TestingT, url string, stopChecking <-chan bool, sleepBetweenChecks time.Duration, ) (*sync.WaitGroup, <-chan GetResponse) { var wg sync.WaitGroup wg.Add(1) responses := make(chan GetResponse, 1000) go func() { defer wg.Done() defer close(responses) for { select { case <-stopChecking: logger.Default.Logf(t, "Got signal to stop downtime checks for URL %s.\n", url) return case <-time.After(sleepBetweenChecks): statusCode, body, err := HttpGetE(t, url, &tls.Config{}) // Non-blocking send, defaulting to logging a warning if there is no channel reader select { case responses <- GetResponse{StatusCode: statusCode, Body: body}: // do nothing since all we want to do is send the response default: logger.Default.Logf(t, "WARNING: ContinuouslyCheckUrl responses channel buffer is full") } logger.Default.Logf(t, "Got response %d and err %v from URL at %s", statusCode, err, url) if err != nil { // We use Errorf instead of Fatalf here because Fatalf is not goroutine safe, while Errorf is. Refer // to the docs on `T`: https://godoc.org/testing#T t.Errorf("Failed to make HTTP request to the URL at %s: %s\n", url, err.Error()) } else if statusCode != 200 { // We use Errorf instead of Fatalf here because Fatalf is not goroutine safe, while Errorf is. Refer // to the docs on `T`: https://godoc.org/testing#T t.Errorf("Got a non-200 response (%d) from the URL at %s, which means there was downtime! Response body: %s", statusCode, url, body) } } } }() return &wg, responses } ================================================ FILE: modules/http-helper/dummy_server.go ================================================ package http_helper import ( "fmt" "net" "net/http" "strconv" "sync/atomic" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/testing" ) // RunDummyServer runs a dummy HTTP server on a unique port that will return the given text. Returns the Listener for the server, the // port it's listening on, or an error if something went wrong while trying to start the listener. Make sure to call // the Close() method on the Listener when you're done! func RunDummyServer(t testing.TestingT, text string) (net.Listener, int) { listener, port, err := RunDummyServerE(t, text) if err != nil { t.Fatal(err) } return listener, port } // RunDummyServerE runs a dummy HTTP server on a unique port that will return the given text. Returns the Listener for the server, the // port it's listening on, or an error if something went wrong while trying to start the listener. Make sure to call // the Close() method on the Listener when you're done! func RunDummyServerE(t testing.TestingT, text string) (net.Listener, int, error) { port := getNextPort() // Create new serve mux so that multiple handlers can be created server := http.NewServeMux() server.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "%s", text) }) logger.Default.Logf(t, "Starting dummy HTTP server in port %d that will return the text '%s'", port, text) listener, err := net.Listen("tcp", ":"+strconv.Itoa(port)) if err != nil { return nil, 0, fmt.Errorf("error listening: %s", err) } go http.Serve(listener, server) return listener, port, err } // RunDummyServerWithHandlers runs a dummy HTTP server on a unique port that will serve given handlers. Returns the Listener for the server, // the port it's listening on, or an error if something went wrong while trying to start the listener. Make sure to call // the Close() method on the Listener when you're done! func RunDummyServerWithHandlers(t testing.TestingT, handlers map[string]func(http.ResponseWriter, *http.Request)) (net.Listener, int) { listener, port, err := RunDummyServerWithHandlersE(t, handlers) if err != nil { t.Fatal(err) } return listener, port } // RunDummyServerWithHandlersE runs a dummy HTTP server on a unique port that will server given handlers. Returns the Listener for the server, // the port it's listening on, or an error if something went wrong while trying to start the listener. Make sure to call // the Close() method on the Listener when you're done! func RunDummyServerWithHandlersE(t testing.TestingT, handlers map[string]func(http.ResponseWriter, *http.Request)) (net.Listener, int, error) { port := getNextPort() server := http.NewServeMux() for path, handler := range handlers { server.HandleFunc(path, handler) } logger.Default.Logf(t, "Starting dummy HTTP server in port %d", port) listener, err := net.Listen("tcp", ":"+strconv.Itoa(port)) if err != nil { return nil, 0, fmt.Errorf("error listening: %s", err) } go http.Serve(listener, server) return listener, port, err } // DO NOT ACCESS THIS VARIABLE DIRECTLY. See getNextPort() below. var testServerPort int32 = 8080 // Since we run tests in parallel, we need to ensure that each test runs on a different port. This function returns a // unique port by atomically incrementing the testServerPort variable. func getNextPort() int { return int(atomic.AddInt32(&testServerPort, 1)) } ================================================ FILE: modules/http-helper/dummy_server_test.go ================================================ package http_helper import ( "crypto/tls" "fmt" "io" "net/http" "testing" "time" "github.com/gruntwork-io/terratest/modules/random" "github.com/stretchr/testify/assert" ) func TestRunDummyServer(t *testing.T) { t.Parallel() uniqueID := random.UniqueId() text := fmt.Sprintf("dummy-server-%s", uniqueID) listener, port := RunDummyServer(t, text) defer shutDownServer(t, listener) url := fmt.Sprintf("http://localhost:%d", port) HttpGetWithValidation(t, url, &tls.Config{}, 200, text) } func TestContinuouslyCheck(t *testing.T) { t.Parallel() uniqueID := random.UniqueId() text := fmt.Sprintf("dummy-server-%s", uniqueID) stopChecking := make(chan bool, 1) listener, port := RunDummyServer(t, text) url := fmt.Sprintf("http://localhost:%d", port) wg, responses := ContinuouslyCheckUrl(t, url, stopChecking, 1*time.Second) defer func() { stopChecking <- true counts := 0 for response := range responses { counts++ assert.Equal(t, response.StatusCode, 200) assert.Equal(t, response.Body, text) } wg.Wait() // Make sure we made at least one call assert.NotEqual(t, counts, 0) shutDownServer(t, listener) }() time.Sleep(5 * time.Second) } func TestRunDummyServersWithHandlers(t *testing.T) { // Given: // several dummy servers, each with the same path // When: // all of them are started at the same time // Then: // every one of them can be started and serves their unique content t.Parallel() numServers := 2 type testData struct { text string port int } data := make([]testData, numServers) for idx := 0; idx < numServers; idx++ { uniqueID := random.UniqueId() text := fmt.Sprintf("dummy-server-%s", uniqueID) handler := func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "%s", text) } handlerMap := map[string]func(http.ResponseWriter, *http.Request){ // The same endpoint is provided for each dummy server. "/v1/endpoint": handler, } listener, port := RunDummyServerWithHandlers(t, handlerMap) defer shutDownServer(t, listener) data[idx] = testData{text: text, port: port} } for _, testInstance := range data { url := fmt.Sprintf("http://localhost:%d/v1/endpoint", testInstance.port) HttpGetWithValidation(t, url, &tls.Config{}, 200, testInstance.text) } } func shutDownServer(t *testing.T, listener io.Closer) { err := listener.Close() assert.NoError(t, err) } ================================================ FILE: modules/http-helper/errors.go ================================================ package http_helper import "fmt" // ValidationFunctionFailed is an error that occurs if a validation function fails. type ValidationFunctionFailed struct { Url string Status int Body string } func (err ValidationFunctionFailed) Error() string { return fmt.Sprintf("Validation failed for URL %s. Response status: %d. Response body:\n%s", err.Url, err.Status, err.Body) } ================================================ FILE: modules/http-helper/http_helper.go ================================================ // Package http_helper contains helpers to interact with deployed resources through HTTP. package http_helper import ( "bytes" "crypto/tls" "fmt" "io" "net/http" "strings" "time" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/testing" ) type HttpGetOptions struct { Url string TlsConfig *tls.Config Timeout int } type HttpDoOptions struct { Method string Url string Body io.Reader Headers map[string]string TlsConfig *tls.Config Timeout int } // HttpGet performs an HTTP GET, with an optional pointer to a custom TLS configuration, on the given URL and // return the HTTP status code and body. If there's any error, fail the test. func HttpGet(t testing.TestingT, url string, tlsConfig *tls.Config) (int, string) { return HttpGetWithOptions(t, HttpGetOptions{Url: url, TlsConfig: tlsConfig, Timeout: 10}) } // HttpGetWithOptions performs an HTTP GET, with an optional pointer to a custom TLS configuration, on the given URL and // return the HTTP status code and body. If there's any error, fail the test. func HttpGetWithOptions(t testing.TestingT, options HttpGetOptions) (int, string) { statusCode, body, err := HttpGetWithOptionsE(t, options) if err != nil { t.Fatal(err) } return statusCode, body } // HttpGetE performs an HTTP GET, with an optional pointer to a custom TLS configuration, on the given URL and // return the HTTP status code, body, and any error. func HttpGetE(t testing.TestingT, url string, tlsConfig *tls.Config) (int, string, error) { return HttpGetWithOptionsE(t, HttpGetOptions{Url: url, TlsConfig: tlsConfig, Timeout: 10}) } // HttpGetWithOptionsE performs an HTTP GET, with an optional pointer to a custom TLS configuration, on the given URL and // return the HTTP status code, body, and any error. func HttpGetWithOptionsE(t testing.TestingT, options HttpGetOptions) (int, string, error) { logger.Default.Logf(t, "Making an HTTP GET call to URL %s", options.Url) // Set HTTP client transport config tr := http.DefaultTransport.(*http.Transport).Clone() tr.TLSClientConfig = options.TlsConfig client := http.Client{ // By default, Go does not impose a timeout, so an HTTP connection attempt can hang for a LONG time. Timeout: time.Duration(options.Timeout) * time.Second, // Include the previously created transport config Transport: tr, } resp, err := client.Get(options.Url) if err != nil { return -1, "", err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return -1, "", err } return resp.StatusCode, strings.TrimSpace(string(body)), nil } // HttpGetWithValidation performs an HTTP GET on the given URL and verify that you get back the expected status code and body. If either // doesn't match, fail the test. func HttpGetWithValidation(t testing.TestingT, url string, tlsConfig *tls.Config, expectedStatusCode int, expectedBody string) { options := HttpGetOptions{Url: url, TlsConfig: tlsConfig, Timeout: 10} HttpGetWithValidationWithOptions(t, options, expectedStatusCode, expectedBody) } // HttpGetWithValidationWithOptions performs an HTTP GET on the given URL and verify that you get back the expected status code and body. If either // doesn't match, fail the test. func HttpGetWithValidationWithOptions(t testing.TestingT, options HttpGetOptions, expectedStatusCode int, expectedBody string) { err := HttpGetWithValidationWithOptionsE(t, options, expectedStatusCode, expectedBody) if err != nil { t.Fatal(err) } } // HttpGetWithValidationE performs an HTTP GET on the given URL and verify that you get back the expected status code and body. If either // doesn't match, return an error. func HttpGetWithValidationE(t testing.TestingT, url string, tlsConfig *tls.Config, expectedStatusCode int, expectedBody string) error { options := HttpGetOptions{Url: url, TlsConfig: tlsConfig, Timeout: 10} return HttpGetWithValidationWithOptionsE(t, options, expectedStatusCode, expectedBody) } // HttpGetWithValidationWithOptionsE performs an HTTP GET on the given URL and verify that you get back the expected status code and body. If either // doesn't match, return an error. func HttpGetWithValidationWithOptionsE(t testing.TestingT, options HttpGetOptions, expectedStatusCode int, expectedBody string) error { return HttpGetWithCustomValidationWithOptionsE(t, options, func(statusCode int, body string) bool { return statusCode == expectedStatusCode && body == expectedBody }) } // HttpGetWithCustomValidation performs an HTTP GET on the given URL and validate the returned status code and body using the given function. func HttpGetWithCustomValidation(t testing.TestingT, url string, tlsConfig *tls.Config, validateResponse func(int, string) bool) { HttpGetWithCustomValidationWithOptions(t, HttpGetOptions{Url: url, TlsConfig: tlsConfig, Timeout: 10}, validateResponse) } // HttpGetWithCustomValidationWithOptions performs an HTTP GET on the given URL and validate the returned status code and body using the given function. func HttpGetWithCustomValidationWithOptions(t testing.TestingT, options HttpGetOptions, validateResponse func(int, string) bool) { err := HttpGetWithCustomValidationWithOptionsE(t, options, validateResponse) if err != nil { t.Fatal(err) } } // HttpGetWithCustomValidationE performs an HTTP GET on the given URL and validate the returned status code and body using the given function. func HttpGetWithCustomValidationE(t testing.TestingT, url string, tlsConfig *tls.Config, validateResponse func(int, string) bool) error { return HttpGetWithCustomValidationWithOptionsE(t, HttpGetOptions{Url: url, TlsConfig: tlsConfig, Timeout: 10}, validateResponse) } // HttpGetWithCustomValidationWithOptionsE performs an HTTP GET on the given URL and validate the returned status code and body using the given function. func HttpGetWithCustomValidationWithOptionsE(t testing.TestingT, options HttpGetOptions, validateResponse func(int, string) bool) error { statusCode, body, err := HttpGetWithOptionsE(t, options) if err != nil { return err } if !validateResponse(statusCode, body) { return ValidationFunctionFailed{Url: options.Url, Status: statusCode, Body: body} } return nil } // HttpGetWithRetry repeatedly performs an HTTP GET on the given URL until the given status code and body are returned or until max // retries has been exceeded. func HttpGetWithRetry(t testing.TestingT, url string, tlsConfig *tls.Config, expectedStatus int, expectedBody string, retries int, sleepBetweenRetries time.Duration) { options := HttpGetOptions{Url: url, TlsConfig: tlsConfig, Timeout: 10} HttpGetWithRetryWithOptions(t, options, expectedStatus, expectedBody, retries, sleepBetweenRetries) } // HttpGetWithRetryWithOptions repeatedly performs an HTTP GET on the given URL until the given status code and body are returned or until max // retries has been exceeded. func HttpGetWithRetryWithOptions(t testing.TestingT, options HttpGetOptions, expectedStatus int, expectedBody string, retries int, sleepBetweenRetries time.Duration) { err := HttpGetWithRetryWithOptionsE(t, options, expectedStatus, expectedBody, retries, sleepBetweenRetries) if err != nil { t.Fatal(err) } } // HttpGetWithRetryE repeatedly performs an HTTP GET on the given URL until the given status code and body are returned or until max // retries has been exceeded. func HttpGetWithRetryE(t testing.TestingT, url string, tlsConfig *tls.Config, expectedStatus int, expectedBody string, retries int, sleepBetweenRetries time.Duration) error { options := HttpGetOptions{Url: url, TlsConfig: tlsConfig, Timeout: 10} return HttpGetWithRetryWithOptionsE(t, options, expectedStatus, expectedBody, retries, sleepBetweenRetries) } // HttpGetWithRetryWithOptionsE repeatedly performs an HTTP GET on the given URL until the given status code and body are returned or until max // retries has been exceeded. func HttpGetWithRetryWithOptionsE(t testing.TestingT, options HttpGetOptions, expectedStatus int, expectedBody string, retries int, sleepBetweenRetries time.Duration) error { _, err := retry.DoWithRetryE(t, fmt.Sprintf("HTTP GET to URL %s", options.Url), retries, sleepBetweenRetries, func() (string, error) { return "", HttpGetWithValidationWithOptionsE(t, options, expectedStatus, expectedBody) }) return err } // HttpGetWithRetryWithCustomValidation repeatedly performs an HTTP GET on the given URL until the given validation function returns true or max retries // has been exceeded. func HttpGetWithRetryWithCustomValidation(t testing.TestingT, url string, tlsConfig *tls.Config, retries int, sleepBetweenRetries time.Duration, validateResponse func(int, string) bool) { options := HttpGetOptions{Url: url, TlsConfig: tlsConfig, Timeout: 10} HttpGetWithRetryWithCustomValidationWithOptions(t, options, retries, sleepBetweenRetries, validateResponse) } // HttpGetWithRetryWithCustomValidationWithOptions repeatedly performs an HTTP GET on the given URL until the given validation function returns true or max retries // has been exceeded. func HttpGetWithRetryWithCustomValidationWithOptions(t testing.TestingT, options HttpGetOptions, retries int, sleepBetweenRetries time.Duration, validateResponse func(int, string) bool) { err := HttpGetWithRetryWithCustomValidationWithOptionsE(t, options, retries, sleepBetweenRetries, validateResponse) if err != nil { t.Fatal(err) } } // HttpGetWithRetryWithCustomValidationE repeatedly performs an HTTP GET on the given URL until the given validation function returns true or max retries // has been exceeded. func HttpGetWithRetryWithCustomValidationE(t testing.TestingT, url string, tlsConfig *tls.Config, retries int, sleepBetweenRetries time.Duration, validateResponse func(int, string) bool) error { options := HttpGetOptions{Url: url, TlsConfig: tlsConfig, Timeout: 10} return HttpGetWithRetryWithCustomValidationWithOptionsE(t, options, retries, sleepBetweenRetries, validateResponse) } // HttpGetWithRetryWithCustomValidationWithOptionsE repeatedly performs an HTTP GET on the given URL until the given validation function returns true or max retries // has been exceeded. func HttpGetWithRetryWithCustomValidationWithOptionsE(t testing.TestingT, options HttpGetOptions, retries int, sleepBetweenRetries time.Duration, validateResponse func(int, string) bool) error { _, err := retry.DoWithRetryE(t, fmt.Sprintf("HTTP GET to URL %s", options.Url), retries, sleepBetweenRetries, func() (string, error) { return "", HttpGetWithCustomValidationWithOptionsE(t, options, validateResponse) }) return err } // HTTPDo performs the given HTTP method on the given URL and return the HTTP status code and body. // If there's any error, fail the test. func HTTPDo( t testing.TestingT, method string, url string, body io.Reader, headers map[string]string, tlsConfig *tls.Config, ) (int, string) { options := HttpDoOptions{ Method: method, Url: url, Body: body, Headers: headers, TlsConfig: tlsConfig, Timeout: 10} return HTTPDoWithOptions(t, options) } // HTTPDoWithOptions performs the given HTTP method on the given URL and return the HTTP status code and body. // If there's any error, fail the test. func HTTPDoWithOptions( t testing.TestingT, options HttpDoOptions, ) (int, string) { statusCode, respBody, err := HTTPDoWithOptionsE(t, options) if err != nil { t.Fatal(err) } return statusCode, respBody } // HTTPDoE performs the given HTTP method on the given URL and return the HTTP status code, body, and any error. func HTTPDoE( t testing.TestingT, method string, url string, body io.Reader, headers map[string]string, tlsConfig *tls.Config, ) (int, string, error) { options := HttpDoOptions{ Method: method, Url: url, Body: body, Headers: headers, Timeout: 10, TlsConfig: tlsConfig} return HTTPDoWithOptionsE(t, options) } // HTTPDoWithOptionsE performs the given HTTP method on the given URL and return the HTTP status code, body, and any error. func HTTPDoWithOptionsE( t testing.TestingT, options HttpDoOptions, ) (int, string, error) { logger.Default.Logf(t, "Making an HTTP %s call to URL %s", options.Method, options.Url) tr := http.DefaultTransport.(*http.Transport).Clone() tr.TLSClientConfig = options.TlsConfig client := http.Client{ // By default, Go does not impose a timeout, so an HTTP connection attempt can hang for a LONG time. Timeout: time.Duration(options.Timeout) * time.Second, Transport: tr, } req := newRequest(options.Method, options.Url, options.Body, options.Headers) resp, err := client.Do(req) if err != nil { return -1, "", err } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return -1, "", err } return resp.StatusCode, strings.TrimSpace(string(respBody)), nil } // HTTPDoWithRetry repeatedly performs the given HTTP method on the given URL until the given status code and body are // returned or until max retries has been exceeded. // The function compares the expected status code against the received one and fails if they don't match. func HTTPDoWithRetry( t testing.TestingT, method string, url string, body []byte, headers map[string]string, expectedStatus int, retries int, sleepBetweenRetries time.Duration, tlsConfig *tls.Config, ) string { options := HttpDoOptions{ Method: method, Url: url, Body: bytes.NewReader(body), Headers: headers, TlsConfig: tlsConfig, Timeout: 10} return HTTPDoWithRetryWithOptions(t, options, expectedStatus, retries, sleepBetweenRetries) } // HTTPDoWithRetryWithOptions repeatedly performs the given HTTP method on the given URL until the given status code and body are // returned or until max retries has been exceeded. // The function compares the expected status code against the received one and fails if they don't match. func HTTPDoWithRetryWithOptions( t testing.TestingT, options HttpDoOptions, expectedStatus int, retries int, sleepBetweenRetries time.Duration, ) string { out, err := HTTPDoWithRetryWithOptionsE(t, options, expectedStatus, retries, sleepBetweenRetries) if err != nil { t.Fatal(err) } return out } // HTTPDoWithRetryE repeatedly performs the given HTTP method on the given URL until the given status code and body are // returned or until max retries has been exceeded. // The function compares the expected status code against the received one and fails if they don't match. func HTTPDoWithRetryE( t testing.TestingT, method string, url string, body []byte, headers map[string]string, expectedStatus int, retries int, sleepBetweenRetries time.Duration, tlsConfig *tls.Config, ) (string, error) { options := HttpDoOptions{ Method: method, Url: url, Body: bytes.NewReader(body), Headers: headers, TlsConfig: tlsConfig, Timeout: 10} return HTTPDoWithRetryWithOptionsE(t, options, expectedStatus, retries, sleepBetweenRetries) } // HTTPDoWithRetryWithOptionsE repeatedly performs the given HTTP method on the given URL until the given status code and body are // returned or until max retries has been exceeded. // The function compares the expected status code against the received one and fails if they don't match. func HTTPDoWithRetryWithOptionsE( t testing.TestingT, options HttpDoOptions, expectedStatus int, retries int, sleepBetweenRetries time.Duration, ) (string, error) { var data []byte if options.Body != nil { // The request body is closed after a request is complete. // Read the underlying data and cache it, so we can reuse for retried requests. b, err := io.ReadAll(options.Body) if err != nil { return "", err } data = b } options.Body = nil out, err := retry.DoWithRetryE( t, fmt.Sprintf("HTTP %s to URL %s", options.Method, options.Url), retries, sleepBetweenRetries, func() (string, error) { options.Body = bytes.NewReader(data) statusCode, out, err := HTTPDoWithOptionsE(t, options) if err != nil { return "", err } logger.Default.Logf(t, "output: %v", out) if statusCode != expectedStatus { return "", ValidationFunctionFailed{Url: options.Url, Status: statusCode} } return out, nil }) return out, err } // HTTPDoWithValidationRetry repeatedly performs the given HTTP method on the given URL until the given status code and // body are returned or until max retries has been exceeded. func HTTPDoWithValidationRetry( t testing.TestingT, method string, url string, body []byte, headers map[string]string, expectedStatus int, expectedBody string, retries int, sleepBetweenRetries time.Duration, tlsConfig *tls.Config, ) { options := HttpDoOptions{ Method: method, Url: url, Body: bytes.NewReader(body), Headers: headers, TlsConfig: tlsConfig, Timeout: 10} HTTPDoWithValidationRetryWithOptions(t, options, expectedStatus, expectedBody, retries, sleepBetweenRetries) } // HTTPDoWithValidationRetryWithOptions repeatedly performs the given HTTP method on the given URL until the given status code and // body are returned or until max retries has been exceeded. func HTTPDoWithValidationRetryWithOptions( t testing.TestingT, options HttpDoOptions, expectedStatus int, expectedBody string, retries int, sleepBetweenRetries time.Duration, ) { err := HTTPDoWithValidationRetryWithOptionsE(t, options, expectedStatus, expectedBody, retries, sleepBetweenRetries) if err != nil { t.Fatal(err) } } // HTTPDoWithValidationRetryE repeatedly performs the given HTTP method on the given URL until the given status code and // body are returned or until max retries has been exceeded. func HTTPDoWithValidationRetryE( t testing.TestingT, method string, url string, body []byte, headers map[string]string, expectedStatus int, expectedBody string, retries int, sleepBetweenRetries time.Duration, tlsConfig *tls.Config, ) error { options := HttpDoOptions{ Method: method, Url: url, Body: bytes.NewReader(body), Headers: headers, TlsConfig: tlsConfig, Timeout: 10} return HTTPDoWithValidationRetryWithOptionsE(t, options, expectedStatus, expectedBody, retries, sleepBetweenRetries) } // HTTPDoWithValidationRetryWithOptionsE repeatedly performs the given HTTP method on the given URL until the given status code and // body are returned or until max retries has been exceeded. func HTTPDoWithValidationRetryWithOptionsE( t testing.TestingT, options HttpDoOptions, expectedStatus int, expectedBody string, retries int, sleepBetweenRetries time.Duration, ) error { _, err := retry.DoWithRetryE(t, fmt.Sprintf("HTTP %s to URL %s", options.Method, options.Url), retries, sleepBetweenRetries, func() (string, error) { return "", HTTPDoWithValidationWithOptionsE(t, options, expectedStatus, expectedBody) }) return err } // HTTPDoWithValidation performs the given HTTP method on the given URL and verify that you get back the expected status // code and body. If either doesn't match, fail the test. func HTTPDoWithValidation(t testing.TestingT, method string, url string, body io.Reader, headers map[string]string, expectedStatusCode int, expectedBody string, tlsConfig *tls.Config) { options := HttpDoOptions{ Method: method, Url: url, Body: body, Headers: headers, TlsConfig: tlsConfig, Timeout: 10} HTTPDoWithValidationWithOptions(t, options, expectedStatusCode, expectedBody) } // HTTPDoWithValidationWithOptions performs the given HTTP method on the given URL and verify that you get back the expected status // code and body. If either doesn't match, fail the test. func HTTPDoWithValidationWithOptions(t testing.TestingT, options HttpDoOptions, expectedStatusCode int, expectedBody string) { err := HTTPDoWithValidationWithOptionsE(t, options, expectedStatusCode, expectedBody) if err != nil { t.Fatal(err) } } // HTTPDoWithValidationE performs the given HTTP method on the given URL and verify that you get back the expected status // code and body. If either doesn't match, return an error. func HTTPDoWithValidationE(t testing.TestingT, method string, url string, body io.Reader, headers map[string]string, expectedStatusCode int, expectedBody string, tlsConfig *tls.Config) error { options := HttpDoOptions{ Method: method, Url: url, Body: body, Headers: headers, TlsConfig: tlsConfig, Timeout: 10} return HTTPDoWithValidationWithOptionsE(t, options, expectedStatusCode, expectedBody) } // HTTPDoWithValidationWithOptionsE performs the given HTTP method on the given URL and verify that you get back the expected status // code and body. If either doesn't match, return an error. func HTTPDoWithValidationWithOptionsE(t testing.TestingT, options HttpDoOptions, expectedStatusCode int, expectedBody string) error { return HTTPDoWithCustomValidationWithOptionsE(t, options, func(statusCode int, body string) bool { return statusCode == expectedStatusCode && body == expectedBody }) } // HTTPDoWithCustomValidation performs the given HTTP method on the given URL and validate the returned status code and // body using the given function. func HTTPDoWithCustomValidation(t testing.TestingT, method string, url string, body io.Reader, headers map[string]string, validateResponse func(int, string) bool, tlsConfig *tls.Config) { options := HttpDoOptions{ Method: method, Url: url, Body: body, Headers: headers, TlsConfig: tlsConfig, Timeout: 10} HTTPDoWithCustomValidationWithOptions(t, options, validateResponse) } // HTTPDoWithCustomValidationWithOptions performs the given HTTP method on the given URL and validate the returned status code and // body using the given function. func HTTPDoWithCustomValidationWithOptions(t testing.TestingT, options HttpDoOptions, validateResponse func(int, string) bool) { err := HTTPDoWithCustomValidationWithOptionsE(t, options, validateResponse) if err != nil { t.Fatal(err) } } // HTTPDoWithCustomValidationE performs the given HTTP method on the given URL and validate the returned status code and // body using the given function. func HTTPDoWithCustomValidationE(t testing.TestingT, method string, url string, body io.Reader, headers map[string]string, validateResponse func(int, string) bool, tlsConfig *tls.Config) error { options := HttpDoOptions{ Method: method, Url: url, Body: body, Headers: headers, TlsConfig: tlsConfig, Timeout: 10} return HTTPDoWithCustomValidationWithOptionsE(t, options, validateResponse) } // HTTPDoWithCustomValidationWithOptionsE performs the given HTTP method on the given URL and validate the returned status code and // body using the given function. func HTTPDoWithCustomValidationWithOptionsE(t testing.TestingT, options HttpDoOptions, validateResponse func(int, string) bool) error { statusCode, respBody, err := HTTPDoWithOptionsE(t, options) if err != nil { return err } if !validateResponse(statusCode, respBody) { return ValidationFunctionFailed{Url: options.Url, Status: statusCode, Body: respBody} } return nil } func newRequest(method string, url string, body io.Reader, headers map[string]string) *http.Request { req, err := http.NewRequest(method, url, body) if err != nil { return nil } for k, v := range headers { switch k { case "Host": req.Host = v default: req.Header.Add(k, v) } } return req } ================================================ FILE: modules/http-helper/http_helper_test.go ================================================ package http_helper import ( "bytes" "fmt" "io" "net/http" "net/http/httptest" "regexp" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func getTestServerForFunction(handler func(w http.ResponseWriter, r *http.Request)) *httptest.Server { return httptest.NewServer(http.HandlerFunc(handler)) } func TestOkBody(t *testing.T) { t.Parallel() ts := getTestServerForFunction(bodyCopyHandler) defer ts.Close() url := ts.URL expectedBody := "Hello, Terratest!" body := bytes.NewReader([]byte(expectedBody)) statusCode, respBody := HTTPDo(t, "POST", url, body, nil, nil) expectedCode := 200 if statusCode != expectedCode { t.Errorf("handler returned wrong status code: got %v want %v", statusCode, expectedCode) } if respBody != expectedBody { t.Errorf("handler returned wrong body: got %v want %v", respBody, expectedBody) } } func TestHTTPDoWithValidation(t *testing.T) { t.Parallel() ts := getTestServerForFunction(bodyCopyHandler) defer ts.Close() url := ts.URL expectedBody := "Hello, Terratest!" body := bytes.NewReader([]byte(expectedBody)) HTTPDoWithValidation(t, "POST", url, body, nil, 200, expectedBody, nil) } func TestHTTPDoWithCustomValidation(t *testing.T) { t.Parallel() ts := getTestServerForFunction(bodyCopyHandler) defer ts.Close() url := ts.URL expectedBody := "Hello, Terratest!" body := bytes.NewReader([]byte(expectedBody)) customValidation := func(statusCode int, response string) bool { return statusCode == 200 && response == expectedBody } HTTPDoWithCustomValidation(t, "POST", url, body, nil, customValidation, nil) } func TestOkHeaders(t *testing.T) { t.Parallel() ts := getTestServerForFunction(headersCopyHandler) defer ts.Close() url := ts.URL headers := map[string]string{"Authorization": "Bearer 1a2b3c99ff"} statusCode, respBody := HTTPDo(t, "POST", url, nil, headers, nil) expectedCode := 200 if statusCode != expectedCode { t.Errorf("handler returned wrong status code: got %v want %v", statusCode, expectedCode) } expectedLine := "Authorization: Bearer 1a2b3c99ff" if !strings.Contains(respBody, expectedLine) { t.Errorf("handler returned wrong body: got %v want %v", respBody, expectedLine) } } func TestWrongStatus(t *testing.T) { t.Parallel() ts := getTestServerForFunction(wrongStatusHandler) defer ts.Close() url := ts.URL statusCode, _ := HTTPDo(t, "POST", url, nil, nil, nil) expectedCode := 500 if statusCode != expectedCode { t.Errorf("handler returned wrong status code: got %v want %v", statusCode, expectedCode) } } func TestRequestTimeout(t *testing.T) { t.Parallel() ts := getTestServerForFunction(sleepingHandler) defer ts.Close() url := ts.URL _, _, err := HTTPDoE(t, "DELETE", url, nil, nil, nil) if err == nil { t.Error("handler didn't return a timeout error") } if !strings.Contains(err.Error(), "Client.Timeout") { t.Errorf("handler didn't return an expected error, got %q", err) } } func TestOkWithRetry(t *testing.T) { t.Parallel() ts := getTestServerForFunction(retryHandler) defer ts.Close() body := "TEST_CONTENT" bodyBytes := []byte(body) url := ts.URL counter = 3 response := HTTPDoWithRetry(t, "POST", url, bodyBytes, nil, 200, 10, time.Second, nil) require.Equal(t, body, response) } func TestErrorWithRetry(t *testing.T) { t.Parallel() ts := getTestServerForFunction(failRetryHandler) defer ts.Close() failCounter = 3 url := ts.URL _, err := HTTPDoWithRetryE(t, "POST", url, nil, nil, 200, 2, time.Second, nil) if err == nil { t.Error("handler didn't return a retry error") } pattern := `unsuccessful after \d+ retries` match, _ := regexp.MatchString(pattern, err.Error()) if !match { t.Errorf("handler didn't return an expected error, got %q", err) } } func TestEmptyRequestBodyWithRetryWithOptions(t *testing.T) { t.Parallel() ts := getTestServerForFunction(bodyCopyHandler) defer ts.Close() options := HttpDoOptions{ Method: "GET", Url: ts.URL, Body: nil, } response := HTTPDoWithRetryWithOptions(t, options, 200, 0, time.Second) require.Equal(t, "", response) } func bodyCopyHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) body, _ := io.ReadAll(r.Body) w.Write(body) } func headersCopyHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) var buffer bytes.Buffer for key, values := range r.Header { buffer.WriteString(fmt.Sprintf("%s: %s\n", key, strings.Join(values, ","))) } w.Write(buffer.Bytes()) } func wrongStatusHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) } func sleepingHandler(w http.ResponseWriter, r *http.Request) { time.Sleep(time.Second * 15) } var counter int func retryHandler(w http.ResponseWriter, r *http.Request) { if counter > 0 { counter-- w.WriteHeader(http.StatusServiceUnavailable) io.ReadAll(r.Body) } else { w.WriteHeader(http.StatusOK) bytes, _ := io.ReadAll(r.Body) w.Write(bytes) } } var failCounter int func failRetryHandler(w http.ResponseWriter, r *http.Request) { if failCounter > 0 { failCounter-- w.WriteHeader(http.StatusServiceUnavailable) io.ReadAll(r.Body) } else { w.WriteHeader(http.StatusOK) bytes, _ := io.ReadAll(r.Body) w.Write(bytes) } } func TestGlobalProxy(t *testing.T) { proxiedURL := "" httpProxy := getTestServerForFunction(func(w http.ResponseWriter, r *http.Request) { proxiedURL = r.RequestURI bodyCopyHandler(w, r) }) t.Cleanup(httpProxy.Close) t.Setenv("HTTP_PROXY", httpProxy.URL) targetURL := "http://www.notexist.com/" body := "should be copied" st, b, err := HTTPDoWithOptionsE(t, HttpDoOptions{ Url: targetURL, Method: http.MethodPost, Body: strings.NewReader(body), }) require.NoError(t, err) assert.Equal(t, http.StatusOK, st) assert.Equal(t, targetURL, proxiedURL) assert.Equal(t, body, b) } ================================================ FILE: modules/k8s/client.go ================================================ package k8s import ( "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" // The following line loads the gcp plugin which is required to authenticate against GKE clusters. // See: https://github.com/kubernetes/client-go/issues/242 _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" "github.com/gruntwork-io/terratest/modules/testing" ) // GetKubernetesClientE returns a Kubernetes API client that can be used to make requests. func GetKubernetesClientE(t testing.TestingT) (*kubernetes.Clientset, error) { kubeConfigPath, err := GetKubeConfigPathE(t) if err != nil { return nil, err } options := NewKubectlOptions("", kubeConfigPath, "default") return GetKubernetesClientFromOptionsE(t, options) } // GetKubernetesClientFromOptionsE returns a Kubernetes API client given a configured KubectlOptions object. func GetKubernetesClientFromOptionsE(t testing.TestingT, options *KubectlOptions) (*kubernetes.Clientset, error) { var err error var config *rest.Config if options.InClusterAuth { config, err = rest.InClusterConfig() if err != nil { return nil, err } options.Logger.Logf(t, "Configuring Kubernetes client to use the in-cluster serviceaccount token") } else if options.RestConfig != nil { config = options.RestConfig options.Logger.Logf(t, "Configuring Kubernetes client to use provided rest config object set with API server address: %s", config.Host) } else { kubeConfigPath, err := options.GetConfigPath(t) if err != nil { return nil, err } options.Logger.Logf(t, "Configuring Kubernetes client using config file %s with context %s", kubeConfigPath, options.ContextName) // Load API config (instead of more low level ClientConfig) config, err = LoadApiClientConfigE(kubeConfigPath, options.ContextName) if err != nil { options.Logger.Logf(t, "Error loading api client config, falling back to in-cluster authentication via serviceaccount token: %s", err) config, err = rest.InClusterConfig() if err != nil { return nil, err } options.Logger.Logf(t, "Configuring Kubernetes client to use the in-cluster serviceaccount token") } } clientset, err := kubernetes.NewForConfig(config) if err != nil { return nil, err } return clientset, nil } ================================================ FILE: modules/k8s/cluster_role.go ================================================ package k8s import ( "context" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" rbacv1 "k8s.io/api/rbac/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // GetClusterRole returns a Kubernetes ClusterRole resource with the given name. This will fail the test if there is an error. func GetClusterRole(t testing.TestingT, options *KubectlOptions, roleName string) *rbacv1.ClusterRole { role, err := GetClusterRoleE(t, options, roleName) require.NoError(t, err) return role } // GetClusterRoleE returns a Kubernetes ClusterRole resource with the given name. func GetClusterRoleE(t testing.TestingT, options *KubectlOptions, roleName string) (*rbacv1.ClusterRole, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } return clientset.RbacV1().ClusterRoles().Get(context.Background(), roleName, metav1.GetOptions{}) } ================================================ FILE: modules/k8s/cluster_role_test.go ================================================ //go:build kubeall || kubernetes // +build kubeall kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package k8s import ( "testing" "github.com/stretchr/testify/require" ) func TestGetClusterRoleEReturnsErrorForNonExistantClusterRole(t *testing.T) { t.Parallel() options := NewKubectlOptions("", "", "default") _, err := GetClusterRoleE(t, options, "non-existing-role") require.Error(t, err) } func TestGetClusterRoleEReturnsCorrectClusterRoleInCorrectNamespace(t *testing.T) { t.Parallel() options := NewKubectlOptions("", "", "default") defer KubectlDeleteFromString(t, options, EXAMPLE_CLUSTER_ROLE_YAML_TEMPLATE) KubectlApplyFromString(t, options, EXAMPLE_CLUSTER_ROLE_YAML_TEMPLATE) role := GetClusterRole(t, options, "terratest-cluster-role") require.Equal(t, role.Name, "terratest-cluster-role") } const EXAMPLE_CLUSTER_ROLE_YAML_TEMPLATE = `--- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: 'terratest-cluster-role' rules: - apiGroups: - '*' resources: - '*' verbs: - '*' ` ================================================ FILE: modules/k8s/config.go ================================================ package k8s import ( "errors" "os" "path/filepath" "sort" gwErrors "github.com/gruntwork-io/go-commons/errors" homedir "github.com/mitchellh/go-homedir" restclient "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/tools/clientcmd/api" "github.com/gruntwork-io/terratest/modules/environment" "github.com/gruntwork-io/terratest/modules/files" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/testing" ) // LoadConfigFromPath will load a ClientConfig object from a file path that points to a location on disk containing a // kubectl config. func LoadConfigFromPath(path string) clientcmd.ClientConfig { config := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( &clientcmd.ClientConfigLoadingRules{ExplicitPath: path}, &clientcmd.ConfigOverrides{}) return config } // LoadApiClientConfigE will load a ClientConfig object from a file path that points to a location on disk containing a // kubectl config, with the requested context loaded. func LoadApiClientConfigE(configPath string, contextName string) (*restclient.Config, error) { overrides := clientcmd.ConfigOverrides{} if contextName != "" { overrides.CurrentContext = contextName } config := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( &clientcmd.ClientConfigLoadingRules{ExplicitPath: configPath}, &overrides) return config.ClientConfig() } // DeleteConfigContextE will remove the context specified at the provided name, and remove any clusters and authinfos // that are orphaned as a result of it. The config path is either specified in the environment variable KUBECONFIG or at // the user's home directory under `.kube/config`. func DeleteConfigContextE(t testing.TestingT, contextName string) error { kubeConfigPath, err := GetKubeConfigPathE(t) if err != nil { return err } return DeleteConfigContextWithPathE(t, kubeConfigPath, contextName) } // DeleteConfigContextWithPathE will remove the context specified at the provided name, and remove any clusters and // authinfos that are orphaned as a result of it. func DeleteConfigContextWithPathE(t testing.TestingT, kubeConfigPath string, contextName string) error { logger.Default.Logf(t, "Removing kubectl config context %s from config at path %s", contextName, kubeConfigPath) // Load config and get data structure representing config info config := LoadConfigFromPath(kubeConfigPath) rawConfig, err := config.RawConfig() if err != nil { return err } // Check if the context we want to delete actually exists, and if so, delete it. _, ok := rawConfig.Contexts[contextName] if !ok { logger.Default.Logf(t, "WARNING: Could not find context %s from config at path %s", contextName, kubeConfigPath) return nil } delete(rawConfig.Contexts, contextName) // If the removing context is the current context, be sure to set a new one if contextName == rawConfig.CurrentContext { if err := setNewContext(&rawConfig); err != nil { return err } } // Finally, clean up orphaned clusters and authinfos and then save config RemoveOrphanedClusterAndAuthInfoConfig(&rawConfig) if err := clientcmd.ModifyConfig(config.ConfigAccess(), rawConfig, false); err != nil { return err } logger.Default.Logf( t, "Removed context %s from config at path %s and any orphaned clusters and authinfos", contextName, kubeConfigPath) return nil } // setNewContext will pick the alphebetically first available context from the list of contexts in the config to use as // the new current context func setNewContext(config *api.Config) error { // Sort contextNames and pick the first one var contextNames []string for name := range config.Contexts { contextNames = append(contextNames, name) } sort.Strings(contextNames) if len(contextNames) > 0 { config.CurrentContext = contextNames[0] } else { return errors.New("There are no available contexts remaining") } return nil } // RemoveOrphanedClusterAndAuthInfoConfig will remove all configurations related to clusters and users that have no // contexts associated with it func RemoveOrphanedClusterAndAuthInfoConfig(config *api.Config) { newAuthInfos := map[string]*api.AuthInfo{} newClusters := map[string]*api.Cluster{} for _, context := range config.Contexts { newClusters[context.Cluster] = config.Clusters[context.Cluster] newAuthInfos[context.AuthInfo] = config.AuthInfos[context.AuthInfo] } config.AuthInfos = newAuthInfos config.Clusters = newClusters } // GetKubeConfigPathE determines which file path to use as the kubectl config path func GetKubeConfigPathE(t testing.TestingT) (string, error) { kubeConfigPath := environment.GetFirstNonEmptyEnvVarOrEmptyString(t, []string{"KUBECONFIG"}) if kubeConfigPath == "" { configPath, err := KubeConfigPathFromHomeDirE() if err != nil { return "", err } kubeConfigPath = configPath } return kubeConfigPath, nil } // KubeConfigPathFromHomeDirE returns a string to the default Kubernetes config path in the home directory. This will // error if the home directory can not be determined. func KubeConfigPathFromHomeDirE() (string, error) { home, err := homedir.Dir() if err != nil { return "", err } configPath := filepath.Join(home, ".kube", "config") return configPath, err } // CopyHomeKubeConfigToTemp will copy the kubeconfig in the home directory to a temp file. This will fail the test if // there are any errors. func CopyHomeKubeConfigToTemp(t testing.TestingT) string { path, err := CopyHomeKubeConfigToTempE(t) if err != nil { if path != "" { os.Remove(path) } t.Fatal(err) } return path } // CopyHomeKubeConfigToTempE will copy the kubeconfig in the home directory to a temp file. func CopyHomeKubeConfigToTempE(t testing.TestingT) (string, error) { configPath, err := KubeConfigPathFromHomeDirE() if err != nil { return "", err } tmpConfig, err := os.CreateTemp("", "") if err != nil { return "", gwErrors.WithStackTrace(err) } defer tmpConfig.Close() err = files.CopyFile(configPath, tmpConfig.Name()) return tmpConfig.Name(), err } // UpsertConfigContext will update or insert a new context to the provided config, binding the provided cluster to the // provided user. func UpsertConfigContext(config *api.Config, contextName string, clusterName string, userName string) { config.Contexts[contextName] = &api.Context{Cluster: clusterName, AuthInfo: userName} } ================================================ FILE: modules/k8s/config_test.go ================================================ //go:build kubeall || kubernetes // +build kubeall kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package k8s import ( "os" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/client-go/tools/clientcmd" ) func TestDeleteConfigContext(t *testing.T) { t.Parallel() path := StoreConfigToTempFile(t, BASIC_CONFIG_WITH_EXTRA_CONTEXT) defer os.Remove(path) err := DeleteConfigContextWithPathE(t, path, "extra_minikube") require.NoError(t, err) data, err := os.ReadFile(path) require.NoError(t, err) storedConfig := string(data) assert.Equal(t, BASIC_CONFIG, storedConfig) } func TestDeleteConfigContextWithAnotherContextRemaining(t *testing.T) { t.Parallel() path := StoreConfigToTempFile(t, BASIC_CONFIG_WITH_EXTRA_CONTEXT_NO_GARBAGE) defer os.Remove(path) err := DeleteConfigContextWithPathE(t, path, "extra_minikube") require.NoError(t, err) data, err := os.ReadFile(path) require.NoError(t, err) storedConfig := string(data) assert.Equal(t, EXPECTED_CONFIG_AFTER_EXTRA_MINIKUBE_DELETED_NO_GARBAGE, storedConfig) } func TestRemoveOrphanedClusterAndAuthInfoConfig(t *testing.T) { t.Parallel() testCases := []struct { name string in string out string }{ { "TestExtraClusterRemoveOrphanedClusterAndAuthInfoed", BASIC_CONFIG_WITH_EXTRA_CLUSTER, BASIC_CONFIG, }, { "TestExtraAuthInfoRemoveOrphanedClusterAndAuthInfoed", BASIC_CONFIG_WITH_EXTRA_AUTH_INFO, BASIC_CONFIG, }, } for _, testCase := range testCases { // Capture range variable to scope within range testCase := testCase t.Run(testCase.name, func(t *testing.T) { t.Parallel() removeOrphanedClusterAndAuthInfoConfigTestFunc(t, testCase.in, testCase.out) }) } } func removeOrphanedClusterAndAuthInfoConfigTestFunc(t *testing.T, inputConfig string, expectedOutputConfig string) { path := StoreConfigToTempFile(t, inputConfig) defer os.Remove(path) config := LoadConfigFromPath(path) rawConfig, err := config.RawConfig() require.NoError(t, err) RemoveOrphanedClusterAndAuthInfoConfig(&rawConfig) err = clientcmd.ModifyConfig(config.ConfigAccess(), rawConfig, false) require.NoError(t, err) data, err := os.ReadFile(path) require.NoError(t, err) storedConfig := string(data) assert.Equal(t, expectedOutputConfig, storedConfig) } // Various example configs used in testing the config manipulation functions const BASIC_CONFIG = `apiVersion: v1 clusters: - cluster: certificate-authority: /home/terratest/.minikube/ca.crt server: https://172.17.0.48:8443 name: minikube contexts: - context: cluster: minikube user: minikube name: minikube current-context: minikube kind: Config users: - name: minikube user: client-certificate: /home/terratest/.minikube/client.crt client-key: /home/terratest/.minikube/client.key ` const BASIC_CONFIG_WITH_EXTRA_CLUSTER = `apiVersion: v1 clusters: - cluster: certificate-authority: /home/terratest/.minikube/ca.crt server: https://172.17.0.48:8443 name: minikube - cluster: certificate-authority: /home/terratest/.minikube/extra_ca.crt server: https://172.17.0.48:8443 name: extra_minikube contexts: - context: cluster: minikube user: minikube name: minikube current-context: minikube kind: Config preferences: {} users: - name: minikube user: client-certificate: /home/terratest/.minikube/client.crt client-key: /home/terratest/.minikube/client.key ` const BASIC_CONFIG_WITH_EXTRA_AUTH_INFO = `apiVersion: v1 clusters: - cluster: certificate-authority: /home/terratest/.minikube/ca.crt server: https://172.17.0.48:8443 name: minikube contexts: - context: cluster: minikube user: minikube name: minikube current-context: minikube kind: Config preferences: {} users: - name: minikube user: client-certificate: /home/terratest/.minikube/client.crt client-key: /home/terratest/.minikube/client.key - name: extra_minikube user: client-certificate: /home/terratest/.minikube/extra_client.crt client-key: /home/terratest/.minikube/extra_client.key ` const BASIC_CONFIG_WITH_EXTRA_CONTEXT = `apiVersion: v1 clusters: - cluster: certificate-authority: /home/terratest/.minikube/ca.crt server: https://172.17.0.48:8443 name: minikube - cluster: certificate-authority: /home/terratest/.minikube/extra_ca.crt server: https://172.17.0.48:8443 name: extra_minikube contexts: - context: cluster: minikube user: minikube name: minikube - context: cluster: extra_minikube user: extra_minikube name: extra_minikube current-context: extra_minikube kind: Config preferences: {} users: - name: minikube user: client-certificate: /home/terratest/.minikube/client.crt client-key: /home/terratest/.minikube/client.key - name: extra_minikube user: client-certificate: /home/terratest/.minikube/extra_client.crt client-key: /home/terratest/.minikube/extra_client.key ` const BASIC_CONFIG_WITH_EXTRA_CONTEXT_NO_GARBAGE = `apiVersion: v1 clusters: - cluster: certificate-authority: /home/terratest/.minikube/ca.crt server: https://172.17.0.48:8443 name: minikube - cluster: certificate-authority: /home/terratest/.minikube/extra_ca.crt server: https://172.17.0.48:8443 name: extra_minikube contexts: - context: cluster: minikube user: minikube name: minikube - context: cluster: extra_minikube user: extra_minikube name: extra_minikube - context: cluster: extra_minikube user: minikube name: other_minikube current-context: extra_minikube kind: Config preferences: {} users: - name: minikube user: client-certificate: /home/terratest/.minikube/client.crt client-key: /home/terratest/.minikube/client.key - name: extra_minikube user: client-certificate: /home/terratest/.minikube/extra_client.crt client-key: /home/terratest/.minikube/extra_client.key ` const EXPECTED_CONFIG_AFTER_EXTRA_MINIKUBE_DELETED_NO_GARBAGE = `apiVersion: v1 clusters: - cluster: certificate-authority: /home/terratest/.minikube/extra_ca.crt server: https://172.17.0.48:8443 name: extra_minikube - cluster: certificate-authority: /home/terratest/.minikube/ca.crt server: https://172.17.0.48:8443 name: minikube contexts: - context: cluster: minikube user: minikube name: minikube - context: cluster: extra_minikube user: minikube name: other_minikube current-context: minikube kind: Config users: - name: minikube user: client-certificate: /home/terratest/.minikube/client.crt client-key: /home/terratest/.minikube/client.key ` ================================================ FILE: modules/k8s/configmap.go ================================================ package k8s import ( "context" "fmt" "time" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // GetConfigMap returns a Kubernetes configmap resource in the provided namespace with the given name. The namespace used // is the one provided in the KubectlOptions. This will fail the test if there is an error. func GetConfigMap(t testing.TestingT, options *KubectlOptions, configMapName string) *corev1.ConfigMap { configMap, err := GetConfigMapE(t, options, configMapName) require.NoError(t, err) return configMap } // GetConfigMapE returns a Kubernetes configmap resource in the provided namespace with the given name. The namespace used // is the one provided in the KubectlOptions. func GetConfigMapE(t testing.TestingT, options *KubectlOptions, configMapName string) (*corev1.ConfigMap, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } return clientset.CoreV1().ConfigMaps(options.Namespace).Get(context.Background(), configMapName, metav1.GetOptions{}) } // WaitUntilConfigMapAvailable waits until the configmap is present on the cluster in cases where it is not immediately // available (for example, when using ClusterIssuer to request a certificate). func WaitUntilConfigMapAvailable(t testing.TestingT, options *KubectlOptions, configMapName string, retries int, sleepBetweenRetries time.Duration) { statusMsg := fmt.Sprintf("Wait for configmap %s to be provisioned.", configMapName) message := retry.DoWithRetry( t, statusMsg, retries, sleepBetweenRetries, func() (string, error) { _, err := GetConfigMapE(t, options, configMapName) if err != nil { return "", err } return "configmap is now available", nil }, ) options.Logger.Logf(t, "%s", message) } ================================================ FILE: modules/k8s/configmap_test.go ================================================ //go:build kubeall || kubernetes // +build kubeall kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package k8s import ( "fmt" "strings" "testing" "time" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terratest/modules/random" ) func TestGetConfigMapEReturnsErrorForNonExistantConfigMap(t *testing.T) { t.Parallel() options := NewKubectlOptions("", "", "default") _, err := GetConfigMapE(t, options, "test-config-map") require.Error(t, err) } func TestGetConfigMapEReturnsCorrectConfigMapInCorrectNamespace(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_CONFIGMAP_YAML_TEMPLATE, uniqueID, uniqueID) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) configMap := GetConfigMap(t, options, "test-config-map") require.Equal(t, configMap.Name, "test-config-map") require.Equal(t, configMap.Namespace, uniqueID) } func TestWaitUntilConfigMapAvailableReturnsSuccessfully(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_CONFIGMAP_YAML_TEMPLATE, uniqueID, uniqueID) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) WaitUntilConfigMapAvailable(t, options, "test-config-map", 10, 1*time.Second) } const EXAMPLE_CONFIGMAP_YAML_TEMPLATE = `--- apiVersion: v1 kind: Namespace metadata: name: %s --- apiVersion: v1 kind: ConfigMap metadata: name: test-config-map namespace: %s ` ================================================ FILE: modules/k8s/cronjob.go ================================================ package k8s import ( "context" "fmt" "time" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" batchv1 "k8s.io/api/batch/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // ListCronJobs list cron jobs in namespace that match provided filters. This will fail the test if there is an error. func ListCronJobs(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) []batchv1.CronJob { cronJobs, err := ListCronJobsE(t, options, filters) require.NoError(t, err) return cronJobs } // ListCronJobsE list cron jobs in namespace that match provided filters. This will return list or error. func ListCronJobsE(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) ([]batchv1.CronJob, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } resp, err := clientset.BatchV1().CronJobs(options.Namespace).List(context.Background(), filters) if err != nil { return nil, err } return resp.Items, nil } // GetCronJob return cron job resource from namespace by name. This will fail the test if there is an error. func GetCronJob(t testing.TestingT, options *KubectlOptions, cronJobName string) *batchv1.CronJob { job, err := GetCronJobE(t, options, cronJobName) require.NoError(t, err) return job } // GetCronJobE return cron job resource from namespace by name. This will return cron job or error. func GetCronJobE(t testing.TestingT, options *KubectlOptions, cronJobName string) (*batchv1.CronJob, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } return clientset.BatchV1().CronJobs(options.Namespace).Get(context.Background(), cronJobName, metav1.GetOptions{}) } // WaitUntilCronJobSucceed waits until cron job will successfully complete a job. This will fail the test if there is an // error or if the check times out. func WaitUntilCronJobSucceed(t testing.TestingT, options *KubectlOptions, cronJobName string, retries int, sleepBetweenRetries time.Duration) { require.NoError(t, WaitUntilCronJobSucceedE(t, options, cronJobName, retries, sleepBetweenRetries)) } // WaitUntilCronJobSucceedE waits until cron job will successfully complete a job, retrying the check for the specified // amount of times, sleeping for the provided duration between each try. func WaitUntilCronJobSucceedE(t testing.TestingT, options *KubectlOptions, cronJobName string, retries int, sleepBetweenRetries time.Duration) error { statusMsg := fmt.Sprintf("Wait for CronJob %s to successfully schedule container", cronJobName) message, err := retry.DoWithRetryE( t, statusMsg, retries, sleepBetweenRetries, func() (string, error) { job, err := GetCronJobE(t, options, cronJobName) if err != nil { return "", err } if !IsCronJobSucceeded(job) { return "", NewCronJobNotSucceeded(job) } return "CronJob scheduled container", nil }, ) if err != nil { options.Logger.Logf(t, "Timed out waiting for CronJob to schedule job: %s", err) return err } options.Logger.Logf(t, "%s", message) return nil } // IsCronJobSucceeded returns true if cron job successfully scheduled and completed job. // https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/cron-job-v1/#CronJobStatus func IsCronJobSucceeded(cronJob *batchv1.CronJob) bool { return cronJob.Status.LastScheduleTime != nil } ================================================ FILE: modules/k8s/cronjob_test.go ================================================ //go:build kubeall || kubernetes // +build kubeall kubernetes package k8s import ( "fmt" "strings" "testing" "time" batchv1 "k8s.io/api/batch/v1" "github.com/gruntwork-io/terratest/modules/random" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func TestListCronJobsReturnsCronJobsInNamespace(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(ExampleCronjobYamlTemplate, uniqueID, uniqueID) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) jobs := ListCronJobs(t, options, metav1.ListOptions{}) require.Equal(t, len(jobs), 1) job := jobs[0] require.Equal(t, job.Name, "cron-job") require.Equal(t, job.Namespace, uniqueID) } func TestGetCronJobEReturnErrorForNotExistingCronJob(t *testing.T) { t.Parallel() options := NewKubectlOptions("", "", "default") _, err := GetJobE(t, options, random.UniqueId()) require.Error(t, err) } func TestGetCronJobEReturnsCorrectJobInNamespace(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(ExampleCronjobYamlTemplate, uniqueID, uniqueID) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) job := GetCronJob(t, options, "cron-job") require.Equal(t, job.Name, "cron-job") require.Equal(t, job.Namespace, uniqueID) } func TestWaitUntilCronJobScheduleSuccessfullyContainer(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(ExampleCronjobYamlTemplate, uniqueID, uniqueID) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) WaitUntilCronJobSucceed(t, options, "cron-job", 60, 5*time.Second) } func TestIsCronJobSucceeded(t *testing.T) { cases := []struct { title string cronJob *batchv1.CronJob expectedResult bool }{ { title: "CronJobScheduledContainer", cronJob: &batchv1.CronJob{ Status: batchv1.CronJobStatus{ LastScheduleTime: &metav1.Time{}, }, }, expectedResult: true, }, { title: "CronJobNotScheduledContainer", cronJob: &batchv1.CronJob{ Status: batchv1.CronJobStatus{ LastScheduleTime: nil, }, }, expectedResult: false, }, } for _, tc := range cases { tc := tc t.Run(tc.title, func(t *testing.T) { t.Parallel() actualResult := IsCronJobSucceeded(tc.cronJob) require.Equal(t, tc.expectedResult, actualResult) }) } } const ExampleCronjobYamlTemplate = `--- apiVersion: v1 kind: Namespace metadata: name: %s --- apiVersion: batch/v1 kind: CronJob metadata: name: cron-job namespace: %s spec: schedule: "* * * * *" jobTemplate: spec: template: spec: containers: - name: ubuntu image: ubuntu:20.04 command: ["sh", "-c", "ls"] restartPolicy: OnFailure ` ================================================ FILE: modules/k8s/daemonset.go ================================================ package k8s import ( "context" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/gruntwork-io/terratest/modules/testing" ) // ListDaemonSets will look for daemonsets in the given namespace that match the given filters and return them. This will // fail the test if there is an error. func ListDaemonSets(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) []appsv1.DaemonSet { daemonset, err := ListDaemonSetsE(t, options, filters) require.NoError(t, err) return daemonset } // ListDaemonSetsE will look for daemonsets in the given namespace that match the given filters and return them. func ListDaemonSetsE(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) ([]appsv1.DaemonSet, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } resp, err := clientset.AppsV1().DaemonSets(options.Namespace).List(context.Background(), filters) if err != nil { return nil, err } return resp.Items, nil } // GetDaemonSet returns a Kubernetes daemonset resource in the provided namespace with the given name. This will // fail the test if there is an error. func GetDaemonSet(t testing.TestingT, options *KubectlOptions, daemonSetName string) *appsv1.DaemonSet { daemonset, err := GetDaemonSetE(t, options, daemonSetName) require.NoError(t, err) return daemonset } // GetDaemonSetE returns a Kubernetes daemonset resource in the provided namespace with the given name. func GetDaemonSetE(t testing.TestingT, options *KubectlOptions, daemonSetName string) (*appsv1.DaemonSet, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } return clientset.AppsV1().DaemonSets(options.Namespace).Get(context.Background(), daemonSetName, metav1.GetOptions{}) } ================================================ FILE: modules/k8s/daemonset_test.go ================================================ //go:build kubernetes // +build kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package k8s import ( "fmt" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "strings" "testing" "github.com/gruntwork-io/terratest/modules/random" "github.com/stretchr/testify/require" ) func TestGetDaemonSetEReturnsErrorForNonExistantDaemonSet(t *testing.T) { t.Parallel() options := NewKubectlOptions("", "", "") _, err := GetDaemonSetE(t, options, "sample-ds") require.Error(t, err) } func TestGetDaemonSetEReturnsCorrectServiceInCorrectNamespace(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_DAEMONSET_YAML_TEMPLATE, uniqueID, uniqueID) KubectlApplyFromString(t, options, configData) defer KubectlDeleteFromString(t, options, configData) daemonSet := GetDaemonSet(t, options, "sample-ds") require.Equal(t, daemonSet.Name, "sample-ds") require.Equal(t, daemonSet.Namespace, uniqueID) } func TestListDaemonSetsReturnsCorrectServiceInCorrectNamespace(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_DAEMONSET_YAML_TEMPLATE, uniqueID, uniqueID) KubectlApplyFromString(t, options, configData) defer KubectlDeleteFromString(t, options, configData) daemonSets := ListDaemonSets(t, options, metav1.ListOptions{}) require.Equal(t, len(daemonSets), 1) daemonSet := daemonSets[0] require.Equal(t, daemonSet.Name, "sample-ds") require.Equal(t, daemonSet.Namespace, uniqueID) } const EXAMPLE_DAEMONSET_YAML_TEMPLATE = `--- apiVersion: v1 kind: Namespace metadata: name: %s --- apiVersion: apps/v1 kind: DaemonSet metadata: name: sample-ds namespace: %s labels: k8s-app: sample-ds spec: selector: matchLabels: name: sample-ds template: metadata: labels: name: sample-ds spec: tolerations: - key: node-role.kubernetes.io/master effect: NoSchedule containers: - name: alpine image: alpine:3.8 command: ['sh', '-c', 'echo Hello Terratest! && sleep 99999'] ` ================================================ FILE: modules/k8s/deployment.go ================================================ package k8s import ( "context" "fmt" "time" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/testing" ) // ListDeployments will look for deployments in the given namespace that match the given filters and return them. This will // fail the test if there is an error. func ListDeployments(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) []appsv1.Deployment { deployment, err := ListDeploymentsE(t, options, filters) require.NoError(t, err) return deployment } // ListDeploymentsE will look for deployments in the given namespace that match the given filters and return them. func ListDeploymentsE(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) ([]appsv1.Deployment, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } deployments, err := clientset.AppsV1().Deployments(options.Namespace).List(context.Background(), filters) if err != nil { return nil, err } return deployments.Items, nil } // GetDeployment returns a Kubernetes deployment resource in the provided namespace with the given name. This will // fail the test if there is an error. func GetDeployment(t testing.TestingT, options *KubectlOptions, deploymentName string) *appsv1.Deployment { deployment, err := GetDeploymentE(t, options, deploymentName) require.NoError(t, err) return deployment } // GetDeploymentE returns a Kubernetes deployment resource in the provided namespace with the given name. func GetDeploymentE(t testing.TestingT, options *KubectlOptions, deploymentName string) (*appsv1.Deployment, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } return clientset.AppsV1().Deployments(options.Namespace).Get(context.Background(), deploymentName, metav1.GetOptions{}) } // WaitUntilDeploymentAvailableE waits until all pods within the deployment are ready and started, // retrying the check for the specified amount of times, sleeping // for the provided duration between each try. // This will fail the test if there is an error. func WaitUntilDeploymentAvailable(t testing.TestingT, options *KubectlOptions, deploymentName string, retries int, sleepBetweenRetries time.Duration) { require.NoError(t, WaitUntilDeploymentAvailableE(t, options, deploymentName, retries, sleepBetweenRetries)) } // WaitUntilDeploymentAvailableE waits until all pods within the deployment are ready and started, // retrying the check for the specified amount of times, sleeping // for the provided duration between each try. func WaitUntilDeploymentAvailableE( t testing.TestingT, options *KubectlOptions, deploymentName string, retries int, sleepBetweenRetries time.Duration, ) error { statusMsg := fmt.Sprintf("Wait for deployment %s to be provisioned.", deploymentName) message, err := retry.DoWithRetryE( t, statusMsg, retries, sleepBetweenRetries, func() (string, error) { deployment, err := GetDeploymentE(t, options, deploymentName) if err != nil { return "", err } if !IsDeploymentAvailable(deployment) { return "", NewDeploymentNotAvailableError(deployment) } return "Deployment is now available", nil }, ) if err != nil { options.Logger.Logf(t, "Timedout waiting for Deployment to be provisioned: %s", err) return err } options.Logger.Logf(t, "%s", message) return nil } // IsDeploymentAvailable returns true if all pods within the deployment are ready and started func IsDeploymentAvailable(deploy *appsv1.Deployment) bool { dc := getDeploymentCondition(deploy, appsv1.DeploymentProgressing) return dc != nil && dc.Status == v1.ConditionTrue && dc.Reason == "NewReplicaSetAvailable" } func getDeploymentCondition(deploy *appsv1.Deployment, cType appsv1.DeploymentConditionType) *appsv1.DeploymentCondition { for idx := range deploy.Status.Conditions { dc := &deploy.Status.Conditions[idx] if dc.Type == cType { return dc } } return nil } ================================================ FILE: modules/k8s/deployment_test.go ================================================ //go:build kubeall || kubernetes // +build kubeall kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package k8s import ( "fmt" "time" "strings" "testing" "github.com/gruntwork-io/terratest/modules/random" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func TestGetDeploymentEReturnsError(t *testing.T) { t.Parallel() options := NewKubectlOptions("", "", "") _, err := GetDeploymentE(t, options, "nginx-deployment") require.Error(t, err) } func TestGetDeployments(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(ExampleDeploymentYAMLTemplate, uniqueID) KubectlApplyFromString(t, options, configData) defer KubectlDeleteFromString(t, options, configData) deployment := GetDeployment(t, options, "nginx-deployment") require.Equal(t, deployment.Name, "nginx-deployment") require.Equal(t, deployment.Namespace, uniqueID) } func TestListDeployments(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(ExampleDeploymentYAMLTemplate, uniqueID) KubectlApplyFromString(t, options, configData) defer KubectlDeleteFromString(t, options, configData) deployments := ListDeployments(t, options, metav1.ListOptions{}) require.Equal(t, len(deployments), 1) deployment := deployments[0] require.Equal(t, deployment.Name, "nginx-deployment") require.Equal(t, deployment.Namespace, uniqueID) } func TestWaitUntilDeploymentAvailable(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(ExampleDeploymentYAMLTemplate, uniqueID) KubectlApplyFromString(t, options, configData) defer KubectlDeleteFromString(t, options, configData) WaitUntilDeploymentAvailable(t, options, "nginx-deployment", 60, 1*time.Second) } func TestTestIsDeploymentAvailable(t *testing.T) { testCases := []struct { title string deploy *appsv1.Deployment expectedResult bool }{ { title: "TestIsDeploymentAvailableWithProgressingNewReplicaSetAvailable", deploy: &appsv1.Deployment{ Status: appsv1.DeploymentStatus{ Conditions: []appsv1.DeploymentCondition{ { Type: appsv1.DeploymentProgressing, Status: v1.ConditionTrue, Reason: "NewReplicaSetAvailable", }, }, }, }, expectedResult: true, }, { title: "TestIsDeploymentAvailableWithoutProgressingNewReplicaSetAvailable", deploy: &appsv1.Deployment{ Status: appsv1.DeploymentStatus{ Conditions: []appsv1.DeploymentCondition{ { Type: appsv1.DeploymentProgressing, Status: v1.ConditionTrue, Reason: "ReplicaSetUpdated", }, }, }, }, expectedResult: false, }, { title: "TestIsDeploymentAvailableWithoutProgressingCondition", deploy: &appsv1.Deployment{ Status: appsv1.DeploymentStatus{ Conditions: []appsv1.DeploymentCondition{}, }, }, expectedResult: false, }, } for _, tc := range testCases { tc := tc t.Run(tc.title, func(t *testing.T) { t.Parallel() actualResult := IsDeploymentAvailable(tc.deploy) require.Equal(t, tc.expectedResult, actualResult) }) } } const ExampleDeploymentYAMLTemplate = `--- apiVersion: v1 kind: Namespace metadata: name: %s --- apiVersion: apps/v1 kind: Deployment metadata: name: nginx-deployment labels: app: nginx spec: strategy: rollingUpdate: maxSurge: 10%% maxUnavailable: 0 replicas: 2 selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - name: nginx image: nginx:1.15.7 ports: - containerPort: 80 readinessProbe: httpGet: path: / port: 80 ` ================================================ FILE: modules/k8s/errors.go ================================================ package k8s import ( "fmt" appsv1 "k8s.io/api/apps/v1" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" networkingv1beta1 "k8s.io/api/networking/v1beta1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // IngressNotAvailable is returned when a Kubernetes service is not yet available to accept traffic. type IngressNotAvailable struct { ingress *networkingv1.Ingress } // Error is a simple function to return a formatted error message as a string func (err IngressNotAvailable) Error() string { return fmt.Sprintf("Ingress %s is not available", err.ingress.Name) } // IngressNotAvailableV1Beta1 is returned when a Kubernetes service is not yet available to accept traffic. type IngressNotAvailableV1Beta1 struct { ingress *networkingv1beta1.Ingress } // Error is a simple function to return a formatted error message as a string func (err IngressNotAvailableV1Beta1) Error() string { return fmt.Sprintf("Ingress %s is not available", err.ingress.Name) } // UnknownKubeResourceType is returned if the given resource type does not match the list of known resource types. type UnknownKubeResourceType struct { ResourceType KubeResourceType } func (err UnknownKubeResourceType) Error() string { return fmt.Sprintf("ResourceType ID %d is unknown", err.ResourceType) } // DesiredNumberOfPodsNotCreated is returned when the number of pods matching a filter condition does not match the // desired number of Pods. type DesiredNumberOfPodsNotCreated struct { Filter metav1.ListOptions DesiredCount int } // Error is a simple function to return a formatted error message as a string func (err DesiredNumberOfPodsNotCreated) Error() string { return fmt.Sprintf("Desired number of pods (%d) matching filter %v not yet created", err.DesiredCount, err.Filter) } // ServiceAccountTokenNotAvailable is returned when a Kubernetes ServiceAccount does not have a token provisioned yet. type ServiceAccountTokenNotAvailable struct { Name string } // Error is a simple function to return a formatted error message as a string func (err ServiceAccountTokenNotAvailable) Error() string { return fmt.Sprintf("ServiceAccount %s does not have a token yet.", err.Name) } // DeploymentNotAvailable is returned when a Kubernetes deployment is not yet available to accept traffic. type DeploymentNotAvailable struct { deploy *appsv1.Deployment } // Error is a simple function to return a formatted error message as a string func (err DeploymentNotAvailable) Error() string { dc := getDeploymentCondition(err.deploy, appsv1.DeploymentProgressing) if dc == nil { return fmt.Sprintf( "Deployment %s is not available, missing '%s' condition", err.deploy.Name, appsv1.DeploymentProgressing, ) } return fmt.Sprintf( "Deployment %s is not available as '%s' condition indicates that the Deployment is not complete, status: %v, reason: %s, message: %s", err.deploy.Name, appsv1.DeploymentProgressing, dc.Status, dc.Reason, dc.Message, ) } // NewDeploymentNotAvailableError returns a DeploymentNotAvailable struct when Kubernetes deems a deployment is not available func NewDeploymentNotAvailableError(deploy *appsv1.Deployment) DeploymentNotAvailable { return DeploymentNotAvailable{deploy} } // PodNotAvailable is returned when a Kubernetes service is not yet available to accept traffic. type PodNotAvailable struct { pod *corev1.Pod } // Error is a simple function to return a formatted error message as a string func (err PodNotAvailable) Error() string { return fmt.Sprintf("Pod %s is not available, reason: %s, message: %s", err.pod.Name, err.pod.Status.Reason, err.pod.Status.Message) } // NewPodNotAvailableError returns a PodNotAvailable struct when Kubernetes deems a pod is not available func NewPodNotAvailableError(pod *corev1.Pod) PodNotAvailable { return PodNotAvailable{pod} } // JobNotSucceeded is returned when a Kubernetes job is not Succeeded type JobNotSucceeded struct { job *batchv1.Job } // Error is a simple function to return a formatted error message as a string func (err JobNotSucceeded) Error() string { return fmt.Sprintf("Job %s is not Succeeded", err.job.Name) } // NewJobNotSucceeded returns a JobNotSucceeded when the status of the job is not Succeeded func NewJobNotSucceeded(job *batchv1.Job) JobNotSucceeded { return JobNotSucceeded{job} } // ServiceNotAvailable is returned when a Kubernetes service is not yet available to accept traffic. type ServiceNotAvailable struct { service *corev1.Service } // Error is a simple function to return a formatted error message as a string func (err ServiceNotAvailable) Error() string { return fmt.Sprintf("Service %s is not available", err.service.Name) } // NewServiceNotAvailableError returns a ServiceNotAvailable struct when Kubernetes deems a service is not available func NewServiceNotAvailableError(service *corev1.Service) ServiceNotAvailable { return ServiceNotAvailable{service} } // UnknownServiceType is returned when a Kubernetes service has a type that is not yet handled by the test functions. type UnknownServiceType struct { service *corev1.Service } // Error is a simple function to return a formatted error message as a string func (err UnknownServiceType) Error() string { return fmt.Sprintf("Service %s has an unknown service type", err.service.Name) } // NewUnknownServiceTypeError returns an UnknownServiceType struct when is it deemed that Kubernetes does not know the service type provided func NewUnknownServiceTypeError(service *corev1.Service) UnknownServiceType { return UnknownServiceType{service} } // UnknownServicePort is returned when the given service port is not an exported port of the service. type UnknownServicePort struct { service *corev1.Service port int32 } // Error is a simple function to return a formatted error message as a string func (err UnknownServicePort) Error() string { return fmt.Sprintf("Port %d is not a part of the service %s", err.port, err.service.Name) } // NewUnknownServicePortError returns an UnknownServicePort struct when it is deemed that Kubernetes does not know of the provided Service Port func NewUnknownServicePortError(service *corev1.Service, port int32) UnknownServicePort { return UnknownServicePort{service, port} } // PersistentVolumeNotInStatus is returned when a Kubernetes PersistentVolume is not in the expected status phase type PersistentVolumeNotInStatus struct { pv *corev1.PersistentVolume pvStatusPhase *corev1.PersistentVolumePhase } // Error is a simple function to return a formatted error message as a string func (err PersistentVolumeNotInStatus) Error() string { return fmt.Sprintf("Pv %s is not '%s'", err.pv.Name, *err.pvStatusPhase) } // NewPersistentVolumeNotInStatusError returns a PersistentVolumeNotInStatus struct when the given Persistent Volume is not in the expected status phase func NewPersistentVolumeNotInStatusError(pv *corev1.PersistentVolume, pvStatusPhase *corev1.PersistentVolumePhase) PersistentVolumeNotInStatus { return PersistentVolumeNotInStatus{pv, pvStatusPhase} } // PersistentVolumeClaimNotInStatus is returned when a Kubernetes PersistentVolumeClaim is not in the expected status phase type PersistentVolumeClaimNotInStatus struct { pvc *corev1.PersistentVolumeClaim pvcStatusPhase *corev1.PersistentVolumeClaimPhase } // Error is a simple function to return a formatted error message as a string func (err PersistentVolumeClaimNotInStatus) Error() string { return fmt.Sprintf("PVC %s is not '%s'", err.pvc.Name, *err.pvcStatusPhase) } // NewPersistentVolumeClaimNotInStatusError returns a PersistentVolumeClaimNotInStatus struct when the given PersistentVolumeClaim is not in the expected status phase func NewPersistentVolumeClaimNotInStatusError(pvc *corev1.PersistentVolumeClaim, pvcStatusPhase *corev1.PersistentVolumeClaimPhase) PersistentVolumeClaimNotInStatus { return PersistentVolumeClaimNotInStatus{pvc, pvcStatusPhase} } // NoNodesInKubernetes is returned when the Kubernetes cluster has no nodes registered. type NoNodesInKubernetes struct{} // Error is a simple function to return a formatted error message as a string func (err NoNodesInKubernetes) Error() string { return "There are no nodes in the Kubernetes cluster" } // NewNoNodesInKubernetesError returns a NoNodesInKubernetes struct when it is deemed that there are no Kubernetes nodes registered func NewNoNodesInKubernetesError() NoNodesInKubernetes { return NoNodesInKubernetes{} } // NodeHasNoHostname is returned when a Kubernetes node has no discernible hostname type NodeHasNoHostname struct { node *corev1.Node } // Error is a simple function to return a formatted error message as a string func (err NodeHasNoHostname) Error() string { return fmt.Sprintf("Node %s has no hostname", err.node.Name) } // NewNodeHasNoHostnameError returns a NodeHasNoHostname struct when it is deemed that the provided node has no hostname func NewNodeHasNoHostnameError(node *corev1.Node) NodeHasNoHostname { return NodeHasNoHostname{node} } // MalformedNodeID is returned when a Kubernetes node has a malformed node id scheme type MalformedNodeID struct { node *corev1.Node } // Error is a simple function to return a formatted error message as a string func (err MalformedNodeID) Error() string { return fmt.Sprintf("Node %s has malformed ID %s", err.node.Name, err.node.Spec.ProviderID) } // NewMalformedNodeIDError returns a MalformedNodeID struct when Kubernetes deems that a NodeID is malformed func NewMalformedNodeIDError(node *corev1.Node) MalformedNodeID { return MalformedNodeID{node} } // JSONPathMalformedJSONErr is returned when the jsonpath unmarshal routine fails to parse the given JSON blob. type JSONPathMalformedJSONErr struct { underlyingErr error } func (err JSONPathMalformedJSONErr) Error() string { return fmt.Sprintf("Error unmarshaling original json blob: %s", err.underlyingErr) } // JSONPathMalformedJSONPathErr is returned when the jsonpath unmarshal routine fails to parse the given JSON path // string. type JSONPathMalformedJSONPathErr struct { underlyingErr error } func (err JSONPathMalformedJSONPathErr) Error() string { return fmt.Sprintf("Error parsing json path: %s", err.underlyingErr) } // JSONPathExtractJSONPathErr is returned when the jsonpath unmarshal routine fails to extract the given JSON path from // the JSON blob. type JSONPathExtractJSONPathErr struct { underlyingErr error } func (err JSONPathExtractJSONPathErr) Error() string { return fmt.Sprintf("Error extracting json path from blob: %s", err.underlyingErr) } // JSONPathMalformedJSONPathResultErr is returned when the jsonpath unmarshal routine fails to unmarshal the resulting // data from extraction. type JSONPathMalformedJSONPathResultErr struct { underlyingErr error } func (err JSONPathMalformedJSONPathResultErr) Error() string { return fmt.Sprintf("Error unmarshaling json path output: %s", err.underlyingErr) } // CronJobNotSucceeded is returned when a Kubernetes cron job didn't successfully schedule a job. type CronJobNotSucceeded struct { cronJob *batchv1.CronJob } // Error format message for cron job error. func (err CronJobNotSucceeded) Error() string { return fmt.Sprintf("CronJob %s failed to be scheduled.", err.cronJob.Name) } // NewCronJobNotSucceeded create error for case when CronJob didn't schedule a job. func NewCronJobNotSucceeded(cronJob *batchv1.CronJob) CronJobNotSucceeded { return CronJobNotSucceeded{cronJob} } ================================================ FILE: modules/k8s/errors_test.go ================================================ package k8s import ( "testing" "github.com/stretchr/testify/assert" appsv1 "k8s.io/api/apps/v1" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func TestErrorDeploymentNotAvailable(t *testing.T) { testCases := []struct { title string deploy *appsv1.Deployment expectedErr string }{ { title: "NoProgressingCondition", deploy: &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ Name: "foo", }, Status: appsv1.DeploymentStatus{ Conditions: []appsv1.DeploymentCondition{}, }, }, expectedErr: "Deployment foo is not available, missing 'Progressing' condition", }, { title: "DeploymentNotComplete", deploy: &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ Name: "foo", }, Status: appsv1.DeploymentStatus{ Conditions: []appsv1.DeploymentCondition{ { Type: appsv1.DeploymentProgressing, Status: v1.ConditionTrue, Reason: "ReplicaSetUpdated", Message: "bar", }, }, }, }, expectedErr: "Deployment foo is not available as 'Progressing' condition indicates that the Deployment is not complete, status: True, reason: ReplicaSetUpdated, message: bar", }, } for _, tc := range testCases { tc := tc t.Run(tc.title, func(t *testing.T) { t.Parallel() err := NewDeploymentNotAvailableError(tc.deploy) assert.EqualError(t, err, tc.expectedErr) }) } } ================================================ FILE: modules/k8s/event.go ================================================ package k8s import ( "context" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/gruntwork-io/terratest/modules/testing" ) // ListEvents will retrieve the Events in the given namespace that match the given filters and return them. This will fail the // test if there is an error. func ListEvents(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) []corev1.Event { events, err := ListEventsE(t, options, filters) require.NoError(t, err) return events } // ListEventsE will retrieve the Events that match the given filters and return them. func ListEventsE(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) ([]corev1.Event, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } resp, err := clientset.CoreV1().Events(options.Namespace).List(context.Background(), filters) if err != nil { return nil, err } return resp.Items, nil } ================================================ FILE: modules/k8s/event_test.go ================================================ //go:build kubernetes // +build kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package k8s import ( "testing" "github.com/stretchr/testify/require" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" _ "k8s.io/client-go/plugin/pkg/client/auth" ) func TestListEventsEReturnsNilErrorWhenListingEvents(t *testing.T) { t.Parallel() options := NewKubectlOptions("", "", "kube-system") events, err := ListEventsE(t, options, v1.ListOptions{}) require.Nil(t, err) require.Greater(t, len(events), 0) } func TestListEventsInNamespace(t *testing.T) { t.Parallel() options := NewKubectlOptions("", "", "kube-system") events := ListEvents(t, options, v1.ListOptions{}) require.Greater(t, len(events), 0) } func TestListEventsReturnsZeroEventsIfNoneCreated(t *testing.T) { t.Parallel() ns := "test-ns" options := NewKubectlOptions("", "", "") defer DeleteNamespace(t, options, ns) CreateNamespace(t, options, ns) options.Namespace = ns events := ListEvents(t, options, v1.ListOptions{}) require.Equal(t, 0, len(events)) } ================================================ FILE: modules/k8s/ingress.go ================================================ package k8s import ( "context" "fmt" "time" "github.com/stretchr/testify/require" networkingv1 "k8s.io/api/networking/v1" networkingv1beta1 "k8s.io/api/networking/v1beta1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/testing" ) // ListIngresses will look for Ingress resources in the given namespace that match the given filters and return them. // This will fail the test if there is an error. func ListIngresses(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) []networkingv1.Ingress { ingresses, err := ListIngressesE(t, options, filters) require.NoError(t, err) return ingresses } // ListIngressesE will look for Ingress resources in the given namespace that match the given filters and return them. func ListIngressesE(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) ([]networkingv1.Ingress, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } resp, err := clientset.NetworkingV1().Ingresses(options.Namespace).List(context.Background(), filters) if err != nil { return nil, err } return resp.Items, nil } // GetIngress returns a Kubernetes Ingress resource in the provided namespace with the given name. This will fail the // test if there is an error. func GetIngress(t testing.TestingT, options *KubectlOptions, ingressName string) *networkingv1.Ingress { ingress, err := GetIngressE(t, options, ingressName) require.NoError(t, err) return ingress } // GetIngressE returns a Kubernetes Ingress resource in the provided namespace with the given name. func GetIngressE(t testing.TestingT, options *KubectlOptions, ingressName string) (*networkingv1.Ingress, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } return clientset.NetworkingV1().Ingresses(options.Namespace).Get(context.Background(), ingressName, metav1.GetOptions{}) } // IsIngressAvailable returns true if the Ingress endpoint is provisioned and available. func IsIngressAvailable(ingress *networkingv1.Ingress) bool { // Ingress is ready if it has at least one endpoint endpoints := ingress.Status.LoadBalancer.Ingress return len(endpoints) > 0 } // WaitUntilIngressAvailable waits until the Ingress resource has an endpoint provisioned for it. func WaitUntilIngressAvailable(t testing.TestingT, options *KubectlOptions, ingressName string, retries int, sleepBetweenRetries time.Duration) { statusMsg := fmt.Sprintf("Wait for ingress %s to be provisioned.", ingressName) message := retry.DoWithRetry( t, statusMsg, retries, sleepBetweenRetries, func() (string, error) { ingress, err := GetIngressE(t, options, ingressName) if err != nil { return "", err } if !IsIngressAvailable(ingress) { return "", IngressNotAvailable{ingress: ingress} } return "Ingress is now available", nil }, ) options.Logger.Logf(t, "%s", message) } // ListIngressesV1Beta1 will look for Ingress resources in the given namespace that match the given filters and return // them, using networking.k8s.io/v1beta1 API. This will fail the test if there is an error. func ListIngressesV1Beta1(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) []networkingv1beta1.Ingress { ingresses, err := ListIngressesV1Beta1E(t, options, filters) require.NoError(t, err) return ingresses } // ListIngressesV1Beta1E will look for Ingress resources in the given namespace that match the given filters and return // them, using networking.k8s.io/v1beta1 API. func ListIngressesV1Beta1E(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) ([]networkingv1beta1.Ingress, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } resp, err := clientset.NetworkingV1beta1().Ingresses(options.Namespace).List(context.Background(), filters) if err != nil { return nil, err } return resp.Items, nil } // GetIngressV1Beta1 returns a Kubernetes Ingress resource in the provided namespace with the given name, using // networking.k8s.io/v1beta1 API. This will fail the test if there is an error. func GetIngressV1Beta1(t testing.TestingT, options *KubectlOptions, ingressName string) *networkingv1beta1.Ingress { ingress, err := GetIngressV1Beta1E(t, options, ingressName) require.NoError(t, err) return ingress } // GetIngressV1Beta1E returns a Kubernetes Ingress resource in the provided namespace with the given name, using // networking.k8s.io/v1beta1. func GetIngressV1Beta1E(t testing.TestingT, options *KubectlOptions, ingressName string) (*networkingv1beta1.Ingress, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } return clientset.NetworkingV1beta1().Ingresses(options.Namespace).Get(context.Background(), ingressName, metav1.GetOptions{}) } // IsIngressAvailableV1Beta1 returns true if the Ingress endpoint is provisioned and available, using // networking.k8s.io/v1beta1 API. func IsIngressAvailableV1Beta1(ingress *networkingv1beta1.Ingress) bool { // Ingress is ready if it has at least one endpoint endpoints := ingress.Status.LoadBalancer.Ingress return len(endpoints) > 0 } // WaitUntilIngressAvailableV1Beta1 waits until the Ingress resource has an endpoint provisioned for it, using // networking.k8s.io/v1beta1 API. func WaitUntilIngressAvailableV1Beta1(t testing.TestingT, options *KubectlOptions, ingressName string, retries int, sleepBetweenRetries time.Duration) { statusMsg := fmt.Sprintf("Wait for ingress %s to be provisioned.", ingressName) message := retry.DoWithRetry( t, statusMsg, retries, sleepBetweenRetries, func() (string, error) { ingress, err := GetIngressV1Beta1E(t, options, ingressName) if err != nil { return "", err } if !IsIngressAvailableV1Beta1(ingress) { return "", IngressNotAvailableV1Beta1{ingress: ingress} } return "Ingress is now available", nil }, ) options.Logger.Logf(t, "%s", message) } ================================================ FILE: modules/k8s/ingress_test.go ================================================ //go:build kubernetes // +build kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package k8s import ( "fmt" "strings" "testing" "time" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/gruntwork-io/terratest/modules/random" ) const ExampleIngressName = "nginx-service-ingress" func TestGetIngressEReturnsErrorForNonExistantIngress(t *testing.T) { t.Parallel() options := NewKubectlOptions("", "", "default") _, err := GetIngressE(t, options, "i-dont-exist") require.Error(t, err) } func TestGetIngressEReturnsCorrectIngressInCorrectNamespace(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(exampleIngressDeploymentYamlTemplate, uniqueID, uniqueID, uniqueID, uniqueID, uniqueID) KubectlApplyFromString(t, options, configData) defer KubectlDeleteFromString(t, options, configData) service := GetIngress(t, options, "nginx-service-ingress") require.Equal(t, service.Name, "nginx-service-ingress") require.Equal(t, service.Namespace, uniqueID) } func TestListIngressesReturnsCorrectIngressInCorrectNamespace(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(exampleIngressDeploymentYamlTemplate, uniqueID, uniqueID, uniqueID, uniqueID, uniqueID) KubectlApplyFromString(t, options, configData) defer KubectlDeleteFromString(t, options, configData) ingresses := ListIngresses(t, options, metav1.ListOptions{}) require.Equal(t, len(ingresses), 1) ingress := ingresses[0] require.Equal(t, ingress.Name, ExampleIngressName) require.Equal(t, ingress.Namespace, uniqueID) } func TestWaitUntilIngressAvailableReturnsSuccessfully(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(exampleIngressDeploymentYamlTemplate, uniqueID, uniqueID, uniqueID, uniqueID, uniqueID) KubectlApplyFromString(t, options, configData) defer KubectlDeleteFromString(t, options, configData) WaitUntilIngressAvailable(t, options, ExampleIngressName, 60, 5*time.Second) } const exampleIngressDeploymentYamlTemplate = `--- apiVersion: v1 kind: Namespace metadata: name: %s --- apiVersion: apps/v1 kind: Deployment metadata: name: nginx-deployment namespace: %s spec: selector: matchLabels: app: nginx replicas: 1 template: metadata: labels: app: nginx spec: containers: - name: nginx image: nginx:1.15.7 ports: - containerPort: 80 --- kind: Service apiVersion: v1 metadata: name: nginx-service namespace: %s spec: selector: app: nginx ports: - protocol: TCP targetPort: 80 port: 80 type: NodePort --- kind: Ingress apiVersion: networking.k8s.io/v1 metadata: name: nginx-service-ingress namespace: %s spec: rules: - http: paths: - path: /app-%s pathType: Prefix backend: service: name: nginx-service port: number: 80 ` ================================================ FILE: modules/k8s/job.go ================================================ package k8s import ( "context" "fmt" "time" "github.com/stretchr/testify/require" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/testing" ) // ListJobs will look for Jobs in the given namespace that match the given filters and return them. This will fail the // test if there is an error. func ListJobs(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) []batchv1.Job { jobs, err := ListJobsE(t, options, filters) require.NoError(t, err) return jobs } // ListJobsE will look for jobs in the given namespace that match the given filters and return them. func ListJobsE(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) ([]batchv1.Job, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } resp, err := clientset.BatchV1().Jobs(options.Namespace).List(context.Background(), filters) if err != nil { return nil, err } return resp.Items, nil } // GetJob returns a Kubernetes job resource in the provided namespace with the given name. This will // fail the test if there is an error. func GetJob(t testing.TestingT, options *KubectlOptions, jobName string) *batchv1.Job { job, err := GetJobE(t, options, jobName) require.NoError(t, err) return job } // GetJobE returns a Kubernetes job resource in the provided namespace with the given name. func GetJobE(t testing.TestingT, options *KubectlOptions, jobName string) (*batchv1.Job, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } return clientset.BatchV1().Jobs(options.Namespace).Get(context.Background(), jobName, metav1.GetOptions{}) } // WaitUntilJobSucceed waits until requested job is suceeded, retrying the check for the specified amount of times, sleeping // for the provided duration between each try. This will fail the test if there is an error or if the check times out. func WaitUntilJobSucceed(t testing.TestingT, options *KubectlOptions, jobName string, retries int, sleepBetweenRetries time.Duration) { require.NoError(t, WaitUntilJobSucceedE(t, options, jobName, retries, sleepBetweenRetries)) } // WaitUntilJobSucceedE waits until requested job is succeeded, retrying the check for the specified amount of times, sleeping // for the provided duration between each try. func WaitUntilJobSucceedE(t testing.TestingT, options *KubectlOptions, jobName string, retries int, sleepBetweenRetries time.Duration) error { statusMsg := fmt.Sprintf("Wait for job %s to be provisioned.", jobName) message, err := retry.DoWithRetryE( t, statusMsg, retries, sleepBetweenRetries, func() (string, error) { job, err := GetJobE(t, options, jobName) if err != nil { return "", err } if !IsJobSucceeded(job) { return "", NewJobNotSucceeded(job) } return "Job is now Succeeded", nil }, ) if err != nil { options.Logger.Logf(t, "Timed out waiting for Job to be provisioned: %s", err) return err } options.Logger.Logf(t, "%s", message) return nil } // IsJobSucceeded returns true when the job status condition "Complete" is true. This behavior is documented in the kubernetes API reference: // https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/job-v1/#JobStatus func IsJobSucceeded(job *batchv1.Job) bool { for _, condition := range job.Status.Conditions { if condition.Type == batchv1.JobComplete && condition.Status == corev1.ConditionTrue { return true } } return false } // CreateJobFromCronJob creates a Job from the specified CronJob in the given namespace and returns the created Job. func CreateJobFromCronJob(t testing.TestingT, options *KubectlOptions, cronJobName, newJobName string) *batchv1.Job { job, err := CreateJobFromCronJobE(t, options, cronJobName, newJobName) require.NoError(t, err) return job } // CreateJobFromCronJobE creates a Job from the specified CronJob in the given namespace and returns the created Job. // This function is similar to running `kubectl create job --from=cronjob/ `. func CreateJobFromCronJobE(t testing.TestingT, options *KubectlOptions, cronJobName, newJobName string) (*batchv1.Job, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } cronJob, err := GetCronJobE(t, options, cronJobName) if err != nil { return nil, err } annotations := make(map[string]string) for k, v := range cronJob.Spec.JobTemplate.Annotations { annotations[k] = v } job := &batchv1.Job{ TypeMeta: metav1.TypeMeta{APIVersion: batchv1.SchemeGroupVersion.String(), Kind: "Job"}, ObjectMeta: metav1.ObjectMeta{ Name: newJobName, Namespace: options.Namespace, Labels: cronJob.Spec.JobTemplate.Labels, Annotations: annotations, }, Spec: cronJob.Spec.JobTemplate.Spec, } createdJob, err := clientset.BatchV1().Jobs(options.Namespace).Create(context.Background(), job, metav1.CreateOptions{}) return createdJob, err } ================================================ FILE: modules/k8s/job_test.go ================================================ //go:build kubeall || kubernetes // +build kubeall kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package k8s import ( "fmt" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/gruntwork-io/terratest/modules/random" ) func TestListJobsReturnsJobsInNamespace(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_JOB_YAML_TEMPLATE, uniqueID, uniqueID) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) jobs := ListJobs(t, options, metav1.ListOptions{}) require.Equal(t, len(jobs), 1) job := jobs[0] require.Equal(t, job.Name, "pi-job") require.Equal(t, job.Namespace, uniqueID) } func TestGetJobEReturnsErrorForNonExistantJob(t *testing.T) { t.Parallel() options := NewKubectlOptions("", "", "default") _, err := GetJobE(t, options, "pi-job") require.Error(t, err) } func TestGetJobEReturnsCorrectJobInCorrectNamespace(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_JOB_YAML_TEMPLATE, uniqueID, uniqueID) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) job := GetJob(t, options, "pi-job") require.Equal(t, job.Name, "pi-job") require.Equal(t, job.Namespace, uniqueID) } func TestWaitUntilJobSucceedReturnsSuccessfully(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_JOB_YAML_TEMPLATE, uniqueID, uniqueID) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) WaitUntilJobSucceed(t, options, "pi-job", 60, 1*time.Second) } func TestIsJobSucceeded(t *testing.T) { t.Parallel() cases := []struct { title string job *batchv1.Job expectedResult bool }{ { title: "TestIsJobSucceeded", job: &batchv1.Job{ Status: batchv1.JobStatus{ Conditions: []batchv1.JobCondition{ batchv1.JobCondition{ Type: batchv1.JobComplete, Status: corev1.ConditionTrue, }, }, }, }, expectedResult: true, }, { title: "TestIsJobFailed", job: &batchv1.Job{ Status: batchv1.JobStatus{ Conditions: []batchv1.JobCondition{ batchv1.JobCondition{ Type: batchv1.JobFailed, Status: corev1.ConditionTrue, }, }, }, }, expectedResult: false, }, { title: "TestIsJobStarting", job: &batchv1.Job{ Status: batchv1.JobStatus{ Conditions: []batchv1.JobCondition{}, }, }, expectedResult: false, }, } for _, tc := range cases { tc := tc t.Run(tc.title, func(t *testing.T) { t.Parallel() actualResult := IsJobSucceeded(tc.job) require.Equal(t, tc.expectedResult, actualResult) }) } } func TestCreateJobFromCronJobReturnsCreatedJob(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_CRON_JOB_YAML_TEMPLATE, uniqueID, uniqueID) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) newJobName := "pi-copied-job" job := CreateJobFromCronJob(t, options, "pi-cronjob", newJobName) require.NotNil(t, job) assert.Equal(t, job.Namespace, uniqueID) assert.Equal(t, job.Name, newJobName) } func TestCreateJobFromCronJobEReturnsErrorForNonExistentCronJob(t *testing.T) { t.Parallel() options := NewKubectlOptions("", "", "default") _, err := CreateJobFromCronJobE(t, options, "non-existent-cronjob", "new-job-name") require.Error(t, err) } const EXAMPLE_JOB_YAML_TEMPLATE = `--- apiVersion: v1 kind: Namespace metadata: name: %s --- apiVersion: batch/v1 kind: Job metadata: name: pi-job namespace: %s spec: template: spec: containers: - name: pi image: "perl:5.34.1" command: ["perl", "-Mbignum=bpi", "-wle", "print bpi(2000)"] restartPolicy: Never backoffLimit: 4 ` const EXAMPLE_CRON_JOB_YAML_TEMPLATE = `--- apiVersion: v1 kind: Namespace metadata: name: %s --- apiVersion: batch/v1 kind: CronJob metadata: name: pi-cronjob namespace: %s spec: schedule: "* 1 * * *" successfulJobsHistoryLimit: 3 failedJobsHistoryLimit: 1 jobTemplate: spec: backoffLimit: 4 template: spec: containers: - name: pi image: "perl:5.34.1" command: ["perl", "-Mbignum=bpi", "-wle", "print bpi(2000)"] restartPolicy: Never ` ================================================ FILE: modules/k8s/jsonpath.go ================================================ package k8s import ( "bytes" "encoding/json" "k8s.io/client-go/util/jsonpath" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // UnmarshalJSONPath allows you to use an arbitrary JSONPath string to query a json blob and unmarshal the resulting // output into a go object. Note that the output will always be a list. That means that if you query a single object, // the output will be a list of single element, not the element itself. However, if the json path maps to a list, then // the output will be that list. // Example: // // jsonBlob := []byte(`{"key": {"data": [1,2,3]}}`) // jsonPath := "{.key.data[*]}" // var output []int // UnmarshalJSONPath(t, jsonBlob, jsonPath, &output) // // output is []int{1,2,3} // // This will fail the test if there is an error. func UnmarshalJSONPath(t testing.TestingT, jsonData []byte, jsonpathStr string, output interface{}) { err := UnmarshalJSONPathE(t, jsonData, jsonpathStr, output) require.NoError(t, err) } // UnmarshalJSONPathE allows you to use an arbitrary JSONPath string to query a json blob and unmarshal the resulting // output into a go object. Note that the output will always be a list. That means that if you query a single object, // the output will be a list of single element, not the element itself. However, if the json path maps to a list, then // the output will be that list. // Example: // // jsonBlob := []byte(`{"key": {"data": [1,2,3]}}`) // jsonPath := "{.key.data[*]}" // var output []int // UnmarshalJSONPathE(t, jsonBlob, jsonPath, &output) // => output = []int{1,2,3} func UnmarshalJSONPathE(t testing.TestingT, jsonData []byte, jsonpathStr string, output interface{}) error { // First, unmarshal the full json object. We use interface{} to avoid the type conversions, as jsonpath will handle // it for us. var blob interface{} if err := json.Unmarshal(jsonData, &blob); err != nil { return JSONPathMalformedJSONErr{err} } // Then, query the json object with the given jsonpath to get the output string. jsonpathParser := jsonpath.New(t.Name()) jsonpathParser.EnableJSONOutput(true) if err := jsonpathParser.Parse(jsonpathStr); err != nil { return JSONPathMalformedJSONPathErr{err} } outputJSONBuffer := new(bytes.Buffer) if err := jsonpathParser.Execute(outputJSONBuffer, blob); err != nil { return JSONPathExtractJSONPathErr{err} } outputJSON := outputJSONBuffer.Bytes() // Finally, we need to unmarshal the output object into the given output var. if err := json.Unmarshal(outputJSON, output); err != nil { return JSONPathMalformedJSONPathResultErr{err} } return nil } ================================================ FILE: modules/k8s/jsonpath_test.go ================================================ package k8s import ( "encoding/json" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestUnmarshalJSONPath(t *testing.T) { t.Parallel() testCases := []struct { name string jsonBlob string jsonPath string expectedOut interface{} }{ { "boolField", `{"key": true}`, "{ .key }", []bool{true}, }, { "nestedObject", `{"key": {"data": [1,2,3]}}`, "{ .key }", []map[string][]int{ map[string][]int{ "data": []int{1, 2, 3}, }, }, }, { "nestedArray", `{"key": {"data": [1,2,3]}}`, "{ .key.data[*] }", []int{1, 2, 3}, }, } for _, testCase := range testCases { // capture range variable so that it doesn't update when the subtest goroutine swaps. testCase := testCase t.Run(testCase.name, func(t *testing.T) { t.Parallel() var output interface{} UnmarshalJSONPath(t, []byte(testCase.jsonBlob), testCase.jsonPath, &output) // NOTE: we have to do equality check on the marshalled json data to allow equality checks over dynamic // types in this table driven test. expectedOutJSON, err := json.Marshal(testCase.expectedOut) require.NoError(t, err) actualOutJSON, err := json.Marshal(output) require.NoError(t, err) assert.Equal(t, string(expectedOutJSON), string(actualOutJSON)) }) } } ================================================ FILE: modules/k8s/k8s.go ================================================ // Package k8s provides common functionalities for interacting with a Kubernetes cluster in the context of // infrastructure testing. package k8s ================================================ FILE: modules/k8s/kubectl.go ================================================ package k8s import ( "net/url" "os" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terratest/modules/shell" "github.com/gruntwork-io/terratest/modules/testing" ) // RunKubectl will call kubectl using the provided options and args, failing the test on error. func RunKubectl(t testing.TestingT, options *KubectlOptions, args ...string) { require.NoError(t, RunKubectlE(t, options, args...)) } // RunKubectlE will call kubectl using the provided options and args. func RunKubectlE(t testing.TestingT, options *KubectlOptions, args ...string) error { _, err := RunKubectlAndGetOutputE(t, options, args...) return err } // RunKubectlAndGetOutputE will call kubectl using the provided options and args, returning the output of stdout and // stderr. func RunKubectlAndGetOutputE(t testing.TestingT, options *KubectlOptions, args ...string) (string, error) { cmdArgs := []string{} if options.ContextName != "" { cmdArgs = append(cmdArgs, "--context", options.ContextName) } if options.ConfigPath != "" { cmdArgs = append(cmdArgs, "--kubeconfig", options.ConfigPath) } if options.Namespace != "" { cmdArgs = append(cmdArgs, "--namespace", options.Namespace) } if options.RequestTimeout > 0 { cmdArgs = append(cmdArgs, "--request-timeout", options.RequestTimeout.String()) } cmdArgs = append(cmdArgs, args...) command := shell.Command{ Command: "kubectl", Args: cmdArgs, Env: options.Env, Logger: options.Logger, } return shell.RunCommandAndGetOutputE(t, command) } // KubectlDelete will take in a file path and delete it from the cluster targeted by KubectlOptions. If there are any // errors, fail the test immediately. func KubectlDelete(t testing.TestingT, options *KubectlOptions, configPath string) { require.NoError(t, KubectlDeleteE(t, options, configPath)) } // KubectlDeleteE will take in a file path and delete it from the cluster targeted by KubectlOptions. func KubectlDeleteE(t testing.TestingT, options *KubectlOptions, configPath string) error { return RunKubectlE(t, options, "delete", "-f", configPath) } // KubectlDeleteFromKustomize will take in a kustomization directory path and delete it from the cluster targeted by KubectlOptions. If there are any // errors, fail the test immediately. func KubectlDeleteFromKustomize(t testing.TestingT, options *KubectlOptions, configPath string) { require.NoError(t, KubectlDeleteFromKustomizeE(t, options, configPath)) } // KubectlDeleteFromKustomizeE will take in a kustomization directory path and delete it from the cluster targeted by KubectlOptions. func KubectlDeleteFromKustomizeE(t testing.TestingT, options *KubectlOptions, configPath string) error { return RunKubectlE(t, options, "delete", "-k", configPath) } // KubectlDeleteFromString will take in a kubernetes resource config as a string and delete it on the cluster specified // by the provided kubectl options. func KubectlDeleteFromString(t testing.TestingT, options *KubectlOptions, configData string) { require.NoError(t, KubectlDeleteFromStringE(t, options, configData)) } // KubectlDeleteFromStringE will take in a kubernetes resource config as a string and delete it on the cluster specified // by the provided kubectl options. If it fails, this will return the error. func KubectlDeleteFromStringE(t testing.TestingT, options *KubectlOptions, configData string) error { tmpfile, err := StoreConfigToTempFileE(t, configData) if err != nil { return err } defer os.Remove(tmpfile) return KubectlDeleteE(t, options, tmpfile) } // KubectlApply will take in a file path and apply it to the cluster targeted by KubectlOptions. If there are any // errors, fail the test immediately. func KubectlApply(t testing.TestingT, options *KubectlOptions, configPath string) { require.NoError(t, KubectlApplyE(t, options, configPath)) } // KubectlApplyE will take in a file path and apply it to the cluster targeted by KubectlOptions. func KubectlApplyE(t testing.TestingT, options *KubectlOptions, configPath string) error { return RunKubectlE(t, options, "apply", "-f", configPath) } // KubectlApplyFromKustomize will take in a kustomization directory path and apply it to the cluster targeted by KubectlOptions. If there are any // errors, fail the test immediately. func KubectlApplyFromKustomize(t testing.TestingT, options *KubectlOptions, configPath string) { require.NoError(t, KubectlApplyFromKustomizeE(t, options, configPath)) } // KubectlApplyFromKustomizeE will take in a kustomization directory path and apply it to the cluster targeted by KubectlOptions. func KubectlApplyFromKustomizeE(t testing.TestingT, options *KubectlOptions, configPath string) error { return RunKubectlE(t, options, "apply", "-k", configPath) } // KubectlApplyFromString will take in a kubernetes resource config as a string and apply it on the cluster specified // by the provided kubectl options. func KubectlApplyFromString(t testing.TestingT, options *KubectlOptions, configData string) { require.NoError(t, KubectlApplyFromStringE(t, options, configData)) } // KubectlApplyFromStringE will take in a kubernetes resource config as a string and apply it on the cluster specified // by the provided kubectl options. If it fails, this will return the error. func KubectlApplyFromStringE(t testing.TestingT, options *KubectlOptions, configData string) error { tmpfile, err := StoreConfigToTempFileE(t, configData) if err != nil { return err } defer os.Remove(tmpfile) return KubectlApplyE(t, options, tmpfile) } // StoreConfigToTempFile will store the provided config data to a temporary file created on the os and return the // filename. func StoreConfigToTempFile(t testing.TestingT, configData string) string { out, err := StoreConfigToTempFileE(t, configData) require.NoError(t, err) return out } // StoreConfigToTempFileE will store the provided config data to a temporary file created on the os and return the // filename, or error. func StoreConfigToTempFileE(t testing.TestingT, configData string) (string, error) { escapedTestName := url.PathEscape(t.Name()) tmpfile, err := os.CreateTemp("", escapedTestName) if err != nil { return "", err } defer tmpfile.Close() _, err = tmpfile.WriteString(configData) return tmpfile.Name(), err } ================================================ FILE: modules/k8s/kubectl_options.go ================================================ package k8s import ( "time" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/testing" "k8s.io/client-go/rest" ) // KubectlOptions represents common options necessary to specify for all Kubectl calls type KubectlOptions struct { ContextName string ConfigPath string Namespace string Env map[string]string InClusterAuth bool RestConfig *rest.Config Logger *logger.Logger RequestTimeout time.Duration } // NewKubectlOptions will return a pointer to new instance of KubectlOptions with the configured options func NewKubectlOptions(contextName string, configPath string, namespace string) *KubectlOptions { return &KubectlOptions{ ContextName: contextName, ConfigPath: configPath, Namespace: namespace, Env: map[string]string{}, } } // NewKubectlOptionsWithInClusterAuth will return a pointer to a new instance of KubectlOptions with the InClusterAuth field set to true func NewKubectlOptionsWithInClusterAuth() *KubectlOptions { return &KubectlOptions{ InClusterAuth: true, } } // NewKubectlOptionsWithRestConfig will return a pointer to a new instance of KubectlOptions with pre-built config object func NewKubectlOptionsWithRestConfig(config *rest.Config, namespace string) *KubectlOptions { return &KubectlOptions{ Namespace: namespace, RestConfig: config, } } // GetConfigPath will return a sensible default if the config path is not set on the options. func (kubectlOptions *KubectlOptions) GetConfigPath(t testing.TestingT) (string, error) { // We predeclare `err` here so that we can update `kubeConfigPath` in the if block below. Otherwise, go complains // saying `err` is undefined. var err error kubeConfigPath := kubectlOptions.ConfigPath if kubeConfigPath == "" { kubeConfigPath, err = GetKubeConfigPathE(t) if err != nil { return "", err } } return kubeConfigPath, nil } ================================================ FILE: modules/k8s/kubectl_test.go ================================================ //go:build kubeall || kubernetes // +build kubeall kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package k8s import ( "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/gruntwork-io/terratest/modules/random" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // Test that RunKubectlAndGetOutputE will run kubectl and return the output by running a can-i command call. func TestRunKubectlAndGetOutputReturnsOutput(t *testing.T) { namespaceName := fmt.Sprintf("kubectl-test-%s", strings.ToLower(random.UniqueId())) options := NewKubectlOptions("", "", namespaceName) output, err := RunKubectlAndGetOutputE(t, options, "auth", "can-i", "get", "pods") require.NoError(t, err) require.Equal(t, output, "yes") } func TestKubectlRequestTimeout(t *testing.T) { t.Parallel() var parsedTimeout time.Duration server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { parsedTimeout, _ = time.ParseDuration(r.URL.Query().Get("timeout")) select { case <-time.After(3 * time.Second): case <-r.Context().Done(): } w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("dummy-error")) })) config := fmt.Sprintf(` apiVersion: v1 kind: Config clusters: - name: dummy-cluster cluster: server: %s users: - name: dummy-user user: token: dummy-token contexts: - name: dummy-context context: cluster: dummy-cluster user: dummy-user current-context: dummy-context `, server.URL) t.Run("WithoutTimeout", func(t *testing.T) { options := &KubectlOptions{ ContextName: "dummy-context", ConfigPath: StoreConfigToTempFile(t, config), } _, err := RunKubectlAndGetOutputE(t, options, "get", "pods") require.Error(t, err) assert.Contains(t, err.Error(), "dummy-error") assert.NotContains(t, err.Error(), "Client.Timeout exceeded while awaiting headers") }) t.Run("WithTimeout", func(t *testing.T) { options := &KubectlOptions{ ContextName: "dummy-context", ConfigPath: StoreConfigToTempFile(t, config), RequestTimeout: time.Second, } _, err := RunKubectlAndGetOutputE(t, options, "get", "pods") require.Error(t, err) assert.Equal(t, options.RequestTimeout, parsedTimeout) assert.NotContains(t, err.Error(), "dummy-error") assert.Contains(t, err.Error(), "Client.Timeout exceeded while awaiting headers") }) } ================================================ FILE: modules/k8s/minikube.go ================================================ package k8s import ( "strings" "github.com/gruntwork-io/terratest/modules/testing" corev1 "k8s.io/api/core/v1" ) // IsMinikubeE returns true if the underlying kubernetes cluster is Minikube. This is determined by getting the // associated nodes and checking if all nodes has at least one label namespaced with "minikube.k8s.io". func IsMinikubeE(t testing.TestingT, options *KubectlOptions) (bool, error) { nodes, err := GetNodesE(t, options) if err != nil { return false, err } // ASSUMPTION: All minikube setups will have nodes with labels that are namespaced with minikube.k8s.io for _, node := range nodes { if !nodeHasMinikubeLabel(node) { return false, nil } } // At this point we know that all the nodes in the cluster has the minikube label, so we return true. return true, nil } // nodeHasMinikubeLabel returns true if any of the labels on the node is namespaced with minikube.k8s.io func nodeHasMinikubeLabel(node corev1.Node) bool { labels := node.GetLabels() for key, _ := range labels { if strings.HasPrefix(key, "minikube.k8s.io") { return true } } return false } ================================================ FILE: modules/k8s/minikube_test.go ================================================ //go:build kubeall || kubernetes // +build kubeall kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package k8s import ( "testing" "github.com/stretchr/testify/assert" ) // Since we always run unit tests against minikube, we can only test if IsMinikubeE returns true. func TestIsMinikube(t *testing.T) { t.Parallel() options := NewKubectlOptions("", "", "") isMinikube, err := IsMinikubeE(t, options) assert.NoError(t, err) assert.True(t, isMinikube) } ================================================ FILE: modules/k8s/namespace.go ================================================ package k8s import ( "context" "strings" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // CreateNamespace will create a new Kubernetes namespace on the cluster targeted by the provided options. This will // fail the test if there is an error in creating the namespace. func CreateNamespace(t testing.TestingT, options *KubectlOptions, namespaceName string) { require.NoError(t, CreateNamespaceE(t, options, namespaceName)) } // CreateNamespaceE will create a new Kubernetes namespace on the cluster targeted by the provided options. func CreateNamespaceE(t testing.TestingT, options *KubectlOptions, namespaceName string) error { namespaceObject := metav1.ObjectMeta{ Name: namespaceName, } return CreateNamespaceWithMetadataE(t, options, namespaceObject) } // CreateNamespaceWithMetadataE will create a new Kubernetes namespace on the cluster targeted by the provided options and // with the provided metadata. This method expects the entire namespace ObjectMeta to be passed in, so you'll need to set the name within the ObjectMeta struct yourself. func CreateNamespaceWithMetadataE(t testing.TestingT, options *KubectlOptions, namespaceObjectMeta metav1.ObjectMeta) error { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return err } namespace := corev1.Namespace{ ObjectMeta: namespaceObjectMeta, } _, err = clientset.CoreV1().Namespaces().Create(context.Background(), &namespace, metav1.CreateOptions{}) return err } // CreateNamespaceWithMetadata will create a new Kubernetes namespace on the cluster targeted by the provided options and // with the provided metadata. This method expects the entire namespace ObjectMeta to be passed in, so you'll need to set the name within the ObjectMeta struct yourself. // This will fail the test if there is an error while creating the namespace. func CreateNamespaceWithMetadata(t testing.TestingT, options *KubectlOptions, namespaceObjectMeta metav1.ObjectMeta) { require.NoError(t, CreateNamespaceWithMetadataE(t, options, namespaceObjectMeta)) } // GetNamespace will query the Kubernetes cluster targeted by the provided options for the requested namespace. This will // fail the test if there is an error in getting the namespace or if the namespace doesn't exist. func GetNamespace(t testing.TestingT, options *KubectlOptions, namespaceName string) *corev1.Namespace { namespace, err := GetNamespaceE(t, options, namespaceName) require.NoError(t, err) require.NotNil(t, namespace) return namespace } // GetNamespaceE will query the Kubernetes cluster targeted by the provided options for the requested namespace. func GetNamespaceE(t testing.TestingT, options *KubectlOptions, namespaceName string) (*corev1.Namespace, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } return clientset.CoreV1().Namespaces().Get(context.Background(), namespaceName, metav1.GetOptions{}) } // DeleteNamespace will delete the requested namespace from the Kubernetes cluster targeted by the provided options. This will // fail the test if there is an error in creating the namespace. func DeleteNamespace(t testing.TestingT, options *KubectlOptions, namespaceName string) { require.NoError(t, DeleteNamespaceE(t, options, namespaceName)) } // DeleteNamespaceE will delete the requested namespace from the Kubernetes cluster targeted by the provided options. func DeleteNamespaceE(t testing.TestingT, options *KubectlOptions, namespaceName string) error { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return err } return clientset.CoreV1().Namespaces().Delete(context.Background(), namespaceName, metav1.DeleteOptions{}) } // ListNamespaces will list all namespaces in the Kubernetes cluster targeted by the provided options. // This will fail the test if there is an error in listing the namespaces. func ListNamespaces(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) []corev1.Namespace { namespaces, err := ListNamespacesE(t, options, filters) require.NoError(t, err) if len(namespaces) > 0 { var namespaceNames []string for _, ns := range namespaces { namespaceNames = append(namespaceNames, ns.Name) } options.Logger.Logf(t, "Found namespaces: %s", strings.Join(namespaceNames, ", ")) } else { options.Logger.Logf(t, "No namespaces found matching the provided filters.") } return namespaces } // ListNamespacesE lists all namespaces in the Kubernetes cluster and returns them or an error. func ListNamespacesE(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) ([]corev1.Namespace, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } namespaceList, err := clientset.CoreV1().Namespaces().List(context.Background(), filters) if err != nil { return nil, err } return namespaceList.Items, nil } ================================================ FILE: modules/k8s/namespace_test.go ================================================ //go:build kubeall || kubernetes // +build kubeall kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package k8s import ( "strings" "testing" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/gruntwork-io/terratest/modules/random" ) func TestNamespaces(t *testing.T) { t.Parallel() uniqueId := random.UniqueId() namespaceName := strings.ToLower(uniqueId) options := NewKubectlOptions("", "", namespaceName) CreateNamespace(t, options, namespaceName) defer func() { DeleteNamespace(t, options, namespaceName) namespace := GetNamespace(t, options, namespaceName) require.Equal(t, namespace.Status.Phase, corev1.NamespaceTerminating) }() namespace := GetNamespace(t, options, namespaceName) require.Equal(t, namespace.Name, namespaceName) } func TestNamespaceWithMetadata(t *testing.T) { t.Parallel() uniqueId := random.UniqueId() namespaceName := strings.ToLower(uniqueId) options := NewKubectlOptions("", "", namespaceName) namespaceLabels := map[string]string{"foo": "bar"} namespaceObjectMetaWithLabels := metav1.ObjectMeta{ Name: namespaceName, Labels: namespaceLabels, } CreateNamespaceWithMetadata(t, options, namespaceObjectMetaWithLabels) defer func() { DeleteNamespace(t, options, namespaceName) namespace := GetNamespace(t, options, namespaceName) require.Equal(t, namespace.Status.Phase, corev1.NamespaceTerminating) }() namespace := GetNamespace(t, options, namespaceName) require.Equal(t, namespace.Name, namespaceName) for k, v := range namespaceLabels { require.Equal(t, v, namespace.Labels[k], "Expected label %s=%s", k, v) } } func TestListNamespaces(t *testing.T) { t.Parallel() uniqueId := random.UniqueId() namespaceName := strings.ToLower(uniqueId) options := NewKubectlOptions("", "", namespaceName) CreateNamespace(t, options, namespaceName) defer DeleteNamespace(t, options, namespaceName) t.Run("List all namespaces and find the created one", func(t *testing.T) { t.Parallel() namespaces := ListNamespaces(t, options, metav1.ListOptions{}) require.NotEmpty(t, namespaces, "Should find at least some namespaces") found := false for _, ns := range namespaces { if ns.Name == namespaceName { found = true break } } require.True(t, found, "Should find the created namespace in the list") }) } ================================================ FILE: modules/k8s/networkpolicy.go ================================================ package k8s import ( "context" "fmt" "time" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" networkingv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // GetNetworkPolicy returns a Kubernetes networkpolicy resource in the provided namespace with the given name. The namespace used // is the one provided in the KubectlOptions. This will fail the test if there is an error. func GetNetworkPolicy(t testing.TestingT, options *KubectlOptions, networkPolicyName string) *networkingv1.NetworkPolicy { networkPolicy, err := GetNetworkPolicyE(t, options, networkPolicyName) require.NoError(t, err) return networkPolicy } // GetNetworkPolicyE returns a Kubernetes networkpolicy resource in the provided namespace with the given name. The namespace used // is the one provided in the KubectlOptions. func GetNetworkPolicyE(t testing.TestingT, options *KubectlOptions, networkPolicyName string) (*networkingv1.NetworkPolicy, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } return clientset.NetworkingV1().NetworkPolicies(options.Namespace).Get(context.Background(), networkPolicyName, metav1.GetOptions{}) } // WaitUntilNetworkPolicyAvailable waits until the networkpolicy is present on the cluster in cases where it is not immediately // available (for example, when using ClusterIssuer to request a certificate). func WaitUntilNetworkPolicyAvailable(t testing.TestingT, options *KubectlOptions, networkPolicyName string, retries int, sleepBetweenRetries time.Duration) { statusMsg := fmt.Sprintf("Wait for networkpolicy %s to be provisioned.", networkPolicyName) message := retry.DoWithRetry( t, statusMsg, retries, sleepBetweenRetries, func() (string, error) { _, err := GetNetworkPolicyE(t, options, networkPolicyName) if err != nil { return "", err } return "networkpolicy is now available", nil }, ) options.Logger.Logf(t, "%s", message) } ================================================ FILE: modules/k8s/networkpolicy_test.go ================================================ //go:build kubeall || kubernetes // +build kubeall kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package k8s import ( "fmt" "strings" "testing" "time" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terratest/modules/random" ) func TestGetNetworkPolicyEReturnsErrorForNonExistantNetworkPolicy(t *testing.T) { t.Parallel() options := NewKubectlOptions("", "", "default") _, err := GetNetworkPolicyE(t, options, "test-network-policy") require.Error(t, err) } func TestGetNetworkPolicyEReturnsCorrectNetworkPolicyInCorrectNamespace(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_NETWORK_POLICY_YAML_TEMPLATE, uniqueID, uniqueID) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) networkPolicy := GetNetworkPolicy(t, options, "test-network-policy") require.Equal(t, networkPolicy.Name, "test-network-policy") require.Equal(t, networkPolicy.Namespace, uniqueID) } func TestWaitUntilNetworkPolicyAvailableReturnsSuccessfully(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_NETWORK_POLICY_YAML_TEMPLATE, uniqueID, uniqueID) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) WaitUntilNetworkPolicyAvailable(t, options, "test-network-policy", 10, 1*time.Second) } const EXAMPLE_NETWORK_POLICY_YAML_TEMPLATE = `--- apiVersion: v1 kind: Namespace metadata: name: %s --- apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: test-network-policy namespace: %s spec: podSelector: {} policyTypes: - Ingress ` ================================================ FILE: modules/k8s/node.go ================================================ package k8s import ( "context" "errors" "time" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/testing" ) // GetNodes queries Kubernetes for information about the worker nodes registered to the cluster. If anything goes wrong, // the function will automatically fail the test. func GetNodes(t testing.TestingT, options *KubectlOptions) []corev1.Node { nodes, err := GetNodesE(t, options) require.NoError(t, err) return nodes } // GetNodesE queries Kubernetes for information about the worker nodes registered to the cluster. func GetNodesE(t testing.TestingT, options *KubectlOptions) ([]corev1.Node, error) { return GetNodesByFilterE(t, options, metav1.ListOptions{}) } // GetNodesByFilterE queries Kubernetes for information about the worker nodes registered to the cluster, filtering the // list of nodes using the provided ListOptions. func GetNodesByFilterE(t testing.TestingT, options *KubectlOptions, filter metav1.ListOptions) ([]corev1.Node, error) { options.Logger.Logf(t, "Getting list of nodes from Kubernetes") clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } nodes, err := clientset.CoreV1().Nodes().List(context.Background(), filter) if err != nil { return nil, err } return nodes.Items, err } // GetReadyNodes queries Kubernetes for information about the worker nodes registered to the cluster and only returns // those that are in the ready state. If anything goes wrong, the function will automatically fail the test. func GetReadyNodes(t testing.TestingT, options *KubectlOptions) []corev1.Node { nodes, err := GetReadyNodesE(t, options) require.NoError(t, err) return nodes } // GetReadyNodesE queries Kubernetes for information about the worker nodes registered to the cluster and only returns // those that are in the ready state. func GetReadyNodesE(t testing.TestingT, options *KubectlOptions) ([]corev1.Node, error) { nodes, err := GetNodesE(t, options) if err != nil { return nil, err } options.Logger.Logf(t, "Filtering list of nodes from Kubernetes for Ready nodes") nodesFiltered := []corev1.Node{} for _, node := range nodes { if IsNodeReady(node) { nodesFiltered = append(nodesFiltered, node) } } return nodesFiltered, nil } // IsNodeReady takes a Kubernetes Node information object and checks if the Node is in the ready state. func IsNodeReady(node corev1.Node) bool { for _, condition := range node.Status.Conditions { if condition.Type == corev1.NodeReady { return condition.Status == corev1.ConditionTrue } } return false } // WaitUntilAllNodesReady continuously polls the Kubernetes cluster until all nodes in the cluster reach the ready // state, or runs out of retries. Will fail the test immediately if it times out. func WaitUntilAllNodesReady(t testing.TestingT, options *KubectlOptions, retries int, sleepBetweenRetries time.Duration) { err := WaitUntilAllNodesReadyE(t, options, retries, sleepBetweenRetries) require.NoError(t, err) } // WaitUntilAllNodesReadyE continuously polls the Kubernetes cluster until all nodes in the cluster reach the ready // state, or runs out of retries. func WaitUntilAllNodesReadyE(t testing.TestingT, options *KubectlOptions, retries int, sleepBetweenRetries time.Duration) error { message, err := retry.DoWithRetryE( t, "Wait for all Kube Nodes to be ready", retries, sleepBetweenRetries, func() (string, error) { _, err := AreAllNodesReadyE(t, options) if err != nil { return "", err } return "All nodes ready", nil }, ) options.Logger.Logf(t, "%s", message) return err } // AreAllNodesReady checks if all nodes are ready in the Kubernetes cluster targeted by the current config context func AreAllNodesReady(t testing.TestingT, options *KubectlOptions) bool { nodesReady, _ := AreAllNodesReadyE(t, options) return nodesReady } // AreAllNodesReadyE checks if all nodes are ready in the Kubernetes cluster targeted by the current config context. If // false, returns an error indicating the reason. func AreAllNodesReadyE(t testing.TestingT, options *KubectlOptions) (bool, error) { nodes, err := GetNodesE(t, options) if err != nil { return false, err } if len(nodes) == 0 { return false, errors.New("No nodes available") } for _, node := range nodes { if !IsNodeReady(node) { return false, errors.New("Not all nodes ready") } } return true, nil } ================================================ FILE: modules/k8s/node_test.go ================================================ //go:build kubeall || kubernetes // +build kubeall kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package k8s import ( "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // Tests that: // - kubectl is properly configured to talk to a kubernetes cluster // - GetNodes will return a list of nodes registered with kubernetes func TestGetNodes(t *testing.T) { t.Parallel() // Assumes local kubernetes (minikube or docker-for-desktop kube), where there is only one node options := NewKubectlOptions("", "", "default") nodes := GetNodes(t, options) require.Equal(t, len(nodes), 1) node := nodes[0] // Make sure node name is not blank, indicating an uninitialized Node object assert.NotEqual(t, node.Name, "") } // Tests that: // - kubectl is properly configured to talk to a kubernetes cluster // - GetReadyNodes will return a list of ready nodes registered with kubernetes func TestGetReadyNodes(t *testing.T) { t.Parallel() // Assumes local kubernetes (minikube or docker-for-desktop kube), where there is only one node options := NewKubectlOptions("", "", "default") nodes := GetReadyNodes(t, options) require.Equal(t, len(nodes), 1) node := nodes[0] // Make sure node name is not blank, indicating an uninitialized Node object assert.NotEqual(t, node.Name, "") } // Tests that: // - kubectl is properly configured to talk to a kubernetes cluster // - WaitUntilAllNodesReady checks if all nodes in the cluster are ready func TestWaitUntilAllNodesReady(t *testing.T) { t.Parallel() options := NewKubectlOptions("", "", "default") WaitUntilAllNodesReady(t, options, 12, 5*time.Second) nodes := GetNodes(t, options) nodeNames := map[string]bool{} for _, node := range nodes { nodeNames[node.Name] = true } readyNodes := GetReadyNodes(t, options) readyNodeNames := map[string]bool{} for _, node := range readyNodes { readyNodeNames[node.Name] = true } assert.Equal(t, nodeNames, readyNodeNames) } ================================================ FILE: modules/k8s/persistent_volume.go ================================================ package k8s import ( "context" "fmt" "time" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/testing" ) // ListPersistentVolumes will look for PersistentVolumes in the given namespace that match the given filters and return them. This will fail the // test if there is an error. func ListPersistentVolumes(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) []corev1.PersistentVolume { pvs, err := ListPersistentVolumesE(t, options, filters) require.NoError(t, err) return pvs } // ListPersistentVolumesE will look for PersistentVolumes that match the given filters and return them. func ListPersistentVolumesE(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) ([]corev1.PersistentVolume, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } resp, err := clientset.CoreV1().PersistentVolumes().List(context.Background(), filters) if err != nil { return nil, err } return resp.Items, nil } // GetPersistentVolume returns a Kubernetes PersistentVolume resource with the given name. This will fail the test if there is an error. func GetPersistentVolume(t testing.TestingT, options *KubectlOptions, name string) *corev1.PersistentVolume { pv, err := GetPersistentVolumeE(t, options, name) require.NoError(t, err) return pv } // GetPersistentVolumeE returns a Kubernetes PersistentVolume resource with the given name. func GetPersistentVolumeE(t testing.TestingT, options *KubectlOptions, name string) (*corev1.PersistentVolume, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } return clientset.CoreV1().PersistentVolumes().Get(context.Background(), name, metav1.GetOptions{}) } // WaitUntilPersistentVolumeInStatus waits until the given Persistent Volume is the given status phase, // retrying the check for the specified amount of times, sleeping // for the provided duration between each try. // This will fail the test if there is an error. func WaitUntilPersistentVolumeInStatus(t testing.TestingT, options *KubectlOptions, pvName string, pvStatusPhase *corev1.PersistentVolumePhase, retries int, sleepBetweenRetries time.Duration) { require.NoError(t, WaitUntilPersistentVolumeInStatusE(t, options, pvName, pvStatusPhase, retries, sleepBetweenRetries)) } // WaitUntilPersistentVolumeInStatusE waits until the given PersistentVolume is in the given status phase, // retrying the check for the specified amount of times, sleeping // for the provided duration between each try. func WaitUntilPersistentVolumeInStatusE( t testing.TestingT, options *KubectlOptions, pvName string, pvStatusPhase *corev1.PersistentVolumePhase, retries int, sleepBetweenRetries time.Duration, ) error { statusMsg := fmt.Sprintf("Wait for Persistent Volume %s to be '%s'", pvName, *pvStatusPhase) message, err := retry.DoWithRetryE( t, statusMsg, retries, sleepBetweenRetries, func() (string, error) { pv, err := GetPersistentVolumeE(t, options, pvName) if err != nil { return "", err } if !IsPersistentVolumeInStatus(pv, pvStatusPhase) { return "", NewPersistentVolumeNotInStatusError(pv, pvStatusPhase) } return fmt.Sprintf("Persistent Volume is now '%s'", *pvStatusPhase), nil }, ) if err != nil { options.Logger.Logf(t, "Timeout waiting for PersistentVolume to be '%s': %s", *pvStatusPhase, err) return err } options.Logger.Logf(t, "%s", message) return nil } // IsPersistentVolumeInStatus returns true if the given PersistentVolume is in the given status phase func IsPersistentVolumeInStatus(pv *corev1.PersistentVolume, pvStatusPhase *corev1.PersistentVolumePhase) bool { return pv != nil && pv.Status.Phase == *pvStatusPhase } ================================================ FILE: modules/k8s/persistent_volume_claim.go ================================================ package k8s import ( "context" "fmt" "time" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/testing" ) // ListPersistentVolumeClaims will look for PersistentVolumeClaims in the given namespace that match the given filters and return them. This will fail the // test if there is an error. func ListPersistentVolumeClaims(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) []corev1.PersistentVolumeClaim { pvcs, err := ListPersistentVolumeClaimsE(t, options, filters) require.NoError(t, err) return pvcs } // ListPersistentVolumeClaimsE will look for PersistentVolumeClaims in the given namespace that match the given filters and return them. func ListPersistentVolumeClaimsE(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) ([]corev1.PersistentVolumeClaim, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } resp, err := clientset.CoreV1().PersistentVolumeClaims(options.Namespace).List(context.Background(), filters) if err != nil { return nil, err } return resp.Items, nil } // GetPersistentVolumeClaim returns a Kubernetes PersistentVolumeClaim resource in the provided namespace with the given name. This will // fail the test if there is an error. func GetPersistentVolumeClaim(t testing.TestingT, options *KubectlOptions, pvcName string) *corev1.PersistentVolumeClaim { pvc, err := GetPersistentVolumeClaimE(t, options, pvcName) require.NoError(t, err) return pvc } // GetPersistentVolumeClaimE returns a Kubernetes PersistentVolumeClaim resource in the provided namespace with the given name. func GetPersistentVolumeClaimE(t testing.TestingT, options *KubectlOptions, pvcName string) (*corev1.PersistentVolumeClaim, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } return clientset.CoreV1().PersistentVolumeClaims(options.Namespace).Get(context.Background(), pvcName, metav1.GetOptions{}) } // WaitUntilPersistentVolumeClaimInStatus waits until the given PersistentVolumeClaim is the given status phase, // retrying the check for the specified amount of times, sleeping // for the provided duration between each try. // This will fail the test if there is an error. func WaitUntilPersistentVolumeClaimInStatus(t testing.TestingT, options *KubectlOptions, pvcName string, pvcStatusPhase *corev1.PersistentVolumeClaimPhase, retries int, sleepBetweenRetries time.Duration) { require.NoError(t, WaitUntilPersistentVolumeClaimInStatusE(t, options, pvcName, pvcStatusPhase, retries, sleepBetweenRetries)) } // WaitUntilPersistentVolumeClaimInStatusE waits until the given PersistentVolumeClaim is the given status phase, // retrying the check for the specified amount of times, sleeping // for the provided duration between each try. // This will fail the test if there is an error. func WaitUntilPersistentVolumeClaimInStatusE(t testing.TestingT, options *KubectlOptions, pvcName string, pvcStatusPhase *corev1.PersistentVolumeClaimPhase, retries int, sleepBetweenRetries time.Duration) error { statusMsg := fmt.Sprintf("Wait for PersistentVolumeClaim %s to be '%s'.", pvcName, *pvcStatusPhase) message, err := retry.DoWithRetryE( t, statusMsg, retries, sleepBetweenRetries, func() (string, error) { pvc, err := GetPersistentVolumeClaimE(t, options, pvcName) if err != nil { return "", err } if !IsPersistentVolumeClaimInStatus(pvc, pvcStatusPhase) { return "", NewPersistentVolumeClaimNotInStatusError(pvc, pvcStatusPhase) } return fmt.Sprintf("PersistentVolumeClaim is now '%s'", *pvcStatusPhase), nil }, ) if err != nil { logger.Default.Logf(t, "Timeout waiting for PersistentVolumeClaim to be '%s': %s", *pvcStatusPhase, err) return err } logger.Default.Logf(t, "%s", message) return nil } // IsPersistentVolumeClaimInStatus returns true if the given PersistentVolumeClaim is in the given status phase func IsPersistentVolumeClaimInStatus(pvc *corev1.PersistentVolumeClaim, pvcStatusPhase *corev1.PersistentVolumeClaimPhase) bool { return pvc != nil && pvc.Status.Phase == *pvcStatusPhase } ================================================ FILE: modules/k8s/persistent_volume_claim_test.go ================================================ //go:build kubeall || kubernetes // +build kubeall kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package k8s import ( "strings" "testing" "time" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" _ "k8s.io/client-go/plugin/pkg/client/auth" "github.com/gruntwork-io/terratest/modules/random" ) func TestListPersistentVolumeClaimsReturnsPersistentVolumeClaimsInNamespace(t *testing.T) { t.Parallel() pvcName := "test-dummy-pvc" namespace := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", namespace) configData := renderFixtureYamlTemplate(namespace, pvcName) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) pvcs := ListPersistentVolumeClaims(t, options, metav1.ListOptions{}) require.Equal(t, len(pvcs), 1) pvc := pvcs[0] require.Equal(t, pvc.Name, pvcName) require.Equal(t, pvc.Namespace, namespace) } func TestListPersistentVolumeClaimsReturnsZeroPersistentVolumeClaimsIfNoneCreated(t *testing.T) { t.Parallel() namespace := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", namespace) CreateNamespace(t, options, namespace) defer DeleteNamespace(t, options, namespace) pvcs := ListPersistentVolumeClaims(t, options, metav1.ListOptions{}) require.Equal(t, len(pvcs), 0) } func TestGetPersistentVolumeClaimEReturnsErrorForNonExistantPersistentVolumeClaim(t *testing.T) { t.Parallel() options := NewKubectlOptions("", "", "default") _, err := GetPersistentVolumeClaimE(t, options, "non-existent") require.Error(t, err) } func TestGetPersistentVolumeClaimReturnsCorrectPersistentVolumeClaimInCorrectNamespace(t *testing.T) { t.Parallel() pvcName := "test-dummy-pvc" namespace := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", namespace) configData := renderFixtureYamlTemplate(namespace, pvcName) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) pvc := GetPersistentVolumeClaim(t, options, pvcName) require.Equal(t, pvc.Name, pvcName) require.Equal(t, pvc.Namespace, namespace) } func TestWaitUntilPersistentVolumeClaimInGivenStatusPhase(t *testing.T) { t.Parallel() pvcName := "test-dummy-pvc" namespace := strings.ToLower(random.UniqueId()) pvcBoundStatusPhase := corev1.ClaimBound options := NewKubectlOptions("", "", namespace) configData := renderFixtureYamlTemplate(namespace, pvcName) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) WaitUntilPersistentVolumeClaimInStatus(t, options, pvcName, &pvcBoundStatusPhase, 60, 1*time.Second) } func TestWaitUntilPersistentVolumeClaimInStatusEReturnsErrorWhenWaitingForAnUnexistentPvc(t *testing.T) { t.Parallel() pvcBoundStatusPhase := corev1.ClaimBound options := NewKubectlOptions("", "", "default") err := WaitUntilPersistentVolumeClaimInStatusE(t, options, "non-existent", &pvcBoundStatusPhase, 3, 1*time.Second) require.NotEqual(t, err, nil) } func TestWaitUntilPersistentVolumeClaimInStatusEReturnsErrorWhenTimesOut(t *testing.T) { t.Parallel() pvcName := "test-dummy-pvc" pvcLostStatusPhase := corev1.ClaimLost namespace := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", namespace) configData := renderFixtureYamlTemplate(namespace, pvcName) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) err := WaitUntilPersistentVolumeClaimInStatusE(t, options, pvcName, &pvcLostStatusPhase, 5, 1*time.Second) require.NotEqual(t, err, nil) } func TestIsPersistentVolumeClaimInStatusReturnsFalseIfPvcIsNil(t *testing.T) { t.Parallel() result := IsPersistentVolumeClaimInStatus(nil, nil) require.Equal(t, result, false) } const pvcFixtureYamlTemplate = `--- apiVersion: v1 kind: Namespace metadata: name: __namespace__ --- apiVersion: v1 kind: PersistentVolume metadata: name: __namespace__ spec: capacity: storage: 10Mi accessModes: - ReadWriteOnce hostPath: path: "/tmp/__namespace__" --- apiVersion: v1 kind: PersistentVolumeClaim metadata: namespace: __namespace__ name: __pvcName__ spec: accessModes: - ReadWriteOnce resources: requests: storage: 10Mi --- apiVersion: v1 kind: Pod metadata: name: test-pvc-pod namespace: __namespace__ spec: volumes: - name: test-pvc-volume persistentVolumeClaim: claimName: __pvcName__ containers: - name: test-pvc-image image: nginx volumeMounts: - mountPath: "/tmp/foo" name: test-pvc-volume ` func renderFixtureYamlTemplate(namespace, pvcName string) string { return strings.Replace(strings.Replace(pvcFixtureYamlTemplate, "__namespace__", namespace, -1), "__pvcName__", pvcName, -1) } ================================================ FILE: modules/k8s/persistent_volume_test.go ================================================ //go:build kubeall || kubernetes // +build kubeall kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package k8s import ( "fmt" "strings" "testing" "time" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" _ "k8s.io/client-go/plugin/pkg/client/auth" "github.com/gruntwork-io/terratest/modules/random" ) func TestListPersistentVolumesReturnsAllPersistentVolumes(t *testing.T) { t.Parallel() numPvFound := 0 pvNames := map[string]struct{}{ strings.ToLower(random.UniqueId()): {}, strings.ToLower(random.UniqueId()): {}, strings.ToLower(random.UniqueId()): {}, } options := NewKubectlOptions("", "", "") for pvName := range pvNames { pv := fmt.Sprintf(PvFixtureYamlTemplate, pvName, pvName) defer KubectlDeleteFromString(t, options, pv) KubectlApplyFromString(t, options, pv) } pvs := ListPersistentVolumes(t, options, metav1.ListOptions{}) for _, pv := range pvs { if _, ok := pvNames[pv.Name]; ok { numPvFound++ } } require.Equal(t, numPvFound, len(pvNames)) } func TestListPersistentVolumesReturnsZeroPersistentVolumesIfNoneCreated(t *testing.T) { t.Parallel() options := NewKubectlOptions("", "", "") pvs := ListPersistentVolumes(t, options, metav1.ListOptions{}) require.Equal(t, 0, len(pvs)) } func TestGetPersistentVolumeEReturnsErrorForNonExistentPersistentVolumes(t *testing.T) { t.Parallel() options := NewKubectlOptions("", "", "") _, err := GetPersistentVolumeE(t, options, "non-existent") require.Error(t, err) } func TestGetPersistentVolumeReturnsCorrectPersistentVolume(t *testing.T) { t.Parallel() pvName := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", "") configData := fmt.Sprintf(PvFixtureYamlTemplate, pvName, pvName) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) pv := GetPersistentVolume(t, options, pvName) require.Equal(t, pv.Name, pvName) } func TestWaitUntilPersistentVolumeInTheGivenStatusPhase(t *testing.T) { t.Parallel() pvName := strings.ToLower(random.UniqueId()) pvAvailableStatusPhase := corev1.VolumeAvailable options := NewKubectlOptions("", "", pvName) configData := fmt.Sprintf(PvFixtureYamlTemplate, pvName, pvName) KubectlApplyFromString(t, options, configData) defer KubectlDeleteFromString(t, options, configData) WaitUntilPersistentVolumeInStatus(t, options, pvName, &pvAvailableStatusPhase, 60, 1*time.Second) } const PvFixtureYamlTemplate = `--- apiVersion: v1 kind: PersistentVolume metadata: name: %s spec: capacity: storage: 10Mi accessModes: - ReadWriteOnce hostPath: path: "/tmp/%s" ` ================================================ FILE: modules/k8s/pod.go ================================================ package k8s import ( "context" "fmt" "time" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/testing" ) // ListPods will look for pods in the given namespace that match the given filters and return them. This will fail the // test if there is an error. func ListPods(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) []corev1.Pod { pods, err := ListPodsE(t, options, filters) require.NoError(t, err) return pods } // ListPodsE will look for pods in the given namespace that match the given filters and return them. func ListPodsE(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) ([]corev1.Pod, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } resp, err := clientset.CoreV1().Pods(options.Namespace).List(context.Background(), filters) if err != nil { return nil, err } return resp.Items, nil } // GetPod returns a Kubernetes pod resource in the provided namespace with the given name. This will // fail the test if there is an error. func GetPod(t testing.TestingT, options *KubectlOptions, podName string) *corev1.Pod { pod, err := GetPodE(t, options, podName) require.NoError(t, err) return pod } // GetPodE returns a Kubernetes pod resource in the provided namespace with the given name. func GetPodE(t testing.TestingT, options *KubectlOptions, podName string) (*corev1.Pod, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } return clientset.CoreV1().Pods(options.Namespace).Get(context.Background(), podName, metav1.GetOptions{}) } // WaitUntilNumPodsCreated waits until the desired number of pods are created that match the provided filter. This will // retry the check for the specified amount of times, sleeping for the provided duration between each try. This will // fail the test if the retry times out. func WaitUntilNumPodsCreated( t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions, desiredCount int, retries int, sleepBetweenRetries time.Duration, ) { require.NoError(t, WaitUntilNumPodsCreatedE(t, options, filters, desiredCount, retries, sleepBetweenRetries)) } // WaitUntilNumPodsCreatedE waits until the desired number of pods are created that match the provided filter. This will // retry the check for the specified amount of times, sleeping for the provided duration between each try. func WaitUntilNumPodsCreatedE( t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions, desiredCount int, retries int, sleepBetweenRetries time.Duration, ) error { statusMsg := fmt.Sprintf("Wait for num pods created to match desired count %d.", desiredCount) message, err := retry.DoWithRetryE( t, statusMsg, retries, sleepBetweenRetries, func() (string, error) { pods, err := ListPodsE(t, options, filters) if err != nil { return "", err } if len(pods) != desiredCount { return "", DesiredNumberOfPodsNotCreated{Filter: filters, DesiredCount: desiredCount} } return "Desired number of Pods created", nil }, ) if err != nil { options.Logger.Logf(t, "Timedout waiting for the desired number of Pods to be created: %s", err) return err } options.Logger.Logf(t, "%s", message) return nil } // WaitUntilPodAvailable waits until all of the containers within the pod are ready and started, retrying the check for the specified amount of times, sleeping // for the provided duration between each try. This will fail the test if there is an error or if the check times out. func WaitUntilPodAvailable(t testing.TestingT, options *KubectlOptions, podName string, retries int, sleepBetweenRetries time.Duration) { require.NoError(t, WaitUntilPodAvailableE(t, options, podName, retries, sleepBetweenRetries)) } // WaitUntilPodAvailableE waits until all of the containers within the pod are ready and started, retrying the check for the specified amount of times, sleeping // for the provided duration between each try. func WaitUntilPodAvailableE(t testing.TestingT, options *KubectlOptions, podName string, retries int, sleepBetweenRetries time.Duration) error { statusMsg := fmt.Sprintf("Wait for pod %s to be provisioned.", podName) message, err := retry.DoWithRetryE( t, statusMsg, retries, sleepBetweenRetries, func() (string, error) { pod, err := GetPodE(t, options, podName) if err != nil { return "", err } if !IsPodAvailable(pod) { return "", NewPodNotAvailableError(pod) } return "Pod is now available", nil }, ) if err != nil { options.Logger.Logf(t, "Timedout waiting for Pod to be provisioned: %s", err) return err } options.Logger.Logf(t, "%s", message) return nil } // IsPodAvailable returns true if the all of the containers within the pod are ready and started func IsPodAvailable(pod *corev1.Pod) bool { // Ensure all containers have reported their status if len(pod.Status.ContainerStatuses) != len(pod.Spec.Containers) { return false } for _, containerStatus := range pod.Status.ContainerStatuses { isContainerStarted := containerStatus.Started isContainerReady := containerStatus.Ready if !isContainerReady || (isContainerStarted != nil && *isContainerStarted == false) { return false } } return pod.Status.Phase == corev1.PodRunning } // GetPodLogsE returns the logs of a Pod at the time when the function was called. Pass container name if there are more containers in the Pod or set to "" if there is only one. // If the Pod is not running an Error is returned. // If the provided containerName is not the name of a container in the Pod an Error is returned. func GetPodLogsE(t testing.TestingT, options *KubectlOptions, pod *corev1.Pod, containerName string) (string, error) { var output string var err error if containerName == "" { output, err = RunKubectlAndGetOutputE(t, options, "logs", pod.Name) } else { output, err = RunKubectlAndGetOutputE(t, options, "logs", pod.Name, fmt.Sprintf("-c%s", containerName)) } if err != nil { return "", err } return output, nil } // GetPodLogs returns the logs of a Pod at the time when the function was called. Pass container name if there are more containers in the Pod or set to "" if there is only one. func GetPodLogs(t testing.TestingT, options *KubectlOptions, pod *corev1.Pod, containerName string) string { logs, err := GetPodLogsE(t, options, pod, containerName) require.NoError(t, err) return logs } // ExecPod executes a command in a container within a Kubernetes pod and returns the output. This will fail the test if // there is an error. Set containerName to "" if there is only one container in the pod. func ExecPod(t testing.TestingT, options *KubectlOptions, podName string, containerName string, command ...string) string { o, err := ExecPodE(t, options, podName, containerName, command...) require.NoError(t, err) return o } // ExecPodE executes a command in a container within a Kubernetes pod and returns the output. Set containerName to "" if // there is only one container in the pod. func ExecPodE(t testing.TestingT, options *KubectlOptions, podName string, containerName string, command ...string) (string, error) { var args []string if containerName == "" { args = append([]string{"exec", podName, "--"}, command...) } else { args = append([]string{"exec", podName, fmt.Sprintf("-c%s", containerName), "--"}, command...) } return RunKubectlAndGetOutputE(t, options, args...) } ================================================ FILE: modules/k8s/pod_test.go ================================================ //go:build kubeall || kubernetes // +build kubeall kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package k8s import ( "fmt" "strings" "testing" "time" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/gruntwork-io/terratest/modules/random" ) func TestListPodsReturnsPodsInNamespace(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_POD_YAML_TEMPLATE, uniqueID, uniqueID) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) pods := ListPods(t, options, metav1.ListOptions{}) require.Equal(t, len(pods), 1) pod := pods[0] require.Equal(t, pod.Name, "nginx-pod") require.Equal(t, pod.Namespace, uniqueID) } func TestGetPodEReturnsErrorForNonExistantPod(t *testing.T) { t.Parallel() options := NewKubectlOptions("", "", "default") _, err := GetPodE(t, options, "nginx-pod") require.Error(t, err) } func TestGetPodEReturnsCorrectPodInCorrectNamespace(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_POD_YAML_TEMPLATE, uniqueID, uniqueID) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) pod := GetPod(t, options, "nginx-pod") require.Equal(t, pod.Name, "nginx-pod") require.Equal(t, pod.Namespace, uniqueID) } func TestWaitUntilNumPodsCreatedReturnsSuccessfully(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_POD_YAML_TEMPLATE, uniqueID, uniqueID) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) WaitUntilNumPodsCreated(t, options, metav1.ListOptions{}, 1, 60, 1*time.Second) } func TestWaitUntilPodAvailableReturnsSuccessfully(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_POD_YAML_TEMPLATE, uniqueID, uniqueID) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) WaitUntilPodAvailable(t, options, "nginx-pod", 60, 1*time.Second) } func TestWaitUntilPodWithMultipleContainersAvailableReturnsSuccessfully(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_POD_WITH_MULTIPLE_CONTAINERS_YAML_TEMPLATE, uniqueID, uniqueID) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) WaitUntilPodAvailable(t, options, "nginx-pod", 60, 1*time.Second) } func TestWaitUntilPodAvailableWithReadinessProbe(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_POD_WITH_READINESS_PROBE, uniqueID, uniqueID) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) WaitUntilPodAvailable(t, options, "nginx-pod", 60, 1*time.Second) } func TestWaitUntilPodAvailableWithFailingReadinessProbe(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_POD_WITH_FAILING_READINESS_PROBE, uniqueID, uniqueID) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) err := WaitUntilPodAvailableE(t, options, "nginx-pod", 60, 1*time.Second) require.Error(t, err) } const EXAMPLE_POD_YAML_TEMPLATE = `--- apiVersion: v1 kind: Namespace metadata: name: %s --- apiVersion: v1 kind: Pod metadata: name: nginx-pod namespace: %s spec: containers: - name: nginx image: nginx:1.15.7 env: - name: NAME value: "nginx" ports: - containerPort: 80 ` const EXAMPLE_POD_WITH_MULTIPLE_CONTAINERS_YAML_TEMPLATE = `--- apiVersion: v1 kind: Namespace metadata: name: %s --- apiVersion: v1 kind: Pod metadata: name: nginx-pod namespace: %s spec: containers: - name: nginx image: nginx:1.15.7 env: - name: NAME value: "nginx" ports: - containerPort: 80 - name: nginx-two image: nginx:1.15.7 env: - name: NAME value: "nginx-two" ports: - containerPort: 8080 command: ["sh", "-c", "sed -i 's/80/8080/' /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'"] ` const EXAMPLE_POD_WITH_READINESS_PROBE = EXAMPLE_POD_YAML_TEMPLATE + ` readinessProbe: httpGet: path: / port: 80 ` const EXAMPLE_POD_WITH_FAILING_READINESS_PROBE = EXAMPLE_POD_YAML_TEMPLATE + ` readinessProbe: httpGet: path: /not-ready port: 80 periodSeconds: 1 ` func TestIsPodAvailable(t *testing.T) { t.Parallel() cases := []struct { title string pod *corev1.Pod expectedResult bool }{ { title: "TestIsPodAvailableStartedButNotReady", pod: &corev1.Pod{ Spec: corev1.PodSpec{ Containers: []corev1.Container{{Name: "container1"}}, }, Status: corev1.PodStatus{ ContainerStatuses: []corev1.ContainerStatus{ { Name: "container1", Ready: false, Started: &[]bool{true}[0], }, }, Phase: corev1.PodRunning, }, }, expectedResult: false, }, { title: "TestIsPodAvailableStartedAndReady", pod: &corev1.Pod{ Spec: corev1.PodSpec{ Containers: []corev1.Container{{Name: "container1"}}, }, Status: corev1.PodStatus{ ContainerStatuses: []corev1.ContainerStatus{ { Name: "container1", Ready: true, Started: &[]bool{true}[0], }, }, Phase: corev1.PodRunning, }, }, expectedResult: true, }, { title: "TestIsPodAvailableMissingContainerStatus", pod: &corev1.Pod{ Spec: corev1.PodSpec{ Containers: []corev1.Container{{Name: "container1"}, {Name: "container2"}}, }, Status: corev1.PodStatus{ ContainerStatuses: []corev1.ContainerStatus{ { Name: "container1", Ready: true, Started: &[]bool{true}[0], }, }, Phase: corev1.PodRunning, }, }, expectedResult: false, }, } for _, tc := range cases { tc := tc t.Run(tc.title, func(t *testing.T) { t.Parallel() actualResult := IsPodAvailable(tc.pod) require.Equal(t, tc.expectedResult, actualResult) }) } } func TestExecPod(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_POD_WITH_MULTIPLE_CONTAINERS_YAML_TEMPLATE, uniqueID, uniqueID) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) WaitUntilPodAvailable(t, options, "nginx-pod", 60, 1*time.Second) t.Run("TestExecPodWithoutContainer", func(t *testing.T) { stdout, err := ExecPodE(t, options, "nginx-pod", "", "env") require.NoError(t, err) require.Contains(t, stdout, "NAME=nginx\n") }) t.Run("TestExecPodWithContainer", func(t *testing.T) { stdout, err := ExecPodE(t, options, "nginx-pod", "nginx-two", "env") require.NoError(t, err) require.Contains(t, stdout, "NAME=nginx-two\n") }) } ================================================ FILE: modules/k8s/replicaset.go ================================================ package k8s import ( "context" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/gruntwork-io/terratest/modules/testing" ) // ListReplicaSets will look for replicasets in the given namespace that match the given filters and return them. This will // fail the test if there is an error. func ListReplicaSets(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) []appsv1.ReplicaSet { replicaset, err := ListReplicaSetsE(t, options, filters) require.NoError(t, err) return replicaset } // ListReplicaSetsE will look for replicasets in the given namespace that match the given filters and return them. func ListReplicaSetsE(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) ([]appsv1.ReplicaSet, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } replicasets, err := clientset.AppsV1().ReplicaSets(options.Namespace).List(context.Background(), filters) if err != nil { return nil, err } return replicasets.Items, nil } // GetReplicaSet returns a Kubernetes replicaset resource in the provided namespace with the given name. This will // fail the test if there is an error. func GetReplicaSet(t testing.TestingT, options *KubectlOptions, replicaSetName string) *appsv1.ReplicaSet { replicaset, err := GetReplicaSetE(t, options, replicaSetName) require.NoError(t, err) return replicaset } // GetReplicaSetE returns a Kubernetes replicaset resource in the provided namespace with the given name. func GetReplicaSetE(t testing.TestingT, options *KubectlOptions, replicaSetName string) (*appsv1.ReplicaSet, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } return clientset.AppsV1().ReplicaSets(options.Namespace).Get(context.Background(), replicaSetName, metav1.GetOptions{}) } ================================================ FILE: modules/k8s/replicaset_test.go ================================================ //go:build kubeall || kubernetes // +build kubeall kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package k8s import ( "fmt" "strings" "testing" "github.com/gruntwork-io/terratest/modules/random" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func TestGetReplicaSetEReturnsError(t *testing.T) { t.Parallel() options := NewKubectlOptions("", "", "") _, err := GetReplicaSetE(t, options, "sample-rs") require.Error(t, err) } func TestGetReplicaSets(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_REPLICASET_YAML_TEMPLATE, uniqueID, uniqueID) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) replicaSet := GetReplicaSet(t, options, "sample-rs") require.Equal(t, replicaSet.Name, "sample-rs") require.Equal(t, replicaSet.Namespace, uniqueID) } func TestListReplicaSets(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_REPLICASET_YAML_TEMPLATE, uniqueID, uniqueID) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) replicaSets := ListReplicaSets(t, options, metav1.ListOptions{}) require.Equal(t, len(replicaSets), 1) replicaSet := replicaSets[0] require.Equal(t, replicaSet.Name, "sample-rs") require.Equal(t, replicaSet.Namespace, uniqueID) } const EXAMPLE_REPLICASET_YAML_TEMPLATE = `--- apiVersion: v1 kind: Namespace metadata: name: %s --- apiVersion: apps/v1 kind: ReplicaSet metadata: name: sample-rs namespace: %s labels: app: sample-rs spec: selector: matchLabels: name: sample-rs template: metadata: labels: name: sample-rs spec: containers: - name: alpine image: alpine:3.8 command: ['sh', '-c', 'echo Hello Terratest! && sleep 99999'] ` ================================================ FILE: modules/k8s/role.go ================================================ package k8s import ( "context" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" rbacv1 "k8s.io/api/rbac/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // GetRole returns a Kubernetes role resource in the provided namespace with the given name. The namespace used // is the one provided in the KubectlOptions. This will fail the test if there is an error. func GetRole(t testing.TestingT, options *KubectlOptions, roleName string) *rbacv1.Role { role, err := GetRoleE(t, options, roleName) require.NoError(t, err) return role } // GetRoleE returns a Kubernetes role resource in the provided namespace with the given name. The namespace used // is the one provided in the KubectlOptions. func GetRoleE(t testing.TestingT, options *KubectlOptions, roleName string) (*rbacv1.Role, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } return clientset.RbacV1().Roles(options.Namespace).Get(context.Background(), roleName, metav1.GetOptions{}) } ================================================ FILE: modules/k8s/role_test.go ================================================ //go:build kubeall || kubernetes // +build kubeall kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package k8s import ( "fmt" "strings" "testing" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terratest/modules/random" ) func TestGetRoleEReturnsErrorForNonExistantRole(t *testing.T) { t.Parallel() options := NewKubectlOptions("", "", "default") _, err := GetRoleE(t, options, "non-existing-role") require.Error(t, err) } func TestGetRoleEReturnsCorrectRoleInCorrectNamespace(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_ROLE_YAML_TEMPLATE, uniqueID, uniqueID) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) role := GetRole(t, options, "terratest-role") require.Equal(t, role.Name, "terratest-role") require.Equal(t, role.Namespace, uniqueID) } const EXAMPLE_ROLE_YAML_TEMPLATE = `--- apiVersion: v1 kind: Namespace metadata: name: '%s' --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: 'terratest-role' namespace: '%s' rules: - apiGroups: - '*' resources: - '*' verbs: - '*' ` ================================================ FILE: modules/k8s/secret.go ================================================ package k8s import ( "context" "fmt" "time" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // GetSecret returns a Kubernetes secret resource in the provided namespace with the given name. The namespace used // is the one provided in the KubectlOptions. This will fail the test if there is an error. func GetSecret(t testing.TestingT, options *KubectlOptions, secretName string) *corev1.Secret { secret, err := GetSecretE(t, options, secretName) require.NoError(t, err) return secret } // GetSecretE returns a Kubernetes secret resource in the provided namespace with the given name. The namespace used // is the one provided in the KubectlOptions. func GetSecretE(t testing.TestingT, options *KubectlOptions, secretName string) (*corev1.Secret, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } return clientset.CoreV1().Secrets(options.Namespace).Get(context.Background(), secretName, metav1.GetOptions{}) } // WaitUntilSecretAvailable waits until the secret is present on the cluster in cases where it is not immediately // available (for example, when using ClusterIssuer to request a certificate). func WaitUntilSecretAvailable(t testing.TestingT, options *KubectlOptions, secretName string, retries int, sleepBetweenRetries time.Duration) { statusMsg := fmt.Sprintf("Wait for secret %s to be provisioned.", secretName) message := retry.DoWithRetry( t, statusMsg, retries, sleepBetweenRetries, func() (string, error) { _, err := GetSecretE(t, options, secretName) if err != nil { return "", err } return "Secret is now available", nil }, ) options.Logger.Logf(t, "%s", message) } ================================================ FILE: modules/k8s/secret_test.go ================================================ //go:build kubeall || kubernetes // +build kubeall kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package k8s import ( "fmt" "strings" "testing" "time" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terratest/modules/random" ) func TestGetSecretEReturnsErrorForNonExistantSecret(t *testing.T) { t.Parallel() options := NewKubectlOptions("", "", "default") _, err := GetSecretE(t, options, "master-password") require.Error(t, err) } func TestGetSecretEReturnsCorrectSecretInCorrectNamespace(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_SECRET_YAML_TEMPLATE, uniqueID, uniqueID) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) secret := GetSecret(t, options, "master-password") require.Equal(t, secret.Name, "master-password") require.Equal(t, secret.Namespace, uniqueID) } func TestWaitUntilSecretAvailableReturnsSuccessfully(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_SECRET_YAML_TEMPLATE, uniqueID, uniqueID) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) WaitUntilSecretAvailable(t, options, "master-password", 10, 1*time.Second) } const EXAMPLE_SECRET_YAML_TEMPLATE = `--- apiVersion: v1 kind: Namespace metadata: name: %s --- apiVersion: v1 kind: Secret metadata: name: master-password namespace: %s ` ================================================ FILE: modules/k8s/self_subject_access_review.go ================================================ package k8s import ( "context" "github.com/gruntwork-io/go-commons/errors" "github.com/stretchr/testify/require" authv1 "k8s.io/api/authorization/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/gruntwork-io/terratest/modules/testing" ) // CanIDo returns whether or not the provided action is allowed by the client configured by the provided kubectl option. // This will fail if there are any errors accessing the kubernetes API (but not if the action is denied). func CanIDo(t testing.TestingT, options *KubectlOptions, action authv1.ResourceAttributes) bool { allowed, err := CanIDoE(t, options, action) require.NoError(t, err) return allowed } // CanIDoE returns whether or not the provided action is allowed by the client configured by the provided kubectl option. // This will an error if there are problems accessing the kubernetes API (but not if the action is simply denied). func CanIDoE(t testing.TestingT, options *KubectlOptions, action authv1.ResourceAttributes) (bool, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return false, err } check := authv1.SelfSubjectAccessReview{ Spec: authv1.SelfSubjectAccessReviewSpec{ResourceAttributes: &action}, } resp, err := clientset.AuthorizationV1().SelfSubjectAccessReviews().Create(context.Background(), &check, metav1.CreateOptions{}) if err != nil { return false, errors.WithStackTrace(err) } if !resp.Status.Allowed { options.Logger.Logf(t, "Denied action %s on resource %s with name '%s' for reason %s", action.Verb, action.Resource, action.Name, resp.Status.Reason) } return resp.Status.Allowed, nil } ================================================ FILE: modules/k8s/self_subject_access_review_test.go ================================================ //go:build kubeall || kubernetes // +build kubeall kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package k8s import ( "testing" "github.com/stretchr/testify/assert" authv1 "k8s.io/api/authorization/v1" ) // NOTE: See service_account_test.go:TestGetServiceAccountWithAuthTokenGetsTokenThatCanBeUsedForAuth for the deny case, // as the current authed user is assumed to be a super user and so there is nothing they can't do. func TestCanIDoReturnsTrueForAllowedAction(t *testing.T) { t.Parallel() action := authv1.ResourceAttributes{ Namespace: "kube-system", Verb: "list", Resource: "pod", } options := NewKubectlOptions("", "", "kube-system") assert.True(t, CanIDo(t, options, action)) } ================================================ FILE: modules/k8s/service.go ================================================ package k8s import ( "context" "fmt" "net/url" "strings" "time" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/gruntwork-io/terratest/modules/aws" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/testing" ) // ListServices will look for services in the given namespace that match the given filters and return them. This will // fail the test if there is an error. func ListServices(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) []corev1.Service { service, err := ListServicesE(t, options, filters) require.NoError(t, err) return service } // ListServicesE will look for services in the given namespace that match the given filters and return them. func ListServicesE(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) ([]corev1.Service, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } resp, err := clientset.CoreV1().Services(options.Namespace).List(context.Background(), filters) if err != nil { return nil, err } return resp.Items, nil } // GetService returns a Kubernetes service resource in the provided namespace with the given name. This will // fail the test if there is an error. func GetService(t testing.TestingT, options *KubectlOptions, serviceName string) *corev1.Service { service, err := GetServiceE(t, options, serviceName) require.NoError(t, err) return service } // GetServiceE returns a Kubernetes service resource in the provided namespace with the given name. func GetServiceE(t testing.TestingT, options *KubectlOptions, serviceName string) (*corev1.Service, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } return clientset.CoreV1().Services(options.Namespace).Get(context.Background(), serviceName, metav1.GetOptions{}) } // WaitUntilServiceAvailable waits until the service endpoint is ready to accept traffic. func WaitUntilServiceAvailable(t testing.TestingT, options *KubectlOptions, serviceName string, retries int, sleepBetweenRetries time.Duration) { statusMsg := fmt.Sprintf("Wait for service %s to be provisioned.", serviceName) message := retry.DoWithRetry( t, statusMsg, retries, sleepBetweenRetries, func() (string, error) { service, err := GetServiceE(t, options, serviceName) if err != nil { return "", err } isMinikube, err := IsMinikubeE(t, options) if err != nil { return "", err } // For minikube, all services will be available immediately so we only do the check if we are not on // minikube. if !isMinikube && !IsServiceAvailable(service) { return "", NewServiceNotAvailableError(service) } return "Service is now available", nil }, ) options.Logger.Logf(t, "%s", message) } // IsServiceAvailable returns true if the service endpoint is ready to accept traffic. Note that for Minikube, this // function is moot as all services, even LoadBalancer, is available immediately. func IsServiceAvailable(service *corev1.Service) bool { // Only the LoadBalancer type has a delay. All other service types are available if the resource exists. switch service.Spec.Type { case corev1.ServiceTypeLoadBalancer: ingress := service.Status.LoadBalancer.Ingress // The load balancer is ready if it has at least one ingress point return len(ingress) > 0 default: return true } } // GetServiceEndpoint will return the service access point. If the service endpoint is not ready, will fail the test // immediately. func GetServiceEndpoint(t testing.TestingT, options *KubectlOptions, service *corev1.Service, servicePort int) string { endpoint, err := GetServiceEndpointE(t, options, service, servicePort) require.NoError(t, err) return endpoint } // GetServiceEndpointE will return the service access point using the following logic: // - For ClusterIP service type, return the URL that maps to ClusterIP and Service Port // - For NodePort service type, identify the public IP of the node (if it exists, otherwise return the bound hostname), // and the assigned node port for the provided service port, and return the URL that maps to node ip and node port. // - For LoadBalancer service type, return the publicly accessible hostname of the load balancer. // If the hostname is empty, it will return the public IP of the LoadBalancer. // - All other service types are not supported. func GetServiceEndpointE(t testing.TestingT, options *KubectlOptions, service *corev1.Service, servicePort int) (string, error) { switch service.Spec.Type { case corev1.ServiceTypeClusterIP: // ClusterIP service type will map directly to service port return fmt.Sprintf("%s:%d", service.Spec.ClusterIP, servicePort), nil case corev1.ServiceTypeNodePort: return findEndpointForNodePortService(t, options, service, int32(servicePort)) case corev1.ServiceTypeLoadBalancer: // For minikube, LoadBalancer service is exactly the same as NodePort service isMinikube, err := IsMinikubeE(t, options) if err != nil { return "", err } if isMinikube { return findEndpointForNodePortService(t, options, service, int32(servicePort)) } ingress := service.Status.LoadBalancer.Ingress if len(ingress) == 0 { return "", NewServiceNotAvailableError(service) } if ingress[0].Hostname == "" { return fmt.Sprintf("%s:%d", ingress[0].IP, servicePort), nil } // Load Balancer service type will map directly to service port return fmt.Sprintf("%s:%d", ingress[0].Hostname, servicePort), nil default: return "", NewUnknownServiceTypeError(service) } } // Extracts a endpoint that can be reached outside the kubernetes cluster. NodePort type needs to find the right // allocated node port mapped to the service port, as well as find out the externally reachable ip (if available). func findEndpointForNodePortService( t testing.TestingT, options *KubectlOptions, service *corev1.Service, servicePort int32, ) (string, error) { nodePort, err := FindNodePortE(service, int32(servicePort)) if err != nil { return "", err } node, err := pickRandomNodeE(t, options) if err != nil { return "", err } nodeHostname, err := FindNodeHostnameE(t, node) if err != nil { return "", err } return fmt.Sprintf("%s:%d", nodeHostname, nodePort), nil } // Given the desired servicePort, return the allocated nodeport func FindNodePortE(service *corev1.Service, servicePort int32) (int32, error) { for _, port := range service.Spec.Ports { if port.Port == servicePort { return port.NodePort, nil } } return -1, NewUnknownServicePortError(service, servicePort) } // pickRandomNode will pick a random node in the kubernetes cluster func pickRandomNodeE(t testing.TestingT, options *KubectlOptions) (corev1.Node, error) { nodes, err := GetNodesE(t, options) if err != nil { return corev1.Node{}, err } if len(nodes) == 0 { return corev1.Node{}, NewNoNodesInKubernetesError() } index := random.Random(0, len(nodes)-1) return nodes[index], nil } // Given a node, return the ip address, preferring the external IP func FindNodeHostnameE(t testing.TestingT, node corev1.Node) (string, error) { nodeIDUri, err := url.Parse(node.Spec.ProviderID) if err != nil { return "", err } switch nodeIDUri.Scheme { case "aws": return findAwsNodeHostnameE(t, node, nodeIDUri) default: return findDefaultNodeHostnameE(node) } } // findAwsNodeHostname will return the public ip of the node, assuming the node is an AWS EC2 instance. // If the instance does not have a public IP, will return the internal hostname as recorded on the Kubernetes node // object. func findAwsNodeHostnameE(t testing.TestingT, node corev1.Node, awsIDUri *url.URL) (string, error) { // Path is /AVAILABILITY_ZONE/INSTANCE_ID parts := strings.Split(awsIDUri.Path, "/") if len(parts) != 3 { return "", NewMalformedNodeIDError(&node) } instanceID := parts[2] availabilityZone := parts[1] // Availability Zone name is known to be region code + 1 letter // https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html region := availabilityZone[:len(availabilityZone)-1] ipMap, err := aws.GetPublicIpsOfEc2InstancesE(t, []string{instanceID}, region) if err != nil { return "", err } publicIP, containsIP := ipMap[instanceID] if !containsIP || publicIP == "" { // return default hostname return findDefaultNodeHostnameE(node) } return publicIP, nil } // findDefaultNodeHostname returns the hostname recorded on the Kubernetes node object. func findDefaultNodeHostnameE(node corev1.Node) (string, error) { for _, address := range node.Status.Addresses { if address.Type == corev1.NodeHostName { return address.Address, nil } } return "", NewNodeHasNoHostnameError(&node) } ================================================ FILE: modules/k8s/service_account.go ================================================ package k8s import ( "context" "fmt" "time" "github.com/gruntwork-io/go-commons/errors" "github.com/stretchr/testify/require" authenticationv1 "k8s.io/api/authentication/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/tools/clientcmd/api" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/testing" ) // GetServiceAccount returns a Kubernetes service account resource in the provided namespace with the given name. The // namespace used is the one provided in the KubectlOptions. This will fail the test if there is an error. func GetServiceAccount(t testing.TestingT, options *KubectlOptions, serviceAccountName string) *corev1.ServiceAccount { serviceAccount, err := GetServiceAccountE(t, options, serviceAccountName) require.NoError(t, err) return serviceAccount } // GetServiceAccountE returns a Kubernetes service account resource in the provided namespace with the given name. The // namespace used is the one provided in the KubectlOptions. func GetServiceAccountE(t testing.TestingT, options *KubectlOptions, serviceAccountName string) (*corev1.ServiceAccount, error) { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return nil, err } return clientset.CoreV1().ServiceAccounts(options.Namespace).Get(context.Background(), serviceAccountName, metav1.GetOptions{}) } // CreateServiceAccount will create a new service account resource in the provided namespace with the given name. The // namespace used is the one provided in the KubectlOptions. This will fail the test if there is an error. func CreateServiceAccount(t testing.TestingT, options *KubectlOptions, serviceAccountName string) { require.NoError(t, CreateServiceAccountE(t, options, serviceAccountName)) } // CreateServiceAccountE will create a new service account resource in the provided namespace with the given name. The // namespace used is the one provided in the KubectlOptions. func CreateServiceAccountE(t testing.TestingT, options *KubectlOptions, serviceAccountName string) error { clientset, err := GetKubernetesClientFromOptionsE(t, options) if err != nil { return err } serviceAccount := corev1.ServiceAccount{ ObjectMeta: metav1.ObjectMeta{ Name: serviceAccountName, Namespace: options.Namespace, }, } _, err = clientset.CoreV1().ServiceAccounts(options.Namespace).Create(context.Background(), &serviceAccount, metav1.CreateOptions{}) return err } // GetServiceAccountAuthToken will retrieve the ServiceAccount token from the cluster so it can be used to // authenticate requests as that ServiceAccount. This will fail the test if there is an error. func GetServiceAccountAuthToken(t testing.TestingT, kubectlOptions *KubectlOptions, serviceAccountName string) string { token, err := GetServiceAccountAuthTokenE(t, kubectlOptions, serviceAccountName) require.NoError(t, err) return token } // GetServiceAccountAuthTokenE will retrieve the ServiceAccount token from the cluster so it can be used to // authenticate requests as that ServiceAccount. // On K8s 1.24+, service account tokens are no longer auto-created as secrets, so this uses the TokenRequest API. func GetServiceAccountAuthTokenE(t testing.TestingT, kubectlOptions *KubectlOptions, serviceAccountName string) (string, error) { clientset, err := GetKubernetesClientFromOptionsE(t, kubectlOptions) if err != nil { return "", err } // First try the TokenRequest API (K8s 1.24+) tokenRequest := &authenticationv1.TokenRequest{ Spec: authenticationv1.TokenRequestSpec{ // Use a long expiration for test purposes ExpirationSeconds: new(int64(3600)), }, } tokenResponse, err := clientset.CoreV1().ServiceAccounts(kubectlOptions.Namespace).CreateToken( context.Background(), serviceAccountName, tokenRequest, metav1.CreateOptions{}, ) if err == nil { return tokenResponse.Status.Token, nil } // Fall back to legacy secret-based tokens for older K8s versions kubectlOptions.Logger.Logf(t, "TokenRequest API failed (%s), falling back to secret-based tokens", err) msg, retryErr := retry.DoWithRetryE( t, "Waiting for ServiceAccount Token to be provisioned", 30, 10*time.Second, func() (string, error) { kubectlOptions.Logger.Logf(t, "Checking if service account has secret") serviceAccount := GetServiceAccount(t, kubectlOptions, serviceAccountName) if len(serviceAccount.Secrets) == 0 { msg := "No secrets on the service account yet" kubectlOptions.Logger.Logf(t, "%s", msg) return "", fmt.Errorf("%s", msg) } return "Service Account has secret", nil }, ) if retryErr != nil { return "", retryErr } kubectlOptions.Logger.Logf(t, "%s", msg) serviceAccount, err := GetServiceAccountE(t, kubectlOptions, serviceAccountName) if err != nil { return "", err } if len(serviceAccount.Secrets) != 1 { return "", errors.WithStackTrace(ServiceAccountTokenNotAvailable{serviceAccountName}) } secret := GetSecret(t, kubectlOptions, serviceAccount.Secrets[0].Name) return string(secret.Data["token"]), nil } // AddConfigContextForServiceAccountE will add a new config context that binds the ServiceAccount auth token to the // Kubernetes cluster of the current config context. func AddConfigContextForServiceAccountE( t testing.TestingT, kubectlOptions *KubectlOptions, contextName string, serviceAccountName string, token string, ) error { // First load the config context config := LoadConfigFromPath(kubectlOptions.ConfigPath) rawConfig, err := config.RawConfig() if err != nil { return errors.WithStackTrace(err) } // Next get the current cluster currentContext := rawConfig.Contexts[rawConfig.CurrentContext] currentCluster := currentContext.Cluster // Now insert the auth info for the service account rawConfig.AuthInfos[serviceAccountName] = &api.AuthInfo{Token: token} // We now have enough info to add the new context UpsertConfigContext(&rawConfig, contextName, currentCluster, serviceAccountName) // Finally, overwrite the config if err := clientcmd.ModifyConfig(config.ConfigAccess(), rawConfig, false); err != nil { return errors.WithStackTrace(err) } return nil } ================================================ FILE: modules/k8s/service_account_test.go ================================================ //go:build kubeall || kubernetes // +build kubeall kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package k8s import ( "fmt" "strings" "testing" "github.com/stretchr/testify/require" authv1 "k8s.io/api/authorization/v1" "github.com/gruntwork-io/terratest/modules/random" ) func TestGetServiceAccountWithAuthTokenGetsTokenThatCanBeUsedForAuth(t *testing.T) { t.Parallel() // make a copy of kubeconfig to namespace it tmpConfigPath := CopyHomeKubeConfigToTemp(t) // Create a new namespace to work in namespaceName := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", tmpConfigPath, namespaceName) CreateNamespace(t, options, namespaceName) defer DeleteNamespace(t, options, namespaceName) // Create service account serviceAccountName := strings.ToLower(random.UniqueId()) CreateServiceAccount(t, options, serviceAccountName) token := GetServiceAccountAuthToken(t, options, serviceAccountName) require.NoError(t, AddConfigContextForServiceAccountE(t, options, serviceAccountName, serviceAccountName, token)) // Now validate auth as service account. This is a bit tricky because we don't have an API endpoint in k8s that // tells you who you are, so we will rely on the self subject access review and see if we have access to the // kube-system namespace. serviceAccountOptions := NewKubectlOptions(serviceAccountName, tmpConfigPath, namespaceName) action := authv1.ResourceAttributes{ Namespace: "kube-system", Verb: "list", Resource: "pod", } require.False(t, CanIDo(t, serviceAccountOptions, action)) } func TestGetServiceAccountEReturnsErrorForNonExistantServiceAccount(t *testing.T) { t.Parallel() options := NewKubectlOptions("", "", "default") _, err := GetServiceAccountE(t, options, "terratest") require.Error(t, err) } func TestGetServiceAccountEReturnsCorrectServiceAccountInCorrectNamespace(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_SERVICEACCOUNT_YAML_TEMPLATE, uniqueID, uniqueID) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) serviceAccount := GetServiceAccount(t, options, "terratest") require.Equal(t, serviceAccount.Name, "terratest") require.Equal(t, serviceAccount.Namespace, uniqueID) } func TestCreateServiceAccountECreatesServiceAccountInNamespaceWithGivenName(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) defer DeleteNamespace(t, options, options.Namespace) CreateNamespace(t, options, options.Namespace) // Note: We don't need to delete this at the end of test, because deleting the namespace automatically deletes // everything created in the namespace. CreateServiceAccount(t, options, "terratest") serviceAccount := GetServiceAccount(t, options, "terratest") require.Equal(t, serviceAccount.Name, "terratest") require.Equal(t, serviceAccount.Namespace, uniqueID) } const EXAMPLE_SERVICEACCOUNT_YAML_TEMPLATE = `--- apiVersion: v1 kind: Namespace metadata: name: %s --- apiVersion: v1 kind: ServiceAccount metadata: name: terratest namespace: %s ` ================================================ FILE: modules/k8s/service_test.go ================================================ //go:build kubernetes // +build kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package k8s import ( "crypto/tls" "fmt" "strings" "testing" "time" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" http_helper "github.com/gruntwork-io/terratest/modules/http-helper" "github.com/gruntwork-io/terratest/modules/random" ) func TestGetServiceEReturnsErrorForNonExistantService(t *testing.T) { t.Parallel() options := NewKubectlOptions("", "", "default") _, err := GetServiceE(t, options, "nginx-service") require.Error(t, err) } func TestGetServiceEReturnsCorrectServiceInCorrectNamespace(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_DEPLOYMENT_YAML_TEMPLATE, uniqueID, uniqueID, uniqueID) KubectlApplyFromString(t, options, configData) defer KubectlDeleteFromString(t, options, configData) service := GetService(t, options, "nginx-service") require.Equal(t, service.Name, "nginx-service") require.Equal(t, service.Namespace, uniqueID) } func TestListServicesReturnsCorrectServiceInCorrectNamespace(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_DEPLOYMENT_YAML_TEMPLATE, uniqueID, uniqueID, uniqueID) KubectlApplyFromString(t, options, configData) defer KubectlDeleteFromString(t, options, configData) services := ListServices(t, options, metav1.ListOptions{}) require.Equal(t, len(services), 1) service := services[0] require.Equal(t, service.Name, "nginx-service") require.Equal(t, service.Namespace, uniqueID) } func TestWaitUntilServiceAvailableReturnsSuccessfullyOnNodePortType(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_DEPLOYMENT_YAML_TEMPLATE, uniqueID, uniqueID, uniqueID) KubectlApplyFromString(t, options, configData) defer KubectlDeleteFromString(t, options, configData) WaitUntilServiceAvailable(t, options, "nginx-service", 10, 1*time.Second) } func TestGetServiceEndpointEReturnsAccessibleEndpointForNodePort(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_DEPLOYMENT_YAML_TEMPLATE, uniqueID, uniqueID, uniqueID) KubectlApplyFromString(t, options, configData) defer KubectlDeleteFromString(t, options, configData) service := GetService(t, options, "nginx-service") endpoint := GetServiceEndpoint(t, options, service, 80) // Setup a TLS configuration to submit with the helper, a blank struct is acceptable tlsConfig := tls.Config{} // Test up to 5 minutes http_helper.HttpGetWithRetryWithCustomValidation( t, fmt.Sprintf("http://%s", endpoint), &tlsConfig, 30, 10*time.Second, func(statusCode int, body string) bool { return statusCode == 200 }, ) } const EXAMPLE_DEPLOYMENT_YAML_TEMPLATE = `--- apiVersion: v1 kind: Namespace metadata: name: %s --- apiVersion: apps/v1 kind: Deployment metadata: name: nginx-deployment namespace: %s spec: selector: matchLabels: app: nginx replicas: 1 template: metadata: labels: app: nginx spec: containers: - name: nginx image: nginx:1.15.7 ports: - containerPort: 80 --- kind: Service apiVersion: v1 metadata: name: nginx-service namespace: %s spec: selector: app: nginx ports: - protocol: TCP targetPort: 80 port: 80 type: NodePort ` ================================================ FILE: modules/k8s/tunnel.go ================================================ package k8s // The following code is a fork of the Helm client. The main differences are: // - Support testing context for better logging // - Support resources other than pods // See: https://github.com/helm/helm/blob/master/pkg/kube/tunnel.go import ( "errors" "fmt" "io" "net" "net/http" "strconv" "strings" "sync" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/client-go/tools/portforward" "k8s.io/client-go/transport/spdy" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/testing" ) // Global lock to synchronize port selections var globalMutex sync.Mutex // KubeResourceType is an enum representing known resource types that can support port forwarding type KubeResourceType int const ( // ResourceTypePod is a k8s pod kind identifier ResourceTypePod KubeResourceType = iota // ResourceTypeDeployment is a k8s deployment kind identifier ResourceTypeDeployment // ResourceTypeService is a k8s service kind identifier ResourceTypeService ) func (resourceType KubeResourceType) String() string { switch resourceType { case ResourceTypeDeployment: return "deploy" case ResourceTypePod: return "pod" case ResourceTypeService: return "svc" default: // This should not happen return "UNKNOWN_RESOURCE_TYPE" } } // makeLabels is a helper to format a map of label key and value pairs into a single string for use as a selector. func makeLabels(labels map[string]string) string { out := []string{} for key, value := range labels { out = append(out, fmt.Sprintf("%s=%s", key, value)) } return strings.Join(out, ",") } // Tunnel is the main struct that configures and manages port forwading tunnels to Kubernetes resources. type Tunnel struct { out io.Writer localPort int remotePort int kubectlOptions *KubectlOptions resourceType KubeResourceType resourceName string logger logger.TestLogger stopChan chan struct{} readyChan chan struct{} } // NewTunnel creates a new tunnel with NewTunnelWithLogger, setting logger.Terratest as the logger. func NewTunnel(kubectlOptions *KubectlOptions, resourceType KubeResourceType, resourceName string, local int, remote int) *Tunnel { return NewTunnelWithLogger(kubectlOptions, resourceType, resourceName, local, remote, logger.Terratest) } // NewTunnelWithLogger will create a new Tunnel struct with the provided logger. // Note that if you use 0 for the local port, an open port on the host system // will be selected automatically, and the Tunnel struct will be updated with the selected port. func NewTunnelWithLogger( kubectlOptions *KubectlOptions, resourceType KubeResourceType, resourceName string, local int, remote int, logger logger.TestLogger, ) *Tunnel { return &Tunnel{ out: io.Discard, localPort: local, remotePort: remote, kubectlOptions: kubectlOptions, resourceType: resourceType, resourceName: resourceName, logger: logger, stopChan: make(chan struct{}, 1), readyChan: make(chan struct{}, 1), } } // Endpoint returns the tunnel endpoint func (tunnel *Tunnel) Endpoint() string { return fmt.Sprintf("localhost:%d", tunnel.localPort) } // Close disconnects a tunnel connection by closing the StopChan, thereby stopping the goroutine. func (tunnel *Tunnel) Close() { close(tunnel.stopChan) } // getAttachablePodForResource will find a pod that can be port forwarded to given the provided resource type and return // the name. func (tunnel *Tunnel) getAttachablePodForResourceE(t testing.TestingT) (string, error) { switch tunnel.resourceType { case ResourceTypePod: return tunnel.resourceName, nil case ResourceTypeService: return tunnel.getAttachablePodForServiceE(t) case ResourceTypeDeployment: return tunnel.getAttachablePodForDeploymentE(t) default: return "", UnknownKubeResourceType{tunnel.resourceType} } } // getAttachablePodForDeploymentE will find an active pod associated with the Deployment and return the pod name. func (tunnel *Tunnel) getAttachablePodForDeploymentE(t testing.TestingT) (string, error) { deploy, err := GetDeploymentE(t, tunnel.kubectlOptions, tunnel.resourceName) if err != nil { return "", err } selectorLabelsOfPods := makeLabels(deploy.Spec.Selector.MatchLabels) deploymentPods, err := ListPodsE(t, tunnel.kubectlOptions, metav1.ListOptions{LabelSelector: selectorLabelsOfPods}) if err != nil { return "", err } for _, pod := range deploymentPods { if IsPodAvailable(&pod) { return pod.Name, nil } } return "", DeploymentNotAvailable{deploy} } // getAttachablePodForServiceE will find an active pod associated with the Service and return the pod name. func (tunnel *Tunnel) getAttachablePodForServiceE(t testing.TestingT) (string, error) { service, err := GetServiceE(t, tunnel.kubectlOptions, tunnel.resourceName) if err != nil { return "", err } selectorLabelsOfPods := makeLabels(service.Spec.Selector) servicePods, err := ListPodsE(t, tunnel.kubectlOptions, metav1.ListOptions{LabelSelector: selectorLabelsOfPods}) if err != nil { return "", err } for _, pod := range servicePods { if IsPodAvailable(&pod) { return pod.Name, nil } } return "", ServiceNotAvailable{service} } // ForwardPort opens a tunnel to a kubernetes resource, as specified by the provided tunnel struct. This will fail the // test if there is an error attempting to open the port. func (tunnel *Tunnel) ForwardPort(t testing.TestingT) { require.NoError(t, tunnel.ForwardPortE(t)) } // ForwardPortE opens a tunnel to a kubernetes resource, as specified by the provided tunnel struct. func (tunnel *Tunnel) ForwardPortE(t testing.TestingT) error { tunnel.logger.Logf( t, "Creating a port forwarding tunnel for resource %s/%s routing local port %d to remote port %d", tunnel.resourceType.String(), tunnel.resourceName, tunnel.localPort, tunnel.remotePort, ) // Prepare a kubernetes client for the client-go library clientset, err := GetKubernetesClientFromOptionsE(t, tunnel.kubectlOptions) if err != nil { tunnel.logger.Logf(t, "Error creating a new Kubernetes client: %s", err) return err } config := tunnel.kubectlOptions.RestConfig if config == nil { kubeConfigPath, err := tunnel.kubectlOptions.GetConfigPath(t) if err != nil { tunnel.logger.Logf(t, "Error getting kube config path: %s", err) return err } config, err = LoadApiClientConfigE(kubeConfigPath, tunnel.kubectlOptions.ContextName) if err != nil { tunnel.logger.Logf(t, "Error loading Kubernetes config: %s", err) return err } } // Find the pod to port forward to podName, err := tunnel.getAttachablePodForResourceE(t) if err != nil { tunnel.logger.Logf(t, "Error finding available pod: %s", err) return err } tunnel.logger.Logf(t, "Selected pod %s to open port forward to", podName) var targetPort = tunnel.remotePort // in case of services, find target port on pod based on service definition if tunnel.resourceType == ResourceTypeService { service := GetService(t, tunnel.kubectlOptions, tunnel.resourceName) var portFound = false for _, portSpec := range service.Spec.Ports { if portSpec.Port == int32(targetPort) { if portSpec.TargetPort.Type == intstr.String { pod, err := GetPodE(t, tunnel.kubectlOptions, podName) if err != nil { return err } targetPort, err = getPodPortByName(pod, portSpec.TargetPort.String()) if err != nil { tunnel.logger.Logf(t, "Error selecting port by name: %s", err) return err } portFound = true break } targetPort = portSpec.TargetPort.IntValue() portFound = true break } } if !portFound { return errors.New(fmt.Sprintf("Target port %d not found in service %s definition.", targetPort, tunnel.resourceName)) } } // Build a url to the portforward endpoint // example: http://localhost:8080/api/v1/namespaces/helm/pods/tiller-deploy-9itlq/portforward postEndpoint := clientset.CoreV1().RESTClient().Post() namespace := tunnel.kubectlOptions.Namespace portForwardCreateURL := postEndpoint. Resource("pods"). Namespace(namespace). Name(podName). SubResource("portforward"). URL() tunnel.logger.Logf(t, "Using URL %s to create portforward", portForwardCreateURL) // Construct the spdy client required by the client-go portforward library transport, upgrader, err := spdy.RoundTripperFor(config) if err != nil { tunnel.logger.Logf(t, "Error creating http client: %s", err) return err } dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, "POST", portForwardCreateURL) // If the localport is 0, get an available port before continuing. We do this here instead of relying on the // underlying portforwarder library, because the portforwarder library does not expose the selected local port in a // machine readable manner. // Synchronize on the global lock to avoid race conditions with concurrently selecting the same available port, // since there is a brief moment between `GetAvailablePort` and `portforwader.ForwardPorts` where the selected port // is available for selection again. if tunnel.localPort == 0 { tunnel.logger.Logf(t, "Requested local port is 0. Selecting an open port on host system") tunnel.localPort, err = GetAvailablePortE(t) if err != nil { tunnel.logger.Logf(t, "Error getting available port: %s", err) return err } tunnel.logger.Logf(t, "Selected port %d", tunnel.localPort) globalMutex.Lock() defer globalMutex.Unlock() } // Construct a new PortForwarder struct that manages the instructed port forward tunnel ports := []string{fmt.Sprintf("%d:%d", tunnel.localPort, targetPort)} portforwarder, err := portforward.New(dialer, ports, tunnel.stopChan, tunnel.readyChan, tunnel.out, tunnel.out) if err != nil { tunnel.logger.Logf(t, "Error creating port forwarding tunnel: %s", err) return err } // Open the tunnel in a goroutine so that it is available in the background. Report errors to the main goroutine via // a new channel. errChan := make(chan error) go func() { errChan <- portforwarder.ForwardPorts() }() // Wait for an error or the tunnel to be ready select { case err = <-errChan: tunnel.logger.Logf(t, "Error starting port forwarding tunnel: %s", err) return err case <-portforwarder.Ready: tunnel.logger.Logf(t, "Successfully created port forwarding tunnel") return nil } } // GetAvailablePort retrieves an available port on the host machine. This delegates the port selection to the golang net // library by starting a server and then checking the port that the server is using. This will fail the test if it could // not find an available port. func GetAvailablePort(t testing.TestingT) int { port, err := GetAvailablePortE(t) require.NoError(t, err) return port } // GetAvailablePortE retrieves an available port on the host machine. This delegates the port selection to the golang net // library by starting a server and then checking the port that the server is using. func GetAvailablePortE(t testing.TestingT) (int, error) { l, err := net.Listen("tcp", ":0") if err != nil { return 0, err } defer l.Close() _, p, err := net.SplitHostPort(l.Addr().String()) if err != nil { return 0, err } port, err := strconv.Atoi(p) if err != nil { return 0, err } return port, err } func getPodPortByName(pod *corev1.Pod, portName string) (int, error) { if pod == nil { return 0, errors.New("cannot get port for pod which is nil") } for _, container := range pod.Spec.Containers { for _, port := range container.Ports { if port.Name == portName { return int(port.ContainerPort), nil } } } return 0, fmt.Errorf("could not find port %s in pod %s", portName, pod.Name) } ================================================ FILE: modules/k8s/tunnel_test.go ================================================ //go:build kubeall || kubernetes // +build kubeall kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package k8s import ( "crypto/tls" "fmt" "strings" "testing" "time" http_helper "github.com/gruntwork-io/terratest/modules/http-helper" "github.com/gruntwork-io/terratest/modules/random" ) func TestTunnelOpensAPortForwardTunnelToPod(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(EXAMPLE_POD_YAML_TEMPLATE, uniqueID, uniqueID) defer KubectlDeleteFromString(t, options, configData) KubectlApplyFromString(t, options, configData) WaitUntilPodAvailable(t, options, "nginx-pod", 60, 1*time.Second) // Open a tunnel to pod from any available port locally tunnel := NewTunnel(options, ResourceTypePod, "nginx-pod", 0, 80) defer tunnel.Close() tunnel.ForwardPort(t) // Setup a TLS configuration to submit with the helper, a blank struct is acceptable tlsConfig := tls.Config{} // Try to access the nginx service on the local port, retrying until we get a good response for up to 5 minutes http_helper.HttpGetWithRetryWithCustomValidation( t, fmt.Sprintf("http://%s", tunnel.Endpoint()), &tlsConfig, 60, 5*time.Second, verifyNginxWelcomePage, ) } func TestTunnelOpensAPortForwardTunnelToDeployment(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(ExampleDeploymentYAMLTemplate, uniqueID) KubectlApplyFromString(t, options, configData) defer KubectlDeleteFromString(t, options, configData) WaitUntilDeploymentAvailable(t, options, "nginx-deployment", 60, 1*time.Second) // Open a tunnel to pod from any available port locally tunnel := NewTunnel(options, ResourceTypeDeployment, "nginx-deployment", 0, 80) defer tunnel.Close() tunnel.ForwardPort(t) // Setup a TLS configuration to submit with the helper, a blank struct is acceptable tlsConfig := tls.Config{} // Try to access the nginx service on the local port, retrying until we get a good response for up to 5 minutes http_helper.HttpGetWithRetryWithCustomValidation( t, fmt.Sprintf("http://%s", tunnel.Endpoint()), &tlsConfig, 60, 5*time.Second, verifyNginxWelcomePage, ) } func TestTunnelOpensAPortForwardTunnelToService(t *testing.T) { t.Parallel() uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) configData := fmt.Sprintf(ExamplePodWithServiceYAMLTemplate, uniqueID, uniqueID, uniqueID, uniqueID) t.Cleanup(func() { KubectlDeleteFromString(t, options, configData) }) KubectlApplyFromString(t, options, configData) // t.FailNow() WaitUntilPodAvailable(t, options, "nginx-pod", 60, 1*time.Second) testCases := []struct { name string serviceName string }{ { "Pod target port by number", "nginx-service-number", }, { "Pod target port by name", "nginx-service-name", }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { t.Parallel() WaitUntilServiceAvailable(t, options, testCase.serviceName, 60, 1*time.Second) // Open a tunnel from any available port locally tunnel := NewTunnel(options, ResourceTypeService, testCase.serviceName, 0, 8080) t.Cleanup(func() { tunnel.Close() }) tunnel.ForwardPort(t) // Setup a TLS configuration to submit with the helper, a blank struct is acceptable tlsConfig := tls.Config{} // Try to access the nginx service on the local port, retrying until we get a good response for up to 5 minutes http_helper.HttpGetWithRetryWithCustomValidation( t, fmt.Sprintf("http://%s", tunnel.Endpoint()), &tlsConfig, 60, 5*time.Second, verifyNginxWelcomePage, ) }) } } func verifyNginxWelcomePage(statusCode int, body string) bool { if statusCode != 200 { return false } return strings.Contains(body, "Welcome to nginx") } const ExamplePodWithServiceYAMLTemplate = `--- apiVersion: v1 kind: Namespace metadata: name: %s --- apiVersion: v1 kind: Pod metadata: name: nginx-pod namespace: %s labels: app: nginx spec: containers: - name: nginx image: nginx:1.15.7 ports: - containerPort: 80 name: http readinessProbe: httpGet: path: / port: 80 --- apiVersion: v1 kind: Service metadata: name: nginx-service-number namespace: %s spec: selector: app: nginx ports: - protocol: TCP targetPort: 80 port: 8080 --- apiVersion: v1 kind: Service metadata: name: nginx-service-name namespace: %s spec: selector: app: nginx ports: - protocol: TCP targetPort: http port: 8080 ` ================================================ FILE: modules/k8s/version.go ================================================ package k8s import "github.com/gruntwork-io/terratest/modules/testing" // GetKubernetesClusterVersion returns the Kubernetes cluster version. func GetKubernetesClusterVersionE(t testing.TestingT) (string, error) { kubeConfigPath, err := GetKubeConfigPathE(t) if err != nil { return "", err } options := NewKubectlOptions("", kubeConfigPath, "default") return GetKubernetesClusterVersionWithOptionsE(t, options) } // GetKubernetesClusterVersion returns the Kubernetes cluster version given a configured KubectlOptions object. func GetKubernetesClusterVersionWithOptionsE(t testing.TestingT, kubectlOptions *KubectlOptions) (string, error) { clientset, err := GetKubernetesClientFromOptionsE(t, kubectlOptions) if err != nil { return "", err } versionInfo, err := clientset.DiscoveryClient.ServerVersion() if err != nil { return "", err } return versionInfo.String(), nil } ================================================ FILE: modules/k8s/version_test.go ================================================ //go:build kubeall || kubernetes // +build kubeall kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package k8s import ( "encoding/json" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type KubectlVersion struct { ServerVersion struct { GitVersion string `json:"gitVersion"` } `json:"serverVersion"` } func TestGetKubernetesClusterVersionE(t *testing.T) { t.Parallel() kubernetesClusterVersion, err := GetKubernetesClusterVersionE(t) require.NoError(t, err) options := NewKubectlOptions("", "", "") kubernetesClusterVersionFromKubectl, err := RunKubectlAndGetOutputE(t, options, "version", "-o", "json") require.NoError(t, err) var kctlClusterVersion KubectlVersion require.NoError( t, json.Unmarshal([]byte(kubernetesClusterVersionFromKubectl), &kctlClusterVersion), ) assert.EqualValues(t, kubernetesClusterVersion, kctlClusterVersion.ServerVersion.GitVersion) } func TestGetKubernetesClusterVersionWithOptionsE(t *testing.T) { t.Parallel() options := NewKubectlOptions("", "", "") kubernetesClusterVersion, err := GetKubernetesClusterVersionWithOptionsE(t, options) require.NoError(t, err) kubernetesClusterVersionFromKubectl, err := RunKubectlAndGetOutputE(t, options, "version", "-o", "json") require.NoError(t, err) var kctlClusterVersion KubectlVersion require.NoError( t, json.Unmarshal([]byte(kubernetesClusterVersionFromKubectl), &kctlClusterVersion), ) assert.EqualValues(t, kubernetesClusterVersion, kctlClusterVersion.ServerVersion.GitVersion) } ================================================ FILE: modules/logger/logger.go ================================================ // Package logger contains different methods to log. package logger import ( "fmt" "io" "os" "runtime" "strings" "sync" gotesting "testing" "time" "github.com/gruntwork-io/terratest/modules/testing" ) var ( // Default is the default logger that is used for the Logf function, if no one is provided. It uses the // TerratestLogger to log messages. This can be overwritten to change the logging globally. Default = New(terratestLogger{}) // Discard discards all logging. Discard = New(discardLogger{}) // Terratest logs the given format and arguments, formatted using fmt.Sprintf, to stdout, along with a timestamp and // information about what test and file is doing the logging. Before Go 1.14, this is an alternative to t.Logf as it // logs to stdout immediately, rather than buffering all log output and only displaying it at the very end of the test. // This is useful because: // // 1. It allows you to iterate faster locally, as you get feedback on whether your code changes are working as expected // right away, rather than at the very end of the test run. // // 2. If you have a bug in your code that causes a test to never complete or if the test code crashes, t.Logf would // show you no log output whatsoever, making debugging very hard, where as this method will show you all the log // output available. // // 3. If you have a test that takes a long time to complete, some CI systems will kill the test suite prematurely // because there is no log output with t.Logf (e.g., CircleCI kills tests after 10 minutes of no log output). With // this log method, you get log output continuously. // Terratest = New(terratestLogger{}) // TestingT can be used to use Go's testing.T to log. If this is used, but no testing.T is provided, it will fallback // to Default. TestingT = New(testingT{}) ) type TestLogger interface { Logf(t testing.TestingT, format string, args ...interface{}) } type Logger struct { l TestLogger } func New(l TestLogger) *Logger { return &Logger{ l, } } func (l *Logger) Logf(t testing.TestingT, format string, args ...interface{}) { if tt, ok := t.(helper); ok { tt.Helper() } // methods can be called on (typed) nil pointers. In this case, use the Default function to log. This enables the // caller to do `var l *Logger` and then use the logger already. if l == nil || l.l == nil { Default.Logf(t, format, args...) return } l.l.Logf(t, format, args...) } // helper is used to mark this library as a "helper", and thus not appearing in the line numbers. testing.T implements // this interface, for example. type helper interface { Helper() } type discardLogger struct{} func (_ discardLogger) Logf(_ testing.TestingT, format string, args ...interface{}) {} type testingT struct{} func (_ testingT) Logf(t testing.TestingT, format string, args ...interface{}) { // this should never fail tt, ok := t.(*gotesting.T) if !ok { // fallback DoLog(t, 2, os.Stdout, fmt.Sprintf(format, args...)) return } tt.Helper() tt.Logf(format, args...) return } type terratestLogger struct{} func (_ terratestLogger) Logf(t testing.TestingT, format string, args ...interface{}) { DoLog(t, 3, os.Stdout, fmt.Sprintf(format, args...)) } // Deprecated: use Logger instead, as it provides more flexibility on logging. // Logf logs the given format and arguments, formatted using fmt.Sprintf, to stdout, along with a timestamp and information // about what test and file is doing the logging. Before Go 1.14, this is an alternative to t.Logf as it logs to stdout // immediately, rather than buffering all log output and only displaying it at the very end of the test. This is useful // because: // // 1. It allows you to iterate faster locally, as you get feedback on whether your code changes are working as expected // right away, rather than at the very end of the test run. // // 2. If you have a bug in your code that causes a test to never complete or if the test code crashes, t.Logf would // show you no log output whatsoever, making debugging very hard, where as this method will show you all the log // output available. // // 3. If you have a test that takes a long time to complete, some CI systems will kill the test suite prematurely // because there is no log output with t.Logf (e.g., CircleCI kills tests after 10 minutes of no log output). With // this log method, you get log output continuously. // // Although t.Logf now supports streaming output since Go 1.14, this is kept for compatibility purposes. func Logf(t testing.TestingT, format string, args ...interface{}) { if tt, ok := t.(helper); ok { tt.Helper() } mutexStdout.Lock() defer mutexStdout.Unlock() DoLog(t, 2, os.Stdout, fmt.Sprintf(format, args...)) } // Log logs the given arguments to stdout, along with a timestamp and information about what test and file is doing the // logging. This is an alternative to t.Logf that logs to stdout immediately, rather than buffering all log output and // only displaying it at the very end of the test. See the Logf method for more info. func Log(t testing.TestingT, args ...interface{}) { if tt, ok := t.(helper); ok { tt.Helper() } mutexStdout.Lock() defer mutexStdout.Unlock() DoLog(t, 2, os.Stdout, args...) } var mutexStdout sync.Mutex // DoLog logs the given arguments to the given writer, along with a timestamp and information about what test and file is // doing the logging. func DoLog(t testing.TestingT, callDepth int, writer io.Writer, args ...interface{}) { date := time.Now() prefix := fmt.Sprintf("%s %s %s:", t.Name(), date.Format(time.RFC3339), CallerPrefix(callDepth+1)) allArgs := append([]interface{}{prefix}, args...) fmt.Fprintln(writer, allArgs...) } // CallerPrefix returns the file and line number information about the methods that called this method, based on the current // goroutine's stack. The argument callDepth is the number of stack frames to ascend, with 0 identifying the method // that called CallerPrefix, 1 identifying the method that called that method, and so on. // // This code is adapted from testing.go, where it is in a private method called decorate. func CallerPrefix(callDepth int) string { _, file, line, ok := runtime.Caller(callDepth) if ok { // Truncate file name at last file name separator. if index := strings.LastIndex(file, "/"); index >= 0 { file = file[index+1:] } else if index = strings.LastIndex(file, "\\"); index >= 0 { file = file[index+1:] } } else { file = "???" line = 1 } return fmt.Sprintf("%s:%d", file, line) } ================================================ FILE: modules/logger/logger_test.go ================================================ package logger import ( "bytes" "fmt" "io" "os" "strings" "testing" tftesting "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestDoLog(t *testing.T) { t.Parallel() text := "test-do-log" var buffer bytes.Buffer DoLog(t, 1, &buffer, text) assert.Regexp(t, fmt.Sprintf("^%s .+? [[:word:]]+.go:[0-9]+: %s$", t.Name(), text), strings.TrimSpace(buffer.String())) } type customLogger struct { logs []string } func (c *customLogger) Logf(t tftesting.TestingT, format string, args ...interface{}) { c.logs = append(c.logs, fmt.Sprintf(format, args...)) } func TestCustomLogger(t *testing.T) { Logf(t, "this should be logged with the default logger") var l *Logger l.Logf(t, "this should be logged with the default logger too") l = New(nil) l.Logf(t, "this should be logged with the default logger too!") c := &customLogger{} l = New(c) l.Logf(t, "log output 1") l.Logf(t, "log output 2") t.Run("logger-subtest", func(t *testing.T) { l.Logf(t, "subtest log") }) assert.Len(t, c.logs, 3) assert.Equal(t, "log output 1", c.logs[0]) assert.Equal(t, "log output 2", c.logs[1]) assert.Equal(t, "subtest log", c.logs[2]) } // TestLockedLog make sure that Log and Logf which use stdout are thread-safe func TestLockedLog(t *testing.T) { // should not call t.Parallel() since we are modifying os.Stdout stdout := os.Stdout t.Cleanup(func() { os.Stdout = stdout }) data := []struct { name string fn func(*testing.T, string) }{ { name: "Log", fn: func(t *testing.T, s string) { Log(t, s) }}, { name: "Logf", fn: func(t *testing.T, s string) { Logf(t, "%s", s) }}, } for _, d := range data { mutexStdout.Lock() str := "Logging something" + t.Name() r, w, _ := os.Pipe() os.Stdout = w ch := make(chan struct{}) go func() { d.fn(t, str) w.Close() close(ch) }() select { case <-ch: t.Error("Log should be locked") default: } mutexStdout.Unlock() b, err := io.ReadAll(r) require.NoError(t, err, "log should be unlocked") assert.Contains(t, string(b), str, "should contains logged string") } } ================================================ FILE: modules/logger/parser/failed_test_marker.go ================================================ // Package logger/parser contains methods to parse and restructure log output from go testing and terratest package parser // TestResultMarker tracks the indentation level of a test result line in go test output. // Example: // --- FAIL: TestSnafu // // --- PASS: TestSnafu/Situation // --- FAIL: TestSnafu/Normal // // The three markers for the above in order are: // TestResultMarker{TestName: "TestSnafu", IndentLevel: 0} // TestResultMarker{TestName: "TestSnafu/Situation", IndentLevel: 4} // TestResultMarker{TestName: "TestSnafu/Normal", IndentLevel: 4} type TestResultMarker struct { TestName string IndentLevel int } // TestResultMarkerStack is a stack data structure to store TestResultMarkers type TestResultMarkerStack []TestResultMarker // A blank TestResultMarker is considered null. Used when peeking or popping an empty stack. var NULL_TEST_RESULT_MARKER = TestResultMarker{} // TestResultMarker.push will push a TestResultMarker object onto the stack, returning the new one. func (s TestResultMarkerStack) push(v TestResultMarker) TestResultMarkerStack { return append(s, v) } // TestResultMarker.pop will pop a TestResultMarker object off of the stack, returning the new one with the popped // marker. // When stack is empty, will return an empty object. func (s TestResultMarkerStack) pop() (TestResultMarkerStack, TestResultMarker) { l := len(s) if l == 0 { return s, NULL_TEST_RESULT_MARKER } return s[:l-1], s[l-1] } // TestResultMarker.peek will return the top TestResultMarker from the stack, but will not remove it. func (s TestResultMarkerStack) peek() TestResultMarker { l := len(s) if l == 0 { return NULL_TEST_RESULT_MARKER } return s[l-1] } // TestResultMarker.isEmpty will return whether or not the stack is empty. func (s TestResultMarkerStack) isEmpty() bool { return len(s) == 0 } // removeDedentedTestResultMarkers will pop items off of the stack of TestResultMarker objects until the top most item // has an indent level less than the current indent level. // Assumes that the stack is ordered, in that recently pushed items in the stack have higher indent levels. func (s TestResultMarkerStack) removeDedentedTestResultMarkers(currentIndentLevel int) TestResultMarkerStack { // This loop is a garbage collection of the stack, where it removes entries every time we dedent out of a fail // block. for !s.isEmpty() && s.peek().IndentLevel >= currentIndentLevel { s, _ = s.pop() } return s } ================================================ FILE: modules/logger/parser/failed_test_marker_test.go ================================================ package parser import ( "testing" "github.com/stretchr/testify/assert" ) func createTestStack() TestResultMarkerStack { return TestResultMarkerStack{ TestResultMarker{ TestName: "TestSnafu", IndentLevel: 0, }, TestResultMarker{ TestName: "TestSnafu/Situation", IndentLevel: 4, }, TestResultMarker{ TestName: "TestSnafu/Normal", IndentLevel: 4, }, } } // Test that pushing items to the stack appends to the list func TestStackPush(t *testing.T) { t.Parallel() markers := createTestStack() newMarker := TestResultMarker{ TestName: "TestThatEverythingWorks", IndentLevel: 0, } markers = markers.push(newMarker) assert.Equal(t, len(markers), 4) assert.Equal(t, markers[3], newMarker) } // Test that popping items off the stack will remove it from the stack and return the LAST item in list func TestStackPop(t *testing.T) { t.Parallel() originalMarkers := createTestStack() markers := createTestStack() markers, poppedMarker := markers.pop() assert.Equal(t, poppedMarker, originalMarkers[2]) assert.Equal(t, len(markers), 2) markers, poppedMarker = markers.pop() assert.Equal(t, poppedMarker, originalMarkers[1]) assert.Equal(t, len(markers), 1) markers, poppedMarker = markers.pop() assert.Equal(t, poppedMarker, originalMarkers[0]) assert.Equal(t, len(markers), 0) } // Test that popping item off an empty stack will return an empty TestResultMarker func TestStackPopEmpty(t *testing.T) { t.Parallel() markers := TestResultMarkerStack{} markers, poppedMarker := markers.pop() assert.Equal(t, len(markers), 0) assert.Equal(t, poppedMarker, NULL_TEST_RESULT_MARKER) } // Test that peek will return the LAST item in the list WITHOUT removing it. func TestPeek(t *testing.T) { t.Parallel() originalMarkers := createTestStack() markers := createTestStack() peekedMarker := markers.peek() assert.Equal(t, peekedMarker, originalMarkers[2]) assert.Equal(t, originalMarkers, markers) } // Test that peeking an empty stack will return an empty TestResultMarker func TestPeekEmpty(t *testing.T) { t.Parallel() markers := TestResultMarkerStack{} peekedMarker := markers.peek() assert.Equal(t, len(markers), 0) assert.Equal(t, peekedMarker, NULL_TEST_RESULT_MARKER) } // Test isEmpty only returns True on empty stack func TestIsEmpty(t *testing.T) { t.Parallel() emptyMarkerStack := TestResultMarkerStack{} fullMarkerStack := createTestStack() assert.True(t, emptyMarkerStack.isEmpty()) assert.False(t, fullMarkerStack.isEmpty()) } // Test that removeDedentedTestResultMarkers remove items that are dedented from the current level, assuming the stack // is ordered by indent level. func TestRemoveDedentedTestResultMarkers(t *testing.T) { t.Parallel() originalMarkers := createTestStack() newMarkers := originalMarkers.removeDedentedTestResultMarkers(2) assert.Equal(t, len(newMarkers), 1) assert.Equal(t, newMarkers, originalMarkers[:1]) } // Test that removeDedentedTestResultMarkers handles empty stack. func TestRemoveDedentedTestResultMarkersEmpty(t *testing.T) { t.Parallel() originalMarkers := TestResultMarkerStack{} newMarkers := originalMarkers.removeDedentedTestResultMarkers(2) assert.Equal(t, len(newMarkers), 0) } // Test that removeDedentedTestResultMarkers handles removing everything func TestRemoveDedentedTestResultMarkersAll(t *testing.T) { t.Parallel() originalMarkers := TestResultMarkerStack{} newMarkers := originalMarkers.removeDedentedTestResultMarkers(-1) assert.Equal(t, len(newMarkers), 0) } ================================================ FILE: modules/logger/parser/fixtures/basic_example.log ================================================ === RUN TestStackPush === PAUSE TestStackPush === RUN TestStackPop === PAUSE TestStackPop === RUN TestStackPopEmpty === PAUSE TestStackPopEmpty === RUN TestPeek === PAUSE TestPeek === RUN TestPeekEmpty === PAUSE TestPeekEmpty === RUN TestIsEmpty === PAUSE TestIsEmpty === RUN TestRemoveDedentedTestResultMarkers --- PASS: TestRemoveDedentedTestResultMarkers (0.00s) === RUN TestRemoveDedentedTestResultMarkersEmpty --- PASS: TestRemoveDedentedTestResultMarkersEmpty (0.00s) === RUN TestRemoveDedentedTestResultMarkersAll --- PASS: TestRemoveDedentedTestResultMarkersAll (0.00s) === RUN TestGetIndent === PAUSE TestGetIndent === RUN TestGetTestNameFromResultLine === PAUSE TestGetTestNameFromResultLine === RUN TestIsResultLine === PAUSE TestIsResultLine === RUN TestGetTestNameFromStatusLine === PAUSE TestGetTestNameFromStatusLine === RUN TestIsStatusLine === PAUSE TestIsStatusLine === RUN TestIsSummaryLine === PAUSE TestIsSummaryLine === RUN TestIsPanicLine === PAUSE TestIsPanicLine === RUN TestEnsureDirectoryExistsCreatesDirectory === PAUSE TestEnsureDirectoryExistsCreatesDirectory === RUN TestEnsureDirectoryExistsHandlesExistingDirectory === PAUSE TestEnsureDirectoryExistsHandlesExistingDirectory === RUN TestGetOrCreateChannelCreatesNewChannel === PAUSE TestGetOrCreateChannelCreatesNewChannel === RUN TestGetOrCreateChannelReturnsExistingChannel === PAUSE TestGetOrCreateChannelReturnsExistingChannel === RUN TestLogCollectorCreatesAndWritesToFile === PAUSE TestLogCollectorCreatesAndWritesToFile === RUN TestGetOrCreateChannelSpawnsLogCollectorOnCreate === PAUSE TestGetOrCreateChannelSpawnsLogCollectorOnCreate === RUN TestCloseChannelsClosesAll === PAUSE TestCloseChannelsClosesAll === CONT TestStackPush === CONT TestIsSummaryLine === CONT TestGetOrCreateChannelReturnsExistingChannel === RUN TestIsSummaryLine/BaseCase === CONT TestCloseChannelsClosesAll --- PASS: TestStackPush (0.00s) === CONT TestGetOrCreateChannelSpawnsLogCollectorOnCreate === RUN TestIsSummaryLine/NotSummary --- PASS: TestIsSummaryLine (0.00s) --- PASS: TestIsSummaryLine/BaseCase (0.00s) --- PASS: TestIsSummaryLine/NotSummary (0.00s) === CONT TestLogCollectorCreatesAndWritesToFile TestCloseChannelsClosesAll INFO 2018-10-20T13:03:33-07:00 Closing all the channels in log writer TestGetOrCreateChannelSpawnsLogCollectorOnCreate INFO 2018-10-20T13:03:33-07:00 Spawned log writer for test TestGetOrCreateChannelSpawnsLogCollectorOnCreate TestGetOrCreateChannelSpawnsLogCollectorOnCreate INFO 2018-10-20T13:03:33-07:00 Storing logs for test TestGetOrCreateChannelSpawnsLogCollectorOnCreate to /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory894837527/TestGetOrCreateChannelSpawnsLogCollectorOnCreate.log --- PASS: TestCloseChannelsClosesAll (0.00s) === CONT TestGetIndent === RUN TestGetIndent/BaseCase === CONT TestPeek TestLogCollectorCreatesAndWritesToFile INFO 2018-10-20T13:03:33-07:00 Spawned log writer for test TestLogCollectorCreatesAndWritesToFile --- PASS: TestGetOrCreateChannelReturnsExistingChannel (0.00s) === CONT TestIsStatusLine === RUN TestIsStatusLine/BaseCase --- PASS: TestPeek (0.00s) TestGetOrCreateChannelSpawnsLogCollectorOnCreate INFO 2018-10-20T13:03:33-07:00 Directory /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory894837527 already exists === RUN TestGetIndent/NoIndent === RUN TestIsStatusLine/Indented === RUN TestGetIndent/EmptyString === RUN TestIsStatusLine/SpecialChars === RUN TestIsStatusLine/WhenPaused === RUN TestGetIndent/Tabs === RUN TestIsStatusLine/WhenCont === RUN TestGetIndent/MixTabSpace === RUN TestIsStatusLine/NonStatusLine --- PASS: TestIsStatusLine (0.00s) --- PASS: TestIsStatusLine/BaseCase (0.00s) --- PASS: TestIsStatusLine/Indented (0.00s) --- PASS: TestIsStatusLine/SpecialChars (0.00s) --- PASS: TestIsStatusLine/WhenPaused (0.00s) --- PASS: TestIsStatusLine/WhenCont (0.00s) --- PASS: TestIsStatusLine/NonStatusLine (0.00s) === CONT TestIsEmpty --- PASS: TestGetIndent (0.00s) --- PASS: TestGetIndent/BaseCase (0.00s) --- PASS: TestGetIndent/NoIndent (0.00s) --- PASS: TestGetIndent/EmptyString (0.00s) --- PASS: TestGetIndent/Tabs (0.00s) --- PASS: TestGetIndent/MixTabSpace (0.00s) === CONT TestGetTestNameFromStatusLine --- PASS: TestIsEmpty (0.00s) === CONT TestPeekEmpty === RUN TestGetTestNameFromStatusLine/BaseCase --- PASS: TestPeekEmpty (0.00s) === CONT TestIsResultLine === RUN TestIsResultLine/BaseCase === RUN TestGetTestNameFromStatusLine/Indented === RUN TestGetTestNameFromStatusLine/SpecialChars TestLogCollectorCreatesAndWritesToFile INFO 2018-10-20T13:03:33-07:00 Storing logs for test TestLogCollectorCreatesAndWritesToFile to /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestLogCollectorCreatesAndWritesToFile509683594/TestLogCollectorCreatesAndWritesToFile.log === RUN TestIsResultLine/Indented === RUN TestGetTestNameFromStatusLine/WhenPaused === RUN TestIsResultLine/SpecialChars TestLogCollectorCreatesAndWritesToFile INFO 2018-10-20T13:03:33-07:00 Directory /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestLogCollectorCreatesAndWritesToFile509683594 already exists === RUN TestGetTestNameFromStatusLine/WhenCont === RUN TestIsResultLine/WhenFailed --- PASS: TestGetTestNameFromStatusLine (0.00s) --- PASS: TestGetTestNameFromStatusLine/BaseCase (0.00s) --- PASS: TestGetTestNameFromStatusLine/Indented (0.00s) --- PASS: TestGetTestNameFromStatusLine/SpecialChars (0.00s) --- PASS: TestGetTestNameFromStatusLine/WhenPaused (0.00s) --- PASS: TestGetTestNameFromStatusLine/WhenCont (0.00s) === CONT TestGetTestNameFromResultLine === RUN TestGetTestNameFromResultLine/BaseCase TestGetOrCreateChannelSpawnsLogCollectorOnCreate INFO 2018-10-20T13:03:33-07:00 Channel closed for log writer of test TestGetOrCreateChannelSpawnsLogCollectorOnCreate === RUN TestGetTestNameFromResultLine/Indented === RUN TestGetTestNameFromResultLine/SpecialChars === RUN TestGetTestNameFromResultLine/WhenFailed === RUN TestIsResultLine/NonResultLine --- PASS: TestGetTestNameFromResultLine (0.00s) --- PASS: TestGetTestNameFromResultLine/BaseCase (0.00s) --- PASS: TestGetTestNameFromResultLine/Indented (0.00s) --- PASS: TestGetTestNameFromResultLine/SpecialChars (0.00s) --- PASS: TestGetTestNameFromResultLine/WhenFailed (0.00s) === CONT TestEnsureDirectoryExistsHandlesExistingDirectory --- PASS: TestIsResultLine (0.00s) --- PASS: TestIsResultLine/BaseCase (0.00s) --- PASS: TestIsResultLine/Indented (0.00s) --- PASS: TestIsResultLine/SpecialChars (0.00s) --- PASS: TestIsResultLine/WhenFailed (0.00s) --- PASS: TestIsResultLine/NonResultLine (0.00s) === CONT TestGetOrCreateChannelCreatesNewChannel TestLogCollectorCreatesAndWritesToFile INFO 2018-10-20T13:03:33-07:00 Channel closed for log writer of test TestLogCollectorCreatesAndWritesToFile TestEnsureDirectoryExistsHandlesExistingDirectory INFO 2018-10-20T13:03:33-07:00 Directory /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory503195489 already exists TestGetOrCreateChannelCreatesNewChannel INFO 2018-10-20T13:03:33-07:00 Spawned log writer for test TestGetOrCreateChannelCreatesNewChannel TestGetOrCreateChannelCreatesNewChannel INFO 2018-10-20T13:03:33-07:00 Storing logs for test TestGetOrCreateChannelCreatesNewChannel to /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory272503116/TestGetOrCreateChannelCreatesNewChannel.log --- PASS: TestGetOrCreateChannelCreatesNewChannel (0.00s) === CONT TestStackPopEmpty --- PASS: TestEnsureDirectoryExistsHandlesExistingDirectory (0.00s) TestGetOrCreateChannelCreatesNewChannel INFO 2018-10-20T13:03:33-07:00 Creating directory /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory272503116 === CONT TestIsPanicLine === CONT TestEnsureDirectoryExistsCreatesDirectory === RUN TestIsPanicLine/BaseCase === RUN TestIsPanicLine/NotPanic --- PASS: TestIsPanicLine (0.00s) --- PASS: TestIsPanicLine/BaseCase (0.00s) --- PASS: TestIsPanicLine/NotPanic (0.00s) === CONT TestStackPop --- PASS: TestStackPop (0.00s) --- PASS: TestStackPopEmpty (0.00s) TestEnsureDirectoryExistsCreatesDirectory INFO 2018-10-20T13:03:33-07:00 Creating directory /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory896401467/tmpdir TestGetOrCreateChannelCreatesNewChannel INFO 2018-10-20T13:03:33-07:00 Channel closed for log writer of test TestGetOrCreateChannelCreatesNewChannel --- PASS: TestEnsureDirectoryExistsCreatesDirectory (0.00s) --- PASS: TestGetOrCreateChannelSpawnsLogCollectorOnCreate (1.01s) --- PASS: TestLogCollectorCreatesAndWritesToFile (1.01s) PASS ok github.com/gruntwork-io/terratest/modules/logger/parser 1.019s ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestCloseChannelsClosesAll.log ================================================ === RUN TestCloseChannelsClosesAll === PAUSE TestCloseChannelsClosesAll === CONT TestCloseChannelsClosesAll TestCloseChannelsClosesAll INFO 2018-10-20T13:03:33-07:00 Closing all the channels in log writer --- PASS: TestCloseChannelsClosesAll (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestEnsureDirectoryExistsCreatesDirectory.log ================================================ === RUN TestEnsureDirectoryExistsCreatesDirectory === PAUSE TestEnsureDirectoryExistsCreatesDirectory === CONT TestEnsureDirectoryExistsCreatesDirectory TestEnsureDirectoryExistsCreatesDirectory INFO 2018-10-20T13:03:33-07:00 Creating directory /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory896401467/tmpdir --- PASS: TestEnsureDirectoryExistsCreatesDirectory (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestEnsureDirectoryExistsHandlesExistingDirectory.log ================================================ === RUN TestEnsureDirectoryExistsHandlesExistingDirectory === PAUSE TestEnsureDirectoryExistsHandlesExistingDirectory === CONT TestEnsureDirectoryExistsHandlesExistingDirectory TestEnsureDirectoryExistsHandlesExistingDirectory INFO 2018-10-20T13:03:33-07:00 Directory /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory503195489 already exists --- PASS: TestEnsureDirectoryExistsHandlesExistingDirectory (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestGetIndent/BaseCase.log ================================================ === RUN TestGetIndent/BaseCase --- PASS: TestGetIndent/BaseCase (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestGetIndent/EmptyString.log ================================================ === RUN TestGetIndent/EmptyString --- PASS: TestGetIndent/EmptyString (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestGetIndent/MixTabSpace.log ================================================ === RUN TestGetIndent/MixTabSpace --- PASS: TestGetIndent/MixTabSpace (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestGetIndent/NoIndent.log ================================================ === RUN TestGetIndent/NoIndent --- PASS: TestGetIndent/NoIndent (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestGetIndent/Tabs.log ================================================ === RUN TestGetIndent/Tabs --- PASS: TestGetIndent/Tabs (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestGetIndent.log ================================================ === RUN TestGetIndent === PAUSE TestGetIndent === CONT TestGetIndent --- PASS: TestGetIndent (0.00s) --- PASS: TestGetIndent/BaseCase (0.00s) --- PASS: TestGetIndent/NoIndent (0.00s) --- PASS: TestGetIndent/EmptyString (0.00s) --- PASS: TestGetIndent/Tabs (0.00s) --- PASS: TestGetIndent/MixTabSpace (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestGetOrCreateChannelCreatesNewChannel.log ================================================ === RUN TestGetOrCreateChannelCreatesNewChannel === PAUSE TestGetOrCreateChannelCreatesNewChannel === CONT TestGetOrCreateChannelCreatesNewChannel TestGetOrCreateChannelCreatesNewChannel INFO 2018-10-20T13:03:33-07:00 Spawned log writer for test TestGetOrCreateChannelCreatesNewChannel TestGetOrCreateChannelCreatesNewChannel INFO 2018-10-20T13:03:33-07:00 Storing logs for test TestGetOrCreateChannelCreatesNewChannel to /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory272503116/TestGetOrCreateChannelCreatesNewChannel.log --- PASS: TestGetOrCreateChannelCreatesNewChannel (0.00s) TestGetOrCreateChannelCreatesNewChannel INFO 2018-10-20T13:03:33-07:00 Creating directory /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory272503116 TestGetOrCreateChannelCreatesNewChannel INFO 2018-10-20T13:03:33-07:00 Channel closed for log writer of test TestGetOrCreateChannelCreatesNewChannel PASS ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestGetOrCreateChannelReturnsExistingChannel.log ================================================ === RUN TestGetOrCreateChannelReturnsExistingChannel === PAUSE TestGetOrCreateChannelReturnsExistingChannel === CONT TestGetOrCreateChannelReturnsExistingChannel --- PASS: TestGetOrCreateChannelReturnsExistingChannel (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestGetOrCreateChannelSpawnsLogCollectorOnCreate.log ================================================ === RUN TestGetOrCreateChannelSpawnsLogCollectorOnCreate === PAUSE TestGetOrCreateChannelSpawnsLogCollectorOnCreate === CONT TestGetOrCreateChannelSpawnsLogCollectorOnCreate TestGetOrCreateChannelSpawnsLogCollectorOnCreate INFO 2018-10-20T13:03:33-07:00 Spawned log writer for test TestGetOrCreateChannelSpawnsLogCollectorOnCreate TestGetOrCreateChannelSpawnsLogCollectorOnCreate INFO 2018-10-20T13:03:33-07:00 Storing logs for test TestGetOrCreateChannelSpawnsLogCollectorOnCreate to /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory894837527/TestGetOrCreateChannelSpawnsLogCollectorOnCreate.log TestGetOrCreateChannelSpawnsLogCollectorOnCreate INFO 2018-10-20T13:03:33-07:00 Directory /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory894837527 already exists TestGetOrCreateChannelSpawnsLogCollectorOnCreate INFO 2018-10-20T13:03:33-07:00 Channel closed for log writer of test TestGetOrCreateChannelSpawnsLogCollectorOnCreate --- PASS: TestGetOrCreateChannelSpawnsLogCollectorOnCreate (1.01s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestGetTestNameFromResultLine/BaseCase.log ================================================ === RUN TestGetTestNameFromResultLine/BaseCase --- PASS: TestGetTestNameFromResultLine/BaseCase (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestGetTestNameFromResultLine/Indented.log ================================================ === RUN TestGetTestNameFromResultLine/Indented --- PASS: TestGetTestNameFromResultLine/Indented (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestGetTestNameFromResultLine/SpecialChars.log ================================================ === RUN TestGetTestNameFromResultLine/SpecialChars --- PASS: TestGetTestNameFromResultLine/SpecialChars (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestGetTestNameFromResultLine/WhenFailed.log ================================================ === RUN TestGetTestNameFromResultLine/WhenFailed --- PASS: TestGetTestNameFromResultLine/WhenFailed (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestGetTestNameFromResultLine.log ================================================ === RUN TestGetTestNameFromResultLine === PAUSE TestGetTestNameFromResultLine === CONT TestGetTestNameFromResultLine --- PASS: TestGetTestNameFromResultLine (0.00s) --- PASS: TestGetTestNameFromResultLine/BaseCase (0.00s) --- PASS: TestGetTestNameFromResultLine/Indented (0.00s) --- PASS: TestGetTestNameFromResultLine/SpecialChars (0.00s) --- PASS: TestGetTestNameFromResultLine/WhenFailed (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestGetTestNameFromStatusLine/BaseCase.log ================================================ === RUN TestGetTestNameFromStatusLine/BaseCase --- PASS: TestGetTestNameFromStatusLine/BaseCase (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestGetTestNameFromStatusLine/Indented.log ================================================ === RUN TestGetTestNameFromStatusLine/Indented --- PASS: TestGetTestNameFromStatusLine/Indented (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestGetTestNameFromStatusLine/SpecialChars.log ================================================ === RUN TestGetTestNameFromStatusLine/SpecialChars --- PASS: TestGetTestNameFromStatusLine/SpecialChars (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestGetTestNameFromStatusLine/WhenCont.log ================================================ === RUN TestGetTestNameFromStatusLine/WhenCont --- PASS: TestGetTestNameFromStatusLine/WhenCont (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestGetTestNameFromStatusLine/WhenPaused.log ================================================ === RUN TestGetTestNameFromStatusLine/WhenPaused --- PASS: TestGetTestNameFromStatusLine/WhenPaused (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestGetTestNameFromStatusLine.log ================================================ === RUN TestGetTestNameFromStatusLine === PAUSE TestGetTestNameFromStatusLine === CONT TestGetTestNameFromStatusLine --- PASS: TestGetTestNameFromStatusLine (0.00s) --- PASS: TestGetTestNameFromStatusLine/BaseCase (0.00s) --- PASS: TestGetTestNameFromStatusLine/Indented (0.00s) --- PASS: TestGetTestNameFromStatusLine/SpecialChars (0.00s) --- PASS: TestGetTestNameFromStatusLine/WhenPaused (0.00s) --- PASS: TestGetTestNameFromStatusLine/WhenCont (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestIsEmpty.log ================================================ === RUN TestIsEmpty === PAUSE TestIsEmpty === CONT TestIsEmpty --- PASS: TestIsEmpty (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestIsPanicLine/BaseCase.log ================================================ === RUN TestIsPanicLine/BaseCase --- PASS: TestIsPanicLine/BaseCase (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestIsPanicLine/NotPanic.log ================================================ === RUN TestIsPanicLine/NotPanic --- PASS: TestIsPanicLine/NotPanic (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestIsPanicLine.log ================================================ === RUN TestIsPanicLine === PAUSE TestIsPanicLine === CONT TestIsPanicLine --- PASS: TestIsPanicLine (0.00s) --- PASS: TestIsPanicLine/BaseCase (0.00s) --- PASS: TestIsPanicLine/NotPanic (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestIsResultLine/BaseCase.log ================================================ === RUN TestIsResultLine/BaseCase --- PASS: TestIsResultLine/BaseCase (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestIsResultLine/Indented.log ================================================ === RUN TestIsResultLine/Indented --- PASS: TestIsResultLine/Indented (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestIsResultLine/NonResultLine.log ================================================ === RUN TestIsResultLine/NonResultLine --- PASS: TestIsResultLine/NonResultLine (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestIsResultLine/SpecialChars.log ================================================ === RUN TestIsResultLine/SpecialChars --- PASS: TestIsResultLine/SpecialChars (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestIsResultLine/WhenFailed.log ================================================ === RUN TestIsResultLine/WhenFailed --- PASS: TestIsResultLine/WhenFailed (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestIsResultLine.log ================================================ === RUN TestIsResultLine === PAUSE TestIsResultLine === CONT TestIsResultLine --- PASS: TestIsResultLine (0.00s) --- PASS: TestIsResultLine/BaseCase (0.00s) --- PASS: TestIsResultLine/Indented (0.00s) --- PASS: TestIsResultLine/SpecialChars (0.00s) --- PASS: TestIsResultLine/WhenFailed (0.00s) --- PASS: TestIsResultLine/NonResultLine (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestIsStatusLine/BaseCase.log ================================================ === RUN TestIsStatusLine/BaseCase --- PASS: TestIsStatusLine/BaseCase (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestIsStatusLine/Indented.log ================================================ === RUN TestIsStatusLine/Indented --- PASS: TestIsStatusLine/Indented (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestIsStatusLine/NonStatusLine.log ================================================ === RUN TestIsStatusLine/NonStatusLine --- PASS: TestIsStatusLine/NonStatusLine (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestIsStatusLine/SpecialChars.log ================================================ === RUN TestIsStatusLine/SpecialChars --- PASS: TestIsStatusLine/SpecialChars (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestIsStatusLine/WhenCont.log ================================================ === RUN TestIsStatusLine/WhenCont --- PASS: TestIsStatusLine/WhenCont (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestIsStatusLine/WhenPaused.log ================================================ === RUN TestIsStatusLine/WhenPaused --- PASS: TestIsStatusLine/WhenPaused (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestIsStatusLine.log ================================================ === RUN TestIsStatusLine === PAUSE TestIsStatusLine === CONT TestIsStatusLine --- PASS: TestIsStatusLine (0.00s) --- PASS: TestIsStatusLine/BaseCase (0.00s) --- PASS: TestIsStatusLine/Indented (0.00s) --- PASS: TestIsStatusLine/SpecialChars (0.00s) --- PASS: TestIsStatusLine/WhenPaused (0.00s) --- PASS: TestIsStatusLine/WhenCont (0.00s) --- PASS: TestIsStatusLine/NonStatusLine (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestIsSummaryLine/BaseCase.log ================================================ === RUN TestIsSummaryLine/BaseCase --- PASS: TestIsSummaryLine/BaseCase (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestIsSummaryLine/NotSummary.log ================================================ === RUN TestIsSummaryLine/NotSummary --- PASS: TestIsSummaryLine/NotSummary (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestIsSummaryLine.log ================================================ === RUN TestIsSummaryLine === PAUSE TestIsSummaryLine === CONT TestIsSummaryLine --- PASS: TestIsSummaryLine (0.00s) --- PASS: TestIsSummaryLine/BaseCase (0.00s) --- PASS: TestIsSummaryLine/NotSummary (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestLogCollectorCreatesAndWritesToFile.log ================================================ === RUN TestLogCollectorCreatesAndWritesToFile === PAUSE TestLogCollectorCreatesAndWritesToFile === CONT TestLogCollectorCreatesAndWritesToFile TestLogCollectorCreatesAndWritesToFile INFO 2018-10-20T13:03:33-07:00 Spawned log writer for test TestLogCollectorCreatesAndWritesToFile TestLogCollectorCreatesAndWritesToFile INFO 2018-10-20T13:03:33-07:00 Storing logs for test TestLogCollectorCreatesAndWritesToFile to /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestLogCollectorCreatesAndWritesToFile509683594/TestLogCollectorCreatesAndWritesToFile.log TestLogCollectorCreatesAndWritesToFile INFO 2018-10-20T13:03:33-07:00 Directory /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestLogCollectorCreatesAndWritesToFile509683594 already exists TestLogCollectorCreatesAndWritesToFile INFO 2018-10-20T13:03:33-07:00 Channel closed for log writer of test TestLogCollectorCreatesAndWritesToFile --- PASS: TestLogCollectorCreatesAndWritesToFile (1.01s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestPeek.log ================================================ === RUN TestPeek === PAUSE TestPeek === CONT TestPeek --- PASS: TestPeek (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestPeekEmpty.log ================================================ === RUN TestPeekEmpty === PAUSE TestPeekEmpty === CONT TestPeekEmpty --- PASS: TestPeekEmpty (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestRemoveDedentedTestResultMarkers.log ================================================ === RUN TestRemoveDedentedTestResultMarkers --- PASS: TestRemoveDedentedTestResultMarkers (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestRemoveDedentedTestResultMarkersAll.log ================================================ === RUN TestRemoveDedentedTestResultMarkersAll --- PASS: TestRemoveDedentedTestResultMarkersAll (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestRemoveDedentedTestResultMarkersEmpty.log ================================================ === RUN TestRemoveDedentedTestResultMarkersEmpty --- PASS: TestRemoveDedentedTestResultMarkersEmpty (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestStackPop.log ================================================ === RUN TestStackPop === PAUSE TestStackPop === CONT TestStackPop --- PASS: TestStackPop (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestStackPopEmpty.log ================================================ === RUN TestStackPopEmpty === PAUSE TestStackPopEmpty === CONT TestStackPopEmpty --- PASS: TestStackPopEmpty (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/TestStackPush.log ================================================ === RUN TestStackPush === PAUSE TestStackPush === CONT TestStackPush --- PASS: TestStackPush (0.00s) ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/report.xml ================================================ ================================================ FILE: modules/logger/parser/fixtures/basic_example_expected/summary.log ================================================ --- PASS: TestRemoveDedentedTestResultMarkers (0.00s) --- PASS: TestRemoveDedentedTestResultMarkersEmpty (0.00s) --- PASS: TestRemoveDedentedTestResultMarkersAll (0.00s) --- PASS: TestStackPush (0.00s) --- PASS: TestIsSummaryLine (0.00s) --- PASS: TestIsSummaryLine/BaseCase (0.00s) --- PASS: TestIsSummaryLine/NotSummary (0.00s) --- PASS: TestCloseChannelsClosesAll (0.00s) --- PASS: TestGetOrCreateChannelReturnsExistingChannel (0.00s) --- PASS: TestPeek (0.00s) --- PASS: TestIsStatusLine (0.00s) --- PASS: TestIsStatusLine/BaseCase (0.00s) --- PASS: TestIsStatusLine/Indented (0.00s) --- PASS: TestIsStatusLine/SpecialChars (0.00s) --- PASS: TestIsStatusLine/WhenPaused (0.00s) --- PASS: TestIsStatusLine/WhenCont (0.00s) --- PASS: TestIsStatusLine/NonStatusLine (0.00s) --- PASS: TestGetIndent (0.00s) --- PASS: TestGetIndent/BaseCase (0.00s) --- PASS: TestGetIndent/NoIndent (0.00s) --- PASS: TestGetIndent/EmptyString (0.00s) --- PASS: TestGetIndent/Tabs (0.00s) --- PASS: TestGetIndent/MixTabSpace (0.00s) --- PASS: TestIsEmpty (0.00s) --- PASS: TestPeekEmpty (0.00s) --- PASS: TestGetTestNameFromStatusLine (0.00s) --- PASS: TestGetTestNameFromStatusLine/BaseCase (0.00s) --- PASS: TestGetTestNameFromStatusLine/Indented (0.00s) --- PASS: TestGetTestNameFromStatusLine/SpecialChars (0.00s) --- PASS: TestGetTestNameFromStatusLine/WhenPaused (0.00s) --- PASS: TestGetTestNameFromStatusLine/WhenCont (0.00s) --- PASS: TestGetTestNameFromResultLine (0.00s) --- PASS: TestGetTestNameFromResultLine/BaseCase (0.00s) --- PASS: TestGetTestNameFromResultLine/Indented (0.00s) --- PASS: TestGetTestNameFromResultLine/SpecialChars (0.00s) --- PASS: TestGetTestNameFromResultLine/WhenFailed (0.00s) --- PASS: TestIsResultLine (0.00s) --- PASS: TestIsResultLine/BaseCase (0.00s) --- PASS: TestIsResultLine/Indented (0.00s) --- PASS: TestIsResultLine/SpecialChars (0.00s) --- PASS: TestIsResultLine/WhenFailed (0.00s) --- PASS: TestIsResultLine/NonResultLine (0.00s) --- PASS: TestGetOrCreateChannelCreatesNewChannel (0.00s) --- PASS: TestEnsureDirectoryExistsHandlesExistingDirectory (0.00s) --- PASS: TestIsPanicLine (0.00s) --- PASS: TestIsPanicLine/BaseCase (0.00s) --- PASS: TestIsPanicLine/NotPanic (0.00s) --- PASS: TestStackPop (0.00s) --- PASS: TestStackPopEmpty (0.00s) --- PASS: TestEnsureDirectoryExistsCreatesDirectory (0.00s) --- PASS: TestGetOrCreateChannelSpawnsLogCollectorOnCreate (1.01s) --- PASS: TestLogCollectorCreatesAndWritesToFile (1.01s) ok github.com/gruntwork-io/terratest/modules/logger/parser 1.019s ================================================ FILE: modules/logger/parser/fixtures/failing_example.log ================================================ === RUN TestStackPush === PAUSE TestStackPush === RUN TestStackPop === PAUSE TestStackPop === RUN TestStackPopEmpty === PAUSE TestStackPopEmpty === RUN TestPeek === PAUSE TestPeek === RUN TestPeekEmpty === PAUSE TestPeekEmpty === RUN TestIsEmpty === PAUSE TestIsEmpty === RUN TestRemoveDedentedTestResultMarkers --- PASS: TestRemoveDedentedTestResultMarkers (0.00s) === RUN TestRemoveDedentedTestResultMarkersEmpty --- PASS: TestRemoveDedentedTestResultMarkersEmpty (0.00s) === RUN TestRemoveDedentedTestResultMarkersAll --- PASS: TestRemoveDedentedTestResultMarkersAll (0.00s) === RUN TestBasicExample --- FAIL: TestBasicExample (0.00s) integration_test.go:10: Error Trace: integration_test.go:10 Error: Expected value not to be nil. Test: TestBasicExample === RUN TestPanicExample --- FAIL: TestPanicExample (0.00s) integration_test.go:14: Error Trace: integration_test.go:14 Error: Expected value not to be nil. Test: TestPanicExample === RUN TestRealWorldExample --- FAIL: TestRealWorldExample (0.00s) integration_test.go:18: Error Trace: integration_test.go:18 Error: Expected value not to be nil. Test: TestRealWorldExample === RUN TestGetIndent === PAUSE TestGetIndent === RUN TestGetTestNameFromResultLine === PAUSE TestGetTestNameFromResultLine === RUN TestIsResultLine === PAUSE TestIsResultLine === RUN TestGetTestNameFromStatusLine === PAUSE TestGetTestNameFromStatusLine === RUN TestIsStatusLine === PAUSE TestIsStatusLine === RUN TestIsSummaryLine === PAUSE TestIsSummaryLine === RUN TestIsPanicLine === PAUSE TestIsPanicLine === RUN TestEnsureDirectoryExistsCreatesDirectory === PAUSE TestEnsureDirectoryExistsCreatesDirectory === RUN TestEnsureDirectoryExistsHandlesExistingDirectory === PAUSE TestEnsureDirectoryExistsHandlesExistingDirectory === RUN TestGetOrCreateChannelCreatesNewChannel === PAUSE TestGetOrCreateChannelCreatesNewChannel === RUN TestGetOrCreateChannelReturnsExistingChannel === PAUSE TestGetOrCreateChannelReturnsExistingChannel === RUN TestLogCollectorCreatesAndWritesToFile === PAUSE TestLogCollectorCreatesAndWritesToFile === RUN TestGetOrCreateChannelSpawnsLogCollectorOnCreate === PAUSE TestGetOrCreateChannelSpawnsLogCollectorOnCreate === RUN TestCloseChannelsClosesAll === PAUSE TestCloseChannelsClosesAll === CONT TestStackPush === CONT TestIsSummaryLine --- PASS: TestStackPush (0.00s) === CONT TestIsStatusLine === RUN TestIsStatusLine/BaseCase === CONT TestGetTestNameFromStatusLine === RUN TestGetTestNameFromStatusLine/BaseCase === CONT TestIsResultLine === RUN TestIsResultLine/BaseCase === RUN TestIsSummaryLine/BaseCase === RUN TestGetTestNameFromStatusLine/Indented === RUN TestIsResultLine/Indented === RUN TestIsStatusLine/Indented === RUN TestGetTestNameFromStatusLine/SpecialChars === RUN TestIsResultLine/SpecialChars === RUN TestIsStatusLine/SpecialChars === RUN TestGetTestNameFromStatusLine/WhenPaused === RUN TestIsResultLine/WhenFailed === RUN TestIsStatusLine/WhenPaused === RUN TestGetTestNameFromStatusLine/WhenCont === RUN TestIsStatusLine/WhenCont === RUN TestIsResultLine/NonResultLine --- PASS: TestGetTestNameFromStatusLine (0.00s) --- PASS: TestGetTestNameFromStatusLine/BaseCase (0.00s) --- PASS: TestGetTestNameFromStatusLine/Indented (0.00s) --- PASS: TestGetTestNameFromStatusLine/SpecialChars (0.00s) --- PASS: TestGetTestNameFromStatusLine/WhenPaused (0.00s) --- PASS: TestGetTestNameFromStatusLine/WhenCont (0.00s) === RUN TestIsStatusLine/NonStatusLine === CONT TestGetTestNameFromResultLine === RUN TestGetTestNameFromResultLine/BaseCase === CONT TestGetIndent --- PASS: TestIsStatusLine (0.00s) --- PASS: TestIsStatusLine/BaseCase (0.00s) --- PASS: TestIsStatusLine/Indented (0.00s) --- PASS: TestIsStatusLine/SpecialChars (0.00s) --- PASS: TestIsStatusLine/WhenPaused (0.00s) --- PASS: TestIsStatusLine/WhenCont (0.00s) --- PASS: TestIsStatusLine/NonStatusLine (0.00s) === RUN TestGetIndent/BaseCase === RUN TestGetTestNameFromResultLine/Indented === CONT TestIsEmpty --- PASS: TestIsEmpty (0.00s) === RUN TestGetTestNameFromResultLine/SpecialChars === CONT TestPeekEmpty --- PASS: TestPeekEmpty (0.00s) === CONT TestPeek === RUN TestGetTestNameFromResultLine/WhenFailed === RUN TestGetIndent/NoIndent --- PASS: TestPeek (0.00s) === CONT TestStackPop === RUN TestGetIndent/EmptyString --- PASS: TestGetTestNameFromResultLine (0.00s) --- PASS: TestGetTestNameFromResultLine/BaseCase (0.00s) --- PASS: TestGetTestNameFromResultLine/Indented (0.00s) --- PASS: TestGetTestNameFromResultLine/SpecialChars (0.00s) --- PASS: TestGetTestNameFromResultLine/WhenFailed (0.00s) === CONT TestGetOrCreateChannelReturnsExistingChannel === CONT TestStackPopEmpty --- PASS: TestStackPopEmpty (0.00s) === RUN TestGetIndent/Tabs === CONT TestCloseChannelsClosesAll === RUN TestGetIndent/MixTabSpace --- PASS: TestGetIndent (0.00s) --- PASS: TestGetIndent/BaseCase (0.00s) --- PASS: TestGetIndent/NoIndent (0.00s) --- PASS: TestGetIndent/EmptyString (0.00s) --- PASS: TestGetIndent/Tabs (0.00s) --- PASS: TestGetIndent/MixTabSpace (0.00s) === CONT TestGetOrCreateChannelSpawnsLogCollectorOnCreate --- PASS: TestGetOrCreateChannelReturnsExistingChannel (0.00s) === CONT TestLogCollectorCreatesAndWritesToFile TestCloseChannelsClosesAll INFO 2018-10-20T13:15:09-07:00 Closing all the channels in log writer --- PASS: TestStackPop (0.00s) TestGetOrCreateChannelSpawnsLogCollectorOnCreate INFO 2018-10-20T13:15:09-07:00 Spawned log writer for test TestGetOrCreateChannelSpawnsLogCollectorOnCreate TestGetOrCreateChannelSpawnsLogCollectorOnCreate INFO 2018-10-20T13:15:09-07:00 Storing logs for test TestGetOrCreateChannelSpawnsLogCollectorOnCreate to /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory945346773/TestGetOrCreateChannelSpawnsLogCollectorOnCreate.log TestLogCollectorCreatesAndWritesToFile INFO 2018-10-20T13:15:09-07:00 Spawned log writer for test TestLogCollectorCreatesAndWritesToFile TestGetOrCreateChannelSpawnsLogCollectorOnCreate INFO 2018-10-20T13:15:09-07:00 Directory /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory945346773 already exists TestLogCollectorCreatesAndWritesToFile INFO 2018-10-20T13:15:09-07:00 Storing logs for test TestLogCollectorCreatesAndWritesToFile to /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestLogCollectorCreatesAndWritesToFile262063152/TestLogCollectorCreatesAndWritesToFile.log --- PASS: TestCloseChannelsClosesAll (0.00s) TestLogCollectorCreatesAndWritesToFile INFO 2018-10-20T13:15:09-07:00 Directory /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestLogCollectorCreatesAndWritesToFile262063152 already exists === CONT TestEnsureDirectoryExistsHandlesExistingDirectory === RUN TestIsSummaryLine/NotSummary --- PASS: TestIsSummaryLine (0.00s) --- PASS: TestIsSummaryLine/BaseCase (0.00s) --- PASS: TestIsSummaryLine/NotSummary (0.00s) === CONT TestGetOrCreateChannelCreatesNewChannel TestEnsureDirectoryExistsHandlesExistingDirectory INFO 2018-10-20T13:15:09-07:00 Directory /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory292537295 already exists TestLogCollectorCreatesAndWritesToFile INFO 2018-10-20T13:15:09-07:00 Channel closed for log writer of test TestLogCollectorCreatesAndWritesToFile TestGetOrCreateChannelSpawnsLogCollectorOnCreate INFO 2018-10-20T13:15:09-07:00 Channel closed for log writer of test TestGetOrCreateChannelSpawnsLogCollectorOnCreate --- PASS: TestGetOrCreateChannelCreatesNewChannel (0.00s) === CONT TestEnsureDirectoryExistsCreatesDirectory --- PASS: TestIsResultLine (0.00s) TestGetOrCreateChannelCreatesNewChannel INFO 2018-10-20T13:15:09-07:00 Spawned log writer for test TestGetOrCreateChannelCreatesNewChannel --- PASS: TestIsResultLine/BaseCase (0.00s) --- PASS: TestIsResultLine/Indented (0.00s) --- PASS: TestIsResultLine/SpecialChars (0.00s) --- PASS: TestIsResultLine/WhenFailed (0.00s) --- PASS: TestIsResultLine/NonResultLine (0.00s) TestGetOrCreateChannelCreatesNewChannel INFO 2018-10-20T13:15:09-07:00 Storing logs for test TestGetOrCreateChannelCreatesNewChannel to /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory867148002/TestGetOrCreateChannelCreatesNewChannel.log --- PASS: TestEnsureDirectoryExistsHandlesExistingDirectory (0.00s) === CONT TestIsPanicLine TestGetOrCreateChannelCreatesNewChannel INFO 2018-10-20T13:15:09-07:00 Creating directory /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory867148002 === RUN TestIsPanicLine/BaseCase === RUN TestIsPanicLine/NotPanic --- PASS: TestIsPanicLine (0.00s) --- PASS: TestIsPanicLine/BaseCase (0.00s) --- PASS: TestIsPanicLine/NotPanic (0.00s) TestEnsureDirectoryExistsCreatesDirectory INFO 2018-10-20T13:15:09-07:00 Creating directory /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory357603033/tmpdir TestGetOrCreateChannelCreatesNewChannel INFO 2018-10-20T13:15:09-07:00 Channel closed for log writer of test TestGetOrCreateChannelCreatesNewChannel --- PASS: TestEnsureDirectoryExistsCreatesDirectory (0.00s) --- PASS: TestLogCollectorCreatesAndWritesToFile (1.01s) --- PASS: TestGetOrCreateChannelSpawnsLogCollectorOnCreate (1.01s) FAIL exit status 1 FAIL github.com/gruntwork-io/terratest/modules/logger/parser 1.020s ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestBasicExample.log ================================================ === RUN TestBasicExample --- FAIL: TestBasicExample (0.00s) integration_test.go:10: Error Trace: integration_test.go:10 Error: Expected value not to be nil. Test: TestBasicExample ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestCloseChannelsClosesAll.log ================================================ === RUN TestCloseChannelsClosesAll === PAUSE TestCloseChannelsClosesAll === CONT TestCloseChannelsClosesAll TestCloseChannelsClosesAll INFO 2018-10-20T13:15:09-07:00 Closing all the channels in log writer --- PASS: TestCloseChannelsClosesAll (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestEnsureDirectoryExistsCreatesDirectory.log ================================================ === RUN TestEnsureDirectoryExistsCreatesDirectory === PAUSE TestEnsureDirectoryExistsCreatesDirectory === CONT TestEnsureDirectoryExistsCreatesDirectory TestEnsureDirectoryExistsCreatesDirectory INFO 2018-10-20T13:15:09-07:00 Creating directory /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory357603033/tmpdir --- PASS: TestEnsureDirectoryExistsCreatesDirectory (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestEnsureDirectoryExistsHandlesExistingDirectory.log ================================================ === RUN TestEnsureDirectoryExistsHandlesExistingDirectory === PAUSE TestEnsureDirectoryExistsHandlesExistingDirectory === CONT TestEnsureDirectoryExistsHandlesExistingDirectory TestEnsureDirectoryExistsHandlesExistingDirectory INFO 2018-10-20T13:15:09-07:00 Directory /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory292537295 already exists --- PASS: TestEnsureDirectoryExistsHandlesExistingDirectory (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestGetIndent/BaseCase.log ================================================ === RUN TestGetIndent/BaseCase --- PASS: TestGetIndent/BaseCase (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestGetIndent/EmptyString.log ================================================ === RUN TestGetIndent/EmptyString --- PASS: TestGetIndent/EmptyString (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestGetIndent/MixTabSpace.log ================================================ === RUN TestGetIndent/MixTabSpace --- PASS: TestGetIndent/MixTabSpace (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestGetIndent/NoIndent.log ================================================ === RUN TestGetIndent/NoIndent --- PASS: TestGetIndent/NoIndent (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestGetIndent/Tabs.log ================================================ === RUN TestGetIndent/Tabs --- PASS: TestGetIndent/Tabs (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestGetIndent.log ================================================ === RUN TestGetIndent === PAUSE TestGetIndent === CONT TestGetIndent --- PASS: TestGetIndent (0.00s) --- PASS: TestGetIndent/BaseCase (0.00s) --- PASS: TestGetIndent/NoIndent (0.00s) --- PASS: TestGetIndent/EmptyString (0.00s) --- PASS: TestGetIndent/Tabs (0.00s) --- PASS: TestGetIndent/MixTabSpace (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestGetOrCreateChannelCreatesNewChannel.log ================================================ === RUN TestGetOrCreateChannelCreatesNewChannel === PAUSE TestGetOrCreateChannelCreatesNewChannel === CONT TestGetOrCreateChannelCreatesNewChannel --- PASS: TestGetOrCreateChannelCreatesNewChannel (0.00s) TestGetOrCreateChannelCreatesNewChannel INFO 2018-10-20T13:15:09-07:00 Spawned log writer for test TestGetOrCreateChannelCreatesNewChannel TestGetOrCreateChannelCreatesNewChannel INFO 2018-10-20T13:15:09-07:00 Storing logs for test TestGetOrCreateChannelCreatesNewChannel to /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory867148002/TestGetOrCreateChannelCreatesNewChannel.log TestGetOrCreateChannelCreatesNewChannel INFO 2018-10-20T13:15:09-07:00 Creating directory /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory867148002 TestGetOrCreateChannelCreatesNewChannel INFO 2018-10-20T13:15:09-07:00 Channel closed for log writer of test TestGetOrCreateChannelCreatesNewChannel exit status 1 ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestGetOrCreateChannelReturnsExistingChannel.log ================================================ === RUN TestGetOrCreateChannelReturnsExistingChannel === PAUSE TestGetOrCreateChannelReturnsExistingChannel === CONT TestGetOrCreateChannelReturnsExistingChannel --- PASS: TestGetOrCreateChannelReturnsExistingChannel (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestGetOrCreateChannelSpawnsLogCollectorOnCreate.log ================================================ === RUN TestGetOrCreateChannelSpawnsLogCollectorOnCreate === PAUSE TestGetOrCreateChannelSpawnsLogCollectorOnCreate === CONT TestGetOrCreateChannelSpawnsLogCollectorOnCreate TestGetOrCreateChannelSpawnsLogCollectorOnCreate INFO 2018-10-20T13:15:09-07:00 Spawned log writer for test TestGetOrCreateChannelSpawnsLogCollectorOnCreate TestGetOrCreateChannelSpawnsLogCollectorOnCreate INFO 2018-10-20T13:15:09-07:00 Storing logs for test TestGetOrCreateChannelSpawnsLogCollectorOnCreate to /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory945346773/TestGetOrCreateChannelSpawnsLogCollectorOnCreate.log TestGetOrCreateChannelSpawnsLogCollectorOnCreate INFO 2018-10-20T13:15:09-07:00 Directory /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory945346773 already exists TestGetOrCreateChannelSpawnsLogCollectorOnCreate INFO 2018-10-20T13:15:09-07:00 Channel closed for log writer of test TestGetOrCreateChannelSpawnsLogCollectorOnCreate --- PASS: TestGetOrCreateChannelSpawnsLogCollectorOnCreate (1.01s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestGetTestNameFromResultLine/BaseCase.log ================================================ === RUN TestGetTestNameFromResultLine/BaseCase --- PASS: TestGetTestNameFromResultLine/BaseCase (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestGetTestNameFromResultLine/Indented.log ================================================ === RUN TestGetTestNameFromResultLine/Indented --- PASS: TestGetTestNameFromResultLine/Indented (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestGetTestNameFromResultLine/SpecialChars.log ================================================ === RUN TestGetTestNameFromResultLine/SpecialChars --- PASS: TestGetTestNameFromResultLine/SpecialChars (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestGetTestNameFromResultLine/WhenFailed.log ================================================ === RUN TestGetTestNameFromResultLine/WhenFailed --- PASS: TestGetTestNameFromResultLine/WhenFailed (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestGetTestNameFromResultLine.log ================================================ === RUN TestGetTestNameFromResultLine === PAUSE TestGetTestNameFromResultLine === CONT TestGetTestNameFromResultLine --- PASS: TestGetTestNameFromResultLine (0.00s) --- PASS: TestGetTestNameFromResultLine/BaseCase (0.00s) --- PASS: TestGetTestNameFromResultLine/Indented (0.00s) --- PASS: TestGetTestNameFromResultLine/SpecialChars (0.00s) --- PASS: TestGetTestNameFromResultLine/WhenFailed (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestGetTestNameFromStatusLine/BaseCase.log ================================================ === RUN TestGetTestNameFromStatusLine/BaseCase --- PASS: TestGetTestNameFromStatusLine/BaseCase (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestGetTestNameFromStatusLine/Indented.log ================================================ === RUN TestGetTestNameFromStatusLine/Indented --- PASS: TestGetTestNameFromStatusLine/Indented (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestGetTestNameFromStatusLine/SpecialChars.log ================================================ === RUN TestGetTestNameFromStatusLine/SpecialChars --- PASS: TestGetTestNameFromStatusLine/SpecialChars (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestGetTestNameFromStatusLine/WhenCont.log ================================================ === RUN TestGetTestNameFromStatusLine/WhenCont --- PASS: TestGetTestNameFromStatusLine/WhenCont (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestGetTestNameFromStatusLine/WhenPaused.log ================================================ === RUN TestGetTestNameFromStatusLine/WhenPaused --- PASS: TestGetTestNameFromStatusLine/WhenPaused (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestGetTestNameFromStatusLine.log ================================================ === RUN TestGetTestNameFromStatusLine === PAUSE TestGetTestNameFromStatusLine === CONT TestGetTestNameFromStatusLine --- PASS: TestGetTestNameFromStatusLine (0.00s) --- PASS: TestGetTestNameFromStatusLine/BaseCase (0.00s) --- PASS: TestGetTestNameFromStatusLine/Indented (0.00s) --- PASS: TestGetTestNameFromStatusLine/SpecialChars (0.00s) --- PASS: TestGetTestNameFromStatusLine/WhenPaused (0.00s) --- PASS: TestGetTestNameFromStatusLine/WhenCont (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestIsEmpty.log ================================================ === RUN TestIsEmpty === PAUSE TestIsEmpty === CONT TestIsEmpty --- PASS: TestIsEmpty (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestIsPanicLine/BaseCase.log ================================================ === RUN TestIsPanicLine/BaseCase --- PASS: TestIsPanicLine/BaseCase (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestIsPanicLine/NotPanic.log ================================================ === RUN TestIsPanicLine/NotPanic --- PASS: TestIsPanicLine/NotPanic (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestIsPanicLine.log ================================================ === RUN TestIsPanicLine === PAUSE TestIsPanicLine === CONT TestIsPanicLine --- PASS: TestIsPanicLine (0.00s) --- PASS: TestIsPanicLine/BaseCase (0.00s) --- PASS: TestIsPanicLine/NotPanic (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestIsResultLine/BaseCase.log ================================================ === RUN TestIsResultLine/BaseCase --- PASS: TestIsResultLine/BaseCase (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestIsResultLine/Indented.log ================================================ === RUN TestIsResultLine/Indented --- PASS: TestIsResultLine/Indented (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestIsResultLine/NonResultLine.log ================================================ === RUN TestIsResultLine/NonResultLine --- PASS: TestIsResultLine/NonResultLine (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestIsResultLine/SpecialChars.log ================================================ === RUN TestIsResultLine/SpecialChars --- PASS: TestIsResultLine/SpecialChars (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestIsResultLine/WhenFailed.log ================================================ === RUN TestIsResultLine/WhenFailed --- PASS: TestIsResultLine/WhenFailed (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestIsResultLine.log ================================================ === RUN TestIsResultLine === PAUSE TestIsResultLine === CONT TestIsResultLine --- PASS: TestIsResultLine (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestIsStatusLine/BaseCase.log ================================================ === RUN TestIsStatusLine/BaseCase --- PASS: TestIsStatusLine/BaseCase (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestIsStatusLine/Indented.log ================================================ === RUN TestIsStatusLine/Indented --- PASS: TestIsStatusLine/Indented (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestIsStatusLine/NonStatusLine.log ================================================ === RUN TestIsStatusLine/NonStatusLine --- PASS: TestIsStatusLine/NonStatusLine (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestIsStatusLine/SpecialChars.log ================================================ === RUN TestIsStatusLine/SpecialChars --- PASS: TestIsStatusLine/SpecialChars (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestIsStatusLine/WhenCont.log ================================================ === RUN TestIsStatusLine/WhenCont --- PASS: TestIsStatusLine/WhenCont (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestIsStatusLine/WhenPaused.log ================================================ === RUN TestIsStatusLine/WhenPaused --- PASS: TestIsStatusLine/WhenPaused (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestIsStatusLine.log ================================================ === RUN TestIsStatusLine === PAUSE TestIsStatusLine === CONT TestIsStatusLine --- PASS: TestIsStatusLine (0.00s) --- PASS: TestIsStatusLine/BaseCase (0.00s) --- PASS: TestIsStatusLine/Indented (0.00s) --- PASS: TestIsStatusLine/SpecialChars (0.00s) --- PASS: TestIsStatusLine/WhenPaused (0.00s) --- PASS: TestIsStatusLine/WhenCont (0.00s) --- PASS: TestIsStatusLine/NonStatusLine (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestIsSummaryLine/BaseCase.log ================================================ === RUN TestIsSummaryLine/BaseCase --- PASS: TestIsSummaryLine/BaseCase (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestIsSummaryLine/NotSummary.log ================================================ === RUN TestIsSummaryLine/NotSummary --- PASS: TestIsSummaryLine/NotSummary (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestIsSummaryLine.log ================================================ === RUN TestIsSummaryLine === PAUSE TestIsSummaryLine === CONT TestIsSummaryLine --- PASS: TestIsSummaryLine (0.00s) --- PASS: TestIsSummaryLine/BaseCase (0.00s) --- PASS: TestIsSummaryLine/NotSummary (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestLogCollectorCreatesAndWritesToFile.log ================================================ === RUN TestLogCollectorCreatesAndWritesToFile === PAUSE TestLogCollectorCreatesAndWritesToFile === CONT TestLogCollectorCreatesAndWritesToFile TestLogCollectorCreatesAndWritesToFile INFO 2018-10-20T13:15:09-07:00 Spawned log writer for test TestLogCollectorCreatesAndWritesToFile TestLogCollectorCreatesAndWritesToFile INFO 2018-10-20T13:15:09-07:00 Storing logs for test TestLogCollectorCreatesAndWritesToFile to /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestLogCollectorCreatesAndWritesToFile262063152/TestLogCollectorCreatesAndWritesToFile.log TestLogCollectorCreatesAndWritesToFile INFO 2018-10-20T13:15:09-07:00 Directory /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestLogCollectorCreatesAndWritesToFile262063152 already exists TestLogCollectorCreatesAndWritesToFile INFO 2018-10-20T13:15:09-07:00 Channel closed for log writer of test TestLogCollectorCreatesAndWritesToFile --- PASS: TestLogCollectorCreatesAndWritesToFile (1.01s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestPanicExample.log ================================================ === RUN TestPanicExample --- FAIL: TestPanicExample (0.00s) integration_test.go:14: Error Trace: integration_test.go:14 Error: Expected value not to be nil. Test: TestPanicExample ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestPeek.log ================================================ === RUN TestPeek === PAUSE TestPeek === CONT TestPeek --- PASS: TestPeek (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestPeekEmpty.log ================================================ === RUN TestPeekEmpty === PAUSE TestPeekEmpty === CONT TestPeekEmpty --- PASS: TestPeekEmpty (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestRealWorldExample.log ================================================ === RUN TestRealWorldExample --- FAIL: TestRealWorldExample (0.00s) integration_test.go:18: Error Trace: integration_test.go:18 Error: Expected value not to be nil. Test: TestRealWorldExample ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestRemoveDedentedTestResultMarkers.log ================================================ === RUN TestRemoveDedentedTestResultMarkers --- PASS: TestRemoveDedentedTestResultMarkers (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestRemoveDedentedTestResultMarkersAll.log ================================================ === RUN TestRemoveDedentedTestResultMarkersAll --- PASS: TestRemoveDedentedTestResultMarkersAll (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestRemoveDedentedTestResultMarkersEmpty.log ================================================ === RUN TestRemoveDedentedTestResultMarkersEmpty --- PASS: TestRemoveDedentedTestResultMarkersEmpty (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestStackPop.log ================================================ === RUN TestStackPop === PAUSE TestStackPop === CONT TestStackPop --- PASS: TestStackPop (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestStackPopEmpty.log ================================================ === RUN TestStackPopEmpty === PAUSE TestStackPopEmpty === CONT TestStackPopEmpty --- PASS: TestStackPopEmpty (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/TestStackPush.log ================================================ === RUN TestStackPush === PAUSE TestStackPush === CONT TestStackPush --- PASS: TestStackPush (0.00s) ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/report.xml ================================================ integration_test.go:10: Error Trace: integration_test.go:10 Error: Expected value not to be nil. Test: TestBasicExample integration_test.go:14: Error Trace: integration_test.go:14 Error: Expected value not to be nil. Test: TestPanicExample integration_test.go:18: Error Trace: integration_test.go:18 Error: Expected value not to be nil. Test: TestRealWorldExample ================================================ FILE: modules/logger/parser/fixtures/failing_example_expected/summary.log ================================================ --- PASS: TestRemoveDedentedTestResultMarkers (0.00s) --- PASS: TestRemoveDedentedTestResultMarkersEmpty (0.00s) --- PASS: TestRemoveDedentedTestResultMarkersAll (0.00s) --- FAIL: TestBasicExample (0.00s) --- FAIL: TestPanicExample (0.00s) --- FAIL: TestRealWorldExample (0.00s) --- PASS: TestStackPush (0.00s) --- PASS: TestGetTestNameFromStatusLine (0.00s) --- PASS: TestGetTestNameFromStatusLine/BaseCase (0.00s) --- PASS: TestGetTestNameFromStatusLine/Indented (0.00s) --- PASS: TestGetTestNameFromStatusLine/SpecialChars (0.00s) --- PASS: TestGetTestNameFromStatusLine/WhenPaused (0.00s) --- PASS: TestGetTestNameFromStatusLine/WhenCont (0.00s) --- PASS: TestIsStatusLine (0.00s) --- PASS: TestIsStatusLine/BaseCase (0.00s) --- PASS: TestIsStatusLine/Indented (0.00s) --- PASS: TestIsStatusLine/SpecialChars (0.00s) --- PASS: TestIsStatusLine/WhenPaused (0.00s) --- PASS: TestIsStatusLine/WhenCont (0.00s) --- PASS: TestIsStatusLine/NonStatusLine (0.00s) --- PASS: TestIsEmpty (0.00s) --- PASS: TestPeekEmpty (0.00s) --- PASS: TestPeek (0.00s) --- PASS: TestGetTestNameFromResultLine (0.00s) --- PASS: TestGetTestNameFromResultLine/BaseCase (0.00s) --- PASS: TestGetTestNameFromResultLine/Indented (0.00s) --- PASS: TestGetTestNameFromResultLine/SpecialChars (0.00s) --- PASS: TestGetTestNameFromResultLine/WhenFailed (0.00s) --- PASS: TestStackPopEmpty (0.00s) --- PASS: TestGetIndent (0.00s) --- PASS: TestGetIndent/BaseCase (0.00s) --- PASS: TestGetIndent/NoIndent (0.00s) --- PASS: TestGetIndent/EmptyString (0.00s) --- PASS: TestGetIndent/Tabs (0.00s) --- PASS: TestGetIndent/MixTabSpace (0.00s) --- PASS: TestGetOrCreateChannelReturnsExistingChannel (0.00s) --- PASS: TestStackPop (0.00s) --- PASS: TestCloseChannelsClosesAll (0.00s) --- PASS: TestIsSummaryLine (0.00s) --- PASS: TestIsSummaryLine/BaseCase (0.00s) --- PASS: TestIsSummaryLine/NotSummary (0.00s) --- PASS: TestGetOrCreateChannelCreatesNewChannel (0.00s) --- PASS: TestIsResultLine (0.00s) --- PASS: TestIsResultLine/BaseCase (0.00s) --- PASS: TestIsResultLine/Indented (0.00s) --- PASS: TestIsResultLine/SpecialChars (0.00s) --- PASS: TestIsResultLine/WhenFailed (0.00s) --- PASS: TestIsResultLine/NonResultLine (0.00s) --- PASS: TestEnsureDirectoryExistsHandlesExistingDirectory (0.00s) --- PASS: TestIsPanicLine (0.00s) --- PASS: TestIsPanicLine/BaseCase (0.00s) --- PASS: TestIsPanicLine/NotPanic (0.00s) --- PASS: TestEnsureDirectoryExistsCreatesDirectory (0.00s) --- PASS: TestLogCollectorCreatesAndWritesToFile (1.01s) --- PASS: TestGetOrCreateChannelSpawnsLogCollectorOnCreate (1.01s) FAIL FAIL github.com/gruntwork-io/terratest/modules/logger/parser 1.020s ================================================ FILE: modules/logger/parser/fixtures/new_go_failing_example.log ================================================ === RUN TestIntegrationBasicExample === PAUSE TestIntegrationBasicExample === RUN TestIntegrationFailingExample === PAUSE TestIntegrationFailingExample === RUN TestIntegrationPanicExample === PAUSE TestIntegrationPanicExample === CONT TestIntegrationBasicExample === CONT TestIntegrationPanicExample === CONT TestIntegrationFailingExample === CONT TestIntegrationBasicExample integration_test.go:57: Error Trace: integration_test.go:57 Error: Should be true Test: TestIntegrationBasicExample --- PASS: TestIntegrationPanicExample (0.00s) --- PASS: TestIntegrationFailingExample (0.00s) --- FAIL: TestIntegrationBasicExample (0.00s) FAIL FAIL github.com/gruntwork-io/terratest/modules/logger/parser 1.589s FAIL ================================================ FILE: modules/logger/parser/fixtures/new_go_failing_example_expected/TestIntegrationBasicExample.log ================================================ === RUN TestIntegrationBasicExample === PAUSE TestIntegrationBasicExample === CONT TestIntegrationBasicExample === CONT TestIntegrationBasicExample integration_test.go:57: Error Trace: integration_test.go:57 Error: Should be true Test: TestIntegrationBasicExample --- FAIL: TestIntegrationBasicExample (0.00s) ================================================ FILE: modules/logger/parser/fixtures/new_go_failing_example_expected/TestIntegrationFailingExample.log ================================================ === RUN TestIntegrationFailingExample === PAUSE TestIntegrationFailingExample === CONT TestIntegrationFailingExample --- PASS: TestIntegrationFailingExample (0.00s) ================================================ FILE: modules/logger/parser/fixtures/new_go_failing_example_expected/TestIntegrationPanicExample.log ================================================ === RUN TestIntegrationPanicExample === PAUSE TestIntegrationPanicExample === CONT TestIntegrationPanicExample --- PASS: TestIntegrationPanicExample (0.00s) ================================================ FILE: modules/logger/parser/fixtures/new_go_failing_example_expected/report.xml ================================================ integration_test.go:57: ================================================ FILE: modules/logger/parser/fixtures/new_go_failing_example_expected/summary.log ================================================ --- PASS: TestIntegrationPanicExample (0.00s) --- PASS: TestIntegrationFailingExample (0.00s) --- FAIL: TestIntegrationBasicExample (0.00s) FAIL FAIL github.com/gruntwork-io/terratest/modules/logger/parser 1.589s FAIL ================================================ FILE: modules/logger/parser/fixtures/panic_example.log ================================================ === RUN TestStackPush === PAUSE TestStackPush === RUN TestStackPop === PAUSE TestStackPop === RUN TestStackPopEmpty === PAUSE TestStackPopEmpty === RUN TestPeek === PAUSE TestPeek === RUN TestPeekEmpty === PAUSE TestPeekEmpty === RUN TestIsEmpty === PAUSE TestIsEmpty === RUN TestRemoveDedentedTestResultMarkers --- PASS: TestRemoveDedentedTestResultMarkers (0.00s) === RUN TestRemoveDedentedTestResultMarkersEmpty --- PASS: TestRemoveDedentedTestResultMarkersEmpty (0.00s) === RUN TestRemoveDedentedTestResultMarkersAll --- PASS: TestRemoveDedentedTestResultMarkersAll (0.00s) === RUN TestGetIndent === PAUSE TestGetIndent === RUN TestGetTestNameFromResultLine === PAUSE TestGetTestNameFromResultLine === RUN TestIsResultLine === PAUSE TestIsResultLine === RUN TestGetTestNameFromStatusLine === PAUSE TestGetTestNameFromStatusLine === RUN TestIsStatusLine === PAUSE TestIsStatusLine === RUN TestIsSummaryLine === PAUSE TestIsSummaryLine === RUN TestIsPanicLine === PAUSE TestIsPanicLine === RUN TestEnsureDirectoryExistsCreatesDirectory === PAUSE TestEnsureDirectoryExistsCreatesDirectory === RUN TestEnsureDirectoryExistsHandlesExistingDirectory === PAUSE TestEnsureDirectoryExistsHandlesExistingDirectory === RUN TestGetOrCreateChannelCreatesNewChannel === PAUSE TestGetOrCreateChannelCreatesNewChannel === RUN TestGetOrCreateChannelReturnsExistingChannel === PAUSE TestGetOrCreateChannelReturnsExistingChannel === RUN TestLogCollectorCreatesAndWritesToFile === PAUSE TestLogCollectorCreatesAndWritesToFile === RUN TestGetOrCreateChannelSpawnsLogCollectorOnCreate === PAUSE TestGetOrCreateChannelSpawnsLogCollectorOnCreate === RUN TestCloseChannelsClosesAll === PAUSE TestCloseChannelsClosesAll === CONT TestStackPush === CONT TestIsSummaryLine === CONT TestGetIndent === RUN TestIsSummaryLine/BaseCase === RUN TestGetIndent/BaseCase === CONT TestPeek --- PASS: TestStackPush (0.00s) === CONT TestIsStatusLine === RUN TestIsStatusLine/BaseCase === RUN TestIsSummaryLine/NotSummary === RUN TestIsStatusLine/Indented === RUN TestGetIndent/NoIndent --- PASS: TestIsSummaryLine (0.00s) --- PASS: TestIsSummaryLine/BaseCase (0.00s) --- PASS: TestIsSummaryLine/NotSummary (0.00s) === CONT TestGetTestNameFromStatusLine === RUN TestIsStatusLine/SpecialChars === RUN TestGetTestNameFromStatusLine/BaseCase === RUN TestGetIndent/EmptyString === RUN TestIsStatusLine/WhenPaused === RUN TestGetTestNameFromStatusLine/Indented === RUN TestIsStatusLine/WhenCont === RUN TestGetIndent/Tabs === RUN TestGetTestNameFromStatusLine/SpecialChars === CONT TestIsResultLine --- PASS: TestPeek (0.00s) === RUN TestIsResultLine/BaseCase === RUN TestIsStatusLine/NonStatusLine === RUN TestGetIndent/MixTabSpace --- PASS: TestIsStatusLine (0.00s) --- PASS: TestIsStatusLine/BaseCase (0.00s) --- PASS: TestIsStatusLine/Indented (0.00s) --- PASS: TestIsStatusLine/SpecialChars (0.00s) --- PASS: TestIsStatusLine/WhenPaused (0.00s) --- PASS: TestIsStatusLine/WhenCont (0.00s) --- PASS: TestIsStatusLine/NonStatusLine (0.00s) === CONT TestGetTestNameFromResultLine === RUN TestGetTestNameFromStatusLine/WhenPaused === RUN TestGetTestNameFromResultLine/BaseCase === RUN TestGetTestNameFromStatusLine/WhenCont --- PASS: TestGetIndent (0.00s) --- PASS: TestGetIndent/BaseCase (0.00s) --- PASS: TestGetIndent/NoIndent (0.00s) --- PASS: TestGetIndent/EmptyString (0.00s) --- PASS: TestGetIndent/Tabs (0.00s) --- PASS: TestGetIndent/MixTabSpace (0.00s) === CONT TestGetOrCreateChannelReturnsExistingChannel --- PASS: TestGetTestNameFromStatusLine (0.00s) --- PASS: TestGetTestNameFromStatusLine/BaseCase (0.00s) --- PASS: TestGetTestNameFromStatusLine/Indented (0.00s) --- PASS: TestGetTestNameFromStatusLine/SpecialChars (0.00s) --- PASS: TestGetTestNameFromStatusLine/WhenPaused (0.00s) --- PASS: TestGetTestNameFromStatusLine/WhenCont (0.00s) === CONT TestIsEmpty === RUN TestGetTestNameFromResultLine/Indented === RUN TestGetTestNameFromResultLine/SpecialChars --- PASS: TestIsEmpty (0.00s) === CONT TestPeekEmpty --- PASS: TestPeekEmpty (0.00s) === RUN TestGetTestNameFromResultLine/WhenFailed === CONT TestStackPopEmpty --- PASS: TestStackPopEmpty (0.00s) === CONT TestGetOrCreateChannelSpawnsLogCollectorOnCreate --- PASS: TestGetTestNameFromResultLine (0.00s) --- PASS: TestGetTestNameFromResultLine/BaseCase (0.00s) --- PASS: TestGetTestNameFromResultLine/Indented (0.00s) --- PASS: TestGetTestNameFromResultLine/SpecialChars (0.00s) --- PASS: TestGetTestNameFromResultLine/WhenFailed (0.00s) === CONT TestCloseChannelsClosesAll === RUN TestIsResultLine/Indented === RUN TestIsResultLine/SpecialChars === RUN TestIsResultLine/WhenFailed === RUN TestIsResultLine/NonResultLine --- PASS: TestIsResultLine (0.00s) --- PASS: TestIsResultLine/BaseCase (0.00s) --- PASS: TestIsResultLine/Indented (0.00s) --- PASS: TestIsResultLine/SpecialChars (0.00s) --- PASS: TestIsResultLine/WhenFailed (0.00s) --- PASS: TestIsResultLine/NonResultLine (0.00s) === CONT TestStackPop --- PASS: TestStackPop (0.00s) === CONT TestLogCollectorCreatesAndWritesToFile === CONT TestEnsureDirectoryExistsHandlesExistingDirectory --- PASS: TestGetOrCreateChannelReturnsExistingChannel (0.00s) TestGetOrCreateChannelSpawnsLogCollectorOnCreate INFO 2018-10-20T13:03:19-07:00 Spawned log writer for test TestGetOrCreateChannelSpawnsLogCollectorOnCreate TestCloseChannelsClosesAll INFO 2018-10-20T13:03:19-07:00 Closing all the channels in log writer TestGetOrCreateChannelSpawnsLogCollectorOnCreate INFO 2018-10-20T13:03:19-07:00 Storing logs for test TestGetOrCreateChannelSpawnsLogCollectorOnCreate to /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory724282597/TestGetOrCreateChannelSpawnsLogCollectorOnCreate.log TestEnsureDirectoryExistsHandlesExistingDirectory INFO 2018-10-20T13:03:19-07:00 Directory /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory135329330 already exists TestGetOrCreateChannelSpawnsLogCollectorOnCreate INFO 2018-10-20T13:03:19-07:00 Directory /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory724282597 already exists --- PASS: TestCloseChannelsClosesAll (0.00s) === CONT TestGetOrCreateChannelCreatesNewChannel --- PASS: TestEnsureDirectoryExistsHandlesExistingDirectory (0.00s) === CONT TestEnsureDirectoryExistsCreatesDirectory TestLogCollectorCreatesAndWritesToFile INFO 2018-10-20T13:03:19-07:00 Spawned log writer for test TestLogCollectorCreatesAndWritesToFile TestGetOrCreateChannelSpawnsLogCollectorOnCreate INFO 2018-10-20T13:03:19-07:00 Channel closed for log writer of test TestGetOrCreateChannelSpawnsLogCollectorOnCreate TestEnsureDirectoryExistsCreatesDirectory INFO 2018-10-20T13:03:19-07:00 Creating directory /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory601920052/tmpdir --- PASS: TestGetOrCreateChannelCreatesNewChannel (0.00s) === CONT TestIsPanicLine === RUN TestIsPanicLine/BaseCase === RUN TestIsPanicLine/NotPanic --- FAIL: TestIsPanicLine (0.00s) --- PASS: TestIsPanicLine/BaseCase (0.00s) --- PASS: TestIsPanicLine/NotPanic (0.00s) panic: error [recovered] panic: error goroutine 36 [running]: testing.tRunner.func1(0xc0000c5300) /usr/local/Cellar/go/1.11/libexec/src/testing/testing.go:792 +0x387 panic(0x1329720, 0x13fd400) /usr/local/Cellar/go/1.11/libexec/src/runtime/panic.go:513 +0x1b9 github.com/gruntwork-io/terratest/modules/logger/parser.TestIsPanicLine(0xc0000c5300) /Users/yoriy/go/src/github.com/gruntwork-io/terratest/modules/logger/parser/parser_test.go:306 +0x1c4 testing.tRunner(0xc0000c5300, 0x13bb160) /usr/local/Cellar/go/1.11/libexec/src/testing/testing.go:827 +0xbf created by testing.(*T).Run /usr/local/Cellar/go/1.11/libexec/src/testing/testing.go:878 +0x353 exit status 2 FAIL github.com/gruntwork-io/terratest/modules/logger/parser 0.020s ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestCloseChannelsClosesAll.log ================================================ === RUN TestCloseChannelsClosesAll === PAUSE TestCloseChannelsClosesAll === CONT TestCloseChannelsClosesAll TestCloseChannelsClosesAll INFO 2018-10-20T13:03:19-07:00 Closing all the channels in log writer --- PASS: TestCloseChannelsClosesAll (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestEnsureDirectoryExistsCreatesDirectory.log ================================================ === RUN TestEnsureDirectoryExistsCreatesDirectory === PAUSE TestEnsureDirectoryExistsCreatesDirectory === CONT TestEnsureDirectoryExistsCreatesDirectory TestEnsureDirectoryExistsCreatesDirectory INFO 2018-10-20T13:03:19-07:00 Creating directory /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory601920052/tmpdir ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestEnsureDirectoryExistsHandlesExistingDirectory.log ================================================ === RUN TestEnsureDirectoryExistsHandlesExistingDirectory === PAUSE TestEnsureDirectoryExistsHandlesExistingDirectory === CONT TestEnsureDirectoryExistsHandlesExistingDirectory TestEnsureDirectoryExistsHandlesExistingDirectory INFO 2018-10-20T13:03:19-07:00 Directory /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory135329330 already exists --- PASS: TestEnsureDirectoryExistsHandlesExistingDirectory (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestGetIndent/BaseCase.log ================================================ === RUN TestGetIndent/BaseCase --- PASS: TestGetIndent/BaseCase (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestGetIndent/EmptyString.log ================================================ === RUN TestGetIndent/EmptyString --- PASS: TestGetIndent/EmptyString (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestGetIndent/MixTabSpace.log ================================================ === RUN TestGetIndent/MixTabSpace --- PASS: TestGetIndent/MixTabSpace (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestGetIndent/NoIndent.log ================================================ === RUN TestGetIndent/NoIndent --- PASS: TestGetIndent/NoIndent (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestGetIndent/Tabs.log ================================================ === RUN TestGetIndent/Tabs --- PASS: TestGetIndent/Tabs (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestGetIndent.log ================================================ === RUN TestGetIndent === PAUSE TestGetIndent === CONT TestGetIndent --- PASS: TestGetIndent (0.00s) --- PASS: TestGetIndent/BaseCase (0.00s) --- PASS: TestGetIndent/NoIndent (0.00s) --- PASS: TestGetIndent/EmptyString (0.00s) --- PASS: TestGetIndent/Tabs (0.00s) --- PASS: TestGetIndent/MixTabSpace (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestGetOrCreateChannelCreatesNewChannel.log ================================================ === RUN TestGetOrCreateChannelCreatesNewChannel === PAUSE TestGetOrCreateChannelCreatesNewChannel === CONT TestGetOrCreateChannelCreatesNewChannel --- PASS: TestGetOrCreateChannelCreatesNewChannel (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestGetOrCreateChannelReturnsExistingChannel.log ================================================ === RUN TestGetOrCreateChannelReturnsExistingChannel === PAUSE TestGetOrCreateChannelReturnsExistingChannel === CONT TestGetOrCreateChannelReturnsExistingChannel --- PASS: TestGetOrCreateChannelReturnsExistingChannel (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestGetOrCreateChannelSpawnsLogCollectorOnCreate.log ================================================ === RUN TestGetOrCreateChannelSpawnsLogCollectorOnCreate === PAUSE TestGetOrCreateChannelSpawnsLogCollectorOnCreate === CONT TestGetOrCreateChannelSpawnsLogCollectorOnCreate TestGetOrCreateChannelSpawnsLogCollectorOnCreate INFO 2018-10-20T13:03:19-07:00 Spawned log writer for test TestGetOrCreateChannelSpawnsLogCollectorOnCreate TestGetOrCreateChannelSpawnsLogCollectorOnCreate INFO 2018-10-20T13:03:19-07:00 Storing logs for test TestGetOrCreateChannelSpawnsLogCollectorOnCreate to /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory724282597/TestGetOrCreateChannelSpawnsLogCollectorOnCreate.log TestGetOrCreateChannelSpawnsLogCollectorOnCreate INFO 2018-10-20T13:03:19-07:00 Directory /var/folders/n2/pljz6dq52bd1ksmw23qyr3sr0000gn/T/TestEnsureDirectoryCreatesDirectory724282597 already exists TestGetOrCreateChannelSpawnsLogCollectorOnCreate INFO 2018-10-20T13:03:19-07:00 Channel closed for log writer of test TestGetOrCreateChannelSpawnsLogCollectorOnCreate ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestGetTestNameFromResultLine/BaseCase.log ================================================ === RUN TestGetTestNameFromResultLine/BaseCase --- PASS: TestGetTestNameFromResultLine/BaseCase (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestGetTestNameFromResultLine/Indented.log ================================================ === RUN TestGetTestNameFromResultLine/Indented --- PASS: TestGetTestNameFromResultLine/Indented (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestGetTestNameFromResultLine/SpecialChars.log ================================================ === RUN TestGetTestNameFromResultLine/SpecialChars --- PASS: TestGetTestNameFromResultLine/SpecialChars (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestGetTestNameFromResultLine/WhenFailed.log ================================================ === RUN TestGetTestNameFromResultLine/WhenFailed --- PASS: TestGetTestNameFromResultLine/WhenFailed (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestGetTestNameFromResultLine.log ================================================ === RUN TestGetTestNameFromResultLine === PAUSE TestGetTestNameFromResultLine === CONT TestGetTestNameFromResultLine --- PASS: TestGetTestNameFromResultLine (0.00s) --- PASS: TestGetTestNameFromResultLine/BaseCase (0.00s) --- PASS: TestGetTestNameFromResultLine/Indented (0.00s) --- PASS: TestGetTestNameFromResultLine/SpecialChars (0.00s) --- PASS: TestGetTestNameFromResultLine/WhenFailed (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestGetTestNameFromStatusLine/BaseCase.log ================================================ === RUN TestGetTestNameFromStatusLine/BaseCase --- PASS: TestGetTestNameFromStatusLine/BaseCase (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestGetTestNameFromStatusLine/Indented.log ================================================ === RUN TestGetTestNameFromStatusLine/Indented --- PASS: TestGetTestNameFromStatusLine/Indented (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestGetTestNameFromStatusLine/SpecialChars.log ================================================ === RUN TestGetTestNameFromStatusLine/SpecialChars --- PASS: TestGetTestNameFromStatusLine/SpecialChars (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestGetTestNameFromStatusLine/WhenCont.log ================================================ === RUN TestGetTestNameFromStatusLine/WhenCont --- PASS: TestGetTestNameFromStatusLine/WhenCont (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestGetTestNameFromStatusLine/WhenPaused.log ================================================ === RUN TestGetTestNameFromStatusLine/WhenPaused --- PASS: TestGetTestNameFromStatusLine/WhenPaused (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestGetTestNameFromStatusLine.log ================================================ === RUN TestGetTestNameFromStatusLine === PAUSE TestGetTestNameFromStatusLine === CONT TestGetTestNameFromStatusLine --- PASS: TestGetTestNameFromStatusLine (0.00s) --- PASS: TestGetTestNameFromStatusLine/BaseCase (0.00s) --- PASS: TestGetTestNameFromStatusLine/Indented (0.00s) --- PASS: TestGetTestNameFromStatusLine/SpecialChars (0.00s) --- PASS: TestGetTestNameFromStatusLine/WhenPaused (0.00s) --- PASS: TestGetTestNameFromStatusLine/WhenCont (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestIsEmpty.log ================================================ === RUN TestIsEmpty === PAUSE TestIsEmpty === CONT TestIsEmpty --- PASS: TestIsEmpty (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestIsPanicLine/BaseCase.log ================================================ === RUN TestIsPanicLine/BaseCase --- PASS: TestIsPanicLine/BaseCase (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestIsPanicLine/NotPanic.log ================================================ === RUN TestIsPanicLine/NotPanic --- PASS: TestIsPanicLine/NotPanic (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestIsPanicLine.log ================================================ === RUN TestIsPanicLine === PAUSE TestIsPanicLine === CONT TestIsPanicLine --- FAIL: TestIsPanicLine (0.00s) --- PASS: TestIsPanicLine/BaseCase (0.00s) --- PASS: TestIsPanicLine/NotPanic (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestIsResultLine/BaseCase.log ================================================ === RUN TestIsResultLine/BaseCase --- PASS: TestIsResultLine/BaseCase (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestIsResultLine/Indented.log ================================================ === RUN TestIsResultLine/Indented --- PASS: TestIsResultLine/Indented (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestIsResultLine/NonResultLine.log ================================================ === RUN TestIsResultLine/NonResultLine --- PASS: TestIsResultLine/NonResultLine (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestIsResultLine/SpecialChars.log ================================================ === RUN TestIsResultLine/SpecialChars --- PASS: TestIsResultLine/SpecialChars (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestIsResultLine/WhenFailed.log ================================================ === RUN TestIsResultLine/WhenFailed --- PASS: TestIsResultLine/WhenFailed (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestIsResultLine.log ================================================ === RUN TestIsResultLine === PAUSE TestIsResultLine === CONT TestIsResultLine --- PASS: TestIsResultLine (0.00s) --- PASS: TestIsResultLine/BaseCase (0.00s) --- PASS: TestIsResultLine/Indented (0.00s) --- PASS: TestIsResultLine/SpecialChars (0.00s) --- PASS: TestIsResultLine/WhenFailed (0.00s) --- PASS: TestIsResultLine/NonResultLine (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestIsStatusLine/BaseCase.log ================================================ === RUN TestIsStatusLine/BaseCase --- PASS: TestIsStatusLine/BaseCase (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestIsStatusLine/Indented.log ================================================ === RUN TestIsStatusLine/Indented --- PASS: TestIsStatusLine/Indented (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestIsStatusLine/NonStatusLine.log ================================================ === RUN TestIsStatusLine/NonStatusLine --- PASS: TestIsStatusLine/NonStatusLine (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestIsStatusLine/SpecialChars.log ================================================ === RUN TestIsStatusLine/SpecialChars --- PASS: TestIsStatusLine/SpecialChars (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestIsStatusLine/WhenCont.log ================================================ === RUN TestIsStatusLine/WhenCont --- PASS: TestIsStatusLine/WhenCont (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestIsStatusLine/WhenPaused.log ================================================ === RUN TestIsStatusLine/WhenPaused --- PASS: TestIsStatusLine/WhenPaused (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestIsStatusLine.log ================================================ === RUN TestIsStatusLine === PAUSE TestIsStatusLine === CONT TestIsStatusLine --- PASS: TestIsStatusLine (0.00s) --- PASS: TestIsStatusLine/BaseCase (0.00s) --- PASS: TestIsStatusLine/Indented (0.00s) --- PASS: TestIsStatusLine/SpecialChars (0.00s) --- PASS: TestIsStatusLine/WhenPaused (0.00s) --- PASS: TestIsStatusLine/WhenCont (0.00s) --- PASS: TestIsStatusLine/NonStatusLine (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestIsSummaryLine/BaseCase.log ================================================ === RUN TestIsSummaryLine/BaseCase --- PASS: TestIsSummaryLine/BaseCase (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestIsSummaryLine/NotSummary.log ================================================ === RUN TestIsSummaryLine/NotSummary --- PASS: TestIsSummaryLine/NotSummary (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestIsSummaryLine.log ================================================ === RUN TestIsSummaryLine === PAUSE TestIsSummaryLine === CONT TestIsSummaryLine --- PASS: TestIsSummaryLine (0.00s) --- PASS: TestIsSummaryLine/BaseCase (0.00s) --- PASS: TestIsSummaryLine/NotSummary (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestLogCollectorCreatesAndWritesToFile.log ================================================ === RUN TestLogCollectorCreatesAndWritesToFile === PAUSE TestLogCollectorCreatesAndWritesToFile === CONT TestLogCollectorCreatesAndWritesToFile TestLogCollectorCreatesAndWritesToFile INFO 2018-10-20T13:03:19-07:00 Spawned log writer for test TestLogCollectorCreatesAndWritesToFile ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestPeek.log ================================================ === RUN TestPeek === PAUSE TestPeek === CONT TestPeek --- PASS: TestPeek (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestPeekEmpty.log ================================================ === RUN TestPeekEmpty === PAUSE TestPeekEmpty === CONT TestPeekEmpty --- PASS: TestPeekEmpty (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestRemoveDedentedTestResultMarkers.log ================================================ === RUN TestRemoveDedentedTestResultMarkers --- PASS: TestRemoveDedentedTestResultMarkers (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestRemoveDedentedTestResultMarkersAll.log ================================================ === RUN TestRemoveDedentedTestResultMarkersAll --- PASS: TestRemoveDedentedTestResultMarkersAll (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestRemoveDedentedTestResultMarkersEmpty.log ================================================ === RUN TestRemoveDedentedTestResultMarkersEmpty --- PASS: TestRemoveDedentedTestResultMarkersEmpty (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestStackPop.log ================================================ === RUN TestStackPop === PAUSE TestStackPop === CONT TestStackPop --- PASS: TestStackPop (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestStackPopEmpty.log ================================================ === RUN TestStackPopEmpty === PAUSE TestStackPopEmpty === CONT TestStackPopEmpty --- PASS: TestStackPopEmpty (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/TestStackPush.log ================================================ === RUN TestStackPush === PAUSE TestStackPush === CONT TestStackPush --- PASS: TestStackPush (0.00s) ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/report.xml ================================================ ================================================ FILE: modules/logger/parser/fixtures/panic_example_expected/summary.log ================================================ --- PASS: TestRemoveDedentedTestResultMarkers (0.00s) --- PASS: TestRemoveDedentedTestResultMarkersEmpty (0.00s) --- PASS: TestRemoveDedentedTestResultMarkersAll (0.00s) --- PASS: TestStackPush (0.00s) --- PASS: TestIsSummaryLine (0.00s) --- PASS: TestIsSummaryLine/BaseCase (0.00s) --- PASS: TestIsSummaryLine/NotSummary (0.00s) --- PASS: TestPeek (0.00s) --- PASS: TestIsStatusLine (0.00s) --- PASS: TestIsStatusLine/BaseCase (0.00s) --- PASS: TestIsStatusLine/Indented (0.00s) --- PASS: TestIsStatusLine/SpecialChars (0.00s) --- PASS: TestIsStatusLine/WhenPaused (0.00s) --- PASS: TestIsStatusLine/WhenCont (0.00s) --- PASS: TestIsStatusLine/NonStatusLine (0.00s) --- PASS: TestGetIndent (0.00s) --- PASS: TestGetIndent/BaseCase (0.00s) --- PASS: TestGetIndent/NoIndent (0.00s) --- PASS: TestGetIndent/EmptyString (0.00s) --- PASS: TestGetIndent/Tabs (0.00s) --- PASS: TestGetIndent/MixTabSpace (0.00s) --- PASS: TestGetTestNameFromStatusLine (0.00s) --- PASS: TestGetTestNameFromStatusLine/BaseCase (0.00s) --- PASS: TestGetTestNameFromStatusLine/Indented (0.00s) --- PASS: TestGetTestNameFromStatusLine/SpecialChars (0.00s) --- PASS: TestGetTestNameFromStatusLine/WhenPaused (0.00s) --- PASS: TestGetTestNameFromStatusLine/WhenCont (0.00s) --- PASS: TestIsEmpty (0.00s) --- PASS: TestPeekEmpty (0.00s) --- PASS: TestStackPopEmpty (0.00s) --- PASS: TestGetTestNameFromResultLine (0.00s) --- PASS: TestGetTestNameFromResultLine/BaseCase (0.00s) --- PASS: TestGetTestNameFromResultLine/Indented (0.00s) --- PASS: TestGetTestNameFromResultLine/SpecialChars (0.00s) --- PASS: TestGetTestNameFromResultLine/WhenFailed (0.00s) --- PASS: TestIsResultLine (0.00s) --- PASS: TestIsResultLine/BaseCase (0.00s) --- PASS: TestIsResultLine/Indented (0.00s) --- PASS: TestIsResultLine/SpecialChars (0.00s) --- PASS: TestIsResultLine/WhenFailed (0.00s) --- PASS: TestIsResultLine/NonResultLine (0.00s) --- PASS: TestStackPop (0.00s) --- PASS: TestGetOrCreateChannelReturnsExistingChannel (0.00s) --- PASS: TestCloseChannelsClosesAll (0.00s) --- PASS: TestEnsureDirectoryExistsHandlesExistingDirectory (0.00s) --- PASS: TestGetOrCreateChannelCreatesNewChannel (0.00s) --- FAIL: TestIsPanicLine (0.00s) --- PASS: TestIsPanicLine/BaseCase (0.00s) --- PASS: TestIsPanicLine/NotPanic (0.00s) panic: error [recovered] panic: error goroutine 36 [running]: testing.tRunner.func1(0xc0000c5300) /usr/local/Cellar/go/1.11/libexec/src/testing/testing.go:792 +0x387 panic(0x1329720, 0x13fd400) /usr/local/Cellar/go/1.11/libexec/src/runtime/panic.go:513 +0x1b9 github.com/gruntwork-io/terratest/modules/logger/parser.TestIsPanicLine(0xc0000c5300) /Users/yoriy/go/src/github.com/gruntwork-io/terratest/modules/logger/parser/parser_test.go:306 +0x1c4 testing.tRunner(0xc0000c5300, 0x13bb160) /usr/local/Cellar/go/1.11/libexec/src/testing/testing.go:827 +0xbf created by testing.(*T).Run /usr/local/Cellar/go/1.11/libexec/src/testing/testing.go:878 +0x353 exit status 2 FAIL github.com/gruntwork-io/terratest/modules/logger/parser 0.020s ================================================ FILE: modules/logger/parser/helpers_for_test.go ================================================ package parser import ( "bytes" "fmt" "strings" "testing" "time" "github.com/sirupsen/logrus" ) func NewTestLogger(t *testing.T) *logrus.Logger { logger := logrus.New() logger.SetFormatter(&LogTestFormatter{TestName: t.Name()}) return logger } type LogTestFormatter struct { TestName string } func (formatter *LogTestFormatter) Format(entry *logrus.Entry) ([]byte, error) { b := bytes.Buffer{} outStr := fmt.Sprintf( "%s %s %s %s\n", formatter.TestName, strings.ToUpper(entry.Level.String()), entry.Time.Format(time.RFC3339), entry.Message, ) b.WriteString(outStr) return b.Bytes(), nil } ================================================ FILE: modules/logger/parser/integration_test.go ================================================ package parser import ( "bytes" "fmt" "os" "path" "path/filepath" "runtime" "testing" "github.com/gruntwork-io/terratest/modules/files" "github.com/gruntwork-io/terratest/modules/shell" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func DirectoryEqual(t *testing.T, dirA string, dirB string) bool { dirAAbs, err := filepath.Abs(dirA) if err != nil { t.Fatal(err) } dirBAbs, err := filepath.Abs(dirB) if err != nil { t.Fatal(err) } // We use diff here instead of using something in go for simplicity of comparing directories and file contents // recursively cmd := shell.Command{ Command: "diff", Args: []string{"-ar", dirAAbs, dirBAbs}, } err = shell.RunCommandE(t, cmd) exitCode, err := shell.GetExitCodeForRunCommandError(err) if err != nil { t.Fatal(err) } return exitCode == 0 } func openFile(t *testing.T, filename string) *os.File { file, err := os.Open(filename) if err != nil { t.Fatalf("Error opening file: %s", err) } return file } func testExample(t *testing.T, example string) { expected, output := path.Join(t.TempDir(), "expected"), path.Join(t.TempDir(), "output") require.NoError(t, os.Mkdir(expected, 0755)) require.NoError(t, os.Mkdir(output, 0755)) // prepare expected directory to diff against expectedOutputDirName := fmt.Sprintf("./fixtures/%s_example_expected", example) require.NoError(t, files.CopyFolderContents(expectedOutputDirName, expected)) b, err := os.ReadFile(path.Join(expected, "report.xml")) require.NoError(t, err) b = bytes.ReplaceAll(b, []byte("go1.21.1"), []byte(runtime.Version())) // replace the harcoded go version of the fixture require.NoError(t, os.WriteFile(path.Join(expected, "report.xml"), b, 644)) // run the parser logger := NewTestLogger(t) logFileName := fmt.Sprintf("./fixtures/%s_example.log", example) file := openFile(t, logFileName) SpawnParsers(logger, file, output) // assert assert.True(t, DirectoryEqual(t, expected, output)) } func TestIntegrationBasicExample(t *testing.T) { t.Parallel() testExample(t, "basic") } func TestIntegrationFailingExample(t *testing.T) { t.Parallel() testExample(t, "failing") } func TestIntegrationPanicExample(t *testing.T) { t.Parallel() testExample(t, "panic") } func TestIntegrationNewGoExample(t *testing.T) { t.Parallel() testExample(t, "new_go_failing") } ================================================ FILE: modules/logger/parser/parser.go ================================================ // Package logger/parser contains methods to parse and restructure log output from go testing and terratest package parser import ( "bufio" "io" "os" "regexp" "strings" "sync" junitparser "github.com/jstemmer/go-junit-report/parser" "github.com/sirupsen/logrus" ) // SpawnParsers will spawn the log parser and junit report parsers off of a single reader. func SpawnParsers(logger *logrus.Logger, reader io.Reader, outputDir string) { forkedReader, forkedWriter := io.Pipe() teedReader := io.TeeReader(reader, forkedWriter) var waitForParsers sync.WaitGroup waitForParsers.Add(2) go func() { // close pipe writer, because this section drains the tee reader indicating reader is done draining defer forkedWriter.Close() defer waitForParsers.Done() parseAndStoreTestOutput(logger, teedReader, outputDir) }() go func() { defer waitForParsers.Done() report, err := junitparser.Parse(forkedReader, "") if err == nil { storeJunitReport(logger, outputDir, report) } else { logger.Errorf("Error parsing test output into junit report: %s", err) } }() waitForParsers.Wait() } // RegEx for parsing test status lines. Pulled from jstemmer/go-junit-report var ( regexResult = regexp.MustCompile(`--- (PASS|FAIL|SKIP): (.+) \((\d+\.\d+)(?: ?seconds|s)\)`) regexStatus = regexp.MustCompile(`=== (RUN|PAUSE|CONT)\s+(.+)`) regexSummary = regexp.MustCompile(`(^FAIL$)|(^(ok|FAIL)\s+([^ ]+)\s+(?:(\d+\.\d+)s|\(cached\)|(\[\w+ failed]))(?:\s+coverage:\s+(\d+\.\d+)%\sof\sstatements(?:\sin\s.+)?)?$)`) regexPanic = regexp.MustCompile(`^panic:`) ) // getIndent takes a line and returns the indent string // Example: // // in: " --- FAIL: TestSnafu" // out: " " func getIndent(data string) string { re := regexp.MustCompile(`^\s+`) indent := re.FindString(data) return indent } // getTestNameFromResultLine takes a go testing result line and extracts out the test name // Example: // // in: --- FAIL: TestSnafu // out: TestSnafu func getTestNameFromResultLine(text string) string { m := regexResult.FindStringSubmatch(text) return m[2] } // isResultLine checks if a line of text matches a test result (begins with "--- FAIL" or "--- PASS") func isResultLine(text string) bool { return regexResult.MatchString(text) } // getTestNameFromStatusLine takes a go testing status line and extracts out the test name // Example: // // in: === RUN TestSnafu // out: TestSnafu func getTestNameFromStatusLine(text string) string { m := regexStatus.FindStringSubmatch(text) return m[2] } // isStatusLine checks if a line of text matches a test status func isStatusLine(text string) bool { return regexStatus.MatchString(text) } // isSummaryLine checks if a line of text matches the test summary func isSummaryLine(text string) bool { return regexSummary.MatchString(text) } // isPanicLine checks if a line of text matches a panic func isPanicLine(text string) bool { return regexPanic.MatchString(text) } // parseAndStoreTestOutput will take test log entries from terratest and aggregate the output by test. Takes advantage // of the fact that terratest logs are prefixed by the test name. This will store the broken out logs into files under // the outputDir, named by test name. // Additionally will take test result lines and collect them under a summary log file named `summary.log`. // See the `fixtures` directory for some examples. func parseAndStoreTestOutput( logger *logrus.Logger, read io.Reader, outputDir string, ) { logWriter := LogWriter{ lookup: make(map[string]*os.File), outputDir: outputDir, } defer logWriter.closeFiles(logger) // Track some state that persists across lines testResultMarkers := TestResultMarkerStack{} previousTestName := "" var err error reader := bufio.NewReader(read) for { var data string data, err = reader.ReadString('\n') if len(data) == 0 && err == io.EOF { break } data = strings.TrimSuffix(data, "\n") // separate block so that we do not overwrite the err variable that we need afterwards to check if we're done { indentLevel := len(getIndent(data)) isIndented := indentLevel > 0 // Garbage collection of test result markers. Primary purpose is to detect when we dedent out, which can only be // detected when we reach a dedented line. testResultMarkers = testResultMarkers.removeDedentedTestResultMarkers(indentLevel) // Handle each possible category of test lines switch { case isSummaryLine(data): logWriter.writeLog(logger, "summary", data) case isStatusLine(data): testName := getTestNameFromStatusLine(data) previousTestName = testName logWriter.writeLog(logger, testName, data) case strings.HasPrefix(data, "Test"): // Heuristic: `go test` will only execute test functions named `Test.*`, so we assume any line prefixed // with `Test` is a test output for a named test. Also assume that test output will be space delimeted and // test names can't contain spaces (because they are function names). // This must be modified when `logger.DoLog` changes. vals := strings.Split(data, " ") testName := vals[0] previousTestName = testName logWriter.writeLog(logger, testName, data) case isIndented && isResultLine(data): // In a nested test result block, so collect the line into all the test results we have seen so far. for _, marker := range testResultMarkers { logWriter.writeLog(logger, marker.TestName, data) } case isPanicLine(data): // When panic, we want all subsequent nonstandard test lines to roll up to the summary previousTestName = "summary" logWriter.writeLog(logger, "summary", data) case isResultLine(data): // We ignore result lines, because that is handled specially below. case previousTestName != "": // Base case: roll up to the previous test line, if it exists. // Handles case where terratest log has entries with newlines in them. logWriter.writeLog(logger, previousTestName, data) default: logger.Warnf("Found test line that does not match known cases: %s", data) } // This has to happen separately from main if block to handle the special case of nested tests (e.g table driven // tests). For those result lines, we want it to roll up to the parent test, so we need to run the handler in // the `isIndented` section. But for both root and indented result lines, we want to execute the following code, // hence this special block. if isResultLine(data) { testName := getTestNameFromResultLine(data) logWriter.writeLog(logger, testName, data) logWriter.writeLog(logger, "summary", data) marker := TestResultMarker{ TestName: testName, IndentLevel: indentLevel, } testResultMarkers = testResultMarkers.push(marker) } } if err != nil { break } } if err != io.EOF { logger.Fatalf("Error reading from Reader: %s", err) } } ================================================ FILE: modules/logger/parser/parser_test.go ================================================ package parser import ( "testing" "github.com/stretchr/testify/assert" ) func TestGetIndent(t *testing.T) { t.Parallel() testCases := []struct { name string in string out string }{ { "BaseCase", " --- FAIL: TestSnafu", " ", }, { "NoIndent", "--- FAIL: TestSnafu", "", }, { "EmptyString", "", "", }, { "Tabs", "\t\t---FAIL: TestSnafu", "\t\t", }, { "MixTabSpace", "\t ---FAIL: TestSnafu", "\t ", }, } for _, testCase := range testCases { testCase := testCase t.Run(testCase.name, func(t *testing.T) { assert.Equal( t, getIndent(testCase.in), testCase.out, ) }) } } func TestGetTestNameFromResultLine(t *testing.T) { t.Parallel() testCases := []struct { name string in string out string }{ { "BaseCase", "--- PASS: TestGetTestNameFromResultLine (0.00s)", "TestGetTestNameFromResultLine", }, { "Indented", " --- PASS: TestGetTestNameFromResultLine/Indented (0.00s)", "TestGetTestNameFromResultLine/Indented", }, { "SpecialChars", " --- PASS: TestGetTestNameFromResultLine/SpecialChars---_FAIL (0.00s)", "TestGetTestNameFromResultLine/SpecialChars---_FAIL", }, { "WhenFailed", "--- FAIL: TestGetTestNameFromResultLine (0.00s)", "TestGetTestNameFromResultLine", }, } for _, testCase := range testCases { testCase := testCase t.Run(testCase.name, func(t *testing.T) { assert.Equal( t, getTestNameFromResultLine(testCase.in), testCase.out, ) }) } } func TestIsResultLine(t *testing.T) { t.Parallel() testCases := []struct { name string in string out bool }{ { "BaseCase", "--- PASS: TestIsResultLine (0.00s)", true, }, { "Indented", " --- PASS: TestIsResultLine/Indented (0.00s)", true, }, { "SpecialChars", " --- PASS: TestIsResultLine/SpecialChars---_FAIL (0.00s)", true, }, { "WhenFailed", "--- FAIL: TestIsResultLine (0.00s)", true, }, { "NonResultLine", "=== RUN TestIsResultLine", false, }, } for _, testCase := range testCases { testCase := testCase t.Run(testCase.name, func(t *testing.T) { assert.Equal( t, isResultLine(testCase.in), testCase.out, ) }) } } func TestGetTestNameFromStatusLine(t *testing.T) { t.Parallel() testCases := []struct { name string in string out string }{ { "BaseCase", "=== RUN TestGetTestNameFromStatusLine", "TestGetTestNameFromStatusLine", }, { "Indented", " === RUN TestGetTestNameFromStatusLine/Indented", "TestGetTestNameFromStatusLine/Indented", }, { "SpecialChars", "=== RUN TestGetTestNameFromStatusLine/SpecialChars---_FAIL", "TestGetTestNameFromStatusLine/SpecialChars---_FAIL", }, { "WhenPaused", "=== PAUSE TestGetTestNameFromStatusLine", "TestGetTestNameFromStatusLine", }, { "WhenCont", "=== CONT TestGetTestNameFromStatusLine", "TestGetTestNameFromStatusLine", }, } for _, testCase := range testCases { testCase := testCase t.Run(testCase.name, func(t *testing.T) { assert.Equal( t, getTestNameFromStatusLine(testCase.in), testCase.out, ) }) } } func TestIsStatusLine(t *testing.T) { t.Parallel() testCases := []struct { name string in string out bool }{ { "BaseCase", "=== RUN TestGetTestNameFromStatusLine", true, }, { "Indented", " === RUN TestGetTestNameFromStatusLine/Indented", true, }, { "SpecialChars", "=== RUN TestGetTestNameFromStatusLine/SpecialChars---_FAIL", true, }, { "WhenPaused", "=== PAUSE TestGetTestNameFromStatusLine", true, }, { "WhenCont", "=== CONT TestGetTestNameFromStatusLine", true, }, { "NonStatusLine", "--- FAIL: TestIsStatusLine", false, }, } for _, testCase := range testCases { testCase := testCase t.Run(testCase.name, func(t *testing.T) { assert.Equal( t, isStatusLine(testCase.in), testCase.out, ) }) } } func TestIsSummaryLine(t *testing.T) { t.Parallel() testCases := []struct { name string in string out bool }{ { "BaseCase", "ok github.com/gruntwork-io/terratest/test 812.034s", true, }, { "NotSummary", "--- FAIL: TestIsStatusLine", false, }, } for _, testCase := range testCases { testCase := testCase t.Run(testCase.name, func(t *testing.T) { assert.Equal( t, isSummaryLine(testCase.in), testCase.out, ) }) } } func TestIsPanicLine(t *testing.T) { t.Parallel() testCases := []struct { name string in string out bool }{ { "BaseCase", "panic: error [recovered]", true, }, { "NotPanic", "--- FAIL: TestIsStatusLine", false, }, } for _, testCase := range testCases { testCase := testCase t.Run(testCase.name, func(t *testing.T) { assert.Equal( t, isPanicLine(testCase.in), testCase.out, ) }) } } ================================================ FILE: modules/logger/parser/store.go ================================================ // Package logger/parser contains methods to parse and restructure log output from go testing and terratest package parser import ( "os" "path/filepath" "github.com/gruntwork-io/go-commons/errors" "github.com/gruntwork-io/go-commons/files" junitformatter "github.com/jstemmer/go-junit-report/formatter" junitparser "github.com/jstemmer/go-junit-report/parser" "github.com/sirupsen/logrus" ) type LogWriter struct { // Represents an open file to a log corresponding to a test (key = test name) lookup map[string]*os.File outputDir string } // LogWriter.getOrCreateFile will get the corresponding file to a log for the provided test name, or create a new file. func (logWriter LogWriter) getOrCreateFile(logger *logrus.Logger, testName string) (*os.File, error) { file, hasKey := logWriter.lookup[testName] if hasKey { return file, nil } filename := filepath.Join(logWriter.outputDir, testName+".log") file, err := createLogFile(logger, filename) if err != nil { return nil, errors.WithStackTrace(err) } logWriter.lookup[testName] = file return file, nil } // LogWriter.closeChannels closes all the channels in the lookup dictionary func (logWriter LogWriter) closeFiles(logger *logrus.Logger) { logger.Infof("Closing all the files in log writer") for testName, file := range logWriter.lookup { err := file.Close() if err != nil { logger.Errorf("Error closing log file for test %s: %s", testName, err) } } } // writeLog will write the provided text to the corresponding log file for the provided test. func (logWriter LogWriter) writeLog(logger *logrus.Logger, testName string, text string) error { file, err := logWriter.getOrCreateFile(logger, testName) if err != nil { logger.Errorf("Error retrieving log for test: %s", testName) return errors.WithStackTrace(err) } _, err = file.WriteString(text + "\n") if err != nil { logger.Errorf("Error (%s) writing log entry: %s", err, text) return errors.WithStackTrace(err) } file.Sync() return nil } // createLogFile will create and return the open file handle for the file at provided filename, creating all directories // in the process. func createLogFile(logger *logrus.Logger, filename string) (*os.File, error) { // We extract and create the directory for interpolated filename, to handle nested tests where testname contains '/' dirName := filepath.Dir(filename) err := ensureDirectoryExists(logger, dirName) if err != nil { return nil, errors.WithStackTrace(err) } file, err := os.Create(filename) if err != nil { return nil, errors.WithStackTrace(err) } return file, nil } // ensureDirectoryExists will only attempt to create the directory if it does not exist func ensureDirectoryExists(logger *logrus.Logger, dirName string) error { if files.IsDir(dirName) { logger.Infof("Directory %s already exists", dirName) return nil } logger.Infof("Creating directory %s", dirName) err := os.MkdirAll(dirName, os.ModePerm) if err != nil { logger.Errorf("Error making directory %s: %s", dirName, err) return errors.WithStackTrace(err) } return nil } // storeJunitReport takes a parsed Junit report and stores it as report.xml in the output directory func storeJunitReport(logger *logrus.Logger, outputDir string, report *junitparser.Report) { ensureDirectoryExists(logger, outputDir) filename := filepath.Join(outputDir, "report.xml") f, err := os.Create(filename) if err != nil { logger.Errorf("Error making file %s for junit report", filename) return } defer f.Close() err = junitformatter.JUnitReportXML(report, false, "", f) if err != nil { logger.Errorf("Error formatting junit xml report: %s", err) return } } ================================================ FILE: modules/logger/parser/store_test.go ================================================ package parser import ( "os" "path/filepath" "testing" "github.com/gruntwork-io/go-commons/files" "github.com/gruntwork-io/terratest/modules/random" "github.com/stretchr/testify/assert" ) func createLogWriter(t *testing.T) LogWriter { logWriter := LogWriter{ lookup: make(map[string]*os.File), outputDir: t.TempDir(), } return logWriter } func TestEnsureDirectoryExistsCreatesDirectory(t *testing.T) { t.Parallel() dir := t.TempDir() logger := NewTestLogger(t) tmpd := filepath.Join(dir, "tmpdir") assert.False(t, files.IsDir(tmpd)) ensureDirectoryExists(logger, tmpd) assert.True(t, files.IsDir(tmpd)) } func TestEnsureDirectoryExistsHandlesExistingDirectory(t *testing.T) { t.Parallel() dir := t.TempDir() logger := NewTestLogger(t) assert.True(t, files.IsDir(dir)) ensureDirectoryExists(logger, dir) assert.True(t, files.IsDir(dir)) } func TestGetOrCreateFileCreatesNewFile(t *testing.T) { t.Parallel() logWriter := createLogWriter(t) logger := NewTestLogger(t) testFileName := filepath.Join(logWriter.outputDir, t.Name()+".log") assert.False(t, files.FileExists(testFileName)) file, err := logWriter.getOrCreateFile(logger, t.Name()) defer file.Close() assert.Nil(t, err) assert.NotNil(t, file) assert.True(t, files.FileExists(testFileName)) } func TestGetOrCreateFileCreatesNewFileIfTestNameHasDir(t *testing.T) { t.Parallel() logWriter := createLogWriter(t) logger := NewTestLogger(t) dirName := filepath.Join(logWriter.outputDir, "TestMain") testFileName := filepath.Join(dirName, t.Name()+".log") assert.False(t, files.IsDir(dirName)) assert.False(t, files.FileExists(testFileName)) file, err := logWriter.getOrCreateFile(logger, filepath.Join("TestMain", t.Name())) defer file.Close() assert.Nil(t, err) assert.NotNil(t, file) assert.True(t, files.IsDir(dirName)) assert.True(t, files.FileExists(testFileName)) } func TestGetOrCreateChannelReturnsExistingFileHandle(t *testing.T) { t.Parallel() logWriter := createLogWriter(t) testName := t.Name() logger := NewTestLogger(t) testFileName := filepath.Join(logWriter.outputDir, t.Name()) file, err := os.Create(testFileName) if err != nil { t.Fatalf("error creating test file %s", testFileName) } defer file.Close() logWriter.lookup[testName] = file lookupFile, err := logWriter.getOrCreateFile(logger, testName) assert.Nil(t, err) assert.Equal(t, lookupFile, file) } func TestCloseFilesClosesAll(t *testing.T) { t.Parallel() logWriter := createLogWriter(t) logger := NewTestLogger(t) testName := t.Name() testFileName := filepath.Join(logWriter.outputDir, testName) testFile, err := os.Create(testFileName) if err != nil { t.Fatalf("error creating test file %s", testFileName) } alternativeTestName := t.Name() + "Alternative" alternativeTestFileName := filepath.Join(logWriter.outputDir, alternativeTestName) alternativeTestFile, err := os.Create(alternativeTestFileName) if err != nil { t.Fatalf("error creating test file %s", alternativeTestFileName) } logWriter.lookup[testName] = testFile logWriter.lookup[alternativeTestName] = alternativeTestFile logWriter.closeFiles(logger) err = testFile.Close() assert.Contains(t, err.Error(), os.ErrClosed.Error()) err = alternativeTestFile.Close() assert.Contains(t, err.Error(), os.ErrClosed.Error()) } func TestWriteLogWritesToCorrectLogFile(t *testing.T) { t.Parallel() logWriter := createLogWriter(t) logger := NewTestLogger(t) testName := t.Name() testFileName := filepath.Join(logWriter.outputDir, testName) testFile, err := os.Create(testFileName) if err != nil { t.Fatalf("error creating test file %s", testFileName) } defer testFile.Close() alternativeTestName := t.Name() + "Alternative" alternativeTestFileName := filepath.Join(logWriter.outputDir, alternativeTestName) alternativeTestFile, err := os.Create(alternativeTestFileName) if err != nil { t.Fatalf("error creating test file %s", alternativeTestFileName) } defer alternativeTestFile.Close() logWriter.lookup[testName] = testFile logWriter.lookup[alternativeTestName] = alternativeTestFile randomString := random.UniqueId() err = logWriter.writeLog(logger, testName, randomString) assert.Nil(t, err) alternativeRandomString := random.UniqueId() err = logWriter.writeLog(logger, alternativeTestName, alternativeRandomString) assert.Nil(t, err) buf, err := os.ReadFile(testFileName) assert.Nil(t, err) assert.Equal(t, string(buf), randomString+"\n") buf, err = os.ReadFile(alternativeTestFileName) assert.Nil(t, err) assert.Equal(t, string(buf), alternativeRandomString+"\n") } func TestWriteLogCreatesLogFileIfNotExists(t *testing.T) { t.Parallel() logWriter := createLogWriter(t) logger := NewTestLogger(t) testName := t.Name() testFileName := filepath.Join(logWriter.outputDir, testName+".log") randomString := random.UniqueId() err := logWriter.writeLog(logger, testName, randomString) assert.Nil(t, err) assert.True(t, files.FileExists(testFileName)) buf, err := os.ReadFile(testFileName) assert.Nil(t, err) assert.Equal(t, string(buf), randomString+"\n") } ================================================ FILE: modules/oci/compute.go ================================================ package oci import ( "context" "fmt" "sort" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/testing" "github.com/oracle/oci-go-sdk/common" "github.com/oracle/oci-go-sdk/core" ) // DeleteImage deletes a custom image with given OCID. func DeleteImage(t testing.TestingT, ocid string) { err := DeleteImageE(t, ocid) if err != nil { t.Fatal(err) } } // DeleteImageE deletes a custom image with given OCID. func DeleteImageE(t testing.TestingT, ocid string) error { logger.Default.Logf(t, "Deleting image with OCID %s", ocid) configProvider := common.DefaultConfigProvider() client, err := core.NewComputeClientWithConfigurationProvider(configProvider) if err != nil { return err } request := core.DeleteImageRequest{ImageId: &ocid} _, err = client.DeleteImage(context.Background(), request) return err } // GetMostRecentImageID gets the OCID of the most recent image in the given compartment that has the given OS name and version. func GetMostRecentImageID(t testing.TestingT, compartmentID string, osName string, osVersion string) string { ocid, err := GetMostRecentImageIDE(t, compartmentID, osName, osVersion) if err != nil { t.Fatal(err) } return ocid } // GetMostRecentImageIDE gets the OCID of the most recent image in the given compartment that has the given OS name and version. func GetMostRecentImageIDE(t testing.TestingT, compartmentID string, osName string, osVersion string) (string, error) { configProvider := common.DefaultConfigProvider() client, err := core.NewComputeClientWithConfigurationProvider(configProvider) if err != nil { return "", err } request := core.ListImagesRequest{ CompartmentId: &compartmentID, OperatingSystem: &osName, OperatingSystemVersion: &osVersion, } response, err := client.ListImages(context.Background(), request) if err != nil { return "", err } if len(response.Items) == 0 { return "", fmt.Errorf("No %s %s images found in the %s compartment", osName, osVersion, compartmentID) } mostRecentImage := mostRecentImage(response.Items) return *mostRecentImage.Id, nil } // Image sorting code borrowed from: https://github.com/hashicorp/packer/blob/7f4112ba229309cfc0ebaa10ded2abdfaf1b22c8/builder/amazon/common/step_source_ami_info.go type imageSort []core.Image func (a imageSort) Len() int { return len(a) } func (a imageSort) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a imageSort) Less(i, j int) bool { iTime := a[i].TimeCreated.Unix() jTime := a[j].TimeCreated.Unix() return iTime < jTime } // mostRecentImage returns the most recent image out of a slice of images. func mostRecentImage(images []core.Image) core.Image { sortedImages := images sort.Sort(imageSort(sortedImages)) return sortedImages[len(sortedImages)-1] } ================================================ FILE: modules/oci/identity.go ================================================ package oci import ( "context" "fmt" "os" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/testing" "github.com/oracle/oci-go-sdk/common" "github.com/oracle/oci-go-sdk/identity" ) // GetRandomAvailabilityDomain gets a randomly chosen availability domain for given compartment. // The returned value can be overridden by of the environment variable TF_VAR_availability_domain. func GetRandomAvailabilityDomain(t testing.TestingT, compartmentID string) string { ad, err := GetRandomAvailabilityDomainE(t, compartmentID) if err != nil { t.Fatal(err) } return ad } // GetRandomAvailabilityDomainE gets a randomly chosen availability domain for given compartment. // The returned value can be overridden by of the environment variable TF_VAR_availability_domain. func GetRandomAvailabilityDomainE(t testing.TestingT, compartmentID string) (string, error) { adFromEnvVar := os.Getenv(availabilityDomainEnvVar) if adFromEnvVar != "" { logger.Default.Logf(t, "Using availability domain %s from environment variable %s", adFromEnvVar, availabilityDomainEnvVar) return adFromEnvVar, nil } allADs, err := GetAllAvailabilityDomainsE(t, compartmentID) if err != nil { return "", err } ad := random.RandomString(allADs) logger.Default.Logf(t, "Using availability domain %s", ad) return ad, nil } // GetAllAvailabilityDomains gets the list of availability domains available in the given compartment. func GetAllAvailabilityDomains(t testing.TestingT, compartmentID string) []string { ads, err := GetAllAvailabilityDomainsE(t, compartmentID) if err != nil { t.Fatal(err) } return ads } // GetAllAvailabilityDomainsE gets the list of availability domains available in the given compartment. func GetAllAvailabilityDomainsE(t testing.TestingT, compartmentID string) ([]string, error) { configProvider := common.DefaultConfigProvider() client, err := identity.NewIdentityClientWithConfigurationProvider(configProvider) if err != nil { return nil, err } request := identity.ListAvailabilityDomainsRequest{CompartmentId: &compartmentID} response, err := client.ListAvailabilityDomains(context.Background(), request) if err != nil { return nil, err } if len(response.Items) == 0 { return nil, fmt.Errorf("No availability domains found in the %s compartment", compartmentID) } return availabilityDomainsNames(response.Items), nil } func availabilityDomainsNames(ads []identity.AvailabilityDomain) []string { names := []string{} for _, ad := range ads { names = append(names, *ad.Name) } return names } ================================================ FILE: modules/oci/network.go ================================================ package oci import ( "context" "fmt" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/testing" "github.com/oracle/oci-go-sdk/common" "github.com/oracle/oci-go-sdk/core" ) // GetRandomSubnetID gets a randomly chosen subnet OCID in the given availability domain. // The returned value can be overridden by of the environment variable TF_VAR_subnet_ocid. func GetRandomSubnetID(t testing.TestingT, compartmentID string, availabilityDomain string) string { ocid, err := GetRandomSubnetIDE(t, compartmentID, availabilityDomain) if err != nil { t.Fatal(err) } return ocid } // GetRandomSubnetIDE gets a randomly chosen subnet OCID in the given availability domain. // The returned value can be overridden by of the environment variable TF_VAR_subnet_ocid. func GetRandomSubnetIDE(t testing.TestingT, compartmentID string, availabilityDomain string) (string, error) { configProvider := common.DefaultConfigProvider() client, err := core.NewVirtualNetworkClientWithConfigurationProvider(configProvider) if err != nil { return "", err } vcnIDs, err := GetAllVcnIDsE(t, compartmentID) if err != nil { return "", err } allSubnetIDs := map[string][]string{} for _, vcnID := range vcnIDs { request := core.ListSubnetsRequest{ CompartmentId: &compartmentID, VcnId: &vcnID, } response, err := client.ListSubnets(context.Background(), request) if err != nil { return "", err } mapSubnetsByAvailabilityDomain(allSubnetIDs, response.Items) } subnetID := random.RandomString(allSubnetIDs[availabilityDomain]) logger.Default.Logf(t, "Using subnet with OCID %s", subnetID) return subnetID, nil } // GetAllVcnIDs gets the list of VCNs available in the given compartment. func GetAllVcnIDs(t testing.TestingT, compartmentID string) []string { vcnIDS, err := GetAllVcnIDsE(t, compartmentID) if err != nil { t.Fatal(err) } return vcnIDS } // GetAllVcnIDsE gets the list of VCNs available in the given compartment. func GetAllVcnIDsE(t testing.TestingT, compartmentID string) ([]string, error) { configProvider := common.DefaultConfigProvider() client, err := core.NewVirtualNetworkClientWithConfigurationProvider(configProvider) if err != nil { return nil, err } request := core.ListVcnsRequest{CompartmentId: &compartmentID} response, err := client.ListVcns(context.Background(), request) if err != nil { return nil, err } if len(response.Items) == 0 { return nil, fmt.Errorf("No VCNs found in the %s compartment", compartmentID) } return vcnsIDs(response.Items), nil } func mapSubnetsByAvailabilityDomain(allSubnets map[string][]string, subnets []core.Subnet) map[string][]string { for _, subnet := range subnets { allSubnets[*subnet.AvailabilityDomain] = append(allSubnets[*subnet.AvailabilityDomain], *subnet.Id) } return allSubnets } func vcnsIDs(vcns []core.Vcn) []string { ids := []string{} for _, vcn := range vcns { ids = append(ids, *vcn.Id) } return ids } ================================================ FILE: modules/oci/provider.go ================================================ package oci import ( "os" "github.com/gruntwork-io/terratest/modules/testing" "github.com/oracle/oci-go-sdk/common" ) // You can set this environment variable to force Terratest to use a specific compartment. const compartmentIDEnvVar = "TF_VAR_compartment_ocid" // You can set this environment variable to force Terratest to use a specific availability domain // rather than a random one. This is convenient when iterating locally. const availabilityDomainEnvVar = "TF_VAR_availability_domain" // You can set this environment variable to force Terratest to use a specific subnet. const subnetIDEnvVar = "TF_VAR_subnet_ocid" // You can set this environment variable to force Terratest to use a pass phrase. const passPhraseEnvVar = "TF_VAR_pass_phrase" // GetRootComparmentID gets an OCID of the root compartment (a.k.a. tenancy OCID). func GetRootCompartmentID(t testing.TestingT) string { tenancyID, err := GetRootCompartmentIDE(t) if err != nil { t.Fatal(err) } return tenancyID } // GetRootComparmentIDE gets an OCID of the root compartment (a.k.a. tenancy OCID). func GetRootCompartmentIDE(t testing.TestingT) (string, error) { configProvider := common.DefaultConfigProvider() tenancyID, err := configProvider.TenancyOCID() if err != nil { return "", err } return tenancyID, nil } // GetCompartmentIDFromEnvVar returns the compartment OCID for use with testing. func GetCompartmentIDFromEnvVar() string { return os.Getenv(compartmentIDEnvVar) } // GetSubnetIDFromEnvVar returns the subnet OCID for use with testing. func GetSubnetIDFromEnvVar() string { return os.Getenv(subnetIDEnvVar) } // GetPassPhraseFromEnvVar returns the pass phrase for use with testing. func GetPassPhraseFromEnvVar() string { return os.Getenv(passPhraseEnvVar) } ================================================ FILE: modules/opa/download_policy.go ================================================ package opa import ( "context" "os" "path/filepath" "sync" getter "github.com/hashicorp/go-getter/v2" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/testing" ) var ( // A map that maps the go-getter base URL to the temporary directory where it is downloaded. policyDirCache sync.Map ) // DownloadPolicyE takes in a rule path written in go-getter syntax and downloads it to a temporary directory so that it // can be passed to opa. The temporary directory that is used is cached based on the go-getter base path, and reused // across calls. // For example, if you call DownloadPolicyE with the go-getter URL multiple times: // // git::https://github.com/gruntwork-io/terratest.git//policies/foo.rego?ref=main // // The first time the gruntwork-io/terratest repo will be downloaded to a new temp directory. All subsequent calls will // reuse that first temporary dir where the repo was cloned. This is preserved even if a different subdir is requested // later, e.g.: git::https://github.com/gruntwork-io/terratest.git//examples/bar.rego?ref=main // Note that the query parameters are always included in the base URL. This means that if you use a different ref (e.g., // git::https://github.com/gruntwork-io/terratest.git//examples/bar.rego?ref=v0.39.3), then that will be cloned to a new // temporary directory rather than the cached dir. func DownloadPolicyE(t testing.TestingT, rulePath string) (string, error) { cwd, err := os.Getwd() if err != nil { return "", err } // File getters are assumed to be a local path reference, so pass through the original path. var fileGetter getter.FileGetter if ok, _ := fileGetter.Detect(&getter.Request{ Src: rulePath, Pwd: cwd, GetMode: getter.ModeAny, }); ok { return rulePath, nil } // At this point we assume the getter URL is a remote URL, so we start the process of downloading it to a temp dir. // First, check if we had already downloaded the source and it is in our cache. baseDir, subDir := getter.SourceDirSubdir(rulePath) downloadPath, hasDownloaded := policyDirCache.Load(baseDir) if hasDownloaded { logger.Default.Logf(t, "Previously downloaded %s: returning cached path", baseDir) return filepath.Join(downloadPath.(string), subDir), nil } // Not downloaded, so use go-getter to download the remote source to a temp dir. tempDir, err := os.MkdirTemp("", "terratest-opa-policy-*") if err != nil { return "", err } // go-getter doesn't work if you give it a directory that already exists, so we add an additional path in the // tempDir to make sure we feed a directory that doesn't exist yet. tempDir = filepath.Join(tempDir, "getter") logger.Default.Logf(t, "Downloading %s to temp dir %s", rulePath, tempDir) if _, err := getter.GetAny(context.Background(), tempDir, baseDir); err != nil { return "", err } policyDirCache.Store(baseDir, tempDir) return filepath.Join(tempDir, subDir), nil } ================================================ FILE: modules/opa/download_policy_test.go ================================================ package opa import ( "fmt" "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terratest/modules/files" "github.com/gruntwork-io/terratest/modules/git" ) // Test to make sure the DownloadPolicyE function returns a local path without processing it. func TestDownloadPolicyReturnsLocalPath(t *testing.T) { t.Parallel() localPath := "../../examples/terraform-opa-example/policy/enforce_source.rego" path, err := DownloadPolicyE(t, localPath) require.NoError(t, err) assert.Equal(t, localPath, path) } // Test to make sure the DownloadPolicyE function returns a remote path to a temporary directory. func TestDownloadPolicyDownloadsRemote(t *testing.T) { t.Parallel() curRef := git.GetCurrentGitRef(t) baseDir := fmt.Sprintf("git::https://github.com/gruntwork-io/terratest.git?ref=%s", curRef) localPath := "../../examples/terraform-opa-example/policy/enforce_source.rego" remotePath := fmt.Sprintf("git::https://github.com/gruntwork-io/terratest.git//examples/terraform-opa-example/policy/enforce_source.rego?ref=%s", curRef) // Make sure we clean up the downloaded file, while simultaneously asserting that the download dir was stored in the // cache. defer func() { downloadPathRaw, inCache := policyDirCache.Load(baseDir) require.True(t, inCache) downloadPath := downloadPathRaw.(string) if strings.HasSuffix(downloadPath, "/getter") { downloadPath = filepath.Dir(downloadPath) } assert.NoError(t, os.RemoveAll(downloadPath)) }() path, err := DownloadPolicyE(t, remotePath) require.NoError(t, err) absPath, err := filepath.Abs(localPath) require.NoError(t, err) assert.NotEqual(t, absPath, path) localContents, err := os.ReadFile(localPath) require.NoError(t, err) remoteContents, err := os.ReadFile(path) require.NoError(t, err) assert.Equal(t, localContents, remoteContents) } // Test to make sure the DownloadPolicyE function uses the cache if it has already downloaded an existing base path. func TestDownloadPolicyReusesCachedDir(t *testing.T) { t.Parallel() baseDir := "git::https://github.com/gruntwork-io/terratest.git?ref=main" remotePath := "git::https://github.com/gruntwork-io/terratest.git//examples/terraform-opa-example/policy/enforce_source.rego?ref=main" remotePathAltSubPath := "git::https://github.com/gruntwork-io/terratest.git//modules/opa/eval.go?ref=main" // Make sure we clean up the downloaded file, while simultaneously asserting that the download dir was stored in the // cache. defer func() { downloadPathRaw, inCache := policyDirCache.Load(baseDir) require.True(t, inCache) downloadPath := downloadPathRaw.(string) if strings.HasSuffix(downloadPath, "/getter") { downloadPath = filepath.Dir(downloadPath) } assert.NoError(t, os.RemoveAll(downloadPath)) }() path, err := DownloadPolicyE(t, remotePath) require.NoError(t, err) files.FileExists(path) downloadPathRaw, inCache := policyDirCache.Load(baseDir) require.True(t, inCache) downloadPath := downloadPathRaw.(string) // make sure the second call is exactly equal to the first call newPath, err := DownloadPolicyE(t, remotePath) require.NoError(t, err) assert.Equal(t, path, newPath) // Also make sure the cache is reused for alternative sub dirs. newAltPath, err := DownloadPolicyE(t, remotePathAltSubPath) require.NoError(t, err) assert.True(t, strings.HasPrefix(path, downloadPath)) assert.True(t, strings.HasPrefix(newAltPath, downloadPath)) } ================================================ FILE: modules/opa/eval.go ================================================ package opa import ( "path/filepath" "strings" "sync" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/shell" "github.com/gruntwork-io/terratest/modules/testing" "github.com/hashicorp/go-multierror" "github.com/stretchr/testify/require" ) // EvalOptions defines options that can be passed to the 'opa eval' command for checking policies on arbitrary JSON data // via OPA. type EvalOptions struct { // Whether OPA should run checks with failure. FailMode FailMode // Path to rego file containing the OPA rules. Can also be a remote path defined in go-getter syntax. Refer to // https://github.com/hashicorp/go-getter#url-format for supported options. RulePath string // Set a logger that should be used. See the logger package for more info. Logger *logger.Logger // Extra command line arguments to pass to opa eval. These are added after the eval subcommand // and before the standard arguments (-i, -d, query). // Example: []string{"--v0-compatible"} to enable OPA v0 compatibility mode. // Example: []string{"--strict"} to enable strict mode for the eval subcommand. ExtraArgs []string // The following options can be used to change the behavior of the related functions for debuggability. // When true, keep any temp files and folders that are created for the purpose of running opa eval. DebugKeepTempFiles bool // When true, disable the functionality where terratest reruns the opa check on the same file and query all elements // on error. By default, terratest will rerun the opa eval call with `data` query so you can see all the contents // evaluated. DebugDisableQueryDataOnError bool } // FailMode signals whether `opa eval` should fail when the query returns an undefined value (FailUndefined), a // defined value (FailDefined), or not at all (NoFail). type FailMode int const ( FailUndefined FailMode = iota FailDefined NoFail ) // EvalE runs `opa eval` on the given JSON files using the configured policy file and result query. Translates to: // // opa eval -i $JSONFile -d $RulePath $ResultQuery // // This will asynchronously run OPA on each file concurrently using goroutines. // This will fail the test if any one of the files failed. func Eval(t testing.TestingT, options *EvalOptions, jsonFilePaths []string, resultQuery string) { require.NoError(t, EvalE(t, options, jsonFilePaths, resultQuery)) } // EvalE runs `opa eval` on the given JSON files using the configured policy file and result query. Translates to: // // opa eval -i $JSONFile -d $RulePath $ResultQuery // // This will asynchronously run OPA on each file concurrently using goroutines. // This will fail the test if any one of the files failed. // For each file, the output will be returned on the outputs slice. func EvalWithOutput(t testing.TestingT, options *EvalOptions, jsonFilePaths []string, resultQuery string) (outputs []string) { outputs, err := EvalWithOutputE(t, options, jsonFilePaths, resultQuery) require.NoError(t, err) return } // EvalE runs `opa eval` on the given JSON files using the configured policy file and result query. Translates to: // // opa eval -i $JSONFile -d $RulePath $ResultQuery // // This will asynchronously run OPA on each file concurrently using goroutines. func EvalE(t testing.TestingT, options *EvalOptions, jsonFilePaths []string, resultQuery string) (err error) { _, err = evalE(t, options, jsonFilePaths, resultQuery) return } // EvalWithOutputE runs `opa eval` on the given JSON files using the configured policy file and result query. Translates to: // // opa eval -i $JSONFile -d $RulePath $ResultQuery // // This will asynchronously run OPA on each file concurrently using goroutines. // For each file, the output will be returned on the outputs slice. func EvalWithOutputE(t testing.TestingT, options *EvalOptions, jsonFilePaths []string, resultQuery string) (outputs []string, err error) { return evalE(t, options, jsonFilePaths, resultQuery) } func evalE(t testing.TestingT, options *EvalOptions, jsonFilePaths []string, resultQuery string) (outputs []string, err error) { downloadedPolicyPath, err := DownloadPolicyE(t, options.RulePath) if err != nil { return } outputs = make([]string, len(jsonFilePaths)) wg := new(sync.WaitGroup) wg.Add(len(jsonFilePaths)) errorsOccurred := new(multierror.Error) errChans := make([]chan error, len(jsonFilePaths)) for i, jsonFilePath := range jsonFilePaths { errChan := make(chan error, 1) errChans[i] = errChan go func(i int, jsonFilePath string) { outputs[i] = asyncEval(t, wg, errChan, options, downloadedPolicyPath, jsonFilePath, resultQuery) }(i, jsonFilePath) } wg.Wait() for _, errChan := range errChans { err := <-errChan if err != nil { errorsOccurred = multierror.Append(errorsOccurred, err) } } return outputs, errorsOccurred.ErrorOrNil() } // asyncEval is a function designed to be run in a goroutine to asynchronously call `opa eval` on a single input file. func asyncEval( t testing.TestingT, wg *sync.WaitGroup, errChan chan error, options *EvalOptions, downloadedPolicyPath string, jsonFilePath string, resultQuery string, ) (output string) { defer wg.Done() cmd := shell.Command{ Command: "opa", Args: formatOPAEvalArgs(options, downloadedPolicyPath, jsonFilePath, resultQuery), // Do not log output from shell package so we can log the full json without breaking it up. This is ok, because // opa eval is typically very quick. Logger: logger.Discard, } output, err := runCommandWithFullLoggingE(t, options.Logger, cmd) ruleBasePath := filepath.Base(downloadedPolicyPath) if err == nil { options.Logger.Logf(t, "opa eval passed on file %s (policy %s; query %s)", jsonFilePath, ruleBasePath, resultQuery) } else { options.Logger.Logf(t, "Failed opa eval on file %s (policy %s; query %s)", jsonFilePath, ruleBasePath, resultQuery) if options.DebugDisableQueryDataOnError == false { options.Logger.Logf(t, "DEBUG: rerunning opa eval to query for full data.") cmd.Args = formatOPAEvalArgs(options, downloadedPolicyPath, jsonFilePath, "data") // We deliberately ignore the error here as we want to only return the original error. output, _ = runCommandWithFullLoggingE(t, options.Logger, cmd) } } errChan <- err return } // formatOPAEvalArgs formats the arguments for the `opa eval` command. func formatOPAEvalArgs(options *EvalOptions, rulePath, jsonFilePath, resultQuery string) []string { var args []string // Add the eval subcommand args = append(args, "eval") // Add any extra arguments provided by the user (for the eval subcommand) // These come before the fail mode flags to allow overriding behavior if len(options.ExtraArgs) > 0 { args = append(args, options.ExtraArgs...) } switch options.FailMode { case FailUndefined: args = append(args, "--fail") case FailDefined: args = append(args, "--fail-defined") } args = append( args, []string{ "-i", jsonFilePath, "-d", rulePath, resultQuery, }..., ) return args } // runCommandWithFullLogging will log the command output in its entirety with buffering. This avoids breaking up the // logs when commands are run concurrently. This is a private function used in the context of opa only because opa runs // very quickly, and the output of opa is hard to parse if it is broken up by interleaved logs. func runCommandWithFullLoggingE(t testing.TestingT, logger *logger.Logger, cmd shell.Command) (output string, err error) { output, err = shell.RunCommandAndGetOutputE(t, cmd) logger.Logf(t, "Output of command `%s %s`:\n%s", cmd.Command, strings.Join(cmd.Args, " "), output) return } ================================================ FILE: modules/opa/eval_test.go ================================================ package opa import ( "os" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestFormatOPAEvalArgs(t *testing.T) { t.Parallel() tests := []struct { name string options *EvalOptions rulePath string jsonFile string query string expected []string }{ { name: "Basic args without extras", options: &EvalOptions{ FailMode: NoFail, }, rulePath: "/path/to/policy.rego", jsonFile: "/path/to/input.json", query: "data.test.allow", expected: []string{"eval", "-i", "/path/to/input.json", "-d", "/path/to/policy.rego", "data.test.allow"}, }, { name: "With fail mode", options: &EvalOptions{ FailMode: FailUndefined, }, rulePath: "/path/to/policy.rego", jsonFile: "/path/to/input.json", query: "data.test.allow", expected: []string{"eval", "--fail", "-i", "/path/to/input.json", "-d", "/path/to/policy.rego", "data.test.allow"}, }, { name: "With extra args", options: &EvalOptions{ FailMode: FailUndefined, ExtraArgs: []string{"--format", "json"}, }, rulePath: "/path/to/policy.rego", jsonFile: "/path/to/input.json", query: "data.test.allow", expected: []string{"eval", "--format", "json", "--fail", "-i", "/path/to/input.json", "-d", "/path/to/policy.rego", "data.test.allow"}, }, { name: "With v0-compatible flag", options: &EvalOptions{ FailMode: FailUndefined, ExtraArgs: []string{"--v0-compatible"}, }, rulePath: "/path/to/policy.rego", jsonFile: "/path/to/input.json", query: "data.test.allow", expected: []string{"eval", "--v0-compatible", "--fail", "-i", "/path/to/input.json", "-d", "/path/to/policy.rego", "data.test.allow"}, }, { name: "With multiple extra args", options: &EvalOptions{ FailMode: FailUndefined, ExtraArgs: []string{"--v0-compatible", "--format", "json"}, }, rulePath: "/path/to/policy.rego", jsonFile: "/path/to/input.json", query: "data.test.allow", expected: []string{"eval", "--v0-compatible", "--format", "json", "--fail", "-i", "/path/to/input.json", "-d", "/path/to/policy.rego", "data.test.allow"}, }, } for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { t.Parallel() actual := formatOPAEvalArgs(test.options, test.rulePath, test.jsonFile, test.query) assert.Equal(t, test.expected, actual) }) } } func TestEvalWithOutput(t *testing.T) { t.Parallel() tests := []struct { name string policy string query string inputs []string outputs []string isError bool }{ { name: "Success", policy: ` package test allow := true if { startswith(input.user, "admin") } `, query: "data.test.allow", inputs: []string{ `{"user": "admin-1"}`, `{"user": "admin-2"}`, `{"user": "admin-3"}`, }, outputs: []string{ `{ "result": [{ "expressions": [{ "value": true, "text": "data.test.allow", "location": { "row": 1, "col": 1 } }] }] }`, `{ "result": [{ "expressions": [{ "value": true, "text": "data.test.allow", "location": { "row": 1, "col": 1 } }] }] }`, `{ "result": [{ "expressions": [{ "value": true, "text": "data.test.allow", "location": { "row": 1, "col": 1 } }] }] }`, }, }, { name: "ContainsError", policy: ` package test allow := true if { input.user == "admin" } `, query: "data.test.allow", isError: true, inputs: []string{ `{"user": "admin"}`, `{"user": "nobody"}`, }, outputs: []string{ `{ "result": [{ "expressions": [{ "value": true, "text": "data.test.allow", "location": { "row": 1, "col": 1 } }] }] }`, `{ "result": [{ "expressions": [{ "value": { "test": {} }, "text": "data", "location": { "row": 1, "col": 1 } }] }] }`, }, }, } createTempFile := func(t *testing.T, name string, content string) string { f, err := os.CreateTemp(t.TempDir(), name) require.NoError(t, err) t.Cleanup(func() { os.Remove(f.Name()) }) _, err = f.WriteString(content) require.NoError(t, err) return f.Name() } for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { policy := createTempFile(t, "policy-*.rego", test.policy) inputs := make([]string, len(test.inputs)) for i, input := range test.inputs { f := createTempFile(t, "inputs-*.json", input) inputs[i] = f } options := &EvalOptions{ RulePath: policy, } outputs, err := EvalWithOutputE(t, options, inputs, test.query) if test.isError { assert.Error(t, err) } else { assert.NoError(t, err) } for i, output := range test.outputs { require.JSONEq(t, output, outputs[i], "output for input: %d", i) } }) } } ================================================ FILE: modules/packer/packer.go ================================================ // Package packer allows to interact with Packer. package packer import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "regexp" "sync" "time" "github.com/gruntwork-io/terratest/modules/retry" "github.com/hashicorp/go-multierror" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/shell" "github.com/gruntwork-io/terratest/modules/testing" "github.com/hashicorp/go-version" ) // Options are the options for Packer. type Options struct { Template string // The path to the Packer template Vars map[string]string // The custom vars to pass when running the build command VarFiles []string // Var file paths to pass Packer using -var-file option Only string // If specified, only run the build of this name Except string // Runs the build excluding the specified builds and post-processors Env map[string]string // Custom environment variables to set when running Packer RetryableErrors map[string]string // If packer build fails with one of these (transient) errors, retry. The keys are a regexp to match against the error and the message is what to display to a user if that error is matched. MaxRetries int // Maximum number of times to retry errors matching RetryableErrors TimeBetweenRetries time.Duration // The amount of time to wait between retries WorkingDir string // The directory to run packer in Logger *logger.Logger // If set, use a non-default logger DisableTemporaryPluginPath bool // If set, do not use a temporary directory for Packer plugins. } // BuildArtifacts can take a map of identifierName <-> Options and then parallelize // the packer builds. Once all the packer builds have completed a map of identifierName <-> generated identifier // is returned. The identifierName can be anything you want, it is only used so that you can // know which generated artifact is which. func BuildArtifacts(t testing.TestingT, artifactNameToOptions map[string]*Options) map[string]string { result, err := BuildArtifactsE(t, artifactNameToOptions) if err != nil { t.Fatalf("Error building artifacts: %s", err.Error()) } return result } // BuildArtifactsE can take a map of identifierName <-> Options and then parallelize // the packer builds. Once all the packer builds have completed a map of identifierName <-> generated identifier // is returned. If any artifact fails to build, the errors are accumulated and returned // as a MultiError. The identifierName can be anything you want, it is only used so that you can // know which generated artifact is which. func BuildArtifactsE(t testing.TestingT, artifactNameToOptions map[string]*Options) (map[string]string, error) { var waitForArtifacts sync.WaitGroup waitForArtifacts.Add(len(artifactNameToOptions)) var artifactNameToArtifactId = map[string]string{} var errorsOccurred = new(multierror.Error) for artifactName, curOptions := range artifactNameToOptions { // The following is necessary to make sure artifactName and curOptions don't // get updated due to concurrency within the scope of t.Run(..) below artifactName := artifactName curOptions := curOptions go func() { defer waitForArtifacts.Done() artifactId, err := BuildArtifactE(t, curOptions) if err != nil { errorsOccurred = multierror.Append(errorsOccurred, err) } else { artifactNameToArtifactId[artifactName] = artifactId } }() } waitForArtifacts.Wait() return artifactNameToArtifactId, errorsOccurred.ErrorOrNil() } // BuildArtifact builds the given Packer template and return the generated Artifact ID. func BuildArtifact(t testing.TestingT, options *Options) string { artifactID, err := BuildArtifactE(t, options) if err != nil { t.Fatal(err) } return artifactID } // BuildArtifactE builds the given Packer template and return the generated Artifact ID. func BuildArtifactE(t testing.TestingT, options *Options) (string, error) { options.Logger.Logf(t, "Running Packer to generate a custom artifact for template %s", options.Template) // By default, we download packer plugins to a temporary directory rather than use the global plugin path. // This prevents race conditions when multiple tests are running in parallel and each of them attempt // to download the same plugin at the same time to the global path. // Set DisableTemporaryPluginPath to disable this behavior. if !options.DisableTemporaryPluginPath { // The built-in env variable defining where plugins are downloaded const packerPluginPathEnvVar = "PACKER_PLUGIN_PATH" options.Logger.Logf(t, "Creating a temporary directory for Packer plugins") pluginDir, err := os.MkdirTemp("", "terratest-packer-") require.NoError(t, err) if len(options.Env) == 0 { options.Env = make(map[string]string) } options.Env[packerPluginPathEnvVar] = pluginDir defer os.RemoveAll(pluginDir) } err := packerInit(t, options) if err != nil { return "", err } cmd := shell.Command{ Command: "packer", Args: formatPackerArgs(options), Env: options.Env, WorkingDir: options.WorkingDir, } description := fmt.Sprintf("%s %v", cmd.Command, cmd.Args) output, err := retry.DoWithRetryableErrorsE(t, description, options.RetryableErrors, options.MaxRetries, options.TimeBetweenRetries, func() (string, error) { return shell.RunCommandAndGetOutputE(t, cmd) }) if err != nil { return "", err } return extractArtifactID(output) } // BuildAmi builds the given Packer template and return the generated AMI ID. // // Deprecated: Use BuildArtifact instead. func BuildAmi(t testing.TestingT, options *Options) string { return BuildArtifact(t, options) } // BuildAmiE builds the given Packer template and return the generated AMI ID. // // Deprecated: Use BuildArtifactE instead. func BuildAmiE(t testing.TestingT, options *Options) (string, error) { return BuildArtifactE(t, options) } // The Packer machine-readable log output should contain an entry of this format: // // AWS: ,,artifact,,id,: // GCP: ,,artifact,,id, // // For example: // // 1456332887,amazon-ebs,artifact,0,id,us-east-1:ami-b481b3de // 1533742764,googlecompute,artifact,0,id,terratest-packer-example-2018-08-08t15-35-19z func extractArtifactID(packerLogOutput string) (string, error) { re := regexp.MustCompile(`.+artifact,\d+?,id,(?:.+?:|)(.+)`) matches := re.FindStringSubmatch(packerLogOutput) if len(matches) == 2 { return matches[1], nil } return "", errors.New("Could not find Artifact ID pattern in Packer output") } // Check if the local version of Packer has init func hasPackerInit(t testing.TestingT, options *Options) (bool, error) { // The init command was introduced in Packer 1.7.0 const packerInitVersion = "1.7.0" minInitVersion, err := version.NewVersion(packerInitVersion) if err != nil { return false, err } cmd := shell.Command{ Command: "packer", Args: []string{"-version"}, Env: options.Env, WorkingDir: options.WorkingDir, } versionCmdOutput, err := shell.RunCommandAndGetOutputE(t, cmd) if err != nil { return false, err } localVersion := trimPackerVersion(versionCmdOutput) thisVersion, err := version.NewVersion(localVersion) if err != nil { return false, err } if thisVersion.LessThan(minInitVersion) { return false, nil } return true, nil } // packerInit runs 'packer init' if it is supported by the local packer func packerInit(t testing.TestingT, options *Options) error { hasInit, err := hasPackerInit(t, options) if err != nil { return err } if !hasInit { options.Logger.Logf(t, "Skipping 'packer init' because it is not present in this version") return nil } extension := filepath.Ext(options.Template) if extension != ".hcl" { options.Logger.Logf(t, "Skipping 'packer init' because it is only supported for HCL2 templates") return nil } cmd := shell.Command{ Command: "packer", Args: []string{"init", options.Template}, Env: options.Env, WorkingDir: options.WorkingDir, } description := "Running Packer init" _, err = retry.DoWithRetryableErrorsE(t, description, options.RetryableErrors, options.MaxRetries, options.TimeBetweenRetries, func() (string, error) { return shell.RunCommandAndGetOutputE(t, cmd) }) if err != nil { return err } return nil } // Convert the inputs to a format palatable to packer. The build command should have the format: // // packer build [OPTIONS] template func formatPackerArgs(options *Options) []string { args := []string{"build", "-machine-readable"} for key, value := range options.Vars { args = append(args, "-var", fmt.Sprintf("%s=%s", key, value)) } for _, filePath := range options.VarFiles { args = append(args, "-var-file", filePath) } if options.Only != "" { args = append(args, fmt.Sprintf("-only=%s", options.Only)) } if options.Except != "" { args = append(args, fmt.Sprintf("-except=%s", options.Except)) } return append(args, options.Template) } // From packer 1.10 the -version command output is prefixed with Packer v func trimPackerVersion(versionCmdOutput string) string { re := regexp.MustCompile(`(?:Packer v?|)(\d+\.\d+\.\d+)`) matches := re.FindStringSubmatch(versionCmdOutput) if len(matches) > 1 { return matches[1] } return "" } type packerManifest struct { Builds []packerManifestBuild `json:"builds"` LastRunUUID string `json:"last_run_uuid"` } type packerManifestBuild struct { Name string `json:"name"` BuilderType string `json:"builder_type"` BuildTime int64 `json:"build_time"` Files []packerManifestBuildFile `json:"files"` ArtifactID string `json:"artifact_id"` PackerRunUUID string `json:"packer_run_uuid"` CustomData map[string]interface{} `json:"custom_data"` } type packerManifestBuildFile struct { Name string `json:"name"` Size int64 `json:"size"` } // GetArtifactIDFromManifestBuildName returns the artifact id from a build name contained in the manifest file // see https://developer.hashicorp.com/packer/docs/post-processors/manifest for more info // if the build name is not found, it will fail the test func GetArtifactIDFromManifestBuildName(t testing.TestingT, manifestPath string, buildName string) string { artifactID, err := GetArtifactIDFromManifestBuildNameE(t, manifestPath, buildName) if err != nil { t.Fatalf("failed to get artifact id from manifest build name: %s", err) } return artifactID } // GetArtifactIDFromManifestBuildNameE returns the artifact id from a build name contained in the manifest file // see https://developer.hashicorp.com/packer/docs/post-processors/manifest for more info func GetArtifactIDFromManifestBuildNameE(t testing.TestingT, manifestPath string, buildName string) (artifactID string, err error) { b, err := os.ReadFile(manifestPath) if err != nil { return "", fmt.Errorf("error reading manifest file: %w", err) } var manifest packerManifest if err = json.Unmarshal(b, &manifest); err != nil { return "", fmt.Errorf("error unmarshalling manifest file: %w", err) } found := false for _, build := range manifest.Builds { if build.Name != buildName { continue } artifactID, found = build.ArtifactID, true break } if !found { return "", fmt.Errorf("build name %s not found in manifest file %s", buildName, manifestPath) } return artifactID, nil } ================================================ FILE: modules/packer/packer_test.go ================================================ package packer import ( "fmt" "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestExtractAmiIdFromOneLine(t *testing.T) { t.Parallel() expectedAMIID := "ami-b481b3de" text := fmt.Sprintf("1456332887,amazon-ebs,artifact,0,id,us-east-1:%s", expectedAMIID) actualAMIID, err := extractArtifactID(text) if err != nil { t.Errorf("Did not expect to get an error when extracting a valid AMI ID: %s", err) } if actualAMIID != expectedAMIID { t.Errorf("Did not get expected AMI ID. Expected: %s. Actual: %s.", expectedAMIID, actualAMIID) } } func TestExtractImageIdFromOneLine(t *testing.T) { t.Parallel() expectedImageID := "terratest-packer-example-2018-08-09t12-02-58z" text := fmt.Sprintf("1533816302,googlecompute,artifact,0,id,%s", expectedImageID) actualImageID, err := extractArtifactID(text) if err != nil { t.Errorf("Did not expect to get an error when extracting a valid Image ID: %s", err) } if actualImageID != expectedImageID { t.Errorf("Did not get expected Image ID. Expected: %s. Actual: %s.", expectedImageID, actualImageID) } } func TestExtractAmiIdFromMultipleLines(t *testing.T) { t.Parallel() expectedAMIID := "ami-b481b3de" text := fmt.Sprintf(` foo bar 1456332887,amazon-ebs,artifact,0,id,us-east-1:%s baz blah `, expectedAMIID) actualAMIID, err := extractArtifactID(text) if err != nil { t.Errorf("Did not expect to get an error when extracting a valid AMI ID: %s", err) } if actualAMIID != expectedAMIID { t.Errorf("Did not get expected AMI ID. Expected: %s. Actual: %s.", expectedAMIID, actualAMIID) } } func TestExtractImageIdFromMultipleLines(t *testing.T) { t.Parallel() expectedImageID := "terratest-packer-example-2018-08-09t12-02-58z" text := fmt.Sprintf(` foo bar 1533816302,googlecompute,artifact,0,id,%s baz blah `, expectedImageID) actualImageID, err := extractArtifactID(text) if err != nil { t.Errorf("Did not expect to get an error when extracting a valid Image ID: %s", err) } if actualImageID != expectedImageID { t.Errorf("Did not get the expected Image ID. Expected: %s. Actual: %s.", expectedImageID, actualImageID) } } func TestExtractAmiIdNoIdPresent(t *testing.T) { t.Parallel() text := ` foo bar baz blah ` _, err := extractArtifactID(text) if err == nil { t.Error("Expected to get an error when extracting an AMI ID from text with no AMI in it, but got nil") } } func TestExtractArtifactINoIdPresent(t *testing.T) { t.Parallel() text := ` foo bar baz blah ` _, err := extractArtifactID(text) if err == nil { t.Error("Expected to get an error when extracting an Artifact ID from text with no Artifact ID in it, but got nil") } } func TestFormatPackerArgs(t *testing.T) { t.Parallel() tests := []struct { option *Options expected string }{ { option: &Options{ Template: "packer.json", }, expected: "build -machine-readable packer.json", }, { option: &Options{ Template: "packer.json", Vars: map[string]string{ "foo": "bar", }, Only: "onlythis", }, expected: "build -machine-readable -var foo=bar -only=onlythis packer.json", }, { option: &Options{ Template: "packer.json", Vars: map[string]string{ "foo": "bar", }, Only: "onlythis", Except: "long-run-pp,artifact", }, expected: "build -machine-readable -var foo=bar -only=onlythis -except=long-run-pp,artifact packer.json", }, { option: &Options{ Template: "packer.json", Vars: map[string]string{ "foo": "bar", }, VarFiles: []string{ "foofile.json", }, }, expected: "build -machine-readable -var foo=bar -var-file foofile.json packer.json", }, } for _, test := range tests { args := formatPackerArgs(test.option) assert.Equal(t, strings.Join(args, " "), test.expected) } } func TestTrimPackerVersion(t *testing.T) { t.Parallel() tests := []struct { versionOutput string expected string }{ { // Pre 1.10 output versionOutput: "1.7.0", expected: "1.7.0", }, { // From 1.10 matches the output of packer version versionOutput: "Packer v1.10.0", expected: "1.10.0", }, { // From 1.10 matches the output of packer version versionOutput: "Packer v1.10.0\n\nYour version of Packer is out of date! The latest version\nis 1.10.3. You can update by downloading from www.packer.io/downloads\n", expected: "1.10.0", }, } for _, test := range tests { t.Run(test.versionOutput, func(t *testing.T) { out := trimPackerVersion(test.versionOutput) assert.Equal(t, test.expected, out) }) } } func TestGetArtifactIDFromManifestBuildNameE(t *testing.T) { t.Parallel() // example manifest from https://developer.hashicorp.com/packer/docs/post-processors/manifest manifest := ` { "builds": [ { "name": "docker", "builder_type": "docker", "build_time": 1507245986, "files": [ { "name": "packer_example", "size": 102219776 } ], "artifact_id": "Container", "packer_run_uuid": "6d5d3185-fa95-44e1-8775-9e64fe2e2d8f", "custom_data": { "my_custom_data": "example" } } ], "last_run_uuid": "6d5d3185-fa95-44e1-8775-9e64fe2e2d8f" } ` manifestPath := filepath.Join(t.TempDir(), "manifest.json") err := os.WriteFile(manifestPath, []byte(manifest), 0600) require.NoError(t, err) t.Run("Found", func(t *testing.T) { t.Parallel() artifactID, err := GetArtifactIDFromManifestBuildNameE(t, manifestPath, "docker") require.NoError(t, err) assert.Equal(t, "Container", artifactID) artifactID2 := GetArtifactIDFromManifestBuildName(t, manifestPath, "docker") assert.Equal(t, "Container", artifactID2) }) t.Run("Not Found", func(t *testing.T) { t.Parallel() _, err := GetArtifactIDFromManifestBuildNameE(t, manifestPath, "notfound") require.Error(t, err) }) } ================================================ FILE: modules/random/random.go ================================================ // Package random contains different random generators. package random import ( "bytes" "math/rand" "time" ) // Random generates a random int between min and max, inclusive. func Random(min int, max int) int { return newRand().Intn(max-min+1) + min } // RandomInt picks a random element in the slice of ints. func RandomInt(elements []int) int { index := Random(0, len(elements)-1) return elements[index] } // RandomString picks a random element in the slice of string. func RandomString(elements []string) string { index := Random(0, len(elements)-1) return elements[index] } const base62chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" const uniqueIDLength = 6 // Should be good for 62^6 = 56+ billion combinations // UniqueID returns a unique (ish) id we can attach to resources and tfstate files so they don't conflict with each other // Uses base 62 to generate a 6 character string that's unlikely to collide with the handful of tests we run in // parallel. Based on code here: http://stackoverflow.com/a/9543797/483528 func UniqueID() string { var out bytes.Buffer generator := newRand() for i := 0; i < uniqueIDLength; i++ { out.WriteByte(base62chars[generator.Intn(len(base62chars))]) } return out.String() } // UniqueId is deprecated, use UniqueID instead. // // Deprecated: Use UniqueID. // //nolint:staticcheck,revive func UniqueId() string { return UniqueID() } // newRand creates a new random number generator, seeding it with the current system time. func newRand() *rand.Rand { return rand.New(rand.NewSource(time.Now().UnixNano())) } ================================================ FILE: modules/random/random_test.go ================================================ package random_test import ( "strconv" "testing" "github.com/gruntwork-io/terratest/modules/random" "github.com/stretchr/testify/assert" ) func TestRandom(t *testing.T) { t.Parallel() min := 0 max := 100 for i := 0; i < 100000; i++ { value := random.Random(min, max) assert.True(t, value >= min && value <= max) } } func TestRandomInt(t *testing.T) { t.Parallel() min := 0 max := 1000 list := []int{} for i := min; i < max; i++ { list = append(list, i) } for i := 0; i < 100000; i++ { value := random.RandomInt(list) assert.Contains(t, list, value) } } func TestRandomString(t *testing.T) { t.Parallel() min := 0 max := 1000 list := []string{} for i := min; i < max; i++ { list = append(list, strconv.Itoa(i)) } for i := 0; i < 100000; i++ { value := random.RandomString(list) assert.Contains(t, list, value) } } func TestUniqueID(t *testing.T) { t.Parallel() previouslySeen := map[string]bool{} for i := 0; i < 100; i++ { uniqueID := random.UniqueID() assert.Len(t, uniqueID, 6) assert.NotContains(t, previouslySeen, uniqueID) previouslySeen[uniqueID] = true } } ================================================ FILE: modules/retry/retry.go ================================================ // Package retry contains logic to retry actions with certain conditions. package retry import ( "context" "errors" "fmt" "regexp" "time" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/testing" ) // Either contains a result and potentially an error. type Either struct { Error error Result string } // DoWithTimeout runs the specified action and waits up to the specified timeout for it to complete. Return the output of the action if // it completes on time or fail the test otherwise. func DoWithTimeout(t testing.TestingT, actionDescription string, timeout time.Duration, action func() (string, error)) string { out, err := DoWithTimeoutE(t, actionDescription, timeout, action) if err != nil { t.Fatal(err) } return out } // DoWithTimeoutE runs the specified action and waits up to the specified timeout for it to complete. Return the output of the action if // it completes on time or an error otherwise. func DoWithTimeoutE(t testing.TestingT, actionDescription string, timeout time.Duration, action func() (string, error)) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() resultChannel := make(chan Either, 1) go func() { out, err := action() resultChannel <- Either{Result: out, Error: err} }() select { case either := <-resultChannel: return either.Result, either.Error case <-ctx.Done(): return "", TimeoutExceeded{Description: actionDescription, Timeout: timeout} } } // DoWithRetry runs the specified action. If it returns a string, return that string. If it returns a FatalError, return that error // immediately. If it returns any other type of error, sleep for sleepBetweenRetries and try again, up to a maximum of // maxRetries retries. If maxRetries is exceeded, fail the test. func DoWithRetry(t testing.TestingT, actionDescription string, maxRetries int, sleepBetweenRetries time.Duration, action func() (string, error)) string { out, err := DoWithRetryE(t, actionDescription, maxRetries, sleepBetweenRetries, action) if err != nil { t.Fatal(err) } return out } // DoWithRetryE runs the specified action. If it returns a string, return that string. If it returns a FatalError, return that error // immediately. If it returns any other type of error, sleep for sleepBetweenRetries and try again, up to a maximum of // maxRetries retries. If maxRetries is exceeded, return a MaxRetriesExceeded error. func DoWithRetryE(t testing.TestingT, actionDescription string, maxRetries int, sleepBetweenRetries time.Duration, action func() (string, error)) (string, error) { out, err := DoWithRetryInterfaceE(t, actionDescription, maxRetries, sleepBetweenRetries, func() (any, error) { return action() }) return out.(string), err } // DoWithRetryInterface runs the specified action. If it returns a value, return that value. If it returns a FatalError, return that error // immediately. If it returns any other type of error, sleep for sleepBetweenRetries and try again, up to a maximum of // maxRetries retries. If maxRetries is exceeded, fail the test. func DoWithRetryInterface(t testing.TestingT, actionDescription string, maxRetries int, sleepBetweenRetries time.Duration, action func() (any, error)) any { out, err := DoWithRetryInterfaceE(t, actionDescription, maxRetries, sleepBetweenRetries, action) if err != nil { t.Fatal(err) } return out } // DoWithRetryInterfaceE runs the specified action. If it returns a value, return that value. If it returns a FatalError, return that error // immediately. If it returns any other type of error, sleep for sleepBetweenRetries and try again, up to a maximum of // maxRetries retries. If maxRetries is exceeded, return a MaxRetriesExceeded error. func DoWithRetryInterfaceE(t testing.TestingT, actionDescription string, maxRetries int, sleepBetweenRetries time.Duration, action func() (any, error)) (any, error) { var output any var err error for i := 0; i <= maxRetries; i++ { logger.Default.Logf(t, "%s", actionDescription) output, err = action() if err == nil { return output, nil } var fatalErr FatalError if errors.As(err, &fatalErr) { logger.Default.Logf(t, "Returning due to fatal error: %v", err) return output, err } logger.Default.Logf(t, "%s returned an error: %s. Sleeping for %s and will try again.", actionDescription, err.Error(), sleepBetweenRetries) time.Sleep(sleepBetweenRetries) } return output, MaxRetriesExceeded{Description: actionDescription, MaxRetries: maxRetries} } // DoWithRetryableErrors runs the specified action. If it returns a value, return that value. If it returns an error, // check if error message or the string output from the action (which is often stdout/stderr from running some command) // matches any of the regular expressions in the specified retryableErrors map. If there is a match, sleep for // sleepBetweenRetries, and retry the specified action, up to a maximum of maxRetries retries. If there is no match, // return that error immediately, wrapped in a FatalError. If maxRetries is exceeded, return a MaxRetriesExceeded error. func DoWithRetryableErrors(t testing.TestingT, actionDescription string, retryableErrors map[string]string, maxRetries int, sleepBetweenRetries time.Duration, action func() (string, error)) string { out, err := DoWithRetryableErrorsE(t, actionDescription, retryableErrors, maxRetries, sleepBetweenRetries, action) require.NoError(t, err) return out } // DoWithRetryableErrorsE runs the specified action. If it returns a value, return that value. If it returns an error, // check if error message or the string output from the action (which is often stdout/stderr from running some command) // matches any of the regular expressions in the specified retryableErrors map. If there is a match, sleep for // sleepBetweenRetries, and retry the specified action, up to a maximum of maxRetries retries. If there is no match, // return that error immediately, wrapped in a FatalError. If maxRetries is exceeded, return a MaxRetriesExceeded error. func DoWithRetryableErrorsE(t testing.TestingT, actionDescription string, retryableErrors map[string]string, maxRetries int, sleepBetweenRetries time.Duration, action func() (string, error)) (string, error) { retryableErrorsRegexp := map[*regexp.Regexp]string{} for errorStr, errorMessage := range retryableErrors { errorRegex, err := regexp.Compile(errorStr) if err != nil { return "", FatalError{Underlying: err} } retryableErrorsRegexp[errorRegex] = errorMessage } return DoWithRetryE(t, actionDescription, maxRetries, sleepBetweenRetries, func() (string, error) { output, err := action() if err == nil { return output, nil } for errorRegexp, errorMessage := range retryableErrorsRegexp { if errorRegexp.MatchString(output) || errorRegexp.MatchString(err.Error()) { logger.Default.Logf(t, "'%s' failed with the error '%s' but this error was expected and warrants a retry. Further details: %s\n", actionDescription, err.Error(), errorMessage) return output, err } } return output, FatalError{Underlying: err} }) } // Done can be stopped. type Done struct { stop chan bool } // Done stops the execution. func (done Done) Done() { done.stop <- true } // DoInBackgroundUntilStopped runs the specified action in the background (in a goroutine) repeatedly, waiting the specified amount of time between // repetitions. To stop this action, call the Done() function on the returned value. func DoInBackgroundUntilStopped(t testing.TestingT, actionDescription string, sleepBetweenRepeats time.Duration, action func()) Done { stop := make(chan bool) go func() { for { logger.Default.Logf(t, "Executing action '%s'", actionDescription) action() logger.Default.Logf(t, "Sleeping for %s before repeating action '%s'", sleepBetweenRepeats, actionDescription) select { case <-time.After(sleepBetweenRepeats): // Nothing to do, just allow the loop to continue case <-stop: logger.Default.Logf(t, "Received stop signal for action '%s'.", actionDescription) return } } }() return Done{stop: stop} } // Custom error types // TimeoutExceeded is an error that occurs when a timeout is exceeded. type TimeoutExceeded struct { Description string Timeout time.Duration } func (err TimeoutExceeded) Error() string { return fmt.Sprintf("'%s' did not complete before timeout of %s", err.Description, err.Timeout) } // MaxRetriesExceeded is an error that occurs when the maximum amount of retries is exceeded. type MaxRetriesExceeded struct { Description string MaxRetries int } func (err MaxRetriesExceeded) Error() string { return fmt.Sprintf("'%s' unsuccessful after %d retries", err.Description, err.MaxRetries) } // FatalError is a marker interface for errors that should not be retried. type FatalError struct { Underlying error } func (err FatalError) Error() string { return fmt.Sprintf("FatalError{Underlying: %v}", err.Underlying) } ================================================ FILE: modules/retry/retry_test.go ================================================ package retry_test import ( "errors" "strconv" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terratest/modules/retry" ) func TestDoWithRetry(t *testing.T) { t.Parallel() expectedOutput := "expected" expectedError := errors.New("expected error") actionAlwaysReturnsExpected := func() (string, error) { return expectedOutput, nil } actionAlwaysReturnsError := func() (string, error) { return expectedOutput, expectedError } createActionThatReturnsExpectedAfterFiveRetries := func() func() (string, error) { count := 0 return func() (string, error) { count++ if count > 5 { return expectedOutput, nil } return expectedOutput, expectedError } } testCases := []struct { expectedError error action func() (string, error) description string maxRetries int }{ {description: "Return value on first try", maxRetries: 10, action: actionAlwaysReturnsExpected}, {description: "Return error on all retries", maxRetries: 10, expectedError: retry.MaxRetriesExceeded{Description: "Return error on all retries", MaxRetries: 10}, action: actionAlwaysReturnsError}, {description: "Return value after 5 retries", maxRetries: 10, action: createActionThatReturnsExpectedAfterFiveRetries()}, {description: "Return value after 5 retries, but only do 4 retries", maxRetries: 4, expectedError: retry.MaxRetriesExceeded{Description: "Return value after 5 retries, but only do 4 retries", MaxRetries: 4}, action: createActionThatReturnsExpectedAfterFiveRetries()}, } for _, testCase := range testCases { testCase := testCase // capture range variable for each test case t.Run(testCase.description, func(t *testing.T) { t.Parallel() actualOutput, err := retry.DoWithRetryE(t, testCase.description, testCase.maxRetries, 1*time.Millisecond, testCase.action) assert.Equal(t, expectedOutput, actualOutput) if testCase.expectedError != nil { assert.Equal(t, testCase.expectedError, err) } else { require.NoError(t, err) assert.Equal(t, expectedOutput, actualOutput) } }) } } func TestDoWithTimeout(t *testing.T) { t.Parallel() expectedOutput := "expected" expectedError := errors.New("expected error") actionReturnsValueImmediately := func() (string, error) { return expectedOutput, nil } actionReturnsErrorImmediately := func() (string, error) { return "", expectedError } createActionThatReturnsValueAfterDelay := func(delay time.Duration) func() (string, error) { return func() (string, error) { time.Sleep(delay) return expectedOutput, nil } } createActionThatReturnsErrorAfterDelay := func(delay time.Duration) func() (string, error) { return func() (string, error) { time.Sleep(delay) return "", expectedError } } testCases := []struct { expectedError error action func() (string, error) description string timeout time.Duration }{ {description: "Returns value immediately", timeout: 5 * time.Second, action: actionReturnsValueImmediately}, {description: "Returns error immediately", timeout: 5 * time.Second, expectedError: expectedError, action: actionReturnsErrorImmediately}, {description: "Returns value after 2 seconds", timeout: 5 * time.Second, action: createActionThatReturnsValueAfterDelay(2 * time.Second)}, {description: "Returns error after 2 seconds", timeout: 5 * time.Second, expectedError: expectedError, action: createActionThatReturnsErrorAfterDelay(2 * time.Second)}, {description: "Returns value after timeout exceeded", timeout: 5 * time.Second, expectedError: retry.TimeoutExceeded{Description: "Returns value after timeout exceeded", Timeout: 5 * time.Second}, action: createActionThatReturnsValueAfterDelay(10 * time.Second)}, {description: "Returns error after timeout exceeded", timeout: 5 * time.Second, expectedError: retry.TimeoutExceeded{Description: "Returns error after timeout exceeded", Timeout: 5 * time.Second}, action: createActionThatReturnsErrorAfterDelay(10 * time.Second)}, } for _, testCase := range testCases { testCase := testCase // capture range variable for each test case t.Run(testCase.description, func(t *testing.T) { t.Parallel() actualOutput, err := retry.DoWithTimeoutE(t, testCase.description, testCase.timeout, testCase.action) if testCase.expectedError != nil { assert.Equal(t, testCase.expectedError, err) } else { require.NoError(t, err) assert.Equal(t, expectedOutput, actualOutput) } }) } } func TestDoInBackgroundUntilStopped(t *testing.T) { t.Parallel() sleepBetweenRetries := 2 * time.Second waitStop := sleepBetweenRetries*2 + sleepBetweenRetries/2 counter := 0 stop := retry.DoInBackgroundUntilStopped(t, t.Name(), sleepBetweenRetries, func() { counter++ t.Log(time.Now(), counter) }) time.Sleep(waitStop) stop.Done() assert.Equal(t, 3, counter) time.Sleep(waitStop) assert.Equal(t, 3, counter) } func TestDoWithRetryableErrors(t *testing.T) { t.Parallel() expectedOutput := "this is the expected output" expectedError := errors.New("expected error") unexpectedError := errors.New("some other error") actionAlwaysReturnsExpected := func() (string, error) { return expectedOutput, nil } actionAlwaysReturnsExpectedError := func() (string, error) { return expectedOutput, expectedError } actionAlwaysReturnsUnexpectedError := func() (string, error) { return expectedOutput, unexpectedError } createActionThatReturnsExpectedAfterFiveRetriesOfExpectedErrors := func() func() (string, error) { count := 0 return func() (string, error) { count++ if count > 5 { return expectedOutput, nil } return expectedOutput, expectedError } } createActionThatReturnsExpectedAfterFiveRetriesOfUnexpectedErrors := func() func() (string, error) { count := 0 return func() (string, error) { count++ if count > 5 { return expectedOutput, nil } return expectedOutput, unexpectedError } } createActionThatReturnsErrorCounterAfterFiveRetriesOfExpectedErrors := func() func() (string, error) { count := 0 return func() (string, error) { count++ if count > 5 { return expectedOutput, ErrorCounter(count) } return expectedOutput, expectedError } } matchAllRegexp := ".*" matchExpectedErrorExactRegexp := expectedError.Error() matchExpectedErrorRegexp := "^expected.*$" matchNothingRegexp1 := "this won't match any of our errors" matchNothingRegexp2 := "this also won't match any of our errors" matchStdoutExactlyRegexp := expectedOutput matchStdoutRegexp := "this.*output" noRetryableErrors := map[string]string{} retryOnAllErrors := map[string]string{ matchAllRegexp: "match all errors", } retryOnExpectedErrorExactMatch := map[string]string{ matchExpectedErrorExactRegexp: "match expected error exactly", } retryOnExpectedErrorRegexpMatch := map[string]string{ matchExpectedErrorRegexp: "match expected error using a regex", } retryOnExpectedErrorRegexpMatchWithOthers := map[string]string{ matchNothingRegexp1: "unrelated regex that shouldn't match anything", matchExpectedErrorRegexp: "match expected error using a regex", matchNothingRegexp2: "another unrelated regex that shouldn't match anything", } retryOnErrorsThatWontMatch := map[string]string{ matchNothingRegexp1: "unrelated regex that shouldn't match anything", matchNothingRegexp2: "another unrelated regex that shouldn't match anything", } retryOnExpectedStdoutExactMatch := map[string]string{ matchStdoutExactlyRegexp: "match expected stdout exactly", } retryOnExpectedStdoutRegex := map[string]string{ matchStdoutRegexp: "match expected stdout using a regex", } testCases := []struct { expectedError error retryableErrors map[string]string action func() (string, error) description string maxRetries int }{ {description: "Return value on first try", retryableErrors: noRetryableErrors, maxRetries: 10, action: actionAlwaysReturnsExpected}, {description: "Return expected error, but no retryable errors requested", retryableErrors: noRetryableErrors, maxRetries: 10, expectedError: retry.FatalError{Underlying: expectedError}, action: actionAlwaysReturnsExpectedError}, {description: "Return expected error, but retryable errors do not match", retryableErrors: retryOnErrorsThatWontMatch, maxRetries: 10, expectedError: retry.FatalError{Underlying: expectedError}, action: actionAlwaysReturnsExpectedError}, {description: "Return expected error on all retries, use match all regex", retryableErrors: retryOnAllErrors, maxRetries: 10, expectedError: retry.MaxRetriesExceeded{Description: "Return expected error on all retries, use match all regex", MaxRetries: 10}, action: actionAlwaysReturnsExpectedError}, {description: "Return expected error on all retries, use match exactly regex", retryableErrors: retryOnExpectedErrorExactMatch, maxRetries: 3, expectedError: retry.MaxRetriesExceeded{Description: "Return expected error on all retries, use match exactly regex", MaxRetries: 3}, action: actionAlwaysReturnsExpectedError}, {description: "Return expected error on all retries, use regex", retryableErrors: retryOnExpectedErrorRegexpMatch, maxRetries: 1, expectedError: retry.MaxRetriesExceeded{Description: "Return expected error on all retries, use regex", MaxRetries: 1}, action: actionAlwaysReturnsExpectedError}, {description: "Return expected error on all retries, use regex amidst others", retryableErrors: retryOnExpectedErrorRegexpMatchWithOthers, maxRetries: 1, expectedError: retry.MaxRetriesExceeded{Description: "Return expected error on all retries, use regex amidst others", MaxRetries: 1}, action: actionAlwaysReturnsExpectedError}, {description: "Return unexpected error on all retries, but match stdout exactly", retryableErrors: retryOnExpectedStdoutExactMatch, maxRetries: 10, expectedError: retry.MaxRetriesExceeded{Description: "Return unexpected error on all retries, but match stdout exactly", MaxRetries: 10}, action: actionAlwaysReturnsUnexpectedError}, {description: "Return unexpected error on all retries, but match stdout with regex", retryableErrors: retryOnExpectedStdoutRegex, maxRetries: 3, expectedError: retry.MaxRetriesExceeded{Description: "Return unexpected error on all retries, but match stdout with regex", MaxRetries: 3}, action: actionAlwaysReturnsUnexpectedError}, {description: "Return value after 5 retries with expected error, match all", retryableErrors: retryOnAllErrors, maxRetries: 10, action: createActionThatReturnsExpectedAfterFiveRetriesOfExpectedErrors()}, {description: "Return value after 5 retries with expected error, match exactly", retryableErrors: retryOnExpectedErrorExactMatch, maxRetries: 10, action: createActionThatReturnsExpectedAfterFiveRetriesOfExpectedErrors()}, {description: "Return value after 5 retries with expected error, match regex", retryableErrors: retryOnExpectedErrorRegexpMatch, maxRetries: 10, action: createActionThatReturnsExpectedAfterFiveRetriesOfExpectedErrors()}, {description: "Return value after 5 retries with expected error, match multiple regex", retryableErrors: retryOnExpectedErrorRegexpMatchWithOthers, maxRetries: 10, action: createActionThatReturnsExpectedAfterFiveRetriesOfExpectedErrors()}, {description: "Return value after 5 retries with expected error, match stdout exactly", retryableErrors: retryOnExpectedStdoutExactMatch, maxRetries: 10, action: createActionThatReturnsExpectedAfterFiveRetriesOfUnexpectedErrors()}, {description: "Return value after 5 retries with expected error, match stdout with regex", retryableErrors: retryOnExpectedStdoutRegex, maxRetries: 10, action: createActionThatReturnsExpectedAfterFiveRetriesOfUnexpectedErrors()}, {description: "Return value after 5 retries with expected error, match exactly, but only do 4 retries", retryableErrors: retryOnExpectedErrorExactMatch, maxRetries: 4, expectedError: retry.MaxRetriesExceeded{Description: "Return value after 5 retries with expected error, match exactly, but only do 4 retries", MaxRetries: 4}, action: createActionThatReturnsExpectedAfterFiveRetriesOfExpectedErrors()}, {description: "Return unexpected error after 5 retries with expected error, match exactly", retryableErrors: retryOnExpectedErrorExactMatch, maxRetries: 10, expectedError: retry.FatalError{Underlying: ErrorCounter(6)}, action: createActionThatReturnsErrorCounterAfterFiveRetriesOfExpectedErrors()}, {description: "Return unexpected error after 5 retries with expected error, match regex", retryableErrors: retryOnExpectedErrorRegexpMatch, maxRetries: 10, expectedError: retry.FatalError{Underlying: ErrorCounter(6)}, action: createActionThatReturnsErrorCounterAfterFiveRetriesOfExpectedErrors()}, {description: "Return unexpected error after 5 retries with expected error, match multiple regex", retryableErrors: retryOnExpectedErrorRegexpMatchWithOthers, maxRetries: 10, expectedError: retry.FatalError{Underlying: ErrorCounter(6)}, action: createActionThatReturnsErrorCounterAfterFiveRetriesOfExpectedErrors()}, {description: "Return unexpected error after 5 retries with expected error, match all", retryableErrors: retryOnAllErrors, maxRetries: 10, expectedError: retry.MaxRetriesExceeded{Description: "Return unexpected error after 5 retries with expected error, match all", MaxRetries: 10}, action: createActionThatReturnsErrorCounterAfterFiveRetriesOfExpectedErrors()}, } for _, testCase := range testCases { testCase := testCase // capture range variable for each test case t.Run(testCase.description, func(t *testing.T) { t.Parallel() actualOutput, err := retry.DoWithRetryableErrorsE(t, testCase.description, testCase.retryableErrors, testCase.maxRetries, 1*time.Millisecond, testCase.action) assert.Equal(t, expectedOutput, actualOutput) if testCase.expectedError != nil { assert.Equal(t, testCase.expectedError, err) } else { require.NoError(t, err) assert.Equal(t, expectedOutput, actualOutput) } }) } } type ErrorCounter int func (count ErrorCounter) Error() string { return strconv.Itoa(int(count)) } ================================================ FILE: modules/shell/command.go ================================================ package shell import ( "bufio" "context" "errors" "fmt" "io" "os" "os/exec" "strings" "sync" "syscall" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // Command is a simpler struct for defining commands than Go's built-in Cmd. type Command struct { // Use the specified logger for the command's output. Use logger.Discard to not print the output while executing the command. Logger *logger.Logger Stdin io.Reader Env map[string]string // Additional environment variables to set Command string // The command to run WorkingDir string // The working directory Args []string // The args to pass to the command } // RunCommand runs a shell command and redirects its stdout and stderr to the stdout of the atomic script itself. If // there are any errors, fail the test. // // Deprecated: Use RunCommandContext instead. // //nolint:gocritic // hugeParam - changing to pointer would break public API func RunCommand(t testing.TestingT, command Command) { RunCommandContext(t, context.Background(), &command) } // RunCommandContext is like RunCommand but includes a context. func RunCommandContext(t testing.TestingT, ctx context.Context, command *Command) { err := RunCommandContextE(t, ctx, command) require.NoError(t, err) } // RunCommandE runs a shell command and redirects its stdout and stderr to the stdout of the atomic script itself. Any // returned error will be of type ErrWithCmdOutput, containing the output streams and the underlying error. // // Deprecated: Use RunCommandContextE instead. // //nolint:gocritic // hugeParam - changing to pointer would break public API func RunCommandE(t testing.TestingT, command Command) error { return RunCommandContextE(t, context.Background(), &command) } // RunCommandContextE is like RunCommandE but includes a context. func RunCommandContextE(t testing.TestingT, ctx context.Context, command *Command) error { output, err := runCommand(t, ctx, command) if err != nil { return &ErrWithCmdOutput{err, output} } return nil } // RunCommandAndGetOutput runs a shell command and returns its stdout and stderr as a string. The stdout and stderr of // that command will also be logged with Command.Log to make debugging easier. If there are any errors, fail the test. // // Deprecated: Use RunCommandContextAndGetOutput instead. // //nolint:gocritic // hugeParam - changing to pointer would break public API func RunCommandAndGetOutput(t testing.TestingT, command Command) string { return RunCommandContextAndGetOutput(t, context.Background(), &command) } // RunCommandContextAndGetOutput is like RunCommandAndGetOutput but includes a context. func RunCommandContextAndGetOutput(t testing.TestingT, ctx context.Context, command *Command) string { out, err := RunCommandContextAndGetOutputE(t, ctx, command) require.NoError(t, err) return out } // RunCommandAndGetOutputE runs a shell command and returns its stdout and stderr as a string. The stdout and stderr of // that command will also be logged with Command.Log to make debugging easier. Any returned error will be of type // ErrWithCmdOutput, containing the output streams and the underlying error. // // Deprecated: Use RunCommandContextAndGetOutputE instead. // //nolint:gocritic // hugeParam - changing to pointer would break public API func RunCommandAndGetOutputE(t testing.TestingT, command Command) (string, error) { return RunCommandContextAndGetOutputE(t, context.Background(), &command) } // RunCommandContextAndGetOutputE is like RunCommandAndGetOutputE but includes a context. func RunCommandContextAndGetOutputE(t testing.TestingT, ctx context.Context, command *Command) (string, error) { output, err := runCommand(t, ctx, command) if err != nil { return output.Combined(), &ErrWithCmdOutput{err, output} } return output.Combined(), nil } // RunCommandAndGetStdOut runs a shell command and returns solely its stdout (but not stderr) as a string. The stdout and // stderr of that command will also be logged with Command.Log to make debugging easier. If there are any errors, fail // the test. // // Deprecated: Use RunCommandContextAndGetStdOut instead. // //nolint:gocritic // hugeParam - changing to pointer would break public API func RunCommandAndGetStdOut(t testing.TestingT, command Command) string { return RunCommandContextAndGetStdOut(t, context.Background(), &command) } // RunCommandContextAndGetStdOut is like RunCommandAndGetStdOut but includes a context. func RunCommandContextAndGetStdOut(t testing.TestingT, ctx context.Context, command *Command) string { output, err := RunCommandContextAndGetStdOutE(t, ctx, command) require.NoError(t, err) return output } // RunCommandAndGetStdOutE runs a shell command and returns solely its stdout (but not stderr) as a string. The stdout // and stderr of that command will also be printed to the stdout and stderr of this Go program to make debugging easier. // Any returned error will be of type ErrWithCmdOutput, containing the output streams and the underlying error. // // Deprecated: Use RunCommandContextAndGetStdOutE instead. // //nolint:gocritic // hugeParam - changing to pointer would break public API func RunCommandAndGetStdOutE(t testing.TestingT, command Command) (string, error) { return RunCommandContextAndGetStdOutE(t, context.Background(), &command) } // RunCommandContextAndGetStdOutE is like RunCommandAndGetStdOutE but includes a context. func RunCommandContextAndGetStdOutE(t testing.TestingT, ctx context.Context, command *Command) (string, error) { output, err := runCommand(t, ctx, command) if err != nil { return output.Stdout(), &ErrWithCmdOutput{err, output} } return output.Stdout(), nil } // RunCommandAndGetStdOutErr runs a shell command and returns solely its stdout and stderr as a string. The stdout and // stderr of that command will also be logged with Command.Log to make debugging easier. If there are any errors, fail // the test. // // Deprecated: Use RunCommandContextAndGetStdOutErr instead. // //nolint:gocritic // hugeParam - changing to pointer would break public API func RunCommandAndGetStdOutErr(t testing.TestingT, command Command) (stdout string, stderr string) { return RunCommandContextAndGetStdOutErr(t, context.Background(), &command) } // RunCommandContextAndGetStdOutErr is like RunCommandAndGetStdOutErr but includes a context. func RunCommandContextAndGetStdOutErr(t testing.TestingT, ctx context.Context, command *Command) (stdout string, stderr string) { stdout, stderr, err := RunCommandContextAndGetStdOutErrE(t, ctx, command) require.NoError(t, err) return stdout, stderr } // RunCommandAndGetStdOutErrE runs a shell command and returns solely its stdout and stderr as a string. The stdout // and stderr of that command will also be printed to the stdout and stderr of this Go program to make debugging easier. // Any returned error will be of type ErrWithCmdOutput, containing the output streams and the underlying error. // // Deprecated: Use RunCommandContextAndGetStdOutErrE instead. // //nolint:gocritic // hugeParam - changing to pointer would break public API func RunCommandAndGetStdOutErrE(t testing.TestingT, command Command) (stdout string, stderr string, err error) { return RunCommandContextAndGetStdOutErrE(t, context.Background(), &command) } // RunCommandContextAndGetStdOutErrE is like RunCommandAndGetStdOutErrE but includes a context. func RunCommandContextAndGetStdOutErrE(t testing.TestingT, ctx context.Context, command *Command) (stdout string, stderr string, err error) { output, err := runCommand(t, ctx, command) if err != nil { return output.Stdout(), output.Stderr(), &ErrWithCmdOutput{err, output} } return output.Stdout(), output.Stderr(), nil } type ErrWithCmdOutput struct { Underlying error Output *output } func (e *ErrWithCmdOutput) Error() string { return fmt.Sprintf("error while running command: %v; %s", e.Underlying, e.Output.Stderr()) } // runCommand runs a shell command and stores each line from stdout and stderr in Output. Depending on the logger, the // stdout and stderr of that command will also be printed to the stdout and stderr of this Go program to make debugging // easier. func runCommand(t testing.TestingT, ctx context.Context, command *Command) (*output, error) { command.Logger.Logf(t, "Running command %s with args %s", command.Command, command.Args) cmd := exec.CommandContext(ctx, command.Command, command.Args...) cmd.Dir = command.WorkingDir if command.Stdin != nil { cmd.Stdin = command.Stdin } else { cmd.Stdin = os.Stdin } cmd.Env = formatEnvVars(command) stdout, err := cmd.StdoutPipe() if err != nil { return nil, err } stderr, err := cmd.StderrPipe() if err != nil { return nil, err } err = cmd.Start() if err != nil { return nil, err } output, err := readStdoutAndStderr(t, command.Logger, stdout, stderr) if err != nil { return output, err } return output, cmd.Wait() } // This function captures stdout and stderr into the given variables while still printing it to the stdout and stderr // of this Go program func readStdoutAndStderr(t testing.TestingT, log *logger.Logger, stdout, stderr io.ReadCloser) (*output, error) { out := newOutput() stdoutReader := bufio.NewReader(stdout) stderrReader := bufio.NewReader(stderr) wg := &sync.WaitGroup{} wg.Add(2) //nolint:mnd // 2 goroutines: one for stdout, one for stderr var stdoutErr, stderrErr error go func() { defer wg.Done() stdoutErr = readData(t, log, stdoutReader, out.stdout) }() go func() { defer wg.Done() stderrErr = readData(t, log, stderrReader, out.stderr) }() wg.Wait() if stdoutErr != nil { return out, stdoutErr } if stderrErr != nil { return out, stderrErr } return out, nil } func readData(t testing.TestingT, log *logger.Logger, reader *bufio.Reader, writer io.StringWriter) error { var ( line string readErr error ) for { line, readErr = reader.ReadString('\n') // remove newline, our output is in a slice, // one element per line. line = strings.TrimSuffix(line, "\n") // only return early if the line does not have // any contents. We could have a line that does // not not have a newline before io.EOF, we still // need to add it to the output. if len(line) == 0 && readErr == io.EOF { break } // logger.Logger has a Logf method, but not a Log method. // We have to use the format string indirection to avoid // interpreting any possible formatting characters in // the line. // // See https://github.com/gruntwork-io/terratest/issues/982. log.Logf(t, "%s", line) if _, err := writer.WriteString(line); err != nil { return err } if readErr != nil { break } } if readErr != io.EOF { return readErr } return nil } // GetExitCodeForRunCommandError tries to read the exit code for the error object returned from running a shell command. This is a bit tricky to do // in a way that works across platforms. func GetExitCodeForRunCommandError(err error) (int, error) { var errWithOutput *ErrWithCmdOutput if errors.As(err, &errWithOutput) { err = errWithOutput.Underlying } // http://stackoverflow.com/a/10385867/483528 var exitErr *exec.ExitError if errors.As(err, &exitErr) { // The program has exited with an exit code != 0 // This works on both Unix and Windows. Although package // syscall is generally platform dependent, WaitStatus is // defined for both Unix and Windows and in both cases has // an ExitStatus() method with the same signature. if status, ok := exitErr.Sys().(syscall.WaitStatus); ok { return status.ExitStatus(), nil } return 1, errors.New("could not determine exit code") } return 0, nil } func formatEnvVars(command *Command) []string { env := os.Environ() for key, value := range command.Env { env = append(env, fmt.Sprintf("%s=%s", key, value)) } return env } ================================================ FILE: modules/shell/command_test.go ================================================ package shell_test import ( "bytes" "errors" "fmt" "regexp" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/shell" ) func TestRunCommandAndGetOutput(t *testing.T) { t.Parallel() text := "Hello, World" cmd := &shell.Command{ Command: "echo", Args: []string{text}, } out := shell.RunCommandContextAndGetOutput(t, t.Context(), cmd) assert.Equal(t, text, strings.TrimSpace(out)) } func TestRunCommandAndGetOutputOrder(t *testing.T) { t.Parallel() stderrText := "Hello, Error" stdoutText := "Hello, World" expectedText := "Hello, Error\nHello, World\nHello, Error\nHello, World\nHello, Error\nHello, Error" bashCode := fmt.Sprintf(` echo_stderr(){ (>&2 echo "%s") # Add sleep to stabilize the test sleep .01s } echo_stdout(){ echo "%s" # Add sleep to stabilize the test sleep .01s } echo_stderr echo_stdout echo_stderr echo_stdout echo_stderr echo_stderr `, stderrText, stdoutText, ) cmd := &shell.Command{ Command: "bash", Args: []string{"-c", bashCode}, } out := shell.RunCommandContextAndGetOutput(t, t.Context(), cmd) assert.Equal(t, expectedText, strings.TrimSpace(out)) } func TestRunCommandGetExitCode(t *testing.T) { t.Parallel() cmd := &shell.Command{ Command: "bash", Args: []string{"-c", "exit 42"}, Logger: logger.Discard, } out, err := shell.RunCommandContextAndGetOutputE(t, t.Context(), cmd) assert.Empty(t, out) require.Error(t, err) code, err := shell.GetExitCodeForRunCommandError(err) require.NoError(t, err) assert.Equal(t, 42, code) } func TestRunCommandAndGetOutputConcurrency(t *testing.T) { t.Parallel() uniqueStderr := random.UniqueID() uniqueStdout := random.UniqueID() bashCode := fmt.Sprintf(` echo_stderr(){ sleep .0$[ ( $RANDOM %% 10 ) + 1 ]s (>&2 echo "%s") } echo_stdout(){ sleep .0$[ ( $RANDOM %% 10 ) + 1 ]s echo "%s" } for i in {1..500} do echo_stderr & echo_stdout & done wait `, uniqueStderr, uniqueStdout, ) cmd := &shell.Command{ Command: "bash", Args: []string{"-c", bashCode}, Logger: logger.Discard, } out := shell.RunCommandContextAndGetOutput(t, t.Context(), cmd) stdoutReg := regexp.MustCompile(uniqueStdout) stderrReg := regexp.MustCompile(uniqueStderr) assert.Len(t, stdoutReg.FindAllString(out, -1), 500) assert.Len(t, stderrReg.FindAllString(out, -1), 500) } func TestRunCommandWithHugeLineOutput(t *testing.T) { t.Parallel() // generate a ~100KB line bashCode := ` for i in {0..35000} do echo -n foo done echo ` cmd := &shell.Command{ Command: "bash", Args: []string{"-c", bashCode}, Logger: logger.Discard, // don't print that line to stdout } out, err := shell.RunCommandContextAndGetOutputE(t, t.Context(), cmd) require.NoError(t, err) var buffer bytes.Buffer for i := 0; i <= 35000; i++ { buffer.WriteString("foo") } assert.Equal(t, out, buffer.String()) } // TestRunCommandOutputError ensures that getting the output never panics, even if no command was ever run. func TestRunCommandOutputError(t *testing.T) { t.Parallel() cmd := &shell.Command{ Command: "thisbinarydoesnotexistbecausenobodyusesnamesthatlong", Args: []string{"-no-flag"}, Logger: logger.Discard, } out, err := shell.RunCommandContextAndGetOutputE(t, t.Context(), cmd) assert.Empty(t, out) assert.Error(t, err) } func TestCommandOutputType(t *testing.T) { t.Parallel() stdout := "hello world" stderr := "this command has failed" _, err := shell.RunCommandContextAndGetOutputE(t, t.Context(), &shell.Command{ Command: "sh", Args: []string{"-c", `echo "` + stdout + `" && echo "` + stderr + `" >&2 && exit 1`}, Logger: logger.Discard, }) if err != nil { var o *shell.ErrWithCmdOutput if !errors.As(err, &o) { t.Fatalf("did not get correct type. got=%T", err) } assert.Len(t, o.Output.Stdout(), len(stdout)) assert.Len(t, o.Output.Stderr(), len(stderr)) assert.Len(t, o.Output.Combined(), len(stdout)+len(stderr)+1) // +1 for newline } } func TestCommandWithStdoutAndStdErr(t *testing.T) { t.Parallel() stdout := "hello world" stderr := "this command has failed" command := &shell.Command{ Command: "sh", Args: []string{"-c", `echo "` + stdout + `" && echo "` + stderr + `" >&2`}, Logger: logger.Discard, } t.Run("MustNotError", func(t *testing.T) { t.Parallel() ostdout, ostderr := shell.RunCommandContextAndGetStdOutErr(t, t.Context(), command) assert.Equal(t, stdout, ostdout) assert.Equal(t, stderr, ostderr) }) t.Run("ReturnError", func(t *testing.T) { t.Parallel() ostdout, ostderr, err := shell.RunCommandContextAndGetStdOutErrE(t, t.Context(), command) require.NoError(t, err) assert.Equal(t, stdout, ostdout) assert.Equal(t, stderr, ostderr) }) } func TestRunCommandWithStdinAndGetOutput(t *testing.T) { t.Parallel() text := "Hello, World" cmd := &shell.Command{ Command: "cat", Stdin: strings.NewReader(text), } out := shell.RunCommandContextAndGetOutput(t, t.Context(), cmd) assert.Equal(t, text, strings.TrimSpace(out)) } ================================================ FILE: modules/shell/output.go ================================================ package shell import ( "strings" "sync" ) // output contains the output after runnig a command. type output struct { stdout *outputStream stderr *outputStream // merged contains stdout and stderr merged into one stream. merged *merged } func newOutput() *output { m := new(merged) return &output{ merged: m, stdout: &outputStream{ merged: m, }, stderr: &outputStream{ merged: m, }, } } func (o *output) Stdout() string { if o == nil { return "" } return o.stdout.String() } func (o *output) Stderr() string { if o == nil { return "" } return o.stderr.String() } func (o *output) Combined() string { if o == nil { return "" } return o.merged.String() } type outputStream struct { *merged Lines []string } func (st *outputStream) WriteString(s string) (n int, err error) { st.Lines = append(st.Lines, s) return st.merged.WriteString(s) } func (st *outputStream) String() string { if st == nil { return "" } return strings.Join(st.Lines, "\n") } type merged struct { Lines []string // ensure that there are no parallel writes sync.Mutex } func (m *merged) String() string { if m == nil { return "" } return strings.Join(m.Lines, "\n") } func (m *merged) WriteString(s string) (n int, err error) { m.Lock() defer m.Unlock() m.Lines = append(m.Lines, s) return len(s), nil } ================================================ FILE: modules/shell/shell.go ================================================ // Package shell allows to run commands in a shell. package shell ================================================ FILE: modules/slack/doc.go ================================================ // Package slack contains routines useful for testing slack integrations. package slack ================================================ FILE: modules/slack/validate.go ================================================ package slack import ( "strconv" "strings" "time" "github.com/gruntwork-io/terratest/modules/testing" "github.com/slack-go/slack" ) // ValidateExpectedSlackMessageE validates whether a message containing the expected text was posted in the given channel // ID, looking back historyLimit messages up to the given duration. For example, if you set (15*time.Minute) as the // lookBack parameter with historyLimit set to 50, then this will look back the last 50 messages, up to 15 minutes ago. // This expects a slack token to be provided. This returns MessageNotFoundErr when there is no match. // For the purposes of matching, this only checks the following blocks: // - Section block text // - Header block text // All other blocks are ignored in the validation. // NOTE: This only looks for bot posted messages. func ValidateExpectedSlackMessageE( t testing.TestingT, token, channelID, expectedText string, historyLimit int, lookBack time.Duration, ) error { lookBackTime := time.Now().Add(-1 * lookBack) slackClt := slack.New(token) params := slack.GetConversationHistoryParameters{ ChannelID: channelID, Limit: historyLimit, Oldest: strconv.FormatInt(lookBackTime.Unix(), 10), } resp, err := slackClt.GetConversationHistory(¶ms) if err != nil { return err } for i := range resp.Messages { if checkMessageContainsText(&resp.Messages[i].Msg, expectedText) { return nil } if resp.Messages[i].SubMessage != nil { if checkMessageContainsText(resp.Messages[i].SubMessage, expectedText) { return nil } } } return MessageNotFoundErr{} } func checkMessageContainsText(msg *slack.Msg, expectedText string) bool { // If this message is not a bot message, ignore. if msg.Type != slack.MsgSubTypeBotMessage && msg.BotID == "" { return false } // Check message text if strings.Contains(msg.Text, expectedText) { return true } // Check attachments for i := range msg.Attachments { if strings.Contains(msg.Attachments[i].Text, expectedText) { return true } } // Check blocks for _, block := range msg.Blocks.BlockSet { switch block.BlockType() { case slack.MBTSection: sectionBlk := block.(*slack.SectionBlock) if sectionBlk.Text != nil && strings.Contains(sectionBlk.Text.Text, expectedText) { return true } case slack.MBTHeader: headerBlk := block.(*slack.HeaderBlock) if headerBlk.Text != nil && strings.Contains(headerBlk.Text.Text, expectedText) { return true } case slack.MBTDivider, slack.MBTImage, slack.MBTAction, slack.MBTContext, slack.MBTFile, slack.MBTInput, slack.MBTRichText, slack.MBTCall, slack.MBTVideo: } } return false } type MessageNotFoundErr struct{} func (err MessageNotFoundErr) Error() string { return "Could not find the expected text in any of the messages posted in the given channel." } ================================================ FILE: modules/slack/validate_test.go ================================================ package slack_test import ( "os" "testing" "time" "github.com/slack-go/slack" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terratest/modules/environment" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/retry" terratestslack "github.com/gruntwork-io/terratest/modules/slack" ) const ( slackTokenEnv = "SLACK_TOKEN_FOR_TEST" slackChannelIDEnv = "SLACK_CHANNEL_ID_FOR_TEST" ) func TestValidateSlackMessage(t *testing.T) { t.Parallel() environment.RequireEnvVar(t, slackTokenEnv) environment.RequireEnvVar(t, slackChannelIDEnv) token := os.Getenv(slackTokenEnv) channelID := os.Getenv(slackChannelIDEnv) uniqueID := random.UniqueID() msgTxt := "Test message from terratest: " + uniqueID slackClt := slack.New(token) _, _, err := slackClt.PostMessage( channelID, slack.MsgOptionText(msgTxt, false), ) require.NoError(t, err) retry.DoWithRetry( t, "wait for slack message", 10, 10*time.Second, func() (string, error) { err := terratestslack.ValidateExpectedSlackMessageE(t, token, channelID, msgTxt, 10, 5*time.Minute) return "", err }, ) } ================================================ FILE: modules/ssh/agent.go ================================================ package ssh import ( "crypto/x509" "encoding/pem" "net" "os" "path/filepath" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/testing" "golang.org/x/crypto/ssh/agent" ) type SshAgent struct { stop chan bool stopped chan bool socketDir string socketFile string agent agent.Agent ln net.Listener } // Create SSH agent, start it in background and returns control back to the main thread // You should stop the agent to cleanup files afterwards by calling `defer s.Stop()` func NewSshAgent(t testing.TestingT, socketDir string, socketFile string) (*SshAgent, error) { var err error s := &SshAgent{make(chan bool), make(chan bool), socketDir, socketFile, agent.NewKeyring(), nil} s.ln, err = net.Listen("unix", s.socketFile) if err != nil { return nil, err } go s.run(t) return s, nil } // expose socketFile variable func (s *SshAgent) SocketFile() string { return s.socketFile } // SSH Agent listener and handler func (s *SshAgent) run(t testing.TestingT) { defer close(s.stopped) for { select { case <-s.stop: return default: c, err := s.ln.Accept() if err != nil { select { // When s.Stop() closes the listener, s.ln.Accept() returns an error that can be ignored // since the agent is in stopping process case <-s.stop: return // When s.ln.Accept() returns a legit error, we print it and continue accepting further requests default: logger.Default.Logf(t, "could not accept connection to agent %v", err) continue } } else { go func(c net.Conn) { defer c.Close() err := agent.ServeAgent(s.agent, c) if err != nil { logger.Default.Logf(t, "could not serve ssh agent %v", err) } }(c) } } } } // Stop and clean up SSH agent func (s *SshAgent) Stop() { close(s.stop) s.ln.Close() <-s.stopped os.RemoveAll(s.socketDir) } // Instantiates and returns an in-memory ssh agent with the given KeyPair already added // You should stop the agent to cleanup files afterwards by calling `defer sshAgent.Stop()` func SshAgentWithKeyPair(t testing.TestingT, keyPair *KeyPair) *SshAgent { sshAgent, err := SshAgentWithKeyPairE(t, keyPair) if err != nil { t.Fatal(err) } return sshAgent } func SshAgentWithKeyPairE(t testing.TestingT, keyPair *KeyPair) (*SshAgent, error) { sshAgent, err := SshAgentWithKeyPairsE(t, []*KeyPair{keyPair}) return sshAgent, err } func SshAgentWithKeyPairs(t testing.TestingT, keyPairs []*KeyPair) *SshAgent { sshAgent, err := SshAgentWithKeyPairsE(t, keyPairs) if err != nil { t.Fatal(err) } return sshAgent } // Instantiates and returns an in-memory ssh agent with the given KeyPair(s) already added // You should stop the agent to cleanup files afterwards by calling `defer sshAgent.Stop()` func SshAgentWithKeyPairsE(t testing.TestingT, keyPairs []*KeyPair) (*SshAgent, error) { logger.Default.Logf(t, "Generating SSH Agent with given KeyPair(s)") // Instantiate a temporary SSH agent socketDir, err := os.MkdirTemp("", "ssh-agent-") if err != nil { return nil, err } socketFile := filepath.Join(socketDir, "ssh_auth.sock") sshAgent, err := NewSshAgent(t, socketDir, socketFile) if err != nil { return nil, err } // add given ssh keys to the newly created agent for _, keyPair := range keyPairs { // Create SSH key for the agent using the given SSH key pair(s) block, _ := pem.Decode([]byte(keyPair.PrivateKey)) privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) if err != nil { return nil, err } key := agent.AddedKey{PrivateKey: privateKey} sshAgent.agent.Add(key) } return sshAgent, err } ================================================ FILE: modules/ssh/agent_test.go ================================================ package ssh import ( "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/assert" ) func TestSshAgentWithKeyPair(t *testing.T) { t.Parallel() keyPair := GenerateRSAKeyPair(t, 2048) sshAgent := SshAgentWithKeyPair(t, keyPair) // ensure that socket directory is set in environment, and it exists sockFile := filepath.Join(sshAgent.socketDir, "ssh_auth.sock") assert.FileExists(t, sockFile) // assert that there's 1 key in the agent keys, err := sshAgent.agent.List() assert.NoError(t, err) assert.Len(t, keys, 1) sshAgent.Stop() // is socketDir removed as expected? if _, err := os.Stat(sshAgent.socketDir); !os.IsNotExist(err) { assert.FailNow(t, "ssh agent failed to remove socketDir on Stop()") } } func TestSshAgentWithKeyPairs(t *testing.T) { t.Parallel() keyPair := GenerateRSAKeyPair(t, 2048) keyPair2 := GenerateRSAKeyPair(t, 2048) sshAgent := SshAgentWithKeyPairs(t, []*KeyPair{keyPair, keyPair2}) defer sshAgent.Stop() keys, err := sshAgent.agent.List() assert.NoError(t, err) assert.Len(t, keys, 2) } func TestMultipleSshAgents(t *testing.T) { t.Parallel() keyPair := GenerateRSAKeyPair(t, 2048) keyPair2 := GenerateRSAKeyPair(t, 2048) // start a couple of agents sshAgent := SshAgentWithKeyPair(t, keyPair) sshAgent2 := SshAgentWithKeyPair(t, keyPair2) defer sshAgent.Stop() defer sshAgent2.Stop() // collect public keys from the agents keys, err := sshAgent.agent.List() assert.NoError(t, err) keys2, err := sshAgent2.agent.List() assert.NoError(t, err) // check that all keys match up to expected assert.NotEqual(t, keys, keys2) assert.Equal(t, strings.TrimSpace(keyPair.PublicKey), keys[0].String()) assert.Equal(t, strings.TrimSpace(keyPair2.PublicKey), keys2[0].String()) } ================================================ FILE: modules/ssh/key_pair.go ================================================ package ssh import ( "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/pem" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/testing" "golang.org/x/crypto/ssh" ) // KeyPair is a public and private key pair that can be used for SSH access. type KeyPair struct { PublicKey string PrivateKey string } // GenerateRSAKeyPair generates an RSA Keypair and return the public and private keys. func GenerateRSAKeyPair(t testing.TestingT, keySize int) *KeyPair { keyPair, err := GenerateRSAKeyPairE(t, keySize) if err != nil { t.Fatal(err) } return keyPair } // GenerateRSAKeyPairE generates an RSA Keypair and return the public and private keys. func GenerateRSAKeyPairE(t testing.TestingT, keySize int) (*KeyPair, error) { logger.Default.Logf(t, "Generating new public/private key of size %d", keySize) rsaKeyPair, err := rsa.GenerateKey(rand.Reader, keySize) if err != nil { return nil, err } // Extract the private key keyPemBlock := &pem.Block{ Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(rsaKeyPair), } keyPem := string(pem.EncodeToMemory(keyPemBlock)) // Extract the public key sshPubKey, err := ssh.NewPublicKey(rsaKeyPair.Public()) if err != nil { return nil, err } sshPubKeyBytes := ssh.MarshalAuthorizedKey(sshPubKey) sshPubKeyStr := string(sshPubKeyBytes) // Return return &KeyPair{PublicKey: sshPubKeyStr, PrivateKey: keyPem}, nil } ================================================ FILE: modules/ssh/key_pair_test.go ================================================ package ssh import ( "testing" "github.com/stretchr/testify/assert" ) // Basic test to ensure we can successfully generate key pairs (no explicit validation for now) func TestGenerateRSAKeyPair(t *testing.T) { t.Parallel() keyPair := GenerateRSAKeyPair(t, 2048) assert.Contains(t, keyPair.PublicKey, "ssh-rsa") assert.Contains(t, keyPair.PrivateKey, "-----BEGIN RSA PRIVATE KEY-----") } ================================================ FILE: modules/ssh/session.go ================================================ package ssh import ( "io" "net" "reflect" "slices" "strconv" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/testing" "golang.org/x/crypto/ssh" ) // SshConnectionOptions are the options for an SSH connection. type SshConnectionOptions struct { Username string Address string Port int AuthMethods []ssh.AuthMethod Command string JumpHost *SshConnectionOptions } // ConnectionString returns the connection string for an SSH connection. func (options *SshConnectionOptions) ConnectionString() string { return net.JoinHostPort(options.Address, strconv.Itoa(options.Port)) } // SshSession is a container object for all resources created by an SSH session. The reason we need this is so that we can do a // single defer in a top-level method that calls the Cleanup method to go through and ensure all of these resources are // released and cleaned up. type SshSession struct { Options *SshConnectionOptions Client *ssh.Client Session *ssh.Session JumpHost *JumpHostSession Input *func(io.WriteCloser) } // Cleanup cleans up an existing SSH session. func (sshSession *SshSession) Cleanup(t testing.TestingT) { if sshSession == nil { return } // Closing the session may result in an EOF error if it's already closed (e.g. due to hitting CTRL + D), so // don't report those errors, as there is nothing actually wrong in that case. Close(t, sshSession.Session, io.EOF.Error()) Close(t, sshSession.Client) sshSession.JumpHost.Cleanup(t) } // JumpHostSession is a session with a jump host. type JumpHostSession struct { JumpHostClient *ssh.Client HostVirtualConnection net.Conn HostConnection ssh.Conn } // Cleanup cleans the jump host session up. func (jumpHost *JumpHostSession) Cleanup(t testing.TestingT) { if jumpHost == nil { return } // Closing a connection may result in an EOF error if it's already closed (e.g. due to hitting CTRL + D), so // don't report those errors, as there is nothing actually wrong in that case. Close(t, jumpHost.HostConnection, io.EOF.Error()) Close(t, jumpHost.HostVirtualConnection, io.EOF.Error()) Close(t, jumpHost.JumpHostClient) } // Closeable can be closed. type Closeable interface { Close() error } // Close closes a Closeable. func Close(t testing.TestingT, closeable Closeable, ignoreErrors ...string) { if interfaceIsNil(closeable) { return } if err := closeable.Close(); err != nil && !slices.Contains(ignoreErrors, err.Error()) { logger.Default.Logf(t, "Error closing %s: %s", closeable, err.Error()) } } // Go is a shitty language. Checking an interface directly against nil does not work, and if you don't know the exact // types the interface may be ahead of time, the only way to know if you're dealing with nil is to use reflection. // http://stackoverflow.com/questions/13476349/check-for-nil-and-nil-interface-in-go func interfaceIsNil(i interface{}) bool { return i == nil || reflect.ValueOf(i).IsNil() } ================================================ FILE: modules/ssh/session_test.go ================================================ package ssh import ( "testing" "github.com/stretchr/testify/require" ) func TestSshConnectionOptions_ConnectionString(t *testing.T) { type fields struct { Address string Port int } tests := []struct { name string fields fields want string }{ { name: "plain ipv4", fields: fields{ Address: "192.168.86.68", Port: 22, }, want: "192.168.86.68:22", }, { name: "plain ipv6", fields: fields{ Address: "2001:0db8:85a3:0000:0000:8a2e:0370:7334", Port: 22, }, want: "[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:22", }, { name: "host fqdn", fields: fields{ Address: "host.for.test.com", Port: 443, }, want: "host.for.test.com:443", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { options := &SshConnectionOptions{ Address: tt.fields.Address, Port: tt.fields.Port, } got := options.ConnectionString() require.Equal(t, tt.want, got) }) } } ================================================ FILE: modules/ssh/ssh.go ================================================ // Package ssh allows to manage SSH connections and send commands through them. package ssh import ( "errors" "fmt" "io" "net" "os" "path/filepath" "strconv" "strings" "time" "github.com/gruntwork-io/terratest/modules/files" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/testing" "github.com/hashicorp/go-multierror" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/agent" ) // Host is a remote host. type Host struct { Hostname string // host name or ip address SshUserName string // user name // set one or more authentication methods, // the first valid method will be used SshKeyPair *KeyPair // ssh key pair to use as authentication method (disabled by default) SshAgent bool // enable authentication using your existing local SSH agent (disabled by default) OverrideSshAgent *SshAgent // enable an in process `SshAgent` for connections to this host (disabled by default) Password string // plain text password (blank by default) CustomPort int // port number to use to connect to the host (port 22 will be used if unset) } type ScpDownloadOptions struct { FileNameFilters []string //File names to match. May include bash-style wildcards. E.g., *.log. MaxFileSizeMB int //Don't grab any files > MaxFileSizeMB RemoteDir string //Copy from this directory on the remote machine LocalDir string //Copy RemoteDir to this directory on the local machine RemoteHost Host //Connection information for the remote machine } // ScpFileToE uploads the contents using SCP to the given host and fails the test if the connection fails. func ScpFileTo(t testing.TestingT, host Host, mode os.FileMode, remotePath, contents string) { err := ScpFileToE(t, host, mode, remotePath, contents) if err != nil { t.Fatal(err) } } // ScpFileToE uploads the contents using SCP to the given host and return an error if the process fails. func ScpFileToE(t testing.TestingT, host Host, mode os.FileMode, remotePath, contents string) error { authMethods, err := createAuthMethodsForHost(host) if err != nil { return err } dir, file := filepath.Split(remotePath) hostOptions := SshConnectionOptions{ Username: host.SshUserName, Address: host.Hostname, Port: host.getPort(), Command: "/usr/bin/scp -t " + dir, AuthMethods: authMethods, } scp := sendScpCommandsToCopyFile(mode, file, contents) sshSession := &SshSession{ Options: &hostOptions, JumpHost: &JumpHostSession{}, Input: &scp, } defer sshSession.Cleanup(t) _, err = runSSHCommand(t, sshSession) return err } // ScpFileFrom downloads the file from remotePath on the given host using SCP. func ScpFileFrom(t testing.TestingT, host Host, remotePath string, localDestination *os.File, useSudo bool) { err := ScpFileFromE(t, host, remotePath, localDestination, useSudo) if err != nil { t.Fatal(err) } } // ScpFileFromE downloads the file from remotePath on the given host using SCP and returns an error if the process fails. func ScpFileFromE(t testing.TestingT, host Host, remotePath string, localDestination *os.File, useSudo bool) error { authMethods, err := createAuthMethodsForHost(host) if err != nil { return err } dir := filepath.Dir(remotePath) hostOptions := SshConnectionOptions{ Username: host.SshUserName, Address: host.Hostname, Port: host.getPort(), Command: "/usr/bin/scp -t " + dir, AuthMethods: authMethods, } sshSession := &SshSession{ Options: &hostOptions, JumpHost: &JumpHostSession{}, } defer sshSession.Cleanup(t) return copyFileFromRemote(t, sshSession, localDestination, remotePath, useSudo) } // ScpDirFrom downloads all the files from remotePath on the given host using SCP. func ScpDirFrom(t testing.TestingT, options ScpDownloadOptions, useSudo bool) { err := ScpDirFromE(t, options, useSudo) if err != nil { t.Fatal(err) } } // ScpDirFromE downloads all the files from remotePath on the given host using SCP // and returns an error if the process fails. NOTE: only files within remotePath will // be downloaded. This function will not recursively download subdirectories or follow // symlinks. func ScpDirFromE(t testing.TestingT, options ScpDownloadOptions, useSudo bool) error { authMethods, err := createAuthMethodsForHost(options.RemoteHost) if err != nil { return err } hostOptions := SshConnectionOptions{ Username: options.RemoteHost.SshUserName, Address: options.RemoteHost.Hostname, Port: options.RemoteHost.getPort(), Command: "/usr/bin/scp -t " + options.RemoteDir, AuthMethods: authMethods, } sshSession := &SshSession{ Options: &hostOptions, JumpHost: &JumpHostSession{}, } defer sshSession.Cleanup(t) filesInDir, err := listFileInRemoteDir(t, sshSession, options, useSudo) if err != nil { return err } if !files.FileExists(options.LocalDir) { err := os.MkdirAll(options.LocalDir, 0755) if err != nil { return err } } var errorsOccurred = new(multierror.Error) for _, fullRemoteFilePath := range filesInDir { fileName := filepath.Base(fullRemoteFilePath) localFilePath := filepath.Join(options.LocalDir, fileName) localFile, err := os.Create(localFilePath) if err != nil { return err } logger.Default.Logf(t, "Copying remote file: %s to local path %s", fullRemoteFilePath, localFilePath) err = copyFileFromRemote(t, sshSession, localFile, fullRemoteFilePath, useSudo) errorsOccurred = multierror.Append(errorsOccurred, err) } return errorsOccurred.ErrorOrNil() } // CheckSshConnection checks that you can connect via SSH to the given host and fail the test if the connection fails. func CheckSshConnection(t testing.TestingT, host Host) { err := CheckSshConnectionE(t, host) if err != nil { t.Fatal(err) } } // CheckSshConnectionE checks that you can connect via SSH to the given host and return an error if the connection fails. func CheckSshConnectionE(t testing.TestingT, host Host) error { _, err := CheckSshCommandE(t, host, "'exit'") return err } // CheckSshConnectionWithRetry attempts to connect via SSH until max retries has been exceeded and fails the test // if the connection fails func CheckSshConnectionWithRetry(t testing.TestingT, host Host, retries int, sleepBetweenRetries time.Duration, f ...func(testing.TestingT, Host) error) { handler := CheckSshConnectionE if f != nil { handler = f[0] } err := CheckSshConnectionWithRetryE(t, host, retries, sleepBetweenRetries, handler) if err != nil { t.Fatal(err) } } // CheckSshConnectionWithRetryE attempts to connect via SSH until max retries has been exceeded and returns an error if // the connection fails func CheckSshConnectionWithRetryE(t testing.TestingT, host Host, retries int, sleepBetweenRetries time.Duration, f ...func(testing.TestingT, Host) error) error { handler := CheckSshConnectionE if f != nil { handler = f[0] } _, err := retry.DoWithRetryE(t, fmt.Sprintf("Checking SSH connection to %s", host.Hostname), retries, sleepBetweenRetries, func() (string, error) { return "", handler(t, host) }) return err } // CheckSshCommand checks that you can connect via SSH to the given host and run the given command. Returns the stdout/stderr. func CheckSshCommand(t testing.TestingT, host Host, command string) string { out, err := CheckSshCommandE(t, host, command) if err != nil { t.Fatal(err) } return out } // CheckSshCommandE checks that you can connect via SSH to the given host and run the given command. Returns the stdout/stderr. func CheckSshCommandE(t testing.TestingT, host Host, command string) (string, error) { authMethods, err := createAuthMethodsForHost(host) if err != nil { return "", err } hostOptions := SshConnectionOptions{ Username: host.SshUserName, Address: host.Hostname, Port: host.getPort(), Command: command, AuthMethods: authMethods, } sshSession := &SshSession{ Options: &hostOptions, JumpHost: &JumpHostSession{}, } defer sshSession.Cleanup(t) return runSSHCommand(t, sshSession) } // CheckSshCommandWithRetry checks that you can connect via SSH to the given host and run the given command until max retries have been exceeded. Returns the stdout/stderr. func CheckSshCommandWithRetry(t testing.TestingT, host Host, command string, retries int, sleepBetweenRetries time.Duration, f ...func(testing.TestingT, Host, string) (string, error)) string { handler := CheckSshCommandE if f != nil { handler = f[0] } out, err := CheckSshCommandWithRetryE(t, host, command, retries, sleepBetweenRetries, handler) if err != nil { t.Fatal(err) } return out } // CheckSshCommandWithRetryE checks that you can connect via SSH to the given host and run the given command until max retries has been exceeded. // It return an error if the command fails after max retries has been exceeded. func CheckSshCommandWithRetryE(t testing.TestingT, host Host, command string, retries int, sleepBetweenRetries time.Duration, f ...func(testing.TestingT, Host, string) (string, error)) (string, error) { handler := CheckSshCommandE if f != nil { handler = f[0] } return retry.DoWithRetryE(t, fmt.Sprintf("Checking SSH connection to %s", host.Hostname), retries, sleepBetweenRetries, func() (string, error) { return handler(t, host, command) }) } // CheckPrivateSshConnection attempts to connect to privateHost (which is not addressable from the Internet) via a // separate publicHost (which is addressable from the Internet) and then executes "command" on privateHost and returns // its output. It is useful for checking that it's possible to SSH from a Bastion Host to a private instance. func CheckPrivateSshConnection(t testing.TestingT, publicHost Host, privateHost Host, command string) string { out, err := CheckPrivateSshConnectionE(t, publicHost, privateHost, command) if err != nil { t.Fatal(err) } return out } // CheckPrivateSshConnectionE attempts to connect to privateHost (which is not addressable from the Internet) via a // separate publicHost (which is addressable from the Internet) and then executes "command" on privateHost and returns // its output. It is useful for checking that it's possible to SSH from a Bastion Host to a private instance. func CheckPrivateSshConnectionE(t testing.TestingT, publicHost Host, privateHost Host, command string) (string, error) { jumpHostAuthMethods, err := createAuthMethodsForHost(publicHost) if err != nil { return "", err } jumpHostOptions := SshConnectionOptions{ Username: publicHost.SshUserName, Address: publicHost.Hostname, Port: publicHost.getPort(), AuthMethods: jumpHostAuthMethods, } hostAuthMethods, err := createAuthMethodsForHost(privateHost) if err != nil { return "", err } hostOptions := SshConnectionOptions{ Username: privateHost.SshUserName, Address: privateHost.Hostname, Port: privateHost.getPort(), Command: command, AuthMethods: hostAuthMethods, JumpHost: &jumpHostOptions, } sshSession := &SshSession{ Options: &hostOptions, JumpHost: &JumpHostSession{}, } defer sshSession.Cleanup(t) return runSSHCommand(t, sshSession) } // FetchContentsOfFiles connects to the given host via SSH and fetches the contents of the files at the given filePaths. // If useSudo is true, then the contents will be retrieved using sudo. This method returns a map from file path to // contents. func FetchContentsOfFiles(t testing.TestingT, host Host, useSudo bool, filePaths ...string) map[string]string { out, err := FetchContentsOfFilesE(t, host, useSudo, filePaths...) if err != nil { t.Fatal(err) } return out } // FetchContentsOfFilesE connects to the given host via SSH and fetches the contents of the files at the given filePaths. // If useSudo is true, then the contents will be retrieved using sudo. This method returns a map from file path to // contents. func FetchContentsOfFilesE(t testing.TestingT, host Host, useSudo bool, filePaths ...string) (map[string]string, error) { filePathToContents := map[string]string{} for _, filePath := range filePaths { contents, err := FetchContentsOfFileE(t, host, useSudo, filePath) if err != nil { return nil, err } filePathToContents[filePath] = contents } return filePathToContents, nil } // FetchContentsOfFile connects to the given host via SSH and fetches the contents of the file at the given filePath. // If useSudo is true, then the contents will be retrieved using sudo. This method returns the contents of that file. func FetchContentsOfFile(t testing.TestingT, host Host, useSudo bool, filePath string) string { out, err := FetchContentsOfFileE(t, host, useSudo, filePath) if err != nil { t.Fatal(err) } return out } // FetchContentsOfFileE connects to the given host via SSH and fetches the contents of the file at the given filePath. // If useSudo is true, then the contents will be retrieved using sudo. This method returns the contents of that file. func FetchContentsOfFileE(t testing.TestingT, host Host, useSudo bool, filePath string) (string, error) { command := fmt.Sprintf("cat %s", filePath) if useSudo { command = fmt.Sprintf("sudo %s", command) } return CheckSshCommandE(t, host, command) } func listFileInRemoteDir(t testing.TestingT, sshSession *SshSession, options ScpDownloadOptions, useSudo bool) ([]string, error) { logger.Default.Logf(t, "Running command %s on %s@%s", sshSession.Options.Command, sshSession.Options.Username, sshSession.Options.Address) var result []string var findCommandArgs []string if useSudo { findCommandArgs = append(findCommandArgs, "sudo") } findCommandArgs = append(findCommandArgs, "find", options.RemoteDir) findCommandArgs = append(findCommandArgs, "-type", "f") filtersLength := len(options.FileNameFilters) if options.FileNameFilters != nil && filtersLength > 0 { findCommandArgs = append(findCommandArgs, "\\(") for i, curFilter := range options.FileNameFilters { // due to inconsistent bash behavior we need to wrap the // filter in single quotes curFilter = fmt.Sprintf("'%s'", curFilter) findCommandArgs = append(findCommandArgs, "-name", curFilter) // only add the or flag if we're not the last element if filtersLength-i > 1 { findCommandArgs = append(findCommandArgs, "-o") } } findCommandArgs = append(findCommandArgs, "\\)") } if options.MaxFileSizeMB != 0 { findCommandArgs = append(findCommandArgs, "-size", fmt.Sprintf("-%dM", options.MaxFileSizeMB)) } finalCommandString := strings.Join(findCommandArgs, " ") resultString, err := CheckSshCommandE(t, options.RemoteHost, finalCommandString) if err != nil { return result, err } // The last character returned is `\n` this results in an extra "" array // member when we do the split below. Cut off the last character to avoid // having to remove the blank entry in the array. resultString = resultString[:len(resultString)-1] result = append(result, strings.Split(resultString, "\n")...) return result, nil } // Added based on code: https://github.com/bramvdbogaerde/go-scp/pull/6/files func copyFileFromRemote(t testing.TestingT, sshSession *SshSession, file *os.File, remotePath string, useSudo bool) error { if err := setUpSSHClient(sshSession); err != nil { return err } if err := setUpSSHSession(sshSession); err != nil { return err } command := fmt.Sprintf("dd if=%s", remotePath) if useSudo { command = fmt.Sprintf("sudo %s", command) } logger.Default.Logf(t, "Running command %s on %s@%s", command, sshSession.Options.Username, sshSession.Options.Address) r, err := sshSession.Session.Output(command) if err != nil { fmt.Printf("error reading from remote stdout: %s", err) } defer sshSession.Session.Close() //write to local file _, err = file.Write(r) return err } func runSSHCommand(t testing.TestingT, sshSession *SshSession) (string, error) { logger.Default.Logf(t, "Running command %s on %s@%s", sshSession.Options.Command, sshSession.Options.Username, sshSession.Options.Address) if err := setUpSSHClient(sshSession); err != nil { return "", err } if err := setUpSSHSession(sshSession); err != nil { return "", err } if sshSession.Input != nil { w, err := sshSession.Session.StdinPipe() if err != nil { return "", err } go func() { defer w.Close() (*sshSession.Input)(w) }() } bytes, err := sshSession.Session.CombinedOutput(sshSession.Options.Command) if err != nil { return string(bytes), err } return string(bytes), nil } func setUpSSHClient(sshSession *SshSession) error { if sshSession.Options.JumpHost == nil { return fillSSHClientForHost(sshSession) } return fillSSHClientForJumpHost(sshSession) } func fillSSHClientForHost(sshSession *SshSession) error { client, err := createSSHClient(sshSession.Options) if err != nil { return err } sshSession.Client = client return nil } func fillSSHClientForJumpHost(sshSession *SshSession) error { jumpHostClient, err := createSSHClient(sshSession.Options.JumpHost) if err != nil { return err } sshSession.JumpHost.JumpHostClient = jumpHostClient hostVirtualConn, err := jumpHostClient.Dial("tcp", sshSession.Options.ConnectionString()) if err != nil { return err } sshSession.JumpHost.HostVirtualConnection = hostVirtualConn hostConn, hostIncomingChannels, hostIncomingRequests, err := ssh.NewClientConn(hostVirtualConn, sshSession.Options.ConnectionString(), createSSHClientConfig(sshSession.Options)) if err != nil { return err } sshSession.JumpHost.HostConnection = hostConn sshSession.Client = ssh.NewClient(hostConn, hostIncomingChannels, hostIncomingRequests) return nil } func setUpSSHSession(sshSession *SshSession) error { session, err := sshSession.Client.NewSession() if err != nil { return err } sshSession.Session = session return nil } func createSSHClient(options *SshConnectionOptions) (*ssh.Client, error) { sshClientConfig := createSSHClientConfig(options) return ssh.Dial("tcp", options.ConnectionString(), sshClientConfig) } func createSSHClientConfig(hostOptions *SshConnectionOptions) *ssh.ClientConfig { clientConfig := &ssh.ClientConfig{ User: hostOptions.Username, Auth: hostOptions.AuthMethods, // Do not do a host key check, as Terratest is only used for testing, not prod HostKeyCallback: NoOpHostKeyCallback, // By default, Go does not impose a timeout, so a SSH connection attempt can hang for a LONG time. Timeout: 10 * time.Second, } clientConfig.SetDefaults() return clientConfig } // NoOpHostKeyCallback is an ssh.HostKeyCallback that does nothing. Only use this when you're sure you don't want to check the host key at all // (e.g., only for testing and non-production use cases). func NoOpHostKeyCallback(hostname string, remote net.Addr, key ssh.PublicKey) error { return nil } // Returns an array of authentication methods func createAuthMethodsForHost(host Host) ([]ssh.AuthMethod, error) { var methods []ssh.AuthMethod // override local ssh agent with given sshAgent instance if host.OverrideSshAgent != nil { conn, err := net.Dial("unix", host.OverrideSshAgent.socketFile) if err != nil { fmt.Print("Failed to dial in memory ssh agent") return methods, err } agentClient := agent.NewClient(conn) methods = append(methods, []ssh.AuthMethod{ssh.PublicKeysCallback(agentClient.Signers)}...) } // use existing ssh agent socket // if agent authentication is enabled and no agent is set up, returns an error if host.SshAgent { socket := os.Getenv("SSH_AUTH_SOCK") conn, err := net.Dial("unix", socket) if err != nil { return methods, err } agentClient := agent.NewClient(conn) methods = append(methods, []ssh.AuthMethod{ssh.PublicKeysCallback(agentClient.Signers)}...) } // use provided ssh key pair if host.SshKeyPair != nil { signer, err := ssh.ParsePrivateKey([]byte(host.SshKeyPair.PrivateKey)) if err != nil { return methods, err } publicKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(host.SshKeyPair.PublicKey)) if err != nil { return methods, err } if cert, ok := publicKey.(*ssh.Certificate); ok { signer, err = ssh.NewCertSigner(cert, signer) if err != nil { return methods, err } } methods = append(methods, []ssh.AuthMethod{ssh.PublicKeys(signer)}...) } // Use given password if len(host.Password) > 0 { methods = append(methods, []ssh.AuthMethod{ssh.Password(host.Password)}...) } // no valid authentication method was provided if len(methods) < 1 { return methods, errors.New("no authentication method defined") } return methods, nil } // sendScpCommandsToCopyFile returns a function which will send commands to the SCP binary to output a file on the remote machine. // A full explanation of the SCP protocol can be found at // https://web.archive.org/web/20170215184048/https://blogs.oracle.com/janp/entry/how_the_scp_protocol_works func sendScpCommandsToCopyFile(mode os.FileMode, fileName, contents string) func(io.WriteCloser) { return func(input io.WriteCloser) { octalMode := "0" + strconv.FormatInt(int64(mode), 8) // Create a file at with Unix permissions set to and the file will be bytes long. fmt.Fprintln(input, "C"+octalMode, len(contents), fileName) // Actually send the file fmt.Fprint(input, contents) // End of transfer fmt.Fprint(input, "\x00") } } // Gets the port that should be used to communicate with the host func (h Host) getPort() int { //If a CustomPort is not set use standard ssh port if h.CustomPort == 0 { return 22 } else { return h.CustomPort } } ================================================ FILE: modules/ssh/ssh_test.go ================================================ package ssh import ( "errors" "fmt" "testing" grunttest "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/assert" ) func TestHostWithDefaultPort(t *testing.T) { t.Parallel() host := Host{} assert.Equal(t, 22, host.getPort(), "host.getPort() did not return the default ssh port of 22") } func TestHostWithCustomPort(t *testing.T) { t.Parallel() customPort := 2222 host := Host{CustomPort: customPort} assert.Equal(t, customPort, host.getPort(), "host.getPort() did not return the custom port number") } // global var for use in mock callback var timesCalled int func TestCheckSshConnectionWithRetryE(t *testing.T) { // Reset the global call count timesCalled = 0 host := Host{Hostname: "Host"} retries := 10 assert.Nil(t, CheckSshConnectionWithRetryE(t, host, retries, 3, mockSshConnectionE)) } func TestCheckSshConnectionWithRetryEExceedsMaxRetries(t *testing.T) { // Reset the global call count timesCalled = 0 host := Host{Hostname: "Host"} // Not enough retries retries := 3 assert.Error(t, CheckSshConnectionWithRetryE(t, host, retries, 3, mockSshConnectionE)) } func TestCheckSshConnectionWithRetry(t *testing.T) { // Reset the global call count timesCalled = 0 host := Host{Hostname: "Host"} retries := 10 CheckSshConnectionWithRetry(t, host, retries, 3, mockSshConnectionE) } func TestCheckSshCommandWithRetryE(t *testing.T) { // Reset the global call count timesCalled = 0 host := Host{Hostname: "Host"} command := "echo -n hello world" retries := 10 _, err := CheckSshCommandWithRetryE(t, host, command, retries, 3, mockSshCommandE) assert.Nil(t, err) } func TestCheckSshCommandWithRetryEExceedsRetries(t *testing.T) { // Reset the global call count timesCalled = 0 host := Host{Hostname: "Host"} command := "echo -n hello world" // Not enough retries retries := 3 _, err := CheckSshCommandWithRetryE(t, host, command, retries, 3, mockSshCommandE) assert.Error(t, err) } func TestCheckSshCommandWithRetry(t *testing.T) { // Reset the global call count timesCalled = 0 host := Host{Hostname: "Host"} command := "echo -n hello world" retries := 10 CheckSshCommandWithRetry(t, host, command, retries, 3, mockSshCommandE) } func mockSshConnectionE(t grunttest.TestingT, host Host) error { timesCalled += 1 if timesCalled >= 5 { return nil } else { return errors.New(fmt.Sprintf("Called %v times", timesCalled)) } } func mockSshCommandE(t grunttest.TestingT, host Host, command string) (string, error) { return "", mockSshConnectionE(t, host) } ================================================ FILE: modules/terraform/apply.go ================================================ package terraform import ( "errors" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // InitAndApply runs terraform init and apply with the given options and return stdout/stderr from the apply command. Note that this // method does NOT call destroy and assumes the caller is responsible for cleaning up any resources created by running // apply. func InitAndApply(t testing.TestingT, options *Options) string { out, err := InitAndApplyE(t, options) require.NoError(t, err) return out } // InitAndApplyE runs terraform init and apply with the given options and return stdout/stderr from the apply command. Note that this // method does NOT call destroy and assumes the caller is responsible for cleaning up any resources created by running // apply. func InitAndApplyE(t testing.TestingT, options *Options) (string, error) { if _, err := InitE(t, options); err != nil { return "", err } return ApplyE(t, options) } // Apply runs terraform apply with the given options and return stdout/stderr. Note that this method does NOT call destroy and // assumes the caller is responsible for cleaning up any resources created by running apply. func Apply(t testing.TestingT, options *Options) string { out, err := ApplyE(t, options) require.NoError(t, err) return out } // ApplyE runs terraform apply with the given options and return stdout/stderr. Note that this method does NOT call destroy and // assumes the caller is responsible for cleaning up any resources created by running apply. func ApplyE(t testing.TestingT, options *Options) (string, error) { return RunTerraformCommandE(t, options, FormatArgs(options, prepend(options.ExtraArgs.Apply, "apply", "-input=false", "-auto-approve")...)...) } // ApplyAndIdempotent runs terraform apply with the given options and return stdout/stderr from the apply command. It then runs // plan again and will fail the test if plan requires additional changes. Note that this method does NOT call destroy and assumes // the caller is responsible for cleaning up any resources created by running apply. func ApplyAndIdempotent(t testing.TestingT, options *Options) string { out, err := ApplyAndIdempotentE(t, options) require.NoError(t, err) return out } // ApplyAndIdempotentE runs terraform apply with the given options and return stdout/stderr from the apply command. It then runs // plan again and will fail the test if plan requires additional changes. Note that this method does NOT call destroy and assumes // the caller is responsible for cleaning up any resources created by running apply. func ApplyAndIdempotentE(t testing.TestingT, options *Options) (string, error) { out, err := ApplyE(t, options) if err != nil { return out, err } exitCode, err := PlanExitCodeE(t, options) if err != nil { return out, err } if exitCode != 0 { return out, errors.New("terraform configuration not idempotent") } return out, nil } // InitAndApplyAndIdempotent runs terraform init and apply with the given options and return stdout/stderr from the apply command. It then runs // plan again and will fail the test if plan requires additional changes. Note that this method does NOT call destroy and assumes // the caller is responsible for cleaning up any resources created by running apply. func InitAndApplyAndIdempotent(t testing.TestingT, options *Options) string { out, err := InitAndApplyAndIdempotentE(t, options) require.NoError(t, err) return out } // InitAndApplyAndIdempotentE runs terraform init and apply with the given options and return stdout/stderr from the apply command. It then runs // plan again and will fail the test if plan requires additional changes. Note that this method does NOT call destroy and assumes // the caller is responsible for cleaning up any resources created by running apply. func InitAndApplyAndIdempotentE(t testing.TestingT, options *Options) (string, error) { if _, err := InitE(t, options); err != nil { return "", err } return ApplyAndIdempotentE(t, options) } ================================================ FILE: modules/terraform/apply_test.go ================================================ package terraform import ( "path/filepath" "strings" "testing" "time" "github.com/gruntwork-io/terratest/modules/files" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestApplyNoError(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-no-error", t.Name()) require.NoError(t, err) options := WithDefaultRetryableErrors(t, &Options{ TerraformDir: testFolder, NoColor: true, }) out := InitAndApply(t, options) require.Contains(t, out, "Hello, World") // Check that NoColor correctly doesn't output the colour escape codes which look like [0m, or [32m require.NotRegexp(t, `\[\d*m`, out, "Output should not contain color escape codes") } func TestApplyWithErrorNoRetry(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-with-error", t.Name()) require.NoError(t, err) options := WithDefaultRetryableErrors(t, &Options{ TerraformDir: testFolder, }) out, err := InitAndApplyE(t, options) require.Error(t, err) require.Contains(t, out, "This is the first run, exiting with an error") } func TestApplyWithErrorWithRetry(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-with-error", t.Name()) require.NoError(t, err) options := WithDefaultRetryableErrors(t, &Options{ TerraformDir: testFolder, MaxRetries: 1, RetryableTerraformErrors: map[string]string{ "This is the first run, exiting with an error": "Intentional failure in test fixture", }, }) out := InitAndApply(t, options) require.Contains(t, out, "This is the first run, exiting with an error") } func TestApplyWithWarning(t *testing.T) { scenarios := []struct { name string folder string isError bool warnings map[string]string }{ { name: "Warning", folder: "../../test/fixtures/terraform-with-warning", isError: true, warnings: map[string]string{ "lorem ipsum": "lorem ipsum warning", }, }, { name: "WarningNotMatch", folder: "../../test/fixtures/terraform-with-warning", isError: false, warnings: map[string]string{ "lorem ipsum dolor sit amet": "some warning", }, }, { name: "Error", folder: "../../test/fixtures/terraform-with-error", isError: true, warnings: map[string]string{ "lorem ipsum": "lorem ipsum warning", }, }, { name: "NoError", folder: "../../test/fixtures/terraform-no-error", isError: false, warnings: map[string]string{ "lorem ipsum": "lorem ipsum warning", }, }, } for _, scenario := range scenarios { scenario := scenario t.Run(scenario.name, func(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp(scenario.folder, strings.Replace(t.Name(), "/", "-", -1)) require.NoError(t, err) options := WithDefaultRetryableErrors(t, &Options{ TerraformDir: testFolder, NoColor: true, WarningsAsErrors: scenario.warnings, }) out, err := InitAndApplyE(t, options) if scenario.isError { assert.Error(t, err) } else { assert.NoError(t, err) } assert.NotEmpty(t, out) }) } } func TestIdempotentNoChanges(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-no-error", t.Name()) require.NoError(t, err) options := WithDefaultRetryableErrors(t, &Options{ TerraformDir: testFolder, NoColor: true, }) InitAndApplyAndIdempotentE(t, options) } func TestIdempotentWithChanges(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-not-idempotent", t.Name()) require.NoError(t, err) options := WithDefaultRetryableErrors(t, &Options{ TerraformDir: testFolder, NoColor: true, }) out, err := InitAndApplyAndIdempotentE(t, options) require.NotEmpty(t, out) require.Error(t, err) require.EqualError(t, err, "terraform configuration not idempotent") } func TestParallelism(t *testing.T) { // This test depends on precise timing of the concurrent parallel calls in terraform, so we need to run this test // serially by itself so that other concurrent test runs won't influence the timing. testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-parallelism", t.Name()) require.NoError(t, err) options := WithDefaultRetryableErrors(t, &Options{ TerraformDir: testFolder, NoColor: true, }) Init(t, options) // Run the first time with parallelism set to 5 and it should take about 5 seconds (plus or minus 10 seconds to // account for other CPU hogging stuff) options.Parallelism = 5 start := time.Now() Apply(t, options) end := time.Now() require.WithinDuration(t, end, start, 15*time.Second) // Run the second time with parallelism set to 1 and it should take at least 25 seconds options.Parallelism = 1 start = time.Now() Apply(t, options) end = time.Now() duration := end.Sub(start) require.GreaterOrEqual(t, int64(duration.Seconds()), int64(25)) } func TestApplyWithPlanFile(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-basic-configuration", t.Name()) require.NoError(t, err) planFilePath := filepath.Join(testFolder, "plan.out") options := &Options{ TerraformDir: testFolder, Vars: map[string]interface{}{ "cnt": 1, }, NoColor: true, PlanFilePath: planFilePath, } _, err = InitAndPlanE(t, options) require.NoError(t, err) require.FileExists(t, planFilePath, "Plan file was not saved to expected location:", planFilePath) out, err := ApplyE(t, options) require.NoError(t, err) require.Contains(t, out, "1 added, 0 changed, 0 destroyed.") require.NotRegexp(t, `\[\d*m`, out, "Output should not contain color escape codes") } ================================================ FILE: modules/terraform/cmd.go ================================================ package terraform import ( "fmt" "os/exec" "regexp" "slices" "strings" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/shell" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) func generateCommand(options *Options, args ...string) shell.Command { cmd := shell.Command{ Command: options.TerraformBinary, Args: args, WorkingDir: options.TerraformDir, Env: options.EnvVars, Logger: options.Logger, Stdin: options.Stdin, } return cmd } var commandsWithParallelism = []string{ "plan", "apply", "destroy", } const ( // TofuDefaultPath command to run tofu TofuDefaultPath = "tofu" // TerraformDefaultPath to run terraform TerraformDefaultPath = "terraform" ) var DefaultExecutable = defaultTerraformExecutable() // GetCommonOptions extracts commons terraform options func GetCommonOptions(options *Options, args ...string) (*Options, []string) { if options.TerraformBinary == "" { options.TerraformBinary = DefaultExecutable } if options.Parallelism > 0 && len(args) > 0 && slices.Contains(commandsWithParallelism, args[0]) { args = append(args, fmt.Sprintf("--parallelism=%d", options.Parallelism)) } // if SshAgent is provided, override the local SSH agent with the socket of our in-process agent if options.SshAgent != nil { // Initialize EnvVars, if it hasn't been set yet if options.EnvVars == nil { options.EnvVars = map[string]string{} } options.EnvVars["SSH_AUTH_SOCK"] = options.SshAgent.SocketFile() } return options, args } // RunTerraformCommand runs terraform with the given arguments and options and return stdout/stderr. func RunTerraformCommand(t testing.TestingT, additionalOptions *Options, args ...string) string { out, err := RunTerraformCommandE(t, additionalOptions, args...) if err != nil { t.Fatal(err) } return out } // RunTerraformCommandE runs terraform with the given arguments and options and return stdout/stderr. func RunTerraformCommandE(t testing.TestingT, additionalOptions *Options, additionalArgs ...string) (string, error) { options, args := GetCommonOptions(additionalOptions, additionalArgs...) cmd := generateCommand(options, args...) description := fmt.Sprintf("%s %v", options.TerraformBinary, args) return retry.DoWithRetryableErrorsE(t, description, options.RetryableTerraformErrors, options.MaxRetries, options.TimeBetweenRetries, func() (string, error) { s, err := shell.RunCommandAndGetOutputE(t, cmd) if err != nil { return s, err } if err := hasWarning(additionalOptions, s); err != nil { return s, err } return s, err }) } // RunTerraformCommandAndGetStdout runs terraform with the given arguments and options and returns solely its stdout // (but not stderr). func RunTerraformCommandAndGetStdout(t testing.TestingT, additionalOptions *Options, additionalArgs ...string) string { out, err := RunTerraformCommandAndGetStdoutE(t, additionalOptions, additionalArgs...) require.NoError(t, err) return out } // RunTerraformCommandAndGetStdoutE runs terraform with the given arguments and options and returns solely its stdout // (but not stderr). func RunTerraformCommandAndGetStdoutE(t testing.TestingT, additionalOptions *Options, additionalArgs ...string) (string, error) { out, _, _, err := RunTerraformCommandAndGetStdOutErrCodeE(t, additionalOptions, additionalArgs...) return out, err } // RunTerraformCommandAndGetStdOutErrCode runs terraform with the given arguments and options and returns its stdout, stderr, and exitcode func RunTerraformCommandAndGetStdOutErrCode(t testing.TestingT, additionalOptions *Options, additionalArgs ...string) (stdout string, stderr string, exit int) { stdout, stderr, exit, err := RunTerraformCommandAndGetStdOutErrCodeE(t, additionalOptions, additionalArgs...) require.NoError(t, err) return stdout, stderr, exit } // RunTerraformCommandAndGetStdOutErrCodeE runs terraform with the given arguments and options and returns its stdout, stderr, and exitcode func RunTerraformCommandAndGetStdOutErrCodeE(t testing.TestingT, additionalOptions *Options, additionalArgs ...string) (stdout string, stderr string, exit int, err error) { options, args := GetCommonOptions(additionalOptions, additionalArgs...) cmd := generateCommand(options, args...) description := fmt.Sprintf("%s %v", options.TerraformBinary, args) exit = DefaultErrorExitCode _, err = retry.DoWithRetryableErrorsE(t, description, options.RetryableTerraformErrors, options.MaxRetries, options.TimeBetweenRetries, func() (string, error) { stdout, stderr, err = shell.RunCommandAndGetStdOutErrE(t, cmd) if err != nil { exitCode, getExitCodeErr := shell.GetExitCodeForRunCommandError(err) if getExitCodeErr == nil { exit = exitCode } return "", err } if err = hasWarning(additionalOptions, stdout); err != nil { return "", err } exit = DefaultSuccessExitCode return "", nil }) return } // GetExitCodeForTerraformCommand runs terraform with the given arguments and options and returns exit code func GetExitCodeForTerraformCommand(t testing.TestingT, additionalOptions *Options, args ...string) int { exitCode, err := GetExitCodeForTerraformCommandE(t, additionalOptions, args...) if err != nil { t.Fatal(err) } return exitCode } // GetExitCodeForTerraformCommandE runs terraform with the given arguments and options and returns exit code func GetExitCodeForTerraformCommandE(t testing.TestingT, additionalOptions *Options, additionalArgs ...string) (int, error) { options, args := GetCommonOptions(additionalOptions, additionalArgs...) additionalOptions.Logger.Logf(t, "Running %s with args %v", options.TerraformBinary, args) cmd := generateCommand(options, args...) _, err := shell.RunCommandAndGetOutputE(t, cmd) if err == nil { return DefaultSuccessExitCode, nil } exitCode, getExitCodeErr := shell.GetExitCodeForRunCommandError(err) if getExitCodeErr == nil { return exitCode, nil } return DefaultErrorExitCode, getExitCodeErr } func defaultTerraformExecutable() string { cmd := exec.Command(TerraformDefaultPath, "-version") cmd.Stdin = nil cmd.Stdout = nil cmd.Stderr = nil if err := cmd.Run(); err == nil { return TerraformDefaultPath } // fallback to Tofu if terraform is not available return TofuDefaultPath } func hasWarning(opts *Options, out string) error { for k, v := range opts.WarningsAsErrors { str := fmt.Sprintf("\n.*(?i:Warning): %s[^\n]*\n", k) re, err := regexp.Compile(str) if err != nil { return fmt.Errorf("cannot compile regex for warning detection: %w", err) } m := re.FindAllString(out, -1) if len(m) == 0 { continue } return fmt.Errorf("warning(s) were found: %s:\n%s", v, strings.Join(m, "")) } return nil } ================================================ FILE: modules/terraform/cmd_test.go ================================================ package terraform import ( "strings" "testing" "github.com/gruntwork-io/terratest/modules/files" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestTerraformCommand(t *testing.T) { t.Parallel() t.Run("Error", func(t *testing.T) { testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-with-error", strings.ReplaceAll(t.Name(), "/", "-")) require.NoError(t, err) options := &Options{ TerraformDir: testFolder, } Init(t, options) stdout, stderr, code, err := RunTerraformCommandAndGetStdOutErrCodeE(t, options, "apply", "-input=false", "-auto-approve") assert.Error(t, err) assert.Contains(t, stdout, "Creating...", "should capture stdout") assert.Contains(t, stderr, "Error: ", "should capture stderr") assert.Greater(t, code, 0) }) t.Run("WithWarning", func(t *testing.T) { testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-with-warning", strings.ReplaceAll(t.Name(), "/", "-")) require.NoError(t, err) options := &Options{ TerraformDir: testFolder, WarningsAsErrors: map[string]string{ ".*lorem ipsum.*": "this warning message should shown.", }, } Init(t, options) stdout, stderr, code, err := RunTerraformCommandAndGetStdOutErrCodeE(t, options, "apply", "-input=false", "-auto-approve") assert.Error(t, err) assert.Contains(t, stdout, "Creating...", "should capture stdout") assert.Contains(t, stderr, "", "should capture stderr") assert.Greater(t, code, 0) }) t.Run("NoError", func(t *testing.T) { testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-no-error", strings.ReplaceAll(t.Name(), "/", "-")) require.NoError(t, err) options := &Options{ TerraformDir: testFolder, } { stdout, stderr, code := RunTerraformCommandAndGetStdOutErrCode(t, options, "apply", "-input=false", "-auto-approve") assert.Contains(t, stdout, `test = "Hello, World"`, "should capture stdout") assert.Equal(t, code, 0) assert.Empty(t, stderr) } { stdout := RunTerraformCommandAndGetStdout(t, options, "apply", "-input=false", "-auto-approve") assert.Contains(t, stdout, `test = "Hello, World"`, "should capture stdout") } }) } ================================================ FILE: modules/terraform/count.go ================================================ package terraform import ( "errors" "regexp" "strconv" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // ResourceCount represents counts of resources affected by terraform apply/plan/destroy command. type ResourceCount struct { Add int Change int Destroy int } // Regular expressions for terraform commands stdout pattern matching. const ( applyRegexp = `Apply complete! Resources: (\d+) added, (\d+) changed, (\d+) destroyed\.` destroyRegexp = `Destroy complete! Resources: (\d+) destroyed\.` planWithChangesRegexp = `(\033\[1m)?Plan:(\033\[0m)? (\d+) to add, (\d+) to change, (\d+) to destroy` planWithNoChangesRegexp = `No changes\. (Infrastructure is up-to-date)|(Your infrastructure matches the configuration)\.` // '.' doesn't match newline by default in go. We must instruct the regex to match it with the 's' flag. planWithNoInfraChangesRegexp = `(?s)You can apply this plan.+without changing any real infrastructure` ) const getResourceCountErrMessage = "Can't parse Terraform output" // GetResourceCount parses stdout/stderr of apply/plan/destroy commands and returns number of affected resources. // This will fail the test if given stdout/stderr isn't a valid output of apply/plan/destroy. func GetResourceCount(t testing.TestingT, cmdout string) *ResourceCount { cnt, err := GetResourceCountE(t, cmdout) require.NoError(t, err) return cnt } // GetResourceCountE parses stdout/stderr of apply/plan/destroy commands and returns number of affected resources. func GetResourceCountE(t testing.TestingT, cmdout string) (*ResourceCount, error) { cnt := ResourceCount{} terraformCommandPatterns := []struct { regexpStr string addPosition int changePosition int destroyPosition int }{ {applyRegexp, 1, 2, 3}, {destroyRegexp, -1, -1, 1}, {planWithChangesRegexp, 3, 4, 5}, {planWithNoChangesRegexp, -1, -1, -1}, {planWithNoInfraChangesRegexp, -1, -1, -1}, } for _, tc := range terraformCommandPatterns { pattern, err := regexp.Compile(tc.regexpStr) if err != nil { return nil, err } matches := pattern.FindStringSubmatch(cmdout) if matches != nil { if tc.addPosition != -1 { cnt.Add, err = strconv.Atoi(matches[tc.addPosition]) if err != nil { return nil, err } } if tc.changePosition != -1 { cnt.Change, err = strconv.Atoi(matches[tc.changePosition]) if err != nil { return nil, err } } if tc.destroyPosition != -1 { cnt.Destroy, err = strconv.Atoi(matches[tc.destroyPosition]) if err != nil { return nil, err } } return &cnt, nil } } return nil, errors.New(getResourceCountErrMessage) } ================================================ FILE: modules/terraform/count_test.go ================================================ package terraform import ( "testing" "github.com/gruntwork-io/terratest/modules/files" ttesting "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestGetResourceCount(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-basic-configuration", t.Name()) require.NoError(t, err) terraformOptions := &Options{ TerraformDir: testFolder, Vars: map[string]interface{}{ "cnt": 1, }, } cnt := GetResourceCount(t, InitAndPlan(t, terraformOptions)) assert.Equal(t, 1, cnt.Add) assert.Equal(t, 0, cnt.Change) assert.Equal(t, 0, cnt.Destroy) } func TestGetResourceCountEColor(t *testing.T) { t.Parallel() runTestGetResourceCountE(t, false) } func TestGetResourceCountENoColor(t *testing.T) { t.Parallel() runTestGetResourceCountE(t, true) } func runTestGetResourceCountE(t *testing.T, noColor bool) { testCases := []struct { Name string tfFuncToRun func(t ttesting.TestingT, options *Options) string cntValue int expectedAdd, expectedChange, expectedDestroy int }{ {"PlanZero", InitAndPlan, 0, 0, 0, 0}, {"ApplyZero", InitAndApply, 0, 0, 0, 0}, {"PlanAddResouce", InitAndPlan, 2, 2, 0, 0}, {"ApplyAddResouce", InitAndApply, 2, 2, 0, 0}, {"PlanNoOp", InitAndApply, 2, 0, 0, 0}, {"ApplyNoOp", InitAndApply, 2, 0, 0, 0}, {"PlanDestroyResource", InitAndPlan, 1, 0, 0, 1}, {"ApplyDestroyResource", InitAndApply, 1, 0, 0, 1}, {"Destroy", Destroy, 1, 0, 0, 1}, {"DestroyNoOp", Destroy, 1, 0, 0, 0}, } testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-basic-configuration", t.Name()) require.NoError(t, err) terraformOptions := &Options{ TerraformDir: testFolder, Vars: map[string]interface{}{ "cnt": 0, }, NoColor: noColor, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { terraformOptions.Vars["cnt"] = tc.cntValue cnt, err := GetResourceCountE(t, tc.tfFuncToRun(t, terraformOptions)) assert.NoError(t, err) assert.Equal(t, tc.expectedAdd, cnt.Add) assert.Equal(t, tc.expectedChange, cnt.Change) assert.Equal(t, tc.expectedDestroy, cnt.Destroy) }) } t.Run("InvalidInput", func(t *testing.T) { terraformOptions.Vars["cnt"] = "abc" cmdout, _ := PlanE(t, terraformOptions) cnt, err := GetResourceCountE(t, cmdout) assert.EqualError(t, err, getResourceCountErrMessage) assert.Nil(t, cnt) }) } ================================================ FILE: modules/terraform/destroy.go ================================================ package terraform import ( "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // Destroy runs terraform destroy with the given options and return stdout/stderr. func Destroy(t testing.TestingT, options *Options) string { out, err := DestroyE(t, options) require.NoError(t, err) return out } // DestroyE runs terraform destroy with the given options and return stdout/stderr. func DestroyE(t testing.TestingT, options *Options) (string, error) { return RunTerraformCommandE(t, options, FormatArgs(options, prepend(options.ExtraArgs.Destroy, "destroy", "-auto-approve", "-input=false")...)...) } ================================================ FILE: modules/terraform/errors.go ================================================ package terraform import ( "fmt" "reflect" ) // OutputKeyNotFound occurs when terraform output does not contain a value for the key // specified in the function call type OutputKeyNotFound string func (err OutputKeyNotFound) Error() string { return fmt.Sprintf("output doesn't contain a value for the key %q", string(err)) } // OutputValueNotMap occurs when casting a found output value to a map fails type OutputValueNotMap struct { Value interface{} } func (err OutputValueNotMap) Error() string { return fmt.Sprintf("Output value %q is not a map", err.Value) } // OutputValueNotList occurs when casting a found output value to a // list of interfaces fails type OutputValueNotList struct { Value interface{} } func (err OutputValueNotList) Error() string { return fmt.Sprintf("Output value %q is not a list", err.Value) } // EmptyOutput is an error that occurs when an output is empty. type EmptyOutput string func (outputName EmptyOutput) Error() string { return fmt.Sprintf("Required output %s was empty", string(outputName)) } // UnexpectedOutputType is an error that occurs when the output is not of the type we expect type UnexpectedOutputType struct { Key string ExpectedType string ActualType string } func (err UnexpectedOutputType) Error() string { return fmt.Sprintf("Expected output '%s' to be of type '%s' but got '%s'", err.Key, err.ExpectedType, err.ActualType) } // VarFileNotFound is an error that occurs when a var file cannot be found in an option's VarFile list type VarFileNotFound struct { Path string } func (err VarFileNotFound) Error() string { return fmt.Sprintf("Var file '%s' not found", err.Path) } // InputFileKeyNotFound occurs when tfvar file does not contain a value for the key // specified in the function call type InputFileKeyNotFound struct { FilePath string Key string } func (err InputFileKeyNotFound) Error() string { return fmt.Sprintf("tfvar file %q doesn't contain a value for the key %q", err.FilePath, err.Key) } // PanicWhileParsingVarFile is returned when the HCL parsing routine panics due to errors. type PanicWhileParsingVarFile struct { ConfigFile string RecoveredValue interface{} } func (err PanicWhileParsingVarFile) Error() string { return fmt.Sprintf("Recovering panic while parsing '%s'. Got error of type '%v': %v", err.ConfigFile, reflect.TypeOf(err.RecoveredValue), err.RecoveredValue) } // UnsupportedDefaultWorkspaceDeletion is returned when user tries to delete the workspace "default" type UnsupportedDefaultWorkspaceDeletion struct{} func (err *UnsupportedDefaultWorkspaceDeletion) Error() string { return "Deleting the workspace 'default' is not supported" } // WorkspaceDoesNotExist is returned when user tries to delete a workspace which does not exist type WorkspaceDoesNotExist string func (err WorkspaceDoesNotExist) Error() string { return fmt.Sprintf("The workspace %q does not exist.", string(err)) } ================================================ FILE: modules/terraform/format.go ================================================ package terraform import ( "fmt" "reflect" "slices" "strconv" "strings" "github.com/gruntwork-io/terratest/internal/lib/formatting" ) // TerraformCommandsWithLockSupport is a list of all the Terraform commands that // can obtain locks on Terraform state var TerraformCommandsWithLockSupport = []string{ "plan", "apply", "destroy", "init", "refresh", "taint", "untaint", "import", } // TerraformCommandsWithPlanFileSupport is a list of all the Terraform commands that support interacting with plan // files. var TerraformCommandsWithPlanFileSupport = []string{ "plan", "apply", "show", "graph", } // FormatArgs converts the inputs to a format palatable to terraform. This includes converting the given vars to the // format the Terraform CLI expects (-var key=value). func FormatArgs(options *Options, args ...string) []string { var terraformArgs []string commandType := args[0] lockSupported := slices.Contains(TerraformCommandsWithLockSupport, commandType) planFileSupported := slices.Contains(TerraformCommandsWithPlanFileSupport, commandType) // Include -var and -var-file flags unless we're running 'apply' with a plan file includeVars := !(commandType == "apply" && len(options.PlanFilePath) > 0) terraformArgs = append(terraformArgs, args...) if includeVars { for _, v := range options.MixedVars { terraformArgs = append(terraformArgs, v.Args()...) } if options.SetVarsAfterVarFiles { terraformArgs = append(terraformArgs, FormatTerraformArgs("-var-file", options.VarFiles)...) terraformArgs = append(terraformArgs, FormatTerraformVarsAsArgs(options.Vars)...) } else { terraformArgs = append(terraformArgs, FormatTerraformVarsAsArgs(options.Vars)...) terraformArgs = append(terraformArgs, FormatTerraformArgs("-var-file", options.VarFiles)...) } } terraformArgs = append(terraformArgs, FormatTerraformArgs("-target", options.Targets)...) if options.NoColor { terraformArgs = append(terraformArgs, "-no-color") } if lockSupported { // If command supports locking, handle lock arguments terraformArgs = append(terraformArgs, FormatTerraformLockAsArgs(options.Lock, options.LockTimeout)...) } if planFileSupported { // The plan file arg should be last in the terraformArgs slice. Some commands use it as an input (e.g. show, apply) terraformArgs = append(terraformArgs, FormatTerraformPlanFileAsArg(commandType, options.PlanFilePath)...) } return terraformArgs } // FormatTerraformPlanFileAsArg formats the out variable as a command-line arg for Terraform (e.g. of the format // -out=/some/path/to/plan.out or /some/path/to/plan.out). Only plan supports passing in the plan file as -out; the // other commands expect it as the first positional argument. This returns an empty string if outPath is empty string. func FormatTerraformPlanFileAsArg(commandType string, outPath string) []string { if outPath == "" { return nil } if commandType == "plan" { return []string{fmt.Sprintf("%s=%s", "-out", outPath)} } return []string{outPath} } // FormatTerraformVarsAsArgs formats the given variables as command-line args for Terraform (e.g. of the format // -var key=value). func FormatTerraformVarsAsArgs(vars map[string]interface{}) []string { return formatTerraformArgs(vars, "-var", true, false) } // FormatTerraformLockAsArgs formats the lock and lock-timeout variables // -lock, -lock-timeout func FormatTerraformLockAsArgs(lockCheck bool, lockTimeout string) []string { lockArgs := []string{fmt.Sprintf("-lock=%v", lockCheck)} if lockTimeout != "" { lockTimeoutValue := fmt.Sprintf("%s=%s", "-lock-timeout", lockTimeout) lockArgs = append(lockArgs, lockTimeoutValue) } return lockArgs } // FormatTerraformPluginDirAsArgs formats the plugin-dir variable // -plugin-dir func FormatTerraformPluginDirAsArgs(pluginDir string) []string { return formatting.FormatPluginDirAsArgs(pluginDir) } // FormatTerraformArgs will format multiple args with the arg name (e.g. "-var-file", []string{"foo.tfvars", "bar.tfvars", "baz.tfvars.json"}) // returns "-var-file foo.tfvars -var-file bar.tfvars -var-file baz.tfvars.json" func FormatTerraformArgs(argName string, args []string) []string { argsList := []string{} for _, argValue := range args { argsList = append(argsList, argName, argValue) } return argsList } // FormatTerraformBackendConfigAsArgs formats the given variables as backend config args for Terraform (e.g. of the // format -backend-config=key=value). func FormatTerraformBackendConfigAsArgs(vars map[string]interface{}) []string { return formatting.FormatBackendConfigAsArgs(vars) } // Format the given vars into 'Terraform' format, with each var being prefixed with the given prefix. If // useSpaceAsSeparator is true, a space will separate the prefix and each var (e.g., -var foo=bar). If // useSpaceAsSeparator is false, an equals will separate the prefix and each var (e.g., -backend-config=foo=bar). If // omitNil is false, then nil values will be included, (e.g. -backend-config=foo=null). If // omitNil is true, then nil values will not be included, (e.g. -backend-config=foo). If func formatTerraformArgs(vars map[string]interface{}, prefix string, useSpaceAsSeparator bool, omitNil bool) []string { var args []string for key, value := range vars { var argValue string if omitNil && value == nil { argValue = key } else { hclString := toHclString(value, false) argValue = fmt.Sprintf("%s=%s", key, hclString) } if useSpaceAsSeparator { args = append(args, prefix, argValue) } else { args = append(args, fmt.Sprintf("%s=%s", prefix, argValue)) } } return args } // Terraform allows you to pass in command-line variables using HCL syntax (e.g. -var foo=[1,2,3]). Unfortunately, // while their golang hcl library can convert an HCL string to a Go type, they don't seem to offer a library to convert // arbitrary Go types to an HCL string. Therefore, this method is a simple implementation that correctly handles // ints, booleans, lists, and maps. Everything else is forced into a string using Sprintf. Hopefully, this approach is // good enough for the type of variables we deal with in Terratest. func toHclString(value interface{}, isNested bool) string { // Ideally, we'd use a type switch here to identify slices and maps, but we can't do that, because Go doesn't // support generics, and the type switch only matches concrete types. So we could match []interface{}, but if // a user passes in []string{}, that would NOT match (the same logic applies to maps). Therefore, we have to // use reflection and manually convert into []interface{} and map[string]interface{}. if slice, isSlice := tryToConvertToGenericSlice(value); isSlice { return sliceToHclString(slice) } else if m, isMap := tryToConvertToGenericMap(value); isMap { return mapToHclString(m) } else { return primitiveToHclString(value, isNested) } } // Try to convert the given value to a generic slice. Return the slice and true if the underlying value itself was a // slice and an empty slice and false if it wasn't. This is necessary because Go is a shitty language that doesn't // have generics, nor useful utility methods built-in. For more info, see: http://stackoverflow.com/a/12754757/483528 func tryToConvertToGenericSlice(value interface{}) ([]interface{}, bool) { reflectValue := reflect.ValueOf(value) if reflectValue.Kind() != reflect.Slice { return []interface{}{}, false } genericSlice := make([]interface{}, reflectValue.Len()) for i := 0; i < reflectValue.Len(); i++ { genericSlice[i] = reflectValue.Index(i).Interface() } return genericSlice, true } // Try to convert the given value to a generic map. Return the map and true if the underlying value itself was a // map and an empty map and false if it wasn't. This is necessary because Go is a shitty language that doesn't // have generics, nor useful utility methods built-in. For more info, see: http://stackoverflow.com/a/12754757/483528 func tryToConvertToGenericMap(value interface{}) (map[string]interface{}, bool) { reflectValue := reflect.ValueOf(value) if reflectValue.Kind() != reflect.Map { return map[string]interface{}{}, false } reflectType := reflect.TypeOf(value) if reflectType.Key().Kind() != reflect.String { return map[string]interface{}{}, false } genericMap := make(map[string]interface{}, reflectValue.Len()) mapKeys := reflectValue.MapKeys() for _, key := range mapKeys { genericMap[key.String()] = reflectValue.MapIndex(key).Interface() } return genericMap, true } // Convert a slice to an HCL string. See ToHclString for details. func sliceToHclString(slice []interface{}) string { hclValues := []string{} for _, value := range slice { hclValue := toHclString(value, true) hclValues = append(hclValues, hclValue) } return fmt.Sprintf("[%s]", strings.Join(hclValues, ", ")) } // Convert a map to an HCL string. See ToHclString for details. func mapToHclString(m map[string]interface{}) string { keyValuePairs := []string{} for key, value := range m { keyValuePair := fmt.Sprintf(`"%s" = %s`, key, toHclString(value, true)) keyValuePairs = append(keyValuePairs, keyValuePair) } return fmt.Sprintf("{%s}", strings.Join(keyValuePairs, ", ")) } // Convert a primitive, such as a bool, int, or string, to an HCL string. If this isn't a primitive, force its value // using Sprintf. See ToHclString for details. func primitiveToHclString(value interface{}, isNested bool) string { if value == nil { return "null" } switch v := value.(type) { case bool: return strconv.FormatBool(v) case string: // If string is nested in a larger data structure (e.g. list of string, map of string), ensure value is quoted if isNested { return fmt.Sprintf("\"%v\"", v) } return fmt.Sprintf("%v", v) default: return fmt.Sprintf("%v", v) } } ================================================ FILE: modules/terraform/format_test.go ================================================ package terraform import ( "fmt" "testing" "github.com/stretchr/testify/assert" ) func TestFormatTerraformPlanFileAsArgs(t *testing.T) { t.Parallel() testCases := []struct { command string out string expected []string }{ {"plan", "/some/plan/output", []string{"-out=/some/plan/output"}}, {"plan", "", nil}, {"apply", "/some/plan/output", []string{"/some/plan/output"}}, {"apply", "", nil}, {"show", "/some/plan/output", []string{"/some/plan/output"}}, {"show", "", nil}, } for _, testCase := range testCases { checkResultWithRetry(t, 100, testCase.expected, fmt.Sprintf("FormatTerraformPlanFileAsArgs(%v)", testCase.out), func() interface{} { return FormatTerraformPlanFileAsArg(testCase.command, testCase.out) }) } } func TestFormatTerraformVarsAsArgs(t *testing.T) { t.Parallel() testCases := []struct { vars map[string]interface{} expected []string }{ {map[string]interface{}{}, nil}, {map[string]interface{}{"foo": "bar"}, []string{"-var", "foo=bar"}}, {map[string]interface{}{"foo": 123}, []string{"-var", "foo=123"}}, {map[string]interface{}{"foo": true}, []string{"-var", "foo=true"}}, {map[string]interface{}{"foo": nil}, []string{"-var", "foo=null"}}, {map[string]interface{}{"foo": []int{1, 2, 3}}, []string{"-var", "foo=[1, 2, 3]"}}, {map[string]interface{}{"foo": map[string]string{"baz": "blah"}}, []string{"-var", "foo={\"baz\" = \"blah\"}"}}, { map[string]interface{}{"str": "bar", "int": -1, "bool": false, "list": []string{"foo", "bar", "baz"}, "map": map[string]int{"foo": 0}}, []string{"-var", "str=bar", "-var", "int=-1", "-var", "bool=false", "-var", "list=[\"foo\", \"bar\", \"baz\"]", "-var", "map={\"foo\" = 0}"}, }, } for _, testCase := range testCases { checkResultWithRetry(t, 100, testCase.expected, fmt.Sprintf("FormatTerraformVarsAsArgs(%v)", testCase.vars), func() interface{} { return FormatTerraformVarsAsArgs(testCase.vars) }) } } // Some of our tests execute code that loops over a map to produce output. The problem is that the order of map // iteration is generally unpredictable and, to make it even more unpredictable, Go intentionally randomizes the // iteration order (https://blog.golang.org/go-maps-in-action#TOC_7). Therefore, the order of items in the output // is unpredictable, and doing a simple assert.Equals call will intermittently fail. // // We have a few unsatisfactory ways to solve this problem: // // 1. Enforce iteration order. This is easy to do in other languages, where you have built-in sorted maps. In Go, no // such map exists, and if you create a custom one, you can't use the range keyword on it // (http://stackoverflow.com/a/35810932/483528). As a result, we'd have to modify our implementation code to take // iteration order into account which is a totally unnecessary feature that increases complexity. // 2. We could parse the output string and do an order-independent comparison. However, that adds a bunch of parsing // logic into the test code which is a totally unnecessary feature that increases complexity. // 3. We accept that Go is a shitty language and, if the test fails, we re-run it a bunch of times in the hope that, if // the bug is caused by key ordering, we will randomly get the proper order in a future run. The code being tested // here is tiny & fast, so doing a hundred retries is still sub millisecond, so while ugly, this provides a very // simple solution. // // Isn't it great that Go's designers built features into the language to prevent bugs that now force every Go // developer to write thousands of lines of extra code like this, which is of course likely to contain bugs itself? func checkResultWithRetry(t *testing.T, maxRetries int, expectedValue interface{}, description string, generateValue func() interface{}) { for i := 0; i < maxRetries; i++ { actualValue := generateValue() if assert.ObjectsAreEqual(expectedValue, actualValue) { return } t.Logf("Retry %d of %s failed: expected %v, got %v", i, description, expectedValue, actualValue) } assert.Fail(t, "checkResultWithRetry failed", "After %d retries, %s still not succeeding (see retries above)", description) } func TestFormatArgsAppliesLockCorrectly(t *testing.T) { t.Parallel() testCases := []struct { command []string expected []string }{ {[]string{"plan"}, []string{"plan", "-lock=false"}}, {[]string{"validate"}, []string{"validate"}}, {[]string{"validate", "--all"}, []string{"validate", "--all"}}, {[]string{"plan", "--all"}, []string{"plan", "--all", "-lock=false"}}, } for _, testCase := range testCases { assert.Equal(t, testCase.expected, FormatArgs(&Options{}, testCase.command...)) } } func TestFormatSetVarsAfterVarFilesFormatsCorrectly(t *testing.T) { t.Parallel() testCases := []struct { command []string vars map[string]interface{} varFiles []string setVarsAfterVarFiles bool expected []string }{ {[]string{"plan"}, map[string]interface{}{"foo": "bar"}, []string{"test.tfvars"}, true, []string{"plan", "-var-file", "test.tfvars", "-var", "foo=bar", "-lock=false"}}, {[]string{"plan"}, map[string]interface{}{"foo": "bar", "hello": "world"}, []string{"test.tfvars"}, true, []string{"plan", "-var-file", "test.tfvars", "-var", "foo=bar", "-var", "hello=world", "-lock=false"}}, {[]string{"plan"}, map[string]interface{}{"foo": "bar", "hello": "world"}, []string{"test.tfvars"}, false, []string{"plan", "-var", "foo=bar", "-var", "hello=world", "-var-file", "test.tfvars", "-lock=false"}}, {[]string{"plan"}, map[string]interface{}{"foo": "bar"}, []string{"test.tfvars"}, false, []string{"plan", "-var", "foo=bar", "-var-file", "test.tfvars", "-lock=false"}}, } for _, testCase := range testCases { result := FormatArgs(&Options{SetVarsAfterVarFiles: testCase.setVarsAfterVarFiles, Vars: testCase.vars, VarFiles: testCase.varFiles}, testCase.command...) // Make sure that -var and -var-file options are in the expected order relative to each other // Note that the order of the different -var and -var-file options may change // See this comment for more info: https://github.com/gruntwork-io/terratest/blob/6fb86056797e3e62ebdd9011ba26605e0976a6f8/modules/terraform/format_test.go#L123-L142 for idx, arg := range result { if arg == "-var-file" || arg == "-var" { assert.Equal(t, testCase.expected[idx], arg) } } // Make sure that the order of other arguments hasn't been incorrectly modified assert.Equal(t, testCase.expected[0], result[0]) assert.Equal(t, testCase.expected[len(testCase.expected)-1], result[len(result)-1]) } } func TestMixedVars(t *testing.T) { t.Parallel() testCases := []struct { command []string mixedVars []Var vars map[string]interface{} varFiles []string setVarsAfterVarFiles bool expected []string }{ {[]string{"plan"}, []Var{VarFile("/path1"), VarInline("name", "value"), VarFile("/path2")}, map[string]interface{}{"foo": "bar"}, []string{"test.tfvars"}, true, []string{"plan", "-var-file", "/path1", "-var", "name=value", "-var-file", "/path2", "-var-file", "test.tfvars", "-var", "foo=bar", "-lock=false"}}, {[]string{"plan"}, []Var{VarInline("name1", "value"), VarInline("name2", "value"), VarFile("/path")}, map[string]interface{}{"foo": "bar", "hello": "world"}, []string{"test.tfvars"}, true, []string{"plan", "-var", "name1=value", "-var", "name2=value", "-var-file", "/path", "-var-file", "test.tfvars", "-var", "foo=bar", "-var", "hello=world", "-lock=false"}}, {[]string{"plan"}, []Var{VarFile("/path"), VarInline("name1", "value"), VarInline("name2", "value")}, map[string]interface{}{"foo": "bar", "hello": "world"}, []string{"test.tfvars"}, false, []string{"plan", "-var-file", "path", "-var", "name1=value", "-var", "name2=value", "-var", "foo=bar", "-var", "hello=world", "-var-file", "test.tfvars", "-lock=false"}}, {[]string{"plan"}, []Var{VarFile("/path"), VarInline("name", "value")}, map[string]interface{}{"foo": "bar"}, []string{"test.tfvars"}, false, []string{"plan", "-var-file", "/path", "-var", "name=value", "-var", "foo=bar", "-var-file", "test.tfvars", "-lock=false"}}, } for _, testCase := range testCases { result := FormatArgs(&Options{SetVarsAfterVarFiles: testCase.setVarsAfterVarFiles, Vars: testCase.vars, VarFiles: testCase.varFiles, MixedVars: testCase.mixedVars}, testCase.command...) // Make sure that var defined in `MixedVars` are seriliazed in order and precede `Var`` and `VarFiles`` // Make sure that -var and -var-file options are in the expected order relative to each other // Note that the order of the different -var and -var-file options may change // See this comment for more info: https://github.com/gruntwork-io/terratest/blob/6fb86056797e3e62ebdd9011ba26605e0976a6f8/modules/terraform/format_test.go#L123-L142 for idx, arg := range result { if arg == "-var-file" || arg == "-var" { assert.Equal(t, testCase.expected[idx], arg) } } // Make sure that the order of other arguments hasn't been incorrectly modified assert.Equal(t, testCase.expected[0], result[0]) assert.Equal(t, testCase.expected[len(testCase.expected)-1], result[len(result)-1]) } } ================================================ FILE: modules/terraform/get.go ================================================ package terraform import ( "github.com/gruntwork-io/terratest/modules/testing" ) // Get calls terraform get and return stdout/stderr. func Get(t testing.TestingT, options *Options) string { out, err := GetE(t, options) if err != nil { t.Fatal(err) } return out } // GetE calls terraform get and return stdout/stderr. func GetE(t testing.TestingT, options *Options) (string, error) { return RunTerraformCommandE(t, options, prepend(options.ExtraArgs.Get, "get", "-update")...) } ================================================ FILE: modules/terraform/init.go ================================================ package terraform import ( "fmt" "github.com/gruntwork-io/terratest/modules/testing" ) // Init calls terraform init and return stdout/stderr. func Init(t testing.TestingT, options *Options) string { out, err := InitE(t, options) if err != nil { t.Fatal(err) } return out } // InitE calls terraform init and return stdout/stderr. func InitE(t testing.TestingT, options *Options) (string, error) { args := []string{"init", fmt.Sprintf("-upgrade=%t", options.Upgrade)} // Append reconfigure option if specified if options.Reconfigure { args = append(args, "-reconfigure") } // Append combination of migrate-state and force-copy to suppress answer prompt if options.MigrateState { args = append(args, "-migrate-state", "-force-copy") } // Append no-color option if needed if options.NoColor { args = append(args, "-no-color") } args = append(args, FormatTerraformBackendConfigAsArgs(options.BackendConfig)...) args = append(args, FormatTerraformPluginDirAsArgs(options.PluginDir)...) return RunTerraformCommandE(t, options, prepend(options.ExtraArgs.Init, args...)...) } ================================================ FILE: modules/terraform/init_test.go ================================================ package terraform import ( "os" "path/filepath" "testing" "github.com/gruntwork-io/terratest/modules/files" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestInitBackendConfig(t *testing.T) { t.Parallel() testFolderPath := "../../test/fixtures/terraform-backend" testFolder, err := files.CopyTerraformFolderToTemp(testFolderPath, t.Name()) if err != nil { t.Fatal(err) } tmpStateFile := filepath.Join(t.TempDir(), "backend.tfstate") ttable := []struct { name string path string options *Options }{ { name: "KeyValue", path: tmpStateFile, options: &Options{ TerraformDir: testFolder, BackendConfig: map[string]interface{}{ "path": tmpStateFile, }, }, }, { name: "File", path: filepath.Join(testFolder, "backend.tfstate"), options: &Options{ TerraformDir: testFolder, Reconfigure: true, BackendConfig: map[string]interface{}{ "backend.hcl": nil, }, }, }, } for _, tt := range ttable { t.Run(tt.name, func(t *testing.T) { InitAndApply(t, tt.options) assert.FileExists(t, tt.path) }) } } func TestInitPluginDir(t *testing.T) { t.Parallel() testingDir := t.TempDir() terraformFixture := "../../test/fixtures/terraform-basic-configuration" initializedFolder, err := files.CopyTerraformFolderToTemp(terraformFixture, t.Name()) require.NoError(t, err) defer os.RemoveAll(initializedFolder) testFolder, err := files.CopyTerraformFolderToTemp(terraformFixture, t.Name()) require.NoError(t, err) defer os.RemoveAll(testFolder) terraformOptions := &Options{ TerraformDir: initializedFolder, } terraformOptionsPluginDir := &Options{ TerraformDir: testFolder, PluginDir: testingDir, } Init(t, terraformOptions) _, err = InitE(t, terraformOptionsPluginDir) require.Error(t, err) // In Terraform 0.13, the directory is "plugins" initializedPluginDir := initializedFolder + "/.terraform/plugins" // In Terraform 0.14, the directory is "providers" initializedProviderDir := initializedFolder + "/.terraform/providers" files.CopyFolderContents(initializedPluginDir, testingDir) files.CopyFolderContents(initializedProviderDir, testingDir) initOutput := Init(t, terraformOptionsPluginDir) assert.Contains(t, initOutput, "(unauthenticated)") } func TestInitReconfigureBackend(t *testing.T) { t.Parallel() stateDirectory := t.TempDir() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-backend", t.Name()) require.NoError(t, err) defer os.RemoveAll(testFolder) options := &Options{ TerraformDir: testFolder, BackendConfig: map[string]interface{}{ "path": filepath.Join(stateDirectory, "backend.tfstate"), "workspace_dir": "current", }, } Init(t, options) options.BackendConfig["workspace_dir"] = "new" _, err = InitE(t, options) assert.Error(t, err, "Backend initialization with changed configuration should fail without -reconfigure option") options.Reconfigure = true _, err = InitE(t, options) assert.NoError(t, err, "Backend initialization with changed configuration should success with -reconfigure option") } func TestInitBackendMigration(t *testing.T) { t.Parallel() stateDirectory := t.TempDir() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-backend", t.Name()) require.NoError(t, err) defer os.RemoveAll(testFolder) options := &Options{ TerraformDir: testFolder, BackendConfig: map[string]interface{}{ "path": filepath.Join(stateDirectory, "backend.tfstate"), "workspace_dir": "current", }, } Init(t, options) options.BackendConfig["workspace_dir"] = "new" _, err = InitE(t, options) assert.Error(t, err, "Backend initialization with changed configuration should fail without -migrate-state option") options.MigrateState = true _, err = InitE(t, options) assert.NoError(t, err, "Backend initialization with changed configuration should success with -migrate-state option") } func TestInitNoColorOption(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-no-error", t.Name()) require.NoError(t, err) options := WithDefaultRetryableErrors(t, &Options{ TerraformDir: testFolder, NoColor: true, }) out := InitAndApply(t, options) require.Contains(t, out, "Hello, World") // Check that NoColor correctly doesn't output the colour escape codes which look like [0m, or [32m require.NotRegexp(t, `\[\d*m`, out, "Output should not contain color escape codes") } ================================================ FILE: modules/terraform/opa_check.go ================================================ package terraform import ( "os" "path/filepath" "strings" "github.com/hashicorp/go-multierror" "github.com/stretchr/testify/require" "github.com/tmccombs/hcl2json/convert" "github.com/gruntwork-io/terratest/modules/files" "github.com/gruntwork-io/terratest/modules/opa" "github.com/gruntwork-io/terratest/modules/testing" ) // OPAEval runs `opa eval` with the given option on the terraform files identified in the TerraformDir directory of the // Options struct. Note that since OPA does not natively support parsing HCL code, we first convert all the files to // JSON prior to passing it through OPA. This function fails the test if there is an error. func OPAEval( t testing.TestingT, tfOptions *Options, opaEvalOptions *opa.EvalOptions, resultQuery string, ) { require.NoError(t, OPAEvalE(t, tfOptions, opaEvalOptions, resultQuery)) } // OPAEvalE runs `opa eval` with the given option on the terraform files identified in the TerraformDir directory of the // Options struct. Note that since OPA does not natively support parsing HCL code, we first convert all the files to // JSON prior to passing it through OPA. func OPAEvalE( t testing.TestingT, tfOptions *Options, opaEvalOptions *opa.EvalOptions, resultQuery string, ) error { tfOptions.Logger.Logf(t, "Running terraform files in %s through `opa eval` on policy %s", tfOptions.TerraformDir, opaEvalOptions.RulePath) // Find all the tf files in the terraform dir to process. tfFiles, err := files.FindTerraformSourceFilesInDir(tfOptions.TerraformDir) if err != nil { return err } // Create a temporary dir to store all the json files tmpDir, err := os.MkdirTemp("", "terratest-opa-hcl2json-*") if err != nil { return err } if !opaEvalOptions.DebugKeepTempFiles { defer os.RemoveAll(tmpDir) } tfOptions.Logger.Logf(t, "Using temporary folder %s for json representation of terraform module %s", tmpDir, tfOptions.TerraformDir) // Convert all the found tf files to json format so OPA works. jsonFiles := make([]string, len(tfFiles)) errorsOccurred := new(multierror.Error) for i, tfFile := range tfFiles { tfFileBase := filepath.Base(tfFile) tfFileBaseName := strings.TrimSuffix(tfFileBase, filepath.Ext(tfFileBase)) outPath := filepath.Join(tmpDir, tfFileBaseName+".json") tfOptions.Logger.Logf(t, "Converting %s to json %s", tfFile, outPath) if err := HCLFileToJSONFile(tfFile, outPath); err != nil { errorsOccurred = multierror.Append(errorsOccurred, err) } jsonFiles[i] = outPath } if err := errorsOccurred.ErrorOrNil(); err != nil { return err } // Run OPA checks on each of the converted json files. return opa.EvalE(t, opaEvalOptions, jsonFiles, resultQuery) } // HCLFileToJSONFile is a function that takes a path containing HCL code, and converts it to JSON representation and // writes out the contents to the given path. func HCLFileToJSONFile(hclPath, jsonOutPath string) error { fileBytes, err := os.ReadFile(hclPath) if err != nil { return err } converted, err := convert.Bytes(fileBytes, hclPath, convert.Options{}) if err != nil { return err } return os.WriteFile(jsonOutPath, converted, 0600) } ================================================ FILE: modules/terraform/options.go ================================================ package terraform import ( "io" "time" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/ssh" "github.com/gruntwork-io/terratest/modules/testing" "github.com/jinzhu/copier" "github.com/stretchr/testify/require" ) var ( DefaultRetryableTerraformErrors = map[string]string{ // Helm related terraform calls may fail when too many tests run in parallel. While the exact cause is unknown, // this is presumably due to all the network contention involved. Usually a retry resolves the issue. ".*read: connection reset by peer.*": "Failed to reach helm charts repository.", ".*transport is closing.*": "Failed to reach Kubernetes API.", // `terraform init` frequently fails in CI due to network issues accessing plugins. The reason is unknown, but // eventually these succeed after a few retries. ".*unable to verify signature.*": "Failed to retrieve plugin due to transient network error.", ".*unable to verify checksum.*": "Failed to retrieve plugin due to transient network error.", ".*no provider exists with the given name.*": "Failed to retrieve plugin due to transient network error.", ".*registry service is unreachable.*": "Failed to retrieve plugin due to transient network error.", ".*Error installing provider.*": "Failed to retrieve plugin due to transient network error.", ".*Failed to query available provider packages.*": "Failed to retrieve plugin due to transient network error.", ".*timeout while waiting for plugin to start.*": "Failed to retrieve plugin due to transient network error.", ".*timed out waiting for server handshake.*": "Failed to retrieve plugin due to transient network error.", "could not query provider registry for": "Failed to retrieve plugin due to transient network error.", // Provider bugs where the data after apply is not propagated. This is usually an eventual consistency issue, so // retrying should self resolve it. // See https://github.com/terraform-providers/terraform-provider-aws/issues/12449 for an example. ".*Provider produced inconsistent result after apply.*": "Provider eventual consistency error.", } ) // Options for running Terraform commands type Options struct { TerraformBinary string // Name of the binary that will be used TerraformDir string // The path to the folder where the Terraform code is defined. // The vars to pass to Terraform commands using the -var option. Note that terraform does not support passing `null` // as a variable value through the command line. That is, if you use `map[string]interface{}{"foo": nil}` as `Vars`, // this will translate to the string literal `"null"` being assigned to the variable `foo`. However, nulls in // lists and maps/objects are supported. E.g., the following var will be set as expected (`{ bar = null }`: // map[string]interface{}{ // "foo": map[string]interface{}{"bar": nil}, // } Vars map[string]interface{} VarFiles []string // The var file paths to pass to Terraform commands using -var-file option. MixedVars []Var // Mix of `-var` and `-var-file` in arbritrary order, use `VarInline()` `VarFile()` to set the value. Targets []string // The target resources to pass to the terraform command with -target Lock bool // The lock option to pass to the terraform command with -lock LockTimeout string // The lock timeout option to pass to the terraform command with -lock-timeout EnvVars map[string]string // Environment variables to set when running Terraform BackendConfig map[string]interface{} // The vars to pass to the terraform init command for extra configuration for the backend. If a var is nil, it will be formated as `--backend-config=var` instead of `--backend-config=var=null` RetryableTerraformErrors map[string]string // If Terraform apply fails with one of these (transient) errors, retry. The keys are a regexp to match against the error and the message is what to display to a user if that error is matched. MaxRetries int // Maximum number of times to retry errors matching RetryableTerraformErrors TimeBetweenRetries time.Duration // The amount of time to wait between retries Upgrade bool // Whether the -upgrade flag of the terraform init command should be set to true or not Reconfigure bool // Set the -reconfigure flag to the terraform init command MigrateState bool // Set the -migrate-state and -force-copy (suppress 'yes' answer prompt) flag to the terraform init command NoColor bool // Whether the -no-color flag will be set for any Terraform command or not SshAgent *ssh.SshAgent // Overrides local SSH agent with the given in-process agent NoStderr bool // Disable stderr redirection OutputMaxLineSize int // The max size of one line in stdout and stderr (in bytes) Logger *logger.Logger // Set a non-default logger that should be used. See the logger package for more info. Parallelism int // Set the parallelism setting for Terraform PlanFilePath string // The path to output a plan file to (for the plan command) or read one from (for the apply command) PluginDir string // The path of downloaded plugins to pass to the terraform init command (-plugin-dir) SetVarsAfterVarFiles bool // Pass -var options after -var-file options to Terraform commands WarningsAsErrors map[string]string // Terraform warning messages that should be treated as errors. The keys are a regexp to match against the warning and the value is what to display to a user if that warning is matched. ExtraArgs ExtraArgs // Extra arguments passed to Terraform commands Stdin io.Reader // Optional stdin to pass to Terraform commands } type ExtraArgs struct { Apply []string Destroy []string Get []string Init []string Plan []string Validate []string WorkspaceDelete []string WorkspaceSelect []string WorkspaceNew []string Output []string Show []string } func prepend(args []string, arg ...string) []string { return append(arg, args...) } // Clone makes a deep copy of most fields on the Options object and returns it. // // NOTE: options.SshAgent and options.Logger CANNOT be deep copied (e.g., the SshAgent struct contains channels and // listeners that can't be meaningfully copied), so the original values are retained. func (options *Options) Clone() (*Options, error) { newOptions := &Options{} if err := copier.Copy(newOptions, options); err != nil { return nil, err } // copier does not deep copy maps, so we have to do it manually. newOptions.EnvVars = make(map[string]string) for key, val := range options.EnvVars { newOptions.EnvVars[key] = val } newOptions.Vars = make(map[string]interface{}) for key, val := range options.Vars { newOptions.Vars[key] = val } newOptions.BackendConfig = make(map[string]interface{}) for key, val := range options.BackendConfig { newOptions.BackendConfig[key] = val } newOptions.RetryableTerraformErrors = make(map[string]string) for key, val := range options.RetryableTerraformErrors { newOptions.RetryableTerraformErrors[key] = val } newOptions.WarningsAsErrors = make(map[string]string) for key, val := range options.WarningsAsErrors { newOptions.WarningsAsErrors[key] = val } newOptions.MixedVars = append(newOptions.MixedVars, options.MixedVars...) return newOptions, nil } // WithDefaultRetryableErrors makes a copy of the Options object and returns an updated object with sensible defaults // for retryable errors. The included retryable errors are typical errors that most terraform modules encounter during // testing, and are known to self resolve upon retrying. // This will fail the test if there are any errors in the cloning process. func WithDefaultRetryableErrors(t testing.TestingT, originalOptions *Options) *Options { newOptions, err := originalOptions.Clone() require.NoError(t, err) if newOptions.RetryableTerraformErrors == nil { newOptions.RetryableTerraformErrors = map[string]string{} } for k, v := range DefaultRetryableTerraformErrors { newOptions.RetryableTerraformErrors[k] = v } // These defaults for retry configuration are arbitrary, but have worked well in practice across Gruntwork // modules. newOptions.MaxRetries = 3 newOptions.TimeBetweenRetries = 5 * time.Second return newOptions } ================================================ FILE: modules/terraform/options_test.go ================================================ package terraform import ( "fmt" "regexp" "testing" "github.com/gruntwork-io/terratest/modules/random" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestOptionsCloneDeepClonesEnvVars(t *testing.T) { t.Parallel() unique := random.UniqueId() original := Options{ EnvVars: map[string]string{ "unique": unique, "original": unique, }, } copied, err := original.Clone() require.NoError(t, err) copied.EnvVars["unique"] = "nullified" assert.Equal(t, unique, original.EnvVars["unique"]) assert.Equal(t, unique, copied.EnvVars["original"]) } func TestOptionsCloneDeepClonesVars(t *testing.T) { t.Parallel() unique := random.UniqueId() original := Options{ Vars: map[string]interface{}{ "unique": unique, "original": unique, }, } copied, err := original.Clone() require.NoError(t, err) copied.Vars["unique"] = "nullified" assert.Equal(t, unique, original.Vars["unique"]) assert.Equal(t, unique, copied.Vars["original"]) } func TestExtraArgsHelp(t *testing.T) { t.Parallel() testtable := []struct { name string fn func() (string, error) }{ { name: "apply", fn: func() (string, error) { return ApplyE(t, &Options{ExtraArgs: ExtraArgs{Apply: []string{"-help"}}}) }, }, { name: "destroy", fn: func() (string, error) { return DestroyE(t, &Options{ExtraArgs: ExtraArgs{Destroy: []string{"-help"}}}) }, }, { name: "get", fn: func() (string, error) { return GetE(t, &Options{ExtraArgs: ExtraArgs{Get: []string{"-help"}}}) }, }, { name: "init", fn: func() (string, error) { return InitE(t, &Options{ExtraArgs: ExtraArgs{Init: []string{"-help"}}}) }, }, { name: "plan", fn: func() (string, error) { return PlanE(t, &Options{ExtraArgs: ExtraArgs{Plan: []string{"-help"}}}) }, }, { name: "validate", fn: func() (string, error) { return ValidateE(t, &Options{ExtraArgs: ExtraArgs{Validate: []string{"-help"}}}) }, }, } for _, tt := range testtable { out, err := tt.fn() require.NoError(t, err) assert.Regexp(t, regexp.MustCompile(fmt.Sprintf(`(Usage|USAGE):\s+\S+\s+(\[global options\]\s+)?%s`, tt.name)), out) } } func TestExtraArgsWorkspace(t *testing.T) { name := t.Name() t.Run("New", func(t *testing.T) { // set to default WorkspaceSelectOrNew(t, &Options{}, "default") // after adding -help, the function did not create the workspace out, err := WorkspaceSelectOrNewE(t, &Options{ExtraArgs: ExtraArgs{ WorkspaceNew: []string{"-help"}, }}, random.UniqueId()) require.NoError(t, err) require.Equal(t, "default", out) }) out, err := WorkspaceSelectOrNewE(t, &Options{}, name) require.NoError(t, err) require.Equal(t, name, out) t.Run("Select", func(t *testing.T) { // set to default WorkspaceSelectOrNew(t, &Options{}, "default") // after adding -help to select, the function did not select the workspace out, err := WorkspaceSelectOrNewE(t, &Options{ExtraArgs: ExtraArgs{ WorkspaceSelect: []string{"-help"}, }}, name) require.NoError(t, err) require.Equal(t, "default", out) }) t.Run("Delete", func(t *testing.T) { // after adding -help to select, the function did not delete the workspace _, err := WorkspaceDeleteE(t, &Options{ExtraArgs: ExtraArgs{ WorkspaceDelete: []string{"-help"}, }}, name) require.NoError(t, err) // the workspace should still exist out, err := RunTerraformCommandE(t, &Options{}, "workspace", "list") require.NoError(t, err) assert.Contains(t, out, name) }) } func TestOptionsCloneDeepClonesMixedVars(t *testing.T) { t.Parallel() unique := random.UniqueId() original := Options{ MixedVars: []Var{VarFile(unique), VarInline("unique", unique)}, } copied, err := original.Clone() require.NoError(t, err) copied.MixedVars[1] = VarInline("unique", "nullified") assert.Equal(t, VarFile(unique), copied.MixedVars[0]) assert.Equal(t, VarInline("unique", unique), original.MixedVars[1]) } ================================================ FILE: modules/terraform/output.go ================================================ package terraform import ( "encoding/json" "fmt" "reflect" "regexp" "strconv" "strings" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) const skipJsonLogLine = " msg=" var ( // ansiLineRegex matches lines starting with ANSI escape codes for text formatting (e.g., colors, styles). ansiLineRegex = regexp.MustCompile(`(?m)^\x1b\[[0-9;]*m.*`) // tgLogLevel matches log lines containing fields for time, level, prefix, binary, and message, each with non-whitespace values. tgLogLevel = regexp.MustCompile(`.*time=\S+ level=\S+ prefix=\S+ binary=\S+ msg=.*`) ) // Output calls terraform output for the given variable and return its string value representation. // It only designed to work with primitive terraform types: string, number and bool. // Please use OutputStruct for anything else. func Output(t testing.TestingT, options *Options, key string) string { out, err := OutputE(t, options, key) require.NoError(t, err) return out } // OutputE calls terraform output for the given variable and return its string value representation. // It only designed to work with primitive terraform types: string, number and bool. // Please use OutputStructE for anything else. func OutputE(t testing.TestingT, options *Options, key string) (string, error) { var val interface{} err := OutputStructE(t, options, key, &val) return fmt.Sprintf("%v", val), err } // OutputRequired calls terraform output for the given variable and return its value. If the value is empty, fail the test. func OutputRequired(t testing.TestingT, options *Options, key string) string { out, err := OutputRequiredE(t, options, key) require.NoError(t, err) return out } // OutputRequiredE calls terraform output for the given variable and return its value. If the value is empty, return an error. func OutputRequiredE(t testing.TestingT, options *Options, key string) (string, error) { out, err := OutputE(t, options, key) if err != nil { return "", err } if out == "" { return "", EmptyOutput(key) } return out, nil } // parseMap takes a map of interfaces and parses the types. // It is recursive which allows it to support complex nested structures. // At this time, this function uses https://golang.org/pkg/strconv/#ParseInt // to determine if a number should be a float or an int. For this reason, if you are // expecting a float with a zero as the "tenth" you will need to manually convert // the return value to a float. // // This function exists to map return values of the terraform outputs to intuitive // types. ie, if you are expecting a value of "1" you are implicitly expecting an int. // // This also allows the work to be executed recursively to support complex data types. func parseMap(m map[string]interface{}) (map[string]interface{}, error) { result := make(map[string]interface{}) for k, v := range m { switch vt := v.(type) { case map[string]interface{}: nestedMap, err := parseMap(vt) if err != nil { return nil, err } result[k] = nestedMap case []interface{}: nestedList, err := parseList(vt) if err != nil { return nil, err } result[k] = nestedList case float64: result[k] = parseFloat(vt) default: result[k] = vt } } return result, nil } func parseList(items []interface{}) (_ []interface{}, err error) { for i, v := range items { rv := reflect.ValueOf(v) switch rv.Kind() { case reflect.Map: items[i], err = parseMap(rv.Interface().(map[string]interface{})) case reflect.Slice, reflect.Array: items[i], err = parseList(rv.Interface().([]interface{})) case reflect.Float64: items[i] = parseFloat(v) } if err != nil { return nil, err } } return items, nil } func parseFloat(v interface{}) interface{} { testInt, err := strconv.ParseInt((fmt.Sprintf("%v", v)), 10, 0) if err == nil { return int(testInt) } return v } // OutputMapOfObjects calls terraform output for the given variable and returns its value as a map of lists/maps. // If the output value is not a map of lists/maps, then it fails the test. func OutputMapOfObjects(t testing.TestingT, options *Options, key string) map[string]interface{} { out, err := OutputMapOfObjectsE(t, options, key) require.NoError(t, err) return out } // OutputMapOfObjectsE calls terraform output for the given variable and returns its value as a map of lists/maps. // Also returns an error object if an error was generated. // If the output value is not a map of lists/maps, then it fails the test. func OutputMapOfObjectsE(t testing.TestingT, options *Options, key string) (map[string]interface{}, error) { out, err := OutputJsonE(t, options, key) if err != nil { return nil, err } var output map[string]interface{} if err := json.Unmarshal([]byte(out), &output); err != nil { return nil, err } return parseMap(output) } // OutputListOfObjects calls terraform output for the given variable and returns its value as a list of maps/lists. // If the output value is not a list of maps/lists, then it fails the test. func OutputListOfObjects(t testing.TestingT, options *Options, key string) []map[string]interface{} { out, err := OutputListOfObjectsE(t, options, key) require.NoError(t, err) return out } // OutputListOfObjectsE calls terraform output for the given variable and returns its value as a list of maps/lists. // Also returns an error object if an error was generated. // If the output value is not a list of maps/lists, then it fails the test. func OutputListOfObjectsE(t testing.TestingT, options *Options, key string) ([]map[string]interface{}, error) { out, err := OutputJsonE(t, options, key) if err != nil { return nil, err } var output []map[string]interface{} if err := json.Unmarshal([]byte(out), &output); err != nil { return nil, err } var result []map[string]interface{} for _, m := range output { newMap, err := parseMap(m) if err != nil { return nil, err } result = append(result, newMap) } return result, nil } // OutputList calls terraform output for the given variable and returns its value as a list. // If the output value is not a list type, then it fails the test. func OutputList(t testing.TestingT, options *Options, key string) []string { out, err := OutputListE(t, options, key) require.NoError(t, err) return out } // OutputListE calls terraform output for the given variable and returns its value as a list. // If the output value is not a list type, then it returns an error. func OutputListE(t testing.TestingT, options *Options, key string) ([]string, error) { out, err := OutputJsonE(t, options, key) if err != nil { return nil, err } var output interface{} if err := json.Unmarshal([]byte(out), &output); err != nil { return nil, err } if outputList, isList := output.([]interface{}); isList { return parseListOutputTerraform(outputList, key) } return nil, UnexpectedOutputType{Key: key, ExpectedType: "map or list", ActualType: reflect.TypeOf(output).String()} } // Parse a list output in the format it is returned by Terraform 0.12 and newer versions func parseListOutputTerraform(outputList []interface{}, key string) ([]string, error) { list := []string{} for _, item := range outputList { list = append(list, fmt.Sprintf("%v", item)) } return list, nil } // OutputMap calls terraform output for the given variable and returns its value as a map. // If the output value is not a map type, then it fails the test. func OutputMap(t testing.TestingT, options *Options, key string) map[string]string { out, err := OutputMapE(t, options, key) require.NoError(t, err) return out } // OutputMapE calls terraform output for the given variable and returns its value as a map. // If the output value is not a map type, then it returns an error. func OutputMapE(t testing.TestingT, options *Options, key string) (map[string]string, error) { out, err := OutputJsonE(t, options, key) if err != nil { return nil, err } outputMap := map[string]interface{}{} if err := json.Unmarshal([]byte(out), &outputMap); err != nil { return nil, err } resultMap := make(map[string]string) for k, v := range outputMap { resultMap[k] = fmt.Sprintf("%v", v) } return resultMap, nil } // OutputForKeys calls terraform output for the given key list and returns values as a map. // If keys not found in the output, fails the test func OutputForKeys(t testing.TestingT, options *Options, keys []string) map[string]interface{} { out, err := OutputForKeysE(t, options, keys) require.NoError(t, err) return out } // OutputJson calls terraform output for the given variable and returns the // result as the json string. // If key is an empty string, it will return all the output variables. func OutputJson(t testing.TestingT, options *Options, key string) string { str, err := OutputJsonE(t, options, key) require.NoError(t, err) return str } // OutputJsonE calls terraform output for the given variable and returns the // result as the json string. // If key is an empty string, it will return all the output variables. func OutputJsonE(t testing.TestingT, options *Options, key string) (string, error) { args := []string{"output", "-no-color", "-json"} args = append(args, options.ExtraArgs.Output...) if key != "" { args = append(args, key) } rawJson, err := RunTerraformCommandAndGetStdoutE(t, options, args...) if err != nil { return rawJson, err } return cleanJson(rawJson) } // OutputStruct calls terraform output for the given variable and stores the // result in the value pointed to by v. If v is nil or not a pointer, or if // the value returned by Terraform is not appropriate for a given target type, // it fails the test. func OutputStruct(t testing.TestingT, options *Options, key string, v interface{}) { err := OutputStructE(t, options, key, v) require.NoError(t, err) } // OutputStructE calls terraform output for the given variable and stores the // result in the value pointed to by v. If v is nil or not a pointer, or if // the value returned by Terraform is not appropriate for a given target type, // it returns an error. func OutputStructE(t testing.TestingT, options *Options, key string, v interface{}) error { out, err := OutputJsonE(t, options, key) if err != nil { return err } return json.Unmarshal([]byte(out), &v) } // OutputForKeysE calls terraform output for the given key list and returns values as a map. // The returned values are of type interface{} and need to be type casted as necessary. Refer to output_test.go func OutputForKeysE(t testing.TestingT, options *Options, keys []string) (map[string]interface{}, error) { out, err := OutputJsonE(t, options, "") if err != nil { return nil, err } outputMap := map[string]map[string]interface{}{} if err := json.Unmarshal([]byte(out), &outputMap); err != nil { return nil, err } if keys == nil { outputKeys := make([]string, 0, len(outputMap)) for k := range outputMap { outputKeys = append(outputKeys, k) } keys = outputKeys } resultMap := make(map[string]interface{}) for _, key := range keys { value, containsValue := outputMap[key]["value"] if !containsValue { return nil, OutputKeyNotFound(string(key)) } resultMap[key] = value } return resultMap, nil } // OutputAll calls terraform output returns all values as a map. // If there is error fetching the output, fails the test func OutputAll(t testing.TestingT, options *Options) map[string]interface{} { out, err := OutputAllE(t, options) require.NoError(t, err) return out } // OutputAllE calls terraform and returns all the outputs as a map func OutputAllE(t testing.TestingT, options *Options) (map[string]interface{}, error) { return OutputForKeysE(t, options, nil) } // clean the ANSI characters from the JSON and update formating func cleanJson(input string) (string, error) { // Remove ANSI escape codes cleaned := ansiLineRegex.ReplaceAllString(input, "") cleaned = tgLogLevel.ReplaceAllString(cleaned, "") lines := strings.Split(cleaned, "\n") var result []string for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed != "" && !strings.Contains(trimmed, skipJsonLogLine) { result = append(result, trimmed) } } ansiClean := strings.Join(result, "\n") var jsonObj interface{} if err := json.Unmarshal([]byte(ansiClean), &jsonObj); err != nil { return "", err } // Format JSON output with indentation normalized, err := json.MarshalIndent(jsonObj, "", " ") if err != nil { return "", err } return string(normalized), nil } ================================================ FILE: modules/terraform/output_test.go ================================================ package terraform import ( "fmt" "testing" "github.com/stretchr/testify/assert" "github.com/gruntwork-io/terratest/modules/files" "github.com/stretchr/testify/require" ) func TestOutputString(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-output", t.Name()) require.NoError(t, err) options := &Options{ TerraformDir: testFolder, } InitAndApply(t, options) b := Output(t, options, "bool") require.Equal(t, b, "true", "Bool %q should match %q", "true", b) str := Output(t, options, "string") require.Equal(t, str, "This is a string.", "String %q should match %q", "This is a string.", str) num := Output(t, options, "number") require.Equal(t, num, "3.14", "Number %q should match %q", "3.14", num) num1 := Output(t, options, "number1") require.Equal(t, num1, "3", "Number %q should match %q", "3", num1) unicodeString := Output(t, options, "unicode_string") require.Equal(t, "söme chäräcter", unicodeString) } func TestOutputList(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-output-list", t.Name()) require.NoError(t, err) options := &Options{ TerraformDir: testFolder, } InitAndApply(t, options) out := OutputList(t, options, "giant_steps") expectedLen := 4 expectedItem := "John Coltrane" expectedArray := []string{"John Coltrane", "Tommy Flanagan", "Paul Chambers", "Art Taylor"} require.Len(t, out, expectedLen, "Output should contain %d items", expectedLen) require.Contains(t, out, expectedItem, "Output should contain %q item", expectedItem) require.Equal(t, out[0], expectedItem, "First item should be %q, got %q", expectedItem, out[0]) require.Equal(t, out, expectedArray, "Array %q should match %q", expectedArray, out) } func TestOutputNotListError(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-output-list", t.Name()) if err != nil { t.Fatal(err) } options := &Options{ TerraformDir: testFolder, } InitAndApply(t, options) _, err = OutputListE(t, options, "not_a_list") require.Error(t, err) } func TestOutputMap(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-output-map", t.Name()) require.NoError(t, err) options := &Options{ TerraformDir: testFolder, } InitAndApply(t, options) out := OutputMap(t, options, "mogwai") t.Log(out) expectedLen := 4 expectedMap := map[string]string{ "guitar_1": "Stuart Braithwaite", "guitar_2": "Barry Burns", "bass": "Dominic Aitchison", "drums": "Martin Bulloch", } require.Len(t, out, expectedLen, "Output should contain %d items", expectedLen) require.Equal(t, expectedMap, out, "Map %q should match %q", expectedMap, out) } func TestOutputNotMapError(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-output-map", t.Name()) require.NoError(t, err) options := &Options{ TerraformDir: testFolder, } InitAndApply(t, options) _, err = OutputMapE(t, options, "not_a_map") require.Error(t, err) } func TestOutputMapOfObjects(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-output-mapofobjects", t.Name()) require.NoError(t, err) options := &Options{ TerraformDir: testFolder, } InitAndApply(t, options) out := OutputMapOfObjects(t, options, "map_of_objects") nestedMap1 := map[string]interface{}{ "four": 4, "five": "five", } nestedList1 := []interface{}{ map[string]interface{}{ "six": 6, "seven": "seven", }, } expectedMap1 := map[string]interface{}{ "somebool": true, "somefloat": 1.1, "one": 1, "two": "two", "three": "three", "nest": nestedMap1, "nest_list": nestedList1, } require.Equal(t, expectedMap1, out) } func TestOutputNotMapOfObjectsError(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-output-mapofobjects", t.Name()) require.NoError(t, err) options := &Options{ TerraformDir: testFolder, } InitAndApply(t, options) _, err = OutputMapOfObjectsE(t, options, "not_map_of_objects") require.Error(t, err) } func TestOutputListOfObjects(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-output-listofobjects", t.Name()) require.NoError(t, err) options := &Options{ TerraformDir: testFolder, } InitAndApply(t, options) out := OutputListOfObjects(t, options, "list_of_maps") expectedLen := 3 nestedMap1 := map[string]interface{}{ "four": 4, "five": "five", } nestedList1 := []interface{}{ map[string]interface{}{ "four": 4, "five": "five", }, } expectedMap1 := map[string]interface{}{ "one": 1, "two": "two", "three": "three", "more": nestedMap1, } expectedMap2 := map[string]interface{}{ "one": "one", "two": 2, "three": 3, "more": nestedList1, } expectedMap3 := map[string]interface{}{ "one": "one", "two": 2, "three": 3, "more": []interface{}{ "one", 2, 3.4, []interface{}{"one", 2, 3.4}, map[string]interface{}{"one": 2, "three": 3.4}, }, } require.Len(t, out, expectedLen, "Output should contain %d items", expectedLen) assert.Equal(t, out[0], expectedMap1, "First map should be %q, got %q", expectedMap1, out[0]) assert.Equal(t, out[1], expectedMap2, "Second map should be %q, got %q", expectedMap2, out[1]) assert.Equal(t, out[2], expectedMap3, "Third map should be %q, got %q", expectedMap3, out[1]) } func TestOutputNotListOfObjectsError(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-output-listofobjects", t.Name()) require.NoError(t, err) options := &Options{ TerraformDir: testFolder, } InitAndApply(t, options) _, err = OutputListOfObjectsE(t, options, "not_list_of_maps") require.Error(t, err) } func TestOutputsForKeys(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-output-all", t.Name()) require.NoError(t, err) options := &Options{ TerraformDir: testFolder, } keys := []string{"our_star", "stars", "magnitudes"} InitAndApply(t, options) out := OutputForKeys(t, options, keys) expectedLen := 3 require.Len(t, out, expectedLen, "Output should contain %d items", expectedLen) //String value expectedString := "Sun" str, ok := out["our_star"].(string) require.True(t, ok, fmt.Sprintf("Wrong data type for 'our_star', expected string, got %T", out["our_star"])) require.Equal(t, expectedString, str, "String %q should match %q", expectedString, str) //List value expectedListLen := 3 outputInterfaceList, ok := out["stars"].([]interface{}) require.True(t, ok, fmt.Sprintf("Wrong data type for 'stars', expected [], got %T", out["stars"])) expectedListItem := "Sirius" require.Len(t, outputInterfaceList, expectedListLen, "Output list should contain %d items", expectedListLen) require.Equal(t, expectedListItem, outputInterfaceList[0].(string), "List Item %q should match %q", expectedListItem, outputInterfaceList[0].(string)) //Map value outputInterfaceMap, ok := out["magnitudes"].(map[string]interface{}) require.True(t, ok, fmt.Sprintf("Wrong data type for 'magnitudes', expected map[string], got %T", out["magnitudes"])) expectedMapLen := 3 expectedMapItem := -1.46 require.Len(t, outputInterfaceMap, expectedMapLen, "Output map should contain %d items", expectedMapLen) require.Equal(t, expectedMapItem, outputInterfaceMap["Sirius"].(float64), "Map Item %q should match %q", expectedMapItem, outputInterfaceMap["Sirius"].(float64)) //Key not in the parameter list outputNotPresentMap, ok := out["constellations"].(map[string]interface{}) require.False(t, ok) require.Nil(t, outputNotPresentMap) } func TestOutputJson(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-output", t.Name()) require.NoError(t, err) options := &Options{ TerraformDir: testFolder, } InitAndApply(t, options) expected := `{ "bool": { "sensitive": false, "type": "bool", "value": true }, "number": { "sensitive": false, "type": "number", "value": 3.14 }, "number1": { "sensitive": false, "type": "number", "value": 3 }, "string": { "sensitive": false, "type": "string", "value": "This is a string." }, "unicode_string": { "sensitive": false, "type": "string", "value": "söme chäräcter" } }` str := OutputJson(t, options, "") require.Equal(t, str, expected, "JSON %q should match %q", expected, str) } func TestOutputStruct(t *testing.T) { t.Parallel() type TestStruct struct { Somebool bool Somefloat float64 Someint int Somestring string Somemap map[string]interface{} Listmaps []map[string]interface{} Liststrings []string } testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-output-struct", t.Name()) if err != nil { t.Fatal(err) } options := &Options{ TerraformDir: testFolder, // Let's test order or ExtraArgs while we are at it ExtraArgs: ExtraArgs{ Output: []string{"-state=terraform.tfstate"}, }, } InitAndApply(t, options) expectedObject := TestStruct{ Somebool: true, Somefloat: 0.1, Someint: 1, Somestring: "two", Somemap: map[string]interface{}{"three": 3.0, "four": "four"}, Listmaps: []map[string]interface{}{{"five": 5.0, "six": "six"}}, Liststrings: []string{"seven", "eight", "nine"}, } actualObject := TestStruct{} OutputStruct(t, options, "object", &actualObject) expectedList := []TestStruct{ { Somebool: true, Somefloat: 0.1, Someint: 1, Somestring: "two", }, { Somebool: false, Somefloat: 0.3, Someint: 4, Somestring: "five", }, } actualList := []TestStruct{} OutputStruct(t, options, "list_of_objects", &actualList) require.Equal(t, expectedObject, actualObject, "Object should be %q, got %q", expectedObject, actualObject) require.Equal(t, expectedList, actualList, "List should be %q, got %q", expectedList, actualList) } func TestOutputsAll(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-output-all", t.Name()) if err != nil { t.Fatal(err) } options := &Options{ TerraformDir: testFolder, } InitAndApply(t, options) out := OutputAll(t, options) expectedLen := 4 require.Len(t, out, expectedLen, "Output should contain %d items", expectedLen) //String Value expectedString := "Sun" str, ok := out["our_star"].(string) require.True(t, ok, fmt.Sprintf("Wrong data type for 'our_star', expected string, got %T", out["our_star"])) require.Equal(t, expectedString, str, "String %q should match %q", expectedString, str) //List Value expectedListLen := 3 outputInterfaceList, ok := out["stars"].([]interface{}) require.True(t, ok, fmt.Sprintf("Wrong data type for 'stars', expected [], got %T", out["stars"])) expectedListItem := "Betelgeuse" require.Len(t, outputInterfaceList, expectedListLen, "Output list should contain %d items", expectedListLen) require.Equal(t, expectedListItem, outputInterfaceList[2].(string), "List item %q should match %q", expectedListItem, outputInterfaceList[0].(string)) //Map Value expectedMapLen := 4 outputInterfaceMap, ok := out["constellations"].(map[string]interface{}) require.True(t, ok, fmt.Sprintf("Wrong data type for 'constellations', expected map[string], got %T", out["constellations"])) expectedMapItem := "Aldebaran" require.Len(t, outputInterfaceMap, expectedMapLen, "Output map should contain 4 items") require.Equal(t, expectedMapItem, outputInterfaceMap["Taurus"].(string), "Map item %q should match %q", expectedMapItem, outputInterfaceMap["Taurus"].(string)) } func TestOutputsForKeysError(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-output-map", t.Name()) require.NoError(t, err) options := &Options{ TerraformDir: testFolder, } InitAndApply(t, options) _, err = OutputForKeysE(t, options, []string{"random_key"}) require.Error(t, err) } func TestOutputWithDebugLogLevel(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-output-mapofobjects", t.Name()) require.NoError(t, err) options := &Options{ TerraformDir: testFolder, } InitAndApply(t, options) _, err = OutputMapOfObjectsE(t, &Options{ TerraformDir: options.TerraformDir, EnvVars: map[string]string{"TF_LOG": "DEBUG"}, }, "map_of_objects") require.NoError(t, err) } ================================================ FILE: modules/terraform/plan.go ================================================ package terraform import ( "fmt" "os" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // InitAndPlan runs terraform init and plan with the given options and returns stdout/stderr from the plan command. // This will fail the test if there is an error in the command. func InitAndPlan(t testing.TestingT, options *Options) string { out, err := InitAndPlanE(t, options) require.NoError(t, err) return out } // InitAndPlanE runs terraform init and plan with the given options and returns stdout/stderr from the plan command. func InitAndPlanE(t testing.TestingT, options *Options) (string, error) { if _, err := InitE(t, options); err != nil { return "", err } return PlanE(t, options) } // Plan runs terraform plan with the given options and returns stdout/stderr. // This will fail the test if there is an error in the command. func Plan(t testing.TestingT, options *Options) string { out, err := PlanE(t, options) require.NoError(t, err) return out } // PlanE runs terraform plan with the given options and returns stdout/stderr. func PlanE(t testing.TestingT, options *Options) (string, error) { return RunTerraformCommandE(t, options, FormatArgs(options, prepend(options.ExtraArgs.Plan, "plan", "-input=false", "-lock=false")...)...) } // InitAndPlanAndShow runs terraform init, then terraform plan, and then terraform show with the given options, and // returns the json output of the plan file. This will fail the test if there is an error in the command. func InitAndPlanAndShow(t testing.TestingT, options *Options) string { jsonOut, err := InitAndPlanAndShowE(t, options) require.NoError(t, err) return jsonOut } // InitAndPlanAndShowE runs terraform init, then terraform plan, and then terraform show with the given options, and // returns the json output of the plan file. func InitAndPlanAndShowE(t testing.TestingT, options *Options) (string, error) { if options.PlanFilePath == "" { return "", PlanFilePathRequired } _, err := InitAndPlanE(t, options) if err != nil { return "", err } return ShowE(t, options) } // InitAndPlanAndShowWithStructNoLog runs InitAndPlanAndShowWithStruct without logging and also by allocating a // temporary plan file destination that is discarded before returning the struct. func InitAndPlanAndShowWithStructNoLogTempPlanFile(t testing.TestingT, options *Options) *PlanStruct { oldLogger := options.Logger options.Logger = logger.Discard defer func() { options.Logger = oldLogger }() tmpFile, err := os.CreateTemp("", "terratest-plan-file-") require.NoError(t, err) require.NoError(t, tmpFile.Close()) defer require.NoError(t, os.Remove(tmpFile.Name())) options.PlanFilePath = tmpFile.Name() return InitAndPlanAndShowWithStruct(t, options) } // InitAndPlanAndShowWithStruct runs terraform init, then terraform plan, and then terraform show with the given // options, and parses the json result into a go struct. This will fail the test if there is an error in the command. func InitAndPlanAndShowWithStruct(t testing.TestingT, options *Options) *PlanStruct { plan, err := InitAndPlanAndShowWithStructE(t, options) require.NoError(t, err) return plan } // InitAndPlanAndShowWithStructE runs terraform init, then terraform plan, and then terraform show with the given options, and // parses the json result into a go struct. func InitAndPlanAndShowWithStructE(t testing.TestingT, options *Options) (*PlanStruct, error) { jsonOut, err := InitAndPlanAndShowE(t, options) if err != nil { return nil, err } return ParsePlanJSON(jsonOut) } // InitAndPlanWithExitCode runs terraform init and plan with the given options and returns exitcode for the plan command. // This will fail the test if there is an error in the command. func InitAndPlanWithExitCode(t testing.TestingT, options *Options) int { exitCode, err := InitAndPlanWithExitCodeE(t, options) require.NoError(t, err) return exitCode } // InitAndPlanWithExitCodeE runs terraform init and plan with the given options and returns exitcode for the plan command. func InitAndPlanWithExitCodeE(t testing.TestingT, options *Options) (int, error) { if _, err := InitE(t, options); err != nil { return DefaultErrorExitCode, err } return PlanExitCodeE(t, options) } // PlanExitCode runs terraform plan with the given options and returns the detailed exitcode. // This will fail the test if there is an error in the command. func PlanExitCode(t testing.TestingT, options *Options) int { exitCode, err := PlanExitCodeE(t, options) require.NoError(t, err) return exitCode } // PlanExitCodeE runs terraform plan with the given options and returns the detailed exitcode. func PlanExitCodeE(t testing.TestingT, options *Options) (int, error) { return GetExitCodeForTerraformCommandE(t, options, FormatArgs(options, prepend(options.ExtraArgs.Plan, "plan", "-input=false", "-detailed-exitcode")...)...) } // Custom errors var ( PlanFilePathRequired = fmt.Errorf("You must set PlanFilePath on options struct to use this function.") ) ================================================ FILE: modules/terraform/plan_struct.go ================================================ package terraform import ( "encoding/json" "github.com/gruntwork-io/terratest/modules/testing" tfjson "github.com/hashicorp/terraform-json" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // PlanStruct is a Go Struct representation of the plan object returned from Terraform (after running `terraform show`). // Unlike the raw plan representation returned by terraform-json, this struct provides a map that maps the resource // addresses to the changes and planned values to make it easier to navigate the raw plan struct. type PlanStruct struct { // The raw representation of the plan. See // https://www.terraform.io/docs/internals/json-format.html#plan-representation for details on the structure of the // plan output. RawPlan tfjson.Plan // A map that maps full resource addresses (e.g., module.foo.null_resource.test) to the planned values of that // resource. ResourcePlannedValuesMap map[string]*tfjson.StateResource // A map that maps full resource addresses (e.g., module.foo.null_resource.test) to the planned actions terraform // will take on that resource. ResourceChangesMap map[string]*tfjson.ResourceChange } // ParsePlanJSON takes in the json string representation of the terraform plan and returns a go struct representation // for easy introspection. func ParsePlanJSON(jsonStr string) (*PlanStruct, error) { plan := &PlanStruct{} if err := json.Unmarshal([]byte(jsonStr), &plan.RawPlan); err != nil { return nil, err } plan.ResourcePlannedValuesMap = parsePlannedValues(plan) plan.ResourceChangesMap = parseResourceChanges(plan) return plan, nil } // parseResourceChanges takes a plan and returns a map that maps resource addresses to the planned changes for that // resource. If there are no changes, this returns an empty map instead of erroring. func parseResourceChanges(plan *PlanStruct) map[string]*tfjson.ResourceChange { out := map[string]*tfjson.ResourceChange{} for _, change := range plan.RawPlan.ResourceChanges { out[change.Address] = change } return out } // parsePlannedValues takes a plan and walks through the planned values to return a map that maps the full resource // addresses to the planned resources. If there are no planned values, this returns an empty map instead of erroring. func parsePlannedValues(plan *PlanStruct) map[string]*tfjson.StateResource { plannedValues := plan.RawPlan.PlannedValues if plannedValues == nil { // No planned values, so return empty map. return map[string]*tfjson.StateResource{} } rootModule := plannedValues.RootModule if rootModule == nil { // No module resources, so return empty map. return map[string]*tfjson.StateResource{} } return parseModulePlannedValues(rootModule) } // parseModulePlannedValues will recursively walk through the modules in the planned_values of the plan struct to // construct a map that maps the full resource addresses to the planned resource. func parseModulePlannedValues(module *tfjson.StateModule) map[string]*tfjson.StateResource { out := map[string]*tfjson.StateResource{} for _, resource := range module.Resources { // NOTE: the Address attribute of the module resource always returns the full address, even when the resource is // nested within sub modules. out[resource.Address] = resource } // NOTE: base case of recursion is when ChildModules is empty list. for _, child := range module.ChildModules { // Recurse in to the child module. We take a recursive approach here despite limitations of the recursion stack // in golang due to the fact that it is rare to have heavily deep module calls in Terraform. So we optimize for // code readability as opposed to performance. childMap := parseModulePlannedValues(child) for k, v := range childMap { out[k] = v } } return out } // AssertPlannedValuesMapKeyExists checks if the given key exists in the map, failing the test if it does not. func AssertPlannedValuesMapKeyExists(t testing.TestingT, plan *PlanStruct, keyQuery string) { _, hasKey := plan.ResourcePlannedValuesMap[keyQuery] assert.Truef(t, hasKey, "Given planned values map does not have key %s", keyQuery) } // RequirePlannedValuesMapKeyExists checks if the given key exists in the map, failing and halting the test if it does not. func RequirePlannedValuesMapKeyExists(t testing.TestingT, plan *PlanStruct, keyQuery string) { _, hasKey := plan.ResourcePlannedValuesMap[keyQuery] require.Truef(t, hasKey, "Given planned values map does not have key %s", keyQuery) } // AssertResourceChangesMapKeyExists checks if the given key exists in the map, failing the test if it does not. func AssertResourceChangesMapKeyExists(t testing.TestingT, plan *PlanStruct, keyQuery string) { _, hasKey := plan.ResourceChangesMap[keyQuery] assert.Truef(t, hasKey, "Given resource changes map does not have key %s", keyQuery) } // RequireResourceChangesMapKeyExists checks if the given key exists in the map, failing the test if it does not. func RequireResourceChangesMapKeyExists(t testing.TestingT, plan *PlanStruct, keyQuery string) { _, hasKey := plan.ResourceChangesMap[keyQuery] require.Truef(t, hasKey, "Given resource changes map does not have key %s", keyQuery) } ================================================ FILE: modules/terraform/plan_struct_test.go ================================================ package terraform import ( "testing" http_helper "github.com/gruntwork-io/terratest/modules/http-helper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( // NOTE: We pull down the json files from github during test runtime as opposed to checking it in as these source // files are licensed under MPL and we want to avoid a dual license scenario where some source files in terratest // are licensed under a different license. basicJsonUrl = "https://raw.githubusercontent.com/hashicorp/terraform-json/v0.8.0/testdata/basic/plan.json" deepModuleJsonUrl = "https://raw.githubusercontent.com/hashicorp/terraform-json/v0.8.0/testdata/deep_module/plan.json" changesJsonUrl = "https://raw.githubusercontent.com/hashicorp/terraform-json/v0.8.0/testdata/has_changes/plan.json" ) func TestPlannedValuesMapWithBasicJson(t *testing.T) { t.Parallel() // Retrieve test data from the terraform-json project. _, jsonData := http_helper.HttpGet(t, basicJsonUrl, nil) plan, err := ParsePlanJSON(jsonData) require.NoError(t, err) query := []string{ "data.null_data_source.baz", "null_resource.bar", "null_resource.baz[0]", "null_resource.baz[1]", "null_resource.baz[2]", "null_resource.foo", "module.foo.null_resource.aliased", "module.foo.null_resource.foo", } for _, key := range query { RequirePlannedValuesMapKeyExists(t, plan, key) resource := plan.ResourcePlannedValuesMap[key] assert.Equal(t, resource.Address, key) } } func TestPlannedValuesMapWithDeepModuleJson(t *testing.T) { t.Parallel() // Retrieve test data from the terraform-json project. _, jsonData := http_helper.HttpGet(t, deepModuleJsonUrl, nil) plan, err := ParsePlanJSON(jsonData) require.NoError(t, err) query := []string{ "module.foo.module.bar.null_resource.baz", } for _, key := range query { AssertPlannedValuesMapKeyExists(t, plan, key) } } func TestResourceChangesJson(t *testing.T) { t.Parallel() // Retrieve test data from the terraform-json project. _, jsonData := http_helper.HttpGet(t, changesJsonUrl, nil) plan, err := ParsePlanJSON(jsonData) require.NoError(t, err) // Spot check a few changes to make sure the right address was registered RequireResourceChangesMapKeyExists(t, plan, "module.foo.null_resource.foo") fooChanges := plan.ResourceChangesMap["module.foo.null_resource.foo"] require.NotNil(t, fooChanges.Change) assert.Equal(t, fooChanges.Change.After.(map[string]interface{})["triggers"].(map[string]interface{})["foo"].(string), "bar") RequireResourceChangesMapKeyExists(t, plan, "null_resource.bar") barChanges := plan.ResourceChangesMap["null_resource.bar"] require.NotNil(t, barChanges.Change) assert.Equal(t, barChanges.Change.After.(map[string]interface{})["triggers"].(map[string]interface{})["foo_id"].(string), "424881806176056736") } ================================================ FILE: modules/terraform/plan_test.go ================================================ package terraform import ( "fmt" "path/filepath" "strings" "testing" "github.com/gruntwork-io/terratest/modules/files" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestInitAndPlanWithError(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-with-plan-error", t.Name()) require.NoError(t, err) options := &Options{ TerraformDir: testFolder, } _, err = InitAndPlanE(t, options) require.Error(t, err) } func TestInitAndPlanWithNoError(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-no-error", t.Name()) require.NoError(t, err) options := &Options{ TerraformDir: testFolder, } // In Terraform 0.12 and below, if there were no resources to create, update, or destroy, 'plan' command would // report "No changes. Infrastructure is up-to-date." However, with 0.13 and above, if the Terraform configuration // has never been applied at all, 'plan' always shows changes. So we have to run 'apply' first, and can then // check that 'plan' returns the message we expect. InitAndApply(t, options) out, err := PlanE(t, options) require.NoError(t, err) require.Contains(t, out, "No changes.") } func TestInitAndPlanWithOutput(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-basic-configuration", t.Name()) require.NoError(t, err) options := &Options{ TerraformDir: testFolder, Vars: map[string]interface{}{ "cnt": 1, }, } out, err := InitAndPlanE(t, options) require.NoError(t, err) require.Contains(t, out, "1 to add, 0 to change, 0 to destroy.") } func TestInitAndPlanWithPlanFile(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-basic-configuration", t.Name()) require.NoError(t, err) planFilePath := filepath.Join(testFolder, "plan.out") options := &Options{ TerraformDir: testFolder, Vars: map[string]interface{}{ "cnt": 1, }, PlanFilePath: planFilePath, } out, err := InitAndPlanE(t, options) require.NoError(t, err) // clean output to be consistent in checks out = strings.ReplaceAll(out, "\n", "") assert.Contains(t, out, "1 to add, 0 to change, 0 to destroy.") assert.Contains(t, out, fmt.Sprintf("Saved the plan to:%s", planFilePath)) assert.FileExists(t, planFilePath, "Plan file was not saved to expected location:", planFilePath) } func TestInitAndPlanAndShowWithStructNoLogTempPlanFile(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-basic-configuration", t.Name()) require.NoError(t, err) options := &Options{ TerraformDir: testFolder, Vars: map[string]interface{}{ "cnt": 1, }, } planStruct := InitAndPlanAndShowWithStructNoLogTempPlanFile(t, options) assert.Equal(t, 1, len(planStruct.ResourceChangesMap)) } func TestPlanWithExitCodeWithNoChanges(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-no-error", t.Name()) require.NoError(t, err) options := &Options{ TerraformDir: testFolder, } // In Terraform 0.12 and below, if there were no resources to create, update, or destroy, the -detailed-exitcode // would return a code of 0. However, with 0.13 and above, if the Terraform configuration has never been applied // at all, -detailed-exitcode always returns an exit code of 2. So we have to run 'apply' first, and can then // check that 'plan' returns the exit code we expect. InitAndApply(t, options) exitCode := PlanExitCode(t, options) require.Equal(t, DefaultSuccessExitCode, exitCode) } func TestPlanWithExitCodeWithChanges(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-basic-configuration", t.Name()) require.NoError(t, err) options := &Options{ TerraformDir: testFolder, Vars: map[string]interface{}{ "cnt": 1, }, } exitCode := InitAndPlanWithExitCode(t, options) require.Equal(t, TerraformPlanChangesPresentExitCode, exitCode) } func TestPlanWithExitCodeWithFailure(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-with-plan-error", t.Name()) require.NoError(t, err) options := &Options{ TerraformDir: testFolder, } exitCode, getExitCodeErr := InitAndPlanWithExitCodeE(t, options) require.NoError(t, getExitCodeErr) require.Equal(t, exitCode, 1) } ================================================ FILE: modules/terraform/show.go ================================================ package terraform import ( "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // Show calls terraform show in json mode with the given options and returns stdout from the command. If // PlanFilePath is set on the options, this will show the plan file. Otherwise, this will show the current state of the // terraform module at options.TerraformDir. This will fail the test if there is an error in the command. func Show(t testing.TestingT, options *Options) string { out, err := ShowE(t, options) require.NoError(t, err) return out } // ShowE calls terraform show in json mode with the given options and returns stdout from the command. If // PlanFilePath is set on the options, this will show the plan file. Otherwise, this will show the current state of the // terraform module at options.TerraformDir. func ShowE(t testing.TestingT, options *Options) (string, error) { // We manually construct the args here instead of using `FormatArgs`, because show only accepts a limited set of // args. args := []string{"show", "-no-color", "-json"} // Attach plan file path if specified. if options.PlanFilePath != "" { args = append(args, options.PlanFilePath) } return RunTerraformCommandAndGetStdoutE(t, options, prepend(options.ExtraArgs.Show, args...)...) } func ShowWithStruct(t testing.TestingT, options *Options) *PlanStruct { out, err := ShowWithStructE(t, options) require.NoError(t, err) return out } func ShowWithStructE(t testing.TestingT, options *Options) (*PlanStruct, error) { json, err := ShowE(t, options) if err != nil { return nil, err } planStruct, err := ParsePlanJSON(json) if err != nil { return nil, err } return planStruct, nil } ================================================ FILE: modules/terraform/show_test.go ================================================ package terraform import ( "fmt" "path/filepath" "strings" "testing" "github.com/gruntwork-io/terratest/modules/files" "github.com/stretchr/testify/require" ) func TestShowWithInlinePlan(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-basic-configuration", t.Name()) require.NoError(t, err) planFilePath := filepath.Join(testFolder, "plan.out") options := &Options{ TerraformDir: testFolder, PlanFilePath: planFilePath, Vars: map[string]interface{}{ "cnt": 1, }, } out := InitAndPlan(t, options) out = strings.ReplaceAll(out, "\n", "") require.Contains(t, out, fmt.Sprintf("Saved the plan to:%s", planFilePath)) require.FileExists(t, planFilePath, "Plan file was not saved to expected location:", planFilePath) // show command does not accept Vars showOptions := &Options{ TerraformDir: testFolder, PlanFilePath: planFilePath, } // Test the JSON string planJSON := Show(t, showOptions) require.Contains(t, planJSON, "null_resource.test[0]") } func TestShowWithStructInlinePlan(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-basic-configuration", t.Name()) require.NoError(t, err) planFilePath := filepath.Join(testFolder, "plan.out") options := &Options{ TerraformDir: testFolder, PlanFilePath: planFilePath, Vars: map[string]interface{}{ "cnt": 1, }, } out := InitAndPlan(t, options) out = strings.ReplaceAll(out, "\n", "") require.Contains(t, out, fmt.Sprintf("Saved the plan to:%s", planFilePath)) require.FileExists(t, planFilePath, "Plan file was not saved to expected location:", planFilePath) // show command does not accept Vars showOptions := &Options{ TerraformDir: testFolder, PlanFilePath: planFilePath, } // Test the JSON string plan := ShowWithStruct(t, showOptions) require.Contains(t, plan.ResourcePlannedValuesMap, "null_resource.test[0]") } ================================================ FILE: modules/terraform/terraform.go ================================================ // Package terraform allows to interact with Terraform. package terraform // https://www.terraform.io/docs/commands/plan.html#detailed-exitcode // TerraformPlanChangesPresentExitCode is the exit code returned by terraform plan detailed exitcode when changes are present const TerraformPlanChangesPresentExitCode = 2 // DefaultSuccessExitCode is the exit code returned when terraform command succeeds const DefaultSuccessExitCode = 0 // DefaultErrorExitCode is the exit code returned when terraform command fails const DefaultErrorExitCode = 1 ================================================ FILE: modules/terraform/validate.go ================================================ package terraform import ( "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // Validate calls terraform validate and returns stdout/stderr. func Validate(t testing.TestingT, options *Options) string { out, err := ValidateE(t, options) require.NoError(t, err) return out } // ValidateE calls terraform validate and returns stdout/stderr. func ValidateE(t testing.TestingT, options *Options) (string, error) { return RunTerraformCommandE(t, options, FormatArgs(options, prepend(options.ExtraArgs.Validate, "validate")...)...) } // InitAndValidate runs terraform init and validate with the given options and returns stdout/stderr from the validate command. // This will fail the test if there is an error in the command. func InitAndValidate(t testing.TestingT, options *Options) string { out, err := InitAndValidateE(t, options) require.NoError(t, err) return out } // InitAndValidateE runs terraform init and validate with the given options and returns stdout/stderr from the validate command. func InitAndValidateE(t testing.TestingT, options *Options) (string, error) { if _, err := InitE(t, options); err != nil { return "", err } return ValidateE(t, options) } ================================================ FILE: modules/terraform/validate_test.go ================================================ package terraform import ( "testing" "github.com/gruntwork-io/terratest/modules/files" "github.com/stretchr/testify/require" ) func TestInitAndValidateWithNoError(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-basic-configuration", t.Name()) require.NoError(t, err) options := &Options{ TerraformDir: testFolder, } out := InitAndValidate(t, options) require.Contains(t, out, "The configuration is valid") } func TestInitAndValidateWithError(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-with-plan-error", t.Name()) require.NoError(t, err) options := &Options{ TerraformDir: testFolder, } out, err := InitAndValidateE(t, options) require.Error(t, err) require.Contains(t, out, "Reference to undeclared input variable") } ================================================ FILE: modules/terraform/var-file.go ================================================ package terraform import ( "encoding/json" "fmt" "os" "reflect" "strings" "github.com/gruntwork-io/terratest/modules/testing" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclparse" "github.com/stretchr/testify/require" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/gocty" ctyjson "github.com/zclconf/go-cty/cty/json" ) // GetVariableAsStringFromVarFile Gets the string representation of a variable from a provided input file found in VarFile // For list or map, use GetVariableAsListFromVarFile or GetVariableAsMapFromVarFile, respectively. func GetVariableAsStringFromVarFile(t testing.TestingT, fileName string, key string) string { result, err := GetVariableAsStringFromVarFileE(t, fileName, key) require.NoError(t, err) return result } // GetVariableAsStringFromVarFileE Gets the string representation of a variable from a provided input file found in VarFile // Will return an error if GetAllVariablesFromVarFileE returns an error or the key provided does not exist in the file. // For list or map, use GetVariableAsListFromVarFile or GetVariableAsMapFromVarFile, respectively. func GetVariableAsStringFromVarFileE(t testing.TestingT, fileName string, key string) (string, error) { var variables map[string]interface{} err := GetAllVariablesFromVarFileE(t, fileName, &variables) if err != nil { return "", err } variable, exists := variables[key] if !exists { return "", InputFileKeyNotFound{FilePath: fileName, Key: key} } return fmt.Sprintf("%v", variable), nil } // GetVariableAsMapFromVarFile Gets the map representation of a variable from a provided input file found in VarFile // Note that this returns a map of strings. For maps containing complex types, use GetAllVariablesFromVarFile. func GetVariableAsMapFromVarFile(t testing.TestingT, fileName string, key string) map[string]string { result, err := GetVariableAsMapFromVarFileE(t, fileName, key) require.NoError(t, err) return result } // GetVariableAsMapFromVarFileE Gets the map representation of a variable from a provided input file found in VarFile. // Note that this returns a map of strings. For maps containing complex types, use GetAllVariablesFromVarFile // Returns an error if GetAllVariablesFromVarFileE returns an error, the key provided does not exist, or the value associated with the key is not a map func GetVariableAsMapFromVarFileE(t testing.TestingT, fileName string, key string) (map[string]string, error) { var variables map[string]interface{} err := GetAllVariablesFromVarFileE(t, fileName, &variables) if err != nil { return nil, err } variable, exists := variables[key] if !exists { return nil, InputFileKeyNotFound{FilePath: fileName, Key: key} } if reflect.TypeOf(variable).String() != "map[string]interface {}" { return nil, UnexpectedOutputType{Key: key, ExpectedType: "map[string]interface {}", ActualType: reflect.TypeOf(variable).String()} } resultMap := make(map[string]string) for mapKey, mapVal := range variable.(map[string]interface{}) { resultMap[mapKey] = fmt.Sprintf("%v", mapVal) } return resultMap, nil } // GetVariableAsListFromVarFile Gets the string list representation of a variable from a provided input file found in VarFile // Note that this returns a list of strings. For lists containing complex types, use GetAllVariablesFromVarFile. func GetVariableAsListFromVarFile(t testing.TestingT, fileName string, key string) []string { result, err := GetVariableAsListFromVarFileE(t, fileName, key) require.NoError(t, err) return result } // GetVariableAsListFromVarFileE Gets the string list representation of a variable from a provided input file found in VarFile // Note that this returns a list of strings. For lists containing complex types, use GetAllVariablesFromVarFile. // Will return error if GetAllVariablesFromVarFileE returns an error, the key provided does not exist, or the value associated with the key is not a list func GetVariableAsListFromVarFileE(t testing.TestingT, fileName string, key string) ([]string, error) { var variables map[string]interface{} err := GetAllVariablesFromVarFileE(t, fileName, &variables) if err != nil { return nil, err } variable, exists := variables[key] if !exists { return nil, InputFileKeyNotFound{FilePath: fileName, Key: key} } if reflect.TypeOf(variable).String() != "[]interface {}" { return nil, UnexpectedOutputType{Key: key, ExpectedType: "[]interface {}", ActualType: reflect.TypeOf(variable).String()} } resultArray := []string{} for _, item := range variable.([]interface{}) { resultArray = append(resultArray, fmt.Sprintf("%v", item)) } return resultArray, nil } // GetAllVariablesFromVarFile Parses all data from a provided input file found ind in VarFile and stores the result in // the value pointed to by out. func GetAllVariablesFromVarFile(t testing.TestingT, fileName string, out interface{}) { err := GetAllVariablesFromVarFileE(t, fileName, out) require.NoError(t, err) } // GetAllVariablesFromVarFileE Parses all data from a provided input file found ind in VarFile and stores the result in // the value pointed to by out. Returns an error if the specified file does not exist, the specified file is not // readable, or the specified file cannot be decoded from HCL. func GetAllVariablesFromVarFileE(t testing.TestingT, fileName string, out interface{}) error { fileContents, err := os.ReadFile(fileName) if err != nil { return err } return parseAndDecodeVarFile(string(fileContents), fileName, out) } // parseAndDecodeVarFile uses the HCL2 parser to parse the given varfile string into an HCL or HCL JSON file body, and then decode it // into a map that maps var names to values. func parseAndDecodeVarFile(fileContents string, filename string, out interface{}) (err error) { // The HCL2 parser and especially cty conversions will panic in many types of errors, so we have to recover from // those panics here and convert them to normal errors defer func() { if recovered := recover(); recovered != nil { err = PanicWhileParsingVarFile{RecoveredValue: recovered, ConfigFile: filename} } }() parser := hclparse.NewParser() var file *hcl.File var parseDiagnostics hcl.Diagnostics // determine if a JSON variables file is submitted and parse accordingly if strings.HasSuffix(filename, ".json") { file, parseDiagnostics = parser.ParseJSON([]byte(fileContents), filename) } else { file, parseDiagnostics = parser.ParseHCL([]byte(fileContents), filename) } if parseDiagnostics != nil && parseDiagnostics.HasErrors() { return parseDiagnostics } // VarFiles should only have attributes, so extract the attributes and decode the expressions into the return map. attrs, hclDiags := file.Body.JustAttributes() if hclDiags != nil && hclDiags.HasErrors() { return hclDiags } valMap := map[string]cty.Value{} for name, attr := range attrs { val, hclDiags := attr.Expr.Value(nil) // nil because no function calls or variable references are allowed here if hclDiags != nil && hclDiags.HasErrors() { return hclDiags } valMap[name] = val } ctyVal, err := convertValuesMapToCtyVal(valMap) if err != nil { return err } typedOut, hasType := out.(*map[string]interface{}) if hasType { genericMap, err := parseCtyValueToMap(ctyVal) if err != nil { return err } *typedOut = genericMap return nil } return gocty.FromCtyValue(ctyVal, out) } // This is a hacky workaround to convert a cty Value to a Go map[string]interface{}. cty does not support this directly // (https://github.com/hashicorp/hcl2/issues/108) and doing it with gocty.FromCtyValue is nearly impossible, as cty // requires you to specify all the output types and will error out when it hits interface{}. So, as an ugly workaround, // we convert the given value to JSON using cty's JSON library and then convert the JSON back to a // map[string]interface{} using the Go json library. func parseCtyValueToMap(value cty.Value) (map[string]interface{}, error) { jsonBytes, err := ctyjson.Marshal(value, cty.DynamicPseudoType) if err != nil { return nil, err } var ctyJsonOutput CtyJsonOutput if err := json.Unmarshal(jsonBytes, &ctyJsonOutput); err != nil { return nil, err } return ctyJsonOutput.Value, nil } // When you convert a cty value to JSON, if any of that types are not yet known (i.e., are labeled as // DynamicPseudoType), cty's Marshall method will write the type information to a type field and the actual value to // a value field. This struct is used to capture that information so when we parse the JSON back into a Go struct, we // can pull out just the Value field we need. type CtyJsonOutput struct { Value map[string]interface{} Type interface{} } // convertValuesMapToCtyVal takes a map of name - cty.Value pairs and converts to a single cty.Value object that can // then be converted to other go types. func convertValuesMapToCtyVal(valMap map[string]cty.Value) (cty.Value, error) { valMapAsCty := cty.NilVal if valMap != nil && len(valMap) > 0 { var err error valMapAsCty, err = gocty.ToCtyValue(valMap, generateTypeFromValuesMap(valMap)) if err != nil { return valMapAsCty, err } } return valMapAsCty, nil } // generateTypeFromValuesMap takes a values map and returns an object type that has the same number of fields, but // bound to each type of the underlying evaluated expression. This is the only way the HCL decoder will be happy, as // object type is the only map type that allows different types for each attribute (cty.Map requires all attributes to // have the same type. func generateTypeFromValuesMap(valMap map[string]cty.Value) cty.Type { outType := map[string]cty.Type{} for k, v := range valMap { outType[k] = v.Type() } return cty.Object(outType) } ================================================ FILE: modules/terraform/var-file_test.go ================================================ package terraform import ( "fmt" "os" "testing" "github.com/gruntwork-io/terratest/modules/random" "github.com/stretchr/testify/require" ) func TestGetVariablesFromVarFilesAsString(t *testing.T) { randomFileName := fmt.Sprintf("./%s.tfvars", random.UniqueId()) testHcl := []byte(` aws_region = "us-east-2" aws_account_id = "111111111111" number_type = 2 boolean_type = true tags = { foo = "bar" } list = ["item1"]`) WriteFile(t, randomFileName, testHcl) defer os.Remove(randomFileName) stringVal := GetVariableAsStringFromVarFile(t, randomFileName, "aws_region") boolString := GetVariableAsStringFromVarFile(t, randomFileName, "boolean_type") numString := GetVariableAsStringFromVarFile(t, randomFileName, "number_type") require.Equal(t, "us-east-2", stringVal) require.Equal(t, "true", boolString) require.Equal(t, "2", numString) } func TestGetVariablesFromVarFilesAsStringKeyDoesNotExist(t *testing.T) { randomFileName := fmt.Sprintf("./%s.tfvars", random.UniqueId()) testHcl := []byte(` aws_region = "us-east-2" aws_account_id = "111111111111" tags = { foo = "bar" } list = ["item1"]`) WriteFile(t, randomFileName, testHcl) defer os.Remove(randomFileName) _, err := GetVariableAsStringFromVarFileE(t, randomFileName, "badkey") require.Error(t, err) } func TestGetVariableAsMapFromVarFile(t *testing.T) { randomFileName := fmt.Sprintf("./%s.tfvars", random.UniqueId()) expected := make(map[string]string) expected["foo"] = "bar" testHcl := []byte(` aws_region = "us-east-2" aws_account_id = "111111111111" tags = { foo = "bar" } list = ["item1"]`) WriteFile(t, randomFileName, testHcl) defer os.Remove(randomFileName) val := GetVariableAsMapFromVarFile(t, randomFileName, "tags") require.Equal(t, expected, val) } func TestGetVariableAsMapFromVarFileNotMap(t *testing.T) { randomFileName := fmt.Sprintf("./%s.tfvars", random.UniqueId()) testHcl := []byte(` aws_region = "us-east-2" aws_account_id = "111111111111" tags = { foo = "bar" } list = ["item1"]`) WriteFile(t, randomFileName, testHcl) defer os.Remove(randomFileName) _, err := GetVariableAsMapFromVarFileE(t, randomFileName, "aws_region") require.Error(t, err) } func TestGetVariableAsMapFromVarFileKeyDoesNotExist(t *testing.T) { randomFileName := fmt.Sprintf("./%s.tfvars", random.UniqueId()) testHcl := []byte(` aws_region = "us-east-2" aws_account_id = "111111111111" tags = { foo = "bar" } list = ["item1"]`) WriteFile(t, randomFileName, testHcl) defer os.Remove(randomFileName) _, err := GetVariableAsMapFromVarFileE(t, randomFileName, "badkey") require.Error(t, err) } func TestGetVariableAsListFromVarFile(t *testing.T) { randomFileName := fmt.Sprintf("./%s.tfvars", random.UniqueId()) expected := []string{"item1"} testHcl := []byte(` aws_region = "us-east-2" aws_account_id = "111111111111" tags = { foo = "bar" } list = ["item1"]`) WriteFile(t, randomFileName, testHcl) defer os.Remove(randomFileName) val := GetVariableAsListFromVarFile(t, randomFileName, "list") require.Equal(t, expected, val) } func TestGetVariableAsListNotList(t *testing.T) { randomFileName := fmt.Sprintf("./%s.tfvars", random.UniqueId()) testHcl := []byte(` aws_region = "us-east-2" aws_account_id = "111111111111" tags = { foo = "bar" } list = ["item1"]`) WriteFile(t, randomFileName, testHcl) defer os.Remove(randomFileName) _, err := GetVariableAsListFromVarFileE(t, randomFileName, "tags") require.Error(t, err) } func TestGetVariableAsListKeyDoesNotExist(t *testing.T) { randomFileName := fmt.Sprintf("./%s.tfvars", random.UniqueId()) testHcl := []byte(` aws_region = "us-east-2" aws_account_id = "111111111111" tags = { foo = "bar" } list = ["item1"]`) WriteFile(t, randomFileName, testHcl) defer os.Remove(randomFileName) _, err := GetVariableAsListFromVarFileE(t, randomFileName, "badkey") require.Error(t, err) } func TestGetAllVariablesFromVarFileEFileDoesNotExist(t *testing.T) { var variables map[string]interface{} err := GetAllVariablesFromVarFileE(t, "filea", &variables) require.Equal(t, "open filea: no such file or directory", err.Error()) } func TestGetAllVariablesFromVarFileBadFile(t *testing.T) { randomFileName := fmt.Sprintf("./%s.tfvars", random.UniqueId()) testHcl := []byte(` thiswillnotwork`) WriteFile(t, randomFileName, testHcl) defer os.Remove(randomFileName) var variables map[string]interface{} err := GetAllVariablesFromVarFileE(t, randomFileName, &variables) require.Error(t, err) // HCL library could change their error string, so we are only testing the error string contains what we add to it require.Regexp(t, fmt.Sprintf("^%s:2,3-18: ", randomFileName), err.Error()) } func TestGetAllVariablesFromVarFile(t *testing.T) { randomFileName := fmt.Sprintf("./%s.tfvars", random.UniqueId()) testHcl := []byte(` aws_region = "us-east-2" `) WriteFile(t, randomFileName, testHcl) defer os.Remove(randomFileName) var variables map[string]interface{} err := GetAllVariablesFromVarFileE(t, randomFileName, &variables) require.NoError(t, err) expected := make(map[string]interface{}) expected["aws_region"] = "us-east-2" require.Equal(t, expected, variables) } func TestGetAllVariablesFromVarFileStructOut(t *testing.T) { randomFileName := fmt.Sprintf("./%s.tfvars", random.UniqueId()) testHcl := []byte(` aws_region = "us-east-2" `) WriteFile(t, randomFileName, testHcl) defer os.Remove(randomFileName) var region struct { AwsRegion string `cty:"aws_region"` } err := GetAllVariablesFromVarFileE(t, randomFileName, ®ion) require.NoError(t, err) require.Equal(t, "us-east-2", region.AwsRegion) } func TestGetVariablesFromVarFilesAsStringJSON(t *testing.T) { randomFileName := fmt.Sprintf("./%s.tfvars.json", random.UniqueId()) testJSON := []byte(` { "aws_region": "us-east-2", "aws_account_id": "111111111111", "number_type": 2, "boolean_type": true, "tags": { "foo": "bar" }, "list": ["item1"] }`) WriteFile(t, randomFileName, testJSON) defer os.Remove(randomFileName) stringVal := GetVariableAsStringFromVarFile(t, randomFileName, "aws_region") boolString := GetVariableAsStringFromVarFile(t, randomFileName, "boolean_type") numString := GetVariableAsStringFromVarFile(t, randomFileName, "number_type") require.Equal(t, "us-east-2", stringVal) require.Equal(t, "true", boolString) require.Equal(t, "2", numString) } func TestGetVariablesFromVarFilesAsStringKeyDoesNotExistJSON(t *testing.T) { randomFileName := fmt.Sprintf("./%s.tfvars.json", random.UniqueId()) testJSON := []byte(` { "aws_region": "us-east-2", "aws_account_id": "111111111111", "tags": { "foo": "bar" }, "list": ["item1"] }`) WriteFile(t, randomFileName, testJSON) defer os.Remove(randomFileName) _, err := GetVariableAsStringFromVarFileE(t, randomFileName, "badkey") require.Error(t, err) } func TestGetVariableAsMapFromVarFileJSON(t *testing.T) { randomFileName := fmt.Sprintf("./%s.tfvars.json", random.UniqueId()) expected := make(map[string]string) expected["foo"] = "bar" testJSON := []byte(` { "aws_region": "us-east-2", "aws_account_id": "111111111111", "tags": { "foo": "bar" }, "list": ["item1"] }`) WriteFile(t, randomFileName, testJSON) defer os.Remove(randomFileName) val := GetVariableAsMapFromVarFile(t, randomFileName, "tags") require.Equal(t, expected, val) } func TestGetVariableAsMapFromVarFileNotMapJSON(t *testing.T) { randomFileName := fmt.Sprintf("./%s.tfvars.json", random.UniqueId()) testJSON := []byte(` { "aws_region": "us-east-2", "aws_account_id": "111111111111", "tags": { "foo": "bar" }, "list": ["item1"] }`) WriteFile(t, randomFileName, testJSON) defer os.Remove(randomFileName) _, err := GetVariableAsMapFromVarFileE(t, randomFileName, "aws_region") require.Error(t, err) } func TestGetVariableAsMapFromVarFileKeyDoesNotExistJSON(t *testing.T) { randomFileName := fmt.Sprintf("./%s.tfvars.json", random.UniqueId()) testJSON := []byte(` { "aws_region": "us-east-2", "aws_account_id": "111111111111", "tags": { "foo": "bar" }, "list": ["item1"] }`) WriteFile(t, randomFileName, testJSON) defer os.Remove(randomFileName) _, err := GetVariableAsMapFromVarFileE(t, randomFileName, "badkey") require.Error(t, err) } func TestGetVariableAsListFromVarFileJSON(t *testing.T) { randomFileName := fmt.Sprintf("./%s.tfvars.json", random.UniqueId()) expected := []string{"item1"} testJSON := []byte(` { "aws_region": "us-east-2", "aws_account_id": "111111111111", "tags": { "foo": "bar" }, "list": ["item1"] }`) WriteFile(t, randomFileName, testJSON) defer os.Remove(randomFileName) val := GetVariableAsListFromVarFile(t, randomFileName, "list") require.Equal(t, expected, val) } func TestGetVariableAsListNotListJSON(t *testing.T) { randomFileName := fmt.Sprintf("./%s.tfvars.json", random.UniqueId()) testJSON := []byte(` { "aws_region": "us-east-2", "aws_account_id": "111111111111", "tags": { "foo": "bar" }, "list": ["item1"] }`) WriteFile(t, randomFileName, testJSON) defer os.Remove(randomFileName) _, err := GetVariableAsListFromVarFileE(t, randomFileName, "tags") require.Error(t, err) } func TestGetVariableAsListKeyDoesNotExistJSON(t *testing.T) { randomFileName := fmt.Sprintf("./%s.tfvars.json", random.UniqueId()) testJSON := []byte(` { "aws_region": "us-east-2", "aws_account_id": "111111111111", "tags": { "foo": "bar" }, "list": ["item1"] }`) WriteFile(t, randomFileName, testJSON) defer os.Remove(randomFileName) _, err := GetVariableAsListFromVarFileE(t, randomFileName, "badkey") require.Error(t, err) } func TestGetAllVariablesFromVarFileBadFileJSON(t *testing.T) { randomFileName := fmt.Sprintf("./%s.tfvars.json", random.UniqueId()) testJSON := []byte(` { thiswillnotwork }`) WriteFile(t, randomFileName, testJSON) defer os.Remove(randomFileName) var variables map[string]interface{} err := GetAllVariablesFromVarFileE(t, randomFileName, &variables) require.Error(t, err) // HCL library could change their error string, so we are only testing the error string contains what we add to it require.Regexp(t, fmt.Sprintf("^%s:3,7-22: ", randomFileName), err.Error()) } func TestGetAllVariablesFromVarFileJSON(t *testing.T) { randomFileName := fmt.Sprintf("./%s.tfvars.json", random.UniqueId()) testJSON := []byte(` { "aws_region": "us-east-2" } `) WriteFile(t, randomFileName, testJSON) defer os.Remove(randomFileName) var variables map[string]interface{} err := GetAllVariablesFromVarFileE(t, randomFileName, &variables) require.NoError(t, err) expected := make(map[string]interface{}) expected["aws_region"] = "us-east-2" require.Equal(t, expected, variables) } func TestGetAllVariablesFromVarFileStructOutJSON(t *testing.T) { randomFileName := fmt.Sprintf("./%s.tfvars.json", random.UniqueId()) testJSON := []byte(` { "aws_region": "us-east-2" } `) WriteFile(t, randomFileName, testJSON) defer os.Remove(randomFileName) var region struct { AwsRegion string `cty:"aws_region"` } err := GetAllVariablesFromVarFileE(t, randomFileName, ®ion) require.NoError(t, err) require.Equal(t, "us-east-2", region.AwsRegion) } // Helper function to write a file to the filesystem // Will immediately fail the test if it could not write the file func WriteFile(t *testing.T, fileName string, bytes []byte) { err := os.WriteFile(fileName, bytes, 0644) require.NoError(t, err) } ================================================ FILE: modules/terraform/var.go ================================================ package terraform type Var interface { Args() []string internal() } func VarInline(name string, value interface{}) Var { return varInline{name: name, value: value} } type varInline struct { name string value interface{} } func (vi varInline) Args() []string { m := map[string]interface{}{vi.name: vi.value} return formatTerraformArgs(m, "-var", true, false) } func (vi varInline) internal() {} func VarFile(path string) Var { return varFile(path) } type varFile string func (vf varFile) Args() []string { return []string{"-var-file", string(vf)} } func (vi varFile) internal() {} ================================================ FILE: modules/terraform/workspace.go ================================================ package terraform import ( "strings" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // WorkspaceSelectOrNew runs terraform workspace with the given options and the workspace name // and returns a name of the current workspace. It tries to select a workspace with the given // name, or it creates a new one if it doesn't exist. func WorkspaceSelectOrNew(t testing.TestingT, options *Options, name string) string { out, err := WorkspaceSelectOrNewE(t, options, name) if err != nil { t.Fatal(err) } return out } // WorkspaceSelectOrNewE runs terraform workspace with the given options and the workspace name // and returns a name of the current workspace. It tries to select a workspace with the given // name, or it creates a new one if it doesn't exist. func WorkspaceSelectOrNewE(t testing.TestingT, options *Options, name string) (string, error) { out, err := RunTerraformCommandE(t, options, "workspace", "list") if err != nil { return "", err } if isExistingWorkspace(out, name) { _, err = RunTerraformCommandE(t, options, prepend(options.ExtraArgs.WorkspaceSelect, "workspace", "select", name)...) } else { _, err = RunTerraformCommandE(t, options, prepend(options.ExtraArgs.WorkspaceNew, "workspace", "new", name)...) } if err != nil { return "", err } return RunTerraformCommandE(t, options, "workspace", "show") } func isExistingWorkspace(out string, name string) bool { workspaces := strings.Split(out, "\n") for _, ws := range workspaces { if strings.HasSuffix(ws, name) { return true } } return false } // WorkspaceDelete removes the specified terraform workspace with the given options. // It returns the name of the current workspace AFTER deletion, and the returned error (that can be nil). // If the workspace to delete is the current one, then it tries to switch to the "default" workspace. // Deleting the workspace "default" is not supported. func WorkspaceDeleteE(t testing.TestingT, options *Options, name string) (string, error) { currentWorkspace, err := RunTerraformCommandE(t, options, "workspace", "show") if err != nil { return currentWorkspace, err } if name == "default" { return currentWorkspace, &UnsupportedDefaultWorkspaceDeletion{} } out, err := RunTerraformCommandE(t, options, "workspace", "list") if err != nil { return currentWorkspace, err } if !isExistingWorkspace(out, name) { return currentWorkspace, WorkspaceDoesNotExist(name) } // Switch workspace before deleting if it is the current if currentWorkspace == name { currentWorkspace, err = WorkspaceSelectOrNewE(t, options, "default") if err != nil { return currentWorkspace, err } } // delete workspace _, err = RunTerraformCommandE(t, options, prepend(options.ExtraArgs.WorkspaceDelete, "workspace", "delete", name)...) return currentWorkspace, err } // WorkspaceDelete removes the specified terraform workspace with the given options. // It returns the name of the current workspace AFTER deletion. // If the workspace to delete is the current one, then it tries to switch to the "default" workspace. // Deleting the workspace "default" is not supported and only return an empty string (to avoid a fatal error). func WorkspaceDelete(t testing.TestingT, options *Options, name string) string { out, err := WorkspaceDeleteE(t, options, name) require.NoError(t, err) return out } ================================================ FILE: modules/terraform/workspace_test.go ================================================ package terraform import ( "errors" "testing" "github.com/gruntwork-io/terratest/modules/files" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestWorkspaceNew(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-workspace", t.Name()) if err != nil { t.Fatal(err) } options := &Options{ TerraformDir: testFolder, } out := WorkspaceSelectOrNew(t, options, "terratest") assert.Equal(t, "terratest", out) } func TestWorkspaceIllegalName(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-workspace", t.Name()) if err != nil { t.Fatal(err) } options := &Options{ TerraformDir: testFolder, } out, err := WorkspaceSelectOrNewE(t, options, "###@@@&&&") assert.Error(t, err) assert.Equal(t, "", out, "%q should be an empty string", out) } func TestWorkspaceSelect(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-workspace", t.Name()) if err != nil { t.Fatal(err) } options := &Options{ TerraformDir: testFolder, } out := WorkspaceSelectOrNew(t, options, "terratest") assert.Equal(t, "terratest", out) out = WorkspaceSelectOrNew(t, options, "default") assert.Equal(t, "default", out) } func TestWorkspaceApply(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-workspace", t.Name()) if err != nil { t.Fatal(err) } options := &Options{ TerraformDir: testFolder, } WorkspaceSelectOrNew(t, options, "Terratest") out := InitAndApply(t, options) assert.Contains(t, out, "Hello, Terratest") } func TestIsExistingWorkspace(t *testing.T) { t.Parallel() testCases := []struct { out string name string expected bool }{ {" default\n* foo\n", "default", true}, {"* default\n foo\n", "default", true}, {" foo\n* default\n", "default", true}, {"* foo\n default\n", "default", true}, {" foo\n* bar\n", "default", false}, {"* foo\n bar\n", "default", false}, {" default\n* foobar\n", "foo", false}, {"* default\n foobar\n", "foo", false}, {" default\n* foo\n", "foobar", false}, {"* default\n foo\n", "foobar", false}, {"* default\n foo\n", "foo", true}, } for _, testCase := range testCases { actual := isExistingWorkspace(testCase.out, testCase.name) assert.Equal(t, testCase.expected, actual, "Out: %q, Name: %q", testCase.out, testCase.name) } } func TestWorkspaceDeleteE(t *testing.T) { t.Parallel() // state describes an expected status when a given testCase begins type state struct { workspaces []string current string } // testCase describes a named test case with a state, args and expcted results type testCase struct { name string initialState state toDeleteWorkspace string expectedCurrent string expectedError error } testCases := []testCase{ { name: "delete another existing workspace and stay on current", initialState: state{ workspaces: []string{"staging", "production"}, current: "staging", }, toDeleteWorkspace: "production", expectedCurrent: "staging", expectedError: nil, }, { name: "delete current workspace and switch to a specified", initialState: state{ workspaces: []string{"staging", "production"}, current: "production", }, toDeleteWorkspace: "production", expectedCurrent: "default", expectedError: nil, }, { name: "delete a non existing workspace should trigger an error", initialState: state{ workspaces: []string{"staging", "production"}, current: "staging", }, toDeleteWorkspace: "hellothere", expectedCurrent: "staging", expectedError: WorkspaceDoesNotExist("hellothere"), }, { name: "delete the default workspace triggers an error", initialState: state{ workspaces: []string{"staging", "production"}, current: "staging", }, toDeleteWorkspace: "default", expectedCurrent: "staging", expectedError: &UnsupportedDefaultWorkspaceDeletion{}, }, } for _, testCase := range testCases { testCase := testCase t.Run(testCase.name, func(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-workspace", testCase.name) require.NoError(t, err) options := &Options{ TerraformDir: testFolder, } // Set up pre-existing environment based on test case description for _, existingWorkspace := range testCase.initialState.workspaces { _, err = RunTerraformCommandE(t, options, "workspace", "new", existingWorkspace) require.NoError(t, err) } // Switch to the specified workspace _, err = RunTerraformCommandE(t, options, "workspace", "select", testCase.initialState.current) require.NoError(t, err) // Testing time, wooohoooo gotResult, gotErr := WorkspaceDeleteE(t, options, testCase.toDeleteWorkspace) // Check for errors if testCase.expectedError != nil { if !errors.Is(gotErr, testCase.expectedError) { t.Errorf("expected error: %v, got error: %v", testCase.expectedError, gotErr) } } else { assert.NoError(t, gotErr) // Check for results assert.Equal(t, testCase.expectedCurrent, gotResult) assert.False(t, isExistingWorkspace(RunTerraformCommand(t, options, "workspace", "list"), testCase.toDeleteWorkspace)) } }) } } ================================================ FILE: modules/terragrunt/README.md ================================================ # Terragrunt Module Testing library for Terragrunt configurations in Go. Provides helpers for running Terragrunt commands for single units, across multiple modules (run-all), and stack-based workflows. ## Requirements - **Terragrunt** binary in PATH - **OpenTofu** or **Terraform** binary in PATH (Terragrunt is a wrapper and requires one of these) To specify which binary to use (terraform vs opentofu): ```go // Option 1: Via environment variable options := &terragrunt.Options{ TerragruntDir: "/path/to/config", EnvVars: map[string]string{ "TERRAGRUNT_TFPATH": "/usr/local/bin/tofu", // or "TG_TF_PATH" }, } // Option 2: Via command-line flag options := &terragrunt.Options{ TerragruntDir: "/path/to/config", TerragruntArgs: []string{"--tf-path", "/usr/local/bin/tofu"}, } ``` ## Quick Start ### Single Unit ```go import ( "testing" "github.com/gruntwork-io/terratest/modules/terragrunt" "github.com/stretchr/testify/assert" ) func TestSingleUnit(t *testing.T) { t.Parallel() options := &terragrunt.Options{ TerragruntDir: "../path/to/terragrunt/unit", } defer terragrunt.Destroy(t, options) terragrunt.InitAndApply(t, options) // Get a specific output as JSON vpcOutput := terragrunt.OutputJson(t, options, "vpc_id") assert.Contains(t, vpcOutput, "vpc-") } ``` ### Multiple Modules (--all) ```go func TestTerragruntApply(t *testing.T) { t.Parallel() options := &terragrunt.Options{ TerragruntDir: "../path/to/terragrunt/config", } defer terragrunt.DestroyAll(t, options) terragrunt.ApplyAll(t, options) } ``` ## Key Concepts ### Options Struct The `Options` struct has two distinct parts: 1. **Test Framework Configuration** (NOT passed to terragrunt CLI): - `TerragruntDir` - where to run terragrunt (required) - `TerragruntBinary` - binary name (default: "terragrunt") - `EnvVars` - environment variables - `Logger` - custom logger for output - `MaxRetries`, `TimeBetweenRetries` - retry settings - `RetryableTerraformErrors` - map of error patterns to retry messages - `WarningsAsErrors` - map of warning patterns to treat as errors - `BackendConfig` - backend configuration passed to `init` - `PluginDir` - plugin directory passed to `init` - `Stdin` - stdin reader for commands 2. **Command-Line Arguments** (passed to terragrunt): - `TerragruntArgs` - global terragrunt flags (e.g., `--log-level`, `--no-color`) - `TerraformArgs` - command-specific OpenTofu/Terraform flags (e.g., `-upgrade`) ### Error-Returning Variants (E-suffix) Every function has an `E`-suffix variant that returns an error instead of calling `t.Fatal` on failure. For example: - `Apply(t, options)` calls `t.Fatal` on error - `ApplyE(t, options)` returns `(string, error)` for custom error handling Use `E` variants when you need to test error cases or handle failures gracefully: ```go _, err := terragrunt.ApplyE(t, options) require.Error(t, err) ``` ### TerragruntArgs vs TerraformArgs Arguments are passed in this order: ``` terragrunt [TerragruntArgs] --non-interactive run -- [TerraformArgs] ``` **Example:** ```go options := &terragrunt.Options{ TerragruntDir: "/path/to/config", TerragruntArgs: []string{"--log-level", "error"}, // Global TG flags TerraformArgs: []string{"-upgrade"}, // OpenTofu/Terraform flags } // Executes: terragrunt --log-level error --non-interactive run -- init -upgrade ``` ## Functions ### Single-Unit Commands Run terragrunt commands against a single unit (one `terragrunt.hcl` directory): - `Init(t, options)` - Initialize configuration - `Apply(t, options)` - Apply changes - `Destroy(t, options)` - Destroy resources - `Plan(t, options)` - Generate and show execution plan - `PlanExitCode(t, options)` - Plan and return exit code (0=no changes, 2=changes, other=error) - `Validate(t, options)` - Validate configuration - `OutputJson(t, options, key)` - Get output as JSON (specific key or all outputs) ### Convenience Wrappers Run init + command in a single call: - `InitAndApply(t, options)` - Init then apply - `InitAndPlan(t, options)` - Init then plan - `InitAndValidate(t, options)` - Init then validate ### Run Command - `Run(t, options, tgArgs, tfArgs)` - Run any OpenTofu/Terraform command via `terragrunt run [tgArgs...] -- [tfArgs...]` The `--` separator disambiguates Terragrunt flags (like `--all`) from OpenTofu/Terraform flags. The OpenTofu/Terraform command (e.g. `"apply"`) should be the first element of `tfArgs`. ### Run --all Commands Work with [implicit stacks](https://terragrunt.gruntwork.io/docs/features/stacks/#implicit-stacks) (multiple units in a directory): - `ApplyAll(t, options)` - Apply all modules with dependencies - `DestroyAll(t, options)` - Destroy all modules with dependencies - `PlanAllExitCode(t, options)` - Plan all and return exit code (0=no changes, 2=changes, other=error) - `ValidateAll(t, options)` - Validate all modules - `RunAll(t, options, command)` - *Deprecated: use `Run` with `--all` in tgArgs instead.* Run any OpenTofu/Terraform command with --all flag - `OutputAllJson(t, options)` - Get all outputs as raw JSON string (note: returns separate JSON objects per module) ### HCL Commands Terragrunt HCL tooling commands: - `FormatAll(t, options)` - Format all terragrunt.hcl files (`terragrunt hcl format`) - `HclValidate(t, options)` - Validate terragrunt.hcl syntax and configuration (`terragrunt hcl validate`) ### Configuration Commands - `Render(t, options)` - Render resolved terragrunt configuration as HCL - `RenderJson(t, options)` - Render resolved terragrunt configuration as JSON - `Graph(t, options)` - Output dependency graph in DOT format ### Stack Commands Work with [explicit stacks](https://terragrunt.gruntwork.io/docs/features/stacks/#explicit-stacks) (a directory with a `terragrunt.stack.hcl` file): - `StackGenerate(t, options)` - Generate stack from stack.hcl - `StackRun(t, options)` - Run command on generated stack - `StackClean(t, options)` - Remove .terragrunt-stack directory - `StackOutput(t, options, key)` - Get stack output value - `StackOutputJson(t, options, key)` - Get stack output as JSON - `StackOutputAll(t, options)` - Get all stack outputs as map - `StackOutputListAll(t, options)` - Get list of all output variable names ## Examples See the [examples directory](../../examples/) for complete working examples: - [terragrunt-example](../../examples/terragrunt-example/) - Single unit testing - [terragrunt-multi-module-example](../../examples/terragrunt-multi-module-example/) - Multi-module testing - [terragrunt-second-example](../../examples/terragrunt-second-example/) - Additional patterns ### Testing with Dependencies ```go func TestStack(t *testing.T) { t.Parallel() options := &terragrunt.Options{ TerragruntDir: "../live/prod", } // Apply respects dependency order terragrunt.ApplyAll(t, options) defer terragrunt.DestroyAll(t, options) // Verify infrastructure // ... your assertions here } ``` ### Using Custom Arguments ```go func TestWithCustomArgs(t *testing.T) { t.Parallel() options := &terragrunt.Options{ TerragruntDir: "../config", TerragruntArgs: []string{"--log-level", "error", "--no-color"}, TerraformArgs: []string{"-upgrade"}, } terragrunt.Init(t, options) } ``` ### Testing Stack Outputs ```go func TestStackOutput(t *testing.T) { t.Parallel() options := &terragrunt.Options{ TerragruntDir: "../stack", } applyOpts := &terragrunt.Options{ TerragruntDir: "../stack", TerraformArgs: []string{"apply"}, } destroyOpts := &terragrunt.Options{ TerragruntDir: "../stack", TerraformArgs: []string{"destroy"}, } terragrunt.StackRun(t, applyOpts) defer terragrunt.StackRun(t, destroyOpts) // Get specific output vpcID := terragrunt.StackOutput(t, options, "vpc_id") assert.NotEmpty(t, vpcID) // Get all outputs outputs := terragrunt.StackOutputAll(t, options) assert.Contains(t, outputs, "vpc_id") } ``` ### Checking Plan Exit Code ```go func TestInfrastructureUpToDate(t *testing.T) { t.Parallel() options := &terragrunt.Options{ TerragruntDir: "../prod", } // First apply terragrunt.ApplyAll(t, options) defer terragrunt.DestroyAll(t, options) // Plan should show no changes (exit code 0) exitCode := terragrunt.PlanAllExitCode(t, options) assert.Equal(t, 0, exitCode, "No changes expected") } ``` ### Using Run for Flexibility ```go func TestCustomCommand(t *testing.T) { t.Parallel() options := &terragrunt.Options{ TerragruntDir: "../modules", } // Run any OpenTofu/Terraform command with --all terragrunt.Run(t, options, []string{"--all"}, []string{"refresh"}) // Verify state is current output := terragrunt.Run(t, options, []string{"--all"}, []string{"show"}) assert.Contains(t, output, "expected-resource") } ``` ### Validating Stack Output Keys ```go func TestStackOutputKeys(t *testing.T) { t.Parallel() options := &terragrunt.Options{ TerragruntDir: "../stack", } applyOpts := &terragrunt.Options{ TerragruntDir: "../stack", TerraformArgs: []string{"apply"}, } destroyOpts := &terragrunt.Options{ TerragruntDir: "../stack", TerraformArgs: []string{"destroy"}, } terragrunt.StackRun(t, applyOpts) defer terragrunt.StackRun(t, destroyOpts) // Get list of all output keys keys := terragrunt.StackOutputListAll(t, options) // Verify required outputs exist assert.Contains(t, keys, "vpc_id") assert.Contains(t, keys, "subnet_ids") } ``` ### Using Filters (v0.97.0+) ```go options := &terragrunt.Options{ TerragruntDir: "../live/prod", TerragruntArgs: []string{"--filter", "{./vpc}"}, // Only apply vpc } terragrunt.ApplyAll(t, options) ``` ## Not Supported This module does **NOT** have dedicated helpers for: - `import`, `refresh`, `show`, `state`, `test` commands - `backend`, `exec`, `catalog`, `scaffold` commands - Discovery commands (`find`, `list`) - Configuration commands (`info`) For these commands, use `Run`/`RunE` or run terragrunt directly via the `shell` module. ## Compatibility Tested with Terragrunt v0.80.4+, v0.93.5+, and v0.99.x. Earlier versions may work but are not guaranteed. ### Migration from terraform Module The following functions were previously in the `terraform` module and have been moved here. The deprecated versions have been removed from the `terraform` module. | Removed (terraform module) | Replacement (terragrunt module) | |----------------------------|----------------------------------| | `TgApplyAll` / `TgApplyAllE` | `ApplyAll` / `ApplyAllE` | | `TgDestroyAll` / `TgDestroyAllE` | `DestroyAll` / `DestroyAllE` | | `TgPlanAllExitCode` / `TgPlanAllExitCodeE` | `PlanAllExitCode` / `PlanAllExitCodeE` | | `ValidateInputs` / `ValidateInputsE` | `HclValidate` / `HclValidateE` | > **Note:** `ValidateInputs` specifically checked input alignment. For equivalent behavior, pass `TerraformArgs: []string{"--inputs"}` to `HclValidate`. ## More Info - [Terragrunt Documentation](https://terragrunt.gruntwork.io/) - [Terratest Documentation](https://terratest.gruntwork.io/) ================================================ FILE: modules/terragrunt/apply.go ================================================ package terragrunt import ( "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // ApplyAll runs terragrunt run --all apply with the given options and returns stdout/stderr. Note that this method does NOT call destroy and // assumes the caller is responsible for cleaning up any resources created by running apply. func ApplyAll(t testing.TestingT, options *Options) string { out, err := ApplyAllE(t, options) require.NoError(t, err) return out } // ApplyAllE runs terragrunt run --all -- apply with the given options and returns stdout/stderr. Note that this method does NOT call destroy and // assumes the caller is responsible for cleaning up any resources created by running apply. func ApplyAllE(t testing.TestingT, options *Options) (string, error) { args := buildRunArgs([]string{"--all"}, []string{"apply", "-input=false", "-auto-approve"}) return runTerragruntCommandE(t, options, "run", args...) } // Apply runs terragrunt run apply for a single unit and returns stdout/stderr. func Apply(t testing.TestingT, options *Options) string { out, err := ApplyE(t, options) require.NoError(t, err) return out } // ApplyE runs terragrunt run -- apply for a single unit and returns stdout/stderr. func ApplyE(t testing.TestingT, options *Options) (string, error) { args := buildRunArgs([]string{}, []string{"apply", "-input=false", "-auto-approve"}) return runTerragruntCommandE(t, options, "run", args...) } // InitAndApply runs terragrunt init followed by apply for a single unit and returns the apply stdout/stderr. func InitAndApply(t testing.TestingT, options *Options) string { out, err := InitAndApplyE(t, options) require.NoError(t, err) return out } // InitAndApplyE runs terragrunt init followed by apply for a single unit and returns the apply stdout/stderr. func InitAndApplyE(t testing.TestingT, options *Options) (string, error) { if _, err := InitE(t, options); err != nil { return "", err } return ApplyE(t, options) } ================================================ FILE: modules/terragrunt/apply_test.go ================================================ package terragrunt import ( "testing" "github.com/gruntwork-io/terratest/modules/files" "github.com/stretchr/testify/require" ) func TestApplyAll(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-no-error", t.Name()) require.NoError(t, err) options := &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", } defer DestroyAll(t, options) out := ApplyAll(t, options) require.Contains(t, out, "Hello, World") } func TestApply(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-no-error", t.Name()) require.NoError(t, err) options := &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", } defer Destroy(t, options) out := Apply(t, options) require.Contains(t, out, "Hello, World") } func TestInitAndApply(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-no-error", t.Name()) require.NoError(t, err) options := &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", } defer Destroy(t, options) out := InitAndApply(t, options) require.Contains(t, out, "Hello, World") } // TestInitAndApplyE_InitFailure verifies that when init fails, apply is skipped // and the init error is propagated. func TestInitAndApplyE_InitFailure(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp( "testdata/terragrunt-stack-init-error", t.Name()) require.NoError(t, err) options := &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", } out, err := InitAndApplyE(t, options) require.Error(t, err, "InitAndApplyE should propagate init failure") require.Empty(t, out, "Output should be empty when init fails") require.Contains(t, err.Error(), "Missing expression", "Error should be from init, not apply") } ================================================ FILE: modules/terragrunt/cmd.go ================================================ package terragrunt import ( "fmt" "regexp" "strings" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/shell" "github.com/gruntwork-io/terratest/modules/testing" ) // runTerragruntStackCommandE executes terragrunt stack commands // It handles argument construction, retry logic, and error handling for all stack commands func runTerragruntStackCommandE( t testing.TestingT, opts *Options, subCommand string, additionalArgs ...string, ) (string, error) { // Build the base command arguments starting with "stack" commandArgs := []string{"stack"} if subCommand != "" { commandArgs = append(commandArgs, subCommand) } return executeTerragruntCommand(t, opts, commandArgs, additionalArgs...) } // runTerragruntCommandE is the core function that executes regular tg commands // It handles argument construction, retry logic, and error handling for non-stack commands func runTerragruntCommandE( t testing.TestingT, opts *Options, command string, additionalArgs ...string, ) (string, error) { // Build the base command arguments starting with the command commandArgs := []string{command} return executeTerragruntCommand(t, opts, commandArgs, additionalArgs...) } // executeTerragruntCommand is the common execution function for all tg commands // It handles validation, argument construction, retry logic, and error handling func executeTerragruntCommand(t testing.TestingT, opts *Options, baseCommandArgs []string, additionalArgs ...string) (string, error) { // Validate and prepare options if err := prepareOptions(opts); err != nil { return "", err } // Build args and generate command finalArgs := buildTerragruntArgs(opts, append(baseCommandArgs, additionalArgs...)...) execCommand := generateCommand(opts, finalArgs...) commandDescription := fmt.Sprintf("%s %v", opts.TerragruntBinary, finalArgs) // Execute the command with retry logic and error handling return retry.DoWithRetryableErrorsE( t, commandDescription, opts.RetryableTerraformErrors, opts.MaxRetries, opts.TimeBetweenRetries, func() (string, error) { output, err := shell.RunCommandAndGetOutputE(t, execCommand) if err != nil { return output, err } // Check for warnings that should be treated as errors if warningErr := hasWarning(opts, output); warningErr != nil { return output, warningErr } return output, nil }, ) } // hasWarning checks if the command output contains any warnings that should be treated as errors // It uses regex patterns defined in opts.WarningsAsErrors to match warning messages func hasWarning(opts *Options, commandOutput string) error { for warningPattern, errorMessage := range opts.WarningsAsErrors { // Create a regex pattern to match warnings with the specified pattern regexPattern := fmt.Sprintf("\nWarning: %s[^\n]*\n", warningPattern) compiledRegex, err := regexp.Compile(regexPattern) if err != nil { return fmt.Errorf("cannot compile regex for warning detection: %w", err) } // Find all matches of the warning pattern in the output matches := compiledRegex.FindAllString(commandOutput, -1) if len(matches) == 0 { continue } // If warnings are found, return an error with the specified message return fmt.Errorf("warning(s) were found: %s:\n%s", errorMessage, strings.Join(matches, "")) } return nil } // prepareOptions validates options and sets defaults func prepareOptions(opts *Options) error { if err := validateOptions(opts); err != nil { return err } if opts.TerragruntBinary == "" { opts.TerragruntBinary = DefaultTerragruntBinary } setTerragruntLogFormatting(opts) return nil } // buildTerragruntArgs constructs the final argument list for a terragrunt command // Arguments are ordered as: TerragruntArgs → --non-interactive → commandArgs → TerraformArgs func buildTerragruntArgs(opts *Options, commandArgs ...string) []string { var args []string args = append(args, opts.TerragruntArgs...) args = append(args, NonInteractiveFlag) args = append(args, commandArgs...) if len(opts.TerraformArgs) > 0 { args = append(args, opts.TerraformArgs...) } return args } // validateOptions validates that required options are provided func validateOptions(opts *Options) error { if opts == nil { return fmt.Errorf("options cannot be nil") } if opts.TerragruntDir == "" { return fmt.Errorf("TerragruntDir is required") } return nil } // defaultSuccessExitCode is the exit code returned when the OpenTofu/Terraform command succeeds const defaultSuccessExitCode = 0 // defaultErrorExitCode is the exit code returned when the OpenTofu/Terraform command fails const defaultErrorExitCode = 1 // getExitCodeForTerragruntCommandE runs terragrunt with the given arguments and options and returns exit code func getExitCodeForTerragruntCommandE(t testing.TestingT, additionalOptions *Options, additionalArgs ...string) (int, error) { // Validate and prepare options if err := prepareOptions(additionalOptions); err != nil { return defaultErrorExitCode, err } // Build args and generate command args := buildTerragruntArgs(additionalOptions, additionalArgs...) additionalOptions.Logger.Logf(t, "Running terragrunt with args %v", args) cmd := generateCommand(additionalOptions, args...) _, err := shell.RunCommandAndGetOutputE(t, cmd) if err == nil { return defaultSuccessExitCode, nil } exitCode, getExitCodeErr := shell.GetExitCodeForRunCommandError(err) if getExitCodeErr == nil { return exitCode, nil } return defaultErrorExitCode, getExitCodeErr } // buildRunArgs constructs the argument list for a terragrunt run command. // The -- separator disambiguates Terragrunt flags from OpenTofu/Terraform flags: // // run [tgArgs...] -- [tfArgs...] func buildRunArgs(tgArgs []string, tfArgs []string) []string { var args []string args = append(args, tgArgs...) args = append(args, "--") args = append(args, tfArgs...) return args } // generateCommand creates a shell.Command with the specified tg options and arguments // This function encapsulates the command creation logic for consistency func generateCommand(terragruntOptions *Options, commandArgs ...string) shell.Command { return shell.Command{ Command: terragruntOptions.TerragruntBinary, Args: commandArgs, WorkingDir: terragruntOptions.TerragruntDir, Env: terragruntOptions.EnvVars, Logger: terragruntOptions.Logger, Stdin: terragruntOptions.Stdin, } } ================================================ FILE: modules/terragrunt/cmd_args_test.go ================================================ package terragrunt import ( "os/exec" "path/filepath" "testing" "github.com/gruntwork-io/terratest/modules/files" "github.com/stretchr/testify/require" ) // TestTerragruntArgsIncluded verifies that TerragruntArgs are actually passed to terragrunt (issue #1609). // This test uses a real terragrunt command to ensure the args are properly included. func TestTerragruntArgsIncluded(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp( "testdata/terragrunt-stack-init", t.Name()) require.NoError(t, err) options := &Options{ TerragruntDir: filepath.Join(testFolder, "live"), TerragruntBinary: "terragrunt", // Use --log-level which should affect the output TerragruntArgs: []string{"--log-level", "error"}, } // Run init - if TerragruntArgs work, we should only see error-level logs output, err := InitE(t, options) require.NoError(t, err) // With --log-level error, we shouldn't see info-level messages // (Without the fix, --log-level would be ignored and we'd see info logs) require.NotContains(t, output, "level=info", "With --log-level error, info logs should not appear. If they do, TerragruntArgs are being ignored.") } // TestTerraformArgsIncluded verifies that TerraformArgs are passed to the terraform command (issue #1609). func TestTerraformArgsIncluded(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp( "testdata/terragrunt-stack-init", t.Name()) require.NoError(t, err) options := &Options{ TerragruntDir: filepath.Join(testFolder, "live"), TerragruntBinary: "terragrunt", // Use -backend=false to disable backend initialization // This is a distinct terraform flag we can verify TerraformArgs: []string{"-backend=false"}, } // Run init with -backend=false flag output, err := InitE(t, options) require.NoError(t, err) // With -backend=false, we should NOT see backend initialization messages // (Without the fix, -backend=false would be ignored and we'd see backend init) require.NotContains(t, output, "Initializing the backend", "With -backend=false, should not see backend initialization. If we do, TerraformArgs are being ignored.") } // TestPlanExitCodeIncludesArgs verifies that PlanAllExitCodeE properly includes TerragruntArgs and TerraformArgs (issue #1609). // This test specifically checks the exit code functions which use getExitCodeForTerragruntCommandE. func TestPlanExitCodeIncludesArgs(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-multi-plan", t.Name()) require.NoError(t, err) // First apply so we have state baseOptions := &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", } defer DestroyAll(t, baseOptions) ApplyAll(t, baseOptions) // Now run plan with exit code AND TerragruntArgs options := &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", // Use --log-level to verify TerragruntArgs are included in exit code functions TerragruntArgs: []string{"--log-level", "error"}, } // This should return exit code 0 (no changes) and should respect the log level exitCode, err := PlanAllExitCodeE(t, options) require.NoError(t, err) require.Equal(t, 0, exitCode) // The key verification: If TerragruntArgs were ignored, we'd see info-level logs in the output. // Since we can't easily capture the output from the exit code function, we rely on the fact // that if the args were ignored, the function would have failed due to unexpected log output // affecting terragrunt's behavior. The fact that it succeeded with exit code 0 demonstrates // that --log-level error was properly passed. } // TestCombinedArgsOrdering verifies that both TerragruntArgs and TerraformArgs work together // in the correct order: TerragruntArgs → --non-interactive → command → TerraformArgs func TestCombinedArgsOrdering(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp( "testdata/terragrunt-stack-init", t.Name()) require.NoError(t, err) options := &Options{ TerragruntDir: filepath.Join(testFolder, "live"), TerragruntBinary: "terragrunt", // Combine both TerragruntArgs and TerraformArgs TerragruntArgs: []string{"--log-level", "error"}, TerraformArgs: []string{"-backend=false"}, } // Run init - both args should be passed in the correct order output, err := InitE(t, options) require.NoError(t, err) // Verify TerragruntArgs effect: should not see info-level logs require.NotContains(t, output, "level=info", "With --log-level error, info logs should not appear") // Verify TerraformArgs effect: should not see backend initialization require.NotContains(t, output, "Initializing the backend", "With -backend=false, should not see backend initialization") } // TestValidateOptions verifies that validateOptions properly catches invalid configurations func TestValidateOptions(t *testing.T) { t.Parallel() // Test nil options err := validateOptions(nil) require.Error(t, err) require.Contains(t, err.Error(), "options cannot be nil") // Test missing TerragruntDir err = validateOptions(&Options{}) require.Error(t, err) require.Contains(t, err.Error(), "TerragruntDir is required") // Test valid options err = validateOptions(&Options{ TerragruntDir: "/some/path", }) require.NoError(t, err) } // TestBuildTerragruntArgs verifies the argument construction logic func TestBuildTerragruntArgs(t *testing.T) { t.Parallel() tests := []struct { name string opts *Options commandArgs []string expectedArgs []string description string }{ { name: "empty args", opts: &Options{}, commandArgs: []string{"init"}, expectedArgs: []string{"--non-interactive", "init"}, description: "Should add --non-interactive even with no custom args", }, { name: "only terragrunt args", opts: &Options{ TerragruntArgs: []string{"--log-level", "error"}, }, commandArgs: []string{"init"}, expectedArgs: []string{"--log-level", "error", "--non-interactive", "init"}, description: "Should place TerragruntArgs before --non-interactive", }, { name: "only terraform args", opts: &Options{ TerraformArgs: []string{"-upgrade"}, }, commandArgs: []string{"init"}, expectedArgs: []string{"--non-interactive", "init", "-upgrade"}, description: "Should place TerraformArgs after command", }, { name: "both arg types", opts: &Options{ TerragruntArgs: []string{"--log-level", "error", "--no-color"}, TerraformArgs: []string{"-upgrade", "-backend=false"}, }, commandArgs: []string{"init"}, expectedArgs: []string{"--log-level", "error", "--no-color", "--non-interactive", "init", "-upgrade", "-backend=false"}, description: "Should maintain correct order: TerragruntArgs → --non-interactive → command → TerraformArgs", }, { name: "stack command with args", opts: &Options{ TerragruntArgs: []string{"--log-level", "error"}, TerraformArgs: []string{"plan"}, }, commandArgs: []string{"stack", "run"}, expectedArgs: []string{"--log-level", "error", "--non-interactive", "stack", "run", "plan"}, description: "Should work with multi-part commands like 'stack run'", }, } for _, tt := range tests { tt := tt // capture range variable t.Run(tt.name, func(t *testing.T) { t.Parallel() actualArgs := buildTerragruntArgs(tt.opts, tt.commandArgs...) require.Equal(t, tt.expectedArgs, actualArgs, tt.description) }) } } // TestPrepareOptions verifies default value setting behavior func TestPrepareOptions(t *testing.T) { t.Parallel() // Test that default binary is set opts := &Options{ TerragruntDir: "/some/path", } err := prepareOptions(opts) require.NoError(t, err) require.Equal(t, DefaultTerragruntBinary, opts.TerragruntBinary) // Test that custom binary is preserved opts = &Options{ TerragruntDir: "/some/path", TerragruntBinary: "custom-terragrunt", } err = prepareOptions(opts) require.NoError(t, err) require.Equal(t, "custom-terragrunt", opts.TerragruntBinary) // Test that validation errors propagate err = prepareOptions(&Options{}) require.Error(t, err) require.Contains(t, err.Error(), "TerragruntDir is required") } // TestHasWarning verifies warning detection in command output func TestHasWarning(t *testing.T) { t.Parallel() tests := []struct { name string warningsAsErrors map[string]string output string expectError bool errorContains string }{ { name: "nil map returns no error", warningsAsErrors: nil, output: "\nWarning: something bad\n", expectError: false, }, { name: "empty map returns no error", warningsAsErrors: map[string]string{}, output: "\nWarning: something bad\n", expectError: false, }, { name: "matching warning returns error", warningsAsErrors: map[string]string{"something bad": "found a bad warning"}, output: "some output\nWarning: something bad happened\nmore output", expectError: true, errorContains: "found a bad warning", }, { name: "no match returns no error", warningsAsErrors: map[string]string{"something bad": "found a bad warning"}, output: "some output\nno warnings here\nmore output", expectError: false, }, { name: "invalid regex returns error", warningsAsErrors: map[string]string{"(?P<": "bad regex"}, output: "\nWarning: anything\n", expectError: true, errorContains: "cannot compile regex", }, { name: "first matching pattern wins", warningsAsErrors: map[string]string{ "alpha": "alpha error", "beta": "beta error", }, output: "\nWarning: alpha problem\n\nWarning: beta problem\n", expectError: true, // We can't predict map iteration order, but one of these should match }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() opts := &Options{WarningsAsErrors: tt.warningsAsErrors} err := hasWarning(opts, tt.output) if tt.expectError { require.Error(t, err) if tt.errorContains != "" { require.Contains(t, err.Error(), tt.errorContains) } } else { require.NoError(t, err) } }) } } // TestEnvVarsPropagation verifies environment variables are passed through func TestEnvVarsPropagation(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp( "testdata/terragrunt-stack-init", t.Name()) require.NoError(t, err) // Detect which IaC binary is available (terraform or tofu) tfBinary := "terraform" if _, err := exec.LookPath("terraform"); err != nil { // terraform not found, try tofu if _, err := exec.LookPath("tofu"); err != nil { t.Skip("Neither terraform nor tofu found in PATH") } tfBinary = "tofu" } options := &Options{ TerragruntDir: filepath.Join(testFolder, "live"), EnvVars: map[string]string{ "TERRAGRUNT_TFPATH": tfBinary, // Use whichever binary is available "TG_LOG_LEVEL": "error", // Alternative to --log-level flag }, } // Run init - should succeed with env vars set output, err := InitE(t, options) require.NoError(t, err) require.NotEmpty(t, output) // With TG_LOG_LEVEL=error, should not see info logs require.NotContains(t, output, "level=info") } ================================================ FILE: modules/terragrunt/destroy.go ================================================ package terragrunt import ( "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // DestroyAll runs terragrunt run --all destroy with the given options and returns stdout. func DestroyAll(t testing.TestingT, options *Options) string { out, err := DestroyAllE(t, options) require.NoError(t, err) return out } // DestroyAllE runs terragrunt run --all -- destroy with the given options and returns stdout. func DestroyAllE(t testing.TestingT, options *Options) (string, error) { args := buildRunArgs([]string{"--all"}, []string{"destroy", "-auto-approve", "-input=false"}) return runTerragruntCommandE(t, options, "run", args...) } // Destroy runs terragrunt run destroy for a single unit and returns stdout/stderr. func Destroy(t testing.TestingT, options *Options) string { out, err := DestroyE(t, options) require.NoError(t, err) return out } // DestroyE runs terragrunt run -- destroy for a single unit and returns stdout/stderr. func DestroyE(t testing.TestingT, options *Options) (string, error) { args := buildRunArgs([]string{}, []string{"destroy", "-auto-approve", "-input=false"}) return runTerragruntCommandE(t, options, "run", args...) } ================================================ FILE: modules/terragrunt/destroy_test.go ================================================ package terragrunt import ( "testing" "github.com/gruntwork-io/terratest/modules/files" "github.com/stretchr/testify/require" ) func TestDestroyAll(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-no-error", t.Name()) require.NoError(t, err) options := &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", } ApplyAll(t, options) destroyOut := DestroyAll(t, options) require.NotEmpty(t, destroyOut) } func TestDestroy(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-no-error", t.Name()) require.NoError(t, err) options := &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", } Apply(t, options) destroyOut := Destroy(t, options) require.NotEmpty(t, destroyOut) } // TestDestroyAllWithArgs verifies DestroyAll respects TerragruntArgs func TestDestroyAllWithArgs(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-multi-plan", t.Name()) require.NoError(t, err) // Apply first ApplyAll(t, &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", }) // Destroy with TerragruntArgs options := &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", TerragruntArgs: []string{"--log-level", "error"}, } destroyOut := DestroyAll(t, options) require.NotEmpty(t, destroyOut) // With --log-level error, should not see info logs require.NotContains(t, destroyOut, "level=info") } ================================================ FILE: modules/terragrunt/format.go ================================================ package terragrunt import ( "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // FormatAll runs terragrunt hcl format to format all terragrunt.hcl files and returns stdout/stderr func FormatAll(t testing.TestingT, options *Options) string { out, err := FormatAllE(t, options) require.NoError(t, err) return out } // FormatAllE runs terragrunt hcl format to format all terragrunt.hcl files and returns stdout/stderr func FormatAllE(t testing.TestingT, options *Options) (string, error) { return runTerragruntCommandE(t, options, "hcl", "format") } ================================================ FILE: modules/terragrunt/format_test.go ================================================ package terragrunt import ( "os" "path/filepath" "testing" "github.com/gruntwork-io/terratest/modules/files" "github.com/stretchr/testify/require" ) func TestFormatAll(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-multi-plan", t.Name()) require.NoError(t, err) // Create an unformatted terragrunt.hcl file in foo directory unformattedContent := `terraform { source = "git::git@github.com:foo/modules.git//app" } inputs={ foo="bar" }` tgFile := filepath.Join(testFolder, "foo", "terragrunt.hcl") err = os.WriteFile(tgFile, []byte(unformattedContent), 0644) require.NoError(t, err) options := &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", } // Run format command FormatAll(t, options) // Read the formatted file to verify it was actually formatted formattedContent, err := os.ReadFile(tgFile) require.NoError(t, err) // Verify the file was formatted (should have proper spacing now) require.Contains(t, string(formattedContent), `source = "git::git@github.com:foo/modules.git//app"`, "Expected file to be formatted with proper spacing") require.Contains(t, string(formattedContent), `inputs = {`, "Expected inputs block to be formatted with spaces around =") require.Contains(t, string(formattedContent), `foo = "bar"`, "Expected key-value pairs to be formatted with spaces around =") } func TestFormatAllE(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-multi-plan", t.Name()) require.NoError(t, err) // Create an unformatted file to ensure the command actually does something unformattedContent := `terraform { source = "git::git@github.com:foo/modules.git//app" } inputs={ foo="bar" }` tgFile := filepath.Join(testFolder, "foo", "terragrunt.hcl") err = os.WriteFile(tgFile, []byte(unformattedContent), 0644) require.NoError(t, err) options := &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", } // Run format command - should succeed _, err = FormatAllE(t, options) require.NoError(t, err) // Verify the file was actually formatted by reading it formattedContent, err := os.ReadFile(tgFile) require.NoError(t, err) require.Contains(t, string(formattedContent), `inputs = {`, "File should be formatted with proper spacing") } ================================================ FILE: modules/terragrunt/graph.go ================================================ package terragrunt import ( "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // Graph runs terragrunt dag graph and returns the DOT-format dependency graph. // This is useful for verifying dependency relationships between terragrunt units. func Graph(t testing.TestingT, options *Options) string { out, err := GraphE(t, options) require.NoError(t, err) return out } // GraphE runs terragrunt dag graph and returns the DOT-format dependency graph. // This is useful for verifying dependency relationships between terragrunt units. // Log lines are stripped from the output so the result is clean DOT format. func GraphE(t testing.TestingT, options *Options) (string, error) { rawOutput, err := runTerragruntCommandE(t, options, "dag", "graph") if err != nil { return "", err } return filterLogLines(rawOutput), nil } ================================================ FILE: modules/terragrunt/graph_test.go ================================================ package terragrunt import ( "os" "path/filepath" "testing" "github.com/gruntwork-io/terratest/modules/files" "github.com/stretchr/testify/require" ) func TestGraph(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-multi-plan", t.Name()) require.NoError(t, err) output := Graph(t, &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", }) require.Contains(t, output, "digraph") require.Contains(t, output, `"foo"`) require.Contains(t, output, `"bar"`) } func TestGraphE_InvalidConfig(t *testing.T) { t.Parallel() tmpDir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "terragrunt.hcl"), []byte("not_valid!!!"), 0644)) output, err := GraphE(t, &Options{TerragruntDir: tmpDir}) require.NoError(t, err) require.Contains(t, output, "digraph") // Invalid config produces a minimal graph with just the current unit require.NotContains(t, output, "->") } ================================================ FILE: modules/terragrunt/hcl_validate.go ================================================ package terragrunt import ( "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // HclValidate runs terragrunt hcl validate to check terragrunt.hcl syntax. // This validates Terragrunt HCL configuration and can check for mis-aligned inputs. // Use TerraformArgs to pass additional flags like "--inputs" or "--strict". // // Examples: // // HclValidate(t, options) // Basic syntax check // HclValidate(t, &Options{TerraformArgs: []string{"--inputs"}}) // Check input alignment func HclValidate(t testing.TestingT, options *Options) string { out, err := HclValidateE(t, options) require.NoError(t, err) return out } // HclValidateE runs terragrunt hcl validate to check terragrunt.hcl syntax. // This validates Terragrunt HCL configuration and can check for mis-aligned inputs. // Use TerraformArgs to pass additional flags like "--inputs" or "--strict". func HclValidateE(t testing.TestingT, options *Options) (string, error) { return runTerragruntCommandE(t, options, "hcl", "validate") } ================================================ FILE: modules/terragrunt/hcl_validate_test.go ================================================ package terragrunt import ( "os" "path/filepath" "testing" "github.com/gruntwork-io/terratest/modules/files" "github.com/stretchr/testify/require" ) func TestHclValidate(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-multi-plan", t.Name()) require.NoError(t, err) HclValidate(t, &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", }) } func TestHclValidateE_InvalidConfig(t *testing.T) { t.Parallel() tmpDir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "terragrunt.hcl"), []byte("not_valid!!!"), 0644)) _, err := HclValidateE(t, &Options{TerragruntDir: tmpDir}) require.Error(t, err) } ================================================ FILE: modules/terragrunt/init.go ================================================ package terragrunt import ( "github.com/gruntwork-io/terratest/internal/lib/formatting" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // Init calls terragrunt run init and return stdout/stderr func Init(t testing.TestingT, options *Options) string { out, err := InitE(t, options) require.NoError(t, err) return out } // InitE calls terragrunt run -- init and return stdout/stderr func InitE(t testing.TestingT, options *Options) (string, error) { args := buildRunArgs([]string{}, append([]string{"init"}, initArgs(options)...)) return runTerragruntCommandE(t, options, "run", args...) } // initArgs builds the argument list for terragrunt init command. // This function handles complex configuration that requires special formatting. func initArgs(options *Options) []string { var args []string // Add complex configuration that requires special formatting // These are OpenTofu/Terraform-specific arguments that need special formatting args = append(args, formatting.FormatBackendConfigAsArgs(options.BackendConfig)...) args = append(args, formatting.FormatPluginDirAsArgs(options.PluginDir)...) return args } ================================================ FILE: modules/terragrunt/init_test.go ================================================ package terragrunt import ( "path/filepath" "testing" "github.com/gruntwork-io/terratest/modules/files" "github.com/stretchr/testify/require" ) func TestInit(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp( "testdata/terragrunt-no-error", t.Name()) require.NoError(t, err) out := Init(t, &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", TerraformArgs: []string{"-upgrade=true"}, }) // Check for common success indicator (works with both Terraform and OpenTofu) require.Contains(t, out, "successfully initialized") } func TestInitE(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp( "testdata/terragrunt-no-error", t.Name()) require.NoError(t, err) out, err := InitE(t, &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", TerraformArgs: []string{"-upgrade=true"}, // Common terraform init flag }) require.NoError(t, err) // Check for common success indicator (works with both Terraform and OpenTofu) require.Contains(t, out, "successfully initialized") } func TestInitWithInvalidConfig(t *testing.T) { t.Parallel() // Test error handling when tg.hcl has invalid HCL syntax testFolder, err := files.CopyTerraformFolderToTemp( "testdata/terragrunt-stack-init-error", t.Name()) require.NoError(t, err) // This should fail due to invalid HCL syntax in tg.hcl _, err = InitE(t, &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", TerraformArgs: []string{"-upgrade=true"}, // Common terraform init flag }) require.Error(t, err) // The error should contain information about the HCL parsing error require.Contains(t, err.Error(), "Missing expression") } // TestInitWithBothArgTypes verifies init works with both TerragruntArgs and TerraformArgs func TestInitWithBothArgTypes(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp( "testdata/terragrunt-stack-init", t.Name()) require.NoError(t, err) options := &Options{ TerragruntDir: filepath.Join(testFolder, "live"), TerragruntBinary: "terragrunt", TerragruntArgs: []string{"--log-level", "error"}, TerraformArgs: []string{"-upgrade"}, } output, err := InitE(t, options) require.NoError(t, err) // Verify TerragruntArgs: no info logs require.NotContains(t, output, "level=info") // Verify TerraformArgs: -upgrade was passed (shows in terraform output) require.Contains(t, output, "Initializing") } ================================================ FILE: modules/terragrunt/json_helpers.go ================================================ package terragrunt import ( "encoding/json" "regexp" "strings" ) // removeLogLines removes terragrunt log lines and metadata from output func removeLogLines(rawOutput string) string { lines := strings.Split(rawOutput, "\n") var result []string for _, line := range lines { trimmed := strings.TrimSpace(line) // Skip empty lines, terragrunt log lines, and metadata lines if trimmed == "" { continue } if isLogLine(trimmed) || isMetadataLine(trimmed) { continue } result = append(result, trimmed) } return strings.Join(result, "\n") } // isMetadataLine checks if a line is terragrunt metadata (e.g., "Group 1", "- Unit ./foo") func isMetadataLine(line string) bool { return tgMetadataPattern.MatchString(line) } // newLogLinePattern matches the new terragrunt log format: "HH:MM:SS.mmm LEVEL ..." // Example: "20:41:53.564 INFO Generating unit father..." var newLogLinePattern = regexp.MustCompile(`^\d{2}:\d{2}:\d{2}\.\d{3}\s+(INFO|WARN|ERROR|DEBUG|TRACE|STDOUT|STDERR)\s+`) // tgMetadataPattern matches terragrunt metadata lines like "Group 1" or "- Unit ./foo" var tgMetadataPattern = regexp.MustCompile(`^(Group \d+|- Unit )`) // isLogLine checks if a line is a terragrunt log line func isLogLine(line string) bool { // Old format: time=... level=... msg=... if strings.HasPrefix(line, "time=") && strings.Contains(line, "level=") && strings.Contains(line, "msg=") { return true } // New format (terragrunt 0.88+): HH:MM:SS.mmm LEVEL message return newLogLinePattern.MatchString(line) } // extractJsonContent extracts only JSON objects from terragrunt output, // filtering out log lines and other non-JSON content like "Group 1" or "- Unit ./foo". // Uses json.Decoder to correctly handle braces inside JSON string values. func extractJsonContent(rawOutput string) (string, error) { lines := strings.Split(rawOutput, "\n") var filtered []string for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed == "" || isLogLine(trimmed) || isMetadataLine(trimmed) { continue } filtered = append(filtered, trimmed) } remaining := strings.Join(filtered, "\n") if remaining == "" { return "", nil } dec := json.NewDecoder(strings.NewReader(remaining)) var results []string for dec.More() { var raw json.RawMessage if err := dec.Decode(&raw); err != nil { return "", err } results = append(results, string(raw)) } return strings.Join(results, "\n"), nil } // cleanTerragruntOutput extracts the actual output value from terragrunt stack's verbose output // // Example input (raw tg output): // // time=2023-07-11T10:30:45Z level=info prefix=foo tf-path=terraform msg=Initializing... // time=2023-07-11T10:30:46Z level=info prefix=foo tf-path=terraform msg=Running command... // "my-bucket-name" // // Example output (cleaned): // // my-bucket-name // // For JSON values, it preserves the structure: // Input: // // time=2023-07-11T10:30:45Z level=info prefix=foo tf-path=terraform msg=Running... // {"vpc_id": "vpc-12345", "subnet_ids": ["subnet-1", "subnet-2"]} // // Output: // // {"vpc_id": "vpc-12345", "subnet_ids": ["subnet-1", "subnet-2"]} func cleanTerragruntOutput(rawOutput string) (string, error) { // Remove terragrunt log lines and metadata finalOutput := removeLogLines(rawOutput) if finalOutput == "" { return "", nil } // Check if it's JSON (starts with { or [) if strings.HasPrefix(finalOutput, "{") || strings.HasPrefix(finalOutput, "[") { // For JSON output, return as-is return finalOutput, nil } // For simple values, remove surrounding quotes if present // Use TrimPrefix/TrimSuffix to remove exactly one quote from each end if strings.HasPrefix(finalOutput, "\"") && strings.HasSuffix(finalOutput, "\"") { finalOutput = strings.TrimPrefix(finalOutput, "\"") finalOutput = strings.TrimSuffix(finalOutput, "\"") } return finalOutput, nil } // cleanTerragruntJson cleans the JSON output from a terragrunt stack command that // returns a single combined JSON object. Returns an error if the output contains // multiple JSON objects (use extractJsonContent directly for multi-object output). // // Example input (raw tg JSON output): // // time=2023-07-11T10:30:45Z level=info prefix=mother tf-path=terraform msg=Initializing... // time=2023-07-11T10:30:46Z level=info prefix=mother tf-path=terraform msg=Running command... // {"mother":{"output":"./test.txt"},"father":{"output":"./test.txt"}} // // Example output (cleaned and formatted): // // { // "mother": { // "output": "./test.txt" // }, // "father": { // "output": "./test.txt" // } // } func cleanTerragruntJson(input string) (string, error) { // Extract only JSON content, filtering out log lines and other non-JSON content cleaned, err := extractJsonContent(input) if err != nil { return "", err } // Parse JSON var jsonObj interface{} if err := json.Unmarshal([]byte(cleaned), &jsonObj); err != nil { return "", err } // Format JSON output with indentation normalized, err := json.MarshalIndent(jsonObj, "", " ") if err != nil { return "", err } return string(normalized), nil } ================================================ FILE: modules/terragrunt/json_helpers_test.go ================================================ package terragrunt import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestIsLogLine(t *testing.T) { t.Parallel() // Old format (time=... level=... msg=...) assert.True(t, isLogLine("time=2026 level=info prefix=foo tf-path=terraform msg=Running")) // New format (HH:MM:SS.mmm LEVEL ...) assert.True(t, isLogLine("20:41:53.564 INFO Generating unit father")) assert.True(t, isLogLine("20:41:53.564 WARN Something is off")) assert.True(t, isLogLine("20:41:53.564 DEBUG Detailed info")) assert.True(t, isLogLine("20:41:53.564 STDOUT [.terragrunt-stack/mother] terraform: output")) assert.True(t, isLogLine("20:41:53.564 STDERR [foo] error message")) assert.True(t, isLogLine("20:41:53.564 ERROR Something went wrong")) assert.True(t, isLogLine("20:41:53.564 TRACE Very detailed")) // Not log lines assert.False(t, isLogLine(`{"key": "value"}`)) assert.False(t, isLogLine(`{"message": "error msg=bad"}`)) assert.False(t, isLogLine("Group 1")) assert.False(t, isLogLine("- Unit ./foo")) } func TestIsMetadataLine(t *testing.T) { t.Parallel() // Metadata lines assert.True(t, isMetadataLine("Group 1")) assert.True(t, isMetadataLine("Group 42")) assert.True(t, isMetadataLine("- Unit ./foo")) assert.True(t, isMetadataLine("- Unit ./.terragrunt-stack/mother")) // Not metadata lines assert.False(t, isMetadataLine(`{"key": "value"}`)) assert.False(t, isMetadataLine("mother = { output = \"./test.txt\" }")) assert.False(t, isMetadataLine("20:41:53.564 INFO Running")) } func TestRemoveLogLines(t *testing.T) { t.Parallel() // Removes old format log lines, keeps JSON result := removeLogLines("time=2026 level=info msg=Start\n{\"key\": \"value\"}") assert.Equal(t, `{"key": "value"}`, result) // Removes new format log lines result = removeLogLines("20:41:53.564 INFO Running\n{\"key\": \"value\"}") assert.Equal(t, `{"key": "value"}`, result) // Removes metadata lines (Group, Unit) result = removeLogLines("Group 1\n- Unit ./foo\n{\"key\": \"value\"}") assert.Equal(t, `{"key": "value"}`, result) // Preserves JSON with msg= in value result = removeLogLines("time=2026 level=info msg=Start\n{\"message\": \"error msg=bad\"}") assert.Contains(t, result, "error msg=bad") } func TestExtractJsonContent(t *testing.T) { t.Parallel() // Extracts JSON with old format, filters non-JSON input := "time=2026 level=info msg=Running\nGroup 1\n- Unit ./foo\n{\"a\": 1}\n{\"b\": 2}" result, err := extractJsonContent(input) require.NoError(t, err) assert.Contains(t, result, `"a": 1`) assert.Contains(t, result, `"b": 2`) assert.NotContains(t, result, "Group") assert.NotContains(t, result, "Unit") // Extracts JSON with new format logs input = "20:41:53.564 INFO Running\n20:41:53.564 STDOUT terraform: done\n{\"key\": \"value\"}" result, err = extractJsonContent(input) require.NoError(t, err) assert.Equal(t, `{"key": "value"}`, result) // Handles nested JSON input = "time=2026 level=info msg=Running\n{\n \"outer\": {\n \"inner\": true\n }\n}" result, err = extractJsonContent(input) require.NoError(t, err) assert.Contains(t, result, `"inner": true`) // Empty when only logs/metadata input = "20:41:53.564 INFO Running\nGroup 1\n- Unit ./foo" result, err = extractJsonContent(input) require.NoError(t, err) assert.Equal(t, "", result) } func TestCleanTerragruntOutput(t *testing.T) { t.Parallel() // Simple quoted string value input := "time=2026 level=info msg=Running\n\"my-bucket-name\"" result, err := cleanTerragruntOutput(input) require.NoError(t, err) assert.Equal(t, "my-bucket-name", result) // JSON output preserved input = "20:41:53.564 INFO Running\n{\"key\": \"value\"}" result, err = cleanTerragruntOutput(input) require.NoError(t, err) assert.Equal(t, `{"key": "value"}`, result) // Filters metadata lines input = "Group 1\n- Unit ./foo\n\"result\"" result, err = cleanTerragruntOutput(input) require.NoError(t, err) assert.Equal(t, "result", result) // Empty input returns empty input = "20:41:53.564 INFO Running" result, err = cleanTerragruntOutput(input) require.NoError(t, err) assert.Equal(t, "", result) } func TestCleanTerragruntJson(t *testing.T) { t.Parallel() // Valid single JSON with old format logs input := "time=2026 level=info msg=Running\n{\"mother\":{\"output\":\"test\"}}" result, err := cleanTerragruntJson(input) require.NoError(t, err) assert.Contains(t, result, "mother") // Valid single JSON with new format logs (terragrunt 0.88+) input = "{\"a\": 1}\n20:41:53.564 INFO Generating unit\n20:41:53.564 STDOUT terraform: done" result, err = cleanTerragruntJson(input) require.NoError(t, err) assert.Contains(t, result, `"a": 1`) // Multiple JSON objects should error _, err = cleanTerragruntJson("{\"a\": 1}\n{\"b\": 2}") require.Error(t, err) // Empty/no-JSON input should error (documents expected behavior) _, err = cleanTerragruntJson("20:41:53.564 INFO Running\nGroup 1\n- Unit ./foo") require.Error(t, err, "cleanTerragruntJson should error when input contains no JSON") } func TestCleanTerragruntOutputEdgeCases(t *testing.T) { t.Parallel() // Empty string value (terraform outputs "" for empty strings) input := "time=2026 level=info msg=Running\n\"\"" result, err := cleanTerragruntOutput(input) require.NoError(t, err) assert.Equal(t, "", result, "Empty quoted string should become empty string") // Value with quotes inside (terraform outputs "\"quoted\"") input = "20:41:53.564 INFO Running\n\"\\\"quoted\\\"\"" result, err = cleanTerragruntOutput(input) require.NoError(t, err) assert.Equal(t, "\\\"quoted\\\"", result, "Escaped quotes should be preserved") // Multiple lines of non-JSON content after filtering logs input = "20:41:53.564 INFO Running\nline1\nline2" result, err = cleanTerragruntOutput(input) require.NoError(t, err) assert.Equal(t, "line1\nline2", result) // Mismatched quotes: opening quote without closing quote should be left as-is input = "20:41:53.564 INFO Running\n\"no-closing-quote" result, err = cleanTerragruntOutput(input) require.NoError(t, err) assert.Equal(t, "\"no-closing-quote", result, "Mismatched quotes should be preserved verbatim") // Closing quote without opening quote should be left as-is input = "20:41:53.564 INFO Running\nno-opening-quote\"" result, err = cleanTerragruntOutput(input) require.NoError(t, err) assert.Equal(t, "no-opening-quote\"", result, "Mismatched quotes should be preserved verbatim") // Array JSON output preserved input = "20:41:53.564 INFO Running\n[\"a\", \"b\"]" result, err = cleanTerragruntOutput(input) require.NoError(t, err) assert.Equal(t, `["a", "b"]`, result, "JSON array should be preserved") } func TestExtractJsonContentMalformedJson(t *testing.T) { t.Parallel() // Valid JSON followed by malformed JSON: returns error input := "{\"valid\": true}\n{broken json" _, err := extractJsonContent(input) assert.Error(t, err) // Malformed JSON only: returns error input = "{not valid json at all" _, err = extractJsonContent(input) assert.Error(t, err) // Valid JSON with log lines before and after (realistic scenario) input = "time=2026 level=info msg=Before\n{\"key\": 1}\ntime=2026 level=info msg=After" result, err := extractJsonContent(input) require.NoError(t, err) assert.Contains(t, result, `"key"`) assert.NotContains(t, result, "Before") assert.NotContains(t, result, "After") // Whitespace-only after filtering logs input = "20:41:53.564 INFO Running\n \n " result, err = extractJsonContent(input) require.NoError(t, err) assert.Equal(t, "", result) // Two valid JSON objects separated by log lines input = "20:41:53.564 INFO Start\n{\"a\": 1}\n20:41:53.564 INFO Middle\n{\"b\": 2}\n20:41:53.564 INFO End" result, err = extractJsonContent(input) require.NoError(t, err) assert.Contains(t, result, `"a"`) assert.Contains(t, result, `"b"`) } ================================================ FILE: modules/terragrunt/options.go ================================================ package terragrunt import ( "io" "os" "time" "github.com/gruntwork-io/terratest/modules/logger" ) // Key concepts: // - Options: Configure HOW the test framework executes tg (directories, retry logic, logging) // - TerragruntArgs: Global terragrunt flags (e.g., --log-level, --no-color) // - TerraformArgs: Command-specific OpenTofu/Terraform args (e.g., -upgrade for init, or the command itself for stack run) // - Use Options.TerragruntDir to specify WHERE to run tg // // Example: // // // For init with OpenTofu/Terraform flags // InitE(t, &Options{ // TerragruntDir: "/path/to/config", // TerragruntArgs: []string{"--log-level", "info"}, // TerraformArgs: []string{"-upgrade=true"}, // }) // // // For run-all with global flags // ApplyAllE(t, &Options{ // TerragruntDir: "/path/to/config", // TerragruntArgs: []string{"--no-color"}, // }) // // Constants for test framework configuration and environment variables const ( DefaultTerragruntBinary = "terragrunt" NonInteractiveFlag = "--non-interactive" TerragruntLogFormatKey = "TG_LOG_FORMAT" TerragruntLogCustomKey = "TG_LOG_CUSTOM_FORMAT" TerragruntNoTipsKey = "TG_NO_TIPS" DefaultLogFormat = "key-value" DefaultLogCustomFormat = "%msg(color=disable)" ) // Options represent the configuration options for tg test execution. // // This struct is divided into two clear categories: // // 1. TEST FRAMEWORK CONFIGURATION: // - Controls HOW the test framework executes tg // - Includes: binary paths, directories, retry logic, logging, environment // - These are NOT passed as command-line arguments to tg // // 2. TG COMMAND ARGUMENTS: // - TerragruntArgs: Global terragrunt flags (placed BEFORE the command) // - TerraformArgs: Command-specific flags (placed AFTER the command) // - These ARE passed directly to tg in the appropriate positions // // This separation eliminates confusion about which settings control the test // framework vs which become tg command-line arguments. type Options struct { // Test framework configuration (NOT passed to tg command line) TerragruntBinary string // The tg binary to use (should be "terragrunt") TerragruntDir string // The directory containing the tg configuration EnvVars map[string]string // Environment variables for command execution Logger *logger.Logger // Logger for command output // Test framework retry and error handling (NOT passed to tg command line) MaxRetries int // Maximum number of retries TimeBetweenRetries time.Duration // Time between retries RetryableTerraformErrors map[string]string // Retryable error patterns WarningsAsErrors map[string]string // Warnings to treat as errors // Complex configuration that requires special formatting (NOT raw command-line args) BackendConfig map[string]interface{} // Backend configuration (formatted specially) PluginDir string // Plugin directory (formatted specially) // Global terragrunt command-line flags (placed BEFORE the command) // Example: []string{"--log-level", "info", "--no-color"} TerragruntArgs []string // Command-specific OpenTofu/Terraform flags (placed AFTER the command) // Example: []string{"-upgrade=true"} for init, or []string{"plan"} for stack run TerraformArgs []string // Optional stdin to pass to OpenTofu/Terraform commands Stdin io.Reader } // setTerragruntLogFormatting sets default log formatting and other env vars for tg // if they are not already set in options.EnvVars or OS environment vars func setTerragruntLogFormatting(options *Options) { if options.EnvVars == nil { options.EnvVars = make(map[string]string) } _, inOpts := options.EnvVars[TerragruntLogFormatKey] if !inOpts { _, inEnv := os.LookupEnv(TerragruntLogFormatKey) if !inEnv { // key-value format for tg logs to avoid colors and have plain form // https://terragrunt.gruntwork.io/docs/reference/cli-options/#terragrunt-log-format options.EnvVars[TerragruntLogFormatKey] = DefaultLogFormat } } _, inOpts = options.EnvVars[TerragruntLogCustomKey] if !inOpts { _, inEnv := os.LookupEnv(TerragruntLogCustomKey) if !inEnv { options.EnvVars[TerragruntLogCustomKey] = DefaultLogCustomFormat } } // Suppress tips for cleaner test output (v1.0.0+, ignored by older versions) _, inOpts = options.EnvVars[TerragruntNoTipsKey] if !inOpts { _, inEnv := os.LookupEnv(TerragruntNoTipsKey) if !inEnv { options.EnvVars[TerragruntNoTipsKey] = "true" } } } ================================================ FILE: modules/terragrunt/output.go ================================================ package terragrunt import ( "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // TODO: Add OutputAll/OutputAllE when terragrunt supports combined JSON output format. // Currently, `output --all -json` returns separate JSON objects per module without module prefixes, // making it impossible to reliably map outputs to their source modules. // OutputAllJson runs terragrunt run --all output -json and returns the raw JSON string. // Note: Current terragrunt versions return separate JSON objects per module, not a combined object. func OutputAllJson(t testing.TestingT, options *Options) string { out, err := OutputAllJsonE(t, options) require.NoError(t, err) return out } // OutputAllJsonE runs terragrunt run --all output -json and returns the raw JSON string. // Note: Current terragrunt versions return separate JSON objects per module, not a combined object. func OutputAllJsonE(t testing.TestingT, options *Options) (string, error) { optsCopy := *options optsCopy.TerragruntArgs = append([]string{"--no-color"}, options.TerragruntArgs...) args := buildRunArgs([]string{"--all"}, []string{"output", "-json"}) rawOutput, err := runTerragruntCommandE(t, &optsCopy, "run", args...) if err != nil { return "", err } // Extract only JSON content from output, filtering log lines and other terragrunt messages return extractJsonContent(rawOutput) } // OutputJson runs terragrunt run output -json for a single unit and returns clean JSON. // If key is non-empty, returns the JSON value for that specific output. // If key is empty, returns all outputs as JSON. func OutputJson(t testing.TestingT, options *Options, key string) string { out, err := OutputJsonE(t, options, key) require.NoError(t, err) return out } // OutputJsonE runs terragrunt run output -json for a single unit and returns clean JSON. // If key is non-empty, returns the JSON value for that specific output. // If key is empty, returns all outputs as JSON. func OutputJsonE(t testing.TestingT, options *Options, key string) (string, error) { optsCopy := *options optsCopy.TerragruntArgs = append([]string{"--no-color"}, options.TerragruntArgs...) tfArgs := []string{"-json"} if key != "" { tfArgs = append(tfArgs, key) } args := buildRunArgs([]string{}, append([]string{"output"}, tfArgs...)) rawOutput, err := runTerragruntCommandE(t, &optsCopy, "run", args...) if err != nil { return "", err } return cleanTerragruntJson(rawOutput) } ================================================ FILE: modules/terragrunt/output_test.go ================================================ package terragrunt import ( "testing" "github.com/gruntwork-io/terratest/modules/files" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestOutputJson(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-output", t.Name()) require.NoError(t, err) options := &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", } Apply(t, options) defer Destroy(t, options) json := OutputJson(t, options, "str") assert.Contains(t, json, "str") } func TestOutputJsonAllKeys(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-output", t.Name()) require.NoError(t, err) options := &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", } Apply(t, options) defer Destroy(t, options) json := OutputJson(t, options, "") assert.Contains(t, json, "str") assert.Contains(t, json, "list") assert.Contains(t, json, "map") } func TestOutputJsonE_Error(t *testing.T) { t.Parallel() options := &Options{ TerragruntDir: t.TempDir(), TerragruntBinary: "terragrunt", } _, err := OutputJsonE(t, options, "") require.Error(t, err) } ================================================ FILE: modules/terragrunt/plan.go ================================================ package terragrunt import ( "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // PlanAllExitCode runs terragrunt run --all plan with the given options and returns the detailed exit code. // This will fail the test if there is an error in the command. func PlanAllExitCode(t testing.TestingT, options *Options) int { exitCode, err := PlanAllExitCodeE(t, options) require.NoError(t, err) return exitCode } // PlanAllExitCodeE runs terragrunt run --all -- plan with the given options and returns the detailed exit code. func PlanAllExitCodeE(t testing.TestingT, options *Options) (int, error) { args := buildRunArgs([]string{"--all"}, []string{"plan", "-input=false", "-lock=true", "-detailed-exitcode"}) return getExitCodeForTerragruntCommandE(t, options, append([]string{"run"}, args...)...) } // Plan runs terragrunt run plan for a single unit and returns stdout/stderr. func Plan(t testing.TestingT, options *Options) string { out, err := PlanE(t, options) require.NoError(t, err) return out } // PlanE runs terragrunt run -- plan for a single unit and returns stdout/stderr. // Uses -lock=false since plan is a read-only operation that does not need state locking. func PlanE(t testing.TestingT, options *Options) (string, error) { args := buildRunArgs([]string{}, []string{"plan", "-input=false", "-lock=false"}) return runTerragruntCommandE(t, options, "run", args...) } // PlanExitCode runs terragrunt run plan for a single unit and returns the detailed exit code. // This will fail the test if there is an error in the command. func PlanExitCode(t testing.TestingT, options *Options) int { exitCode, err := PlanExitCodeE(t, options) require.NoError(t, err) return exitCode } // PlanExitCodeE runs terragrunt run -- plan for a single unit and returns the detailed exit code. func PlanExitCodeE(t testing.TestingT, options *Options) (int, error) { args := buildRunArgs([]string{}, []string{"plan", "-input=false", "-lock=true", "-detailed-exitcode"}) return getExitCodeForTerragruntCommandE(t, options, append([]string{"run"}, args...)...) } // InitAndPlan runs terragrunt init followed by plan for a single unit and returns the plan stdout/stderr. func InitAndPlan(t testing.TestingT, options *Options) string { out, err := InitAndPlanE(t, options) require.NoError(t, err) return out } // InitAndPlanE runs terragrunt init followed by plan for a single unit and returns the plan stdout/stderr. func InitAndPlanE(t testing.TestingT, options *Options) (string, error) { if _, err := InitE(t, options); err != nil { return "", err } return PlanE(t, options) } ================================================ FILE: modules/terragrunt/plan_test.go ================================================ package terragrunt import ( "testing" "github.com/gruntwork-io/terratest/modules/files" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestPlanAllExitCode(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-multi-plan", t.Name()) require.NoError(t, err) options := &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", } defer DestroyAll(t, options) ApplyAll(t, options) exitCode := PlanAllExitCode(t, options) require.Equal(t, 0, exitCode) } func TestPlan(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-no-error", t.Name()) require.NoError(t, err) options := &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", } out := Plan(t, options) require.NotEmpty(t, out) } func TestPlanExitCode(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-no-error", t.Name()) require.NoError(t, err) options := &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", } // Apply first so plan shows no changes (exit code 0) Apply(t, options) defer Destroy(t, options) exitCode := PlanExitCode(t, options) assert.Equal(t, 0, exitCode) } func TestInitAndPlan(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-no-error", t.Name()) require.NoError(t, err) options := &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", } out := InitAndPlan(t, options) require.NotEmpty(t, out) } func TestPlanAllWithError(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-with-plan-error", t.Name()) require.NoError(t, err) options := &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", } getExitCode, errExitCode := PlanAllExitCodeE(t, options) // GetExitCodeForRunCommandError was unable to determine the exit code correctly require.NoError(t, errExitCode) require.Equal(t, 1, getExitCode) } func TestAssertPlanAllExitCodeNoError(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-multi-plan", t.Name()) require.NoError(t, err) options := &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", } defer DestroyAll(t, options) getExitCode, errExitCode := PlanAllExitCodeE(t, options) if errExitCode != nil { t.Fatal(errExitCode) } // since there is no state file we expect `2` to be the success exit code assert.Equal(t, 2, getExitCode) assertPlanAllExitCode(t, getExitCode, true) ApplyAll(t, options) getExitCode, errExitCode = PlanAllExitCodeE(t, options) if errExitCode != nil { t.Fatal(errExitCode) } // since there is a state file we expect `0` to be the success exit code assert.Equal(t, 0, getExitCode) assertPlanAllExitCode(t, getExitCode, true) } func TestAssertPlanAllExitCodeWithError(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-with-plan-error", t.Name()) require.NoError(t, err) options := &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", } getExitCode, errExitCode := PlanAllExitCodeE(t, options) require.NoError(t, errExitCode) assertPlanAllExitCode(t, getExitCode, false) } func assertPlanAllExitCode(t *testing.T, exitCode int, assertTrue bool) { validExitCodes := map[int]bool{ 0: true, 2: true, } _, hasKey := validExitCodes[exitCode] if assertTrue { assert.True(t, hasKey) } else { assert.False(t, hasKey) } } ================================================ FILE: modules/terragrunt/render.go ================================================ package terragrunt import ( "strings" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // Render runs terragrunt render to output the resolved terragrunt configuration as HCL. // This is useful for verifying merged includes, resolved dependencies, and executed functions // without actually applying any changes. func Render(t testing.TestingT, options *Options) string { out, err := RenderE(t, options) require.NoError(t, err) return out } // RenderE runs terragrunt render to output the resolved terragrunt configuration as HCL. // This is useful for verifying merged includes, resolved dependencies, and executed functions // without actually applying any changes. Log lines are stripped from the output. func RenderE(t testing.TestingT, options *Options) (string, error) { rawOutput, err := runTerragruntCommandE(t, options, "render") if err != nil { return "", err } return filterLogLines(rawOutput), nil } // filterLogLines removes terragrunt log lines while preserving original indentation. // Unlike removeLogLines (which trims whitespace for JSON extraction), this keeps // leading whitespace intact so HCL output structure is preserved. func filterLogLines(rawOutput string) string { lines := strings.Split(rawOutput, "\n") var result []string for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed == "" || isLogLine(trimmed) || isMetadataLine(trimmed) { continue } result = append(result, line) } return strings.Join(result, "\n") } // RenderJson runs terragrunt render --format json and returns the cleaned JSON output. // This is useful for programmatic assertions on the resolved terragrunt configuration. func RenderJson(t testing.TestingT, options *Options) string { out, err := RenderJsonE(t, options) require.NoError(t, err) return out } // RenderJsonE runs terragrunt render --format json and returns the cleaned JSON output. // This is useful for programmatic assertions on the resolved terragrunt configuration. func RenderJsonE(t testing.TestingT, options *Options) (string, error) { optsCopy := *options optsCopy.TerragruntArgs = append([]string{"--no-color"}, options.TerragruntArgs...) rawOutput, err := runTerragruntCommandE(t, &optsCopy, "render", "--format", "json") if err != nil { return "", err } return cleanTerragruntJson(rawOutput) } ================================================ FILE: modules/terragrunt/render_test.go ================================================ package terragrunt import ( "encoding/json" "os" "path/filepath" "testing" "github.com/gruntwork-io/terratest/modules/files" "github.com/stretchr/testify/require" ) func TestRender(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-no-error", t.Name()) require.NoError(t, err) output := Render(t, &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", }) require.Contains(t, output, `source = "`) require.Contains(t, output, `extra_arguments`) // Verify log lines are stripped and indentation is preserved require.NotContains(t, output, "level=") require.Contains(t, output, " source = ") } func TestRenderJson(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-no-error", t.Name()) require.NoError(t, err) output := RenderJson(t, &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", }) var parsed map[string]interface{} require.NoError(t, json.Unmarshal([]byte(output), &parsed), "output should be valid JSON") require.Contains(t, parsed, "terraform") } func TestFilterLogLines(t *testing.T) { t.Parallel() input := "20:41:53.564 INFO some log message\n source = \"./modules/vpc\"\n\ntime=2023-07-11 level=info msg=hello\n inputs = {\nGroup 1\n name = \"test\"\n }" result := filterLogLines(input) // Log lines and metadata lines should be stripped require.NotContains(t, result, "INFO") require.NotContains(t, result, "level=info") require.NotContains(t, result, "Group 1") // Indentation should be preserved (unlike removeLogLines which trims) require.Contains(t, result, " source = ") require.Contains(t, result, " inputs = {") require.Contains(t, result, " name = ") } func TestRenderE_InvalidConfig(t *testing.T) { t.Parallel() tmpDir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "terragrunt.hcl"), []byte("not_valid!!!"), 0644)) _, err := RenderE(t, &Options{TerragruntDir: tmpDir}) require.Error(t, err) } ================================================ FILE: modules/terragrunt/run.go ================================================ package terragrunt import ( "fmt" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // Run runs terragrunt run [tgArgs...] -- [tfArgs...] with the given options and returns stdout/stderr. // This is a generic wrapper that allows running any OpenTofu/Terraform command through terragrunt run. // The -- separator disambiguates Terragrunt flags from OpenTofu/Terraform flags. // The OpenTofu/Terraform command (e.g. "apply") should be the first element of tfArgs. func Run(t testing.TestingT, options *Options, tgArgs []string, tfArgs []string) string { out, err := RunE(t, options, tgArgs, tfArgs) require.NoError(t, err) return out } // RunE runs terragrunt run [tgArgs...] -- [tfArgs...] with the given options and returns stdout/stderr. // This is a generic wrapper that allows running any OpenTofu/Terraform command through terragrunt run. // The -- separator disambiguates Terragrunt flags from OpenTofu/Terraform flags. // The OpenTofu/Terraform command (e.g. "apply") should be the first element of tfArgs. func RunE(t testing.TestingT, options *Options, tgArgs []string, tfArgs []string) (string, error) { if len(tfArgs) == 0 { return "", fmt.Errorf("tfArgs cannot be empty; at minimum, an OpenTofu/Terraform command (e.g. \"apply\") is required") } args := buildRunArgs(tgArgs, tfArgs) return runTerragruntCommandE(t, options, "run", args...) } ================================================ FILE: modules/terragrunt/run_all.go ================================================ package terragrunt import ( "github.com/gruntwork-io/terratest/modules/testing" ) // Deprecated: Use Run with the --all flag in tgArgs instead. // RunAll runs terragrunt run --all -- with the given options and returns stdout/stderr. func RunAll(t testing.TestingT, options *Options, command string) string { return Run(t, options, []string{"--all"}, []string{command}) } // Deprecated: Use RunE with the --all flag in tgArgs instead. // RunAllE runs terragrunt run --all -- with the given options and returns stdout/stderr. func RunAllE(t testing.TestingT, options *Options, command string) (string, error) { return RunE(t, options, []string{"--all"}, []string{command}) } ================================================ FILE: modules/terragrunt/run_all_test.go ================================================ package terragrunt import ( "testing" "github.com/gruntwork-io/terratest/modules/files" "github.com/stretchr/testify/require" ) func TestRunAll(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-multi-plan", t.Name()) require.NoError(t, err) options := &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", } // Test with validate command out := RunAll(t, options, "validate") require.NotEmpty(t, out) } func TestRunAllE(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-multi-plan", t.Name()) require.NoError(t, err) options := &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", } // Test with validate command out, err := RunAllE(t, options, "validate") require.NoError(t, err) require.NotEmpty(t, out) } func TestRunAllWithPlan(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-multi-plan", t.Name()) require.NoError(t, err) options := &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", } // Test with plan command - verify output contains expected terraform plan text out, err := RunAllE(t, options, "plan") require.NoError(t, err) require.Contains(t, out, "Changes to Outputs") } ================================================ FILE: modules/terragrunt/run_test.go ================================================ package terragrunt import ( "testing" "github.com/gruntwork-io/terratest/modules/files" "github.com/stretchr/testify/require" ) // TestBuildRunArgs verifies the argument construction logic for the run command. func TestBuildRunArgs(t *testing.T) { t.Parallel() tests := []struct { name string tgArgs []string tfArgs []string expected []string }{ { name: "only tf args", tgArgs: []string{}, tfArgs: []string{"apply", "-auto-approve"}, expected: []string{"--", "apply", "-auto-approve"}, }, { name: "nil tg args with tf args", tgArgs: nil, tfArgs: []string{"plan"}, expected: []string{"--", "plan"}, }, { name: "both tg and tf args", tgArgs: []string{"--all"}, tfArgs: []string{"apply", "-input=false", "-auto-approve"}, expected: []string{"--all", "--", "apply", "-input=false", "-auto-approve"}, }, { name: "multiple tg args", tgArgs: []string{"--all", "--exclude-dir", "staging"}, tfArgs: []string{"plan"}, expected: []string{"--all", "--exclude-dir", "staging", "--", "plan"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() actual := buildRunArgs(tt.tgArgs, tt.tfArgs) require.Equal(t, tt.expected, actual) }) } } // TestRunE_EmptyTfArgs verifies that RunE returns an error when tfArgs is empty. func TestRunE_EmptyTfArgs(t *testing.T) { t.Parallel() options := &Options{ TerragruntDir: "/some/path", } _, err := RunE(t, options, []string{}, []string{}) require.Error(t, err) require.Contains(t, err.Error(), "tfArgs cannot be empty") _, err = RunE(t, options, []string{"--all"}, nil) require.Error(t, err) require.Contains(t, err.Error(), "tfArgs cannot be empty") } // TestRun verifies that Run executes terragrunt run -- apply successfully. func TestRun(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-no-error", t.Name()) require.NoError(t, err) options := &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", } defer Run(t, options, []string{}, []string{"destroy", "-auto-approve"}) out := Run(t, options, []string{}, []string{"apply", "-input=false", "-auto-approve"}) require.Contains(t, out, "Hello, World") } // TestRunE verifies that RunE returns an error on failure rather than calling t.Fatal. func TestRunE(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-no-error", t.Name()) require.NoError(t, err) options := &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", } // Run an invalid tf command to trigger an error _, err = RunE(t, options, []string{}, []string{"not-a-real-command"}) require.Error(t, err) } // TestRunWithTgArgs verifies that terragrunt-specific args are passed before the -- separator. func TestRunWithTgArgs(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-no-error", t.Name()) require.NoError(t, err) options := &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", } defer Run(t, options, []string{}, []string{"destroy", "-auto-approve"}) // Use --log-level error as a tg arg to verify it's respected out := Run(t, options, []string{"--log-level", "error"}, []string{"apply", "-input=false", "-auto-approve"}) require.Contains(t, out, "Hello, World") require.NotContains(t, out, "level=info", "With --log-level error, info logs should not appear") } // TestRunE_ValidationError verifies that RunE returns an error for invalid options. func TestRunE_ValidationError(t *testing.T) { t.Parallel() // Missing TerragruntDir options := &Options{} _, err := RunE(t, options, []string{}, []string{"apply"}) require.Error(t, err) require.Contains(t, err.Error(), "TerragruntDir is required") } ================================================ FILE: modules/terragrunt/stack_clean.go ================================================ package terragrunt import ( "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // StackClean calls terragrunt stack clean to remove the .terragrunt-stack directory // This command cleans up the generated stack files created by stack generate or stack run func StackClean(t testing.TestingT, options *Options) string { out, err := StackCleanE(t, options) require.NoError(t, err) return out } // StackCleanE calls terragrunt stack clean to remove the .terragrunt-stack directory // This command cleans up the generated stack files created by stack generate or stack run func StackCleanE(t testing.TestingT, options *Options) (string, error) { return runTerragruntStackCommandE(t, options, "clean") } ================================================ FILE: modules/terragrunt/stack_clean_test.go ================================================ package terragrunt import ( "path" "testing" "github.com/gruntwork-io/terratest/modules/files" "github.com/stretchr/testify/require" ) func TestStackClean(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp( "testdata/terragrunt-stack-init", t.Name()) require.NoError(t, err) stackDir := path.Join(testFolder, "live", ".terragrunt-stack") StackGenerate(t, &Options{ TerragruntDir: path.Join(testFolder, "live"), TerragruntBinary: "terragrunt", }) require.DirExists(t, stackDir) out := StackClean(t, &Options{ TerragruntDir: path.Join(testFolder, "live"), TerragruntBinary: "terragrunt", }) require.Contains(t, out, "Deleting stack directory") require.NoDirExists(t, stackDir) } func TestStackCleanE(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp( "testdata/terragrunt-stack-init", t.Name()) require.NoError(t, err) stackDir := path.Join(testFolder, "live", ".terragrunt-stack") // First generate the stack to create .terragrunt-stack directory _, err = StackGenerateE(t, &Options{ TerragruntDir: path.Join(testFolder, "live"), TerragruntBinary: "terragrunt", }) require.NoError(t, err) // Verify that the .terragrunt-stack directory was created require.DirExists(t, stackDir) // Clean the stack out, err := StackCleanE(t, &Options{ TerragruntDir: path.Join(testFolder, "live"), TerragruntBinary: "terragrunt", }) require.NoError(t, err) // Verify clean command produced expected output require.Contains(t, out, "Deleting stack directory") // Verify that the .terragrunt-stack directory was removed require.NoDirExists(t, stackDir) } func TestStackCleanNonExistentStack(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp( "testdata/terragrunt-stack-init", t.Name()) require.NoError(t, err) stackDir := path.Join(testFolder, "live", ".terragrunt-stack") // Verify that the .terragrunt-stack directory doesn't exist require.NoDirExists(t, stackDir) // Clean should succeed even if .terragrunt-stack doesn't exist _, err = StackCleanE(t, &Options{ TerragruntDir: path.Join(testFolder, "live"), TerragruntBinary: "terragrunt", }) require.NoError(t, err) } func TestStackCleanAfterRun(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp( "testdata/terragrunt-stack-init", t.Name()) require.NoError(t, err) stackDir := path.Join(testFolder, "live", ".terragrunt-stack") // Initialize the stack _, err = InitE(t, &Options{ TerragruntDir: path.Join(testFolder, "live"), TerragruntBinary: "terragrunt", TerraformArgs: []string{"-upgrade=true"}, }) require.NoError(t, err) // Run plan to generate the stack _, err = StackRunE(t, &Options{ TerragruntDir: path.Join(testFolder, "live"), TerragruntBinary: "terragrunt", TerraformArgs: []string{"plan"}, }) require.NoError(t, err) // Verify that the .terragrunt-stack directory was created require.DirExists(t, stackDir) // Clean the stack out, err := StackCleanE(t, &Options{ TerragruntDir: path.Join(testFolder, "live"), TerragruntBinary: "terragrunt", }) require.NoError(t, err) // Verify clean command produced expected output require.Contains(t, out, "Deleting stack directory") // Verify that the .terragrunt-stack directory was removed require.NoDirExists(t, stackDir) } ================================================ FILE: modules/terragrunt/stack_generate.go ================================================ package terragrunt import ( "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // StackGenerate calls terragrunt stack generate and returns stdout/stderr func StackGenerate(t testing.TestingT, options *Options) string { out, err := StackGenerateE(t, options) require.NoError(t, err) return out } // StackGenerateE calls terragrunt stack generate and returns stdout/stderr func StackGenerateE(t testing.TestingT, options *Options) (string, error) { return runTerragruntStackCommandE(t, options, "generate") } ================================================ FILE: modules/terragrunt/stack_generate_test.go ================================================ package terragrunt import ( "path" "strings" "testing" "github.com/gruntwork-io/terratest/modules/files" "github.com/stretchr/testify/require" ) func TestStackGenerate(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp( "testdata/terragrunt-stack-init", t.Name()) require.NoError(t, err) Init(t, &Options{ TerragruntDir: path.Join(testFolder, "live"), TerragruntBinary: "terragrunt", TerraformArgs: []string{"-upgrade=true"}, }) out := StackGenerate(t, &Options{ TerragruntDir: path.Join(testFolder, "live"), TerragruntBinary: "terragrunt", }) require.True(t, containsEitherString(out, "Processing unit", "Generating unit")) require.DirExists(t, path.Join(testFolder, "live", ".terragrunt-stack")) } func TestStackGenerateE(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp( "testdata/terragrunt-stack-init", t.Name()) require.NoError(t, err) // First initialize the stack _, err = InitE(t, &Options{ TerragruntDir: path.Join(testFolder, "live"), TerragruntBinary: "terragrunt", TerraformArgs: []string{"-upgrade=true"}, }) require.NoError(t, err) // Then generate the stack out, err := StackGenerateE(t, &Options{ TerragruntDir: path.Join(testFolder, "live"), TerragruntBinary: "terragrunt", }) require.NoError(t, err) // Validate that generate command produced output // Terragrunt v0.80.4+ outputs "Processing unit", older versions output "Generating unit" require.True(t, containsEitherString(out, "Processing unit", "Generating unit"), "Output should contain either 'Processing unit' or 'Generating unit'") // Verify that the .terragrunt-stack directory was created stackDir := path.Join(testFolder, "live", ".terragrunt-stack") require.DirExists(t, stackDir) // Verify that the expected unit directories were created expectedUnits := []string{"mother", "father", "chicks/chick-1", "chicks/chick-2"} for _, unit := range expectedUnits { unitPath := path.Join(stackDir, unit) require.DirExists(t, unitPath) } } func TestStackGenerateNonExistentDir(t *testing.T) { t.Parallel() // Test with non-existent directory _, err := StackGenerateE(t, &Options{ TerragruntDir: "/non/existent/path", TerragruntBinary: "terragrunt", }) require.Error(t, err) } // containsEitherString checks if the output contains at least one of the provided strings func containsEitherString(output, str1, str2 string) bool { return strings.Contains(output, str1) || strings.Contains(output, str2) } // TestStackGenerateWithArgs verifies stack commands respect TerragruntArgs func TestStackGenerateWithArgs(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp( "testdata/terragrunt-stack-init", t.Name()) require.NoError(t, err) // Initialize first _, err = InitE(t, &Options{ TerragruntDir: path.Join(testFolder, "live"), TerragruntBinary: "terragrunt", }) require.NoError(t, err) // Generate with TerragruntArgs out, err := StackGenerateE(t, &Options{ TerragruntDir: path.Join(testFolder, "live"), TerragruntBinary: "terragrunt", TerragruntArgs: []string{"--log-level", "error"}, }) require.NoError(t, err) // Verify args were respected require.NotContains(t, out, "level=info") } ================================================ FILE: modules/terragrunt/stack_output.go ================================================ package terragrunt import ( "encoding/json" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // StackOutput calls terragrunt stack output for the given variable and returns its value as a string func StackOutput(t testing.TestingT, options *Options, key string) string { out, err := StackOutputE(t, options, key) require.NoError(t, err) return out } // StackOutputE calls terragrunt stack output for the given variable and returns its value as a string func StackOutputE(t testing.TestingT, options *Options, key string) (string, error) { // Prepare options with no-color flag for parsing optsCopy := *options optsCopy.TerragruntArgs = append([]string{"--no-color"}, options.TerragruntArgs...) var args []string if key != "" { args = append(args, key) } // Append any user-provided TerraformArgs if len(options.TerraformArgs) > 0 { args = append(args, options.TerraformArgs...) } // Output command for stack rawOutput, err := runTerragruntStackCommandE( t, &optsCopy, "output", args...) if err != nil { return "", err } // Extract the actual value from output cleaned, err := cleanTerragruntOutput(rawOutput) if err != nil { return "", err } return cleaned, nil } // StackOutputJson calls terragrunt stack output for the given variable and returns the result as the json string. // If key is an empty string, it will return all the output variables. func StackOutputJson(t testing.TestingT, options *Options, key string) string { str, err := StackOutputJsonE(t, options, key) require.NoError(t, err) return str } // StackOutputJsonE calls terragrunt stack output for the given variable and returns the // result as the json string. // If key is an empty string, it will return all the output variables. func StackOutputJsonE(t testing.TestingT, options *Options, key string) (string, error) { // Prepare options with no-color flag optsCopy := *options optsCopy.TerragruntArgs = append([]string{"--no-color"}, options.TerragruntArgs...) // -json is an OpenTofu/Terraform flag that should go after the output command args := []string{"-json"} if key != "" { args = append(args, key) } // Append any user-provided TerraformArgs if len(options.TerraformArgs) > 0 { args = append(args, options.TerraformArgs...) } // Output command for stack rawOutput, err := runTerragruntStackCommandE( t, &optsCopy, "output", args...) if err != nil { return "", err } // Parse and format JSON output return cleanTerragruntJson(rawOutput) } // StackOutputAll gets all stack outputs and returns them as a map[string]interface{} func StackOutputAll(t testing.TestingT, options *Options) map[string]interface{} { outputs, err := StackOutputAllE(t, options) require.NoError(t, err) return outputs } // StackOutputAllE gets all stack outputs and returns them as a map[string]interface{} func StackOutputAllE(t testing.TestingT, options *Options) (map[string]interface{}, error) { jsonOutput, err := StackOutputJsonE(t, options, "") if err != nil { return nil, err } var outputs map[string]interface{} if err := json.Unmarshal([]byte(jsonOutput), &outputs); err != nil { return nil, err } return outputs, nil } // StackOutputListAll gets all stack output variable names and returns them as a slice func StackOutputListAll(t testing.TestingT, options *Options) []string { keys, err := StackOutputListAllE(t, options) require.NoError(t, err) return keys } // StackOutputListAllE gets all stack output variable names and returns them as a slice func StackOutputListAllE(t testing.TestingT, options *Options) ([]string, error) { outputs, err := StackOutputAllE(t, options) if err != nil { return nil, err } keys := make([]string, 0, len(outputs)) for key := range outputs { keys = append(keys, key) } return keys, nil } ================================================ FILE: modules/terragrunt/stack_output_test.go ================================================ package terragrunt import ( "encoding/json" "strings" "testing" "github.com/gruntwork-io/terratest/modules/files" "github.com/gruntwork-io/terratest/modules/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // Integration test using actual terragrunt stack fixture func TestStackOutputIntegration(t *testing.T) { t.Parallel() // Create a temporary copy of the stack fixture testFolder, err := files.CopyTerragruntFolderToTemp( "testdata/terragrunt-stack-init", "tg-stack-output-test") require.NoError(t, err) options := &Options{ TerragruntDir: testFolder + "/live", TerragruntBinary: "terragrunt", Logger: logger.Discard, } // Initialize and apply tg using stack commands _, err = InitE(t, options) require.NoError(t, err) applyOptions := &Options{ TerragruntDir: testFolder + "/live", TerragruntBinary: "terragrunt", Logger: logger.Discard, TerraformArgs: []string{"apply"}, // stack run auto-approves by default } _, err = StackRunE(t, applyOptions) require.NoError(t, err) // Clean up after test defer func() { destroyOptions := &Options{ TerragruntDir: testFolder + "/live", TerragruntBinary: "terragrunt", Logger: logger.Discard, TerraformArgs: []string{"destroy"}, // stack run auto-approves by default } _, _ = StackRunE(t, destroyOptions) }() // Test string stack output - get output from mother unit strOutput := StackOutput(t, options, "mother") assert.Contains(t, strOutput, "./test.txt") // Test getting stack output as JSON using the StackOutputJson function jsonOptions := &Options{ TerragruntDir: testFolder + "/live", TerragruntBinary: "terragrunt", Logger: logger.Discard, } strOutputJson := StackOutputJson(t, jsonOptions, "mother") // The JSON output for a single value should still be cleaned to just show the value assert.Contains(t, strOutputJson, "./test.txt") // Test getting all stack outputs as JSON allOutputsJson := StackOutputJson(t, jsonOptions, "") require.NotEmpty(t, allOutputsJson) // For JSON output of all outputs, we should get valid JSON // But our function cleans it, so let's test it as-is // The JSON structure should be valid and contain our expected data if strings.Contains(allOutputsJson, "{") { // Parse and validate the JSON structure var allOutputs map[string]interface{} err = json.Unmarshal([]byte(allOutputsJson), &allOutputs) require.NoError(t, err) // Verify all expected stack outputs are present require.Contains(t, allOutputs, "mother") require.Contains(t, allOutputs, "father") require.Contains(t, allOutputs, "chick_1") require.Contains(t, allOutputs, "chick_2") // Verify the structure of outputs motherOutputMap := allOutputs["mother"].(map[string]interface{}) assert.Equal(t, "./test.txt", motherOutputMap["output"]) } else { // If not JSON format, at least verify it contains our expected values assert.Contains(t, allOutputsJson, "mother") assert.Contains(t, allOutputsJson, "father") assert.Contains(t, allOutputsJson, "chick_1") assert.Contains(t, allOutputsJson, "chick_2") } } // Test error handling with non-existent stack output func TestStackOutputErrorHandling(t *testing.T) { t.Parallel() // Create a temporary copy of the stack fixture testFolder, err := files.CopyTerragruntFolderToTemp( "testdata/terragrunt-stack-init", "tg-stack-output-error-test") require.NoError(t, err) options := &Options{ TerragruntDir: testFolder + "/live", TerragruntBinary: "terragrunt", Logger: logger.Discard, } // Initialize and apply tg using stack commands _, err = InitE(t, options) require.NoError(t, err) applyOptions := &Options{ TerragruntDir: testFolder + "/live", TerragruntBinary: "terragrunt", Logger: logger.Discard, TerraformArgs: []string{"apply"}, // stack run auto-approves by default } _, err = StackRunE(t, applyOptions) require.NoError(t, err) // Clean up after test defer func() { destroyOptions := &Options{ TerragruntDir: testFolder + "/live", TerragruntBinary: "terragrunt", Logger: logger.Discard, TerraformArgs: []string{"destroy"}, // stack run auto-approves by default } _, _ = StackRunE(t, destroyOptions) }() // Test that non-existent stack output returns error or empty string output, err := StackOutputE(t, options, "non_existent_output") // Tg stack output might return empty string for non-existent outputs // rather than an error, so we need to handle both cases if err != nil { assert.Contains(t, strings.ToLower(err.Error()), "output") } else { assert.Empty(t, output, "Expected empty output for non-existent stack output") } } // Test StackOutputAll to get all stack outputs as a map func TestStackOutputAll(t *testing.T) { t.Parallel() // Create a temporary copy of the stack fixture testFolder, err := files.CopyTerragruntFolderToTemp( "testdata/terragrunt-stack-init", "tg-stack-output-all-test") require.NoError(t, err) options := &Options{ TerragruntDir: testFolder + "/live", TerragruntBinary: "terragrunt", Logger: logger.Discard, } // Initialize and apply tg using stack commands _, err = InitE(t, options) require.NoError(t, err) applyOptions := &Options{ TerragruntDir: testFolder + "/live", TerragruntBinary: "terragrunt", Logger: logger.Discard, TerraformArgs: []string{"apply"}, // stack run auto-approves by default } _, err = StackRunE(t, applyOptions) require.NoError(t, err) // Clean up after test defer func() { destroyOptions := &Options{ TerragruntDir: testFolder + "/live", TerragruntBinary: "terragrunt", Logger: logger.Discard, TerraformArgs: []string{"destroy"}, // stack run auto-approves by default } _, _ = StackRunE(t, destroyOptions) }() // Test StackOutputAll - get all outputs as a map allOutputs := StackOutputAll(t, options) require.NotEmpty(t, allOutputs) // Verify expected outputs are present require.Contains(t, allOutputs, "mother") require.Contains(t, allOutputs, "father") require.Contains(t, allOutputs, "chick_1") require.Contains(t, allOutputs, "chick_2") // Verify we can access specific output values motherOutput := allOutputs["mother"].(map[string]interface{}) assert.Equal(t, "./test.txt", motherOutput["output"]) } // Test StackOutputListAll to get all stack output keys func TestStackOutputListAll(t *testing.T) { t.Parallel() // Create a temporary copy of the stack fixture testFolder, err := files.CopyTerragruntFolderToTemp( "testdata/terragrunt-stack-init", "tg-stack-output-list-test") require.NoError(t, err) options := &Options{ TerragruntDir: testFolder + "/live", TerragruntBinary: "terragrunt", Logger: logger.Discard, } // Initialize and apply using stack commands _, err = InitE(t, options) require.NoError(t, err) applyOptions := &Options{ TerragruntDir: testFolder + "/live", TerragruntBinary: "terragrunt", Logger: logger.Discard, TerraformArgs: []string{"apply"}, } _, err = StackRunE(t, applyOptions) require.NoError(t, err) // Clean up after test defer func() { destroyOptions := &Options{ TerragruntDir: testFolder + "/live", TerragruntBinary: "terragrunt", Logger: logger.Discard, TerraformArgs: []string{"destroy"}, } _, _ = StackRunE(t, destroyOptions) }() // Test StackOutputListAll - get all output keys keys := StackOutputListAll(t, options) require.NotEmpty(t, keys) // Verify expected keys are present require.Contains(t, keys, "mother") require.Contains(t, keys, "father") require.Contains(t, keys, "chick_1") require.Contains(t, keys, "chick_2") // Verify we got all 4 keys require.Len(t, keys, 4) } // Test StackOutputListAllE func TestStackOutputListAllE(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp( "testdata/terragrunt-stack-init", "tg-stack-output-list-e-test") require.NoError(t, err) options := &Options{ TerragruntDir: testFolder + "/live", TerragruntBinary: "terragrunt", Logger: logger.Discard, } _, err = InitE(t, options) require.NoError(t, err) applyOptions := &Options{ TerragruntDir: testFolder + "/live", TerragruntBinary: "terragrunt", Logger: logger.Discard, TerraformArgs: []string{"apply"}, } _, err = StackRunE(t, applyOptions) require.NoError(t, err) defer func() { destroyOptions := &Options{ TerragruntDir: testFolder + "/live", TerragruntBinary: "terragrunt", Logger: logger.Discard, TerraformArgs: []string{"destroy"}, } _, _ = StackRunE(t, destroyOptions) }() keys, err := StackOutputListAllE(t, options) require.NoError(t, err) require.NotEmpty(t, keys) require.Contains(t, keys, "mother") } ================================================ FILE: modules/terragrunt/stack_run.go ================================================ package terragrunt import ( "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // StackRun calls terragrunt stack run and returns stdout/stderr func StackRun(t testing.TestingT, options *Options) string { out, err := StackRunE(t, options) require.NoError(t, err) return out } // StackRunE calls terragrunt stack run and returns stdout/stderr func StackRunE(t testing.TestingT, options *Options) (string, error) { return runTerragruntStackCommandE(t, options, "run") } ================================================ FILE: modules/terragrunt/stack_run_test.go ================================================ package terragrunt import ( "path" "testing" "github.com/gruntwork-io/terratest/modules/files" "github.com/stretchr/testify/require" ) func TestStackRun(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp( "testdata/terragrunt-stack-init", t.Name()) require.NoError(t, err) Init(t, &Options{ TerragruntDir: path.Join(testFolder, "live"), TerragruntBinary: "terragrunt", TerraformArgs: []string{"-upgrade=true"}, }) out := StackRun(t, &Options{ TerragruntDir: path.Join(testFolder, "live"), TerragruntBinary: "terragrunt", TerraformArgs: []string{"plan"}, }) require.True(t, containsEitherString(out, "Processing unit", "Generating unit")) require.DirExists(t, path.Join(testFolder, "live", ".terragrunt-stack")) } func TestStackRunE(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp( "testdata/terragrunt-stack-init", t.Name()) require.NoError(t, err) // First initialize the stack _, err = InitE(t, &Options{ TerragruntDir: path.Join(testFolder, "live"), TerragruntBinary: "terragrunt", TerraformArgs: []string{"-upgrade=true"}, }) require.NoError(t, err) // Then run plan on the stack out, err := StackRunE(t, &Options{ TerragruntDir: path.Join(testFolder, "live"), TerragruntBinary: "terragrunt", TerraformArgs: []string{"plan"}, }) require.NoError(t, err) // Validate that generate command produced output // Terragrunt v0.80.4+ outputs "Processing unit", older versions output "Generating unit" require.True(t, containsEitherString(out, "Processing unit", "Generating unit"), "Output should contain either 'Processing unit' or 'Generating unit'") // Verify that the .terragrunt-stack directory was created stackDir := path.Join(testFolder, "live", ".terragrunt-stack") require.DirExists(t, stackDir) // Verify that the expected unit directories were created expectedUnits := []string{"mother", "father", "chicks/chick-1", "chicks/chick-2"} for _, unit := range expectedUnits { unitPath := path.Join(stackDir, unit) require.DirExists(t, unitPath) } } func TestStackRunPlanWithNoColor(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerraformFolderToTemp( "testdata/terragrunt-stack-init", t.Name()) require.NoError(t, err) // First initialize the stack _, err = InitE(t, &Options{ TerragruntDir: path.Join(testFolder, "live"), TerragruntBinary: "terragrunt", TerraformArgs: []string{"-upgrade=true"}, }) require.NoError(t, err) // Run plan with no-color option out, err := StackRunE(t, &Options{ TerragruntDir: path.Join(testFolder, "live"), TerragruntBinary: "terragrunt", TerragruntArgs: []string{"--no-color"}, TerraformArgs: []string{"plan"}, }) require.NoError(t, err) // Validate that generate command produced output // Terragrunt v0.80.4+ outputs "Processing unit", older versions output "Generating unit" require.True(t, containsEitherString(out, "Processing unit", "Generating unit"), "Output should contain either 'Processing unit' or 'Generating unit'") // Verify that the .terragrunt-stack directory was created stackDir := path.Join(testFolder, "live", ".terragrunt-stack") require.DirExists(t, stackDir) } func TestStackRunNonExistentDir(t *testing.T) { t.Parallel() // Test with non-existent directory _, err := StackRunE(t, &Options{ TerragruntDir: "/non/existent/path", TerragruntBinary: "terragrunt", }) require.Error(t, err) } func TestStackRunEmptyOptions(t *testing.T) { t.Parallel() // Test with minimal options to verify default behavior _, err := StackRunE(t, &Options{}) require.Error(t, err) // Should fail due to missing TerragruntDir } ================================================ FILE: modules/terragrunt/terragrunt_e2e_test.go ================================================ package terragrunt import ( "encoding/json" "path/filepath" "strings" "testing" "github.com/gruntwork-io/terratest/modules/files" "github.com/stretchr/testify/require" ) // TestTerragruntEndToEndIntegration is a comprehensive integration test that validates // the complete terragrunt workflow with TerragruntArgs and TerraformArgs. // This test exercises the fix for issue #1609 where args were being ignored. func TestTerragruntEndToEndIntegration(t *testing.T) { t.Parallel() // Setup: Copy test fixture to temp directory testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-multi-plan", t.Name()) require.NoError(t, err) // Configure options with TerragruntArgs options := &Options{ TerragruntDir: testFolder, // TerragruntArgs: Global terragrunt flags that should be respected TerragruntArgs: []string{"--log-level", "error"}, } // Step 1: Plan with exit code (original bug scenario from issue #1609) // This is the exact scenario from the bug report t.Log("Step 1: Testing PlanAllExitCode with TerragruntArgs (original bug scenario)") exitCode, err := PlanAllExitCodeE(t, options) require.NoError(t, err) // Should show changes (exit code 2) since nothing has been applied yet require.Equal(t, 2, exitCode, "Plan should detect changes") // Step 2: Apply all modules t.Log("Step 2: Testing ApplyAll with TerragruntArgs") applyOutput := ApplyAll(t, options) require.NotEmpty(t, applyOutput) // Verify TerragruntArgs: should not see info-level logs require.NotContains(t, applyOutput, "level=info", "TerragruntArgs should suppress info logs") // Step 3: Plan again - should show no changes (exit code 0) t.Log("Step 3: Verifying infrastructure is up-to-date") exitCode, err = PlanAllExitCodeE(t, options) require.NoError(t, err) require.Equal(t, 0, exitCode, "Plan should show no changes after apply") // Step 4: Clean up - Destroy all t.Log("Step 4: Testing DestroyAll with TerragruntArgs") destroyOutput := DestroyAll(t, options) require.NotEmpty(t, destroyOutput) // Verify TerragruntArgs: should not see info-level logs require.NotContains(t, destroyOutput, "level=info", "TerragruntArgs should suppress info logs") t.Log("Integration test completed successfully - all args were properly passed") } // TestStackEndToEndIntegration tests the complete stack workflow with args func TestStackEndToEndIntegration(t *testing.T) { t.Parallel() // Setup: Copy stack test fixture testFolder, err := files.CopyTerraformFolderToTemp( "testdata/terragrunt-stack-init", t.Name()) require.NoError(t, err) options := &Options{ TerragruntDir: filepath.Join(testFolder, "live"), TerragruntArgs: []string{"--log-level", "error"}, } // Step 1: Initialize stack t.Log("Step 1: Initializing stack with TerragruntArgs") output, err := InitE(t, options) require.NoError(t, err) require.NotContains(t, output, "level=info", "TerragruntArgs should suppress info logs") // Step 2: Generate stack t.Log("Step 2: Generating stack with TerragruntArgs") genOutput, err := StackGenerateE(t, options) require.NoError(t, err) require.NotContains(t, genOutput, "level=info", "TerragruntArgs should suppress info logs") // Step 3: Run stack plan t.Log("Step 3: Running stack plan with TerraformArgs") runOptions := *options runOptions.TerraformArgs = []string{"plan"} planOutput, err := StackRunE(t, &runOptions) require.NoError(t, err) // Check for common plan indicator (works with both Terraform and OpenTofu) require.Contains(t, planOutput, "will perform") // Step 4: Clean stack t.Log("Step 4: Cleaning stack") _, err = StackCleanE(t, options) require.NoError(t, err) t.Log("Stack integration test completed successfully") } // TestOutputAllJsonEndToEnd tests OutputAllJson extracts clean JSON from terragrunt output func TestOutputAllJsonEndToEnd(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp( "testdata/terragrunt-multi-plan", t.Name()) require.NoError(t, err) options := &Options{TerragruntDir: testFolder} ApplyAll(t, options) defer DestroyAll(t, options) output := OutputAllJson(t, options) // Contains module outputs, no log noise require.Contains(t, output, `"value": "foo"`) require.Contains(t, output, `"value": "bar"`) // Check for both old and new log format markers require.NotContains(t, output, "time=") require.NotContains(t, output, " INFO ") require.NotContains(t, output, " STDOUT ") require.NotContains(t, output, "Group 1") require.NotContains(t, output, "- Unit ") // Validate output contains at least 2 valid JSON objects (foo and bar modules) dec := json.NewDecoder(strings.NewReader(output)) var jsonCount int for dec.More() { var obj json.RawMessage require.NoError(t, dec.Decode(&obj)) jsonCount++ } require.GreaterOrEqual(t, jsonCount, 2) } ================================================ FILE: modules/terragrunt/terragrunt_example_test.go ================================================ package terragrunt import ( "path/filepath" "strings" "testing" "github.com/gruntwork-io/terratest/modules/files" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // This file demonstrates two approaches for testing Terragrunt configurations: // // 1. UNIT TESTING: Use the terraform module with TerraformBinary set to "terragrunt". // This works because terragrunt is a thin wrapper around terraform for single units. // See: TestTerragruntExample, TestTerragruntConsole // // 2. STACK TESTING: Use the dedicated terragrunt module with ApplyAll/DestroyAll. // This is for testing a stack of Terragrunt units with dependencies using --all commands. // See: TestTerragruntMultiModuleExample // TestTerragruntExample demonstrates testing a single Terragrunt unit using the terraform package. // For unit testing, use terraform.Options with TerraformBinary set to "terragrunt". func TestTerragruntExample(t *testing.T) { t.Parallel() // Copy the example folder to a temp folder to avoid state conflicts between parallel tests. testFolder, err := files.CopyTerragruntFolderToTemp("../../examples/terragrunt-example", t.Name()) require.NoError(t, err) terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ // Set the path to the Terragrunt unit that will be tested. TerraformDir: testFolder, // Set the terraform binary path to terragrunt so that terratest uses terragrunt // instead of terraform. You must ensure that you have terragrunt downloaded and // available in your PATH. TerraformBinary: "terragrunt", }) // Clean up resources with "terragrunt destroy" at the end of the test. defer terraform.Destroy(t, terraformOptions) // Run "terragrunt apply". Under the hood, terragrunt will run "terraform init" and // "terraform apply". Fail the test if there are any errors. terraform.Apply(t, terraformOptions) // Run `terraform output` to get the values of output variables and check they have // the expected values. // Note: When using terragrunt, OutputAll is recommended because terragrunt returns // all outputs in the full JSON format even when a specific key is requested. outputs := terraform.OutputAll(t, terraformOptions) assert.Equal(t, "one input another input", outputs["output"]) } // TestTerragruntConsole demonstrates running terragrunt console command. func TestTerragruntConsole(t *testing.T) { t.Parallel() // Copy the example folder to a temp folder to avoid state conflicts between parallel tests. testFolder, err := files.CopyTerragruntFolderToTemp("../../examples/terragrunt-example", t.Name()) require.NoError(t, err) terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ TerraformDir: testFolder, TerraformBinary: "terragrunt", Stdin: strings.NewReader("local.mylocal"), }) defer terraform.Destroy(t, terraformOptions) // Run "terragrunt run -- console". out := terraform.RunTerraformCommand(t, terraformOptions, "run", "--", "console") assert.Contains(t, out, `"local variable named mylocal"`) } // TestTerragruntMultiModuleExample demonstrates testing a stack of Terragrunt units // using the dedicated terragrunt package. Use this approach when you have a stack of // units with dependencies that need to be applied/destroyed together using --all. func TestTerragruntMultiModuleExample(t *testing.T) { t.Parallel() // Copy the entire example folder (including modules) to a temp folder. // We copy the parent folder because terragrunt.hcl files reference ../modules. testFolder, err := files.CopyTerragruntFolderToTemp( "../../examples/terragrunt-multi-module-example", t.Name()) require.NoError(t, err) options := &Options{ // Run from the live subfolder where the terragrunt configs are TerragruntDir: filepath.Join(testFolder, "live"), // Optional: Set log level for cleaner output TerragruntArgs: []string{"--log-level", "error"}, } // Clean up all modules with "terragrunt destroy --all" at the end of the test. // DestroyAll respects the reverse dependency order. defer DestroyAll(t, options) // Run "terragrunt apply --all". This applies all modules in dependency order. ApplyAll(t, options) // Verify the plan shows no changes (infrastructure is up-to-date) exitCode := PlanAllExitCode(t, options) assert.Equal(t, 0, exitCode, "Plan should show no changes after apply") } ================================================ FILE: modules/terragrunt/testdata/terragrunt-multi-plan/bar/main.tf ================================================ output "test" { value = "bar" } ================================================ FILE: modules/terragrunt/testdata/terragrunt-multi-plan/bar/terragrunt.hcl ================================================ terraform { source = "..//bar" extra_arguments "common_vars" { commands = get_terraform_commands_that_need_vars() arguments = [ "-var-file=terraform.tfvars" ] } } ================================================ FILE: modules/terragrunt/testdata/terragrunt-multi-plan/foo/main.tf ================================================ output "test" { value = "foo" } ================================================ FILE: modules/terragrunt/testdata/terragrunt-multi-plan/foo/terragrunt.hcl ================================================ terraform { source = "..//foo" extra_arguments "common_vars" { commands = get_terraform_commands_that_need_vars() arguments = [ "-var-file=terraform.tfvars" ] } } ================================================ FILE: modules/terragrunt/testdata/terragrunt-no-error/main.tf ================================================ output "test" { value = "Hello, World" } ================================================ FILE: modules/terragrunt/testdata/terragrunt-no-error/terragrunt.hcl ================================================ terraform { source = "..//terragrunt-no-error" extra_arguments "common_vars" { commands = get_terraform_commands_that_need_vars() arguments = [ "-var-file=terraform.tfvars" ] } } ================================================ FILE: modules/terragrunt/testdata/terragrunt-output/main.tf ================================================ output "str" { value = "str" } output "list" { value = ["a", "b", "c"] } output "map" { value = { foo = "bar" } } ================================================ FILE: modules/terragrunt/testdata/terragrunt-output/terragrunt.hcl ================================================ terraform { # Intentionally empty } ================================================ FILE: modules/terragrunt/testdata/terragrunt-stack-init/live/placeholder.tf ================================================ # Placeholder Terraform file for Terragrunt stack tests ================================================ FILE: modules/terragrunt/testdata/terragrunt-stack-init/live/terragrunt.hcl ================================================ # Minimal terragrunt.hcl required for stack commands ================================================ FILE: modules/terragrunt/testdata/terragrunt-stack-init/live/terragrunt.stack.hcl ================================================ unit "mother" { source = "../units/chicken" path = "mother" } unit "father" { source = "../units/chicken" path = "father" } unit "chick_1" { source = "../units/chick" path = "chicks/chick-1" } unit "chick_2" { source = "../units/chick" path = "chicks/chick-2" } ================================================ FILE: modules/terragrunt/testdata/terragrunt-stack-init/units/chick/main.tf ================================================ resource "local_file" "file" { content = "chick" filename = "${path.module}/test.txt" } output "output" { value = local_file.file.filename } ================================================ FILE: modules/terragrunt/testdata/terragrunt-stack-init/units/chick/terragrunt.hcl ================================================ terraform { source = "." } ================================================ FILE: modules/terragrunt/testdata/terragrunt-stack-init/units/chicken/main.tf ================================================ resource "local_file" "file" { content = "chicken" filename = "${path.module}/test.txt" } output "output" { value = local_file.file.filename } ================================================ FILE: modules/terragrunt/testdata/terragrunt-stack-init/units/chicken/terragrunt.hcl ================================================ terraform { source = "." } ================================================ FILE: modules/terragrunt/testdata/terragrunt-stack-init/units/father/main.tf ================================================ resource "local_file" "file" { content = "father" filename = "${path.module}/test.txt" } output "output" { value = local_file.file.filename } ================================================ FILE: modules/terragrunt/testdata/terragrunt-stack-init/units/father/terragrunt.hcl ================================================ terraform { source = "." } ================================================ FILE: modules/terragrunt/testdata/terragrunt-stack-init/units/mother/main.tf ================================================ resource "local_file" "file" { content = "mother" filename = "${path.module}/test.txt" } output "output" { value = local_file.file.filename } ================================================ FILE: modules/terragrunt/testdata/terragrunt-stack-init/units/mother/terragrunt.hcl ================================================ terraform { source = "." } ================================================ FILE: modules/terragrunt/testdata/terragrunt-stack-init-error/main.tf ================================================ # Simple Terraform configuration resource "null_resource" "test" { provisioner "local-exec" { command = "echo 'Test resource'" } } ================================================ FILE: modules/terragrunt/testdata/terragrunt-stack-init-error/terragrunt.hcl ================================================ terraform { source = "..//terragrunt-stack-init-error" extra_arguments "common_vars" { commands = get_terraform_commands_that_need_vars() arguments = [ "-var-file=terraform.tfvars" ] } } # This is intentionally invalid HCL syntax - missing closing brace inputs = { test_var = "test_value" # Missing closing brace for the inputs block ================================================ FILE: modules/terragrunt/testdata/terragrunt-with-plan-error/main.tf ================================================ output "test" { value = var.test } ================================================ FILE: modules/terragrunt/testdata/terragrunt-with-plan-error/terragrunt.hcl ================================================ terraform { source = "..//terraform-with-plan-error" extra_arguments "common_vars" { commands = get_terraform_commands_that_need_vars() arguments = [ "-var-file=terraform.tfvars" ] } } ================================================ FILE: modules/terragrunt/validate.go ================================================ package terragrunt import ( "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // ValidateAll runs terragrunt run --all validate with the given options and returns stdout/stderr func ValidateAll(t testing.TestingT, options *Options) string { out, err := ValidateAllE(t, options) require.NoError(t, err) return out } // ValidateAllE runs terragrunt run --all -- validate with the given options and returns stdout/stderr func ValidateAllE(t testing.TestingT, options *Options) (string, error) { args := buildRunArgs([]string{"--all"}, []string{"validate"}) return runTerragruntCommandE(t, options, "run", args...) } // Validate runs terragrunt run validate for a single unit and returns stdout/stderr. func Validate(t testing.TestingT, options *Options) string { out, err := ValidateE(t, options) require.NoError(t, err) return out } // ValidateE runs terragrunt run -- validate for a single unit and returns stdout/stderr. func ValidateE(t testing.TestingT, options *Options) (string, error) { args := buildRunArgs([]string{}, []string{"validate"}) return runTerragruntCommandE(t, options, "run", args...) } // InitAndValidate runs terragrunt init followed by validate for a single unit and returns the validate stdout/stderr. func InitAndValidate(t testing.TestingT, options *Options) string { out, err := InitAndValidateE(t, options) require.NoError(t, err) return out } // InitAndValidateE runs terragrunt init followed by validate for a single unit and returns the validate stdout/stderr. func InitAndValidateE(t testing.TestingT, options *Options) (string, error) { if _, err := InitE(t, options); err != nil { return "", err } return ValidateE(t, options) } ================================================ FILE: modules/terragrunt/validate_test.go ================================================ package terragrunt import ( "testing" "github.com/gruntwork-io/terratest/modules/files" "github.com/stretchr/testify/require" ) func TestValidateAll(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-multi-plan", t.Name()) require.NoError(t, err) ValidateAll(t, &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", }) } func TestValidate(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-no-error", t.Name()) require.NoError(t, err) Validate(t, &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", }) } func TestInitAndValidate(t *testing.T) { t.Parallel() testFolder, err := files.CopyTerragruntFolderToTemp("testdata/terragrunt-no-error", t.Name()) require.NoError(t, err) out := InitAndValidate(t, &Options{ TerragruntDir: testFolder, TerragruntBinary: "terragrunt", }) require.NotEmpty(t, out) } ================================================ FILE: modules/test-structure/save_test_data.go ================================================ package test_structure import ( "encoding/json" "fmt" "os" "path/filepath" "github.com/gruntwork-io/terratest/modules/aws" "github.com/gruntwork-io/terratest/modules/files" "github.com/gruntwork-io/terratest/modules/k8s" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/packer" "github.com/gruntwork-io/terratest/modules/ssh" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // SaveTerraformOptions serializes and saves TerraformOptions into the given folder. This allows you to create TerraformOptions during setup // and to reuse that TerraformOptions later during validation and teardown. func SaveTerraformOptions(t testing.TestingT, testFolder string, terraformOptions *terraform.Options) { SaveTestData(t, formatTerraformOptionsPath(testFolder), true, terraformOptions) } // SaveTerraformOptionsIfNotPresent serializes and saves TerraformOptions into the given folder if the file does not exist or the json is // empty. This allows you to create TerraformOptions during setup and to reuse that TerraformOptions later during validation and teardown, // but will prevent overwritting the contents and potentially duplicating resources. func SaveTerraformOptionsIfNotPresent(t testing.TestingT, testFolder string, terraformOptions *terraform.Options) { SaveTestData(t, formatTerraformOptionsPath(testFolder), false, terraformOptions) } // LoadTerraformOptions loads and unserializes TerraformOptions from the given folder. This allows you to reuse a TerraformOptions that was // created during an earlier setup step in later validation and teardown steps. func LoadTerraformOptions(t testing.TestingT, testFolder string) *terraform.Options { var terraformOptions terraform.Options LoadTestData(t, formatTerraformOptionsPath(testFolder), &terraformOptions) return &terraformOptions } // formatTerraformOptionsPath formats a path to save TerraformOptions in the given folder. func formatTerraformOptionsPath(testFolder string) string { return FormatTestDataPath(testFolder, "TerraformOptions.json") } // SavePackerOptions serializes and saves PackerOptions into the given folder. This allows you to create PackerOptions during setup // and to reuse that PackerOptions later during validation and teardown. func SavePackerOptions(t testing.TestingT, testFolder string, packerOptions *packer.Options) { SaveTestData(t, formatPackerOptionsPath(testFolder), true, packerOptions) } // LoadPackerOptions loads and unserializes PackerOptions from the given folder. This allows you to reuse a PackerOptions that was // created during an earlier setup step in later validation and teardown steps. func LoadPackerOptions(t testing.TestingT, testFolder string) *packer.Options { var packerOptions packer.Options LoadTestData(t, formatPackerOptionsPath(testFolder), &packerOptions) return &packerOptions } // formatPackerOptionsPath formats a path to save PackerOptions in the given folder. func formatPackerOptionsPath(testFolder string) string { return FormatTestDataPath(testFolder, "PackerOptions.json") } // SaveEc2KeyPair serializes and saves an Ec2KeyPair into the given folder. This allows you to create an Ec2KeyPair during setup // and to reuse that Ec2KeyPair later during validation and teardown. func SaveEc2KeyPair(t testing.TestingT, testFolder string, keyPair *aws.Ec2Keypair) { saveTestData(t, formatEc2KeyPairPath(testFolder), true, keyPair, false) } // LoadEc2KeyPair loads and unserializes an Ec2KeyPair from the given folder. This allows you to reuse an Ec2KeyPair that was // created during an earlier setup step in later validation and teardown steps. func LoadEc2KeyPair(t testing.TestingT, testFolder string) *aws.Ec2Keypair { var keyPair aws.Ec2Keypair LoadTestData(t, formatEc2KeyPairPath(testFolder), &keyPair) return &keyPair } // formatEc2KeyPairPath formats a path to save an Ec2KeyPair in the given folder. func formatEc2KeyPairPath(testFolder string) string { return FormatTestDataPath(testFolder, "Ec2KeyPair.json") } // SaveSshKeyPair serializes and saves an SshKeyPair into the given folder. This allows you to create an SshKeyPair during setup // and to reuse that SshKeyPair later during validation and teardown. func SaveSshKeyPair(t testing.TestingT, testFolder string, keyPair *ssh.KeyPair) { SaveTestData(t, formatSshKeyPairPath(testFolder), true, keyPair) } // LoadSshKeyPair loads and unserializes an SshKeyPair from the given folder. This allows you to reuse an SshKeyPair that was // created during an earlier setup step in later validation and teardown steps. func LoadSshKeyPair(t testing.TestingT, testFolder string) *ssh.KeyPair { var keyPair ssh.KeyPair LoadTestData(t, formatSshKeyPairPath(testFolder), &keyPair) return &keyPair } // formatSshKeyPairPath formats a path to save an SshKeyPair in the given folder. func formatSshKeyPairPath(testFolder string) string { return FormatTestDataPath(testFolder, "SshKeyPair.json") } // SaveKubectlOptions serializes and saves KubectlOptions into the given folder. This allows you to create a KubectlOptions during setup // and reuse that KubectlOptions later during validation and teardown. func SaveKubectlOptions(t testing.TestingT, testFolder string, kubectlOptions *k8s.KubectlOptions) { SaveTestData(t, formatKubectlOptionsPath(testFolder), true, kubectlOptions) } // LoadKubectlOptions loads and unserializes a KubectlOptions from the given folder. This allows you to reuse a KubectlOptions that was // created during an earlier setup step in later validation and teardown steps. func LoadKubectlOptions(t testing.TestingT, testFolder string) *k8s.KubectlOptions { var kubectlOptions k8s.KubectlOptions LoadTestData(t, formatKubectlOptionsPath(testFolder), &kubectlOptions) return &kubectlOptions } // formatKubectlOptionsPath formats a path to save a KubectlOptions in the given folder. func formatKubectlOptionsPath(testFolder string) string { return FormatTestDataPath(testFolder, "KubectlOptions.json") } // SaveString serializes and saves a uniquely named string value into the given folder. This allows you to create one or more string // values during one stage -- each with a unique name -- and to reuse those values during later stages. func SaveString(t testing.TestingT, testFolder string, name string, val string) { path := formatNamedTestDataPath(testFolder, name) SaveTestData(t, path, true, val) } // LoadString loads and unserializes a uniquely named string value from the given folder. This allows you to reuse one or more string // values that were created during an earlier setup step in later steps. func LoadString(t testing.TestingT, testFolder string, name string) string { var val string LoadTestData(t, formatNamedTestDataPath(testFolder, name), &val) return val } // SaveInt saves a uniquely named int value into the given folder. This allows you to create one or more int // values during one stage -- each with a unique name -- and to reuse those values during later stages. func SaveInt(t testing.TestingT, testFolder string, name string, val int) { path := formatNamedTestDataPath(testFolder, name) SaveTestData(t, path, true, val) } // LoadInt loads a uniquely named int value from the given folder. This allows you to reuse one or more int // values that were created during an earlier setup step in later steps. func LoadInt(t testing.TestingT, testFolder string, name string) int { var val int LoadTestData(t, formatNamedTestDataPath(testFolder, name), &val) return val } // SaveArtifactID serializes and saves an Artifact ID into the given folder. This allows you to build an Artifact during setup and to reuse that // Artifact later during validation and teardown. func SaveArtifactID(t testing.TestingT, testFolder string, artifactID string) { SaveString(t, testFolder, "Artifact", artifactID) } // LoadArtifactID loads and unserializes an Artifact ID from the given folder. This allows you to reuse an Artifact that was created during an // earlier setup step in later validation and teardown steps. func LoadArtifactID(t testing.TestingT, testFolder string) string { return LoadString(t, testFolder, "Artifact") } // SaveAmiId serializes and saves an AMI ID into the given folder. This allows you to build an AMI during setup and to reuse that // AMI later during validation and teardown. // // Deprecated: Use SaveArtifactID instead. func SaveAmiId(t testing.TestingT, testFolder string, amiId string) { SaveString(t, testFolder, "AMI", amiId) } // LoadAmiId loads and unserializes an AMI ID from the given folder. This allows you to reuse an AMI that was created during an // earlier setup step in later validation and teardown steps. // // Deprecated: Use LoadArtifactID instead. func LoadAmiId(t testing.TestingT, testFolder string) string { return LoadString(t, testFolder, "AMI") } // formatNamedTestDataPath formats a path to save an arbitrary named value in the given folder. func formatNamedTestDataPath(testFolder string, name string) string { filename := fmt.Sprintf("%s.json", name) return FormatTestDataPath(testFolder, filename) } // FormatTestDataPath formats a path to save test data. func FormatTestDataPath(testFolder string, filename string) string { return filepath.Join(testFolder, ".test-data", filename) } // SaveTestData serializes and saves a value used at test time to the given path. This allows you to create some sort of test data // (e.g., TerraformOptions) during setup and to reuse this data later during validation and teardown. If `overwrite` is `true`, // any contents that exist in the file found at `path` will be overwritten. This has the potential for causing duplicated resources // and should be used with caution. If `overwrite` is `false`, the save will be skipped and a warning will be logged. func SaveTestData(t testing.TestingT, path string, overwrite bool, value interface{}) { saveTestData(t, path, overwrite, value, true) } // saveTestData serializes and saves a value used at test time to the given path. This allows you to create some sort of test data // (e.g., TerraformOptions) during setup and to reuse this data later during validation and teardown. If `overwrite` is `true`, // any contents that exist in the file found at `path` will be overwritten. This has the potential for causing duplicated resources // and should be used with caution. If `overwrite` is `false`, the save will be skipped and a warning will be logged. // If `loggedVal` is `true`, the value will be logged as JSON. func saveTestData(t testing.TestingT, path string, overwrite bool, value interface{}, loggedVal bool) { logger.Default.Logf(t, "Storing test data in %s so it can be reused later", path) if IsTestDataPresent(t, path) { if overwrite { logger.Default.Logf(t, "[WARNING] The named test data at path %s is non-empty. Save operation will overwrite existing value with \"%v\".\n.", path, value) } else { logger.Default.Logf(t, "[WARNING] The named test data at path %s is non-empty. Skipping save operation to prevent overwriting existing value with \"%v\".\n.", path, value) return } } bytes, err := json.Marshal(value) if err != nil { t.Fatalf("Failed to convert value %s to JSON: %v", path, err) } if loggedVal { logger.Default.Logf(t, "Marshalled JSON: %s", string(bytes)) } parentDir := filepath.Dir(path) if err := os.MkdirAll(parentDir, 0755); err != nil { t.Fatalf("Failed to create folder %s: %v", parentDir, err) } if err := os.WriteFile(path, bytes, 0644); err != nil { t.Fatalf("Failed to save value %s: %v", path, err) } } // LoadTestData loads and unserializes a value stored at the given path. The value should be a pointer to a struct into which the // value will be deserialized. This allows you to reuse some sort of test data (e.g., TerraformOptions) from earlier // setup steps in later validation and teardown steps. func LoadTestData(t testing.TestingT, path string, value interface{}) { logger.Default.Logf(t, "Loading test data from %s", path) bytes, err := os.ReadFile(path) if err != nil { t.Fatalf("Failed to load value from %s: %v", path, err) } if err := json.Unmarshal(bytes, value); err != nil { t.Fatalf("Failed to parse JSON for value %s: %v", path, err) } } // IsTestDataPresent returns true if a file exists at $path and the test data there is non-empty. func IsTestDataPresent(t testing.TestingT, path string) bool { exists, err := files.FileExistsE(path) if err != nil { t.Fatalf("Failed to load test data from %s due to unexpected error: %v", path, err) } if !exists { return false } bytes, err := os.ReadFile(path) if err != nil { t.Fatalf("Failed to load test data from %s due to unexpected error: %v", path, err) } if isEmptyJSON(t, bytes) { return false } return true } // isEmptyJSON returns true if the given bytes are empty, or in a valid JSON format that can reasonably be considered empty. // The types used are based on the type possibilities listed at https://golang.org/src/encoding/json/decode.go?s=4062:4110#L51 func isEmptyJSON(t testing.TestingT, bytes []byte) bool { var value interface{} if len(bytes) == 0 { return true } if err := json.Unmarshal(bytes, &value); err != nil { t.Fatalf("Failed to parse JSON while testing whether it is empty: %v", err) } if value == nil { return true } valueBool, ok := value.(bool) if ok && !valueBool { return true } valueFloat64, ok := value.(float64) if ok && valueFloat64 == 0 { return true } valueString, ok := value.(string) if ok && valueString == "" { return true } valueSlice, ok := value.([]interface{}) if ok && len(valueSlice) == 0 { return true } valueMap, ok := value.(map[string]interface{}) if ok && len(valueMap) == 0 { return true } return false } // CleanupTestData cleans up the test data at the given path. func CleanupTestData(t testing.TestingT, path string) { if files.FileExists(path) { logger.Default.Logf(t, "Cleaning up test data from %s", path) if err := os.Remove(path); err != nil { t.Fatalf("Failed to clean up file at %s: %v", path, err) } } else { logger.Default.Logf(t, "%s does not exist. Nothing to cleanup.", path) } } // CleanupTestDataFolder cleans up the .test-data folder inside the given folder. // If there are any errors, fail the test. func CleanupTestDataFolder(t testing.TestingT, path string) { err := CleanupTestDataFolderE(t, path) require.NoError(t, err) } // CleanupTestDataFolderE cleans up the .test-data folder inside the given folder. func CleanupTestDataFolderE(t testing.TestingT, path string) error { path = filepath.Join(path, ".test-data") exists, err := files.FileExistsE(path) if err != nil { logger.Default.Logf(t, "Failed to clean up test data folder at %s: %v", path, err) return err } if !exists { logger.Default.Logf(t, "%s does not exist. Nothing to cleanup.", path) return nil } if err := os.RemoveAll(path); err != nil { logger.Default.Logf(t, "Failed to clean up test data folder at %s: %v", path, err) return err } return nil } ================================================ FILE: modules/test-structure/save_test_data_test.go ================================================ package test_structure import ( "encoding/json" "fmt" "os" "strings" "testing" "github.com/gruntwork-io/terratest/modules/aws" "github.com/gruntwork-io/terratest/modules/files" "github.com/gruntwork-io/terratest/modules/k8s" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/ssh" "github.com/gruntwork-io/terratest/modules/terraform" gotesting "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type testData struct { Foo string Bar bool Baz map[string]interface{} } func TestSaveAndLoadTestData(t *testing.T) { t.Parallel() isTestDataPresent := IsTestDataPresent(t, "/file/that/does/not/exist") assert.False(t, isTestDataPresent, "Expected no test data would be present because no test data file exists.") tmpFile, err := os.CreateTemp("", "save-and-load-test-data") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } expectedData := testData{ Foo: "foo", Bar: true, Baz: map[string]interface{}{"abc": "def", "ghi": 1.0, "klm": false}, } isTestDataPresent = IsTestDataPresent(t, tmpFile.Name()) assert.False(t, isTestDataPresent, "Expected no test data would be present because file exists but no data has been written yet.") overwrite := true SaveTestData(t, tmpFile.Name(), overwrite, expectedData) isTestDataPresent = IsTestDataPresent(t, tmpFile.Name()) assert.True(t, isTestDataPresent, "Expected test data would be present because file exists and data has been written to file.") actualData := testData{} LoadTestData(t, tmpFile.Name(), &actualData) assert.Equal(t, expectedData, actualData) overwritingData := testData{ Foo: "foo", Bar: false, Baz: map[string]interface{}{"123": "456", "789": 1.0, "0": false}, } SaveTestData(t, tmpFile.Name(), !overwrite, overwritingData) LoadTestData(t, tmpFile.Name(), &actualData) assert.Equal(t, expectedData, actualData) CleanupTestData(t, tmpFile.Name()) assert.False(t, files.FileExists(tmpFile.Name())) } func TestIsEmptyJson(t *testing.T) { t.Parallel() var jsonValue []byte var isEmpty bool jsonValue = []byte("null") isEmpty = isEmptyJSON(t, jsonValue) assert.True(t, isEmpty, `The JSON literal "null" should be treated as an empty value.`) jsonValue = []byte("false") isEmpty = isEmptyJSON(t, jsonValue) assert.True(t, isEmpty, `The JSON literal "false" should be treated as an empty value.`) jsonValue = []byte("true") isEmpty = isEmptyJSON(t, jsonValue) assert.False(t, isEmpty, `The JSON literal "true" should be treated as a non-empty value.`) jsonValue = []byte("0") isEmpty = isEmptyJSON(t, jsonValue) assert.True(t, isEmpty, `The JSON literal "0" should be treated as an empty value.`) jsonValue = []byte("1") isEmpty = isEmptyJSON(t, jsonValue) assert.False(t, isEmpty, `The JSON literal "1" should be treated as a non-empty value.`) jsonValue = []byte("{}") isEmpty = isEmptyJSON(t, jsonValue) assert.True(t, isEmpty, `The JSON value "{}" should be treated as an empty value.`) jsonValue = []byte(`{ "key": "val" }`) isEmpty = isEmptyJSON(t, jsonValue) assert.False(t, isEmpty, `The JSON value { "key": "val" } should be treated as a non-empty value.`) jsonValue = []byte(`[]`) isEmpty = isEmptyJSON(t, jsonValue) assert.True(t, isEmpty, `The JSON value "[]" should be treated as an empty value.`) jsonValue = []byte(`[{ "key": "val" }]`) isEmpty = isEmptyJSON(t, jsonValue) assert.False(t, isEmpty, `The JSON value [{ "key": "val" }] should be treated as a non-empty value.`) } func TestSaveAndLoadTerraformOptions(t *testing.T) { t.Parallel() tmpFolder := t.TempDir() expectedData := &terraform.Options{ TerraformDir: "/abc/def/ghi", Vars: map[string]interface{}{}, } SaveTerraformOptions(t, tmpFolder, expectedData) actualData := LoadTerraformOptions(t, tmpFolder) assert.Equal(t, expectedData, actualData) } func TestSaveTerraformOptionsIfNotPresent(t *testing.T) { t.Parallel() tmpFolder := t.TempDir() expectedData := &terraform.Options{ TerraformDir: "/abc/def/ghi", Vars: map[string]interface{}{}, } SaveTerraformOptionsIfNotPresent(t, tmpFolder, expectedData) overwritingData := &terraform.Options{ TerraformDir: "/123/456/789", Vars: map[string]interface{}{}, } SaveTerraformOptionsIfNotPresent(t, tmpFolder, overwritingData) actualData := LoadTerraformOptions(t, tmpFolder) assert.Equal(t, expectedData, actualData) } func TestSaveTerraformOptionsOverwrite(t *testing.T) { t.Parallel() tmpFolder := t.TempDir() originaData := &terraform.Options{ TerraformDir: "/abc/def/ghi", Vars: map[string]interface{}{}, } SaveTerraformOptions(t, tmpFolder, originaData) overwritingData := &terraform.Options{ TerraformDir: "/123/456/789", Vars: map[string]interface{}{}, } SaveTerraformOptions(t, tmpFolder, overwritingData) actualData := LoadTerraformOptions(t, tmpFolder) assert.Equal(t, overwritingData, actualData) } func TestSaveAndLoadAmiId(t *testing.T) { t.Parallel() tmpFolder := t.TempDir() expectedData := "ami-abcd1234" SaveAmiId(t, tmpFolder, expectedData) actualData := LoadAmiId(t, tmpFolder) assert.Equal(t, expectedData, actualData) } func TestSaveAndLoadArtifactID(t *testing.T) { t.Parallel() tmpFolder := t.TempDir() expectedData := "terratest-packer-example-2018-08-08t15-35-19z" SaveArtifactID(t, tmpFolder, expectedData) actualData := LoadArtifactID(t, tmpFolder) assert.Equal(t, expectedData, actualData) } func TestSaveAndLoadNamedStrings(t *testing.T) { t.Parallel() tmpFolder := t.TempDir() name1 := "test-ami" expectedData1 := "ami-abcd1234" name2 := "test-ami2" expectedData2 := "ami-xyz98765" name3 := "test-image" expectedData3 := "terratest-packer-example-2018-08-08t15-35-19z" name4 := "test-image2" expectedData4 := "terratest-packer-example-2018-01-03t12-35-00z" SaveString(t, tmpFolder, name1, expectedData1) SaveString(t, tmpFolder, name2, expectedData2) SaveString(t, tmpFolder, name3, expectedData3) SaveString(t, tmpFolder, name4, expectedData4) actualData1 := LoadString(t, tmpFolder, name1) actualData2 := LoadString(t, tmpFolder, name2) actualData3 := LoadString(t, tmpFolder, name3) actualData4 := LoadString(t, tmpFolder, name4) assert.Equal(t, expectedData1, actualData1) assert.Equal(t, expectedData2, actualData2) assert.Equal(t, expectedData3, actualData3) assert.Equal(t, expectedData4, actualData4) } func TestSaveDuplicateTestData(t *testing.T) { t.Parallel() tmpFolder := t.TempDir() name := "hello-world" val1 := "hello world" val2 := "buenos dias, mundo" SaveString(t, tmpFolder, name, val1) SaveString(t, tmpFolder, name, val2) actualVal := LoadString(t, tmpFolder, name) assert.Equal(t, val2, actualVal, "Actual test data should use overwritten values") } func TestSaveAndLoadNamedInts(t *testing.T) { t.Parallel() tmpFolder := t.TempDir() name1 := "test-int1" expectedData1 := 23842834 name2 := "test-int2" expectedData2 := 52 SaveInt(t, tmpFolder, name1, expectedData1) SaveInt(t, tmpFolder, name2, expectedData2) actualData1 := LoadInt(t, tmpFolder, name1) actualData2 := LoadInt(t, tmpFolder, name2) assert.Equal(t, expectedData1, actualData1) assert.Equal(t, expectedData2, actualData2) } func TestSaveAndLoadKubectlOptions(t *testing.T) { t.Parallel() tmpFolder := t.TempDir() expectedData := &k8s.KubectlOptions{ ContextName: "terratest-context", ConfigPath: "~/.kube/config", Namespace: "default", Env: map[string]string{ "TERRATEST_ENV_VAR": "terratest", }, } SaveKubectlOptions(t, tmpFolder, expectedData) actualData := LoadKubectlOptions(t, tmpFolder) assert.Equal(t, expectedData, actualData) } type tStringLogger struct { sb strings.Builder } func (l *tStringLogger) Logf(t gotesting.TestingT, format string, args ...interface{}) { l.sb.WriteString(fmt.Sprintf(format, args...)) l.sb.WriteRune('\n') } func TestSaveAndLoadEC2KeyPair(t *testing.T) { def, slogger := logger.Default, &tStringLogger{} logger.Default = logger.New(slogger) t.Cleanup(func() { logger.Default = def }) keyPair, err := ssh.GenerateRSAKeyPairE(t, 2048) require.NoError(t, err) ec2KeyPair := &aws.Ec2Keypair{ KeyPair: keyPair, Name: "test-ec2-key-pair", Region: "us-east-1", } storedEC2KeyPair, err := json.Marshal(ec2KeyPair) require.NoError(t, err) tmpFolder := t.TempDir() SaveEc2KeyPair(t, tmpFolder, ec2KeyPair) loadedEC2KeyPair := LoadEc2KeyPair(t, tmpFolder) assert.Equal(t, ec2KeyPair, loadedEC2KeyPair) assert.NotContains(t, slogger.sb.String(), string(storedEC2KeyPair), "stored ec2 key pair should not be logged") } ================================================ FILE: modules/test-structure/test_structure.go ================================================ package test_structure import ( "fmt" "os" "path/filepath" "strings" "github.com/gruntwork-io/terratest/modules/git" go_test "testing" "github.com/gruntwork-io/terratest/modules/files" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/opa" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" ) // SKIP_STAGE_ENV_VAR_PREFIX is the prefix used for skipping stage environment variables. const SKIP_STAGE_ENV_VAR_PREFIX = "SKIP_" // RunTestStage executes the given test stage (e.g., setup, teardown, validation) if an environment variable of the name // `SKIP_` (e.g., SKIP_teardown) is not set. func RunTestStage(t testing.TestingT, stageName string, stage func()) { envVarName := fmt.Sprintf("%s%s", SKIP_STAGE_ENV_VAR_PREFIX, stageName) if os.Getenv(envVarName) == "" { logger.Default.Logf(t, "The '%s' environment variable is not set, so executing stage '%s'.", envVarName, stageName) stage() } else { logger.Default.Logf(t, "The '%s' environment variable is set, so skipping stage '%s'.", envVarName, stageName) } } // SkipStageEnvVarSet returns true if an environment variable is set instructing Terratest to skip a test stage. This can be an easy way // to tell if the tests are running in a local dev environment vs a CI server. func SkipStageEnvVarSet() bool { for _, environmentVariable := range os.Environ() { if strings.HasPrefix(environmentVariable, SKIP_STAGE_ENV_VAR_PREFIX) { return true } } return false } // CopyTerraformFolderToTemp copies the given root folder to a randomly-named temp folder and return the path to the // given terraform modules folder within the new temp root folder. This is useful when running multiple tests in // parallel against the same set of Terraform files to ensure the tests don't overwrite each other's .terraform working // directory and terraform.tfstate files. To ensure relative paths work, we copy over the entire root folder to a temp // folder, and then return the path within that temp folder to the given terraform module dir, which is where the actual // test will be running. // For example, suppose you had the target terraform folder you want to test in "/examples/terraform-aws-example" // relative to the repo root. If your tests reside in the "/test" relative to the root, then you will use this as // follows: // // // Root folder where terraform files should be (relative to the test folder) // rootFolder := ".." // // // Relative path to terraform module being tested from the root folder // terraformFolderRelativeToRoot := "examples/terraform-aws-example" // // // Copy the terraform folder to a temp folder // tempTestFolder := test_structure.CopyTerraformFolderToTemp(t, rootFolder, terraformFolderRelativeToRoot) // // // Make sure to use the temp test folder in the terraform options // terraformOptions := &terraform.Options{ // TerraformDir: tempTestFolder, // } // // Note that if any of the SKIP_ environment variables is set, we assume this is a test in the local dev where // there are no other concurrent tests running and we want to be able to cache test data between test stages, so in that // case, we do NOT copy anything to a temp folder, and return the path to the original terraform module folder instead. func CopyTerraformFolderToTemp(t testing.TestingT, rootFolder string, terraformModuleFolder string) string { return CopyTerraformFolderToDest(t, rootFolder, terraformModuleFolder, os.TempDir()) } // CopyTerraformFolderToDest copies the given root folder to a randomly-named temp folder and return the path to the // given terraform modules folder within the new temp root folder. This is useful when running multiple tests in // parallel against the same set of Terraform files to ensure the tests don't overwrite each other's .terraform working // directory and terraform.tfstate files. To ensure relative paths work, we copy over the entire root folder to a temp // folder, and then return the path within that temp folder to the given terraform module dir, which is where the actual // test will be running. // For example, suppose you had the target terraform folder you want to test in "/examples/terraform-aws-example" // relative to the repo root. If your tests reside in the "/test" relative to the root, then you will use this as // follows: // // // Destination for the copy of the files. In this example we are using the Azure Dev Ops variable // // for the folder that is cleaned after each pipeline job. // destRootFolder := os.Getenv("AGENT_TEMPDIRECTORY") // // // Root folder where terraform files should be (relative to the test folder) // rootFolder := ".." // // // Relative path to terraform module being tested from the root folder // terraformFolderRelativeToRoot := "examples/terraform-aws-example" // // // Copy the terraform folder to a temp folder // tempTestFolder := test_structure.CopyTerraformFolderToTemp(t, rootFolder, terraformFolderRelativeToRoot, destRootFolder) // // // Make sure to use the temp test folder in the terraform options // terraformOptions := &terraform.Options{ // TerraformDir: tempTestFolder, // } // // Note that if any of the SKIP_ environment variables is set, we assume this is a test in the local dev where // there are no other concurrent tests running and we want to be able to cache test data between test stages, so in that // case, we do NOT copy anything to a temp folder, and return the path to the original terraform module folder instead. func CopyTerraformFolderToDest(t testing.TestingT, rootFolder string, terraformModuleFolder string, destRootFolder string) string { if SkipStageEnvVarSet() { logger.Default.Logf(t, "A SKIP_XXX environment variable is set. Using original examples folder rather than a temp folder so we can cache data between stages for faster local testing.") return filepath.Join(rootFolder, terraformModuleFolder) } fullTerraformModuleFolder := filepath.Join(rootFolder, terraformModuleFolder) exists, err := files.FileExistsE(fullTerraformModuleFolder) require.NoError(t, err) if !exists { t.Fatal(files.DirNotFoundError{Directory: fullTerraformModuleFolder}) } tmpRootFolder, err := files.CopyTerraformFolderToDest(rootFolder, destRootFolder, cleanName(t.Name())) if err != nil { t.Fatal(err) } tmpTestFolder := filepath.Join(tmpRootFolder, terraformModuleFolder) // Log temp folder so we can see it logger.Default.Logf(t, "Copied terraform folder %s to %s", fullTerraformModuleFolder, tmpTestFolder) return tmpTestFolder } func cleanName(originalName string) string { parts := strings.Split(originalName, "/") return parts[len(parts)-1] } // ValidateAllTerraformModules automatically finds all folders specified in RootDir that contain .tf files and runs // InitAndValidate in all of them. // Filters down to only those paths passed in ValidationOptions.IncludeDirs, if passed. // Excludes any folders specified in the ValidationOptions.ExcludeDirs. IncludeDirs will take precedence over ExcludeDirs // Use the NewValidationOptions method to pass relative paths for either of these options to have the full paths built // Note that go_test is an alias to Golang's native testing package created to avoid naming conflicts with Terratest's // own testing package. We are using the native testing.T here because Terratest's testing.T struct does not implement Run // Note that we have opted to place the ValidateAllTerraformModules function here instead of in the terraform package // to avoid import cycling func ValidateAllTerraformModules(t *go_test.T, opts *ValidationOptions) { runValidateOnAllTerraformModules( t, opts, func(t *go_test.T, _ ValidateFileType, tfOpts *terraform.Options) { terraform.InitAndValidate(t, tfOpts) }, ) } // OPAEvalAllTerraformModules automatically finds all folders specified in RootDir that contain .tf files and runs // OPAEval in all of them. The behavior of this function is similar to ValidateAllTerraformModules. Refer to the docs of // that function for more details. func OPAEvalAllTerraformModules( t *go_test.T, opts *ValidationOptions, opaEvalOpts *opa.EvalOptions, resultQuery string, ) { if opts.FileType != TF { t.Fatalf("OPAEvalAllTerraformModules currently only works with Terraform modules") } runValidateOnAllTerraformModules( t, opts, func(t *go_test.T, _ ValidateFileType, tfOpts *terraform.Options) { terraform.OPAEval(t, tfOpts, opaEvalOpts, resultQuery) }, ) } // runValidateOnAllTerraformModules main driver for ValidateAllTerraformModules and OPAEvalAllTerraformModules. Refer to // the function docs of ValidateAllTerraformModules for more details. func runValidateOnAllTerraformModules( t *go_test.T, opts *ValidationOptions, validationFunc func(t *go_test.T, fileType ValidateFileType, tfOps *terraform.Options), ) { // Find the Git root gitRoot, err := git.GetRepoRootForDirE(t, opts.RootDir) require.NoError(t, err) // Find the relative path between the root dir and the git root relPath, err := filepath.Rel(gitRoot, opts.RootDir) require.NoError(t, err) // Copy git root to tmp testFolder := CopyTerraformFolderToTemp(t, gitRoot, relPath) require.NotNil(t, testFolder) // Clone opts and override the root dir to the temp folder clonedOpts, err := CloneWithNewRootDir(opts, testFolder) require.NoError(t, err) // Find TF modules dirsToValidate, readErr := FindTerraformModulePathsInRootE(clonedOpts) require.NoError(t, readErr) for _, dir := range dirsToValidate { dir := dir t.Run(strings.TrimLeft(dir, "/"), func(t *go_test.T) { // Run the validation function on the test folder that was copied to /tmp to avoid any potential conflicts // with tests that may not use the same copy to /tmp behavior tfOpts := &terraform.Options{TerraformDir: dir} validationFunc(t, clonedOpts.FileType, tfOpts) }) } } ================================================ FILE: modules/test-structure/test_structure_test.go ================================================ package test_structure import ( "os" "path/filepath" "slices" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestCopyToTempFolder(t *testing.T) { tempFolder := CopyTerraformFolderToTemp(t, "../../", "examples") t.Log(tempFolder) } func TestCopySubtestToTempFolder(t *testing.T) { t.Run("Subtest", func(t *testing.T) { tempFolder := CopyTerraformFolderToTemp(t, "../../", "examples") t.Log(tempFolder) }) } // TestValidateAllTerraformModulesSucceedsOnValidTerraform points at a simple text fixture Terraform module that is // known to be valid func TestValidateAllTerraformModulesSucceedsOnValidTerraform(t *testing.T) { cwd, err := os.Getwd() require.NoError(t, err) // Use the test fixtures directory as the RootDir for ValidationOptions projectRootDir := filepath.Join(cwd, "../../test/fixtures") opts, optsErr := NewValidationOptions(projectRootDir, []string{"terraform-validation-valid"}, []string{}) require.NoError(t, optsErr) ValidateAllTerraformModules(t, opts) } func TestNewValidationOptionsRejectsEmptyRootDir(t *testing.T) { _, err := NewValidationOptions("", []string{}, []string{}) require.Error(t, err) } func TestFindTerraformModulePathsInRootEExamples(t *testing.T) { cwd, cwdErr := os.Getwd() require.NoError(t, cwdErr) opts, optsErr := NewValidationOptions(filepath.Join(cwd, "../../"), []string{}, []string{}) require.NoError(t, optsErr) subDirs, err := FindTerraformModulePathsInRootE(opts) require.NoError(t, err) // There are many valid Terraform modules in the root/examples directory of the Terratest project, so we should get back many results require.Greater(t, len(subDirs), 0) } // This test calls ValidateAllTerraformModules on the Terratest root directory func TestValidateAllTerraformModulesOnTerratest(t *testing.T) { cwd, err := os.Getwd() require.NoError(t, err) projectRootDir := filepath.Join(cwd, "../..") opts, optsErr := NewValidationOptions(projectRootDir, []string{}, []string{ "test/fixtures/terraform-with-plan-error", "modules/terragrunt/testdata/terragrunt-with-plan-error", "examples/terraform-backend-example", }) require.NoError(t, optsErr) ValidateAllTerraformModules(t, opts) } // Verify ExcludeDirs is working properly, by explicitly passing a list of two test fixture modules to exclude // and ensuring at the end that they do not appear in the returned slice of sub directories to validate // Then, re-run the function with no exclusions and ensure the excluded paths ARE returned in the result set when no // exclusions are passed func TestFindTerraformModulePathsInRootEWithResultsExclusion(t *testing.T) { cwd, cwdErr := os.Getwd() require.NoError(t, cwdErr) projectRootDir := filepath.Join(cwd, "../..") // First, call the FindTerraformModulePathsInRootE method with several exclusions exclusions := []string{ filepath.Join("test", "fixtures", "terraform-output"), filepath.Join("test", "fixtures", "terraform-output-map"), } opts, optsErr := NewValidationOptions(projectRootDir, []string{}, exclusions) require.NoError(t, optsErr) subDirs, err := FindTerraformModulePathsInRootE(opts) require.NoError(t, err) require.Greater(t, len(subDirs), 0) // Ensure none of the excluded paths were returned by FindTerraformModulePathsInRootE for _, exclusion := range exclusions { assert.False(t, slices.Contains(subDirs, filepath.Join(projectRootDir, exclusion))) } // Next, call the same function but this time without exclusions and ensure that the excluded paths // exist in the non-excluded result set optsWithoutExclusions, optswoErr := NewValidationOptions(projectRootDir, []string{}, []string{}) require.NoError(t, optswoErr) subDirsWithoutExclusions, woExErr := FindTerraformModulePathsInRootE(optsWithoutExclusions) require.NoError(t, woExErr) require.Greater(t, len(subDirsWithoutExclusions), 0) for _, exclusion := range exclusions { assert.True(t, slices.Contains(subDirsWithoutExclusions, filepath.Join(projectRootDir, exclusion))) } } ================================================ FILE: modules/test-structure/validate_struct.go ================================================ package test_structure import ( "fmt" "path" "path/filepath" go_commons_collections "github.com/gruntwork-io/go-commons/collections" "github.com/gruntwork-io/terratest/modules/collections" "github.com/gruntwork-io/terratest/modules/files" "github.com/mattn/go-zglob" ) // ValidateFileType is the underlying module type to search for when performing validation. type ValidateFileType string const ( // TF represents repositories that contain Terraform code TF = "*.tf" ) // ValidationOptions represent the configuration for a given validation sweep of a target repo type ValidationOptions struct { // The target directory to recursively search for all Terraform directories (those that contain .tf files) // If you provide RootDir and do not pass entries in either IncludeDirs or ExcludeDirs, then all Terraform directories // From the RootDir, recursively, will be validated RootDir string FileType ValidateFileType // If you only want to include certain sub directories of RootDir, add the absolute paths here. For example, if the // RootDir is /home/project and you want to only include /home/project/examples, add /home/project/examples here // Note that while the struct requires full paths, you can pass relative paths to the NewValidationOptions function // which will build the full paths based on the supplied RootDir IncludeDirs []string // If you want to explicitly exclude certain sub directories of RootDir, add the absolute paths here. For example, if the // RootDir is /home/project and you want to include everything EXCEPT /home/project/modules, add // /home/project/modules to this slice. Note that ExcludeDirs is only considered when IncludeDirs is not passed // Note that while the struct requires full paths, you can pass relative paths to the NewValidationOptions function // which will build the full paths based on the supplied RootDir ExcludeDirs []string } // CloneWithNewRootDir clones the given opts with a new root dir. Updates all include and exclude dirs to be relative // to the new root dir. func CloneWithNewRootDir(opts *ValidationOptions, newRootDir string) (*ValidationOptions, error) { includeDirs, err := buildRelPathsFromFull(opts.RootDir, opts.IncludeDirs) if err != nil { return nil, err } excludeDirs, err := buildRelPathsFromFull(opts.RootDir, opts.ExcludeDirs) if err != nil { return nil, err } out, err := NewValidationOptions(newRootDir, includeDirs, excludeDirs) if err != nil { return nil, err } out.FileType = opts.FileType return out, nil } // configureBaseValidationOptions returns a pointer to a ValidationOptions struct configured with sane, override-able defaults // Note that the ValidationOptions's fields IncludeDirs and ExcludeDirs must be absolute paths, but this method will accept relative paths // and build the absolute paths when instantiating the ValidationOptions struct, making it the preferred means of configuring // ValidationOptions. // // For example, if your RootDir is /home/project/ and you want to exclude "modules" and "test" you need // only pass the relative paths in your excludeDirs slice like so: // opts, err := NewValidationOptions("/home/project", []string{}, []string{"modules", "test"}) func configureBaseValidationOptions(rootDir string, includeDirs, excludeDirs []string) (*ValidationOptions, error) { vo := &ValidationOptions{ RootDir: "", IncludeDirs: []string{}, ExcludeDirs: []string{}, } if rootDir == "" { return nil, ValidationUndefinedRootDirErr{} } if !filepath.IsAbs(rootDir) { rootDirAbs, err := filepath.Abs(rootDir) if err != nil { return nil, ValidationAbsolutePathErr{rootDir: rootDir} } rootDir = rootDirAbs } vo.RootDir = filepath.Clean(rootDir) if len(includeDirs) > 0 { vo.IncludeDirs = buildFullPathsFromRelative(vo.RootDir, includeDirs) } if len(excludeDirs) > 0 { vo.ExcludeDirs = buildFullPathsFromRelative(vo.RootDir, excludeDirs) } return vo, nil } // NewValidationOptions returns a ValidationOptions struct, with override-able sane defaults, configured to find // and process all directories containing .tf files func NewValidationOptions(rootDir string, includeDirs, excludeDirs []string) (*ValidationOptions, error) { opts, err := configureBaseValidationOptions(rootDir, includeDirs, excludeDirs) if err != nil { return opts, err } opts.FileType = TF return opts, nil } func buildRelPathsFromFull(rootDir string, fullPaths []string) ([]string, error) { var relPaths []string for _, maybeFullPath := range fullPaths { if filepath.IsAbs(maybeFullPath) { relPath, err := filepath.Rel(rootDir, maybeFullPath) if err != nil { return nil, err } relPaths = append(relPaths, relPath) } else { relPaths = append(relPaths, maybeFullPath) } } return relPaths, nil } func buildFullPathsFromRelative(rootDir string, relativePaths []string) []string { var fullPaths []string for _, maybeRelativePath := range relativePaths { // If the relativePath is already an absolute path, don't modify it if filepath.IsAbs(maybeRelativePath) { fullPaths = append(fullPaths, filepath.Clean(maybeRelativePath)) } else { fullPaths = append(fullPaths, filepath.Clean(filepath.Join(rootDir, maybeRelativePath))) } } return fullPaths } // FindTerraformModulePathsInRootE returns a slice strings representing the filepaths for all valid Terraform // modules in the given RootDir, subject to the include / exclude filters. func FindTerraformModulePathsInRootE(opts *ValidationOptions) ([]string, error) { // Find all Terraform files (as specified by opts.FileType) from the configured RootDir pattern := fmt.Sprintf("%s/**/%s", opts.RootDir, opts.FileType) matches, err := zglob.Glob(pattern) if err != nil { return matches, err } // Keep a unique set of the base dirs that contain Terraform files terraformDirSet := make(map[string]string) for _, match := range matches { // The glob match returns all full paths to every target file, whereas we're only interested in their root // directories for the purposes of running Terraform validate rootDir := path.Dir(match) // Don't include hidden .terraform directories when finding paths to validate if !files.PathContainsHiddenFileOrFolder(rootDir) { terraformDirSet[rootDir] = "exists" } } // Retrieve just the unique paths to each Terraform module directory from the map we're using as a set terraformDirs := go_commons_collections.Keys(terraformDirSet) if len(opts.IncludeDirs) > 0 { terraformDirs = collections.ListIntersection(terraformDirs, opts.IncludeDirs) } if len(opts.ExcludeDirs) > 0 { terraformDirs = collections.ListSubtract(terraformDirs, opts.ExcludeDirs) } // Filter out any filepaths that were explicitly included in opts.ExcludeDirs return terraformDirs, nil } // Custom error types // ValidationAbsolutePathErr is returned when NewValidationOptions was unable to convert a non-absolute RootDir to // an absolute path type ValidationAbsolutePathErr struct { rootDir string } func (e ValidationAbsolutePathErr) Error() string { return fmt.Sprintf("Could not convert RootDir: %s to absolute path", e.rootDir) } // ValidationUndefinedRootDirErr is returned when NewValidationOptions is called without a RootDir argument type ValidationUndefinedRootDirErr struct{} func (e ValidationUndefinedRootDirErr) Error() string { return "RootDir must be defined in ValidationOptions passed to ValidateAllTerraformModules" } ================================================ FILE: modules/testing/types.go ================================================ // Package testing provides the TestingT interface used throughout Terratest. package testing // TestingT is an interface that describes the implementation of the testing object // that the majority of Terratest functions accept as first argument. // Using an interface that describes testing.T instead of the actual implementation // makes terratest usable in a wider variety of contexts (e.g. use with ginkgo : https://godoc.org/github.com/onsi/ginkgo#GinkgoT) type TestingT interface { // Fail marks the function as having failed but continues execution. Fail() // FailNow marks the function as having failed and stops its execution // by calling runtime.Goexit (which then runs all deferred calls in the // current goroutine). // Execution will continue at the next test or benchmark. // FailNow must be called from the goroutine running the // test or benchmark function, not from other goroutines // created during the test. Calling FailNow does not stop // those other goroutines. FailNow() Fatal(args ...interface{}) // Fatalf is equivalent to Logf followed by FailNow. Fatalf(format string, args ...interface{}) // Error is equivalent to Log followed by Fail. Error(args ...interface{}) // Errorf is equivalent to Logf followed by Fail. Errorf(format string, args ...interface{}) // Name returns the name of the running test or benchmark. Name() string } ================================================ FILE: modules/version-checker/errors.go ================================================ package version_checker // VersionMismatchErr is an error to indicate version mismatch. type VersionMismatchErr struct { errorMessage string } func (r *VersionMismatchErr) Error() string { return r.errorMessage } ================================================ FILE: modules/version-checker/version_checker.go ================================================ package version_checker import ( "fmt" "regexp" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/gruntwork-io/terratest/modules/shell" "github.com/gruntwork-io/terratest/modules/testing" "github.com/hashicorp/go-version" "github.com/stretchr/testify/require" ) // VersionCheckerBinary is an enum for supported version checking. type VersionCheckerBinary int // List of binaries supported for version checking. const ( Docker VersionCheckerBinary = iota Terraform Packer ) const ( // versionRegexMatcher is a regex used to extract version string from shell command output. versionRegexMatcher = `\d+(\.\d+)+` // defaultVersionArg is a default arg to pass in to get version output from shell command. defaultVersionArg = "--version" ) type CheckVersionParams struct { // BinaryPath is a path to the binary you want to check the version for. BinaryPath string // Binary is the name of the binary you want to check the version for. Binary VersionCheckerBinary // VersionConstraint is a string literal containing one or more conditions, which are separated by commas. // More information here:https://www.terraform.io/language/expressions/version-constraints VersionConstraint string // WorkingDir is a directory you want to run the shell command. WorkingDir string } // CheckVersionE checks whether the given Binary version is greater than or equal // to the given expected version. func CheckVersionE( t testing.TestingT, params CheckVersionParams) error { if err := validateParams(params); err != nil { return err } binaryVersion, err := getVersionWithShellCommand(t, params) if err != nil { return err } return checkVersionConstraint(binaryVersion, params.VersionConstraint) } // CheckVersion checks whether the given Binary version is greater than or equal to the // given expected version and fails if it's not. func CheckVersion( t testing.TestingT, params CheckVersionParams) { require.NoError(t, CheckVersionE(t, params)) } // Validate whether the given params contains valid data to check version. func validateParams(params CheckVersionParams) error { // Check for empty parameters if params.WorkingDir == "" { return fmt.Errorf("set WorkingDir in params") } else if params.VersionConstraint == "" { return fmt.Errorf("set VersionConstraint in params") } // Check the format of the version constraint if present. if _, err := version.NewConstraint(params.VersionConstraint); params.VersionConstraint != "" && err != nil { return fmt.Errorf( "invalid version constraint format found {%s}", params.VersionConstraint) } return nil } // getVersionWithShellCommand get version by running a shell command. func getVersionWithShellCommand(t testing.TestingT, params CheckVersionParams) (string, error) { var versionArg = defaultVersionArg binary, err := getBinary(params) if err != nil { return "", err } // Run a shell command to get the version string. output, err := shell.RunCommandAndGetOutputE(t, shell.Command{ Command: binary, Args: []string{versionArg}, WorkingDir: params.WorkingDir, Env: map[string]string{}, }) if err != nil { return "", fmt.Errorf("failed to run shell command for Binary {%s} "+ "w/ version args {%s}: %w", binary, versionArg, err) } versionStr, err := extractVersionFromShellCommandOutput(output) if err != nil { return "", fmt.Errorf("failed to extract version from shell "+ "command output {%s}: %w", output, err) } return versionStr, nil } // getBinary retrieves the binary to use from the given params. func getBinary(params CheckVersionParams) (string, error) { // Use BinaryPath if it is set, otherwise use the binary enum. if params.BinaryPath != "" { return params.BinaryPath, nil } switch params.Binary { case Docker: return "docker", nil case Packer: return "packer", nil case Terraform: return terraform.DefaultExecutable, nil default: return "", fmt.Errorf("unsupported Binary for checking versions {%d}", params.Binary) } } // extractVersionFromShellCommandOutput extracts version with regex string matching // from the given shell command output string. func extractVersionFromShellCommandOutput(output string) (string, error) { regexMatcher := regexp.MustCompile(versionRegexMatcher) versionStr := regexMatcher.FindString(output) if versionStr == "" { return "", fmt.Errorf("failed to find version using regex matcher") } return versionStr, nil } // checkVersionConstraint checks whether the given version pass the version constraint. // // It returns Error for ill-formatted version string and VersionMismatchErr for // minimum version check failure. // // checkVersionConstraint(t, "1.2.31", ">= 1.2.0, < 2.0.0") - no error // checkVersionConstraint(t, "1.0.31", ">= 1.2.0, < 2.0.0") - error func checkVersionConstraint(actualVersionStr string, versionConstraintStr string) error { actualVersion, err := version.NewVersion(actualVersionStr) if err != nil { return fmt.Errorf("invalid version format found for actualVersionStr: %s", actualVersionStr) } versionConstraint, err := version.NewConstraint(versionConstraintStr) if err != nil { return fmt.Errorf("invalid version format found for versionConstraint: %s", versionConstraintStr) } if !versionConstraint.Check(actualVersion) { return &VersionMismatchErr{ errorMessage: fmt.Sprintf("actual version {%s} failed "+ "the version constraint {%s}", actualVersionStr, versionConstraint), } } return nil } ================================================ FILE: modules/version-checker/version_checker_test.go ================================================ package version_checker import ( "testing" "github.com/stretchr/testify/require" ) func TestParamValidation(t *testing.T) { t.Parallel() tests := []struct { name string param CheckVersionParams containError bool expectedErrorMessage string }{ { name: "Empty Params", param: CheckVersionParams{}, containError: true, expectedErrorMessage: "set WorkingDir in params", }, { name: "Missing VersionConstraint", param: CheckVersionParams{ Binary: Docker, VersionConstraint: "", WorkingDir: ".", }, containError: true, expectedErrorMessage: "set VersionConstraint in params", }, { name: "Invalid Version Constraint Format", param: CheckVersionParams{ Binary: Docker, VersionConstraint: "abc", WorkingDir: ".", }, containError: true, expectedErrorMessage: "invalid version constraint format found {abc}", }, { name: "Success", param: CheckVersionParams{ Binary: Docker, VersionConstraint: ">1.2.3", WorkingDir: ".", }, containError: false, expectedErrorMessage: "", }, } for _, tc := range tests { err := validateParams(tc.param) if tc.containError { require.EqualError(t, err, tc.expectedErrorMessage, tc.name) } else { require.NoError(t, err, tc.name) } } } func TestExtractVersionFromShellCommandOutput(t *testing.T) { t.Parallel() tests := []struct { name string outputStr string expectedVersionStr string containError bool expectedErrorMessage string }{ { name: "Stand-alone version string", outputStr: "version is 1.2.3", expectedVersionStr: "1.2.3", containError: false, expectedErrorMessage: "", }, { name: "version string with v prefix", outputStr: "version is v1.0.0", expectedVersionStr: "1.0.0", containError: false, expectedErrorMessage: "", }, { name: "2 digit version string", outputStr: "version is v1.0", expectedVersionStr: "1.0", containError: false, expectedErrorMessage: "", }, { name: "invalid output string", outputStr: "version is vabc", expectedVersionStr: "", containError: true, expectedErrorMessage: "failed to find version using regex matcher", }, { name: "empty output string", outputStr: "", expectedVersionStr: "", containError: true, expectedErrorMessage: "failed to find version using regex matcher", }, } for _, tc := range tests { versionStr, err := extractVersionFromShellCommandOutput(tc.outputStr) if tc.containError { require.EqualError(t, err, tc.expectedErrorMessage, tc.name) } else { require.NoError(t, err, tc.name) require.Equal(t, tc.expectedVersionStr, versionStr, tc.name) } } } func TestCheckVersionConstraint(t *testing.T) { t.Parallel() tests := []struct { name string actualVersionStr string versionConstraint string containError bool expectedErrorMessage string }{ { name: "invalid actualVersionStr", actualVersionStr: "", versionConstraint: "1.2.3", containError: true, expectedErrorMessage: "invalid version format found for actualVersionStr: ", }, { name: "invalid versionConstraint", actualVersionStr: "1.2.3", versionConstraint: "", containError: true, expectedErrorMessage: "invalid version format found for versionConstraint: ", }, { name: "pass version constraint", actualVersionStr: "1.2.3", versionConstraint: "1.2.3", containError: false, expectedErrorMessage: "", }, { name: "fail version constraint", actualVersionStr: "1.2.3", versionConstraint: "1.2.4", containError: true, expectedErrorMessage: "actual version {1.2.3} failed the version constraint {1.2.4}", }, { name: "special syntax version constraint", actualVersionStr: "1.0.5", versionConstraint: "~> 1.0.4", containError: false, expectedErrorMessage: "", }, { name: "version constraint w/ operators", actualVersionStr: "1.2.7", versionConstraint: ">= 1.2.0, < 2.0.0", containError: false, expectedErrorMessage: ""}, } for _, tc := range tests { err := checkVersionConstraint(tc.actualVersionStr, tc.versionConstraint) if tc.containError { require.EqualError(t, err, tc.expectedErrorMessage, tc.name) } else { require.NoError(t, err, tc.name) } } } // Note: with the current implementation of running shell command, it's not easy to // mock the output of running a shell command. So we assume a certain Binary is installed in the working // directory and it's greater than 0.0.1 version. func TestCheckVersionEndToEnd(t *testing.T) { t.Parallel() tests := []struct { name string param CheckVersionParams }{ {name: "Docker", param: CheckVersionParams{ Binary: Docker, VersionConstraint: ">= 0.0.1", WorkingDir: ".", }}, {name: "Terraform", param: CheckVersionParams{ BinaryPath: "", Binary: Terraform, VersionConstraint: ">= 0.0.1", WorkingDir: ".", }}, {name: "Packer", param: CheckVersionParams{ BinaryPath: "/usr/local/bin/packer", Binary: Packer, VersionConstraint: ">= 0.0.1", WorkingDir: ".", }}, } for _, tc := range tests { err := CheckVersionE(t, tc.param) require.NoError(t, err, tc.name) } } ================================================ FILE: test/azure/terraform_azure_aci_example_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package test import ( "strings" "testing" "github.com/gruntwork-io/terratest/modules/azure" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestTerraformAzureACIExample(t *testing.T) { t.Parallel() uniquePostfix := strings.ToLower(random.UniqueId()) // website::tag::1:: Configure Terraform setting up a path to Terraform code. terraformOptions := &terraform.Options{ TerraformDir: "../../examples/azure/terraform-azure-aci-example", Vars: map[string]interface{}{ "postfix": uniquePostfix, }, } // website::tag::5:: At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // website::tag::2:: Run `terraform init` and `terraform apply`. Fail the test if there are any errors. terraform.InitAndApply(t, terraformOptions) // website::tag::3:: Run `terraform output` to get the values of output variables resourceGroupName := terraform.Output(t, terraformOptions, "resource_group_name") aciName := terraform.Output(t, terraformOptions, "container_instance_name") ipAddress := terraform.Output(t, terraformOptions, "ip_address") fqdn := terraform.Output(t, terraformOptions, "fqdn") // website::tag::4:: Assert assert.True(t, azure.ContainerInstanceExists(t, aciName, resourceGroupName, "")) actualInstance := azure.GetContainerInstance(t, aciName, resourceGroupName, "") assert.Equal(t, ipAddress, *actualInstance.ContainerGroupProperties.IPAddress.IP) assert.Equal(t, fqdn, *actualInstance.ContainerGroupProperties.IPAddress.Fqdn) } ================================================ FILE: test/azure/terraform_azure_acr_example_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package test import ( "strings" "testing" "github.com/gruntwork-io/terratest/modules/azure" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestTerraformAzureACRExample(t *testing.T) { t.Parallel() uniquePostfix := strings.ToLower(random.UniqueId()) acrSKU := "Premium" // website::tag::1:: Configure Terraform setting up a path to Terraform code. terraformOptions := &terraform.Options{ TerraformDir: "../../examples/azure/terraform-azure-acr-example", Vars: map[string]interface{}{ "postfix": uniquePostfix, "sku": acrSKU, }, } // website::tag::5:: At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // website::tag::2:: Run `terraform init` and `terraform apply`. Fail the test if there are any errors. terraform.InitAndApply(t, terraformOptions) // website::tag::3:: Run `terraform output` to get the values of output variables resourceGroupName := terraform.Output(t, terraformOptions, "resource_group_name") acrName := terraform.Output(t, terraformOptions, "container_registry_name") loginServer := terraform.Output(t, terraformOptions, "login_server") // website::tag::4:: Assert assert.True(t, azure.ContainerRegistryExists(t, acrName, resourceGroupName, "")) actualACR := azure.GetContainerRegistry(t, acrName, resourceGroupName, "") assert.Equal(t, loginServer, *actualACR.LoginServer) assert.True(t, *actualACR.AdminUserEnabled) assert.Equal(t, acrSKU, string(actualACR.Sku.Name)) } ================================================ FILE: test/azure/terraform_azure_actiongroup_example_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package test import ( "fmt" "strings" "testing" "github.com/gruntwork-io/terratest/modules/azure" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestTerraformAzureActionGroupExample(t *testing.T) { t.Parallel() _random := strings.ToLower(random.UniqueId()) expectedResourceGroupName := fmt.Sprintf("tmp-rg-%s", _random) expectedAppName := fmt.Sprintf("tmp-asp-%s", _random) terraformOptions := &terraform.Options{ TerraformDir: "../../examples/azure/terraform-azure-actiongroup-example", Vars: map[string]interface{}{ "resource_group_name": expectedResourceGroupName, "app_name": expectedAppName, "location": "westus2", "short_name": "blah", "enable_email": true, "email_name": "emailTestName", "email_address": "sample@test.com", "enable_webhook": true, "webhook_name": "webhookTestName", "webhook_service_uri": "http://example.com/alert", }, } // At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // This will run `terraform init` and `terraform apply` and fail the test if there are any errors terraform.InitAndApply(t, terraformOptions) assert := assert.New(t) actionGroupId := terraform.Output(t, terraformOptions, "action_group_id") assert.NotNil(actionGroupId) assert.Contains(actionGroupId, expectedAppName) actionGroup := azure.GetActionGroupResource(t, expectedAppName, expectedResourceGroupName, "") assert.NotNil(actionGroup) assert.Equal(1, len(*actionGroup.EmailReceivers)) assert.Equal(0, len(*actionGroup.SmsReceivers)) assert.Equal(1, len(*actionGroup.WebhookReceivers)) } ================================================ FILE: test/azure/terraform_azure_aks_example_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package test import ( "crypto/tls" "fmt" "path/filepath" "strings" "testing" "time" "github.com/gruntwork-io/terratest/modules/azure" http_helper "github.com/gruntwork-io/terratest/modules/http-helper" "github.com/gruntwork-io/terratest/modules/k8s" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestTerraformAzureAKSExample(t *testing.T) { t.Parallel() // MC_+ResourceGroupName_ClusterName_AzureRegion must be no greater than 80 chars. // https://docs.microsoft.com/en-us/azure/aks/troubleshooting#what-naming-restrictions-are-enforced-for-aks-resources-and-parameters expectedClusterName := fmt.Sprintf("terratest-aks-cluster-%s", random.UniqueId()) expectedResourceGroupName := fmt.Sprintf("terratest-aks-rg-%s", random.UniqueId()) expectedAagentCount := 3 terraformOptions := &terraform.Options{ TerraformDir: "../../examples/azure/terraform-azure-aks-example", Vars: map[string]interface{}{ "cluster_name": expectedClusterName, "resource_group_name": expectedResourceGroupName, "agent_count": expectedAagentCount, }, } // At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // This will run `terraform init` and `terraform apply` and fail the test if there are any errors terraform.InitAndApply(t, terraformOptions) // Look up the cluster node count cluster, err := azure.GetManagedClusterE(t, expectedResourceGroupName, expectedClusterName, "") require.NoError(t, err) actualCount := *(*cluster.ManagedClusterProperties.AgentPoolProfiles)[0].Count // Test that the Node count matches the Terraform specification assert.Equal(t, int32(expectedAagentCount), actualCount) // Path to the Kubernetes resource config we will test kubeResourcePath, err := filepath.Abs("../../examples/azure/terraform-azure-aks-example/nginx-deployment.yml") require.NoError(t, err) // To ensure we can reuse the resource config on the same cluster to test different scenarios, we setup a unique // namespace for the resources for this test. // Note that namespaces must be lowercase. namespaceName := strings.ToLower(random.UniqueId()) // Setup the kubectl config and context. Here we choose to use the defaults, which is: // - HOME/.kube/config for the kubectl config file // - Current context of the kubectl config file options := k8s.NewKubectlOptions("", "../../examples/azure/terraform-azure-aks-example/kubeconfig", namespaceName) k8s.CreateNamespace(t, options, namespaceName) // ... and make sure to delete the namespace at the end of the test defer k8s.DeleteNamespace(t, options, namespaceName) // At the end of the test, run `kubectl delete -f RESOURCE_CONFIG` to clean up any resources that were created. defer k8s.KubectlDelete(t, options, kubeResourcePath) // This will run `kubectl apply -f RESOURCE_CONFIG` and fail the test if there are any errors k8s.KubectlApply(t, options, kubeResourcePath) // This will wait up to 10 seconds for the service to become available, to ensure that we can access it. k8s.WaitUntilServiceAvailable(t, options, "nginx-service", 10, 20*time.Second) // Now we verify that the service will successfully boot and start serving requests service := k8s.GetService(t, options, "nginx-service") endpoint := k8s.GetServiceEndpoint(t, options, service, 80) // Setup a TLS configuration to submit with the helper, a blank struct is acceptable tlsConfig := tls.Config{} // Test the endpoint for up to 5 minutes. This will only fail if we timeout waiting for the service to return a 200 // response. http_helper.HttpGetWithRetryWithCustomValidation( t, fmt.Sprintf("http://%s", endpoint), &tlsConfig, 30, 10*time.Second, func(statusCode int, body string) bool { return statusCode == 200 }, ) } ================================================ FILE: test/azure/terraform_azure_availabilityset_example_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package test import ( "fmt" "testing" "github.com/gruntwork-io/terratest/modules/azure" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestTerraformAzureAvailabilitySetExample(t *testing.T) { t.Parallel() // subscriptionID is overridden by the environment variable "ARM_SUBSCRIPTION_ID" subscriptionID := "" uniquePostfix := random.UniqueId() expectedAvsName := fmt.Sprintf("avs-%s", uniquePostfix) expectedVMName := fmt.Sprintf("vm-%s", uniquePostfix) var expectedAvsFaultDomainCount int32 = 3 // Configure Terraform setting up a path to Terraform code. terraformOptions := &terraform.Options{ // Relative path to the Terraform dir TerraformDir: "../../examples/azure/terraform-azure-availabilityset-example", // Variables to pass to our Terraform code using -var options Vars: map[string]interface{}{ "postfix": uniquePostfix, "avs_fault_domain_count": expectedAvsFaultDomainCount, // "location": "East US", }, } // At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // Run `terraform init` and `terraform apply`. Fail the test if there are any errors. terraform.InitAndApply(t, terraformOptions) // Run `terraform output` to get the values of output variables resourceGroupName := terraform.Output(t, terraformOptions, "resource_group_name") // Check the Availability Set Exists actualAvsExists := azure.AvailabilitySetExists(t, expectedAvsName, resourceGroupName, subscriptionID) assert.True(t, actualAvsExists) // Check the Availability Set Fault Domain Count actualAvsFaultDomainCount := azure.GetAvailabilitySetFaultDomainCount(t, expectedAvsName, resourceGroupName, subscriptionID) assert.Equal(t, expectedAvsFaultDomainCount, actualAvsFaultDomainCount) // Check the Availability Set for a VM actualVMPresent := azure.CheckAvailabilitySetContainsVM(t, expectedVMName, expectedAvsName, resourceGroupName, subscriptionID) assert.True(t, actualVMPresent) } ================================================ FILE: test/azure/terraform_azure_container_apps_example_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package test import ( "strings" "testing" "github.com/gruntwork-io/terratest/modules/azure" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestTerraformAzureContainerAppExample(t *testing.T) { t.Parallel() subscriptionID := "" uniquePostfix := strings.ToLower(random.UniqueId()) terraformOptions := &terraform.Options{ TerraformBinary: "", // The path to where our Terraform code is located TerraformDir: "../../examples/azure/terraform-azure-container-apps-example", Vars: map[string]interface{}{ "postfix": uniquePostfix, }, } defer terraform.Destroy(t, terraformOptions) terraform.InitAndApply(t, terraformOptions) resourceGroupName := terraform.Output(t, terraformOptions, "resource_group_name") envName := terraform.Output(t, terraformOptions, "container_app_env_name") containerAppName := terraform.Output(t, terraformOptions, "container_app_name") containerAppJobName := terraform.Output(t, terraformOptions, "container_app_job_name") // NOTE: the value of subscriptionID can be left blank, it will be replaced by the value // of the environment variable ARM_SUBSCRIPTION_ID envExsists := azure.ManagedEnvironmentExists(t, envName, resourceGroupName, subscriptionID) assert.True(t, envExsists) actualEnv := azure.GetManagedEnvironment(t, envName, resourceGroupName, subscriptionID) assert.Equal(t, envName, *actualEnv.Name) containerAppExists := azure.ContainerAppExists(t, containerAppName, resourceGroupName, subscriptionID) assert.True(t, containerAppExists) actualContainerApp := azure.GetContainerApp(t, containerAppName, resourceGroupName, subscriptionID) assert.Equal(t, containerAppName, *actualContainerApp.Name) containerAppJobExists := azure.ContainerAppJobExists(t, containerAppJobName, resourceGroupName, subscriptionID) assert.True(t, containerAppJobExists) actualContainerAppJob := azure.GetContainerAppJob(t, containerAppJobName, resourceGroupName, subscriptionID) assert.Equal(t, containerAppJobName, *actualContainerAppJob.Name) } ================================================ FILE: test/azure/terraform_azure_cosmosdb_example_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package test import ( "testing" "github.com/Azure/azure-sdk-for-go/profiles/latest/cosmos-db/mgmt/documentdb" "github.com/gruntwork-io/terratest/modules/azure" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestTerraformAzureCosmosDBExample(t *testing.T) { t.Parallel() subscriptionID := "" uniquePostfix := random.Random(10000, 99999) throughput := 400 // website::tag::1:: Configure Terraform setting up a path to Terraform code. terraformOptions := &terraform.Options{ // The path to where our Terraform code is located TerraformDir: "../../examples/azure/terraform-azure-cosmosdb-example", Vars: map[string]interface{}{ "postfix": uniquePostfix, "throughput": throughput, }, } // website::tag::4:: At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // website::tag::2:: Run `terraform init` and `terraform apply`. Fail the test if there are any errors. terraform.InitAndApply(t, terraformOptions) // website::tag::3:: Run `terraform output` to get the values of output variables resourceGroupName := terraform.Output(t, terraformOptions, "resource_group_name") accountName := terraform.Output(t, terraformOptions, "account_name") // website::tag::4:: Get CosmosDB details and assert them against the terraform output // NOTE: the value of subscriptionID can be left blank, it will be replaced by the value // of the environment variable ARM_SUBSCRIPTION_ID // Database Account properties actualCosmosDBAccount := azure.GetCosmosDBAccount(t, subscriptionID, resourceGroupName, accountName) assert.Equal(t, accountName, *actualCosmosDBAccount.Name) assert.Equal(t, documentdb.GlobalDocumentDB, actualCosmosDBAccount.Kind) assert.Equal(t, documentdb.Session, actualCosmosDBAccount.DatabaseAccountGetProperties.ConsistencyPolicy.DefaultConsistencyLevel) // SQL Database properties cosmosSQLDB := azure.GetCosmosDBSQLDatabase(t, subscriptionID, resourceGroupName, accountName, "testdb") assert.Equal(t, "testdb", *cosmosSQLDB.Name) // SQL Database throughput cosmosSQLDBThroughput := azure.GetCosmosDBSQLDatabaseThroughput(t, subscriptionID, resourceGroupName, accountName, "testdb") assert.Equal(t, int32(throughput), *cosmosSQLDBThroughput.ThroughputSettingsGetProperties.Resource.Throughput) // SQL Container properties cosmosSQLContainer1 := azure.GetCosmosDBSQLContainer(t, subscriptionID, resourceGroupName, accountName, "testdb", "test-container-1") cosmosSQLContainer2 := azure.GetCosmosDBSQLContainer(t, subscriptionID, resourceGroupName, accountName, "testdb", "test-container-2") cosmosSQLContainer3 := azure.GetCosmosDBSQLContainer(t, subscriptionID, resourceGroupName, accountName, "testdb", "test-container-3") assert.Equal(t, "test-container-1", *cosmosSQLContainer1.Name) assert.Equal(t, "/key1", (*cosmosSQLContainer1.SQLContainerGetProperties.Resource.PartitionKey.Paths)[0]) assert.Equal(t, "test-container-2", *cosmosSQLContainer2.Name) assert.Equal(t, "/key2", (*cosmosSQLContainer2.SQLContainerGetProperties.Resource.PartitionKey.Paths)[0]) assert.Equal(t, "test-container-3", *cosmosSQLContainer3.Name) assert.Equal(t, "/key3", (*cosmosSQLContainer3.SQLContainerGetProperties.Resource.PartitionKey.Paths)[0]) // SQL Container throughput cosmosSQLContainer1Throughput := azure.GetCosmosDBSQLContainerThroughput(t, subscriptionID, resourceGroupName, accountName, "testdb", "test-container-1") assert.Equal(t, int32(400), *cosmosSQLContainer1Throughput.ThroughputSettingsGetProperties.Resource.Throughput) } ================================================ FILE: test/azure/terraform_azure_datafactory_example_test.go ================================================ //go:build azure // +build azure package test import ( "strings" "testing" "github.com/gruntwork-io/terratest/modules/azure" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestTerraformAzureDataFactoryExample(t *testing.T) { t.Parallel() uniquePostfix := strings.ToLower(random.UniqueId()) expectedDataFactoryProvisioningState := "Succeeded" expectedLocation := "eastus" //Configure Terraform setting up a path to Terraform code. terraformOptions := &terraform.Options{ // The path to where our Terraform code is located TerraformDir: "../../examples/azure/terraform-azure-datafactory-example", Vars: map[string]interface{}{ "postfix": uniquePostfix, "location": expectedLocation, }, } // At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) //Run `terraform init` and `terraform apply`. Fail the test if there are any errors. terraform.InitAndApply(t, terraformOptions) // Run `terraform output` to get the values of output variables expectedResourceGroupName := terraform.Output(t, terraformOptions, "resource_group_name") expectedDataFactoryName := terraform.Output(t, terraformOptions, "datafactory_name") // check for if data factory exists actualDataFactoryExits := azure.DataFactoryExists(t, expectedDataFactoryName, expectedResourceGroupName, "") assert.True(t, actualDataFactoryExits) //Get data factory details and assert them against the terraform output actualDataFactory := azure.GetDataFactory(t, expectedResourceGroupName, expectedDataFactoryName, "") assert.Equal(t, expectedDataFactoryName, *actualDataFactory.Name) assert.Equal(t, expectedDataFactoryProvisioningState, *actualDataFactory.Properties.ProvisioningState) } ================================================ FILE: test/azure/terraform_azure_disk_example_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package test import ( "testing" "github.com/Azure/azure-sdk-for-go/services/compute/mgmt/2019-07-01/compute" "github.com/gruntwork-io/terratest/modules/azure" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestTerraformAzureDiskExample(t *testing.T) { t.Parallel() // Subscription ID, leave blank if available as an Environment Var subID := "" uniquePostfix := random.UniqueId() // Configure Terraform setting up a path to Terraform code. terraformOptions := &terraform.Options{ // The path to where our Terraform code is located TerraformDir: "../../examples/azure/terraform-azure-disk-example", // Variables to pass to our Terraform code using -var options Vars: map[string]interface{}{ "postfix": uniquePostfix, }, } // At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // Run `terraform init` and `terraform apply`. Fail the test if there are any errors. terraform.InitAndApply(t, terraformOptions) // Run `terraform output` to get the values of output variables resourceGroupName := terraform.Output(t, terraformOptions, "resource_group_name") expectedDiskName := terraform.Output(t, terraformOptions, "disk_name") expectedDiskType := terraform.Output(t, terraformOptions, "disk_type") // Check the Disk Type actualDisk := azure.GetDisk(t, expectedDiskName, resourceGroupName, subID) assert.Equal(t, compute.DiskStorageAccountTypes(expectedDiskType), actualDisk.Sku.Name) } ================================================ FILE: test/azure/terraform_azure_example_test.go ================================================ //go:build azure || (azureslim && compute) // +build azure azureslim,compute // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package test import ( "testing" "github.com/Azure/azure-sdk-for-go/services/compute/mgmt/2019-07-01/compute" "github.com/gruntwork-io/terratest/modules/azure" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestTerraformAzureExample(t *testing.T) { t.Parallel() uniquePostfix := random.UniqueId() // website::tag::1:: Configure Terraform setting up a path to Terraform code. terraformOptions := &terraform.Options{ // The path to where our Terraform code is located TerraformDir: "../../examples/azure/terraform-azure-example", Vars: map[string]interface{}{ "postfix": uniquePostfix, }, } // website::tag::4:: At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // website::tag::2:: Run `terraform init` and `terraform apply`. Fail the test if there are any errors. terraform.InitAndApply(t, terraformOptions) // website::tag::3:: Run `terraform output` to get the values of output variables vmName := terraform.Output(t, terraformOptions, "vm_name") resourceGroupName := terraform.Output(t, terraformOptions, "resource_group_name") // website::tag::4:: Look up the size of the given Virtual Machine and ensure it matches the output. actualVMSize := azure.GetSizeOfVirtualMachine(t, vmName, resourceGroupName, "") expectedVMSize := compute.VirtualMachineSizeTypes("Standard_B1s") assert.Equal(t, expectedVMSize, actualVMSize) } ================================================ FILE: test/azure/terraform_azure_frontdoor_example_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package test import ( "testing" "github.com/gruntwork-io/terratest/modules/azure" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestTerraformAzureFrontDoorExample(t *testing.T) { t.Parallel() subscriptionID := "" uniquePostfix := random.UniqueId() // website::tag::1:: Configure Terraform setting up a path to Terraform code. terraformOptions := &terraform.Options{ // The path to where our Terraform code is located TerraformDir: "../../examples/azure/terraform-azure-frontdoor-example", Vars: map[string]interface{}{ "postfix": uniquePostfix, }, } // website::tag::4:: At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // website::tag::2:: Run `terraform init` and `terraform apply`. Fail the test if there are any errors. terraform.InitAndApply(t, terraformOptions) // website::tag::3:: Run `terraform output` to get the values of output variables resourceGroupName := terraform.Output(t, terraformOptions, "resource_group_name") frontDoorName := terraform.Output(t, terraformOptions, "front_door_name") frontDoorUrl := terraform.Output(t, terraformOptions, "front_door_url") frontendEndpointName := terraform.Output(t, terraformOptions, "front_door_endpoint_name") // website::tag::4:: Get FrontDoor details and assert them against the terraform output // NOTE: the value of subscriptionID can be left blank, it will be replaced by the value // of the environment variable ARM_SUBSCRIPTION_ID frontDoorExists := azure.FrontDoorExists(t, frontDoorName, resourceGroupName, subscriptionID) assert.True(t, frontDoorExists) actualFrontDoorInstance := azure.GetFrontDoor(t, frontDoorName, resourceGroupName, subscriptionID) assert.Equal(t, frontDoorName, *actualFrontDoorInstance.Name) endpointExists := azure.FrontDoorFrontendEndpointExists(t, frontendEndpointName, frontDoorName, resourceGroupName, subscriptionID) assert.True(t, endpointExists) actualFrontDoorEndpoint := azure.GetFrontDoorFrontendEndpoint(t, frontendEndpointName, frontDoorName, resourceGroupName, subscriptionID) endpointProperties := *actualFrontDoorEndpoint.FrontendEndpointProperties assert.Equal(t, frontDoorUrl, *endpointProperties.HostName) } ================================================ FILE: test/azure/terraform_azure_functionapp_example_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package test import ( "strings" "testing" "github.com/gruntwork-io/terratest/modules/azure" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestTerraformAzureFunctionAppExample(t *testing.T) { t.Parallel() //_random := strings.ToLower(random.UniqueId()) uniquePostfix := strings.ToLower(random.UniqueId()) // website::tag::1:: Configure Terraform setting up a path to Terraform code. terraformOptions := &terraform.Options{ TerraformDir: "../../examples/azure/terraform-azure-functionapp-example", Vars: map[string]interface{}{ "postfix": uniquePostfix, }, } // website::tag::5:: At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // website::tag::2:: Run `terraform init` and `terraform apply`. Fail the test if there are any errors. terraform.InitAndApply(t, terraformOptions) // website::tag::3:: Run `terraform output` to get the values of output variables resourceGroupName := terraform.Output(t, terraformOptions, "resource_group_name") appName := terraform.Output(t, terraformOptions, "function_app_name") appId := terraform.Output(t, terraformOptions, "function_app_id") appDefaultHostName := terraform.Output(t, terraformOptions, "default_hostname") appKind := terraform.Output(t, terraformOptions, "function_app_kind") // website::tag::4:: Assert assert.True(t, azure.AppExists(t, appName, resourceGroupName, "")) site := azure.GetAppService(t, appName, resourceGroupName, "") assert.Equal(t, appId, *site.ID) assert.Equal(t, appDefaultHostName, *site.DefaultHostName) assert.Equal(t, appKind, *site.Kind) assert.NotEmpty(t, *site.OutboundIPAddresses) assert.Equal(t, "Running", *site.State) } ================================================ FILE: test/azure/terraform_azure_keyvault_example_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package test import ( "testing" "github.com/gruntwork-io/terratest/modules/azure" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestTerraformAzureKeyVaultExample(t *testing.T) { t.Parallel() uniquePostfix := random.UniqueId() // website::tag::1:: Configure Terraform setting up a path to Terraform code. terraformOptions := &terraform.Options{ // The path to where our Terraform code is located TerraformDir: "../../examples/azure/terraform-azure-keyvault-example", Vars: map[string]interface{}{ "postfix": uniquePostfix, }, } // website::tag::6:: At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // website::tag::2:: Run `terraform init` and `terraform apply`. Fail the test if there are any errors. terraform.InitAndApply(t, terraformOptions) // website::tag::3:: Run `terraform output` to get the values of output variables resourceGroupName := terraform.Output(t, terraformOptions, "resource_group_name") keyVaultName := terraform.Output(t, terraformOptions, "key_vault_name") expectedSecretName := terraform.Output(t, terraformOptions, "secret_name") expectedKeyName := terraform.Output(t, terraformOptions, "key_name") expectedCertificateName := terraform.Output(t, terraformOptions, "certificate_name") // website::tag::4:: Determine whether the keyvault exists keyVault := azure.GetKeyVault(t, resourceGroupName, keyVaultName, "") assert.Equal(t, keyVaultName, *keyVault.Name) // website::tag::5:: Determine whether the secret, key, and certificate exists secretExists := azure.KeyVaultSecretExists(t, keyVaultName, expectedSecretName) assert.True(t, secretExists, "kv-secret does not exist") keyExists := azure.KeyVaultKeyExists(t, keyVaultName, expectedKeyName) assert.True(t, keyExists, "kv-key does not exist") certificateExists := azure.KeyVaultCertificateExists(t, keyVaultName, expectedCertificateName) assert.True(t, certificateExists, "kv-cert does not exist") } ================================================ FILE: test/azure/terraform_azure_loadbalancer_example_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package test import ( "testing" "github.com/gruntwork-io/terratest/modules/azure" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestTerraformAzureLoadBalancerExample(t *testing.T) { t.Parallel() // subscriptionID is overridden by the environment variable "ARM_SUBSCRIPTION_ID" subscriptionID := "" uniquePostfix := random.UniqueId() privateIPForLB02 := "10.200.2.10" // Configure Terraform setting up a path to Terraform code. terraformOptions := &terraform.Options{ // The path to where our Terraform code is located TerraformDir: "../../examples/azure/terraform-azure-loadbalancer-example", // Variables to pass to our Terraform code using -var options Vars: map[string]interface{}{ "postfix": uniquePostfix, "lb_private_ip": privateIPForLB02, // "location": "East US", }, } // At the end of the test, run `terraform destroy` to clean up any resources that were created. defer terraform.Destroy(t, terraformOptions) // Run `terraform init` and `terraform apply`. Fail the test if there are any errors. terraform.InitAndApply(t, terraformOptions) // Run `terraform output` to get the values of output variables resourceGroupName := terraform.Output(t, terraformOptions, "resource_group_name") expectedLBPublicName := terraform.Output(t, terraformOptions, "lb_public_name") expectedLBPrivateName := terraform.Output(t, terraformOptions, "lb_private_name") expectedLBNoFEConfigName := terraform.Output(t, terraformOptions, "lb_default_name") expectedLBPublicFeConfigName := terraform.Output(t, terraformOptions, "lb_public_fe_config_name") expectedLBPrivateFeConfigName := terraform.Output(t, terraformOptions, "lb_private_fe_config_static_name") expectedLBPrivateIP := terraform.Output(t, terraformOptions, "lb_private_ip_static") actualLBDoesNotExist := azure.LoadBalancerExists(t, "negative-test", resourceGroupName, subscriptionID) assert.False(t, actualLBDoesNotExist) t.Run("LoadBalancer_Public", func(t *testing.T) { // Check Public Load Balancer exists. actualLBPublicExists := azure.LoadBalancerExists(t, expectedLBPublicName, resourceGroupName, subscriptionID) assert.True(t, actualLBPublicExists) // Check Frontend Configuration for Load Balancer. actualLBPublicFeConfigNames := azure.GetLoadBalancerFrontendIPConfigNames(t, expectedLBPublicName, resourceGroupName, subscriptionID) assert.Contains(t, actualLBPublicFeConfigNames, expectedLBPublicFeConfigName) // Check Frontend Configuration Public Address and Public IP assignment actualLBPublicIPAddress, actualLBPublicIPType := azure.GetIPOfLoadBalancerFrontendIPConfig(t, expectedLBPublicFeConfigName, expectedLBPublicName, resourceGroupName, subscriptionID) assert.NotEmpty(t, actualLBPublicIPAddress) assert.Equal(t, azure.PublicIP, actualLBPublicIPType) }) t.Run("LoadBalancer_Private", func(t *testing.T) { // Check Private Load Balancer exists. actualLBPrivateExists := azure.LoadBalancerExists(t, expectedLBPrivateName, resourceGroupName, subscriptionID) assert.True(t, actualLBPrivateExists) // Check Frontend Configuration for Load Balancer. actualLBPrivateFeConfigNames := azure.GetLoadBalancerFrontendIPConfigNames(t, expectedLBPrivateName, resourceGroupName, subscriptionID) assert.Equal(t, 2, len(actualLBPrivateFeConfigNames)) assert.Contains(t, actualLBPrivateFeConfigNames, expectedLBPrivateFeConfigName) // Check Frontend Configuration Private IP Type and Address. actualLBPrivateIPAddress, actualLBPrivateIPType := azure.GetIPOfLoadBalancerFrontendIPConfig(t, expectedLBPrivateFeConfigName, expectedLBPrivateName, resourceGroupName, subscriptionID) assert.NotEmpty(t, actualLBPrivateIPAddress) assert.Equal(t, expectedLBPrivateIP, actualLBPrivateIPAddress) assert.Equal(t, azure.PrivateIP, actualLBPrivateIPType) }) t.Run("LoadBalancer_Default", func(t *testing.T) { // Check No Frontend Config Load Balancer exists. actualLBNoFEConfigExists := azure.LoadBalancerExists(t, expectedLBNoFEConfigName, resourceGroupName, subscriptionID) assert.True(t, actualLBNoFEConfigExists) // Check for No Frontend Configuration for Load Balancer. actualLBNoFEConfigFeConfigNames := azure.GetLoadBalancerFrontendIPConfigNames(t, expectedLBNoFEConfigName, resourceGroupName, subscriptionID) assert.Equal(t, 0, len(actualLBNoFEConfigFeConfigNames)) }) } ================================================ FILE: test/azure/terraform_azure_loganalytics_example_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package test import ( "strconv" "strings" "testing" "github.com/gruntwork-io/terratest/modules/azure" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestTerraformAzureLogAnalyticsExample(t *testing.T) { t.Parallel() // subscriptionID is overridden by the environment variable "ARM_SUBSCRIPTION_ID" subscriptionID := "" uniquePostfix := random.UniqueId() // website::tag::1:: Configure Terraform setting up a path to Terraform code. terraformOptions := &terraform.Options{ // The path to where our Terraform code is located TerraformDir: "../../examples/azure/terraform-azure-loganalytics-example", // Variables to pass to our Terraform code using -var options Vars: map[string]interface{}{ "postfix": uniquePostfix, }, } // website::tag::4:: At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // website::tag::2:: Run `terraform init` and `terraform apply`. Fail the test if there are any errors. terraform.InitAndApply(t, terraformOptions) // website::tag::3:: Run `terraform output` to get the values of output variables resourceGroupName := terraform.Output(t, terraformOptions, "resource_group_name") workspaceName := terraform.Output(t, terraformOptions, "loganalytics_workspace_name") sku := terraform.Output(t, terraformOptions, "loganalytics_workspace_sku") retentionPeriodString := terraform.Output(t, terraformOptions, "loganalytics_workspace_retention") // website::tag::4:: Verify the Log Analytics properties and ensure it matches the output. workspaceExists := azure.LogAnalyticsWorkspaceExists(t, workspaceName, resourceGroupName, subscriptionID) assert.True(t, workspaceExists, "log analytics workspace not found.") actualWorkspace := azure.GetLogAnalyticsWorkspace(t, workspaceName, resourceGroupName, subscriptionID) actualSku := string(actualWorkspace.Sku.Name) assert.Equal(t, strings.ToLower(sku), strings.ToLower(actualSku), "log analytics sku mismatch") actualRetentionPeriod := *actualWorkspace.RetentionInDays expectedPeriod, _ := strconv.ParseInt(retentionPeriodString, 10, 32) assert.Equal(t, int32(expectedPeriod), actualRetentionPeriod, "log analytics retention period mismatch") } ================================================ FILE: test/azure/terraform_azure_monitor_example_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package test import ( "testing" "github.com/gruntwork-io/terratest/modules/azure" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestTerraformAzureMonitorExample(t *testing.T) { t.Parallel() // subscriptionID is overridden by the environment variable "ARM_SUBSCRIPTION_ID" subscriptionID := "" uniquePostfix := random.UniqueId() // website::tag::1:: Configure Terraform setting up a path to Terraform code. terraformOptions := &terraform.Options{ // The path to where our Terraform code is located TerraformDir: "../../examples/azure/terraform-azure-monitor-example", Vars: map[string]interface{}{ "postfix": uniquePostfix, }, } // website::tag::4:: At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // website::tag::2:: Run `terraform init` and `terraform apply`. Fail the test if there are any errors. terraform.InitAndApply(t, terraformOptions) expectedDiagnosticSettingName := terraform.Output(t, terraformOptions, "diagnostic_setting_name") keyvaultID := terraform.Output(t, terraformOptions, "keyvault_id") diagnosticSettingsResourceExists := azure.DiagnosticSettingsResourceExists(t, expectedDiagnosticSettingName, keyvaultID, subscriptionID) assert.Equal(t, diagnosticSettingsResourceExists, true, "Diagnostic settings should exist") } ================================================ FILE: test/azure/terraform_azure_mysqldb_example_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package test import ( "fmt" "strings" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/mysql/armmysql" "github.com/gruntwork-io/terratest/modules/azure" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestTerraformAzureMySQLDBExample(t *testing.T) { t.Parallel() uniquePostfix := strings.ToLower(random.UniqueId()) expectedServerSkuName := "GP_Gen5_2" expectedServerStoragemMb := "5120" expectedDatabaseCharSet := "utf8" expectedDatabaseCollation := "utf8_unicode_ci" // website::tag::1:: Configure Terraform setting up a path to Terraform code. terraformOptions := &terraform.Options{ // The path to where our Terraform code is located TerraformDir: "../../examples/azure/terraform-azure-mysqldb-example", Vars: map[string]interface{}{ "postfix": uniquePostfix, "mysqlserver_sku_name": expectedServerSkuName, "mysqlserver_storage_mb": expectedServerStoragemMb, "mysqldb_charset": expectedDatabaseCharSet, }, } // website::tag::4:: At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // website::tag::2:: Run `terraform init` and `terraform apply`. Fail the test if there are any errors. terraform.InitAndApply(t, terraformOptions) // website::tag::3:: Run `terraform output` to get the values of output variables expectedResourceGroupName := terraform.Output(t, terraformOptions, "resource_group_name") expectedMYSQLServerName := terraform.Output(t, terraformOptions, "mysql_server_name") expectedMYSQLDBName := terraform.Output(t, terraformOptions, "mysql_database_name") // website::tag::4:: Get mySQL server details and assert them against the terraform output actualMYSQLServer := azure.GetMYSQLServer(t, expectedResourceGroupName, expectedMYSQLServerName, "") assert.Equal(t, expectedServerSkuName, *actualMYSQLServer.SKU.Name) assert.Equal(t, expectedServerStoragemMb, fmt.Sprint(*actualMYSQLServer.Properties.StorageProfile.StorageMB)) assert.Equal(t, armmysql.ServerStateReady, *actualMYSQLServer.Properties.UserVisibleState) // website::tag::5:: Get mySQL server DB details and assert them against the terraform output actualDatabase := azure.GetMYSQLDB(t, expectedResourceGroupName, expectedMYSQLServerName, expectedMYSQLDBName, "") assert.Equal(t, expectedDatabaseCharSet, *actualDatabase.Properties.Charset) assert.Equal(t, expectedDatabaseCollation, *actualDatabase.Properties.Collation) } ================================================ FILE: test/azure/terraform_azure_network_example_test.go ================================================ //go:build azure || (azureslim && network) // +build azure azureslim,network // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package test import ( "fmt" "strings" "testing" "github.com/gruntwork-io/terratest/modules/azure" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestTerraformAzureNetworkExample(t *testing.T) { t.Parallel() // Create values for Terraform subscriptionID := "" // subscriptionID is overridden by the environment variable "ARM_SUBSCRIPTION_ID" uniquePostfix := random.UniqueId() // "resource" - switch for terratest or manual terraform deployment expectedLocation := "eastus2" expectedSubnetRange := "10.0.20.0/24" expectedPrivateIP := "10.0.20.5" expectedDnsIp01 := "10.0.0.5" expectedDnsIp02 := "10.0.0.6" exectedDNSLabel := fmt.Sprintf("dns-terratest-%s", strings.ToLower(uniquePostfix)) // only lowercase, numeric and hyphens chars allowed for DNS // Configure Terraform setting up a path to Terraform code. terraformOptions := &terraform.Options{ // Relative path to the Terraform dir TerraformDir: "../../examples/azure/terraform-azure-network-example", // Variables to pass to our Terraform code using -var options. Vars: map[string]interface{}{ "postfix": uniquePostfix, "subnet_prefix": expectedSubnetRange, "private_ip": expectedPrivateIP, "dns_ip_01": expectedDnsIp01, "dns_ip_02": expectedDnsIp02, "location": expectedLocation, "domain_name_label": exectedDNSLabel, }, } // At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // Run `terraform init` and `terraform apply`. Fail the test if there are any errors terraform.InitAndApply(t, terraformOptions) // Run `terraform output` to get the values of output variables expectedRgName := terraform.Output(t, terraformOptions, "resource_group_name") expectedVNetName := terraform.Output(t, terraformOptions, "virtual_network_name") expectedSubnetName := terraform.Output(t, terraformOptions, "subnet_name") expectedPublicAddressName := terraform.Output(t, terraformOptions, "public_address_name") expectedPrivateNicName := terraform.Output(t, terraformOptions, "network_interface_internal") expectedPublicNicName := terraform.Output(t, terraformOptions, "network_interface_external") // Tests are separated into subtests to differentiate integrated tests and pure resource tests // Integrated network resource tests t.Run("VirtualNetwork_Subnet", func(t *testing.T) { // Check the Subnet exists in the Virtual Network Subnets with the expected Address Prefix actualVnetSubnets := azure.GetVirtualNetworkSubnets(t, expectedVNetName, expectedRgName, subscriptionID) assert.NotNil(t, actualVnetSubnets[expectedSubnetName]) assert.Equal(t, expectedSubnetRange, actualVnetSubnets[expectedSubnetName]) }) t.Run("NIC_PublicAddress", func(t *testing.T) { // Check the internal network interface does NOT have a public IP actualPrivateIPOnly := azure.GetNetworkInterfacePublicIPs(t, expectedPrivateNicName, expectedRgName, subscriptionID) assert.Equal(t, 0, len(actualPrivateIPOnly)) // Check the external network interface has a public IP actualPublicIPs := azure.GetNetworkInterfacePublicIPs(t, expectedPublicNicName, expectedRgName, subscriptionID) assert.Equal(t, 1, len(actualPublicIPs)) }) t.Run("Subnet_NIC", func(t *testing.T) { // Check the private IP is in the subnet range checkPrivateIpInSubnet := azure.CheckSubnetContainsIP(t, expectedPrivateIP, expectedSubnetName, expectedVNetName, expectedRgName, subscriptionID) assert.True(t, checkPrivateIpInSubnet) }) // Test for resource presence t.Run("Exists", func(t *testing.T) { // Check the Virtual Network exists assert.True(t, azure.VirtualNetworkExists(t, expectedVNetName, expectedRgName, subscriptionID)) // Check the Subnet exists assert.True(t, azure.SubnetExists(t, expectedSubnetName, expectedVNetName, expectedRgName, subscriptionID)) // Check the Network Interfaces exist assert.True(t, azure.NetworkInterfaceExists(t, expectedPrivateNicName, expectedRgName, subscriptionID)) assert.True(t, azure.NetworkInterfaceExists(t, expectedPublicNicName, expectedRgName, subscriptionID)) // Check Network Interface that does not exist in the Resource Group assert.False(t, azure.NetworkInterfaceExists(t, "negative-test", expectedRgName, subscriptionID)) // Check Public Address exists assert.True(t, azure.PublicAddressExists(t, expectedPublicAddressName, expectedRgName, subscriptionID)) }) // Tests for useful network properties t.Run("Network", func(t *testing.T) { // Check the Virtual Network DNS server IPs actualDNSIPs := azure.GetVirtualNetworkDNSServerIPs(t, expectedVNetName, expectedRgName, subscriptionID) assert.Contains(t, actualDNSIPs, expectedDnsIp01) assert.Contains(t, actualDNSIPs, expectedDnsIp02) // Check the Network Interface private IP actualPrivateIPs := azure.GetNetworkInterfacePrivateIPs(t, expectedPrivateNicName, expectedRgName, subscriptionID) assert.Contains(t, actualPrivateIPs, expectedPrivateIP) // Check the Public Address's Public IP is allocated actualPublicIP := azure.GetIPOfPublicIPAddressByName(t, expectedPublicAddressName, expectedRgName, subscriptionID) assert.NotEmpty(t, actualPublicIP) // Check DNS created for this example is reserved actualDnsNotAvailable := azure.CheckPublicDNSNameAvailability(t, expectedLocation, exectedDNSLabel, subscriptionID) assert.False(t, actualDnsNotAvailable) // Check new randomized DNS is available newDNSLabel := fmt.Sprintf("dns-terratest-%s", strings.ToLower(random.UniqueId())) actualDnsAvailable := azure.CheckPublicDNSNameAvailability(t, expectedLocation, newDNSLabel, subscriptionID) assert.True(t, actualDnsAvailable) }) } ================================================ FILE: test/azure/terraform_azure_nsg_example_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package test import ( "testing" "github.com/gruntwork-io/terratest/modules/azure" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestTerraformAzureNsgExample(t *testing.T) { t.Parallel() randomPostfixValue := random.UniqueId() // Construct options for TF apply terraformOptions := &terraform.Options{ // The path to where our Terraform code is located TerraformDir: "../../examples/azure/terraform-azure-nsg-example", Vars: map[string]interface{}{ "postfix": randomPostfixValue, }, } defer terraform.Destroy(t, terraformOptions) terraform.InitAndApply(t, terraformOptions) resourceGroupName := terraform.Output(t, terraformOptions, "resource_group_name") nsgName := terraform.Output(t, terraformOptions, "nsg_name") sshRuleName := terraform.Output(t, terraformOptions, "ssh_rule_name") httpRuleName := terraform.Output(t, terraformOptions, "http_rule_name") // A default NSG has 6 rules, and we have two custom rules for a total of 8 rules, err := azure.GetAllNSGRulesE(resourceGroupName, nsgName, "") assert.NoError(t, err) assert.Equal(t, 8, len(rules.SummarizedRules)) // We should have a rule for allowing ssh sshRule := rules.FindRuleByName(sshRuleName) // That rule should allow port 22 inbound assert.True(t, sshRule.AllowsDestinationPort(t, "22")) // But should not allow 80 inbound assert.False(t, sshRule.AllowsDestinationPort(t, "80")) // SSh is allowed from any port assert.True(t, sshRule.AllowsSourcePort(t, "*")) // We should have a rule for blocking HTTP httpRule := rules.FindRuleByName(httpRuleName) // This rule should BLOCK port 80 inbound assert.False(t, httpRule.AllowsDestinationPort(t, "80")) } ================================================ FILE: test/azure/terraform_azure_postgresql_example_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package test import ( "os" "strings" "testing" "github.com/gruntwork-io/terratest/modules/azure" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestPostgreSQLDatabase(t *testing.T) { t.Parallel() uniquePostfix := strings.ToLower(random.UniqueId()) // website::tag::1:: Configure Terraform setting up a path to Terraform code. terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ TerraformDir: "../../examples/azure/terraform-azure-postgresql-example", Vars: map[string]interface{}{ "postfix": uniquePostfix, }, NoColor: true, }) // website::tag::4:: At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // website::tag::2:: Run `terraform init` and `terraform apply`. Fail the test if there are any errors. terraform.InitAndApply(t, terraformOptions) subscriptionID := os.Getenv("ARM_SUBSCRIPTION_ID") // website::tag::3:: Run `terraform output` to get the values of output variables expectedServername := "postgresqlserver-" + uniquePostfix // see fixture actualServername := terraform.Output(t, terraformOptions, "servername") rgName := terraform.Output(t, terraformOptions, "rgname") expectedSkuName := terraform.Output(t, terraformOptions, "sku_name") // website::tag::4:: Get the Server details and assert them against the terraform output actualServer := azure.GetPostgreSQLServer(t, rgName, actualServername, subscriptionID) // Verify assert.NotNil(t, actualServer) assert.Equal(t, expectedServername, actualServername) assert.Equal(t, expectedSkuName, *actualServer.Sku.Name) } ================================================ FILE: test/azure/terraform_azure_recoveryservices_example_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package test import ( "testing" "github.com/gruntwork-io/terratest/modules/azure" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestTerraformAzureRecoveryServicesExample(t *testing.T) { t.Parallel() // subscriptionID is overridden by the environment variable "ARM_SUBSCRIPTION_ID" subscriptionID := "" uniquePostfix := random.UniqueId() // website::tag::1:: Configure Terraform setting up a path to Terraform code. terraformOptions := &terraform.Options{ // The path to where our Terraform code is located TerraformDir: "../../examples/azure/terraform-azure-recoveryservices-example", // Variables to pass to our Terraform code using -var options Vars: map[string]interface{}{ "postfix": uniquePostfix, }, } // website::tag::4:: At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // website::tag::2:: Run `terraform init` and `terraform apply`. Fail the test if there are any errors. terraform.InitAndApply(t, terraformOptions) // website::tag::3:: Run `terraform output` to get the values of output variables resourceGroupName := terraform.Output(t, terraformOptions, "resource_group_name") vaultName := terraform.Output(t, terraformOptions, "recovery_service_vault_name") policyVmName := terraform.Output(t, terraformOptions, "backup_policy_vm_name") // website::tag::4:: Verify the recovery services resources exists := azure.RecoveryServicesVaultExists(t, vaultName, resourceGroupName, subscriptionID) assert.True(t, exists, "vault does not exist") policyList := azure.GetRecoveryServicesVaultBackupPolicyList(t, vaultName, resourceGroupName, subscriptionID) assert.NotNil(t, policyList, "vault backup policy list is nil") vmPolicyList := azure.GetRecoveryServicesVaultBackupProtectedVMList(t, policyVmName, vaultName, resourceGroupName, subscriptionID) assert.NotNil(t, vmPolicyList, "vault backup policy list for protected vm is nil") } ================================================ FILE: test/azure/terraform_azure_resourcegroup_example_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package test import ( "testing" "github.com/gruntwork-io/terratest/modules/azure" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestTerraformAzureResourceGroupExample(t *testing.T) { t.Parallel() // subscriptionID is overridden by the environment variable "ARM_SUBSCRIPTION_ID" subscriptionID := "" uniquePostfix := random.UniqueId() // website::tag::1:: Configure Terraform setting up a path to Terraform code. terraformOptions := &terraform.Options{ // The path to where our Terraform code is located TerraformDir: "../../examples/azure/terraform-azure-resourcegroup-example", Vars: map[string]interface{}{ "postfix": uniquePostfix, }, } // website::tag::4:: At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // website::tag::2:: Run `terraform init` and `terraform apply`. Fail the test if there are any errors. terraform.InitAndApply(t, terraformOptions) // website::tag::3:: Run `terraform output` to get the values of output variables resourceGroupName := terraform.Output(t, terraformOptions, "resource_group_name") // website::tag::4:: Verify the resource group exists exists := azure.ResourceGroupExists(t, resourceGroupName, subscriptionID) assert.True(t, exists, "Resource group does not exist") // website::tag::4:: Verify the resource group exists existsv2 := azure.ResourceGroupExistsV2(t, resourceGroupName, subscriptionID) assert.True(t, existsv2, "Resource group does not exist") } ================================================ FILE: test/azure/terraform_azure_servicebus_example_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package test import ( "os" "strings" "testing" "github.com/gruntwork-io/terratest/modules/azure" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestTerraformAzureServiceBusExample(t *testing.T) { t.Parallel() uniquePostfix := strings.ToLower(random.UniqueId()) // website::tag::1:: Configure Terraform setting up a path to Terraform code. terraformOptions := &terraform.Options{ // The path to where our Terraform code is located TerraformDir: "../../examples/azure/terraform-azure-servicebus-example", Vars: map[string]interface{}{ "postfix": uniquePostfix, }, } // website::tag::4:: At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // website::tag::2:: Run `terraform init` and `terraform apply`. Fail the test if there are any errors. terraform.InitAndApply(t, terraformOptions) // website::tag::3:: Run `terraform output` to get the values of output variables expectedTopicSubscriptionsMap := terraform.OutputMapOfObjects(t, terraformOptions, "topics") expectedNamespaceName := terraform.Output(t, terraformOptions, "namespace_name") expectedResourceGroup := terraform.Output(t, terraformOptions, "resource_group") for topicName, topicsMap := range expectedTopicSubscriptionsMap { actualsubscriptionNames := azure.ListTopicSubscriptionsName(t, os.Getenv("ARM_SUBSCRIPTION_ID"), expectedNamespaceName, expectedResourceGroup, topicName) subscriptionsMap := topicsMap.(map[string]interface{})["subscriptions"].(map[string]interface{}) subscriptionNamesFromOutput := getMapKeylist(subscriptionsMap) // each subscription from the output should also exist in Azure assert.Equal(t, len(subscriptionNamesFromOutput), len(actualsubscriptionNames)) for _, subscrptionName := range subscriptionNamesFromOutput { assert.Contains(t, actualsubscriptionNames, subscrptionName) } } } func getMapKeylist(mapList map[string]interface{}) []string { names := make([]string, 0) for key := range mapList { names = append(names, key) } return names } ================================================ FILE: test/azure/terraform_azure_sqldb_example_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package test import ( "strings" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql" "github.com/gruntwork-io/terratest/modules/azure" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestTerraformAzureSQLDBExample(t *testing.T) { t.Parallel() uniquePostfix := strings.ToLower(random.UniqueId()) // website::tag::1:: Configure Terraform setting up a path to Terraform code. terraformOptions := &terraform.Options{ // The path to where our Terraform code is located TerraformDir: "../../examples/azure/terraform-azure-sqldb-example", Vars: map[string]interface{}{ "postfix": uniquePostfix, }, } // website::tag::4:: At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // website::tag::2:: Run `terraform init` and `terraform apply`. Fail the test if there are any errors. terraform.InitAndApply(t, terraformOptions) // website::tag::3:: Run `terraform output` to get the values of output variables expectedSQLServerID := terraform.Output(t, terraformOptions, "sql_server_id") expectedSQLServerName := terraform.Output(t, terraformOptions, "sql_server_name") expectedSQLServerFullDomainName := terraform.Output(t, terraformOptions, "sql_server_full_domain_name") expectedSQLDBName := terraform.Output(t, terraformOptions, "sql_database_name") expectedSQLDBID := terraform.Output(t, terraformOptions, "sql_database_id") expectedResourceGroupName := terraform.Output(t, terraformOptions, "resource_group_name") expectedSQLDBStatus := "Online" // website::tag::4:: Get the SQL server details and assert them against the terraform output actualSQLServer := azure.GetSQLServer(t, expectedResourceGroupName, expectedSQLServerName, "") assert.Equal(t, expectedSQLServerID, *actualSQLServer.ID) assert.Equal(t, expectedSQLServerFullDomainName, *actualSQLServer.Properties.FullyQualifiedDomainName) assert.Equal(t, armsql.ServerStateReady, *actualSQLServer.Properties.State) // website::tag::5:: Get the SQL server DB details and assert them against the terraform output actualSQLDatabase := azure.GetSQLDatabase(t, expectedResourceGroupName, expectedSQLServerName, expectedSQLDBName, "") assert.Equal(t, expectedSQLDBID, *actualSQLDatabase.ID) assert.Equal(t, expectedSQLDBStatus, string(*actualSQLDatabase.Properties.Status)) } ================================================ FILE: test/azure/terraform_azure_sqlmanagedinstance_example_test.go ================================================ //go:build azure_ci_excluded // +build azure_ci_excluded // This test is tagged as !azure to prevent it from being executed from CI workflow, as SQL Managed Instance takes 6-8 hours for deployment // Please refer to examples/azure/terraform-azure-sqlmanagedinstance-example/README.md for more details package test import ( "strings" "testing" "github.com/gruntwork-io/terratest/modules/azure" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestTerraformAzureSQLManagedInstanceExample(t *testing.T) { if testing.Short() { t.Skip("Skipping long-running test") } t.Parallel() uniquePostfix := strings.ToLower(random.UniqueId()) expectedLocation := "westus" expectedAdminLogin := "sqlmiadmin" expectedSQLMIState := "Ready" expectedSKUName := "GP_Gen5" expectedDatabaseName := "testdb" // Configure Terraform setting up a path to Terraform code. terraformOptions := &terraform.Options{ // The path to where our Terraform code is located TerraformDir: "../../examples/azure/terraform-azure-sqlmanagedinstance-example", Vars: map[string]interface{}{ "postfix": uniquePostfix, "location": expectedLocation, "admin_login": expectedAdminLogin, "sku_name": expectedSKUName, "sqlmi_db_name": expectedDatabaseName, }, } // At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // Run `terraform init` and `terraform apply`. Fail the test if there are any errors. terraform.InitAndApply(t, terraformOptions) // Run `terraform output` to get the values of output variables expectedResourceGroupName := terraform.Output(t, terraformOptions, "resource_group_name") expectedManagedInstanceName := terraform.Output(t, terraformOptions, "managed_instance_name") // check for if data factory exists actualManagedInstanceExits := azure.SQLManagedInstanceExists(t, expectedManagedInstanceName, expectedResourceGroupName, "") assert.True(t, actualManagedInstanceExits) // Get the SQL Managed Instance details and assert them against the terraform output actualSQLManagedInstance := azure.GetManagedInstance(t, expectedResourceGroupName, expectedManagedInstanceName, "") actualSQLManagedInstanceDatabase := azure.GetManagedInstanceDatabase(t, expectedResourceGroupName, expectedManagedInstanceName, expectedDatabaseName, "") assert.Equal(t, expectedManagedInstanceName, *actualSQLManagedInstance.Name) assert.Equal(t, expectedLocation, *actualSQLManagedInstance.Location) assert.Equal(t, expectedSKUName, *actualSQLManagedInstance.Sku.Name) assert.Equal(t, expectedSQLMIState, *actualSQLManagedInstance.ManagedInstanceProperties.State) assert.Equal(t, expectedDatabaseName, *actualSQLManagedInstanceDatabase.Name) } ================================================ FILE: test/azure/terraform_azure_storage_example_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package test import ( "fmt" "strings" "testing" "github.com/gruntwork-io/terratest/modules/azure" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestTerraformAzureStorageExample(t *testing.T) { t.Parallel() // subscriptionID is overridden by the environment variable "ARM_SUBSCRIPTION_ID" subscriptionID := "" uniquePostfix := random.UniqueId() // website::tag::1:: Configure Terraform setting up a path to Terraform code. terraformOptions := &terraform.Options{ // The path to where our Terraform code is located TerraformDir: "../../examples/azure/terraform-azure-storage-example", // Variables to pass to our Terraform code using -var options Vars: map[string]interface{}{ "postfix": strings.ToLower(uniquePostfix), }, } // website::tag::4:: At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // website::tag::2:: Run `terraform init` and `terraform apply`. Fail the test if there are any errors. terraform.InitAndApply(t, terraformOptions) // website::tag::3:: Run `terraform output` to get the values of output variables resourceGroupName := terraform.Output(t, terraformOptions, "resource_group_name") storageAccountName := terraform.Output(t, terraformOptions, "storage_account_name") storageAccountTier := terraform.Output(t, terraformOptions, "storage_account_account_tier") storageAccountKind := terraform.Output(t, terraformOptions, "storage_account_account_kind") storageBlobContainerName := terraform.Output(t, terraformOptions, "storage_container_name") // website::tag::4:: Verify storage account properties and ensure it matches the output. storageAccountExists := azure.StorageAccountExists(t, storageAccountName, resourceGroupName, subscriptionID) assert.True(t, storageAccountExists, "storage account does not exist") containerExists := azure.StorageBlobContainerExists(t, storageBlobContainerName, storageAccountName, resourceGroupName, subscriptionID) assert.True(t, containerExists, "storage container does not exist") publicAccess := azure.GetStorageBlobContainerPublicAccess(t, storageBlobContainerName, storageAccountName, resourceGroupName, subscriptionID) assert.False(t, publicAccess, "storage container has public access") accountKind := azure.GetStorageAccountKind(t, storageAccountName, resourceGroupName, subscriptionID) assert.Equal(t, storageAccountKind, accountKind, "storage account kind mismatch") skuTier := azure.GetStorageAccountSkuTier(t, storageAccountName, resourceGroupName, subscriptionID) assert.Equal(t, storageAccountTier, skuTier, "sku tier mismatch") actualDNSString := azure.GetStorageDNSString(t, storageAccountName, resourceGroupName, subscriptionID) storageSuffix, _ := azure.GetStorageURISuffixE() expectedDNS := fmt.Sprintf("https://%s.blob.%s/", storageAccountName, storageSuffix) assert.Equal(t, expectedDNS, actualDNSString, "Storage DNS string mismatch") } ================================================ FILE: test/azure/terraform_azure_synapse_example_test.go ================================================ //go:build azure // +build azure // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package test import ( "strings" "testing" "github.com/gruntwork-io/terratest/modules/azure" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestTerraformAzureSynapseExample(t *testing.T) { t.Parallel() uniquePostfix := strings.ToLower(random.UniqueId()) expectedSynapseSqlUser := "sqladminuser" expectedSynapseProvisioningState := "Succeeded" expectedLocation := "westus2" expectedSyPoolSkuName := "DW100c" // website::tag::1:: Configure Terraform setting up a path to Terraform code. terraformOptions := &terraform.Options{ // The path to where our Terraform code is located TerraformDir: "../../examples/azure/terraform-azure-synapse-example", Vars: map[string]interface{}{ "postfix": uniquePostfix, "synapse_sql_user": expectedSynapseSqlUser, "location": expectedLocation, "synapse_sqlpool_sku_name": expectedSyPoolSkuName, }, } // website::tag::4:: At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // website::tag::2:: Run `terraform init` and `terraform apply`. Fail the test if there are any errors. terraform.InitAndApply(t, terraformOptions) // terraform.InitE(t, terraformOptions) // website::tag::3:: Run `terraform output` to get the values of output variables expectedResourceGroupName := terraform.Output(t, terraformOptions, "resource_group_name") expectedSyDLgen2Name := terraform.Output(t, terraformOptions, "synapse_dlgen2_name") expectedSyWorkspaceName := terraform.Output(t, terraformOptions, "synapse_workspace_name") expectedSqlPoolName := terraform.Output(t, terraformOptions, "synapse_sqlpool_name") // website::tag::4:: Get synapse details and assert them against the terraform output actualSynapseWorkspace := azure.GetSynapseWorkspace(t, expectedResourceGroupName, expectedSyWorkspaceName, "") actualSynapseSqlPool := azure.GetSynapseSqlPool(t, expectedResourceGroupName, expectedSyWorkspaceName, expectedSqlPoolName, "") assert.Equal(t, expectedSyWorkspaceName, *actualSynapseWorkspace.Name) assert.Equal(t, expectedSynapseSqlUser, *actualSynapseWorkspace.WorkspaceProperties.SQLAdministratorLogin) assert.Equal(t, expectedSynapseProvisioningState, *actualSynapseWorkspace.WorkspaceProperties.ProvisioningState) assert.Equal(t, expectedLocation, *actualSynapseWorkspace.Location) assert.Equal(t, expectedSyDLgen2Name, *actualSynapseWorkspace.WorkspaceProperties.DefaultDataLakeStorage.Filesystem) assert.Equal(t, expectedSqlPoolName, *actualSynapseSqlPool.Name) assert.Equal(t, expectedSyPoolSkuName, *actualSynapseSqlPool.Sku.Name) } ================================================ FILE: test/azure/terraform_azure_vm_example_test.go ================================================ //go:build azure || (azureslim && compute) // +build azure azureslim,compute // NOTE: We use build tags to differentiate azure testing because we currently do not have azure access setup for // CircleCI. package test import ( "fmt" "strings" "testing" "github.com/Azure/azure-sdk-for-go/services/compute/mgmt/2019-07-01/compute" "github.com/gruntwork-io/terratest/modules/azure" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestTerraformAzureVmExample(t *testing.T) { t.Parallel() subscriptionID := "" uniquePostfix := random.UniqueId() // Configure Terraform setting up a path to Terraform code. terraformOptions := &terraform.Options{ // The path to where our Terraform code is located. TerraformDir: "../../examples/azure/terraform-azure-vm-example", // Variables to pass to our Terraform code using -var options. Vars: map[string]interface{}{ "postfix": uniquePostfix, }, } // At the end of the test, run `terraform destroy` to clean up any resources that were created. defer terraform.Destroy(t, terraformOptions) // Run `terraform init` and `terraform apply`. Fail the test if there are any errors. terraform.InitAndApply(t, terraformOptions) // Run tests for the Virtual Machine. testStrategiesForVMs(t, terraformOptions, subscriptionID) testMultipleVMs(t, terraformOptions, subscriptionID) testInformationOfVM(t, terraformOptions, subscriptionID) testDisksOfVM(t, terraformOptions, subscriptionID) testNetworkOfVM(t, terraformOptions, subscriptionID) } // These 3 tests check for the same property but illustrate different testing strategies for // retriving the data. The first strategy is used in the other tests of this module while // the other two can be extended by the user as needed. func testStrategiesForVMs(t *testing.T, terraformOptions *terraform.Options, subscriptionID string) { // Run `terraform output` to get the values of output variables. resourceGroupName := terraform.Output(t, terraformOptions, "resource_group_name") virtualMachineName := terraform.Output(t, terraformOptions, "vm_name") expectedVMSize := compute.VirtualMachineSizeTypes(terraform.Output(t, terraformOptions, "vm_size")) // 1. Check the VM Size directly. This strategy gets one specific property of the VM per method. actualVMSize := azure.GetSizeOfVirtualMachine(t, virtualMachineName, resourceGroupName, subscriptionID) assert.Equal(t, expectedVMSize, actualVMSize) // 2. Check the VM size by reference. This strategy is beneficial when checking multiple properties // by using one VM reference. Optional parameters have to be checked first to avoid nil panics. vmByRef := azure.GetVirtualMachine(t, virtualMachineName, resourceGroupName, subscriptionID) actualVMSize = vmByRef.HardwareProfile.VMSize assert.Equal(t, expectedVMSize, actualVMSize) // 3. Check the VM size by instance. This strategy is beneficial when checking multiple properties // by using one VM instance and making calls against it with the added benefit of property check abstraction. vmInstance := azure.Instance{VirtualMachine: vmByRef} actualVMSize = vmInstance.GetVirtualMachineInstanceSize() assert.Equal(t, expectedVMSize, actualVMSize) } // These tests check for the multiple Virtual Machines in a Resource Group. func testMultipleVMs(t *testing.T, terraformOptions *terraform.Options, subscriptionID string) { // Run `terraform output` to get the values of output variables. resourceGroupName := terraform.Output(t, terraformOptions, "resource_group_name") expectedVMName := terraform.Output(t, terraformOptions, "vm_name") expectedVMSize := compute.VirtualMachineSizeTypes(terraform.Output(t, terraformOptions, "vm_size")) expectedAvsName := terraform.Output(t, terraformOptions, "availability_set_name") // Check against all VM names in a Resource Group. vmList := azure.ListVirtualMachinesForResourceGroup(t, resourceGroupName, subscriptionID) expectedVMCount := 1 assert.Equal(t, expectedVMCount, len(vmList)) assert.Contains(t, vmList, expectedVMName) // Check Availability Set for multiple VMs. actualVMsInAvs := azure.GetAvailabilitySetVMNamesInCaps(t, expectedAvsName, resourceGroupName, subscriptionID) assert.Contains(t, actualVMsInAvs, strings.ToUpper(expectedVMName)) // Get all VMs in a Resource Group, including their properties, therefore avoiding // multiple SDK calls. The penalty for this approach is introducing direct references // which need to be checked for nil for optional configurations. vmsByRef := azure.GetVirtualMachinesForResourceGroup(t, resourceGroupName, subscriptionID) thisVM := vmsByRef[expectedVMName] assert.Equal(t, expectedVMSize, thisVM.HardwareProfile.VMSize) // Check for the VM negative test. fakeVM := fmt.Sprintf("vm-%s", random.UniqueId()) assert.Nil(t, vmsByRef[fakeVM].VMID) } // These tests check information directly related to the specified Azure Virtual Machine. func testInformationOfVM(t *testing.T, terraformOptions *terraform.Options, subscriptionID string) { // Run `terraform output` to get the values of output variables. resourceGroupName := terraform.Output(t, terraformOptions, "resource_group_name") virtualMachineName := terraform.Output(t, terraformOptions, "vm_name") expectedVmAdminUser := terraform.OutputList(t, terraformOptions, "vm_admin_username") expectedImageSKU := terraform.OutputList(t, terraformOptions, "vm_image_sku") expectedImageVersion := terraform.OutputList(t, terraformOptions, "vm_image_version") expectedAvsName := terraform.Output(t, terraformOptions, "availability_set_name") expectedVMTags := terraform.OutputMap(t, terraformOptions, "vm_tags") // Check if the Virtual Machine exists. assert.True(t, azure.VirtualMachineExists(t, virtualMachineName, resourceGroupName, subscriptionID)) // Check the Admin User of the VM. actualVM := azure.GetVirtualMachine(t, virtualMachineName, resourceGroupName, subscriptionID) actualVmAdminUser := *actualVM.OsProfile.AdminUsername assert.Equal(t, expectedVmAdminUser[0], actualVmAdminUser) // Check the Storage Image properties of the VM. actualImage := azure.GetVirtualMachineImage(t, virtualMachineName, resourceGroupName, subscriptionID) assert.Contains(t, expectedImageSKU[0], actualImage.SKU) assert.Contains(t, expectedImageVersion[0], actualImage.Version) // Check the Availability Set of the VM. // The AVS ID returned from the VM is always CAPS so ignoring case in the assertion. actualexpectedAvsName := azure.GetVirtualMachineAvailabilitySetID(t, virtualMachineName, resourceGroupName, subscriptionID) assert.True(t, strings.EqualFold(expectedAvsName, actualexpectedAvsName)) // Check the assigned Tags of the VM, assert empty if no tags. actualVMTags := azure.GetVirtualMachineTags(t, virtualMachineName, resourceGroupName, "") assert.Equal(t, expectedVMTags, actualVMTags) } // These tests check the OS Disk and Attached Managed Disks for the Azure Virtual Machine. // The following Terratest Azure module is utilized in addition to the compute module: // - disk // See the terraform_azure_disk_example_test.go for other related tests. func testDisksOfVM(t *testing.T, terraformOptions *terraform.Options, subscriptionID string) { // Run `terraform output` to get the values of output variables. resourceGroupName := terraform.Output(t, terraformOptions, "resource_group_name") virtualMachineName := terraform.Output(t, terraformOptions, "vm_name") expectedOSDiskName := terraform.Output(t, terraformOptions, "os_disk_name") expectedDiskName := terraform.Output(t, terraformOptions, "managed_disk_name") expectedDiskType := terraform.Output(t, terraformOptions, "managed_disk_type") // Check the OS Disk name of the VM. actualOSDiskName := azure.GetVirtualMachineOSDiskName(t, virtualMachineName, resourceGroupName, subscriptionID) assert.Equal(t, expectedOSDiskName, actualOSDiskName) // Check the VM Managed Disk exists in the list of all VM Managed Disks. actualManagedDiskNames := azure.GetVirtualMachineManagedDisks(t, virtualMachineName, resourceGroupName, subscriptionID) assert.Contains(t, actualManagedDiskNames, expectedDiskName) // Check the Managed Disk count of the VM. expectedManagedDiskCount := 1 assert.Equal(t, expectedManagedDiskCount, len(actualManagedDiskNames)) // Check the Disk Type of the Managed Disk of the VM. // This does not apply to VHD disks saved under a storage account. actualDisk := azure.GetDisk(t, expectedDiskName, resourceGroupName, subscriptionID) actualDiskType := actualDisk.Sku.Name assert.Equal(t, compute.DiskStorageAccountTypes(expectedDiskType), actualDiskType) } // These tests check the underlying Virtual Network, Network Interface and associated Public IP Address. // The following Terratest Azure modules are utilized in addition to the compute module: // - networkinterface // - publicaddress // - virtualnetwork // See the terraform_azure_network_example_test.go for other related tests. func testNetworkOfVM(t *testing.T, terraformOptions *terraform.Options, subscriptionID string) { // Run `terraform output` to get the values of output variables. resourceGroupName := terraform.Output(t, terraformOptions, "resource_group_name") virtualMachineName := terraform.Output(t, terraformOptions, "vm_name") expectedVNetName := terraform.Output(t, terraformOptions, "virtual_network_name") expectedSubnetName := terraform.Output(t, terraformOptions, "subnet_name") expectedPublicAddressName := terraform.Output(t, terraformOptions, "public_ip_name") expectedNicName := terraform.Output(t, terraformOptions, "network_interface_name") expectedPrivateIPAddress := terraform.Output(t, terraformOptions, "private_ip") // VirtualNetwork and Subnet tests // Check the Subnet exists in the Virtual Network. actualVnetSubnets := azure.GetVirtualNetworkSubnets(t, expectedVNetName, resourceGroupName, subscriptionID) assert.NotNil(t, actualVnetSubnets[expectedVNetName]) // Check the Private IP is in the Subnet Range. actualVMNicIPInSubnet := azure.CheckSubnetContainsIP(t, expectedPrivateIPAddress, expectedSubnetName, expectedVNetName, resourceGroupName, subscriptionID) assert.True(t, actualVMNicIPInSubnet) // Network Interface Card tests // Check the VM Network Interface exists in the list of all VM Network Interfaces. actualNics := azure.GetVirtualMachineNics(t, virtualMachineName, resourceGroupName, subscriptionID) assert.Contains(t, actualNics, expectedNicName) // Check the Network Interface count of the VM. expectedNICCount := 1 assert.Equal(t, expectedNICCount, len(actualNics)) // Check for the Private IP in the NICs IP list. actualPrivateIPAddress := azure.GetNetworkInterfacePrivateIPs(t, expectedNicName, resourceGroupName, subscriptionID) assert.Contains(t, actualPrivateIPAddress, expectedPrivateIPAddress) // Public IP Address test // Check for the Public IP for the NIC. No expected value since it is assigned runtime. actualPublicIP := azure.GetIPOfPublicIPAddressByName(t, expectedPublicAddressName, resourceGroupName, subscriptionID) assert.NotNil(t, actualPublicIP) } ================================================ FILE: test/docker_hello_world_example_test.go ================================================ package test import ( "testing" "github.com/gruntwork-io/terratest/modules/docker" "github.com/stretchr/testify/assert" ) func TestDockerHelloWorldExample(t *testing.T) { // website::tag::1:: Configure the tag to use on the Docker image. tag := "gruntwork/docker-hello-world-example" buildOptions := &docker.BuildOptions{ Tags: []string{tag}, } // website::tag::2:: Build the Docker image. docker.Build(t, "../examples/docker-hello-world-example", buildOptions) // website::tag::3:: Run the Docker image, read the text file from it, and make sure it contains the expected output. opts := &docker.RunOptions{Command: []string{"cat", "/test.txt"}} output := docker.Run(t, tag, opts) assert.Equal(t, "Hello, World!", output) } ================================================ FILE: test/docker_stdout_example_test.go ================================================ package test import ( "testing" "github.com/gruntwork-io/terratest/modules/docker" "github.com/stretchr/testify/assert" ) func TestDockerComposeStdoutExample(t *testing.T) { t.Parallel() dockerComposeFile := "../examples/docker-compose-stdout-example/docker-compose.yml" // Run the build step first so that the build output doesn't go to stdout during the compose step. docker.RunDockerCompose( t, &docker.Options{}, "-f", dockerComposeFile, "build", ) // Run the Docker image, read the stdout from it, and make sure it contains the expected output. // The script must be run using `run bash_script` rather than `up`, so that the echo output from the script // is the only thing that outputs to stdout. output := docker.RunDockerComposeAndGetStdOut( t, &docker.Options{}, "-f", dockerComposeFile, "run", "bash_script", ) assert.Contains(t, output, "stdout: message") assert.NotContains(t, output, "stderr: error") } ================================================ FILE: test/fixtures/copy-folder-contents/full-copy/.hidden-file.txt ================================================ hidden ================================================ FILE: test/fixtures/copy-folder-contents/full-copy/.terraform-version ================================================ 0.15.5 ================================================ FILE: test/fixtures/copy-folder-contents/full-copy/foo.txt ================================================ foo ================================================ FILE: test/fixtures/copy-folder-contents/full-copy/subfolder/.hidden-folder/baz.txt ================================================ baz ================================================ FILE: test/fixtures/copy-folder-contents/full-copy/subfolder/bar.txt ================================================ bar ================================================ FILE: test/fixtures/copy-folder-contents/no-hidden-files/foo.txt ================================================ foo ================================================ FILE: test/fixtures/copy-folder-contents/no-hidden-files/subfolder/bar.txt ================================================ bar ================================================ FILE: test/fixtures/copy-folder-contents/no-hidden-files-no-terraform-files/.terraform-version ================================================ 0.15.5 ================================================ FILE: test/fixtures/copy-folder-contents/no-hidden-files-no-terraform-files/foo.txt ================================================ foo ================================================ FILE: test/fixtures/copy-folder-contents/no-hidden-files-no-terraform-files/subfolder/bar.txt ================================================ bar ================================================ FILE: test/fixtures/copy-folder-contents/no-state-files/terragrunt.hcl ================================================ locals { foo = "bar" } ================================================ FILE: test/fixtures/copy-folder-contents/original/.hidden-file.txt ================================================ hidden ================================================ FILE: test/fixtures/copy-folder-contents/original/.terraform-version ================================================ 0.15.5 ================================================ FILE: test/fixtures/copy-folder-contents/original/foo.txt ================================================ foo ================================================ FILE: test/fixtures/copy-folder-contents/original/subfolder/.hidden-folder/baz.txt ================================================ baz ================================================ FILE: test/fixtures/copy-folder-contents/original/subfolder/bar.txt ================================================ bar ================================================ FILE: test/fixtures/copy-folder-contents/symlinks/foo.txt ================================================ foo ================================================ FILE: test/fixtures/copy-folder-contents/symlinks/subfolder/bar.txt ================================================ bar ================================================ FILE: test/fixtures/copy-folder-contents/symlinks-broken/foo.txt ================================================ foo ================================================ FILE: test/fixtures/copy-folder-contents/symlinks-broken/subfolder/bar.txt ================================================ bar ================================================ FILE: test/fixtures/copy-folder-contents/terragrunt-files/terragrunt.hcl ================================================ locals { foo = "bar" } ================================================ FILE: test/fixtures/docker/Dockerfile ================================================ # A "Hello, World" Docker image used in automated tests for the docker.Build command. FROM alpine:3.7 as step1 ARG text1 RUN echo $text1 > text.txt CMD ["cat", "text.txt"] FROM step1 ARG text RUN echo $text > text.txt CMD ["cat", "text.txt"] ================================================ FILE: test/fixtures/docker-compose-with-buildkit/Dockerfile ================================================ # A "Hello, World" Docker image used in automated tests for the docker.Build command. FROM ubuntu:20.04 as with-secrets RUN --mount=type=secret,id=github-token echo "$(cat /run/secrets/github-token)" > text.txt COPY ./bash_script.sh /usr/local/bin/bash_script.sh ================================================ FILE: test/fixtures/docker-compose-with-buildkit/bash_script.sh ================================================ #!/bin/bash set -e cat text.txt ================================================ FILE: test/fixtures/docker-compose-with-buildkit/docker-compose.yml ================================================ services: test-docker-image: build: context: . secrets: - github-token entrypoint: bash_script.sh secrets: github-token: environment: GITHUB_OAUTH_TOKEN ================================================ FILE: test/fixtures/docker-compose-with-custom-project-name/docker-compose.yml ================================================ services: test-docker-image: image: busybox ================================================ FILE: test/fixtures/docker-with-buildkit/Dockerfile ================================================ # A "Hello, World" Docker image used in automated tests for the docker.Build command. FROM ubuntu:20.04 as with-secrets RUN --mount=type=secret,id=github-token echo "$(cat /run/secrets/github-token)" > text.txt CMD ["cat", "text.txt"] ================================================ FILE: test/fixtures/helm/keda-values.yaml ================================================ metricsServer: replicaCount: 3 operator: name: keda-operator replicaCount: 3 podAnnotations: keda: sidecar.istio.io/inject: "false" metricsAdapter: sidecar.istio.io/inject: "false" podDisruptionBudget: metricServer: minAvailable: 1 operator: minAvailable: 1 resources: metricServer: limits: cpu: 100m memory: 1234Mi requests: cpu: 50m memory: 128Mi operator: limits: cpu: 100m memory: 1111Mi requests: cpu: 50m memory: 888Mi ================================================ FILE: test/fixtures/terraform-backend/backend.hcl ================================================ path="backend.tfstate" ================================================ FILE: test/fixtures/terraform-backend/main.tf ================================================ terraform { backend "local" {} } output "test" { value = "Hello, World" } ================================================ FILE: test/fixtures/terraform-basic-configuration/main.tf ================================================ variable "cnt" {} resource "null_resource" "test" { count = var.cnt } ================================================ FILE: test/fixtures/terraform-no-error/main.tf ================================================ output "test" { value = "Hello, World" } ================================================ FILE: test/fixtures/terraform-not-idempotent/main.tf ================================================ resource "null_resource" "test" { triggers = { time = timestamp() } } ================================================ FILE: test/fixtures/terraform-null/main.tf ================================================ variable "foo" { type = object({ nullable_string = string nonnullable_string = string }) } output "foo" { value = var.foo } output "bar" { value = var.foo.nullable_string == null ? "I AM NULL" : null } ================================================ FILE: test/fixtures/terraform-output/output.tf ================================================ output "bool" { value = true } output "string" { value = "This is a string." } output "number" { value = 3.14 } output "number1" { value = 3 } output "unicode_string" { value = "söme chäräcter" } ================================================ FILE: test/fixtures/terraform-output-all/output.tf ================================================ output "stars" { value = [ "Sirius", "Rigel", "Betelgeuse", ] } output "our_star" { value = "Sun" } output "constellations" { value = { Gemini = "Pollux", Scorpio = "Antares", Taurus = "Aldebaran", Virgo = "Spica", } } output "magnitudes" { value = { Sirius = -1.46, Canopus = -0.72, Antares = 0.96, } } ================================================ FILE: test/fixtures/terraform-output-list/output.tf ================================================ output "giant_steps" { value = [ "John Coltrane", "Tommy Flanagan", "Paul Chambers", "Art Taylor", ] } output "not_a_list" { value = "This is not a list." } ================================================ FILE: test/fixtures/terraform-output-listofobjects/output.tf ================================================ output "list_of_maps" { value = [ { one = 1 two = "two" three = "three" more = { four = 4 five = "five" } }, { one = "one" two = 2 three = 3 more = [{ four = 4 five = "five" }] }, { one = "one" two = 2 three = 3 more = ["one", 2.0, 3.4, ["one", 2.0, 3.4], { "one" : 2.0, "three" : 3.4 }] } ] } output "not_list_of_maps" { value = "Just a string" } ================================================ FILE: test/fixtures/terraform-output-map/output.tf ================================================ output "mogwai" { value = { guitar_1 = "Stuart Braithwaite" guitar_2 = "Barry Burns" bass = "Dominic Aitchison" drums = "Martin Bulloch" } } output "not_a_map" { value = "This is not a map." } output "not_a_map_unicode" { value = "söme chäräcter" } ================================================ FILE: test/fixtures/terraform-output-mapofobjects/output.tf ================================================ output "map_of_objects" { value = { somebool = true somefloat = 1.1 one = 1 two = "two" three = "three" nest = { four = 4 five = "five" } nest_list = [ { six = 6 seven = "seven" }, ] } } output "not_map_of_objects" { value = "Just a string" } ================================================ FILE: test/fixtures/terraform-output-struct/output.tf ================================================ output "object" { value = { somebool = true somefloat = 0.1 someint = 1 somestring = "two" somemap = { three = 3 four = "four" }, listmaps = [ { five = 5 six = "six" }, ] liststrings = [ "seven", "eight", "nine", ] } } output "list_of_objects" { value = [ { somebool = true somefloat = 0.1 someint = 1 somestring = "two" }, { somebool = false somefloat = 0.3 someint = 4 somestring = "five" } ] } ================================================ FILE: test/fixtures/terraform-parallelism/main.tf ================================================ # This resource just waits for 5 seconds. If we run it with enough parallelism, the whole module should apply in about # 5 seconds. If we set parallelism to 1, it should take at least 25 seconds. resource "null_resource" "wait" { count = 5 triggers = { run_always = timestamp() } provisioner "local-exec" { command = "sleep 5" } } ================================================ FILE: test/fixtures/terraform-validation-valid/main.tf ================================================ # This is a test resource that echoes the message specified by var.message resource "null_resource" "greet" { count = 5 triggers = { run_always = timestamp() } provisioner "local-exec" { command = "echo ${var.message}" } } ================================================ FILE: test/fixtures/terraform-validation-valid/outputs.tf ================================================ output "message" { value = var.message } ================================================ FILE: test/fixtures/terraform-validation-valid/vars.tf ================================================ variable "message" { type = string default = "Hello, World" } ================================================ FILE: test/fixtures/terraform-with-error/main.tf ================================================ resource "null_resource" "fail_on_first_run" { provisioner "local-exec" { command = "if [[ -f terraform.tfstate.backup ]]; then echo 'This is not the first run, so exiting successfully' && exit 0; else echo 'This is the first run, exiting with an error' && exit 1; fi" interpreter = ["/bin/bash", "-c"] } } ================================================ FILE: test/fixtures/terraform-with-plan-error/main.tf ================================================ output "test" { value = var.test } ================================================ FILE: test/fixtures/terraform-with-warning/main.tf ================================================ terraform { required_providers { validation = { source = "tlkamp/validation" version = "1.1.1" } null = { source = "hashicorp/null" version = "3.2.2" } } } # this data source will produce warning when `condition` is evaluated to `true` data "validation_warning" "warn" { for_each = toset([for i in range(10) : format("%02d", i)]) condition = true summary = "lorem ipsum ${each.value}" details = "lorem ipsum dolor sit amet" } resource "null_resource" "empty" {} ================================================ FILE: test/fixtures/terraform-workspace/main.tf ================================================ output "test" { value = "Hello, ${terraform.workspace}" } ================================================ FILE: test/gcp/packer_gcp_basic_example_test.go ================================================ //go:build gcp // +build gcp // NOTE: We use build tags to differentiate GCP testing for better isolation and parallelism when executing our tests. package test import ( "testing" "time" "github.com/gruntwork-io/terratest/modules/gcp" "github.com/gruntwork-io/terratest/modules/packer" ) // Occasionally, a Packer build may fail due to intermittent issues (e.g., brief network outage or EC2 issue). We try // to make our tests resilient to that by specifying those known common errors here and telling our builds to retry if // they hit those errors. var DefaultRetryablePackerErrors = map[string]string{ "Script disconnected unexpectedly": "Occasionally, Packer seems to lose connectivity to AWS, perhaps due to a brief network outage", "can not open /var/lib/apt/lists/archive.ubuntu.com_ubuntu_dists_xenial_InRelease": "Occasionally, apt-get fails on ubuntu to update the cache", } var DefaultTimeBetweenPackerRetries = 15 * time.Second // Regions that support running f1-micro instances var RegionsThatSupportF1Micro = []string{"us-central1", "us-east1", "us-west1", "europe-west1"} // Zones that support running f1-micro instances var ZonesThatSupportF1Micro = []string{"us-central1-a", "us-east1-b", "us-west1-a", "europe-north1-a", "europe-west1-b", "europe-central2-a"} const DefaultMaxPackerRetries = 3 // An example of how to test the Packer template in examples/packer-basic-example using Terratest. func TestPackerGCPBasicExample(t *testing.T) { t.Parallel() // Get the Project Id to use projectID := gcp.GetGoogleProjectIDFromEnvVar(t) // Pick a random GCP zone to test in. This helps ensure your code works in all regions. zone := gcp.GetRandomZone(t, projectID, ZonesThatSupportF1Micro, nil, nil) packerOptions := &packer.Options{ // The path to where the Packer template is located Template: "../../examples/packer-basic-example/build-gcp.pkr.hcl", // Variables to pass to our Packer build using -var options Vars: map[string]string{ "gcp_project_id": projectID, "gcp_zone": zone, }, // Only build the Google Compute Image Only: "googlecompute.ubuntu-bionic", // Configure retries for intermittent errors RetryableErrors: DefaultRetryablePackerErrors, TimeBetweenRetries: DefaultTimeBetweenPackerRetries, MaxRetries: DefaultMaxPackerRetries, } // Make sure the Packer build completes successfully imageName := packer.BuildArtifact(t, packerOptions) // Delete the Image after we're done image := gcp.FetchImage(t, projectID, imageName) defer image.DeleteImage(t) } ================================================ FILE: test/gcp/terraform_gcp_example_test.go ================================================ //go:build gcp // +build gcp // NOTE: We use build tags to differentiate GCP testing for better isolation and parallelism when executing our tests. package test import ( "fmt" "strings" "testing" "time" "github.com/gruntwork-io/terratest/modules/gcp" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/ssh" "github.com/gruntwork-io/terratest/modules/terraform" test_structure "github.com/gruntwork-io/terratest/modules/test-structure" "github.com/stretchr/testify/assert" ) func TestTerraformGcpExample(t *testing.T) { t.Parallel() exampleDir := test_structure.CopyTerraformFolderToTemp(t, "../../", "examples/terraform-gcp-example") // Get the Project Id to use projectId := gcp.GetGoogleProjectIDFromEnvVar(t) // Create all resources in the following zone zone := "us-east1-b" // Give the example bucket a unique name so we can distinguish it from any other bucket in your GCP account expectedBucketName := fmt.Sprintf("terratest-gcp-example-%s", strings.ToLower(random.UniqueId())) // Also give the example instance a unique name expectedInstanceName := fmt.Sprintf("terratest-gcp-example-%s", strings.ToLower(random.UniqueId())) // website::tag::1::Configure Terraform setting path to Terraform code, bucket name, and instance name. Construct // the terraform options with default retryable errors to handle the most common retryable errors in terraform // testing. terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ // The path to where our Terraform code is located TerraformDir: exampleDir, // Variables to pass to our Terraform code using -var options Vars: map[string]interface{}{ "gcp_project_id": projectId, "zone": zone, "instance_name": expectedInstanceName, "bucket_name": expectedBucketName, }, }) // website::tag::5::At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // website::tag::2::This will run `terraform init` and `terraform apply` and fail the test if there are any errors terraform.InitAndApply(t, terraformOptions) // Run `terraform output` to get the value of some of the output variables bucketURL := terraform.Output(t, terraformOptions, "bucket_url") instanceName := terraform.Output(t, terraformOptions, "instance_name") // website::tag::3::Verify that the new bucket url matches the expected url expectedURL := fmt.Sprintf("gs://%s", expectedBucketName) assert.Equal(t, expectedURL, bucketURL) // Verify that the Storage Bucket exists gcp.AssertStorageBucketExists(t, expectedBucketName) // Add a tag to the Compute Instance instance := gcp.FetchInstance(t, projectId, instanceName) instance.SetLabels(t, map[string]string{"testing": "testing-tag-value2"}) // Check for the labels within a retry loop as it can sometimes take a while for the // changes to propagate. maxRetries := 12 timeBetweenRetries := 5 * time.Second expectedText := "testing-tag-value2" // website::tag::4::Check if the GCP instance contains a given tag. retry.DoWithRetry(t, fmt.Sprintf("Checking Instance %s for labels", instanceName), maxRetries, timeBetweenRetries, func() (string, error) { // Look up the tags for the given Instance ID instance := gcp.FetchInstance(t, projectId, instanceName) instanceLabels := instance.GetLabels(t) testingTag, containsTestingTag := instanceLabels["testing"] actualText := strings.TrimSpace(testingTag) if !containsTestingTag { return "", fmt.Errorf("Expected the tag 'testing' to exist") } if actualText != expectedText { return "", fmt.Errorf("Expected GetLabelsForComputeInstanceE to return '%s' but got '%s'", expectedText, actualText) } return "", nil }) } // Create a Compute Instance, and attempt to SSH in and run a command. func TestSshAccessToComputeInstance(t *testing.T) { t.Parallel() exampleDir := test_structure.CopyTerraformFolderToTemp(t, "../../", "examples/terraform-gcp-example") // Setup values for our Terraform apply projectID := gcp.GetGoogleProjectIDFromEnvVar(t) randomValidGcpName := gcp.RandomValidGcpName() zone := gcp.GetRandomZone(t, projectID, ZonesThatSupportF1Micro, nil, nil) terraformOptions := &terraform.Options{ // The path to where our Terraform code is located TerraformDir: exampleDir, // Variables to pass to our Terraform code using -var options Vars: map[string]interface{}{ "gcp_project_id": projectID, "instance_name": randomValidGcpName, "bucket_name": randomValidGcpName, "zone": zone, }, } // At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // This will run `terraform init` and `terraform apply` and fail the test if there are any errors terraform.InitAndApply(t, terraformOptions) // Run `terraform output` to get the value of an output variable publicIp := terraform.Output(t, terraformOptions, "public_ip") // Attempt to SSH and execute the command instance := gcp.FetchInstance(t, projectID, randomValidGcpName) sampleText := "Hello World" sshUsername := "terratest" keyPair := ssh.GenerateRSAKeyPair(t, 2048) instance.AddSshKey(t, sshUsername, keyPair.PublicKey) host := ssh.Host{ Hostname: publicIp, SshKeyPair: keyPair, SshUserName: sshUsername, } maxRetries := 20 sleepBetweenRetries := 3 * time.Second retry.DoWithRetry(t, "Attempting to SSH", maxRetries, sleepBetweenRetries, func() (string, error) { output, err := ssh.CheckSshCommandE(t, host, fmt.Sprintf("echo '%s'", sampleText)) if err != nil { return "", err } if strings.TrimSpace(sampleText) != strings.TrimSpace(output) { return "", fmt.Errorf("Expected: %s. Got: %s\n", sampleText, output) } return "", nil }) } ================================================ FILE: test/gcp/terraform_gcp_hello_world_example_test.go ================================================ //go:build gcp // +build gcp // NOTE: We use build tags to differentiate GCP testing for better isolation and parallelism when executing our tests. package test import ( "fmt" "strings" "testing" "github.com/gruntwork-io/terratest/modules/gcp" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" ) func TestTerraformGcpHelloWorldExample(t *testing.T) { t.Parallel() // website::tag::1:: Get the Project Id to use projectId := gcp.GetGoogleProjectIDFromEnvVar(t) // website::tag::2:: Give the example instance a unique name instanceName := fmt.Sprintf("gcp-hello-world-example-%s", strings.ToLower(random.UniqueId())) // website::tag::6:: Construct the terraform options with default retryable errors to handle the most common // retryable errors in terraform testing. terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ // website::tag::3:: The path to where our Terraform code is located TerraformDir: "../../examples/terraform-gcp-hello-world-example", // website::tag::4:: Variables to pass to our Terraform code using -var options Vars: map[string]interface{}{ "instance_name": instanceName, }, // website::tag::5:: Variables to pass to our Terraform code using TF_VAR_xxx environment variables EnvVars: map[string]string{ "GOOGLE_CLOUD_PROJECT": projectId, }, }) // website::tag::8:: At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // website::tag::7:: Run `terraform init` and `terraform apply`. Fail the test if there are any errors. terraform.InitAndApply(t, terraformOptions) } ================================================ FILE: test/gcp/terraform_gcp_ig_example_test.go ================================================ //go:build gcp // +build gcp // NOTE: We use build tags to differentiate GCP testing for better isolation and parallelism when executing our tests. package test import ( "fmt" "testing" "time" "github.com/gruntwork-io/terratest/modules/gcp" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/terraform" test_structure "github.com/gruntwork-io/terratest/modules/test-structure" ) func TestTerraformGcpInstanceGroupExample(t *testing.T) { t.Parallel() exampleDir := test_structure.CopyTerraformFolderToTemp(t, "../../", "examples/terraform-gcp-ig-example") // Setup values for our Terraform apply projectId := gcp.GetGoogleProjectIDFromEnvVar(t) region := gcp.GetRandomRegion(t, projectId, RegionsThatSupportF1Micro, nil) randomValidGcpName := gcp.RandomValidGcpName() clusterSize := 3 terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ // The path to where our Terraform code instances located TerraformDir: exampleDir, // Variables to pass to our Terraform code using -var options Vars: map[string]interface{}{ "gcp_project_id": projectId, "gcp_region": region, "cluster_name": randomValidGcpName, }, }) // At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // This will run `terraform init` and `terraform apply` and fail the test if there are any errors terraform.InitAndApply(t, terraformOptions) instanceGroupName := terraform.Output(t, terraformOptions, "instance_group_name") instanceGroup := gcp.FetchRegionalInstanceGroup(t, projectId, region, instanceGroupName) // Validate that GetInstances() returns a non-zero number of Instances maxRetries := 100 sleepBetweenRetries := 3 * time.Second retry.DoWithRetry(t, "Attempting to fetch Instances from Instance Group", maxRetries, sleepBetweenRetries, func() (string, error) { instances, err := instanceGroup.GetInstancesE(t, projectId) if err != nil { return "", fmt.Errorf("Failed to get Instances: %s", err) } if len(instances) != clusterSize { return "", fmt.Errorf("Expected to find exactly %d Compute Instances in Instance Group but found %d.", clusterSize, len(instances)) } return "", nil }) // Validate that we get the right number of IP addresses retry.DoWithRetry(t, "Attempting to fetch Public IP addresses from Instance Group", maxRetries, sleepBetweenRetries, func() (string, error) { ips, err := instanceGroup.GetPublicIpsE(t, projectId) if err != nil { return "", fmt.Errorf("Failed to get public IPs from Instance Group") } if len(ips) != clusterSize { return "", fmt.Errorf("Expected to get exactly %d public IP addresses but found %d.", clusterSize, len(ips)) } return "", nil }) } ================================================ FILE: test/helm_basic_example_integration_test.go ================================================ //go:build kubeall || helm // +build kubeall helm // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests, and further differentiate helm // tests. This is done because minikube is heavy and can interfere with docker related tests in terratest. Similarly, // helm can overload the minikube system and thus interfere with the other kubernetes tests. To avoid overloading the // system, we run the kubernetes tests and helm tests separately from the others. package test import ( "crypto/tls" "fmt" "net/http" "path/filepath" "strings" "testing" "time" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terratest/modules/helm" http_helper "github.com/gruntwork-io/terratest/modules/http-helper" "github.com/gruntwork-io/terratest/modules/k8s" "github.com/gruntwork-io/terratest/modules/random" ) // This file contains examples of how to use terratest to test helm charts by deploying the chart and verifying the // deployment by hitting the service endpoint. func TestHelmBasicExampleDeployment(t *testing.T) { t.Parallel() // Path to the helm chart we will test helmChartPath, err := filepath.Abs("../examples/helm-basic-example") require.NoError(t, err) // To ensure we can reuse the resource config on the same cluster to test different scenarios, we setup a unique // namespace for the resources for this test. // Note that namespaces must be lowercase. namespaceName := fmt.Sprintf("helm-basic-example-%s", strings.ToLower(random.UniqueId())) // Setup the kubectl config and context. Here we choose to use the defaults, which is: // - HOME/.kube/config for the kubectl config file // - Current context of the kubectl config file kubectlOptions := k8s.NewKubectlOptions("", "", namespaceName) k8s.CreateNamespace(t, kubectlOptions, namespaceName) // ... and make sure to delete the namespace at the end of the test defer k8s.DeleteNamespace(t, kubectlOptions, namespaceName) // Setup the args. For this test, we will set the following input values: // - containerImageRepo=nginx // - containerImageTag=1.15.8 options := &helm.Options{ KubectlOptions: kubectlOptions, SetValues: map[string]string{ "containerImageRepo": "nginx", "containerImageTag": "1.15.8", }, ExtraArgs: map[string][]string{ "install": []string{"--wait", "--timeout", "1m30s"}, }, } // We generate a unique release name so that we can refer to after deployment. // By doing so, we can schedule the delete call here so that at the end of the test, we run // `helm delete RELEASE_NAME` to clean up any resources that were created. releaseName := fmt.Sprintf( "nginx-service-%s", strings.ToLower(random.UniqueId()), ) defer helm.Delete(t, options, releaseName, true) // Deploy the chart using `helm install`. Note that we use the version without `E`, since we want to assert the // install succeeds without any errors. helm.Install(t, options, helmChartPath, releaseName) // Now let's verify the deployment. We will get the service endpoint and try to access it. // First we need to get the service name. We will use domain knowledge of the chart here, where the name is // RELEASE_NAME-CHART_NAME serviceName := fmt.Sprintf("%s-helm-basic-example", releaseName) // Next we wait until the service is available. This will wait up to 10 seconds for the service to become available, // to ensure that we can access it. k8s.WaitUntilServiceAvailable(t, kubectlOptions, serviceName, 10, 1*time.Second) // Now we open a tunnel to port forward service port to localhost tunnel := k8s.NewTunnel( kubectlOptions, k8s.ResourceTypeService, serviceName, 0, 80) defer tunnel.Close() tunnel.ForwardPort(t) // Get endpoint endpoint := tunnel.Endpoint() // Setup a TLS configuration to submit with the helper, a blank struct is acceptable tlsConfig := tls.Config{} // Test the endpoint for up to 5 minutes. This will only fail if we timeout waiting for the service to return a 200 // response. http_helper.HttpGetWithRetryWithCustomValidation( t, fmt.Sprintf("http://%s", endpoint), &tlsConfig, 30, 10*time.Second, func(statusCode int, body string) bool { return statusCode == http.StatusOK }, ) } ================================================ FILE: test/helm_basic_example_template_test.go ================================================ //go:build kubeall || helm // +build kubeall helm // **NOTE**: we have build tags to differentiate kubernetes tests from non-kubernetes tests, and further differentiate helm // tests. This is done because minikube is heavy and can interfere with docker related tests in terratest. Similarly, helm // can overload the minikube system and thus interfere with the other kubernetes tests. Specifically, many of the tests // start to fail with `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes // tests and helm tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. // We recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package test import ( "path/filepath" "strings" "testing" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" "github.com/gruntwork-io/terratest/modules/helm" "github.com/gruntwork-io/terratest/modules/k8s" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/random" ) // This file contains examples of how to use terratest to test helm chart template logic by rendering the templates // using `helm template`, and then reading in the rendered templates. // There are two tests: // - TestHelmBasicExampleTemplateRenderedDeployment: An example of how to read in the rendered object and check the // computed values. // - TestHelmBasicExampleTemplateRequiredTemplateArgs: An example of how to check that the required args are indeed // required for the template to render. // An example of how to verify the rendered template object of a Helm Chart given various inputs. func TestHelmBasicExampleTemplateRenderedDeployment(t *testing.T) { t.Parallel() // Path to the helm chart we will test helmChartPath, err := filepath.Abs("../examples/helm-basic-example") releaseName := "helm-basic" require.NoError(t, err) // Since we aren't deploying any resources, there is no need to setup kubectl authentication or helm home. // Set up the namespace; confirm that the template renders the expected value for the namespace. namespaceName := "medieval-" + strings.ToLower(random.UniqueId()) logger.Logf(t, "Namespace: %s\n", namespaceName) // Setup the args. For this test, we will set the following input values: // - containerImageRepo=nginx // - containerImageTag=1.15.8 options := &helm.Options{ SetValues: map[string]string{ "containerImageRepo": "nginx", "containerImageTag": "1.15.8", }, KubectlOptions: k8s.NewKubectlOptions("", "", namespaceName), } // Run RenderTemplate to render the template and capture the output. Note that we use the version without `E`, since // we want to assert that the template renders without any errors. // Additionally, although we know there is only one yaml file in the template, we deliberately path a templateFiles // arg to demonstrate how to select individual templates to render. output := helm.RenderTemplate(t, options, helmChartPath, releaseName, []string{"templates/deployment.yaml"}) // Now we use kubernetes/client-go library to render the template output into the Deployment struct. This will // ensure the Deployment resource is rendered correctly. var deployment appsv1.Deployment helm.UnmarshalK8SYaml(t, output, &deployment) // Verify the namespace matches the expected supplied namespace. require.Equal(t, namespaceName, deployment.Namespace) // Finally, we verify the deployment pod template spec is set to the expected container image value expectedContainerImage := "nginx:1.15.8" deploymentContainers := deployment.Spec.Template.Spec.Containers require.Equal(t, len(deploymentContainers), 1) require.Equal(t, deploymentContainers[0].Image, expectedContainerImage) } // An example of how to verify required values for a helm chart. func TestHelmBasicExampleTemplateRequiredTemplateArgs(t *testing.T) { t.Parallel() // Path to the helm chart we will test helmChartPath, err := filepath.Abs("../examples/helm-basic-example") releaseName := "helm-basic" require.NoError(t, err) // Since we aren't deploying any resources, there is no need to setup kubectl authentication, helm home, or // namespaces // Here, we use a table driven test to iterate through all the required values as subtests. You can learn more about // go subtests here: https://blog.golang.org/subtests // The struct captures the inputs that we will pass to helm template and a human friendly name so we can identify it // in the test output. In this case, each test case will be a complete values input except for one of the required // values missing, to test that neglecting a required value will cause the template rendering to fail. testCases := []struct { name string values map[string]string }{ { "MissingContainerImageRepo", map[string]string{"containerImageTag": "1.15.8"}, }, { "MissingContainerImageTag", map[string]string{"containerImageRepo": "nginx"}, }, } // Now we iterate over each test case and spawn a sub test for _, testCase := range testCases { // Here, we capture the range variable and force it into the scope of this block. If we don't do this, when the // subtest switches contexts (because of t.Parallel), the testCase value will have been updated by the for loop // and will be the next testCase! testCase := testCase // The actual sub test spawning. We name the sub test using the human friendly name. Note that we name the sub // test T struct to subT to make it clear which T struct corresponds to which test. However, in most cases you // will not reference the main test T so you can name it the same. t.Run(testCase.name, func(subT *testing.T) { subT.Parallel() // Now we try rendering the template, but verify we get an error options := &helm.Options{SetValues: testCase.values} _, err := helm.RenderTemplateE(t, options, helmChartPath, releaseName, []string{}) require.Error(t, err) }) } } ================================================ FILE: test/helm_dependency_example_template_test.go ================================================ //go:build kubeall || helm // +build kubeall helm // **NOTE**: we have build tags to differentiate kubernetes tests from non-kubernetes tests, and further differentiate helm // tests. This is done because minikube is heavy and can interfere with docker related tests in terratest. Similarly, helm // can overload the minikube system and thus interfere with the other kubernetes tests. Specifically, many of the tests // start to fail with `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes // tests and helm tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. // We recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package test import ( "path/filepath" "strings" "testing" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" "github.com/gruntwork-io/terratest/modules/helm" "github.com/gruntwork-io/terratest/modules/k8s" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/random" ) // This file contains examples of how to use terratest to test helm chart template logic by rendering the templates // using `helm template`, and then reading in the rendered templates. // There are two tests: // - TestHelmBasicExampleTemplateRenderedDeployment: An example of how to read in the rendered object and check the // computed values. // - TestHelmBasicExampleTemplateRequiredTemplateArgs: An example of how to check that the required args are indeed // required for the template to render. // An example of how to verify the rendered template object of a Helm Chart given various inputs. func TestHelmDependencyExampleTemplateRenderedDeployment(t *testing.T) { t.Parallel() // Path to the helm chart we will test helmChartPath, err := filepath.Abs("../examples/helm-dependency-example") releaseName := "helm-dependency" require.NoError(t, err) // Since we aren't deploying any resources, there is no need to setup kubectl authentication or helm home. // Set up the namespace; confirm that the template renders the expected value for the namespace. namespaceName := "medieval-" + strings.ToLower(random.UniqueId()) logger.Logf(t, "Namespace: %s\n", namespaceName) // Setup the args. For this test, we will set the following input values: // - containerImageRepo=nginx // - containerImageTag=1.15.8 options := &helm.Options{ SetValues: map[string]string{ "containerImageRepo": "nginx", "containerImageTag": "1.15.8", "basic.containerImageRepo": "nginx", "basic.containerImageTag": "1.15.8", }, KubectlOptions: k8s.NewKubectlOptions("", "", namespaceName), BuildDependencies: true, } testCases := []struct { name string templateName string }{ { "dependent chart", "templates/deployment.yaml", }, { "basic chart", "charts/basic/templates/deployment.yaml", }, } for _, testCase := range testCases { testCase := testCase t.Run(testCase.name, func(subT *testing.T) { // subT.Parallel() // Run RenderTemplate to render the template and capture the output. Note that we use the version without `E`, since // we want to assert that the template renders without any errors. // Additionally, although we know there is only one yaml file in the template, we deliberately path a templateFiles // arg to demonstrate how to select individual templates to render. output := helm.RenderTemplate(t, options, helmChartPath, releaseName, []string{testCase.templateName}) // Now we use kubernetes/client-go library to render the template output into the Deployment struct. This will // ensure the Deployment resource is rendered correctly. var deployment appsv1.Deployment helm.UnmarshalK8SYaml(t, output, &deployment) // Verify the namespace matches the expected supplied namespace. require.Equal(t, namespaceName, deployment.Namespace) // Finally, we verify the deployment pod template spec is set to the expected container image value expectedContainerImage := "nginx:1.15.8" deploymentContainers := deployment.Spec.Template.Spec.Containers require.Equal(t, len(deploymentContainers), 1) require.Equal(t, deploymentContainers[0].Image, expectedContainerImage) }) } } // An example of how to verify required values for a helm chart. func TestHelmDependencyExampleTemplateRequiredTemplateArgs(t *testing.T) { t.Parallel() // Path to the helm chart we will test helmChartPath, err := filepath.Abs("../examples/helm-dependency-example") releaseName := "helm-dependency" require.NoError(t, err) // Since we aren't deploying any resources, there is no need to setup kubectl authentication, helm home, or // namespaces // Here, we use a table driven test to iterate through all the required values as subtests. You can learn more about // go subtests here: https://blog.golang.org/subtests // The struct captures the inputs that we will pass to helm template and a human friendly name so we can identify it // in the test output. In this case, each test case will be a complete values input except for one of the required // values missing, to test that neglecting a required value will cause the template rendering to fail. testCases := []struct { name string values map[string]string }{ { "MissingContainerImageRepo in dependent chart", map[string]string{ "containerImageTag": "1.15.8", "basic.containerImageRepo": "nginx", "basic.containerImageTag": "1.15.8", }, }, { "MissingContainerImageRepo in basic chart", map[string]string{ "basic.containerImageTag": "1.15.8", "containerImageRepo": "nginx", "containerImageTag": "1.15.8", }, }, { "MissingContainerImageTag in dependent chart", map[string]string{ "containerImageRepo": "nginx", "basic.containerImageRepo": "nginx", "basic.containerImageTag": "1.15.8", }, }, { "MissingContainerImageTag in basic chart", map[string]string{ "basic.containerImageRepo": "nginx", "containerImageRepo": "nginx", "containerImageTag": "1.15.8", }, }, } // Now we iterate over each test case and spawn a sub test for _, testCase := range testCases { // Here, we capture the range variable and force it into the scope of this block. If we don't do this, when the // subtest switches contexts (because of t.Parallel), the testCase value will have been updated by the for loop // and will be the next testCase! testCase := testCase // The actual sub test spawning. We name the sub test using the human friendly name. Note that we name the sub // test T struct to subT to make it clear which T struct corresponds to which test. However, in most cases you // will not reference the main test T so you can name it the same. t.Run(testCase.name, func(subT *testing.T) { // subT.Parallel() // Now we try rendering the template, but verify we get an error options := &helm.Options{SetValues: testCase.values} _, err := helm.RenderTemplateE(t, options, helmChartPath, releaseName, []string{}) require.Error(t, err) }) } } ================================================ FILE: test/helm_keda_remote_example_template_snapshot_test.go ================================================ //go:build kubeall || helm // +build kubeall helm // **NOTE**: we have build tags to differentiate kubernetes tests from non-kubernetes tests, and further differentiate helm // tests. This is done because minikube is heavy and can interfere with docker related tests in terratest. Similarly, helm // can overload the minikube system and thus interfere with the other kubernetes tests. Specifically, many of the tests // start to fail with `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes // tests and helm tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. // We recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package test import ( "strings" "testing" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" "github.com/gruntwork-io/terratest/modules/helm" "github.com/gruntwork-io/terratest/modules/k8s" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/random" ) // This file contains an example of how to use terratest to test *remote* helm chart template logic by rendering the templates // using `helm template`, and then reading in the rendered templates. // - TestHelmKedaRemoteExampleTemplateRenderedDeployment: An example of how to read in the rendered object and check the // computed values. // An example of how to verify the rendered template object of a Helm Chart given various inputs. func TestHelmKedaRemoteExampleTemplateRenderedDeploymentDump(t *testing.T) { // chart name releaseName := "keda" // Set up the namespace; confirm that the template renders the expected value for the namespace. namespaceName := "medieval-" + strings.ToLower(random.UniqueId()) logger.Logf(t, "Namespace: %s\n", namespaceName) // Setup the args. For this test, we will set the following input values: options := &helm.Options{ SetValues: map[string]string{ "metricsServer.replicaCount": "999", "resources.metricServer.limits.memory": "1234Mi", }, KubectlOptions: k8s.NewKubectlOptions("", "", namespaceName), Logger: logger.Discard, } // Run RenderTemplate to render the *remote* template and capture the output. Note that we use the version without `E`, since // we want to assert that the template renders without any errors. // Additionally, we path a the templateFile for which we are setting test values to // demonstrate how to select individual templates to render. output := helm.RenderRemoteTemplate(t, options, "https://kedacore.github.io/charts", releaseName, []string{"templates/metrics-server/deployment.yaml"}) // Now we use kubernetes/client-go library to render the template output into the Deployment struct. This will // ensure the Deployment resource is rendered correctly. var deployment appsv1.Deployment helm.UnmarshalK8SYaml(t, output, &deployment) // Verify the namespace matches the expected supplied namespace. require.Equal(t, namespaceName, deployment.Namespace) // Finally, we verify the deployment pod template spec is set to the expected container image value var expectedMetricsServerReplica int32 expectedMetricsServerReplica = 999 deploymentMetricsServerReplica := *deployment.Spec.Replicas require.Equal(t, expectedMetricsServerReplica, deploymentMetricsServerReplica) // write chart manifest to a local filesystem directory helm.UpdateSnapshot(t, options, output, releaseName) } // An example of how to verify the rendered template object of a Helm Chart given various inputs. func TestHelmKedaRemoteExampleTemplateRenderedDeploymentDiff(t *testing.T) { // chart name releaseName := "keda" // Set up the namespace; confirm that the template renders the expected value for the namespace. namespaceName := "medieval-" + strings.ToLower(random.UniqueId()) logger.Logf(t, "Namespace: %s\n", namespaceName) // Setup the args. For this test, we will set the following input values: options := &helm.Options{ SetValues: map[string]string{ "metricsServer.replicaCount": "666", "resources.metricServer.limits.memory": "4321Mi", }, KubectlOptions: k8s.NewKubectlOptions("", "", namespaceName), Logger: logger.Discard, } // Run RenderTemplate to render the *remote* template and capture the output. Note that we use the version without `E`, since // we want to assert that the template renders without any errors. // Additionally, we path a the templateFile for which we are setting test values to // demonstrate how to select individual templates to render. output := helm.RenderRemoteTemplate(t, options, "https://kedacore.github.io/charts", releaseName, []string{"templates/metrics-server/deployment.yaml"}) // Now we use kubernetes/client-go library to render the template output into the Deployment struct. This will // ensure the Deployment resource is rendered correctly. var deployment appsv1.Deployment helm.UnmarshalK8SYaml(t, output, &deployment) // Verify the namespace matches the expected supplied namespace. require.Equal(t, namespaceName, deployment.Namespace) // Finally, we verify the deployment pod template spec is set to the expected container image value var expectedMetricsServerReplica int32 expectedMetricsServerReplica = 666 deploymentMetricsServerReplica := *deployment.Spec.Replicas require.Equal(t, expectedMetricsServerReplica, deploymentMetricsServerReplica) // run the diff and assert the number of diffs require.Equal(t, 4, helm.DiffAgainstSnapshot(t, options, output, releaseName)) } // An example of how to store a snapshot of the current manaifest for future comparison func TestHelmKedaRemoteExampleTemplateRenderedPackageDump(t *testing.T) { // chart name releaseName := "keda" // Set up the namespace; confirm that the template renders the expected value for the namespace. namespaceName := "medieval-" + strings.ToLower(random.UniqueId()) logger.Logf(t, "Namespace: %s\n", namespaceName) // Setup the args. For this test, we will set the following input values: options := &helm.Options{ SetValues: map[string]string{ "metricsServer.replicaCount": "999", "resources.metricServer.limits.memory": "1234Mi", }, KubectlOptions: k8s.NewKubectlOptions("", "", namespaceName), Logger: logger.Discard, } // Run RenderTemplate to render the *remote* template and capture the output. Note that we use the version without `E`, since // we want to assert that the template renders without any errors. // Additionally, we path a the templateFile for which we are setting test values to // demonstrate how to select individual templates to render. output := helm.RenderRemoteTemplate(t, options, "https://kedacore.github.io/charts", releaseName, []string{}) // write chart manifest to a local filesystem directory helm.UpdateSnapshot(t, options, output, releaseName) } // An example of how to verify the current helm k8s manifest against a previous snapshot func TestHelmKedaRemoteExampleTemplateRenderedPackageDiff(t *testing.T) { // chart name releaseName := "keda" // Set up the namespace; confirm that the template renders the expected value for the namespace. namespaceName := "medieval-" + strings.ToLower(random.UniqueId()) logger.Logf(t, "Namespace: %s\n", namespaceName) // Setup the args. For this test, we will set the following input values: options := &helm.Options{ SetValues: map[string]string{ "metricsServer.replicaCount": "666", "resources.metricServer.limits.memory": "4321Mi", }, KubectlOptions: k8s.NewKubectlOptions("", "", namespaceName), Logger: logger.Discard, } // Run RenderTemplate to render the *remote* template and capture the output. Note that we use the version without `E`, since // we want to assert that the template renders without any errors. // Additionally, we path a the templateFile for which we are setting test values to // demonstrate how to select individual templates to render. output := helm.RenderRemoteTemplate(t, options, "https://kedacore.github.io/charts", releaseName, []string{}) // run the diff and assert the number of diffs matches the number of diffs in the snapshot // (namespace diffs + intentional value changes for replicas/memory) require.Equal(t, 27, helm.DiffAgainstSnapshot(t, options, output, releaseName)) } ================================================ FILE: test/helm_keda_remote_example_template_test.go ================================================ //go:build kubeall || helm // +build kubeall helm // **NOTE**: we have build tags to differentiate kubernetes tests from non-kubernetes tests, and further differentiate helm // tests. This is done because minikube is heavy and can interfere with docker related tests in terratest. Similarly, helm // can overload the minikube system and thus interfere with the other kubernetes tests. Specifically, many of the tests // start to fail with `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes // tests and helm tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. // We recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package test import ( "strings" "testing" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" "github.com/gruntwork-io/terratest/modules/helm" "github.com/gruntwork-io/terratest/modules/k8s" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/random" ) // This file contains an example of how to use terratest to test *remote* helm chart template logic by rendering the templates // using `helm template`, and then reading in the rendered templates. // - TestHelmKedaRemoteExampleTemplateRenderedDeployment: An example of how to read in the rendered object and check the // computed values. // An example of how to verify the rendered template object of a Helm Chart given various inputs. func TestHelmKedaRemoteExampleTemplateRenderedDeployment(t *testing.T) { t.Parallel() // chart name releaseName := "keda" // Set up the namespace; confirm that the template renders the expected value for the namespace. namespaceName := "medieval-" + strings.ToLower(random.UniqueId()) logger.Logf(t, "Namespace: %s\n", namespaceName) // Setup the args. For this test, we will set the following input values: options := &helm.Options{ SetValues: map[string]string{ "metricsServer.replicaCount": "999", "resources.metricServer.limits.memory": "1234Mi", }, KubectlOptions: k8s.NewKubectlOptions("", "", namespaceName), Logger: logger.Discard, } // Run RenderTemplate to render the *remote* template and capture the output. Note that we use the version without `E`, since // we want to assert that the template renders without any errors. // Additionally, we path a the templateFile for which we are setting test values to // demonstrate how to select individual templates to render. output := helm.RenderRemoteTemplate(t, options, "https://kedacore.github.io/charts", releaseName, []string{"templates/metrics-server/deployment.yaml"}) // Now we use kubernetes/client-go library to render the template output into the Deployment struct. This will // ensure the Deployment resource is rendered correctly. var deployment appsv1.Deployment helm.UnmarshalK8SYaml(t, output, &deployment) // Verify the namespace matches the expected supplied namespace. require.Equal(t, namespaceName, deployment.Namespace) // Finally, we verify the deployment pod template spec is set to the expected container image value var expectedMetricsServerReplica int32 expectedMetricsServerReplica = 999 deploymentMetricsServerReplica := *deployment.Spec.Replicas require.Equal(t, expectedMetricsServerReplica, deploymentMetricsServerReplica) expectedContainerRLM := "1234Mi" deploymentContainers := deployment.Spec.Template.Spec.Containers require.Equal(t, len(deploymentContainers), 1) currentContainerRLM := deploymentContainers[0].Resources.Limits.Memory().String() require.Equal(t, currentContainerRLM, expectedContainerRLM) } // An example of how to verify the rendered template object of a Helm Chart given input from a `values.yaml` file. func TestHelmKedaRemoteExampleTemplateRenderedValuesFileFixtureDeployment(t *testing.T) { t.Parallel() // chart name releaseName := "keda" // Set up the namespace; confirm that the template renders the expected value for the namespace. namespaceName := "medieval-" + strings.ToLower(random.UniqueId()) logger.Logf(t, "Namespace: %s\n", namespaceName) options := &helm.Options{ ValuesFiles: []string{"./fixtures/helm/keda-values.yaml"}, KubectlOptions: k8s.NewKubectlOptions("", "", namespaceName), Logger: logger.Discard, } // Run RenderTemplate to render the *remote* template and capture the output. Note that we use the version without `E`, since // we want to assert that the template renders without any errors. // Additionally, we path a the templateFile for which we are setting test values to // demonstrate how to select individual templates to render. output := helm.RenderRemoteTemplate(t, options, "https://kedacore.github.io/charts", releaseName, []string{"templates/metrics-server/deployment.yaml"}) // Now we use kubernetes/client-go library to render the template output into the Deployment struct. This will // ensure the Deployment resource is rendered correctly. var deployment appsv1.Deployment helm.UnmarshalK8SYaml(t, output, &deployment) // Verify the namespace matches the expected supplied namespace. require.Equal(t, namespaceName, deployment.Namespace) // Finally, we verify the deployment pod template spec is set to the expected value var expectedMetricsServerReplica int32 expectedMetricsServerReplica = 3 deploymentMetricsServerReplica := *deployment.Spec.Replicas require.Equal(t, expectedMetricsServerReplica, deploymentMetricsServerReplica) expectedContainerRLM := "1234Mi" deploymentContainers := deployment.Spec.Template.Spec.Containers require.Equal(t, len(deploymentContainers), 1) currentContainerRLM := deploymentContainers[0].Resources.Limits.Memory().String() require.Equal(t, currentContainerRLM, expectedContainerRLM) } ================================================ FILE: test/helm_log_redirect_integration_test.go ================================================ //go:build kubeall || helm // +build kubeall helm // **NOTE**: we have build tags to differentiate kubernetes tests from non-kubernetes tests, and further differentiate helm // tests. This is done because minikube is heavy and can interfere with docker related tests in terratest. Similarly, helm // can overload the minikube system and thus interfere with the other kubernetes tests. Specifically, many of the tests // start to fail with `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes // tests and helm tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. // We recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package test import ( "fmt" "path/filepath" "strings" "testing" "github.com/gruntwork-io/terratest/modules/logger" tftesting "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terratest/modules/helm" "github.com/gruntwork-io/terratest/modules/k8s" "github.com/gruntwork-io/terratest/modules/random" ) // Example how to redirect helm logs to custom logger func TestHelmLogsRedirect(t *testing.T) { t.Parallel() // Path to the helm chart we will test helmChartPath, err := filepath.Abs("../examples/helm-basic-example") require.NoError(t, err) // Namespace to deploy helm chart namespaceName := fmt.Sprintf("helm-logs-%s", strings.ToLower(random.UniqueId())) // Setup the kubectl config and context. Here we choose to use the defaults, which is: // - HOME/.kube/config for the kubectl config file // - Current context of the kubectl config file kubectlOptions := k8s.NewKubectlOptions("", "", namespaceName) k8s.CreateNamespace(t, kubectlOptions, namespaceName) defer k8s.DeleteNamespace(t, kubectlOptions, namespaceName) customLogger := helmLogger{} options := &helm.Options{ KubectlOptions: kubectlOptions, SetValues: map[string]string{ "containerImageRepo": "nginx", "containerImageTag": "1.15.8", }, Logger: logger.New(&customLogger), } // Generate a unique release to avoid conflicts with other tests releaseName := fmt.Sprintf( "nginx-service-%s", strings.ToLower(random.UniqueId()), ) defer helm.Delete(t, options, releaseName, true) helm.Install(t, options, helmChartPath, releaseName) // Validate that logs were redirected to custom logger require.Contains(t, customLogger.logs, releaseName) require.Contains(t, customLogger.logs, "STATUS: deployed") } type helmLogger struct { logs string } func (c *helmLogger) Logf(t tftesting.TestingT, format string, args ...interface{}) { c.logs = fmt.Sprintf("%s\n%s", c.logs, fmt.Sprintf(format, args...)) } ================================================ FILE: test/kubernetes_basic_example_logs_test.go ================================================ //go:build kubeall || kubernetes // +build kubeall kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package test import ( "path/filepath" "strings" "testing" "time" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terratest/modules/k8s" "github.com/gruntwork-io/terratest/modules/random" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func setupLogsTest(t *testing.T) (*k8s.KubectlOptions, v1.Pod) { t.Parallel() // Path to the Kubernetes resource config we will test kubeResourcePath, err := filepath.Abs("../examples/kubernetes-basic-example/podinfo-daemonset.yml") require.NoError(t, err) // To ensure we can reuse the resource config on the same cluster to test different scenarios, we setup a unique // namespace for the resources for this test. // Note that namespaces must be lowercase. namespaceName := strings.ToLower(random.UniqueId()) // Setup the kubectl config and context. Here we choose to use the defaults, which is: // - HOME/.kube/config for the kubectl config file // - Current context of the kubectl config file options := k8s.NewKubectlOptions("", "", namespaceName) k8s.CreateNamespace(t, options, namespaceName) // ... and make sure to delete the namespace at the end of the test defer k8s.DeleteNamespace(t, options, namespaceName) // At the end of the test, run `kubectl delete -f RESOURCE_CONFIG` to clean up any resources that were created. defer k8s.KubectlDelete(t, options, kubeResourcePath) // This will run `kubectl apply -f RESOURCE_CONFIG` and fail the test if there are any errors k8s.KubectlApply(t, options, kubeResourcePath) // Wait for at least 1 Pod to be ready from the DaemonSet retries := 10 sleep := time.Second * 1 for i := 1; i < retries; i++ { podsReady := k8s.GetDaemonSet(t, options, "podinfo-deamonset").Status.NumberReady if podsReady > 0 { break } time.Sleep(sleep) } // listOptions are used to select the pods with label app=podinfo listOptions := new(metav1.ListOptions) listOptions.LabelSelector = "app=podinfo" // Get a list of Pods. The pods are not guaranteed to be in running state. pods := k8s.ListPods(t, options, *listOptions) // Check that we did not timeout waiting for the Pod of the DaemonSet to be ready require.Greater(t, len(pods), 0) pod := pods[0] // Wait fot the pod to be started and ready k8s.WaitUntilPodAvailable(t, options, pod.Name, 5, 10*time.Second) return options, pod } func TestKubernetesBasicExampleLogsCheckWithContainerName(t *testing.T) { options, pod := setupLogsTest(t) logs := k8s.GetPodLogs(t, options, &pod, "podinfo") require.Contains(t, logs, "Starting podinfo") } func TestKubernetesBasicExampleLogsCheckWithNoContainerName(t *testing.T) { options, pod := setupLogsTest(t) logs := k8s.GetPodLogs(t, options, &pod, "") require.Contains(t, logs, "Starting podinfo") } ================================================ FILE: test/kubernetes_basic_example_service_check_test.go ================================================ //go:build kubeall || kubernetes // +build kubeall kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package test import ( "crypto/tls" "fmt" "path/filepath" "strings" "testing" "time" "github.com/stretchr/testify/require" http_helper "github.com/gruntwork-io/terratest/modules/http-helper" "github.com/gruntwork-io/terratest/modules/k8s" "github.com/gruntwork-io/terratest/modules/random" ) // An example of how to do more expanded verification of the Kubernetes resource config in examples/kubernetes-basic-example using Terratest. func TestKubernetesBasicExampleServiceCheck(t *testing.T) { t.Parallel() // Path to the Kubernetes resource config we will test kubeResourcePath, err := filepath.Abs("../examples/kubernetes-basic-example/nginx-deployment.yml") require.NoError(t, err) // To ensure we can reuse the resource config on the same cluster to test different scenarios, we setup a unique // namespace for the resources for this test. // Note that namespaces must be lowercase. namespaceName := strings.ToLower(random.UniqueId()) // Setup the kubectl config and context. Here we choose to use the defaults, which is: // - HOME/.kube/config for the kubectl config file // - Current context of the kubectl config file options := k8s.NewKubectlOptions("", "", namespaceName) k8s.CreateNamespace(t, options, namespaceName) // ... and make sure to delete the namespace at the end of the test defer k8s.DeleteNamespace(t, options, namespaceName) // At the end of the test, run `kubectl delete -f RESOURCE_CONFIG` to clean up any resources that were created. defer k8s.KubectlDelete(t, options, kubeResourcePath) // This will run `kubectl apply -f RESOURCE_CONFIG` and fail the test if there are any errors k8s.KubectlApply(t, options, kubeResourcePath) // This will wait up to 10 seconds for the service to become available, to ensure that we can access it. k8s.WaitUntilServiceAvailable(t, options, "nginx-service", 10, 1*time.Second) // Now we verify that the service will successfully boot and start serving requests service := k8s.GetService(t, options, "nginx-service") endpoint := k8s.GetServiceEndpoint(t, options, service, 80) // Setup a TLS configuration to submit with the helper, a blank struct is acceptable tlsConfig := tls.Config{} // Test the endpoint for up to 5 minutes. This will only fail if we timeout waiting for the service to return a 200 // response. http_helper.HttpGetWithRetryWithCustomValidation( t, fmt.Sprintf("http://%s", endpoint), &tlsConfig, 30, 10*time.Second, func(statusCode int, body string) bool { return statusCode == 200 }, ) } ================================================ FILE: test/kubernetes_basic_example_test.go ================================================ //go:build kubeall || kubernetes // +build kubeall kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package test import ( "fmt" "path/filepath" "strings" "testing" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terratest/modules/k8s" "github.com/gruntwork-io/terratest/modules/random" ) // An example of how to test the Kubernetes resource config in examples/kubernetes-basic-example using Terratest. func TestKubernetesBasicExample(t *testing.T) { t.Parallel() // website::tag::1::Path to the Kubernetes resource config we will test kubeResourcePath, err := filepath.Abs("../examples/kubernetes-basic-example/nginx-deployment.yml") require.NoError(t, err) // To ensure we can reuse the resource config on the same cluster to test different scenarios, we setup a unique // namespace for the resources for this test. // Note that namespaces must be lowercase. namespaceName := fmt.Sprintf("kubernetes-basic-example-%s", strings.ToLower(random.UniqueId())) // website::tag::2::Setup the kubectl config and context. // Here we choose to use the defaults, which is: // - HOME/.kube/config for the kubectl config file // - Current context of the kubectl config file // - Random namespace options := k8s.NewKubectlOptions("", "", namespaceName) k8s.CreateNamespace(t, options, namespaceName) // website::tag::5::Make sure to delete the namespace at the end of the test defer k8s.DeleteNamespace(t, options, namespaceName) // website::tag::6::At the end of the test, run `kubectl delete -f RESOURCE_CONFIG` to clean up any resources that were created. defer k8s.KubectlDelete(t, options, kubeResourcePath) // website::tag::3::Apply kubectl with 'kubectl apply -f RESOURCE_CONFIG' command. // This will run `kubectl apply -f RESOURCE_CONFIG` and fail the test if there are any errors k8s.KubectlApply(t, options, kubeResourcePath) // website::tag::4::Check if NGINX service was deployed successfully. // This will get the service resource and verify that it exists and was retrieved successfully. This function will // fail the test if the there is an error retrieving the service resource from Kubernetes. service := k8s.GetService(t, options, "nginx-service") require.Equal(t, service.Name, "nginx-service") } ================================================ FILE: test/kubernetes_hello_world_example_test.go ================================================ //go:build kubeall || kubernetes // +build kubeall kubernetes // NOTE: See the notes in the other Kubernetes example tests for why this build tag is included. package test import ( "fmt" "testing" "time" http_helper "github.com/gruntwork-io/terratest/modules/http-helper" "github.com/gruntwork-io/terratest/modules/k8s" ) func TestKubernetesHelloWorldExample(t *testing.T) { t.Parallel() // website::tag::1:: Path to the Kubernetes resource config we will test. kubeResourcePath := "../examples/kubernetes-hello-world-example/hello-world-deployment.yml" // website::tag::2:: Setup the kubectl config and context. options := k8s.NewKubectlOptions("", "", "default") // website::tag::6:: At the end of the test, run "kubectl delete" to clean up any resources that were created. defer k8s.KubectlDelete(t, options, kubeResourcePath) // website::tag::3:: Run `kubectl apply` to deploy. Fail the test if there are any errors. k8s.KubectlApply(t, options, kubeResourcePath) // website::tag::4:: Verify the service is available and get the URL for it. k8s.WaitUntilServiceAvailable(t, options, "hello-world-service", 10, 1*time.Second) service := k8s.GetService(t, options, "hello-world-service") url := fmt.Sprintf("http://%s", k8s.GetServiceEndpoint(t, options, service, 5000)) // website::tag::5:: Make an HTTP request to the URL and make sure it returns a 200 OK with the body "Hello, World!". http_helper.HttpGetWithRetry(t, url, nil, 200, "Hello, World!", 30, 3*time.Second) } ================================================ FILE: test/kubernetes_kustomize_example_test.go ================================================ //go:build kubeall || kubernetes // +build kubeall kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package test import ( "fmt" "path/filepath" "strings" "testing" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terratest/modules/k8s" "github.com/gruntwork-io/terratest/modules/random" ) // An example of how to test the Kubernetes resource config in examples/kubernetes-kustomize-example using Terratest. func TestKubernetesKustomizeExample(t *testing.T) { t.Parallel() // website::tag::1::Path to the Kubernetes resource config we will test kubeResourcePath, err := filepath.Abs("../examples/kubernetes-kustomize-example/") require.NoError(t, err) // To ensure we can reuse the resource config on the same cluster to test different scenarios, we setup a unique // namespace for the resources for this test. // Note that namespaces must be lowercase. namespaceName := fmt.Sprintf("kubernetes-kustomize-example-%s", strings.ToLower(random.UniqueId())) // website::tag::2::Setup the kubectl config and context. // Here we choose to use the defaults, which is: // - HOME/.kube/config for the kubectl config file // - Current context of the kubectl config file // - Random namespace options := k8s.NewKubectlOptions("", "", namespaceName) k8s.CreateNamespace(t, options, namespaceName) // website::tag::5::Make sure to delete the namespace at the end of the test defer k8s.DeleteNamespace(t, options, namespaceName) // website::tag::6::At the end of the test, run `kubectl delete -f RESOURCE_CONFIG` to clean up any resources that were created. defer k8s.KubectlDeleteFromKustomize(t, options, kubeResourcePath) // website::tag::3::Apply kubectl with 'kubectl apply -f RESOURCE_CONFIG' command. // This will run `kubectl apply -f RESOURCE_CONFIG` and fail the test if there are any errors k8s.KubectlApplyFromKustomize(t, options, kubeResourcePath) // website::tag::4::Check if NGINX service was deployed successfully. // This will get the service resource and verify that it exists and was retrieved successfully. This function will // fail the test if the there is an error retrieving the service resource from Kubernetes. service := k8s.GetService(t, options, "nginx-service") require.Equal(t, service.Name, "nginx-service") } ================================================ FILE: test/kubernetes_rbac_example_test.go ================================================ //go:build kubeall || kubernetes // +build kubeall kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package test import ( "os" "path/filepath" "testing" "github.com/stretchr/testify/require" authv1 "k8s.io/api/authorization/v1" "github.com/gruntwork-io/terratest/modules/k8s" ) // An example of how to test the Kubernetes resource config in examples/kubernetes-rbac-example using Terratest, // including whether or not the permissions are set correctly. func TestKubernetesRBACExample(t *testing.T) { t.Parallel() // These are pulled from the kubernetes resource config const serviceAccountName = "terratest-rbac-example-service-account" const namespaceName = "terratest-rbac-example-namespace" // Path to the Kubernetes resource config we will test kubeResourcePath, err := filepath.Abs("../examples/kubernetes-rbac-example/namespace-service-account.yml") require.NoError(t, err) // Setup the kubectl config and context. Here we choose to create a new one because we will be manipulating the // entries to be able to add a new authentication option. tmpConfigPath := k8s.CopyHomeKubeConfigToTemp(t) defer os.Remove(tmpConfigPath) options := k8s.NewKubectlOptions("", tmpConfigPath, namespaceName) // At the end of the test, run `kubectl delete -f RESOURCE_CONFIG` to clean up any resources that were created. defer k8s.KubectlDelete(t, options, kubeResourcePath) // This will run `kubectl apply -f RESOURCE_CONFIG` and fail the test if there are any errors k8s.KubectlApply(t, options, kubeResourcePath) // Retrieve authentication token for the newly created ServiceAccount token := k8s.GetServiceAccountAuthToken(t, options, serviceAccountName) // Now update the configuration to add a new context that can be used to make requests as that service account require.NoError(t, k8s.AddConfigContextForServiceAccountE( t, options, serviceAccountName, // for this test we will name the context after the ServiceAccount serviceAccountName, token, )) serviceAccountKubectlOptions := k8s.NewKubectlOptions(serviceAccountName, tmpConfigPath, namespaceName) // At this point all requests made with serviceAccountKubectlOptions will be auth'd as that ServiceAccount. So let's // verify that! We will check: // - we can't access the kube-system namespace adminListPodAction := authv1.ResourceAttributes{ Namespace: "kube-system", Verb: "list", Resource: "pod", } require.False(t, k8s.CanIDo(t, serviceAccountKubectlOptions, adminListPodAction)) // - we can access the namespace the service account is in namespaceListPodAction := authv1.ResourceAttributes{ Namespace: namespaceName, Verb: "list", Resource: "pod", } require.True(t, k8s.CanIDo(t, serviceAccountKubectlOptions, namespaceListPodAction)) } ================================================ FILE: test/kubernetes_rest_config_example_test.go ================================================ //go:build kubeall || kubernetes // +build kubeall kubernetes // NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube // is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with // `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm // tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We // recommend at least 4 cores and 16GB of RAM if you want to run all the tests together. package test import ( "fmt" "os/user" "path/filepath" "strings" "testing" "github.com/stretchr/testify/require" "k8s.io/client-go/tools/clientcmd" "github.com/gruntwork-io/terratest/modules/k8s" "github.com/gruntwork-io/terratest/modules/random" ) func TestKubernetesRestConfigBasicExampleConfig(t *testing.T) { t.Parallel() // website::tag::1::Path to the Kubernetes resource config we will test kubeResourcePath, err := filepath.Abs("../examples/kubernetes-basic-example/nginx-deployment.yml") require.NoError(t, err) // To ensure we can reuse the resource config on the same cluster to test different scenarios, we setup a unique // namespace for the resources for this test. // Note that namespaces must be lowercase. namespaceName := fmt.Sprintf("kubernetes-basic-example-%s", strings.ToLower(random.UniqueId())) usr, err := user.Current() if err != nil { require.NoError(t, err) } // Construct the path to the kubeconfig file kubeconfigPath := filepath.Join(usr.HomeDir, ".kube", "config") // Generate rest.Config from kubeconfig file config, err := clientcmd.BuildConfigFromFlags("", kubeconfigPath) if err != nil { panic(err.Error()) } // website::tag::2:: Setup the kubectl config and context. options := k8s.NewKubectlOptionsWithRestConfig(config, namespaceName) k8s.CreateNamespace(t, options, namespaceName) // website::tag::5::Make sure to delete the namespace at the end of the test defer k8s.DeleteNamespace(t, options, namespaceName) // website::tag::6::At the end of the test, run `kubectl delete -f RESOURCE_CONFIG` to clean up any resources that were created. defer k8s.KubectlDelete(t, options, kubeResourcePath) // website::tag::3::Apply kubectl with 'kubectl apply -f RESOURCE_CONFIG' command. // This will run `kubectl apply -f RESOURCE_CONFIG` and fail the test if there are any errors k8s.KubectlApply(t, options, kubeResourcePath) // website::tag::4::Check if NGINX service was deployed successfully. // This will get the service resource and verify that it exists and was retrieved successfully. This function will // fail the test if the there is an error retrieving the service resource from Kubernetes. service := k8s.GetService(t, options, "nginx-service") require.Equal(t, service.Name, "nginx-service") } ================================================ FILE: test/packer_basic_example_test.go ================================================ package test import ( "context" "fmt" "os" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" terratest_aws "github.com/gruntwork-io/terratest/modules/aws" "github.com/gruntwork-io/terratest/modules/packer" "github.com/gruntwork-io/terratest/modules/random" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // Occasionally, a Packer build may fail due to intermittent issues (e.g., brief network outage or EC2 issue). We try // to make our tests resilient to that by specifying those known common errors here and telling our builds to retry if // they hit those errors. var DefaultRetryablePackerErrors = map[string]string{ "Script disconnected unexpectedly": "Occasionally, Packer seems to lose connectivity to AWS, perhaps due to a brief network outage", "can not open /var/lib/apt/lists/archive.ubuntu.com_ubuntu_dists_xenial_InRelease": "Occasionally, apt-get fails on ubuntu to update the cache", "error while running command: exit status 1;": "Occasionally, package installation inside the image seems to fail due to several reasons such as it's being missing from package repository", } var DefaultTimeBetweenPackerRetries = 15 * time.Second const DefaultMaxPackerRetries = 3 // An example of how to test the Packer template in examples/packer-basic-example using Terratest. func TestPackerBasicExample(t *testing.T) { t.Parallel() // Pick a random AWS region to test in. This helps ensure your code works in all regions. awsRegion := terratest_aws.GetRandomStableRegion(t, nil, nil) // Some AWS regions are missing certain instance types, so pick an available type based on the region we picked instanceType := terratest_aws.GetRecommendedInstanceType(t, awsRegion, []string{"t2.micro, t3.micro", "t2.small", "t3.small"}) // website::tag::1::Read Packer's template and set AWS Region variable. packerOptions := &packer.Options{ // The path to where the Packer template is located Template: "../examples/packer-basic-example/build.pkr.hcl", // Variables to pass to our Packer build using -var options Vars: map[string]string{ "aws_region": awsRegion, "ami_base_name": fmt.Sprintf("%s", random.UniqueId()), "instance_type": instanceType, }, // Only build the AWS AMI Only: "amazon-ebs.ubuntu-example", // Configure retries for intermittent errors RetryableErrors: DefaultRetryablePackerErrors, TimeBetweenRetries: DefaultTimeBetweenPackerRetries, MaxRetries: DefaultMaxPackerRetries, } // website::tag::2::Build artifacts from Packer's template. // Make sure the Packer build completes successfully amiID := packer.BuildArtifact(t, packerOptions) // website::tag::4::Remove AMI after test. // Clean up the AMI after we're done defer terratest_aws.DeleteAmiAndAllSnapshots(t, awsRegion, amiID) // Check if AMI is shared/not shared with account requestingAccount := terratest_aws.CanonicalAccountId randomAccount := "123456789012" // Random Account ec2Client := terratest_aws.NewEc2Client(t, awsRegion) ShareAmi(t, amiID, requestingAccount, ec2Client) accountsWithLaunchPermissions := terratest_aws.GetAccountsWithLaunchPermissionsForAmi(t, awsRegion, amiID) assert.NotContains(t, accountsWithLaunchPermissions, randomAccount) assert.Contains(t, accountsWithLaunchPermissions, requestingAccount) // website::tag::3::Check AMI's properties. // Check if AMI is private amiIsPublic := terratest_aws.GetAmiPubliclyAccessible(t, awsRegion, amiID) assert.False(t, amiIsPublic) } // An example of how to test the Packer template in examples/packer-basic-example using Terratest // with the VarFiles option. This test generates a temporary *.json file containing the value // for the `aws_region` variable. func TestPackerBasicExampleWithVarFile(t *testing.T) { t.Parallel() // Pick a random AWS region to test in. This helps ensure your code works in all regions. awsRegion := terratest_aws.GetRandomStableRegion(t, nil, nil) // Some AWS regions are missing certain instance types, so pick an available type based on the region we picked instanceType := terratest_aws.GetRecommendedInstanceType(t, awsRegion, []string{"t2.micro, t3.micro", "t2.small", "t3.small"}) // Create temporary packer variable file to store aws region varFile, err := os.CreateTemp("", "*.json") require.NoError(t, err, "Did not expect temp file creation to cause error") // Be sure to clean up temp file defer os.Remove(varFile.Name()) // Write the vars we need to a temporary json file varFileContent := []byte(fmt.Sprintf(`{"aws_region": "%s", "instance_type": "%s"}`, awsRegion, instanceType)) _, err = varFile.Write(varFileContent) require.NoError(t, err, "Did not expect writing to temp file %s to cause error", varFile.Name()) packerOptions := &packer.Options{ // The path to where the Packer template is located Template: "../examples/packer-basic-example/build.pkr.hcl", // Variable file to pass to our Packer build using -var-file option VarFiles: []string{ varFile.Name(), }, // Environment settings to avoid plugin conflicts Env: map[string]string{ "PACKER_PLUGIN_PATH": "../examples/packer-basic-example/.packer.d/plugins", }, // Only build the AWS AMI Only: "amazon-ebs.ubuntu-example", // Configure retries for intermittent errors RetryableErrors: DefaultRetryablePackerErrors, TimeBetweenRetries: DefaultTimeBetweenPackerRetries, MaxRetries: DefaultMaxPackerRetries, } // Make sure the Packer build completes successfully amiID := packer.BuildArtifact(t, packerOptions) // Clean up the AMI after we're done defer terratest_aws.DeleteAmiAndAllSnapshots(t, awsRegion, amiID) // Check if AMI is shared/not shared with account requestingAccount := terratest_aws.CanonicalAccountId randomAccount := "123456789012" // Random Account ec2Client := terratest_aws.NewEc2Client(t, awsRegion) ShareAmi(t, amiID, requestingAccount, ec2Client) accountsWithLaunchPermissions := terratest_aws.GetAccountsWithLaunchPermissionsForAmi(t, awsRegion, amiID) assert.NotContains(t, accountsWithLaunchPermissions, randomAccount) assert.Contains(t, accountsWithLaunchPermissions, requestingAccount) // Check if AMI is private amiIsPublic := terratest_aws.GetAmiPubliclyAccessible(t, awsRegion, amiID) assert.False(t, amiIsPublic) } func TestPackerMultipleConcurrentAmis(t *testing.T) { t.Parallel() // Build a map of 3 randomId <-> packer.Options, in 3 random AWS Regions // then build all of these AMIs in parallel and make sure that there are // no errors. var identifierToOptions = map[string]*packer.Options{} for i := 0; i < 3; i++ { // Pick a random AWS region to test in. This helps ensure your code works in all regions. awsRegion := terratest_aws.GetRandomStableRegion(t, nil, nil) // Some AWS regions are missing certain instance types, so pick an available type based on the region we picked instanceType := terratest_aws.GetRecommendedInstanceType(t, awsRegion, []string{"t2.micro, t3.micro", "t2.small", "t3.small"}) packerOptions := &packer.Options{ // The path to where the Packer template is located Template: "../examples/packer-basic-example/build.pkr.hcl", // Variables to pass to our Packer build using -var options Vars: map[string]string{ "aws_region": awsRegion, "ami_base_name": fmt.Sprintf("%s", random.UniqueId()), "instance_type": instanceType, }, // Only build the AWS AMI Only: "amazon-ebs.ubuntu-example", // Configure retries for intermittent errors RetryableErrors: DefaultRetryablePackerErrors, TimeBetweenRetries: DefaultTimeBetweenPackerRetries, MaxRetries: DefaultMaxPackerRetries, } identifierToOptions[random.UniqueId()] = packerOptions } resultMap := packer.BuildArtifacts(t, identifierToOptions) // Clean up the AMIs after we're done for key, amiId := range resultMap { awsRegion := identifierToOptions[key].Vars["aws_region"] terratest_aws.DeleteAmiAndAllSnapshots(t, awsRegion, amiId) } } func ShareAmi(t *testing.T, amiID string, accountID string, ec2Client *ec2.Client) { input := &ec2.ModifyImageAttributeInput{ ImageId: aws.String(amiID), LaunchPermission: &types.LaunchPermissionModifications{ Add: []types.LaunchPermission{ { UserId: aws.String(accountID), }, }, }, } _, err := ec2Client.ModifyImageAttribute(context.Background(), input) if err != nil { t.Fatal(err) } } ================================================ FILE: test/packer_docker_example_test.go ================================================ package test import ( "crypto/tls" "fmt" "strconv" "testing" "time" "github.com/gruntwork-io/terratest/modules/docker" http_helper "github.com/gruntwork-io/terratest/modules/http-helper" "github.com/gruntwork-io/terratest/modules/packer" "github.com/gruntwork-io/terratest/modules/random" ) // An example of how to test the Packer template in examples/packer-docker-example completely locally using Terratest // and Docker. func TestPackerDockerExampleLocal(t *testing.T) { t.Parallel() // website::tag::1::Configure Packer to build Docker image. packerOptions := &packer.Options{ // The path to where the Packer template is located Template: "../examples/packer-docker-example/build.pkr.hcl", // Only build the Docker image for local testing Only: "docker.ubuntu-docker", // Configure retries for intermittent errors RetryableErrors: DefaultRetryablePackerErrors, TimeBetweenRetries: DefaultTimeBetweenPackerRetries, MaxRetries: DefaultMaxPackerRetries, } // website::tag::2::Build the Docker image using Packer packer.BuildArtifact(t, packerOptions) serverPort := 8080 expectedServerText := fmt.Sprintf("Hello, %s!", random.UniqueId()) dockerOptions := &docker.Options{ // website::tag::3::Set path to 'docker-compose.yml' and environment variables to run Docker image. // Directory where docker-compose.yml lives WorkingDir: "../examples/packer-docker-example", // Configure the port the web app will listen on and the text it will return using environment variables EnvVars: map[string]string{ "SERVER_PORT": strconv.Itoa(serverPort), "SERVER_TEXT": expectedServerText, }, } // website::tag::6::Make sure to shut down the Docker container at the end of the test. defer docker.RunDockerCompose(t, dockerOptions, "down") // website::tag::4::Run Docker Compose to fire up the web app. We run it in the background (-d) so it doesn't block this test. docker.RunDockerCompose(t, dockerOptions, "up", "-d") // It can take a few seconds for the Docker container boot up, so retry a few times maxRetries := 5 timeBetweenRetries := 2 * time.Second url := fmt.Sprintf("http://localhost:%d", serverPort) // Setup a TLS configuration to submit with the helper, a blank struct is acceptable tlsConfig := tls.Config{} // website::tag::5::Verify that we get back a 200 OK with the expected text http_helper.HttpGetWithRetry(t, url, &tlsConfig, 200, expectedServerText, maxRetries, timeBetweenRetries) } ================================================ FILE: test/packer_hello_world_example_test.go ================================================ package test import ( "testing" "github.com/gruntwork-io/terratest/modules/docker" "github.com/gruntwork-io/terratest/modules/packer" "github.com/stretchr/testify/assert" ) func TestPackerHelloWorldExample(t *testing.T) { packerOptions := &packer.Options{ // website::tag::1:: The path to where the Packer template is located Template: "../examples/packer-hello-world-example/build.pkr.hcl", } // website::tag::2:: Build the Packer template. This template will create a Docker image. packer.BuildArtifact(t, packerOptions) // website::tag::3:: Run the Docker image, read the text file from it, and make sure it contains the expected output. opts := &docker.RunOptions{ Command: []string{"cat", "/test.txt"}, Platform: "linux/amd64", } output := docker.Run(t, "gruntwork/packer-hello-world-example", opts) assert.Equal(t, "Hello, World!", output) } ================================================ FILE: test/packer_oci_example_test.go ================================================ package test import ( "os" "testing" "github.com/gruntwork-io/terratest/modules/oci" "github.com/gruntwork-io/terratest/modules/packer" ) // An example of how to test the Packer template in examples/packer-basic-example using Terratest. func TestPackerOciExample(t *testing.T) { t.Parallel() // The Terratest CI environment does not yet have CI creds set up, so we skip these tests for now // https://github.com/gruntwork-io/terratest/issues/160 if os.Getenv("CIRCLECI") != "" { t.Skip("The build is running on CircleCI, so skipping OCI tests.") } compartmentID := oci.GetRootCompartmentID(t) baseImageID := oci.GetMostRecentImageID(t, compartmentID, "Canonical Ubuntu", "18.04") availabilityDomain := oci.GetRandomAvailabilityDomain(t, compartmentID) subnetID := oci.GetRandomSubnetID(t, compartmentID, availabilityDomain) passPhrase := oci.GetPassPhraseFromEnvVar() packerOptions := &packer.Options{ // The path to where the Packer template is located Template: "../examples/packer-basic-example/build.pkr.hcl", // Variables to pass to our Packer build using -var options Vars: map[string]string{ "oci_compartment_ocid": compartmentID, "oci_base_image_ocid": baseImageID, "oci_availability_domain": availabilityDomain, "oci_subnet_ocid": subnetID, "oci_pass_phrase": passPhrase, }, // Only build an OCI image Only: "oracle-oci", // Configure retries for intermittent errors RetryableErrors: DefaultRetryablePackerErrors, TimeBetweenRetries: DefaultTimeBetweenPackerRetries, MaxRetries: DefaultMaxPackerRetries, } // Make sure the Packer build completes successfully ocid := packer.BuildArtifact(t, packerOptions) // Delete the OCI image after we're done defer oci.DeleteImage(t, ocid) } ================================================ FILE: test/terraform_aws_dynamodb_example_test.go ================================================ package test import ( "fmt" "testing" awsSDK "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/gruntwork-io/terratest/modules/aws" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) // An example of how to test the Terraform module in examples/terraform-aws-dynamodb-example using Terratest. func TestTerraformAwsDynamoDBExample(t *testing.T) { t.Parallel() // Pick a random AWS region to test in. This helps ensure your code works in all regions. awsRegion := aws.GetRandomStableRegion(t, nil, nil) // Set up expected values to be checked later expectedTableName := fmt.Sprintf("terratest-aws-dynamodb-example-table-%s", random.UniqueId()) expectedKmsKeyArn := aws.GetCmkArn(t, awsRegion, "alias/aws/dynamodb") expectedKeySchema := []types.KeySchemaElement{ {AttributeName: awsSDK.String("userId"), KeyType: types.KeyTypeHash}, {AttributeName: awsSDK.String("department"), KeyType: types.KeyTypeRange}, } expectedTags := []types.Tag{ {Key: awsSDK.String("Environment"), Value: awsSDK.String("production")}, } // Construct the terraform options with default retryable errors to handle the most common retryable errors in // terraform testing. terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ // The path to where our Terraform code is located TerraformDir: "../examples/terraform-aws-dynamodb-example", // Variables to pass to our Terraform code using -var options Vars: map[string]interface{}{ "table_name": expectedTableName, "region": awsRegion, }, }) // At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // This will run `terraform init` and `terraform apply` and fail the test if there are any errors terraform.InitAndApply(t, terraformOptions) // Look up the DynamoDB table by name table := aws.GetDynamoDBTable(t, awsRegion, expectedTableName) assert.Equal(t, "ACTIVE", string(table.TableStatus)) assert.ElementsMatch(t, expectedKeySchema, table.KeySchema) // Verify server-side encryption configuration assert.Equal(t, expectedKmsKeyArn, awsSDK.ToString(table.SSEDescription.KMSMasterKeyArn)) assert.Equal(t, "ENABLED", string(table.SSEDescription.Status)) assert.Equal(t, "KMS", string(table.SSEDescription.SSEType)) // Verify TTL configuration ttl := aws.GetDynamoDBTableTimeToLive(t, awsRegion, expectedTableName) assert.Equal(t, "expires", awsSDK.ToString(ttl.AttributeName)) assert.Equal(t, "ENABLED", string(ttl.TimeToLiveStatus)) // Verify resource tags tags := aws.GetDynamoDbTableTags(t, awsRegion, expectedTableName) assert.ElementsMatch(t, expectedTags, tags) } ================================================ FILE: test/terraform_aws_ec2_windows_test.go ================================================ package test import ( "fmt" "path/filepath" "testing" "github.com/gruntwork-io/terratest/modules/aws" "github.com/gruntwork-io/terratest/modules/packer" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" test_structure "github.com/gruntwork-io/terratest/modules/test-structure" ) func TestWindowsInstance(t *testing.T) { // Uncomment any of the following to skip that section during the test //os.Setenv("SKIP_setup", "true") //os.Setenv("SKIP_build_ami", "true") //os.Setenv("SKIP_deploy", "true") //os.Setenv("SKIP_validate", "true") //os.Setenv("SKIP_cleanup", "true") workingDir := filepath.Join(".", "stages", t.Name()) testBasePath := test_structure.CopyTerraformFolderToTemp(t, "..", "examples/terraform-aws-ec2-windows-example") test_structure.RunTestStage(t, "setup", func() { uniqueID := random.UniqueId() region := aws.GetRandomRegion(t, []string{}, []string{}) roleName := fmt.Sprintf("%s-test-role", uniqueID) instanceType := aws.GetRecommendedInstanceType(t, region, []string{"t2.micro, t3.micro", "t2.small", "t3.small"}) test_structure.SaveString(t, workingDir, "region", region) test_structure.SaveString(t, workingDir, "uniqueID", uniqueID) test_structure.SaveString(t, workingDir, "instanceType", instanceType) test_structure.SaveString(t, workingDir, "roleName", roleName) }) test_structure.RunTestStage(t, "build_ami", func() { region := test_structure.LoadString(t, workingDir, "region") instanceType := test_structure.LoadString(t, workingDir, "instanceType") roleName := test_structure.LoadString(t, workingDir, "roleName") varsMap := make(map[string]string) varsMap["instance_type"] = instanceType varsMap["region"] = region packerOptions := &packer.Options{ Template: filepath.Join(testBasePath, "packer/build.pkr.hcl"), Vars: varsMap, } amiID := packer.BuildArtifact(t, packerOptions) test_structure.SaveString(t, workingDir, "amiID", amiID) terratestOptions := &terraform.Options{ TerraformDir: testBasePath, Vars: make(map[string]interface{}), } terratestOptions.Vars["ami"] = amiID terratestOptions.Vars["region"] = region terratestOptions.Vars["iam_role_name"] = roleName test_structure.SaveTerraformOptions(t, workingDir, terratestOptions) }) defer test_structure.RunTestStage(t, "cleanup", func() { terratestOptions := test_structure.LoadTerraformOptions(t, workingDir) terraform.Destroy(t, terratestOptions) }) test_structure.RunTestStage(t, "deploy", func() { terratestOptions := test_structure.LoadTerraformOptions(t, workingDir) terraform.InitAndApply(t, terratestOptions) }) } ================================================ FILE: test/terraform_aws_ecs_example_test.go ================================================ package test import ( "fmt" "testing" "github.com/aws/aws-sdk-go-v2/service/ecs/types" "github.com/gruntwork-io/terratest/modules/aws" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" awsSDK "github.com/aws/aws-sdk-go-v2/aws" "github.com/stretchr/testify/assert" ) // An example of how to test the Terraform module in examples/terraform-aws-ecs-example using Terratest. func TestTerraformAwsEcsExample(t *testing.T) { t.Parallel() expectedClusterName := fmt.Sprintf("terratest-aws-ecs-example-cluster-%s", random.UniqueId()) expectedServiceName := fmt.Sprintf("terratest-aws-ecs-example-service-%s", random.UniqueId()) // Pick a random AWS region to test in. This helps ensure your code works in all regions. awsRegion := aws.GetRandomStableRegion(t, []string{"us-east-1", "eu-west-1"}, nil) // Construct the terraform options with default retryable errors to handle the most common retryable errors in // terraform testing. terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ // The path to where our Terraform code is located TerraformDir: "../examples/terraform-aws-ecs-example", // Variables to pass to our Terraform code using -var options Vars: map[string]interface{}{ "cluster_name": expectedClusterName, "service_name": expectedServiceName, "region": awsRegion, }, }) // At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // This will run `terraform init` and `terraform apply` and fail the test if there are any errors terraform.InitAndApply(t, terraformOptions) // Run `terraform output` to get the value of an output variable taskDefinition := terraform.Output(t, terraformOptions, "task_definition") // Look up the ECS cluster by name cluster := aws.GetEcsCluster(t, awsRegion, expectedClusterName) assert.Equal(t, int32(1), cluster.ActiveServicesCount) // Look up the ECS service by name service := aws.GetEcsService(t, awsRegion, expectedClusterName, expectedServiceName) assert.Equal(t, int32(0), service.DesiredCount) assert.Equal(t, types.LaunchTypeFargate, service.LaunchType) // Look up the ECS task definition by ARN task := aws.GetEcsTaskDefinition(t, awsRegion, taskDefinition) assert.Equal(t, "256", awsSDK.ToString(task.Cpu)) assert.Equal(t, "512", awsSDK.ToString(task.Memory)) assert.Equal(t, types.NetworkModeAwsvpc, task.NetworkMode) } ================================================ FILE: test/terraform_aws_example_plan_test.go ================================================ package test import ( "fmt" "path/filepath" "testing" "github.com/gruntwork-io/terratest/modules/aws" "github.com/gruntwork-io/terratest/modules/k8s" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" test_structure "github.com/gruntwork-io/terratest/modules/test-structure" "github.com/stretchr/testify/assert" ) // An example of how to test the Terraform module in examples/terraform-aws-example using Terratest. func TestTerraformAwsExamplePlan(t *testing.T) { t.Parallel() // Make a copy of the terraform module to a temporary directory. This allows running multiple tests in parallel // against the same terraform module. exampleFolder := test_structure.CopyTerraformFolderToTemp(t, "../", "examples/terraform-aws-example") // Give this EC2 Instance a unique ID for a name tag so we can distinguish it from any other EC2 Instance running // in your AWS account expectedName := fmt.Sprintf("terratest-aws-example-%s", random.UniqueId()) // Pick a random AWS region to test in. This helps ensure your code works in all regions. awsRegion := aws.GetRandomStableRegion(t, nil, nil) // Some AWS regions are missing certain instance types, so pick an available type based on the region we picked instanceType := aws.GetRecommendedInstanceType(t, awsRegion, []string{"t2.micro, t3.micro", "t2.small", "t3.small"}) // website::tag::1::Configure Terraform setting path to Terraform code, EC2 instance name, and AWS Region. We also // configure the options with default retryable errors to handle the most common retryable errors encountered in // terraform testing. planFilePath := filepath.Join(exampleFolder, "plan.out") terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ // The path to where our Terraform code is located TerraformDir: "../examples/terraform-aws-example", // Variables to pass to our Terraform code using -var options Vars: map[string]interface{}{ "instance_name": expectedName, "instance_type": instanceType, }, // Environment variables to set when running Terraform EnvVars: map[string]string{ "AWS_DEFAULT_REGION": awsRegion, }, // Configure a plan file path so we can introspect the plan and make assertions about it. PlanFilePath: planFilePath, }) // website::tag::2::Run `terraform init`, `terraform plan`, and `terraform show` and fail the test if there are any errors plan := terraform.InitAndPlanAndShowWithStruct(t, terraformOptions) // website::tag::3::Use the go struct to introspect the plan values. terraform.RequirePlannedValuesMapKeyExists(t, plan, "aws_instance.example") ec2Resource := plan.ResourcePlannedValuesMap["aws_instance.example"] ec2Tags := ec2Resource.AttributeValues["tags"].(map[string]interface{}) assert.Equal(t, map[string]interface{}{"Name": expectedName}, ec2Tags) // website::tag::4::Alternatively, you can get the direct JSON output and use jsonpath to extract the data. // jsonpath only returns lists. var jsonEC2Tags []map[string]interface{} jsonOut := terraform.InitAndPlanAndShow(t, terraformOptions) k8s.UnmarshalJSONPath( t, []byte(jsonOut), "{ .planned_values.root_module.resources[0].values.tags }", &jsonEC2Tags, ) assert.Equal(t, map[string]interface{}{"Name": expectedName}, jsonEC2Tags[0]) } ================================================ FILE: test/terraform_aws_example_test.go ================================================ package test import ( "fmt" "testing" "github.com/gruntwork-io/terratest/modules/aws" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" test_structure "github.com/gruntwork-io/terratest/modules/test-structure" "github.com/stretchr/testify/assert" ) // An example of how to test the Terraform module in examples/terraform-aws-example using Terratest. func TestTerraformAwsExample(t *testing.T) { t.Parallel() // Make a copy of the terraform module to a temporary directory. This allows running multiple tests in parallel // against the same terraform module. exampleFolder := test_structure.CopyTerraformFolderToTemp(t, "../", "examples/terraform-aws-example") // Give this EC2 Instance a unique ID for a name tag so we can distinguish it from any other EC2 Instance running // in your AWS account expectedName := fmt.Sprintf("terratest-aws-example-%s", random.UniqueId()) // Pick a random AWS region to test in. This helps ensure your code works in all regions. awsRegion := aws.GetRandomStableRegion(t, nil, nil) // Some AWS regions are missing certain instance types, so pick an available type based on the region we picked instanceType := aws.GetRecommendedInstanceType(t, awsRegion, []string{"t2.micro", "t3.micro"}) // website::tag::1::Configure Terraform setting path to Terraform code, EC2 instance name, and AWS Region. We also // configure the options with default retryable errors to handle the most common retryable errors encountered in // terraform testing. terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ // The path to where our Terraform code is located TerraformDir: exampleFolder, // Variables to pass to our Terraform code using -var options Vars: map[string]interface{}{ "instance_name": expectedName, "instance_type": instanceType, }, // Environment variables to set when running Terraform EnvVars: map[string]string{ "AWS_DEFAULT_REGION": awsRegion, }, }) // website::tag::4::At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // website::tag::2::Run `terraform init` and `terraform apply` and fail the test if there are any errors terraform.InitAndApply(t, terraformOptions) // Run `terraform output` to get the value of an output variable instanceID := terraform.Output(t, terraformOptions, "instance_id") aws.AddTagsToResource(t, awsRegion, instanceID, map[string]string{"testing": "testing-tag-value"}) // Look up the tags for the given Instance ID instanceTags := aws.GetTagsForEc2Instance(t, awsRegion, instanceID) // website::tag::3::Check if the EC2 instance with a given tag and name is set. testingTag, containsTestingTag := instanceTags["testing"] assert.True(t, containsTestingTag) assert.Equal(t, "testing-tag-value", testingTag) // Verify that our expected name tag is one of the tags nameTag, containsNameTag := instanceTags["Name"] assert.True(t, containsNameTag) assert.Equal(t, expectedName, nameTag) } ================================================ FILE: test/terraform_aws_hello_world_example_test.go ================================================ package test import ( "fmt" "testing" "time" http_helper "github.com/gruntwork-io/terratest/modules/http-helper" "github.com/gruntwork-io/terratest/modules/terraform" ) func TestTerraformAwsHelloWorldExample(t *testing.T) { t.Parallel() // website::tag::2:: Construct the terraform options with default retryable errors to handle the most common // retryable errors in terraform testing. terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ // website::tag::1:: The path to where our Terraform code is located TerraformDir: "../examples/terraform-aws-hello-world-example", }) // website::tag::6:: At the end of the test, run `terraform destroy` to clean up any resources that were created. defer terraform.Destroy(t, terraformOptions) // website::tag::3:: Run `terraform init` and `terraform apply`. Fail the test if there are any errors. terraform.InitAndApply(t, terraformOptions) // website::tag::4:: Run `terraform output` to get the IP of the instance publicIp := terraform.Output(t, terraformOptions, "public_ip") // website::tag::5:: Make an HTTP request to the instance and make sure we get back a 200 OK with the body "Hello, World!" url := fmt.Sprintf("http://%s:8080", publicIp) http_helper.HttpGetWithRetry(t, url, nil, 200, "Hello, World!", 30, 5*time.Second) } ================================================ FILE: test/terraform_aws_lambda_example_test.go ================================================ package test import ( "fmt" "testing" "github.com/gruntwork-io/terratest/modules/aws" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/shell" "github.com/gruntwork-io/terratest/modules/terraform" test_structure "github.com/gruntwork-io/terratest/modules/test-structure" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // An example of how to test the Terraform module in examples/terraform-aws-lambda-example using Terratest. func TestTerraformAwsLambdaExample(t *testing.T) { t.Parallel() // Make a copy of the terraform module to a temporary directory. This allows running multiple tests in parallel // against the same terraform module. exampleFolder := test_structure.CopyTerraformFolderToTemp(t, "../", "examples/terraform-aws-lambda-example") err := buildLambdaBinary(t, exampleFolder) require.NoError(t, err) // Give this lambda function a unique ID for a name so we can distinguish it from any other lambdas // in your AWS account functionName := fmt.Sprintf("terratest-aws-lambda-example-%s", random.UniqueId()) // Pick a random AWS region to test in. This helps ensure your code works in all regions. awsRegion := aws.GetRandomStableRegion(t, nil, nil) // Construct the terraform options with default retryable errors to handle the most common retryable errors in // terraform testing. terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ // The path to where our Terraform code is located TerraformDir: exampleFolder, // Variables to pass to our Terraform code using -var options Vars: map[string]interface{}{ "function_name": functionName, "region": awsRegion, }, }) // At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // This will run `terraform init` and `terraform apply` and fail the test if there are any errors terraform.InitAndApply(t, terraformOptions) // Invoke the function, so we can test its output response := aws.InvokeFunction(t, awsRegion, functionName, ExampleFunctionPayload{ShouldFail: false, Echo: "hi!"}) // This function just echos it's input as a JSON string when `ShouldFail` is `false`` assert.Equal(t, `"hi!"`, string(response)) // Invoke the function, this time causing it to error and capturing the error _, err = aws.InvokeFunctionE(t, awsRegion, functionName, ExampleFunctionPayload{ShouldFail: true, Echo: "hi!"}) // Function-specific errors have their own special return functionError, ok := err.(*aws.FunctionError) require.True(t, ok) // Make sure the function-specific error comes back assert.Contains(t, string(functionError.Payload), "failed to handle") } func buildLambdaBinary(t *testing.T, tempDir string) error { cmd := shell.Command{ Command: "go", Args: []string{ "build", "-o", tempDir + "/src/bootstrap", tempDir + "/src/bootstrap.go", }, Env: map[string]string{ "GOOS": "linux", "GOARCH": "amd64", "CGO_ENABLED": "0", }, } _, err := shell.RunCommandAndGetOutputE(t, cmd) return err } // Another example of how to test the Terraform module in // examples/terraform-aws-lambda-example using Terratest, this time with // the aws.InvokeFunctionWithParams. func TestTerraformAwsLambdaWithParamsExample(t *testing.T) { t.Parallel() // Make a copy of the terraform module to a temporary directory. This allows running multiple tests in parallel // against the same terraform module. exampleFolder := test_structure.CopyTerraformFolderToTemp(t, "../", "examples/terraform-aws-lambda-example") err := buildLambdaBinary(t, exampleFolder) require.NoError(t, err) // Give this lambda function a unique ID for a name so we can distinguish it from any other lambdas // in your AWS account functionName := fmt.Sprintf("terratest-aws-lambda-withparams-example-%s", random.UniqueId()) // Pick a random AWS region to test in. This helps ensure your code works in all regions. awsRegion := aws.GetRandomStableRegion(t, nil, nil) // Construct the terraform options with default retryable errors to handle the most common retryable errors in // terraform testing. terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ // The path to where our Terraform code is located TerraformDir: exampleFolder, // Variables to pass to our Terraform code using -var options Vars: map[string]interface{}{ "function_name": functionName, "region": awsRegion, }, }) // At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // This will run `terraform init` and `terraform apply` and fail the test if there are any errors terraform.InitAndApply(t, terraformOptions) // Call InvokeFunctionWithParms with an InvocationType of "DryRun". // A "DryRun" invocation does not execute the function, so the example // test function will not be checking the payload. var invocationType aws.InvocationTypeOption = aws.InvocationTypeDryRun input := &aws.LambdaOptions{InvocationType: &invocationType} out := aws.InvokeFunctionWithParams(t, awsRegion, functionName, input) // With "DryRun", there's no message in the output, but there is // a status code which will have a value of 204 for a successful // invocation. assert.Equal(t, int(out.StatusCode), 204) // Invoke the function, this time causing the Lambda to error and // capturing the error. invocationType = aws.InvocationTypeRequestResponse input = &aws.LambdaOptions{ InvocationType: &invocationType, Payload: ExampleFunctionPayload{ShouldFail: true, Echo: "hi!"}, } out, err = aws.InvokeFunctionWithParamsE(t, awsRegion, functionName, input) // The Lambda executed, but should have failed. assert.Error(t, err, "Unhandled") // Make sure the function-specific error comes back assert.Contains(t, string(out.Payload), "failed to handle") // Call InvokeFunctionWithParamsE with a LambdaOptions struct that has // an unsupported InvocationType. The function should fail. invocationType = "Event" input = &aws.LambdaOptions{ InvocationType: &invocationType, Payload: ExampleFunctionPayload{ShouldFail: false, Echo: "hi!"}, } out, err = aws.InvokeFunctionWithParamsE(t, awsRegion, functionName, input) require.NotNil(t, err) assert.Contains(t, err.Error(), "LambdaOptions.InvocationType, if specified, must either be \"RequestResponse\" or \"DryRun\"") } type ExampleFunctionPayload struct { Echo string ShouldFail bool } ================================================ FILE: test/terraform_aws_network_example_test.go ================================================ package test import ( "testing" "github.com/gruntwork-io/terratest/modules/aws" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // An example of how to test the Terraform module in examples/terraform-aws-network-example using Terratest. func TestTerraformAwsNetworkExample(t *testing.T) { t.Parallel() // Pick a random AWS region to test in. This helps ensure your code works in all regions. awsRegion := aws.GetRandomStableRegion(t, nil, nil) // Give the VPC and the subnets correct CIDRs vpcCidr := "10.10.0.0/16" privateSubnetCidr := "10.10.1.0/24" publicSubnetCidr := "10.10.2.0/24" // Construct the terraform options with default retryable errors to handle the most common retryable errors in // terraform testing. terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ // The path to where our Terraform code is located TerraformDir: "../examples/terraform-aws-network-example", // Variables to pass to our Terraform code using -var options Vars: map[string]interface{}{ "main_vpc_cidr": vpcCidr, "private_subnet_cidr": privateSubnetCidr, "public_subnet_cidr": publicSubnetCidr, "aws_region": awsRegion, }, }) // At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // This will run `terraform init` and `terraform apply` and fail the test if there are any errors terraform.InitAndApply(t, terraformOptions) // Run `terraform output` to get the value of an output variable publicSubnetId := terraform.Output(t, terraformOptions, "public_subnet_id") privateSubnetId := terraform.Output(t, terraformOptions, "private_subnet_id") vpcId := terraform.Output(t, terraformOptions, "main_vpc_id") subnets := aws.GetSubnetsForVpc(t, vpcId, awsRegion) require.Equal(t, 2, len(subnets)) // Verify if the network that is supposed to be public is really public assert.True(t, aws.IsPublicSubnet(t, publicSubnetId, awsRegion)) // Verify if the network that is supposed to be private is really private assert.False(t, aws.IsPublicSubnet(t, privateSubnetId, awsRegion)) } ================================================ FILE: test/terraform_aws_rds_example_test.go ================================================ package test import ( "fmt" "strings" "testing" "github.com/gruntwork-io/terratest/modules/aws" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" test_structure "github.com/gruntwork-io/terratest/modules/test-structure" "github.com/stretchr/testify/assert" ) // An example of how to test the Terraform module in examples/terraform-aws-rds-example using Terratest. func TestTerraformAwsRdsExample(t *testing.T) { ttable := []struct { name string engineName string majorEngineVersion string engineFamily string licenseModel string schemaCheck func(t *testing.T, dbUrl string, dbPort int32, dbUsername string, dbPassword string, expectedSchemaName string) bool expectedOptins map[struct { opName string setName string }]string expectedParameter map[string]string }{ { name: "mysql", engineName: "mysql", majorEngineVersion: "5.7", engineFamily: "mysql5.7", licenseModel: "general-public-license", schemaCheck: func(t *testing.T, dbUrl string, dbPort int32, dbUsername, dbPassword, expectedSchemaName string) bool { return aws.GetWhetherSchemaExistsInRdsMySqlInstance(t, dbUrl, dbPort, dbUsername, dbPassword, expectedSchemaName) }, expectedOptins: map[struct { opName string setName string }]string{ {opName: "MARIADB_AUDIT_PLUGIN", setName: "SERVER_AUDIT_EVENTS"}: "CONNECT", }, expectedParameter: map[string]string{ "general_log": "0", "allow-suspicious-udfs": "", }, }, { name: "postgres", engineName: "postgres", majorEngineVersion: "13", engineFamily: "postgres13", licenseModel: "postgresql-license", schemaCheck: func(t *testing.T, dbUrl string, dbPort int32, dbUsername, dbPassword, expectedSchemaName string) bool { return aws.GetWhetherSchemaExistsInRdsPostgresInstance(t, dbUrl, dbPort, dbUsername, dbPassword, expectedSchemaName) }, }, } for _, tt := range ttable { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() // Give this RDS Instance a unique ID for a name tag so we can distinguish it from any other RDS Instance running // in your AWS account expectedName := fmt.Sprintf("terratest-aws-rds-example-%s", strings.ToLower(random.UniqueId())) expectedPort := int32(3306) expectedDatabaseName := "terratest" username := "username" password := "password" // Pick a random AWS region to test in. This helps ensure your code works in all regions. awsRegion := aws.GetRandomStableRegion(t, nil, nil) engineVersion := aws.GetValidEngineVersion(t, awsRegion, tt.engineName, tt.majorEngineVersion) instanceType := aws.GetRecommendedRdsInstanceType(t, awsRegion, tt.engineName, engineVersion, []string{"db.t2.micro", "db.t3.micro", "db.t3.small"}) moduleFolder := test_structure.CopyTerraformFolderToTemp(t, "../", "examples/terraform-aws-rds-example") // Construct the terraform options with default retryable errors to handle the most common retryable errors in // terraform testing. terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ // The path to where our Terraform code is located TerraformDir: moduleFolder, // Variables to pass to our Terraform code using -var options // "username" and "password" should not be passed from here in a production scenario. Vars: map[string]interface{}{ "name": expectedName, "engine_name": tt.engineName, "major_engine_version": tt.majorEngineVersion, "family": tt.engineFamily, "instance_class": instanceType, "username": username, "password": password, "allocated_storage": 5, "license_model": tt.licenseModel, "engine_version": engineVersion, "port": expectedPort, "database_name": expectedDatabaseName, "region": awsRegion, }, }) // At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // This will run `terraform init` and `terraform apply` and fail the test if there are any errors terraform.InitAndApply(t, terraformOptions) // Run `terraform output` to get the value of an output variable dbInstanceID := terraform.Output(t, terraformOptions, "db_instance_id") // Look up the endpoint address and port of the RDS instance address := aws.GetAddressOfRdsInstance(t, dbInstanceID, awsRegion) port := aws.GetPortOfRdsInstance(t, dbInstanceID, awsRegion) schemaExistsInRdsInstance := tt.schemaCheck(t, address, port, username, password, expectedDatabaseName) // Lookup parameter values. All defined values are strings in the API call response // Verify that the address is not null assert.NotNil(t, address) // Verify that the DB instance is listening on the port mentioned assert.Equal(t, expectedPort, port) // Verify that the table/schema requested for creation is actually present in the database assert.True(t, schemaExistsInRdsInstance) // assert expected parameters for k, v := range tt.expectedParameter { assert.Equal(t, v, aws.GetParameterValueForParameterOfRdsInstance(t, k, dbInstanceID, awsRegion)) } // assert all parameters params := aws.GetAllParametersOfRdsInstance(t, dbInstanceID, awsRegion) paramNames := map[string]struct{}{} for _, param := range params { paramNames[*param.ParameterName] = struct{}{} } assert.Len(t, paramNames, len(params), "should return no duplicate parameters") assert.True(t, len(paramNames) > 100) // assert expected options for k, v := range tt.expectedOptins { // Lookup option values. All defined values are strings in the API call response assert.Equal(t, v, aws.GetOptionSettingForOfRdsInstance(t, k.opName, k.setName, dbInstanceID, awsRegion)) } }) } } ================================================ FILE: test/terraform_aws_s3_example_test.go ================================================ package test import ( "fmt" "strings" "testing" "github.com/gruntwork-io/terratest/modules/aws" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) // An example of how to test the Terraform module in examples/terraform-aws-s3-example using Terratest. func TestTerraformAwsS3Example(t *testing.T) { t.Parallel() // Give this S3 Bucket a unique ID for a name tag so we can distinguish it from any other Buckets provisioned // in your AWS account expectedName := fmt.Sprintf("terratest-aws-s3-example-%s", strings.ToLower(random.UniqueId())) // Give this S3 Bucket an environment to operate as a part of for the purposes of resource tagging expectedEnvironment := "Automated Testing" // Pick a random AWS region to test in. This helps ensure your code works in all regions. awsRegion := aws.GetRandomStableRegion(t, nil, nil) // Construct the terraform options with default retryable errors to handle the most common retryable errors in // terraform testing. terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ // The path to where our Terraform code is located TerraformDir: "../examples/terraform-aws-s3-example", // Variables to pass to our Terraform code using -var options Vars: map[string]interface{}{ "tag_bucket_name": expectedName, "tag_bucket_environment": expectedEnvironment, "with_policy": "true", "region": awsRegion, }, }) // At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // This will run `terraform init` and `terraform apply` and fail the test if there are any errors terraform.InitAndApply(t, terraformOptions) // Run `terraform output` to get the value of an output variable bucketID := terraform.Output(t, terraformOptions, "bucket_id") // Verify that our Bucket has versioning enabled actualStatus := aws.GetS3BucketVersioning(t, awsRegion, bucketID) expectedStatus := "Enabled" assert.Equal(t, expectedStatus, actualStatus) // Verify that our Bucket has a policy attached aws.AssertS3BucketPolicyExists(t, awsRegion, bucketID) // Verify that our bucket has server access logging TargetBucket set to what's expected loggingTargetBucket := aws.GetS3BucketLoggingTarget(t, awsRegion, bucketID) expectedLogsTargetBucket := fmt.Sprintf("%s-logs", bucketID) loggingObjectTargetPrefix := aws.GetS3BucketLoggingTargetPrefix(t, awsRegion, bucketID) expectedLogsTargetPrefix := "TFStateLogs/" assert.Equal(t, expectedLogsTargetBucket, loggingTargetBucket) assert.Equal(t, expectedLogsTargetPrefix, loggingObjectTargetPrefix) } ================================================ FILE: test/terraform_aws_ssm_example_test.go ================================================ package test import ( "testing" "time" "github.com/gruntwork-io/terratest/modules/aws" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/require" ) func TestTerraformAwsSsmExample(t *testing.T) { t.Parallel() region := aws.GetRandomStableRegion(t, nil, nil) // Some AWS regions are missing certain instance types, so pick an available type based on the region we picked instanceType := aws.GetRecommendedInstanceType(t, region, []string{"t2.micro, t3.micro", "t2.small", "t3.small"}) terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ TerraformDir: "../examples/terraform-aws-ssm-example", Vars: map[string]interface{}{ "region": region, "instance_type": instanceType, }, }) defer terraform.Destroy(t, terraformOptions) terraform.InitAndApply(t, terraformOptions) instanceID := terraform.Output(t, terraformOptions, "instance_id") timeout := 3 * time.Minute aws.WaitForSsmInstance(t, region, instanceID, timeout) result := aws.CheckSsmCommand(t, region, instanceID, "echo Hello, World", timeout) require.Equal(t, result.Stdout, "Hello, World\n") require.Equal(t, result.Stderr, "") require.Equal(t, int64(0), result.ExitCode) result, err := aws.CheckSsmCommandE(t, region, instanceID, "cat /wrong/file", timeout) require.Error(t, err) require.Equal(t, "Failed", err.Error()) require.Equal(t, "cat: /wrong/file: No such file or directory\nfailed to run commands: exit status 1", result.Stderr) require.Equal(t, "", result.Stdout) require.Equal(t, int64(1), result.ExitCode) } ================================================ FILE: test/terraform_backend_example_test.go ================================================ package test import ( "fmt" "strings" "testing" "github.com/gruntwork-io/terratest/modules/aws" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/require" ) // An example of how to test the Terraform module in examples/terraform-backend-example using Terratest. func TestTerraformBackendExample(t *testing.T) { t.Parallel() awsRegion := aws.GetRandomRegion(t, nil, nil) uniqueId := random.UniqueId() // Create an S3 bucket where we can store state bucketName := fmt.Sprintf("test-terraform-backend-example-%s", strings.ToLower(uniqueId)) defer cleanupS3Bucket(t, awsRegion, bucketName) aws.CreateS3Bucket(t, awsRegion, bucketName) key := fmt.Sprintf("%s/terraform.tfstate", uniqueId) data := fmt.Sprintf("data-for-test-%s", uniqueId) // Deploy the module, configuring it to use the S3 bucket as an S3 backend terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ TerraformDir: "../examples/terraform-backend-example", Vars: map[string]interface{}{ "foo": data, }, BackendConfig: map[string]interface{}{ "bucket": bucketName, "key": key, "region": awsRegion, }, }) defer terraform.Destroy(t, terraformOptions) terraform.InitAndApply(t, terraformOptions) // Check a state file actually got stored and contains our data in it somewhere (since that data is used in an // output of the Terraform code) contents := aws.GetS3ObjectContents(t, awsRegion, bucketName, key) require.Contains(t, contents, data) // The module doesn't really *do* anything, so we just check a dummy output here and move on foo := terraform.OutputRequired(t, terraformOptions, "foo") require.Equal(t, data, foo) } func cleanupS3Bucket(t *testing.T, awsRegion string, bucketName string) { aws.EmptyS3Bucket(t, awsRegion, bucketName) aws.DeleteS3Bucket(t, awsRegion, bucketName) } ================================================ FILE: test/terraform_basic_example_regression_test.go ================================================ package test import ( "encoding/json" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" test_structure "github.com/gruntwork-io/terratest/modules/test-structure" ) // The tests in this folder are not example usage of Terratest. Instead, this is a regression test to ensure the // formatting rules work with an actual Terraform call when using more complex structures. func TestTerraformFormatNestedOneLevelList(t *testing.T) { t.Parallel() testList := [][]string{ []string{random.UniqueId()}, } options := GetTerraformOptionsForFormatTests(t) options.Vars["example_any"] = testList defer terraform.Destroy(t, options) terraform.InitAndApply(t, options) outputMap := terraform.OutputForKeys(t, options, []string{"example_any"}) actualExampleList := outputMap["example_any"] AssertEqualJson(t, actualExampleList, testList) } func TestTerraformFormatNestedTwoLevelList(t *testing.T) { t.Parallel() testList := [][][]string{ [][]string{[]string{random.UniqueId()}}, } options := GetTerraformOptionsForFormatTests(t) options.Vars["example_any"] = testList defer terraform.Destroy(t, options) terraform.InitAndApply(t, options) outputMap := terraform.OutputForKeys(t, options, []string{"example_any"}) actualExampleList := outputMap["example_any"] AssertEqualJson(t, actualExampleList, testList) } func TestTerraformFormatNestedMultipleItems(t *testing.T) { t.Parallel() testList := [][]string{ []string{random.UniqueId(), random.UniqueId()}, []string{random.UniqueId(), random.UniqueId(), random.UniqueId()}, } options := GetTerraformOptionsForFormatTests(t) options.Vars["example_any"] = testList defer terraform.Destroy(t, options) terraform.InitAndApply(t, options) outputMap := terraform.OutputForKeys(t, options, []string{"example_any"}) actualExampleList := outputMap["example_any"] AssertEqualJson(t, actualExampleList, testList) } func TestTerraformFormatNestedOneLevelMap(t *testing.T) { t.Parallel() testMap := map[string]map[string]string{ "test": map[string]string{ "foo": random.UniqueId(), }, } options := GetTerraformOptionsForFormatTests(t) options.Vars["example_any"] = testMap defer terraform.Destroy(t, options) terraform.InitAndApply(t, options) outputMap := terraform.OutputForKeys(t, options, []string{"example_any"}) actualExampleMap := outputMap["example_any"] AssertEqualJson(t, actualExampleMap, testMap) } func TestTerraformFormatNestedTwoLevelMap(t *testing.T) { t.Parallel() testMap := map[string]map[string]map[string]string{ "test": map[string]map[string]string{ "foo": map[string]string{ "bar": random.UniqueId(), }, }, } options := GetTerraformOptionsForFormatTests(t) options.Vars["example_any"] = testMap defer terraform.Destroy(t, options) terraform.InitAndApply(t, options) outputMap := terraform.OutputForKeys(t, options, []string{"example_any"}) actualExampleMap := outputMap["example_any"] AssertEqualJson(t, actualExampleMap, testMap) } func TestTerraformFormatNestedMultipleItemsMap(t *testing.T) { t.Parallel() testMap := map[string]map[string]string{ "test": map[string]string{ "foo": random.UniqueId(), "bar": random.UniqueId(), }, "other": map[string]string{ "baz": random.UniqueId(), "boo": random.UniqueId(), }, } options := GetTerraformOptionsForFormatTests(t) options.Vars["example_any"] = testMap defer terraform.Destroy(t, options) terraform.InitAndApply(t, options) outputMap := terraform.OutputForKeys(t, options, []string{"example_any"}) actualExampleMap := outputMap["example_any"] AssertEqualJson(t, actualExampleMap, testMap) } func TestTerraformFormatNestedListMap(t *testing.T) { t.Parallel() testMap := map[string][]string{ "test": []string{random.UniqueId(), random.UniqueId()}, } options := GetTerraformOptionsForFormatTests(t) options.Vars["example_any"] = testMap defer terraform.Destroy(t, options) terraform.InitAndApply(t, options) outputMap := terraform.OutputForKeys(t, options, []string{"example_any"}) actualExampleMap := outputMap["example_any"] AssertEqualJson(t, actualExampleMap, testMap) } func GetTerraformOptionsForFormatTests(t *testing.T) *terraform.Options { exampleFolder := test_structure.CopyTerraformFolderToTemp(t, "../", "examples/terraform-basic-example") // Set up terratest to retry on known failures maxTerraformRetries := 3 sleepBetweenTerraformRetries := 5 * time.Second retryableTerraformErrors := map[string]string{ // `terraform init` frequently fails in CI due to network issues accessing plugins. The reason is unknown, but // eventually these succeed after a few retries. ".*unable to verify signature.*": "Failed to retrieve plugin due to transient network error.", ".*unable to verify checksum.*": "Failed to retrieve plugin due to transient network error.", ".*no provider exists with the given name.*": "Failed to retrieve plugin due to transient network error.", ".*registry service is unreachable.*": "Failed to retrieve plugin due to transient network error.", ".*connection reset by peer.*": "Failed to retrieve plugin due to transient network error.", } terraformOptions := &terraform.Options{ TerraformDir: exampleFolder, Vars: map[string]interface{}{}, NoColor: true, RetryableTerraformErrors: retryableTerraformErrors, MaxRetries: maxTerraformRetries, TimeBetweenRetries: sleepBetweenTerraformRetries, } return terraformOptions } // The value of the output nested in the outputMap returned by OutputForKeys uses the interface{} type for nested // structures. This can't be compared to actual types like [][]string{}, so we instead compare the json versions. func AssertEqualJson(t *testing.T, actual interface{}, expected interface{}) { actualJson, err := json.Marshal(actual) require.NoError(t, err) expectedJson, err := json.Marshal(expected) require.NoError(t, err) assert.Equal(t, actualJson, expectedJson) } ================================================ FILE: test/terraform_basic_example_test.go ================================================ package test import ( "testing" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) // An example of how to test the simple Terraform module in examples/terraform-basic-example using Terratest. func TestTerraformBasicExample(t *testing.T) { t.Parallel() expectedText := "test" expectedList := []string{expectedText} expectedMap := map[string]string{"expected": expectedText} terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ // website::tag::1::Set the path to the Terraform code that will be tested. // The path to where our Terraform code is located TerraformDir: "../examples/terraform-basic-example", // Variables to pass to our Terraform code using -var options Vars: map[string]interface{}{ "example": expectedText, // We also can see how lists and maps translate between terratest and terraform. "example_list": expectedList, "example_map": expectedMap, }, // Variables to pass to our Terraform code using -var-file options VarFiles: []string{"varfile.tfvars"}, // Disable colors in Terraform commands so its easier to parse stdout/stderr NoColor: true, }) // website::tag::4::Clean up resources with "terraform destroy". Using "defer" runs the command at the end of the test, whether the test succeeds or fails. // At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // website::tag::2::Run "terraform init" and "terraform apply". // This will run `terraform init` and `terraform apply` and fail the test if there are any errors terraform.InitAndApply(t, terraformOptions) // Run `terraform output` to get the values of output variables actualTextExample := terraform.Output(t, terraformOptions, "example") actualTextExample2 := terraform.Output(t, terraformOptions, "example2") actualExampleList := terraform.OutputList(t, terraformOptions, "example_list") actualExampleMap := terraform.OutputMap(t, terraformOptions, "example_map") // website::tag::3::Check the output against expected values. // Verify we're getting back the outputs we expect assert.Equal(t, expectedText, actualTextExample) assert.Equal(t, expectedText, actualTextExample2) assert.Equal(t, expectedList, actualExampleList) assert.Equal(t, expectedMap, actualExampleMap) } ================================================ FILE: test/terraform_database_example_test.go ================================================ package test import ( "fmt" "testing" "time" "github.com/gruntwork-io/terratest/modules/database" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/terraform" ) func TestTerraformDatabaseExample(t *testing.T) { t.Parallel() terraformOptions := &terraform.Options{ // The path to where our Terraform code is located TerraformDir: "../examples/terraform-database-example", // Variables to pass to our Terraform code using -var options Vars: map[string]interface{}{}, } // At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // This will run `terraform init` and `terraform apply` and fail the test if there are any errors terraform.InitAndApply(t, terraformOptions) // Setting database configuration, including host, port, username, password and database name var dbConfig database.DBConfig dbConfig.Host = terraform.Output(t, terraformOptions, "host") dbConfig.Port = terraform.Output(t, terraformOptions, "port") dbConfig.User = terraform.Output(t, terraformOptions, "username") dbConfig.Password = terraform.Output(t, terraformOptions, "password") dbConfig.Database = terraform.Output(t, terraformOptions, "database_name") // It can take a minute or so for the database to boot up, so retry a few times maxRetries := 15 timeBetweenRetries := 15 * time.Second description := fmt.Sprintf("Executing commands on database %s", dbConfig.Host) // Verify that we can connect to the database and run SQL commands retry.DoWithRetry(t, description, maxRetries, timeBetweenRetries, func() (string, error) { // Connect to specific database, i.e. postgres db, err := database.DBConnectionE(t, "postgres", dbConfig) if err != nil { return "", err } // Create a table creation := "create table person (id integer, name varchar(30), primary key (id))" database.DBExecution(t, db, creation) // Insert a row expectedID := 12345 expectedName := "azure" insertion := fmt.Sprintf("insert into person values (%d, '%s')", expectedID, expectedName) database.DBExecution(t, db, insertion) // Query the table and check the output query := "select name from person" database.DBQueryWithValidation(t, db, query, "azure") // Drop the table drop := "drop table person" database.DBExecution(t, db, drop) fmt.Println("Executed SQL commands correctly") defer db.Close() return "", nil }) } ================================================ FILE: test/terraform_hello_world_example_test.go ================================================ package test import ( "testing" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestTerraformHelloWorldExample(t *testing.T) { // website::tag::2:: Construct the terraform options with default retryable errors to handle the most common // retryable errors in terraform testing. terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ // website::tag::1:: Set the path to the Terraform code that will be tested. TerraformDir: "../examples/terraform-hello-world-example", }) // website::tag::5:: Clean up resources with "terraform destroy" at the end of the test. defer terraform.Destroy(t, terraformOptions) // website::tag::3:: Run "terraform init" and "terraform apply". Fail the test if there are any errors. terraform.InitAndApply(t, terraformOptions) // website::tag::4:: Run `terraform output` to get the values of output variables and check they have the expected values. output := terraform.Output(t, terraformOptions, "hello_world") assert.Equal(t, "Hello, World!", output) } ================================================ FILE: test/terraform_http_example_test.go ================================================ package test import ( "crypto/tls" "fmt" "testing" "time" "github.com/gruntwork-io/terratest/modules/aws" http_helper "github.com/gruntwork-io/terratest/modules/http-helper" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" ) // An example of how to test the Terraform module in examples/terraform-http-example using Terratest. func TestTerraformHttpExample(t *testing.T) { t.Parallel() // A unique ID we can use to namespace resources so we don't clash with anything already in the AWS account or // tests running in parallel uniqueID := random.UniqueId() // Give this EC2 Instance and other resources in the Terraform code a name with a unique ID so it doesn't clash // with anything else in the AWS account. instanceName := fmt.Sprintf("terratest-http-example-%s", uniqueID) // Specify the text the EC2 Instance will return when we make HTTP requests to it. instanceText := fmt.Sprintf("Hello, %s!", uniqueID) // Pick a random AWS region to test in. This helps ensure your code works in all regions. awsRegion := aws.GetRandomStableRegion(t, nil, nil) // Some AWS regions are missing certain instance types, so pick an available type based on the region we picked instanceType := aws.GetRecommendedInstanceType(t, awsRegion, []string{"t2.micro, t3.micro", "t2.small", "t3.small"}) // Construct the terraform options with default retryable errors to handle the most common retryable errors in // terraform testing. terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ // The path to where our Terraform code is located TerraformDir: "../examples/terraform-http-example", // Variables to pass to our Terraform code using -var options Vars: map[string]interface{}{ "aws_region": awsRegion, "instance_name": instanceName, "instance_text": instanceText, "instance_type": instanceType, }, }) // At the end of the test, run `terraform destroy` to clean up any resources that were created defer terraform.Destroy(t, terraformOptions) // This will run `terraform init` and `terraform apply` and fail the test if there are any errors terraform.InitAndApply(t, terraformOptions) // Run `terraform output` to get the value of an output variable instanceURL := terraform.Output(t, terraformOptions, "instance_url") // Setup a TLS configuration to submit with the helper, a blank struct is acceptable tlsConfig := tls.Config{} // It can take a minute or so for the Instance to boot up, so retry a few times maxRetries := 30 timeBetweenRetries := 5 * time.Second // Verify that we get back a 200 OK with the expected instanceText http_helper.HttpGetWithRetry(t, instanceURL, &tlsConfig, 200, instanceText, maxRetries, timeBetweenRetries) } ================================================ FILE: test/terraform_opa_example_extra_args_test.go ================================================ package test import ( "testing" "github.com/gruntwork-io/terratest/modules/opa" "github.com/gruntwork-io/terratest/modules/terraform" ) // TestOPAEvalTerraformModuleWithExtraArgs demonstrates how to pass extra command line arguments to OPA, // such as --v0-compatible for backwards compatibility with OPA v0.x. func TestOPAEvalTerraformModuleWithExtraArgs(t *testing.T) { t.Parallel() tfOpts := &terraform.Options{ TerraformDir: "../examples/terraform-opa-example/pass", } opaOpts := &opa.EvalOptions{ RulePath: "../examples/terraform-opa-example/policy/enforce_source_v0.rego", FailMode: opa.FailUndefined, // Pass extra command line arguments to OPA eval subcommand ExtraArgs: []string{"--v0-compatible"}, } // This will run: opa eval --v0-compatible --fail -i -d data.enforce_source.allow terraform.OPAEval(t, tfOpts, opaOpts, "data.enforce_source.allow") } ================================================ FILE: test/terraform_opa_example_test.go ================================================ package test import ( "testing" "github.com/gruntwork-io/terratest/modules/opa" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/require" ) // An example of how to use Terratest to run OPA policy checks on Terraform source code. This will check the module // called `pass` against the rego policy `enforce_source` defined in the `terraform-opa-example` folder. func TestOPAEvalTerraformModulePassesCheck(t *testing.T) { t.Parallel() tfOpts := &terraform.Options{ // website::tag::1:: Set the path to the Terraform code that will be tested. TerraformDir: "../examples/terraform-opa-example/pass", } opaOpts := &opa.EvalOptions{ // website::tag::2:: Set the path to the OPA policy code that should be used. RulePath: "../examples/terraform-opa-example/policy/enforce_source.rego", // website::tag::3:: Run OPA in fail mode so that it will exit with non-zero exit code when the result query is undefined. FailMode: opa.FailUndefined, } // website::tag::4:: Run OPA with the configured options, querying for the allow variable. The OPAEval function automatically expects the check to pass, failing the test if opa eval exits with non-zero exit code. terraform.OPAEval(t, tfOpts, opaOpts, "data.enforce_source.allow") } // An example of how to use Terratest to run OPA policy checks on Terraform source code. This will check the module // called `fail` against the rego policy `enforce_source` defined in the `terraform-opa-example` folder and validate // that the module fails the OPA checks. func TestOPAEvalTerraformModuleFailsCheck(t *testing.T) { t.Parallel() // website::tag::5:: Configure in a similar fashion to the above test, but run against the `fail` example. policyPath := "../examples/terraform-opa-example/policy/enforce_source.rego" tfOpts := &terraform.Options{TerraformDir: "../examples/terraform-opa-example/fail"} opaOpts := &opa.EvalOptions{ FailMode: opa.FailUndefined, RulePath: policyPath, } // website::tag::6:: Here we expect the checks to fail, so we use `OPAEvalE` to check the error. Note that on the files that failed, this function will rerun `opa eval` with the query set to `data`, so you can see the values of all the variables in the policy. This is useful for debugging failures. require.Error(t, terraform.OPAEvalE(t, tfOpts, opaOpts, "data.enforce_source.allow")) } // An example of how to use Terratest to run OPA policy checks on Terraform source code using a remote OPA policy source // file. This will check the module called `pass` against the rego policy `enforce_source` defined in the // `terraform-opa-example` folder of the terratest repository. func TestOPAEvalTerraformModuleRemotePolicy(t *testing.T) { t.Parallel() // Skip this test when using OPA v1.0+ since the main branch may have v0.x syntax // while the local version requires v1.0+ syntax t.Skip("Skipping remote policy test due to syntax mismatch between local OPA version and remote policy") tfOpts := &terraform.Options{ TerraformDir: "../examples/terraform-opa-example/pass", } opaOpts := &opa.EvalOptions{ // This test fetches the policy from the main branch of the terratest repository. // The policy uses OPA v1.0+ compatible syntax. RulePath: "git::https://github.com/gruntwork-io/terratest.git//examples/terraform-opa-example/policy/enforce_source.rego?ref=main", FailMode: opa.FailUndefined, } terraform.OPAEval(t, tfOpts, opaOpts, "data.enforce_source.allow") } ================================================ FILE: test/terraform_packer_example_test.go ================================================ package test import ( "crypto/tls" "fmt" "testing" "time" "github.com/gruntwork-io/terratest/modules/aws" httpHelper "github.com/gruntwork-io/terratest/modules/http-helper" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/packer" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" testStructure "github.com/gruntwork-io/terratest/modules/test-structure" ) // This is a complicated, end-to-end integration test. It builds the AMI from examples/packer-docker-example, // deploys it using the Terraform code on examples/terraform-packer-example, and checks that the web server in the AMI // response to requests. The test is broken into "stages" so you can skip stages by setting environment variables (e.g., // skip stage "build_ami" by setting the environment variable "SKIP_build_ami=true"), which speeds up iteration when // running this test over and over again locally. func TestTerraformPackerExample(t *testing.T) { t.Parallel() // The folder where we have our Terraform code workingDir := "../examples/terraform-packer-example" // At the end of the test, delete the AMI defer testStructure.RunTestStage(t, "cleanup_ami", func() { awsRegion := testStructure.LoadString(t, workingDir, "awsRegion") deleteAMI(t, awsRegion, workingDir) }) // At the end of the test, undeploy the web app using Terraform defer testStructure.RunTestStage(t, "cleanup_terraform", func() { undeployUsingTerraform(t, workingDir) }) // At the end of the test, fetch the most recent syslog entries from each Instance. This can be useful for // debugging issues without having to manually SSH to the server. defer testStructure.RunTestStage(t, "logs", func() { awsRegion := testStructure.LoadString(t, workingDir, "awsRegion") fetchSyslogForInstance(t, awsRegion, workingDir) }) // Build the AMI for the web app testStructure.RunTestStage(t, "build_ami", func() { // Pick a random AWS region to test in. This helps ensure your code works in all regions. awsRegion := aws.GetRandomStableRegion(t, nil, nil) testStructure.SaveString(t, workingDir, "awsRegion", awsRegion) buildAMI(t, awsRegion, workingDir) }) // Deploy the web app using Terraform testStructure.RunTestStage(t, "deploy_terraform", func() { awsRegion := testStructure.LoadString(t, workingDir, "awsRegion") deployUsingTerraform(t, awsRegion, workingDir) }) // Validate that the web app deployed and is responding to HTTP requests testStructure.RunTestStage(t, "validate", func() { validateInstanceRunningWebServer(t, workingDir) }) } // Build the AMI in packer-docker-example func buildAMI(t *testing.T, awsRegion string, workingDir string) { // Some AWS regions are missing certain instance types, so pick an available type based on the region we picked instanceType := aws.GetRecommendedInstanceType(t, awsRegion, []string{"t2.micro, t3.micro", "t2.small", "t3.small"}) packerOptions := &packer.Options{ // The path to where the Packer template is located Template: "../examples/packer-docker-example/build.pkr.hcl", // Only build the AMI Only: "amazon-ebs.ubuntu-ami", // Variables to pass to our Packer build using -var options Vars: map[string]string{ "aws_region": awsRegion, "instance_type": instanceType, }, // Configure retries for intermittent errors RetryableErrors: DefaultRetryablePackerErrors, TimeBetweenRetries: DefaultTimeBetweenPackerRetries, MaxRetries: DefaultMaxPackerRetries, } // Save the Packer Options so future test stages can use them testStructure.SavePackerOptions(t, workingDir, packerOptions) // Build the AMI amiID := packer.BuildArtifact(t, packerOptions) // Save the AMI ID so future test stages can use them testStructure.SaveAmiId(t, workingDir, amiID) } // Delete the AMI func deleteAMI(t *testing.T, awsRegion string, workingDir string) { // Load the AMI ID and Packer Options saved by the earlier build_ami stage amiID := testStructure.LoadAmiId(t, workingDir) aws.DeleteAmi(t, awsRegion, amiID) } // Deploy the terraform-packer-example using Terraform func deployUsingTerraform(t *testing.T, awsRegion string, workingDir string) { // A unique ID we can use to namespace resources so we don't clash with anything already in the AWS account or // tests running in parallel uniqueID := random.UniqueId() // Give this EC2 Instance and other resources in the Terraform code a name with a unique ID so it doesn't clash // with anything else in the AWS account. instanceName := fmt.Sprintf("terratest-http-example-%s", uniqueID) // Specify the text the EC2 Instance will return when we make HTTP requests to it. instanceText := fmt.Sprintf("Hello, %s!", uniqueID) // Some AWS regions are missing certain instance types, so pick an available type based on the region we picked instanceType := aws.GetRecommendedInstanceType(t, awsRegion, []string{"t2.micro, t3.micro", "t2.small", "t3.small"}) // Load the AMI ID saved by the earlier build_ami stage amiID := testStructure.LoadAmiId(t, workingDir) // Construct the terraform options with default retryable errors to handle the most common retryable errors in // terraform testing. terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ // The path to where our Terraform code is located TerraformDir: workingDir, // Variables to pass to our Terraform code using -var options Vars: map[string]interface{}{ "aws_region": awsRegion, "instance_name": instanceName, "instance_text": instanceText, "instance_type": instanceType, "ami_id": amiID, }, }) // Save the Terraform Options struct, instance name, and instance text so future test stages can use it testStructure.SaveTerraformOptions(t, workingDir, terraformOptions) // This will run `terraform init` and `terraform apply` and fail the test if there are any errors terraform.InitAndApply(t, terraformOptions) } // Undeploy the terraform-packer-example using Terraform func undeployUsingTerraform(t *testing.T, workingDir string) { // Load the Terraform Options saved by the earlier deploy_terraform stage terraformOptions := testStructure.LoadTerraformOptions(t, workingDir) terraform.Destroy(t, terraformOptions) } // Fetch the most recent syslogs for the instance. This is a handy way to see what happened on the Instance as part of // your test log output, without having to re-run the test and manually SSH to the Instance. func fetchSyslogForInstance(t *testing.T, awsRegion string, workingDir string) { // Load the Terraform Options saved by the earlier deploy_terraform stage terraformOptions := testStructure.LoadTerraformOptions(t, workingDir) instanceID := terraform.OutputRequired(t, terraformOptions, "instance_id") logs := aws.GetSyslogForInstance(t, instanceID, awsRegion) logger.Default.Logf(t, "Most recent syslog for Instance %s:\n\n%s\n", instanceID, logs) } // Validate the web server has been deployed and is working func validateInstanceRunningWebServer(t *testing.T, workingDir string) { // Load the Terraform Options saved by the earlier deploy_terraform stage terraformOptions := testStructure.LoadTerraformOptions(t, workingDir) // Run `terraform output` to get the value of an output variable instanceURL := terraform.Output(t, terraformOptions, "instance_url") // Setup a TLS configuration to submit with the helper, a blank struct is acceptable tlsConfig := tls.Config{} // Figure out what text the instance should return for each request instanceText, _ := terraformOptions.Vars["instance_text"].(string) // It can take a minute or so for the Instance to boot up, so retry a few times maxRetries := 30 timeBetweenRetries := 5 * time.Second // Verify that we get back a 200 OK with the expected instanceText httpHelper.HttpGetWithRetry(t, instanceURL, &tlsConfig, 200, instanceText, maxRetries, timeBetweenRetries) } ================================================ FILE: test/terraform_redeploy_example_test.go ================================================ package test import ( "crypto/tls" "fmt" "strings" "testing" "time" "github.com/gruntwork-io/terratest/modules/aws" http_helper "github.com/gruntwork-io/terratest/modules/http-helper" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/terraform" test_structure "github.com/gruntwork-io/terratest/modules/test-structure" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // An example of how to test the Terraform module in examples/terraform-redeploy-example using Terratest. We deploy the // Terraform code, check that the load balancer returns the expected response, redeploy the code, and check that the // entire time during the redeploy, the load balancer continues returning a valid response and never returns an error // (i.e., we validate that zero-downtime deployment works). // // The test is broken into "stages" so you can skip stages by setting environment variables (e.g., skip stage // "deploy_initial" by setting the environment variable "SKIP_deploy_initial=true"), which speeds up iteration when // running this test over and over again locally. func TestTerraformRedeployExample(t *testing.T) { t.Parallel() // The folder where we have our Terraform code workingDir := "../examples/terraform-redeploy-example" // Pick a random AWS region to test in. This helps ensure your code works in all regions. test_structure.RunTestStage(t, "pick_region", func() { awsRegion := aws.GetRandomStableRegion(t, nil, nil) // Save the region, so that we reuse the same region when we skip stages test_structure.SaveString(t, workingDir, "region", awsRegion) }) // At the end of the test, clean up all the resources we created defer test_structure.RunTestStage(t, "teardown", func() { terraformOptions := test_structure.LoadTerraformOptions(t, workingDir) terraform.Destroy(t, terraformOptions) }) // At the end of the test, fetch the logs from each Instance. This can be useful for // debugging issues without having to manually SSH to the server. defer test_structure.RunTestStage(t, "logs", func() { awsRegion := test_structure.LoadString(t, workingDir, "region") fetchSyslogForAsg(t, awsRegion, workingDir) fetchFilesFromAsg(t, awsRegion, workingDir) }) // Deploy the web app test_structure.RunTestStage(t, "deploy_initial", func() { awsRegion := test_structure.LoadString(t, workingDir, "region") initialDeploy(t, awsRegion, workingDir) }) // Validate that the ASG deployed and is responding to HTTP requests test_structure.RunTestStage(t, "validate_initial", func() { awsRegion := test_structure.LoadString(t, workingDir, "region") validateAsgRunningWebServer(t, awsRegion, workingDir) }) // Validate that we can deploy a change to the ASG with zero downtime test_structure.RunTestStage(t, "validate_redeploy", func() { validateAsgRedeploy(t, workingDir) }) } // Do the initial deployment of the terraform-redeploy-example func initialDeploy(t *testing.T, awsRegion string, workingDir string) { // A unique ID we can use to namespace resources so we don't clash with anything already in the AWS account or // tests running in parallel uniqueID := random.UniqueId() // Create a KeyPair we can use later to SSH to each Instance keyPair := aws.CreateAndImportEC2KeyPair(t, awsRegion, uniqueID) test_structure.SaveEc2KeyPair(t, workingDir, keyPair) // Give the ASG and other resources in the Terraform code a name with a unique ID so it doesn't clash // with anything else in the AWS account. name := fmt.Sprintf("redeploy-test-%s", uniqueID) // Specify the text the ASG will return when we make HTTP requests to it. text := fmt.Sprintf("Hello, %s!", uniqueID) // Some AWS regions are missing certain instance types, so pick an available type based on the region we picked instanceType := aws.GetRecommendedInstanceType(t, awsRegion, []string{"t2.micro, t3.micro", "t2.small", "t3.small"}) // Construct the terraform options with default retryable errors to handle the most common retryable errors in // terraform testing. terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ // The path to where our Terraform code is located TerraformDir: workingDir, // Variables to pass to our Terraform code using -var options Vars: map[string]interface{}{ "aws_region": awsRegion, "instance_name": name, "instance_text": text, "instance_type": instanceType, "key_pair_name": keyPair.Name, }, }) // Save the Terraform Options struct so future test stages can use it test_structure.SaveTerraformOptions(t, workingDir, terraformOptions) // This will run `terraform init` and `terraform apply` and fail the test if there are any errors terraform.InitAndApply(t, terraformOptions) } // Validate the ASG has been deployed and is working func validateAsgRunningWebServer(t *testing.T, awsRegion string, workingDir string) { // Load the Terraform Options saved by the earlier deploy_terraform stage terraformOptions := test_structure.LoadTerraformOptions(t, workingDir) // Run `terraform output` to get the value of an output variable url := terraform.Output(t, terraformOptions, "url") asgName := terraform.OutputRequired(t, terraformOptions, "asg_name") // Setup a TLS configuration to submit with the helper, a blank struct is acceptable tlsConfig := tls.Config{} // Wait and verify the ASG is scaled to the desired capacity. It can take a few minutes for the ASG to boot up, so // retry a few times. maxRetries := 30 timeBetweenRetries := 10 * time.Second aws.WaitForCapacity(t, asgName, awsRegion, maxRetries, timeBetweenRetries) capacityInfo := aws.GetCapacityInfoForAsg(t, asgName, awsRegion) assert.Equal(t, capacityInfo.DesiredCapacity, int64(3)) assert.Equal(t, capacityInfo.CurrentCapacity, int64(3)) // Figure out what text the ASG should return for each request expectedText, _ := terraformOptions.Vars["instance_text"].(string) // Verify that we get back a 200 OK with the expectedText // It can take a few minutes for the ALB to boot up, so retry a few times http_helper.HttpGetWithRetry(t, url, &tlsConfig, 200, expectedText, maxRetries, timeBetweenRetries) } // Validate we can deploy an update to the ASG with zero downtime for users accessing the ALB func validateAsgRedeploy(t *testing.T, workingDir string) { // Load the Terraform Options saved by the earlier deploy_terraform stage terraformOptions := test_structure.LoadTerraformOptions(t, workingDir) // Figure out what text the ASG was returning for each request originalText, _ := terraformOptions.Vars["instance_text"].(string) // New text for the ASG to return for each request newText := fmt.Sprintf("%s-redeploy", originalText) terraformOptions.Vars["instance_text"] = newText // Save the updated Terraform Options struct test_structure.SaveTerraformOptions(t, workingDir, terraformOptions) // Run `terraform output` to get the value of an output variable url := terraform.Output(t, terraformOptions, "url") // Setup a TLS configuration to submit with the helper, a blank struct is acceptable tlsConfig := tls.Config{} // Check once per second that the ELB returns a proper response to make sure there is no downtime during deployment elbChecks := retry.DoInBackgroundUntilStopped(t, fmt.Sprintf("Check URL %s", url), 1*time.Second, func() { http_helper.HttpGetWithCustomValidation(t, url, &tlsConfig, func(statusCode int, body string) bool { return statusCode == 200 && (body == originalText || body == newText) }) }) // Redeploy the cluster terraform.Apply(t, terraformOptions) // Stop checking the ELB elbChecks.Done() } // (Deprecated) See the fetchFilesFromAsg method below for a more powerful solution. // // Fetch the most recent syslogs for the instances in the ASG. This is a handy way to see what happened on each // Instance as part of your test log output, without having to re-run the test and manually SSH to the Instances. func fetchSyslogForAsg(t *testing.T, awsRegion string, workingDir string) { // Load the Terraform Options saved by the earlier deploy_terraform stage terraformOptions := test_structure.LoadTerraformOptions(t, workingDir) asgName := terraform.OutputRequired(t, terraformOptions, "asg_name") asgLogs := aws.GetSyslogForInstancesInAsg(t, asgName, awsRegion) logger.Logf(t, "===== First few hundred bytes of syslog for instances in ASG %s =====\n\n", asgName) for instanceID, logs := range asgLogs { logger.Logf(t, "Most recent syslog for Instance %s:\n\n%s\n", instanceID, logs) } } // Default syslog location on Ubuntu const syslogPathUbuntu = "/var/log/syslog" // Default location where the User Data script generates an index.html on Ubuntu const indexHtmlUbuntu = "/index.html" // This size is configured in the terraform-redeploy-example itself const asgSize = 3 func fetchFilesFromAsg(t *testing.T, awsRegion string, workingDir string) { // Load the Terraform Options and Key Pair saved by the earlier deploy_terraform stage terraformOptions := test_structure.LoadTerraformOptions(t, workingDir) keyPair := test_structure.LoadEc2KeyPair(t, workingDir) asgName := terraform.OutputRequired(t, terraformOptions, "asg_name") instanceIdToFilePathToContents := aws.FetchContentsOfFilesFromAsg(t, awsRegion, "ubuntu", keyPair, asgName, true, syslogPathUbuntu, indexHtmlUbuntu) require.Len(t, instanceIdToFilePathToContents, asgSize) // Check that the index.html file on each Instance contains the expected text expectedText := terraformOptions.Vars["instance_text"] for instanceID, filePathToContents := range instanceIdToFilePathToContents { require.Contains(t, filePathToContents, indexHtmlUbuntu) assert.Equal(t, expectedText, strings.TrimSpace(filePathToContents[indexHtmlUbuntu]), "Expected %s on instance %s to contain %s", indexHtmlUbuntu, instanceID, expectedText) } logger.Logf(t, "===== Full contents of syslog for instances in ASG %s =====\n\n", asgName) // Print out the FULL contents of syslog (unlike the deprecated GetSyslogForInstancesInAsg, which only returns the // first few hundred bytes) for instanceID, filePathToContents := range instanceIdToFilePathToContents { require.Contains(t, filePathToContents, syslogPathUbuntu) logger.Logf(t, "Full syslog for Instance %s:\n\n%s\n", instanceID, filePathToContents[syslogPathUbuntu]) } } ================================================ FILE: test/terraform_remote_exec_example_test.go ================================================ package test import ( "fmt" "os" "path/filepath" "strings" "testing" "github.com/gruntwork-io/terratest/modules/aws" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/ssh" "github.com/gruntwork-io/terratest/modules/terraform" test_structure "github.com/gruntwork-io/terratest/modules/test-structure" "github.com/stretchr/testify/assert" ) // This test shows how to override the systems local SSH Agent, with an in-process SSH agent, whose keys can be managed // from within your tests. This allows you to test Terraform modules which make SSH connections to the created // instances, useful for tasks such as provisioning. func TestTerraformRemoteExecExample(t *testing.T) { t.Parallel() terraformDirectory := "../examples/terraform-remote-exec-example" // At the end of the test, run `terraform destroy` to clean up any resources that were created defer test_structure.RunTestStage(t, "teardown", func() { terraformOptions := test_structure.LoadTerraformOptions(t, terraformDirectory) keyPair := test_structure.LoadEc2KeyPair(t, terraformDirectory) // destroy terraform resources and delete ec2 key pair terraform.Destroy(t, terraformOptions) aws.DeleteEC2KeyPair(t, keyPair) // remove testFile, if it exists testFile := filepath.Join(terraformDirectory, "public-ip") if _, err := os.Stat(testFile); err == nil { os.Remove(testFile) } }) // Deploy the example test_structure.RunTestStage(t, "setup", func() { // A unique ID we can use to namespace resources so we don't clash with anything already in the AWS account or // tests running in parallel uniqueID := random.UniqueId() // Give this EC2 Instance and other resources in the Terraform code a name with a unique ID so it doesn't clash // with anything else in the AWS account. instanceName := fmt.Sprintf("terratest-remote-exec-example-%s", uniqueID) // Pick a random AWS region to test in. This helps ensure your code works in all regions. awsRegion := aws.GetRandomStableRegion(t, nil, nil) // Some AWS regions are missing certain instance types, so pick an available type based on the region we picked instanceType := aws.GetRecommendedInstanceType(t, awsRegion, []string{"t2.micro, t3.micro", "t2.small", "t3.small"}) // Create an EC2 KeyPair that we can use for SSH access keyPairName := fmt.Sprintf("terratest-remote-exec-example-%s", uniqueID) keyPair := aws.CreateAndImportEC2KeyPair(t, awsRegion, keyPairName) // start an SSH agent, with our key pair added sshAgent := ssh.SshAgentWithKeyPair(t, keyPair.KeyPair) defer sshAgent.Stop() // Construct the terraform options with default retryable errors to handle the most common retryable errors in // terraform testing. terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ // The path to where our Terraform code is located TerraformDir: terraformDirectory, // Variables to pass to our Terraform code using -var options Vars: map[string]interface{}{ "aws_region": awsRegion, "instance_name": instanceName, "instance_type": instanceType, "key_pair_name": keyPairName, }, SshAgent: sshAgent, // Overrides local SSH agent with our new agent }) // Save the options and key pair so later test stages can use them test_structure.SaveTerraformOptions(t, terraformDirectory, terraformOptions) test_structure.SaveEc2KeyPair(t, terraformDirectory, keyPair) // Because of the SshAgent option above, the terraform process will be provided an `SSH_AUTH_SOCK` environment // variable, which will point to the socket file of our in-process `sshAgent` instance: terraform.InitAndApply(t, terraformOptions) // save the `public_instance_ip` output variable for later steps publicIP := terraform.Output(t, terraformOptions, "public_instance_ip") test_structure.SaveString(t, terraformDirectory, "publicIP", publicIP) }) // Make sure we can SSH to the public Instance directly from the public Internet and the private Instance by using // the public Instance as a jump host test_structure.RunTestStage(t, "validate", func() { publicIP := test_structure.LoadString(t, terraformDirectory, "publicIP") // Confirm that the public-ip file that was generated by the provisioner was copied back from the server using // the `scp` command testFile := filepath.Join(terraformDirectory, "public-ip") assert.FileExists(t, testFile) // Check that public IP from output matches public IP generated by script on the server b, err := os.ReadFile(testFile) if err != nil { fmt.Print(err) } assert.Equal(t, strings.TrimSpace(publicIP), strings.TrimSpace(string(b))) }) } ================================================ FILE: test/terraform_scp_example_test.go ================================================ package test import ( "fmt" "os" "path/filepath" "strings" "testing" "time" "github.com/gruntwork-io/terratest/modules/aws" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/ssh" "github.com/gruntwork-io/terratest/modules/terraform" test_structure "github.com/gruntwork-io/terratest/modules/test-structure" "github.com/stretchr/testify/assert" ) func TestTerraformScpExample(t *testing.T) { t.Parallel() exampleFolder := test_structure.CopyTerraformFolderToTemp(t, "../", "examples/terraform-asg-scp-example") // At the end of the test, run `terraform destroy` to clean up any resources that were created defer test_structure.RunTestStage(t, "teardown", func() { terraformOptions := test_structure.LoadTerraformOptions(t, exampleFolder) terraform.Destroy(t, terraformOptions) keyPair := test_structure.LoadEc2KeyPair(t, exampleFolder) aws.DeleteEC2KeyPair(t, keyPair) }) // Deploy the example test_structure.RunTestStage(t, "setup", func() { terraformOptions, keyPair := createTerraformOptions(t, exampleFolder) // Save the options and key pair so later test stages can use them test_structure.SaveTerraformOptions(t, exampleFolder, terraformOptions) test_structure.SaveEc2KeyPair(t, exampleFolder, keyPair) // This will run `terraform init` and `terraform apply` and fail the test if there are any errors terraform.InitAndApply(t, terraformOptions) }) // Make sure we can SCP a file from an EC2 instance to our local box test_structure.RunTestStage(t, "validate_file", func() { terraformOptions := test_structure.LoadTerraformOptions(t, exampleFolder) keyPair := test_structure.LoadEc2KeyPair(t, exampleFolder) testScpFromHost(t, terraformOptions, keyPair) }) // Make sure we can SCP all files in a given remote dir from an EC2 instance to our local box test_structure.RunTestStage(t, "validate_dir", func() { terraformOptions := test_structure.LoadTerraformOptions(t, exampleFolder) keyPair := test_structure.LoadEc2KeyPair(t, exampleFolder) testScpDirFromHost(t, terraformOptions, keyPair) }) // Make sure we can SCP all files in a given remote dir from an EC2 instance to our local box test_structure.RunTestStage(t, "validate_asg", func() { terraformOptions := test_structure.LoadTerraformOptions(t, exampleFolder) keyPair := test_structure.LoadEc2KeyPair(t, exampleFolder) testScpFromAsg(t, terraformOptions, keyPair, exampleFolder) }) } func createTerraformOptions(t *testing.T, exampleFolder string) (*terraform.Options, *aws.Ec2Keypair) { // A unique ID we can use to namespace resources so we don't clash with anything already in the AWS account or // tests running in parallel uniqueID := random.UniqueId() // Give this EC2 Instance and other resources in the Terraform code a name with a unique ID so it doesn't clash // with anything else in the AWS account. instanceName := fmt.Sprintf("terratest-asg-scp-example-%s", uniqueID) // Pick a random AWS region to test in. This helps ensure your code works in all regions. awsRegion := aws.GetRandomStableRegion(t, nil, nil) // Some AWS regions are missing certain instance types, so pick an available type based on the region we picked instanceType := aws.GetRecommendedInstanceType(t, awsRegion, []string{"t2.micro, t3.micro", "t2.small", "t3.small"}) // Create an EC2 KeyPair that we can use for SSH access keyPairName := fmt.Sprintf("terratest-asg-scp-example-%s", uniqueID) keyPair := aws.CreateAndImportEC2KeyPair(t, awsRegion, keyPairName) // Construct the terraform options with default retryable errors to handle the most common retryable errors in // terraform testing. terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ // The path to where our Terraform code is located TerraformDir: exampleFolder, // Variables to pass to our Terraform code using -var options Vars: map[string]interface{}{ "aws_region": awsRegion, "instance_name": instanceName, "key_pair_name": keyPairName, "instance_type": instanceType, }, }) return terraformOptions, keyPair } func testScpDirFromHost(t *testing.T, terraformOptions *terraform.Options, keyPair *aws.Ec2Keypair) { // Run `terraform output` to get the value of an output variable awsRegion := terraformOptions.Vars["aws_region"].(string) asgName := terraform.Output(t, terraformOptions, "asg_name") instanceIds := aws.GetInstanceIdsForAsg(t, asgName, awsRegion) publicInstanceIP := aws.GetPublicIpOfEc2Instance(t, instanceIds[0], awsRegion) // We're going to try to SSH to the instance IP, using the Key Pair we created earlier, and the user "ubuntu", // as we know the Instance is running an Ubuntu AMI that has such a user sshUserName := "ubuntu" publicHost := ssh.Host{ Hostname: publicInstanceIP, SshKeyPair: keyPair.KeyPair, SshUserName: sshUserName, } _, remoteTempFilePath := writeSampleDataToInstance(t, publicInstanceIP, sshUserName, keyPair) remoteTempFolder := filepath.Dir(remoteTempFilePath) defer cleanup(t, publicInstanceIP, sshUserName, keyPair, remoteTempFolder) localDestDir := "/tmp/tempFolder" var testcases = []struct { name string options ssh.ScpDownloadOptions expectedFiles int }{ { "GrabAllFiles", ssh.ScpDownloadOptions{RemoteHost: publicHost, RemoteDir: remoteTempFolder, LocalDir: filepath.Join(localDestDir, random.UniqueId())}, 2, }, { "GrabAllFilesExplicit", ssh.ScpDownloadOptions{RemoteHost: publicHost, RemoteDir: remoteTempFolder, LocalDir: filepath.Join(localDestDir, random.UniqueId()), FileNameFilters: []string{"*"}}, 2, }, { "GrabFilesWithFilter", ssh.ScpDownloadOptions{RemoteHost: publicHost, RemoteDir: remoteTempFolder, LocalDir: filepath.Join(localDestDir, random.UniqueId()), FileNameFilters: []string{"*.baz"}}, 1, }, } for _, testCase := range testcases { // The following is necessary to make sure testCase's values don't // get updated due to concurrency within the scope of t.Run(..) below testCase := testCase t.Run(testCase.name, func(t *testing.T) { err := ssh.ScpDirFromE(t, testCase.options, false) if err != nil { t.Fatalf("Error copying from remote: %s", err.Error()) } expectedNumFiles := testCase.expectedFiles fileInfos, err := os.ReadDir(testCase.options.LocalDir) if err != nil { t.Fatalf("Error reading from local dir: %s, due to: %s", testCase.options.LocalDir, err.Error()) } actualNumFilesCopied := len(fileInfos) if len(fileInfos) != expectedNumFiles { t.Fatalf("Error: expected %d files to be copied. Only found %d", expectedNumFiles, actualNumFilesCopied) } // Clean up the temp file we created os.RemoveAll(testCase.options.LocalDir) }) } } func testScpFromHost(t *testing.T, terraformOptions *terraform.Options, keyPair *aws.Ec2Keypair) { // Run `terraform output` to get the value of an output variable awsRegion := terraformOptions.Vars["aws_region"].(string) asgName := terraform.Output(t, terraformOptions, "asg_name") instanceIds := aws.GetInstanceIdsForAsg(t, asgName, awsRegion) publicInstanceIP := aws.GetPublicIpOfEc2Instance(t, instanceIds[0], awsRegion) // We're going to try to SSH to the instance IP, using the Key Pair we created earlier, and the user "ubuntu", // as we know the Instance is running an Ubuntu AMI that has such a user sshUserName := "ubuntu" publicHost := ssh.Host{ Hostname: publicInstanceIP, SshKeyPair: keyPair.KeyPair, SshUserName: sshUserName, } randomData, remoteTempFilePath := writeSampleDataToInstance(t, publicInstanceIP, sshUserName, keyPair) remoteTempFolder := filepath.Base(remoteTempFilePath) defer cleanup(t, publicInstanceIP, sshUserName, keyPair, remoteTempFolder) localTempFileName := "/tmp/test.out" localFile, err := os.Create(localTempFileName) // Clean up the temp file we created defer os.Remove(localTempFileName) if err != nil { t.Fatalf("Error: creating local temp file: %s", err.Error()) } ssh.ScpFileFromE(t, publicHost, remoteTempFilePath, localFile, false) buf, err := os.ReadFile(localTempFileName) if err != nil { t.Fatalf("Error: Unable to read local file from disk: %s", err.Error()) } localFileContents := string(buf) if !strings.Contains(localFileContents, randomData) { t.Fatalf("Error: unable to find %s in the local file. Local file's contents were: %s", randomData, localFileContents) } } func testScpFromAsg(t *testing.T, terraformOptions *terraform.Options, keyPair *aws.Ec2Keypair, exampleFolder string) { // Run `terraform output` to get the value of an output variable awsRegion := terraformOptions.Vars["aws_region"].(string) asgName := terraform.Output(t, terraformOptions, "asg_name") instanceIds := aws.GetInstanceIdsForAsg(t, asgName, awsRegion) publicInstanceIP := aws.GetPublicIpOfEc2Instance(t, instanceIds[0], awsRegion) // This is where we'll store the logs from the remote server localDestinationDirectory := filepath.Join(exampleFolder, "logs") sshUserName := "ubuntu" randomData, remoteTempFilePath := writeSampleDataToInstance(t, publicInstanceIP, sshUserName, keyPair) remoteTempFolder, remoteTempFileName := filepath.Split(remoteTempFilePath) defer cleanup(t, publicInstanceIP, sshUserName, keyPair, remoteTempFolder) // This is where we will look for the downloaded syslog localSyslogLocation := filepath.Join(localDestinationDirectory, publicInstanceIP, "testFolder", remoteTempFileName) //Create a RemoteFileSpecification for our test ASG //We will specify that we'd like to grab /var/log/syslog //and store that locally. spec := aws.RemoteFileSpecification{ SshUser: sshUserName, UseSudo: true, KeyPair: keyPair, AsgNames: []string{asgName}, RemotePathToFileFilter: map[string][]string{ remoteTempFolder: {remoteTempFileName}, }, LocalDestinationDir: localDestinationDirectory, } // Go and SCP the test file from EC2 instance aws.FetchFilesFromAsgsE(t, awsRegion, spec) // Clean up the temp file we created defer os.RemoveAll(localDestinationDirectory) //Read the locally copied syslog buf, err := os.ReadFile(localSyslogLocation) if err != nil { t.Fatalf("Error: Unable to read local file from disk: %s", err.Error()) } localFileContents := string(buf) assert.Contains(t, localFileContents, randomData) } func writeSampleDataToInstance(t *testing.T, publicInstanceIP string, sshUserName string, keyPair *aws.Ec2Keypair) (string, string) { // We're going to try to SSH to the instance IP, using the Key Pair we created earlier, and the user "ubuntu", // as we know the Instance is running an Ubuntu AMI that has such a user publicHost := ssh.Host{ Hostname: publicInstanceIP, SshKeyPair: keyPair.KeyPair, SshUserName: sshUserName, } // It can take a minute or so for the Instance to boot up, so retry a few times maxRetries := 30 timeBetweenRetries := 5 * time.Second description := fmt.Sprintf("SSH to public host %s", publicInstanceIP) remoteTempFolder := "/tmp/testFolder" remoteTempFilePath := filepath.Join(remoteTempFolder, "test.foo") remoteTempFilePath2 := filepath.Join(remoteTempFolder, "test.baz") randomData := random.UniqueId() // Verify that we can SSH to the Instance and run commands retry.DoWithRetry(t, description, maxRetries, timeBetweenRetries, func() (string, error) { _, err := ssh.CheckSshCommandE(t, publicHost, fmt.Sprintf("mkdir -p %s && touch %s && touch %s && echo \"%s\" >> %s", remoteTempFolder, remoteTempFilePath, remoteTempFilePath2, randomData, remoteTempFilePath)) if err != nil { return "", err } return "", nil }) return randomData, remoteTempFilePath } func cleanup(t *testing.T, publicInstanceIP string, sshUserName string, keyPair *aws.Ec2Keypair, folderToClean string) { publicHost := ssh.Host{ Hostname: publicInstanceIP, SshKeyPair: keyPair.KeyPair, SshUserName: sshUserName, } maxRetries := 30 timeBetweenRetries := 5 * time.Second description := fmt.Sprintf("SSH to public host %s", publicInstanceIP) // clean up the remote folder as we want may want to run another test case defer retry.DoWithRetry(t, description, maxRetries, timeBetweenRetries, func() (string, error) { _, err := ssh.CheckSshCommandE(t, publicHost, fmt.Sprintf("rm -rf %s", folderToClean)) if err != nil { return "", err } return "", nil }) } ================================================ FILE: test/terraform_ssh_certificate_example_test.go ================================================ package test import ( "crypto/rand" "crypto/rsa" "fmt" "strings" "testing" "time" stdssh "golang.org/x/crypto/ssh" "github.com/gruntwork-io/terratest/modules/aws" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/ssh" "github.com/gruntwork-io/terratest/modules/terraform" test_structure "github.com/gruntwork-io/terratest/modules/test-structure" "github.com/stretchr/testify/require" ) // An example of how to test the Terraform module in examples/terraform-ssh-certificate-example using Terratest. The test // also shows an example of how to break a test down into "stages" so you can skip stages by setting environment // variables (e.g., skip stage "teardown" by setting the environment variable "SKIP_teardown=true"), which speeds up // iteration when running this test over and over again locally. func TestTerraformSshCertificateExample(t *testing.T) { t.Parallel() exampleFolder := test_structure.CopyTerraformFolderToTemp(t, "../", "examples/terraform-ssh-certificate-example") // At the end of the test, run `terraform destroy` to clean up any resources that were created. defer test_structure.RunTestStage(t, "teardown", func() { terraformOptions := test_structure.LoadTerraformOptions(t, exampleFolder) terraform.Destroy(t, terraformOptions) }) // Deploy the example. test_structure.RunTestStage(t, "setup", func() { terraformOptions, keyPair := configureTerraformSshCertificateOptions(t, exampleFolder) // Save the options so later test stages can use them. test_structure.SaveTerraformOptions(t, exampleFolder, terraformOptions) test_structure.SaveSshKeyPair(t, exampleFolder, keyPair) // This will run `terraform init` and `terraform apply` and fail the test if there are any errors. terraform.InitAndApply(t, terraformOptions) }) // Make sure we can SSH to the public instance directly from the public internet. test_structure.RunTestStage(t, "validate", func() { terraformOptions := test_structure.LoadTerraformOptions(t, exampleFolder) keypair := test_structure.LoadSshKeyPair(t, exampleFolder) testSSHCertificateToPublicHost(t, terraformOptions, keypair) }) } func configureTerraformSshCertificateOptions(t *testing.T, exampleFolder string) (*terraform.Options, *ssh.KeyPair) { // A unique ID we can use to namespace resources so we don't clash with anything already in the AWS account or // tests running in parallel. uniqueID := random.UniqueId() // Give this EC2 instance and other resources in the Terraform code a name with a unique ID so it doesn't clash // with anything else in the AWS account. instanceName := fmt.Sprintf("terratest-ssh-certificate-example-%s", uniqueID) // Pick a random AWS region to test in. This helps ensure your code works in all regions. awsRegion := aws.GetRandomStableRegion(t, nil, nil) // Some AWS regions are missing certain instance types, so pick an available type based on the region we picked instanceType := aws.GetRecommendedInstanceType(t, awsRegion, []string{"t2.micro, t3.micro", "t2.small", "t3.small"}) // Create a random ssh certificate k, err := rsa.GenerateKey(rand.Reader, 4096) require.NoError(t, err) caSigner, err := stdssh.NewSignerFromKey(k) require.NoError(t, err) keyPair := ssh.GenerateRSAKeyPair(t, 2048) pub, _, _, _, err := stdssh.ParseAuthorizedKey([]byte(keyPair.PublicKey)) require.NoError(t, err) cert := &stdssh.Certificate{ Key: pub, Serial: 1, CertType: stdssh.UserCert, KeyId: "terratest", // identity ValidPrincipals: []string{"terratest"}, // allowed usernames ValidAfter: uint64(time.Now().Unix()), ValidBefore: uint64(time.Now().Add(365 * 24 * time.Hour).Unix()), // 1 year Permissions: stdssh.Permissions{ Extensions: map[string]string{ "permit-pty": "", // allow PTY }, }, } require.NoError(t, cert.SignCert(rand.Reader, caSigner)) keyPair.PublicKey = string(stdssh.MarshalAuthorizedKey(cert)) // Construct the terraform options with default retryable errors to handle the most common retryable errors in // terraform testing. terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ // The path to where our Terraform code is located. TerraformDir: exampleFolder, // Variables to pass to our Terraform code using -var options. Vars: map[string]interface{}{ "aws_region": awsRegion, "instance_name": instanceName, "instance_type": instanceType, "ssh_ca_public_key": string(stdssh.MarshalAuthorizedKey(caSigner.PublicKey())), }, }) return terraformOptions, keyPair } func testSSHCertificateToPublicHost(t *testing.T, terraformOptions *terraform.Options, keyPair *ssh.KeyPair) { // Run `terraform output` to get the value of an output variable. publicInstanceIP := terraform.Output(t, terraformOptions, "public_instance_ip") // We're going to try to SSH to the instance IP, using the username and password that will be set up (by // Terraform's user_data script) in the instance. publicHost := ssh.Host{ Hostname: publicInstanceIP, SshUserName: "terratest", SshKeyPair: keyPair, } // It can take a minute or so for the instance to boot up, so retry a few times. maxRetries := 30 timeBetweenRetries := 10 * time.Second description := fmt.Sprintf("SSH to public host %s", publicInstanceIP) // Run a simple echo command on the server. expectedText := "Hello, World" command := fmt.Sprintf("echo -n '%s'", expectedText) // Verify that we can SSH to the instance and run commands. retry.DoWithRetry(t, description, maxRetries, timeBetweenRetries, func() (string, error) { actualText, err := ssh.CheckSshCommandE(t, publicHost, command) if err != nil { return "", err } if strings.TrimSpace(actualText) != expectedText { return "", fmt.Errorf("Expected SSH command to return '%s' but got '%s'", expectedText, actualText) } return "", nil }) // Run a command on the server that results in an error. expectedText = "Hello, World" command = fmt.Sprintf("echo -n '%s' && exit 1", expectedText) description = fmt.Sprintf("SSH to public host %s with error command", publicInstanceIP) // Verify that we can SSH to the instance, run the command which forces an error, and see the output. retry.DoWithRetry(t, description, maxRetries, timeBetweenRetries, func() (string, error) { actualText, err := ssh.CheckSshCommandE(t, publicHost, command) if err == nil { return "", fmt.Errorf("Expected SSH command to return an error but got none") } if strings.TrimSpace(actualText) != expectedText { return "", fmt.Errorf("Expected SSH command to return '%s' but got '%s'", expectedText, actualText) } return "", nil }) } ================================================ FILE: test/terraform_ssh_example_test.go ================================================ package test import ( "fmt" "os" "strings" "testing" "time" "github.com/gruntwork-io/terratest/modules/aws" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/ssh" "github.com/gruntwork-io/terratest/modules/terraform" test_structure "github.com/gruntwork-io/terratest/modules/test-structure" ) // An example of how to test the Terraform module in examples/terraform-ssh-example using Terratest. The test also // shows an example of how to break a test down into "stages" so you can skip stages by setting environment variables // (e.g., skip stage "teardown" by setting the environment variable "SKIP_teardown=true"), which speeds up iteration // when running this test over and over again locally. func TestTerraformSshExample(t *testing.T) { t.Parallel() exampleFolder := test_structure.CopyTerraformFolderToTemp(t, "../", "examples/terraform-ssh-example") // At the end of the test, run `terraform destroy` to clean up any resources that were created defer test_structure.RunTestStage(t, "teardown", func() { terraformOptions := test_structure.LoadTerraformOptions(t, exampleFolder) terraform.Destroy(t, terraformOptions) keyPair := test_structure.LoadEc2KeyPair(t, exampleFolder) aws.DeleteEC2KeyPair(t, keyPair) }) // Deploy the example test_structure.RunTestStage(t, "setup", func() { terraformOptions, keyPair := configureTerraformOptions(t, exampleFolder) // Save the options and key pair so later test stages can use them test_structure.SaveTerraformOptions(t, exampleFolder, terraformOptions) test_structure.SaveEc2KeyPair(t, exampleFolder, keyPair) // This will run `terraform init` and `terraform apply` and fail the test if there are any errors terraform.InitAndApply(t, terraformOptions) }) // Make sure we can SSH to the public Instance directly from the public Internet and the private Instance by using // the public Instance as a jump host test_structure.RunTestStage(t, "validate", func() { terraformOptions := test_structure.LoadTerraformOptions(t, exampleFolder) keyPair := test_structure.LoadEc2KeyPair(t, exampleFolder) testSSHToPublicHost(t, terraformOptions, keyPair) testSSHToPrivateHost(t, terraformOptions, keyPair) testSSHAgentToPublicHost(t, terraformOptions, keyPair) testSSHAgentToPrivateHost(t, terraformOptions, keyPair) testSCPToPublicHost(t, terraformOptions, keyPair) }) } func configureTerraformOptions(t *testing.T, exampleFolder string) (*terraform.Options, *aws.Ec2Keypair) { // A unique ID we can use to namespace resources so we don't clash with anything already in the AWS account or // tests running in parallel uniqueID := random.UniqueId() // Give this EC2 Instance and other resources in the Terraform code a name with a unique ID so it doesn't clash // with anything else in the AWS account. instanceName := fmt.Sprintf("terratest-ssh-example-%s", uniqueID) // Pick a random AWS region to test in. This helps ensure your code works in all regions. awsRegion := aws.GetRandomStableRegion(t, nil, nil) // Some AWS regions are missing certain instance types, so pick an available type based on the region we picked instanceType := aws.GetRecommendedInstanceType(t, awsRegion, []string{"t2.micro, t3.micro", "t2.small", "t3.small"}) // Create an EC2 KeyPair that we can use for SSH access keyPairName := fmt.Sprintf("terratest-ssh-example-%s", uniqueID) keyPair := aws.CreateAndImportEC2KeyPair(t, awsRegion, keyPairName) // Construct the terraform options with default retryable errors to handle the most common retryable errors in // terraform testing. terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ // The path to where our Terraform code is located TerraformDir: exampleFolder, // Variables to pass to our Terraform code using -var options Vars: map[string]interface{}{ "aws_region": awsRegion, "instance_name": instanceName, "instance_type": instanceType, "key_pair_name": keyPairName, }, }) return terraformOptions, keyPair } func testSSHToPublicHost(t *testing.T, terraformOptions *terraform.Options, keyPair *aws.Ec2Keypair) { // Run `terraform output` to get the value of an output variable publicInstanceIP := terraform.Output(t, terraformOptions, "public_instance_ip") // We're going to try to SSH to the instance IP, using the Key Pair we created earlier, and the user "ubuntu", // as we know the Instance is running an Ubuntu AMI that has such a user publicHost := ssh.Host{ Hostname: publicInstanceIP, SshKeyPair: keyPair.KeyPair, SshUserName: "ubuntu", } // It can take a minute or so for the Instance to boot up, so retry a few times maxRetries := 30 timeBetweenRetries := 5 * time.Second description := fmt.Sprintf("SSH to public host %s", publicInstanceIP) // Run a simple echo command on the server expectedText := "Hello, World" command := fmt.Sprintf("echo -n '%s'", expectedText) // Verify that we can SSH to the Instance and run commands retry.DoWithRetry(t, description, maxRetries, timeBetweenRetries, func() (string, error) { actualText, err := ssh.CheckSshCommandE(t, publicHost, command) if err != nil { return "", err } if strings.TrimSpace(actualText) != expectedText { return "", fmt.Errorf("Expected SSH command to return '%s' but got '%s'", expectedText, actualText) } return "", nil }) // Run a command on the server that results in an error, expectedText = "Hello, World" command = fmt.Sprintf("echo -n '%s' && exit 1", expectedText) description = fmt.Sprintf("SSH to public host %s with error command", publicInstanceIP) // Verify that we can SSH to the Instance, run the command and see the output retry.DoWithRetry(t, description, maxRetries, timeBetweenRetries, func() (string, error) { actualText, err := ssh.CheckSshCommandE(t, publicHost, command) if err == nil { return "", fmt.Errorf("Expected SSH command to return an error but got none") } if strings.TrimSpace(actualText) != expectedText { return "", fmt.Errorf("Expected SSH command to return '%s' but got '%s'", expectedText, actualText) } return "", nil }) } func testSSHToPrivateHostByHostname(t *testing.T, terraformOptions *terraform.Options, keyPair *aws.Ec2Keypair) { // Run `terraform output` to get the value of an output variable publicInstanceIP := terraform.Output(t, terraformOptions, "public_instance_ip") // Get hostname of private instance from AWS helper function instead of Terraform output privateInstanceID := terraform.Output(t, terraformOptions, "private_instance_id") deployedAWSRegion := terraformOptions.Vars["aws_region"].(string) privateInstanceHostname := aws.GetPrivateHostnameOfEc2Instance(t, privateInstanceID, deployedAWSRegion) sshToPrivateHost(t, publicInstanceIP, privateInstanceHostname, keyPair) } func testSSHToPrivateHost(t *testing.T, terraformOptions *terraform.Options, keyPair *aws.Ec2Keypair) { // Run `terraform output` to get the value of an output variable publicInstanceIP := terraform.Output(t, terraformOptions, "public_instance_ip") // Get IP of private instance from AWS helper function instead of Terraform output privateInstanceID := terraform.Output(t, terraformOptions, "private_instance_id") deployedAWSRegion := terraformOptions.Vars["aws_region"].(string) privateInstanceIP := aws.GetPrivateIpOfEc2Instance(t, privateInstanceID, deployedAWSRegion) sshToPrivateHost(t, publicInstanceIP, privateInstanceIP, keyPair) } func sshToPrivateHost(t *testing.T, publicInstanceIP string, privateInstanceIP string, keyPair *aws.Ec2Keypair) { // We're going to try to SSH to the private instance using the public instance as a jump host. For both instances, // we are using the Key Pair we created earlier, and the user "ubuntu", as we know the Instances are running an // Ubuntu AMI that has such a user publicHost := ssh.Host{ Hostname: publicInstanceIP, SshKeyPair: keyPair.KeyPair, SshUserName: "ubuntu", } privateHost := ssh.Host{ Hostname: privateInstanceIP, SshKeyPair: keyPair.KeyPair, SshUserName: "ubuntu", } // It can take a minute or so for the Instance to boot up, so retry a few times maxRetries := 30 timeBetweenRetries := 5 * time.Second description := fmt.Sprintf("SSH to private host %s via public host %s", privateInstanceIP, publicInstanceIP) // Run a simple echo command on the server expectedText := "Hello, World" command := fmt.Sprintf("echo -n '%s'", expectedText) // Verify that we can SSH to the Instance and run commands retry.DoWithRetry(t, description, maxRetries, timeBetweenRetries, func() (string, error) { actualText, err := ssh.CheckPrivateSshConnectionE(t, publicHost, privateHost, command) if err != nil { return "", err } if strings.TrimSpace(actualText) != expectedText { return "", fmt.Errorf("Expected SSH command to return '%s' but got '%s'", expectedText, actualText) } return "", nil }) } func testSCPToPublicHost(t *testing.T, terraformOptions *terraform.Options, keyPair *aws.Ec2Keypair) { // Run `terraform output` to get the value of an output variable publicInstanceIP := terraform.Output(t, terraformOptions, "public_instance_ip") // We're going to try to SSH to the instance IP, using the Key Pair we created earlier, and the user "ubuntu", // as we know the Instance is running an Ubuntu AMI that has such a user publicHost := ssh.Host{ Hostname: publicInstanceIP, SshKeyPair: keyPair.KeyPair, SshUserName: "ubuntu", } // It can take a minute or so for the Instance to boot up, so retry a few times maxRetries := 10 timeBetweenRetries := 1 * time.Second description := fmt.Sprintf("SCP file to public host %s", publicInstanceIP) // Run a simple echo command on the server expectedText := "Hello, World" // Verify that we can SSH to the Instance and run commands retry.DoWithRetry(t, description, maxRetries, timeBetweenRetries, func() (string, error) { err := ssh.ScpFileToE(t, publicHost, os.FileMode(0644), "/tmp/test.txt", expectedText) if err != nil { return "", err } actualText, err := ssh.FetchContentsOfFileE(t, publicHost, false, "/tmp/test.txt") if err != nil { return "", err } if strings.TrimSpace(actualText) != expectedText { return "", fmt.Errorf("Expected SSH command to return '%s' but got '%s'", expectedText, actualText) } return "", nil }) } func testSSHAgentToPublicHost(t *testing.T, terraformOptions *terraform.Options, keyPair *aws.Ec2Keypair) { // Run `terraform output` to get the value of an output variable publicInstanceIP := terraform.Output(t, terraformOptions, "public_instance_ip") // start the ssh agent sshAgent := ssh.SshAgentWithKeyPair(t, keyPair.KeyPair) defer sshAgent.Stop() // We're going to try to SSH to the instance IP, using the Key Pair we created earlier. Instead of // directly using the SSH key in the SSH connection, we're going to rely on an existing SSH agent that we // programatically emulate within this test. We're going to use the user "ubuntu" as we know the Instance // is running an Ubuntu AMI that has such a user publicHost := ssh.Host{ Hostname: publicInstanceIP, SshUserName: "ubuntu", OverrideSshAgent: sshAgent, } // It can take a minute or so for the Instance to boot up, so retry a few times maxRetries := 30 timeBetweenRetries := 5 * time.Second description := fmt.Sprintf("SSH with Agent to public host %s", publicInstanceIP) // Run a simple echo command on the server expectedText := "Hello, World" command := fmt.Sprintf("echo -n '%s'", expectedText) // Verify that we can SSH to the Instance and run commands retry.DoWithRetry(t, description, maxRetries, timeBetweenRetries, func() (string, error) { actualText, err := ssh.CheckSshCommandE(t, publicHost, command) if err != nil { return "", err } if strings.TrimSpace(actualText) != expectedText { return "", fmt.Errorf("Expected SSH command to return '%s' but got '%s'", expectedText, actualText) } return "", nil }) } func testSSHAgentToPrivateHost(t *testing.T, terraformOptions *terraform.Options, keyPair *aws.Ec2Keypair) { // Run `terraform output` to get the value of an output variable publicInstanceIP := terraform.Output(t, terraformOptions, "public_instance_ip") privateInstanceIP := terraform.Output(t, terraformOptions, "private_instance_ip") // start the ssh agent sshAgent := ssh.SshAgentWithKeyPair(t, keyPair.KeyPair) defer sshAgent.Stop() // We're going to try to SSH to the private instance using the public instance as a jump host. Instead of // directly using the SSH key in the SSH connection, we're going to rely on an existing SSH agent that we // programatically emulate within this test. For both instances, we are using the Key Pair we created earlier, // and the user "ubuntu", as we know the Instances are running an Ubuntu AMI that has such a user publicHost := ssh.Host{ Hostname: publicInstanceIP, SshUserName: "ubuntu", OverrideSshAgent: sshAgent, } privateHost := ssh.Host{ Hostname: privateInstanceIP, SshUserName: "ubuntu", OverrideSshAgent: sshAgent, } // It can take a minute or so for the Instance to boot up, so retry a few times maxRetries := 30 timeBetweenRetries := 5 * time.Second description := fmt.Sprintf("SSH with Agent to private host %s via public host %s", privateInstanceIP, publicInstanceIP) // Run a simple echo command on the server expectedText := "Hello, World" command := fmt.Sprintf("echo -n '%s'", expectedText) // Verify that we can SSH to the Instance and run commands retry.DoWithRetry(t, description, maxRetries, timeBetweenRetries, func() (string, error) { actualText, err := ssh.CheckPrivateSshConnectionE(t, publicHost, privateHost, command) if err != nil { return "", err } if strings.TrimSpace(actualText) != expectedText { return "", fmt.Errorf("Expected SSH command to return '%s' but got '%s'", expectedText, actualText) } return "", nil }) } ================================================ FILE: test/terraform_ssh_password_example_test.go ================================================ package test import ( "fmt" "strings" "testing" "time" "github.com/gruntwork-io/terratest/modules/aws" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/retry" "github.com/gruntwork-io/terratest/modules/ssh" "github.com/gruntwork-io/terratest/modules/terraform" test_structure "github.com/gruntwork-io/terratest/modules/test-structure" ) // An example of how to test the Terraform module in examples/terraform-ssh-password-example using Terratest. The test // also shows an example of how to break a test down into "stages" so you can skip stages by setting environment // variables (e.g., skip stage "teardown" by setting the environment variable "SKIP_teardown=true"), which speeds up // iteration when running this test over and over again locally. func TestTerraformSshPasswordExample(t *testing.T) { t.Parallel() exampleFolder := test_structure.CopyTerraformFolderToTemp(t, "../", "examples/terraform-ssh-password-example") // At the end of the test, run `terraform destroy` to clean up any resources that were created. defer test_structure.RunTestStage(t, "teardown", func() { terraformOptions := test_structure.LoadTerraformOptions(t, exampleFolder) terraform.Destroy(t, terraformOptions) }) // Deploy the example. test_structure.RunTestStage(t, "setup", func() { terraformOptions := configureTerraformSshPasswordOptions(t, exampleFolder) // Save the options so later test stages can use them. test_structure.SaveTerraformOptions(t, exampleFolder, terraformOptions) // This will run `terraform init` and `terraform apply` and fail the test if there are any errors. terraform.InitAndApply(t, terraformOptions) }) // Make sure we can SSH to the public instance directly from the public internet. test_structure.RunTestStage(t, "validate", func() { terraformOptions := test_structure.LoadTerraformOptions(t, exampleFolder) testSSHPasswordToPublicHost(t, terraformOptions) }) } func configureTerraformSshPasswordOptions(t *testing.T, exampleFolder string) *terraform.Options { // A unique ID we can use to namespace resources so we don't clash with anything already in the AWS account or // tests running in parallel. uniqueID := random.UniqueId() // Give this EC2 instance and other resources in the Terraform code a name with a unique ID so it doesn't clash // with anything else in the AWS account. instanceName := fmt.Sprintf("terratest-ssh-password-example-%s", uniqueID) // Pick a random AWS region to test in. This helps ensure your code works in all regions. awsRegion := aws.GetRandomStableRegion(t, nil, nil) // Some AWS regions are missing certain instance types, so pick an available type based on the region we picked instanceType := aws.GetRecommendedInstanceType(t, awsRegion, []string{"t2.micro, t3.micro", "t2.small", "t3.small"}) // Create a random password that we can use for SSH access. password := random.UniqueId() // Construct the terraform options with default retryable errors to handle the most common retryable errors in // terraform testing. terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ // The path to where our Terraform code is located. TerraformDir: exampleFolder, // Variables to pass to our Terraform code using -var options. Vars: map[string]interface{}{ "aws_region": awsRegion, "instance_name": instanceName, "instance_type": instanceType, "terratest_password": password, }, }) return terraformOptions } func testSSHPasswordToPublicHost(t *testing.T, terraformOptions *terraform.Options) { // Run `terraform output` to get the value of an output variable. publicInstanceIP := terraform.Output(t, terraformOptions, "public_instance_ip") // We're going to try to SSH to the instance IP, using the username and password that will be set up (by // Terraform's user_data script) in the instance. publicHost := ssh.Host{ Hostname: publicInstanceIP, Password: terraformOptions.Vars["terratest_password"].(string), SshUserName: "terratest", } // It can take a minute or so for the instance to boot up, so retry a few times. maxRetries := 30 timeBetweenRetries := 10 * time.Second description := fmt.Sprintf("SSH to public host %s", publicInstanceIP) // Run a simple echo command on the server. expectedText := "Hello, World" command := fmt.Sprintf("echo -n '%s'", expectedText) // Verify that we can SSH to the instance and run commands. retry.DoWithRetry(t, description, maxRetries, timeBetweenRetries, func() (string, error) { actualText, err := ssh.CheckSshCommandE(t, publicHost, command) if err != nil { return "", err } if strings.TrimSpace(actualText) != expectedText { return "", fmt.Errorf("Expected SSH command to return '%s' but got '%s'", expectedText, actualText) } return "", nil }) // Run a command on the server that results in an error. expectedText = "Hello, World" command = fmt.Sprintf("echo -n '%s' && exit 1", expectedText) description = fmt.Sprintf("SSH to public host %s with error command", publicInstanceIP) // Verify that we can SSH to the instance, run the command which forces an error, and see the output. retry.DoWithRetry(t, description, maxRetries, timeBetweenRetries, func() (string, error) { actualText, err := ssh.CheckSshCommandE(t, publicHost, command) if err == nil { return "", fmt.Errorf("Expected SSH command to return an error but got none") } if strings.TrimSpace(actualText) != expectedText { return "", fmt.Errorf("Expected SSH command to return '%s' but got '%s'", expectedText, actualText) } return "", nil }) } ================================================ FILE: test/terraform_unit_null_test.go ================================================ package test import ( "testing" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/magiconair/properties/assert" ) func TestUnitNullInput(t *testing.T) { t.Parallel() foo := map[string]interface{}{ "nullable_string": nil, "nonnullable_string": "foo", } options := &terraform.Options{ TerraformDir: "./fixtures/terraform-null", Vars: map[string]interface{}{"foo": foo}, } terraform.InitAndApply(t, options) fooOut := terraform.OutputMap(t, options, "foo") assert.Equal(t, fooOut, map[string]string{"nonnullable_string": "foo", "nullable_string": ""}) barOut := terraform.Output(t, options, "bar") assert.Equal(t, barOut, "I AM NULL") } ================================================ FILE: test-docker-images/README.md ================================================ # Gruntwork Terratest Docker Images As part of writing [Unit Tests with Terratest](/README.md#unit-tests), we recommend using [Packer](https://packer.io) to build a Docker image using the same script [provisioners](https://www.packer.io/docs/templates/provisioners.html) that Packer uses to configure the Amazon Machine Image you would normally build for production usage. Docker images build 10x faster than AMIs and launch 100x faster, reducing our cycle time while developing. But Packer's Docker image builds can still be slower than desired because, unlike a native `docker build` command against a `Dockerfile`, Packer does not use any [image caching](https://docs.docker.com/v17.09/engine/userguide/eng-image/dockerfile_best-practices/). As a result, each `packer build` creates the Docker image from scratch. Unfortunately, much of the Docker image build time is spent downloading libraries like `curl` and `sudo` which we assume are present on the AWS AMI associated with Ubuntu, Amazon Linux, CentOS, or any other Linux distro we're supporting. We solve this problem by creating canonical Gruntwork Terratest Docker Images which have most of the desired libraries pre-installed. We upload these images to a public Docker Hub repo such as https://hub.docker.com/r/gruntwork/ubuntu-test/ so that Packer templates that build Docker images can reference them directly as in the following example. ### Sample Packer Builder ```json { "builders": [{ "name": "ubuntu-ami", "type": "amazon-ebs" // ... (other params omitted) ... },{ "name": "ubuntu-docker", "type": "docker", "image": "gruntwork/ubuntu-test:18.04", "commit": "true" }], "provisioners": [ // ... ], "post-processors": [{ "type": "docker-tag", "repository": "gruntwork/example", "tag": "latest", "only": ["ubuntu-docker"] }] } ``` ================================================ FILE: test-docker-images/gruntwork-amazon-linux-test/Dockerfile ================================================ # TODO: Is it worth referencing a specific tag instead of latest? FROM amazonlinux:2017.12 # Reduce Docker image size per https://blog.replicated.com/refactoring-a-dockerfile-for-image-size/ # - perl-Digest-SHA: installs shasum RUN yum update -y && \ yum upgrade -y && \ yum install -y \ hostname \ jq \ perl-Digest-SHA \ rsyslog \ sudo \ tar \ vim \ wget && \ yum clean all && rm -rf /var/cache/yum # Installing pip with yum doesn't actually put it in the PATH, so we use easy_install instead. Pip will now be placed # in /usr/local/bin, but amazonlinux's sudo uses a sanitzed PATH that does not include /usr/local/bin, so we symlink pip. # The last line upgrades pip to the latest version. RUN curl https://bootstrap.pypa.io/ez_setup.py | sudo /usr/bin/python && \ easy_install pip && \ pip install --upgrade pip # Install the AWSCLI (which apparently does not come pre-bundled with Amazon Linux!) RUN pip install awscli --upgrade # Ideally, we'd install the latest version of Docker to avoid a conflict between the Docker client in this container # and the Docker API on your local host, but installing the latest version of Docker yields the error "Requires: # container-selinux >= 2.9", whch indicates that a newer Linux kernel version is required than what comes with Amazon Linux. # So we settle for the Amazon Linux supported version for now. RUN yum install -y docker ================================================ FILE: test-docker-images/gruntwork-amazon-linux-test/README.md ================================================ # Gruntwork Amazon-Linux-Test Docker Image The purpose of this Docker image is to provide a pre-built Amazon Linux Docker image that has most of the libraries we would expect to be installed on the Amazon Linux AMI that would run in AWS. For example, we'd expect `sudo` in AWS, but it doesn't exist by default in Docker `amazonlinux:latest`. ### Building and Pushing a New Docker Image to Docker Hub This Docker image should publicly accessible via Docker Hub at https://hub.docker.com/r/gruntwork/amazonlinux-test/. To build and upload it: 1. `docker build -t gruntwork/amazon-linux-test:2017.12 .` 1. `docker push gruntwork/amazon-linux-test:2017.12` ================================================ FILE: test-docker-images/gruntwork-centos-test/Dockerfile ================================================ FROM centos/systemd:latest # Reduce Docker image size per https://blog.replicated.com/refactoring-a-dockerfile-for-image-size/ # - perl-Digest-SHA: installs shasum RUN yum update -y && \ yum upgrade -y && \ yum install -y epel-release && \ yum install -y \ bind-utils \ perl-Digest-SHA \ python-pip \ rsyslog \ sudo \ vim \ wget && \ yum clean all && rm -rf /var/cache/yum # Install jq. Oddly, there's no RPM for jq, so we install the binary directly. https://serverfault.com/a/768061/199943 RUN wget -O jq https://github.com/stedolan/jq/releases/download/jq-1.5/jq-linux64 && \ chmod +x ./jq && \ cp jq /usr/bin # Install the AWS CLI per https://docs.aws.amazon.com/cli/latest/userguide/installing.html. RUN pip install --upgrade pip && \ pip install --upgrade setuptools && \ pip install awscli --upgrade # Install the latest version of Docker, Consumer Edition RUN yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo && \ yum -y install docker-ce && \ yum clean all # We run systemd as our container process. Systemd can spawn other forks as necessary to help us simulate a real-world # CentOS systemd environment. CMD ["/usr/sbin/init"] # NOTE! This Docker container should be run with the following runtime options to ensure that systemd works correctly: # Although this bind-mounted volume would appear at first glance not to work on MacOS or Windows, because those OSs are # running a VM to execute Docker and only a limited set of paths are mounted directly from the host, Docker is able to # use the Linux VM's privileges to execute systemd correctly. # # docker run -d --privileged -v /sys/fs/cgroup:/sys/fs/cgroup:ro gruntwork/centos-test ================================================ FILE: test-docker-images/gruntwork-centos-test/README.md ================================================ # Gruntwork CentOS-Test Docker Image The purpose of this Docker image is to provide a pre-built CentOS 7 Docker image that has most of the libraries we would expect to be installed on the CentOS 7 AMI that would run in AWS. For example, we'd expect `sudo` in AWS, but it doesn't exist by default in Docker `centos:7`. It also aims to allow [systemd](https://www.freedesktop.org/wiki/Software/systemd/) to run, which, in turn, allows you to run one or more services as [systemd units](https://www.freedesktop.org/software/systemd/man/systemd.unit.html). ### Building and Pushing a New Docker Image to Docker Hub This Docker image should publicly accessible via Docker Hub at https://hub.docker.com/r/gruntwork/centos-test/. To build and upload it: 1. `docker build -t gruntwork/centos-test:7 .` 1. `docker push gruntwork/centos-test:7` ### Running this Docker Image Running systemd require elevated privileges for the Docker container, so you should run this Docker image with at least the following options: ``` docker run -d --privileged -v /sys/fs/cgroup:/sys/fs/cgroup:ro gruntwork/zookeeper-centos-test:latest ``` Note that: - We do not specify a run command like `/bin/bash` because we need to retain the Docker Image's default run command of `/usr/sbin/init`. This makes systemd Process ID 1, which allows it to spawn an arbitrary number of other services - You can then connect to the Docker container with `docker exec -it /bin/bash`. - The container must be `--privileged` because it needs to break out of the typical [cgroups]( https://docs.docker.com/engine/docker-overview/#the-underlying-technology) to run an init system like systemd. - You must "hook in" to a Linux host's cgroups to allow each service to run in its own cgroup. This works even on Docker for Mac and Docker for Windows because those systems still use a Linux VM to run the Docker engine and do not expose the entire host system (e.g. your Mac laptop) for docker volume mounting. ================================================ FILE: test-docker-images/gruntwork-ubuntu-test/Dockerfile ================================================ FROM ubuntu:22.04 # Reduce Docker image size per https://blog.replicated.com/refactoring-a-dockerfile-for-image-size/ # - dnsutils: Install handy DNS checking tools like dig # - libcrypt-hcesha-perl: Install shasum # - software-properties-common: Install add-apt-repository RUN DEBIAN_FRONTEND=noninteractive \ apt-get update && \ apt-get upgrade -y && \ apt-get install --no-install-recommends -y \ gpg-agent \ apt-transport-https \ ca-certificates \ curl \ dnsutils \ jq \ libcrypt-hcesha-perl \ python3 \ python3-pip \ rsyslog \ software-properties-common \ sudo \ vim \ wget && \ rm -rf /var/lib/apt/lists/* # Install the AWS CLI per https://docs.aws.amazon.com/cli/latest/userguide/installing.html. The last line upgrades pip # to the latest version. RUN pip3 install --upgrade setuptools && \ pip3 install awscli --upgrade # Install the latest version of Docker, Consumer Edition RUN curl -fsSL https://download.docker.com/linux/ubuntu/gpg | apt-key add - && \ add-apt-repository \ "deb [arch=amd64] https://download.docker.com/linux/ubuntu \ $(lsb_release -cs) \ stable" && \ apt-get update && \ apt-get -y install docker-ce && \ rm -rf /var/lib/apt/lists/* ================================================ FILE: test-docker-images/gruntwork-ubuntu-test/README.md ================================================ # Gruntwork Ubuntu-Test Docker Image The purpose of this Docker image is to provide a pre-built Ubuntu 18.04 Docker image that has most of the libraries we would expect to be installed on the Ubuntu 18.04 AMI that would run in AWS. For example, we'd expect `curl` in AWS, but it doesn't exist by default in Docker `ubuntu:18.04`. ### Building and Pushing a New Docker Image to Docker Hub This Docker image should publicly accessible via Docker Hub at https://hub.docker.com/r/gruntwork/ubuntu-test/. To build and upload it: 1. `docker build -t gruntwork/ubuntu-test:18.04 .` 1. `docker push gruntwork/ubuntu-test:18.04` ================================================ FILE: test-docker-images/moto/Dockerfile ================================================ FROM ubuntu:16.04 # Reduce Docker image size per https://blog.replicated.com/refactoring-a-dockerfile-for-image-size/ RUN DEBIAN_FRONTEND=noninteractive apt-get update && apt-get install --no-install-recommends -y \ python-pip && \ rm -rf /var/lib/apt/lists/* && \ pip install --upgrade pip && \ pip install --upgrade setuptools && \ pip install --upgrade flask && \ pip install --upgrade pyOpenSSL && \ pip install --upgrade moto ================================================ FILE: test-docker-images/moto/README.md ================================================ # Moto This docker image runs [moto](https://github.com/spulec/moto) as a service. We will use Moto as a local service that accepts AWS API calls and returns valid API responses. Moto can be used with any AWS SDK, including the [awscli]( https://aws.amazon.com/cli/)[]. This Docker image is expected to run alongside a cluster of docker containers to represent a "local AWS". ### About Moto Moto was originally written as a way to mock out the Python [boto](https://github.com/boto/boto3) library, the official AWS SDK for Python. It was motivated by the need to write automated tests for boto. But since the AWS API cannot run "locally", Moto was written to mock the responses of the AWS API. Moto works by receiving AWS API requests across a wide variety of AWS services, including most of EC2. It will then store the requested AWS resource in memory and allow you to query that AWS resource using standard AWS API calls. There is no actual VM created, or other actual resource created. ### Motivation As part of writing [Unit Tests with Terratest](/README.md#unit-tests), we need a way to run our services in a Docker container. But this presents a new challenge: Almost all our cluster-based setups query the AWS APIs to obtain metadata about the EC2 Instance on which they're running. How can we simulate these API calls in a local environment? Moto seems to meet this use case perfectly. ## Usage ### Building and Pushing a New Docker Image to Docker Hub This Docker image should publicly accessible via Docker Hub at https://hub.docker.com/r/gruntwork/moto/. To build and upload it: 1. `docker build -t gruntwork/moto:v1 .` 1. `docker push gruntwork/moto:v1` #### Run a Docker container ``` docker run -p 5000:5000 gruntwork/moto moto_server ec2 --host 0.0.0.0 ``` This runs the `moto` service as a RESTful API, specially for the AWS EC2 API with support for acceping connections from any IP address (versus just from localhost). For additional information: - See the [moto stand-alone server usage docs](https://github.com/spulec/moto#stand-alone-server-mode) - See [which AWS services are supported](https://github.com/spulec/moto#in-a-nutshell) #### Make AWS API calls against Moto Because Moto exposes an API that is intended to be identical to the official AWS API, you can use any any AWS SDK against it, including the AWS CLI, AWS SDK for Go, `curl` calls, or any other AWS API library. The only difference is that you must explicitly set the "endpoint uRL" to point to the Moto server instead of the official AWS API. Changing this setting will be different for each AWS SDK, but for the AWSCLI, you can simplify specify the `--endpoint-url` argument as follows: ``` aws --region "us-west-2" --endpoint-url="http://localhost:5000" ec2 run-instances --image-id ami-abc12345 --tag-specifications 'ResourceType=instance,Tags=[{Key=ServerGroupName,Value=josh}]' ``` Note that Moto supports all AWS regions, and will automatically create a VPC with default subnets for you! ## Other Solutions Considered ### LocalStack [LocalStack](https://localstack.cloud/) is a (coming soon) commercial service intending to offer "local AWS as a service". It is based on the open source [localstack](https://github.com/localstack/localstack) project. Local stack seems to offer a [small set of advantages](https://github.com/localstack/localstack#why-localstack) over Moto, including throwing periodic errors to simulate a real-world cloud environment. But Localstack doesn't implement 100% of the APIs implemented by Moto (including EC2, the most important one for us!), its docker image is ~500 MB, it doesn't appear to be an active commercial entity, and Moto supports a RESTful API already. For these reasons, Moto is the better fit for our needs.