diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 626bfe3..1c60aad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v6 - uses: hashicorp/setup-terraform@v4 with: - terraform_version: "1.5.0" + terraform_version: "1.10.0" terraform_wrapper: false - run: terraform fmt -check -recursive -diff @@ -37,8 +37,29 @@ jobs: - uses: actions/checkout@v6 - uses: hashicorp/setup-terraform@v4 with: - terraform_version: "1.5.0" + terraform_version: "1.10.0" terraform_wrapper: false # -backend=false: validate needs init for provider/module schemas but no backend or cloud credentials. - run: terraform init -backend=false - run: terraform validate -no-color + + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + dir: + - modules/vpc + - modules/quilt/tests/smoke + defaults: + run: + working-directory: ${{ matrix.dir }} + steps: + - uses: actions/checkout@v6 + - uses: hashicorp/setup-terraform@v4 + with: + terraform_version: "1.10.0" + terraform_wrapper: false + # The tests mock the AWS provider, so no backend or cloud credentials are needed. + - run: terraform init -backend=false + - run: terraform test -no-color diff --git a/.gitignore b/.gitignore index ae45930..d551d84 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store .terraform +.terraform.lock.hcl tfplan diff --git a/README.md b/README.md index 74245b9..6cd7d3f 100644 --- a/README.md +++ b/README.md @@ -917,6 +917,18 @@ terraform fmt terraform validate ``` +## Test + +Module tests are plan-only and mock the AWS provider, so they need no AWS +credentials and create no infrastructure. Requires Terraform >= 1.7 (for +`mock_provider`). Run from a module or test-wrapper directory, e.g. +`modules/vpc` or `modules/quilt/tests/smoke`: + +``` +terraform init -backend=false +terraform test +``` + ## Plan ``` terraform plan -out tfplan diff --git a/modules/quilt/tests/smoke/fixtures/quilt.yaml b/modules/quilt/tests/smoke/fixtures/quilt.yaml new file mode 100644 index 0000000..9d939fa --- /dev/null +++ b/modules/quilt/tests/smoke/fixtures/quilt.yaml @@ -0,0 +1,8 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: >- + Test fixture for terraform test smoke runs. Stands in for the real Quilt + CloudFormation template so plan-time references (template_file / filemd5) + resolve. Not a deployable Quilt stack. +Resources: + Placeholder: + Type: AWS::CloudFormation::WaitConditionHandle diff --git a/modules/quilt/tests/smoke/main.tf b/modules/quilt/tests/smoke/main.tf new file mode 100644 index 0000000..3d9ad09 --- /dev/null +++ b/modules/quilt/tests/smoke/main.tf @@ -0,0 +1,96 @@ +# Wrapper root for the `quilt` module smoke tests. +# +# The smoke tests run against this wrapper rather than modules/quilt directly +# because the quilt module's `stack` output embeds sensitive values (DB + admin +# passwords); as a root module under test that trips a sensitive-output error. +# The wrapper re-exposes only the non-sensitive stack name. +# +# The required_providers block below is also load-bearing for the tests: +# declaring the provider requirement at the root is what lets the test's +# mock_provider engage for the whole module tree. Without a direct provider +# reference at the root, the mock never attaches and the child modules fall +# back to real AWS credentials. +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + # Match what the modules under test transitively require: the + # terraform-aws-modules/vpc ~> 6.0 module needs aws >= 6.28. Pinning keeps + # CI deterministic and off a future major. (Note: examples/main.tf and + # modules/cnames still pin ~> 5.0, which is incompatible with that floor.) + version = "~> 6.0" + } + } +} + +variable "create_new_vpc" { + type = bool +} + +variable "internal" { + type = bool + default = false +} + +variable "vpc_id" { + type = string + default = null +} + +variable "api_endpoint" { + type = string + default = null +} + +variable "intra_subnets" { + type = list(string) + default = null +} + +variable "private_subnets" { + type = list(string) + default = null +} + +variable "public_subnets" { + type = list(string) + default = null +} + +variable "user_security_group" { + type = string + default = null +} + +variable "user_subnets" { + type = list(string) + default = null +} + +# New inputs added to the quilt module must be threaded through here, or the +# smoke coverage silently narrows (the new input is never exercised). +module "quilt" { + source = "../../" + + name = "quilt-test" + parameters = {} + template_file = "${path.module}/fixtures/quilt.yaml" + + create_new_vpc = var.create_new_vpc + internal = var.internal + vpc_id = var.vpc_id + api_endpoint = var.api_endpoint + intra_subnets = var.intra_subnets + private_subnets = var.private_subnets + public_subnets = var.public_subnets + user_security_group = var.user_security_group + user_subnets = var.user_subnets +} + +# Re-expose ONLY the non-sensitive stack name. Do not output module.quilt.stack +# (it embeds the DB URL + admin password) or any *_password value — a sensitive +# root output makes `terraform test` fail, which is the whole reason this +# wrapper exists. +output "stack_name" { + value = module.quilt.stack.name +} diff --git a/modules/quilt/tests/smoke/smoke.tftest.hcl b/modules/quilt/tests/smoke/smoke.tftest.hcl new file mode 100644 index 0000000..01f4584 --- /dev/null +++ b/modules/quilt/tests/smoke/smoke.tftest.hcl @@ -0,0 +1,84 @@ +# Smoke tests for the public `quilt` module, run against the wrapper root in +# this directory (see main.tf). +# +# Plan-only with the AWS provider mocked: no credentials, no infrastructure. +# Exercises the full wiring (vpc + db + search + the CloudFormation stack), so +# a change that breaks the public boundary or the vpc pass-through fails in CI. +# +# Assertions reference known inputs (the stack name), not mocked computed +# attributes, whose generated values are intentionally arbitrary. + +mock_provider "aws" { + # slice(..., 0, 2) and the cidrsubnet math in the vpc submodule need at + # least two AZ names; mocked collections are otherwise empty. + mock_data "aws_availability_zones" { + defaults = { + names = ["us-east-1a", "us-east-1b", "us-east-1c"] + } + } +} + +run "new_vpc_plans" { + command = plan + variables { + create_new_vpc = true + internal = false + } + assert { + condition = output.stack_name == "quilt-test" + error_message = "The CloudFormation stack must be named after var.name" + } +} + +run "new_vpc_internal_plans" { + command = plan + variables { + create_new_vpc = true + internal = true + } + # internal = true exercises the most conditional wiring in quilt/main.tf: + # the PublicSubnets/UserSubnets null-coalescing and the internal-gated api + # endpoint. + assert { + condition = output.stack_name == "quilt-test" + error_message = "The CloudFormation stack must be named after var.name" + } +} + +run "existing_vpc_plans" { + command = plan + variables { + create_new_vpc = false + internal = false + vpc_id = "vpc-00000000000000000" + intra_subnets = ["subnet-intra-a", "subnet-intra-b"] + private_subnets = ["subnet-priv-a", "subnet-priv-b"] + public_subnets = ["subnet-pub-a", "subnet-pub-b"] + user_security_group = "sg-00000000000000000" + } + assert { + condition = output.stack_name == "quilt-test" + error_message = "The CloudFormation stack must be named after var.name" + } +} + +run "existing_vpc_internal_plans" { + command = plan + variables { + create_new_vpc = false + internal = true + vpc_id = "vpc-00000000000000000" + api_endpoint = "vpce-00000000000000000" + intra_subnets = ["subnet-intra-a", "subnet-intra-b"] + private_subnets = ["subnet-priv-a", "subnet-priv-b"] + public_subnets = null + user_security_group = "sg-00000000000000000" + user_subnets = ["subnet-user-a", "subnet-user-b"] + } + # internal = true on the existing-VPC path exercises quilt's pass-through of + # api_endpoint + user_subnets and the internal-gated CFN wiring. + assert { + condition = output.stack_name == "quilt-test" + error_message = "The CloudFormation stack must be named after var.name" + } +} diff --git a/modules/vpc/tests/validation.tftest.hcl b/modules/vpc/tests/validation.tftest.hcl new file mode 100644 index 0000000..064bf0c --- /dev/null +++ b/modules/vpc/tests/validation.tftest.hcl @@ -0,0 +1,184 @@ +# Characterization tests for the new-vs-existing VPC input validation. +# +# These run `terraform plan` with the AWS provider mocked, so they need no AWS +# credentials and create no infrastructure. They pin the behavior of the +# `configuration_error` precondition (see outputs.tf): valid input combinations +# must plan cleanly, and contradictory combinations must fail fast. +# +# On a valid config the precondition passes and the `configuration_error` +# output renders the requirement checklist with every line marked ✅; an +# unmet requirement shows ❌ and a contradictory config trips the precondition +# during plan. + +mock_provider "aws" { + # slice(..., 0, 2) and the cidrsubnet math need at least two AZ names. + mock_data "aws_availability_zones" { + defaults = { + names = ["us-east-1a", "us-east-1b", "us-east-1c"] + } + } +} + +variables { + name = "quilt-test" + cidr = "10.0.0.0/16" +} + +# --- Valid: create a new VPC ------------------------------------------------- + +run "new_vpc_external_alb" { + command = plan + + variables { + create_new_vpc = true + internal = false + existing_vpc_id = null + existing_api_endpoint = null + existing_intra_subnets = null + existing_private_subnets = null + existing_public_subnets = null + existing_user_security_group = null + existing_user_subnets = null + } + + assert { + condition = strcontains(output.configuration_error, "create a new VPC") + error_message = "New-VPC config must be checked against the new-network requirements" + } + + assert { + condition = !strcontains(output.configuration_error, "❌") + error_message = "A valid new-VPC config (internal = false) must satisfy every requirement" + } +} + +run "new_vpc_internal_alb" { + command = plan + + variables { + create_new_vpc = true + internal = true + existing_vpc_id = null + existing_api_endpoint = null + existing_intra_subnets = null + existing_private_subnets = null + existing_public_subnets = null + existing_user_security_group = null + existing_user_subnets = null + } + + assert { + condition = strcontains(output.configuration_error, "create a new VPC") + error_message = "New-VPC config must be checked against the new-network requirements" + } + + assert { + condition = !strcontains(output.configuration_error, "❌") + error_message = "A valid new-VPC config (internal = true) must satisfy every requirement" + } +} + +# --- Valid: use an existing VPC ---------------------------------------------- + +run "existing_vpc_external_alb" { + command = plan + + variables { + create_new_vpc = false + internal = false + existing_vpc_id = "vpc-00000000000000000" + existing_api_endpoint = null + existing_intra_subnets = ["subnet-intra-a", "subnet-intra-b"] + existing_private_subnets = ["subnet-priv-a", "subnet-priv-b"] + existing_public_subnets = ["subnet-pub-a", "subnet-pub-b"] + existing_user_security_group = "sg-00000000000000000" + existing_user_subnets = null + } + + assert { + condition = strcontains(output.configuration_error, "use an existing VPC") + error_message = "Existing-VPC config must be checked against the existing-network requirements" + } + + assert { + condition = !strcontains(output.configuration_error, "❌") + error_message = "A valid existing-VPC config (internal = false) must satisfy every requirement" + } + + assert { + condition = output.vpc_id == "vpc-00000000000000000" + error_message = "Existing-VPC mode must surface the supplied existing_vpc_id" + } +} + +run "existing_vpc_internal_alb" { + command = plan + + variables { + create_new_vpc = false + internal = true + existing_vpc_id = "vpc-00000000000000000" + existing_api_endpoint = "vpce-00000000000000000" + existing_intra_subnets = ["subnet-intra-a", "subnet-intra-b"] + existing_private_subnets = ["subnet-priv-a", "subnet-priv-b"] + existing_public_subnets = null + existing_user_security_group = "sg-00000000000000000" + existing_user_subnets = ["subnet-user-a", "subnet-user-b"] + } + + assert { + condition = strcontains(output.configuration_error, "use an existing VPC") + error_message = "Existing-VPC config must be checked against the existing-network requirements" + } + + assert { + condition = !strcontains(output.configuration_error, "❌") + error_message = "A valid existing-VPC config (internal = true) must satisfy every requirement" + } + + assert { + condition = output.vpc_id == "vpc-00000000000000000" + error_message = "Existing-VPC mode must surface the supplied existing_vpc_id" + } +} + +# --- Invalid: contradictory input must fail fast ----------------------------- + +run "new_vpc_with_existing_id_is_rejected" { + command = plan + + variables { + create_new_vpc = true + internal = false + existing_vpc_id = "vpc-00000000000000000" + existing_api_endpoint = null + existing_intra_subnets = null + existing_private_subnets = null + existing_public_subnets = null + existing_user_security_group = null + existing_user_subnets = null + } + + # create_new_vpc = true while supplying an existing VPC id satisfies neither + # the new-network nor the existing-network requirement set. + expect_failures = [output.configuration_error] +} + +run "existing_vpc_missing_inputs_is_rejected" { + command = plan + + variables { + create_new_vpc = false + internal = false + existing_vpc_id = "vpc-00000000000000000" + existing_api_endpoint = null + existing_intra_subnets = null + existing_private_subnets = null + existing_public_subnets = null + existing_user_security_group = null + existing_user_subnets = null + } + + # create_new_vpc = false without the required existing_* inputs is incomplete. + expect_failures = [output.configuration_error] +}