diff --git a/.github/workflows/ministack.yml b/.github/workflows/ministack.yml new file mode 100644 index 0000000000..3f5df29de3 --- /dev/null +++ b/.github/workflows/ministack.yml @@ -0,0 +1,129 @@ +name: "MiniStack Terraform lifecycle" + +on: + push: + branches: + - main + paths: + - ".github/workflows/ministack.yml" + - "tests/ministack/**" + - "*.tf" + - "policies/**" + - "examples/**" + - "modules/**" + pull_request: + paths: + - ".github/workflows/ministack.yml" + - "tests/ministack/**" + - "*.tf" + - "policies/**" + - "examples/**" + - "modules/**" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + AWS_ACCESS_KEY_ID: "000000000000" + AWS_DEFAULT_REGION: eu-west-1 + AWS_EC2_METADATA_DISABLED: "true" + AWS_ENDPOINT_URL: http://ministack:4566 + AWS_REGION: eu-west-1 + AWS_SECRET_ACCESS_KEY: test-only + TF_IN_AUTOMATION: "true" + TF_INPUT: "false" + +jobs: + terraform_lifecycle: + name: Apply/destroy ${{ matrix.example }} against MiniStack + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + max-parallel: 4 + matrix: + example: + - base + - default + - ephemeral + - external-managed-ssm-secrets + - multi-runner + - permissions-boundary + - prebuilt + - termination-watcher + container: + image: hashicorp/terraform@sha256:1d10ec4073f4ddbdf34a28540a3b9250852ab500cb1c53f68c8bd17d82f474d8 # 1.14 + services: + ministack: + image: ghcr.io/ministackorg/ministack:1.5.0@sha256:ba48c20747780605a4287a950e7bb1758ddc3b55ec92409a0c47677cbe26bbb9 + env: + MINISTACK_ACCOUNT_ID: "000000000000" + MINISTACK_REGION: eu-west-1 + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Mark repository as safe + shell: sh + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - id: prepare + name: Prepare isolated fixture + env: + EXAMPLE: ${{ matrix.example }} + run: tests/ministack/run-example.sh prepare "$EXAMPLE" + + - id: fixture + name: Apply shared fixture + env: + EXAMPLE: ${{ matrix.example }} + run: tests/ministack/run-example.sh fixture-apply "$EXAMPLE" + + - id: setup + name: Apply permissions-boundary prerequisite + if: matrix.example == 'permissions-boundary' + env: + EXAMPLE: ${{ matrix.example }} + run: tests/ministack/run-example.sh setup-apply "$EXAMPLE" + + - id: init + name: Terraform init + env: + EXAMPLE: ${{ matrix.example }} + run: tests/ministack/run-example.sh init "$EXAMPLE" + + - id: apply + name: Terraform apply + env: + EXAMPLE: ${{ matrix.example }} + run: tests/ministack/run-example.sh apply "$EXAMPLE" + + - name: Terraform destroy + if: always() && steps.init.outcome == 'success' + env: + EXAMPLE: ${{ matrix.example }} + run: tests/ministack/run-example.sh destroy "$EXAMPLE" + + - name: Destroy permissions-boundary prerequisite + if: always() && matrix.example == 'permissions-boundary' && steps.setup.outcome != 'skipped' + env: + EXAMPLE: ${{ matrix.example }} + run: tests/ministack/run-example.sh setup-destroy "$EXAMPLE" + + - name: Destroy shared fixture + if: always() && steps.prepare.outcome == 'success' && steps.fixture.outcome != 'skipped' + env: + EXAMPLE: ${{ matrix.example }} + run: tests/ministack/run-example.sh fixture-destroy "$EXAMPLE" diff --git a/.gitignore b/.gitignore index 276fe10733..9bb0ac7d7e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ # lock files **/.terraform.lock.hcl !examples/**/.terraform.lock.hcl +!tests/ministack/setup/.terraform.lock.hcl # keys diff --git a/tests/ministack/README.md b/tests/ministack/README.md new file mode 100644 index 0000000000..1b9042a575 --- /dev/null +++ b/tests/ministack/README.md @@ -0,0 +1,30 @@ +# MiniStack example lifecycle tests + +The MiniStack workflow runs explicit `terraform apply` and `terraform destroy` +commands against the repository's runnable AWS examples. Every matrix entry gets +an isolated source tree, Terraform state, and MiniStack service. + +The fixture creates synthetic SSM values through MiniStack and an inert Lambda +archive under its ignored `.terraform/` directory. Test-only Terraform override +files route each example to that archive and a MiniStack AMI. They also disable +the `webhook-github-app` local-exec module, which would otherwise update a real +GitHub App. The production example configurations are not changed. + +| Example | Lifecycle coverage | +| --- | --- | +| `base` | VPC and Resource Groups | +| `default` | Full default runner stack | +| `ephemeral` | Ephemeral runner and job-retry stack | +| `external-managed-ssm-secrets` | Runner stack with fixture-owned external SSM parameters | +| `multi-runner` | All runner lanes: public/private SSM wiring for Linux x64/ARM64 and module-managed AMI parameters for three lanes | +| `permissions-boundary` | IAM setup, assumed-role runner stack, and reverse-order teardown | +| `prebuilt` | Prebuilt-runner stack using a MiniStack AMI | +| `termination-watcher` | Standalone termination watcher | + +Two examples are intentionally outside the matrix: + +- `dedicated-mac-hosts` requires the EC2 Dedicated Hosts and License Manager + APIs, which MiniStack v1.5.0 does not implement. +- `lambdas-download` has no AWS resources. Its apply operation downloads release + archives from GitHub, so it is not a MiniStack lifecycle test. The underlying + `download-lambda` module remains in the existing module validation matrix. diff --git a/tests/ministack/overrides/README.md b/tests/ministack/overrides/README.md new file mode 100644 index 0000000000..a51e9f4e43 --- /dev/null +++ b/tests/ministack/overrides/README.md @@ -0,0 +1,40 @@ + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.10.0 | +| [aws](#requirement\_aws) | = 6.35.1 | + +## Providers + +No providers. + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [runners](#module\_runners) | | n/a | +| [spot\_termination\_watchter](#module\_spot\_termination\_watchter) | | n/a | +| [webhook\_github\_app](#module\_webhook\_github\_app) | | n/a | + +## Resources + +No resources. + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [ami\_name\_filter](#input\_ami\_name\_filter) | n/a | `string` | `"amzn2-ami-hvm-2.0.20231116.0-x86_64-gp2"` | no | +| [aws\_region](#input\_aws\_region) | n/a | `string` | `"eu-west-1"` | no | +| [environment](#input\_environment) | n/a | `string` | `"ministack-prebuilt"` | no | +| [github\_app](#input\_github\_app) | n/a | `map` |
{
"id": "0",
"key_base64": "ministack-invalid-key"
} | no |
+| [github\_app\_ssm\_parameters](#input\_github\_app\_ssm\_parameters) | n/a | `map` | {
"id": {
"arn": "arn:aws:ssm:eu-west-1:000000000000:parameter/ministack/terraform-aws-github-runner/github-app/id",
"name": "/ministack/terraform-aws-github-runner/github-app/id"
},
"key_base64": {
"arn": "arn:aws:ssm:eu-west-1:000000000000:parameter/ministack/terraform-aws-github-runner/github-app/key-base64",
"name": "/ministack/terraform-aws-github-runner/github-app/key-base64"
},
"webhook_secret": {
"arn": "arn:aws:ssm:eu-west-1:000000000000:parameter/ministack/terraform-aws-github-runner/github-app/webhook-secret",
"name": "/ministack/terraform-aws-github-runner/github-app/webhook-secret"
}
} | no |
+| [ministack\_lambda\_archive](#input\_ministack\_lambda\_archive) | Absolute path to the inert Lambda archive created by the MiniStack fixture. | `string` | n/a | yes |
+| [prefix](#input\_prefix) | n/a | `string` | `"ministack-base"` | no |
+
+## Outputs
+
+No outputs.
+
\ No newline at end of file
diff --git a/tests/ministack/overrides/base_override.tf b/tests/ministack/overrides/base_override.tf
new file mode 100644
index 0000000000..7450840076
--- /dev/null
+++ b/tests/ministack/overrides/base_override.tf
@@ -0,0 +1,7 @@
+variable "prefix" {
+ default = "ministack-base"
+}
+
+variable "aws_region" {
+ default = "eu-west-1"
+}
diff --git a/tests/ministack/overrides/common.tf b/tests/ministack/overrides/common.tf
new file mode 100644
index 0000000000..8cec884357
--- /dev/null
+++ b/tests/ministack/overrides/common.tf
@@ -0,0 +1,19 @@
+variable "ministack_lambda_archive" {
+ description = "Absolute path to the inert Lambda archive created by the MiniStack fixture."
+ type = string
+
+ validation {
+ condition = endswith(var.ministack_lambda_archive, "/tests/ministack/setup/.terraform/ministack/lambda.zip")
+ error_message = "The MiniStack Lambda archive must come from the isolated test fixture."
+ }
+}
+
+locals {
+ ministack_ami = {
+ filter = {
+ name = ["amzn2-ami-hvm-2.0.20231116.0-x86_64-gp2"]
+ state = ["available"]
+ }
+ owners = ["self"]
+ }
+}
diff --git a/tests/ministack/overrides/default_override.tf b/tests/ministack/overrides/default_override.tf
new file mode 100644
index 0000000000..654aa37161
--- /dev/null
+++ b/tests/ministack/overrides/default_override.tf
@@ -0,0 +1,29 @@
+variable "environment" {
+ default = "ministack-default"
+}
+
+variable "github_app" {
+ default = {
+ id = "0"
+ key_base64 = "ministack-invalid-key"
+ }
+}
+
+module "runners" {
+ ami = local.ministack_ami
+ ami_housekeeper_lambda_zip = var.ministack_lambda_archive
+ runner_binaries_syncer_lambda_zip = var.ministack_lambda_archive
+ runners_lambda_zip = var.ministack_lambda_archive
+ webhook_lambda_zip = var.ministack_lambda_archive
+
+ instance_termination_watcher = {
+ enable = true
+ zip = var.ministack_lambda_archive
+ }
+}
+
+# The production example updates GitHub through a local-exec provisioner.
+# A zero count structurally prevents any external GitHub API call in this test.
+module "webhook_github_app" {
+ count = 0
+}
diff --git a/tests/ministack/overrides/ephemeral_override.tf b/tests/ministack/overrides/ephemeral_override.tf
new file mode 100644
index 0000000000..ab85df74b1
--- /dev/null
+++ b/tests/ministack/overrides/ephemeral_override.tf
@@ -0,0 +1,23 @@
+variable "environment" {
+ default = "ministack-ephemeral"
+}
+
+variable "github_app" {
+ default = {
+ id = "0"
+ key_base64 = "ministack-invalid-key"
+ }
+}
+
+module "runners" {
+ ami = local.ministack_ami
+ runner_binaries_syncer_lambda_zip = var.ministack_lambda_archive
+ runners_lambda_zip = var.ministack_lambda_archive
+ webhook_lambda_zip = var.ministack_lambda_archive
+}
+
+# The production example updates GitHub through a local-exec provisioner.
+# A zero count structurally prevents any external GitHub API call in this test.
+module "webhook_github_app" {
+ count = 0
+}
diff --git a/tests/ministack/overrides/external-managed-ssm-secrets_override.tf b/tests/ministack/overrides/external-managed-ssm-secrets_override.tf
new file mode 100644
index 0000000000..701b8b61b8
--- /dev/null
+++ b/tests/ministack/overrides/external-managed-ssm-secrets_override.tf
@@ -0,0 +1,27 @@
+variable "environment" {
+ default = "ministack-external-ssm"
+}
+
+variable "github_app_ssm_parameters" {
+ default = {
+ id = {
+ arn = "arn:aws:ssm:eu-west-1:000000000000:parameter/ministack/terraform-aws-github-runner/github-app/id"
+ name = "/ministack/terraform-aws-github-runner/github-app/id"
+ }
+ key_base64 = {
+ arn = "arn:aws:ssm:eu-west-1:000000000000:parameter/ministack/terraform-aws-github-runner/github-app/key-base64"
+ name = "/ministack/terraform-aws-github-runner/github-app/key-base64"
+ }
+ webhook_secret = {
+ arn = "arn:aws:ssm:eu-west-1:000000000000:parameter/ministack/terraform-aws-github-runner/github-app/webhook-secret"
+ name = "/ministack/terraform-aws-github-runner/github-app/webhook-secret"
+ }
+ }
+}
+
+module "runners" {
+ ami = local.ministack_ami
+ runner_binaries_syncer_lambda_zip = var.ministack_lambda_archive
+ runners_lambda_zip = var.ministack_lambda_archive
+ webhook_lambda_zip = var.ministack_lambda_archive
+}
diff --git a/tests/ministack/overrides/multi-runner_override.tf b/tests/ministack/overrides/multi-runner_override.tf
new file mode 100644
index 0000000000..0516d6f33d
--- /dev/null
+++ b/tests/ministack/overrides/multi-runner_override.tf
@@ -0,0 +1,39 @@
+variable "environment" {
+ default = "ministack"
+}
+
+variable "github_app" {
+ default = {
+ id = "0"
+ key_base64 = "ministack-invalid-key"
+ }
+}
+
+locals {
+ # The production example consumes this override after it is copied into the
+ # example root; it is intentionally unused in this standalone fixture folder.
+ # tflint-ignore: terraform_unused_declarations
+ multi_runner_config = {
+ for name, config in local.multi_runner_config_files : name => merge(config, {
+ runner_config = merge(config.runner_config, {
+ ami = contains(keys(config.runner_config), "ami") ? merge(local.ministack_ami, {
+ id_ssm_parameter_arn = lookup(local.ssm_ami_arns, name, null)
+ }) : null
+ subnet_ids = lookup(config.runner_config, "subnet_ids", null) != null ? [module.base.vpc.private_subnets[0]] : null
+ vpc_id = lookup(config.runner_config, "vpc_id", null) != null ? module.base.vpc.vpc_id : null
+ })
+ })
+ }
+}
+
+module "runners" {
+ runner_binaries_syncer_lambda_zip = var.ministack_lambda_archive
+ runners_lambda_zip = var.ministack_lambda_archive
+ webhook_lambda_zip = var.ministack_lambda_archive
+}
+
+# The production example updates GitHub through a local-exec provisioner.
+# A zero count structurally prevents any external GitHub API call in this test.
+module "webhook_github_app" {
+ count = 0
+}
diff --git a/tests/ministack/overrides/permissions-boundary-provider_override.tf b/tests/ministack/overrides/permissions-boundary-provider_override.tf
new file mode 100644
index 0000000000..1c1a2ea07d
--- /dev/null
+++ b/tests/ministack/overrides/permissions-boundary-provider_override.tf
@@ -0,0 +1,14 @@
+provider "aws" {
+ alias = "terraform_role"
+ region = "eu-west-1"
+
+ # These values are MiniStack test identifiers and cannot authenticate to AWS.
+ access_key = "000000000000"
+ secret_key = "ministack-test-only"
+
+ skip_credentials_validation = true
+ skip_metadata_api_check = true
+ skip_region_validation = true
+ skip_requesting_account_id = true
+ s3_use_path_style = true
+}
diff --git a/tests/ministack/overrides/permissions-boundary_override.tf b/tests/ministack/overrides/permissions-boundary_override.tf
new file mode 100644
index 0000000000..b45b581f6b
--- /dev/null
+++ b/tests/ministack/overrides/permissions-boundary_override.tf
@@ -0,0 +1,13 @@
+variable "github_app" {
+ default = {
+ id = "0"
+ key_base64 = "ministack-invalid-key"
+ }
+}
+
+module "runners" {
+ ami = local.ministack_ami
+ runner_binaries_syncer_lambda_zip = var.ministack_lambda_archive
+ runners_lambda_zip = var.ministack_lambda_archive
+ webhook_lambda_zip = var.ministack_lambda_archive
+}
diff --git a/tests/ministack/overrides/prebuilt_override.tf b/tests/ministack/overrides/prebuilt_override.tf
new file mode 100644
index 0000000000..32c35b7f4d
--- /dev/null
+++ b/tests/ministack/overrides/prebuilt_override.tf
@@ -0,0 +1,26 @@
+variable "environment" {
+ default = "ministack-prebuilt"
+}
+
+variable "github_app" {
+ default = {
+ id = "0"
+ key_base64 = "ministack-invalid-key"
+ }
+}
+
+variable "ami_name_filter" {
+ default = "amzn2-ami-hvm-2.0.20231116.0-x86_64-gp2"
+}
+
+module "runners" {
+ ami_housekeeper_lambda_zip = var.ministack_lambda_archive
+ runners_lambda_zip = var.ministack_lambda_archive
+ webhook_lambda_zip = var.ministack_lambda_archive
+}
+
+# The production example updates GitHub through a local-exec provisioner.
+# A zero count structurally prevents any external GitHub API call in this test.
+module "webhook_github_app" {
+ count = 0
+}
diff --git a/tests/ministack/overrides/provider.tf b/tests/ministack/overrides/provider.tf
new file mode 100644
index 0000000000..0a33ab2dbd
--- /dev/null
+++ b/tests/ministack/overrides/provider.tf
@@ -0,0 +1,16 @@
+# The permissions-boundary override merges an alias into this provider when
+# TFLint evaluates this standalone fixture directory.
+# tflint-ignore: terraform_unused_declarations
+provider "aws" {
+ region = "eu-west-1"
+
+ # These values are MiniStack test identifiers and cannot authenticate to AWS.
+ access_key = "000000000000"
+ secret_key = "ministack-test-only"
+
+ skip_credentials_validation = true
+ skip_metadata_api_check = true
+ skip_region_validation = true
+ skip_requesting_account_id = true
+ s3_use_path_style = true
+}
diff --git a/tests/ministack/overrides/termination-watcher_override.tf b/tests/ministack/overrides/termination-watcher_override.tf
new file mode 100644
index 0000000000..b4f4a6833c
--- /dev/null
+++ b/tests/ministack/overrides/termination-watcher_override.tf
@@ -0,0 +1,15 @@
+module "spot_termination_watchter" {
+ config = {
+ metrics = {
+ enable = true
+ metric = {
+ enable_spot_termination_warning = true
+ }
+ }
+ prefix = "global"
+ tag_filters = {
+ "ghr:Application" = "github-action-runner"
+ }
+ zip = var.ministack_lambda_archive
+ }
+}
diff --git a/tests/ministack/overrides/versions.tf b/tests/ministack/overrides/versions.tf
new file mode 100644
index 0000000000..db7f916fb6
--- /dev/null
+++ b/tests/ministack/overrides/versions.tf
@@ -0,0 +1,10 @@
+terraform {
+ required_version = ">= 1.10.0"
+
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = "= 6.35.1"
+ }
+ }
+}
diff --git a/tests/ministack/run-example.sh b/tests/ministack/run-example.sh
new file mode 100755
index 0000000000..21c66a0f15
--- /dev/null
+++ b/tests/ministack/run-example.sh
@@ -0,0 +1,106 @@
+#!/bin/sh
+
+set -eu
+
+action="${1:-}"
+example="${2:-}"
+
+case "$example" in
+ base | default | ephemeral | external-managed-ssm-secrets | multi-runner | permissions-boundary | prebuilt | termination-watcher) ;;
+ *)
+ echo "Unsupported MiniStack example: $example" >&2
+ exit 64
+ ;;
+esac
+
+case "$action" in
+ prepare | fixture-apply | setup-apply | init | apply | destroy | setup-destroy | fixture-destroy) ;;
+ *)
+ echo "Usage: $0 {prepare|fixture-apply|setup-apply|init|apply|destroy|setup-destroy|fixture-destroy} EXAMPLE" >&2
+ exit 64
+ ;;
+esac
+
+script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
+source_root="${GITHUB_WORKSPACE:-$(CDPATH='' cd -- "$script_dir/../.." && pwd)}"
+temporary_root="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
+worktree="$temporary_root/terraform-aws-github-runner-ministack-$example"
+example_root="$worktree/examples/$example"
+fixture_root="$worktree/tests/ministack/setup"
+lambda_archive="$fixture_root/.terraform/ministack/lambda.zip"
+
+terraform_example() {
+ terraform -chdir="$example_root" "$@" -var="ministack_lambda_archive=$lambda_archive"
+}
+
+case "$action" in
+ prepare)
+ if [ -e "$worktree" ]; then
+ echo "Refusing to overwrite existing MiniStack worktree: $worktree" >&2
+ exit 73
+ fi
+
+ mkdir -p "$worktree"
+ git -C "$source_root" archive --format=tar "${MINISTACK_SOURCE_REF:-${GITHUB_SHA:-HEAD}}" | tar -xf - -C "$worktree"
+
+ if [ ! -f "$example_root/main.tf" ]; then
+ echo "The isolated worktree does not contain examples/$example/main.tf" >&2
+ exit 66
+ fi
+
+ cp "$worktree/tests/ministack/overrides/common.tf" "$example_root/ministack_common.tf"
+ cp "$worktree/tests/ministack/overrides/${example}_override.tf" "$example_root/ministack_example_override.tf"
+ cp "$worktree/tests/ministack/overrides/versions.tf" "$example_root/ministack_versions_override.tf"
+
+ case "$example" in
+ base | termination-watcher)
+ cp "$worktree/tests/ministack/overrides/provider.tf" "$example_root/ministack_provider.tf"
+ ;;
+ permissions-boundary)
+ cp "$worktree/tests/ministack/overrides/provider.tf" "$example_root/ministack_provider.tf"
+ cp "$worktree/tests/ministack/overrides/permissions-boundary-provider_override.tf" "$example_root/ministack_permissions_provider_override.tf"
+ ;;
+ *)
+ cp "$worktree/tests/ministack/overrides/provider.tf" "$example_root/ministack_provider_override.tf"
+ ;;
+ esac
+
+ ;;
+ fixture-apply)
+ terraform -chdir="$fixture_root" init -backend=false -input=false -lockfile=readonly
+ terraform -chdir="$fixture_root" apply -auto-approve -input=false
+ ;;
+ setup-apply)
+ if [ "$example" != "permissions-boundary" ]; then
+ exit 0
+ fi
+
+ # The isolated archive may update its lockfile for the Linux test platform.
+ terraform -chdir="$example_root/setup" init -backend=false -input=false
+ terraform -chdir="$example_root/setup" apply -auto-approve -input=false
+ ;;
+ init)
+ # The isolated archive may update its lockfile for the Linux test platform
+ # and the MiniStack-specific provider constraint.
+ terraform -chdir="$example_root" init -backend=false -input=false
+ ;;
+ apply)
+ terraform_example apply -auto-approve -input=false
+ ;;
+ destroy)
+ terraform -chdir="$example_root" init -backend=false -input=false
+ terraform_example destroy -auto-approve -input=false
+ ;;
+ setup-destroy)
+ if [ "$example" != "permissions-boundary" ]; then
+ exit 0
+ fi
+
+ terraform -chdir="$example_root/setup" init -backend=false -input=false
+ terraform -chdir="$example_root/setup" destroy -auto-approve -input=false
+ ;;
+ fixture-destroy)
+ terraform -chdir="$fixture_root" init -backend=false -input=false -lockfile=readonly
+ terraform -chdir="$fixture_root" destroy -auto-approve -input=false
+ ;;
+esac
diff --git a/tests/ministack/setup/.terraform.lock.hcl b/tests/ministack/setup/.terraform.lock.hcl
new file mode 100644
index 0000000000..8917cfa897
--- /dev/null
+++ b/tests/ministack/setup/.terraform.lock.hcl
@@ -0,0 +1,48 @@
+# This file is maintained automatically by "terraform init".
+# Manual edits may be lost in future updates.
+
+provider "registry.terraform.io/hashicorp/archive" {
+ version = "2.8.0"
+ constraints = "~> 2.7"
+ hashes = [
+ "h1:WB6H5ksIZiyq1lQlD/PWeh+tn4FLsbSjVnRW3+4xe2Y=",
+ "h1:jdmKm+xl6ZcQrijxapnZ94RVuz/G4vk7hsIa1N0VT5Q=",
+ "zh:0d14713fdc259fb377d0b899ad3c650a34194bd52194c863303ef22a65a580e2",
+ "zh:369b56040c7a8085d04e7e8ffac1e2b321a3170e502f788819bc34b868ec016f",
+ "zh:4d1a3b983ed6af5a52bfe12794674ae55cbadfa6021b37106ade68b433ad216a",
+ "zh:5c547549e26e083573c78a966ca68ce6d7df6bb8f3948f66a575f07da46b74ea",
+ "zh:6de093e62a975eb19a5e3017ce38e6e3cb639c17b79648d2000e0a8348f0e997",
+ "zh:7267936c2cdbc448efeb594d73e6b56a53d6a7ae14fe88cdd2a4133adc3302f0",
+ "zh:7482f023050ed426b4b45116e1761643bc33b1fd4ce4a6fab207ae2571f35940",
+ "zh:76bbd93b234e5a2927d98b511d86565700f549b570871a194c35f944b96cefb7",
+ "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
+ "zh:c6afc4bc1f002bac9c173007dd4da05fde788cd14c2916089f958c33fedb0dfa",
+ "zh:d3ba40bd806a3a08e9237dece679193c99afb2085de6b45d7f5d1f673cfcd368",
+ "zh:e1ad7ded53ecd6f0e5b473a3b44eae2b2e885653a56050ab583d387332be02e4",
+ "zh:e93e78575ce82be6084cc153c24ba8f385dc8d6880888ee66e918460c870953d",
+ ]
+}
+
+provider "registry.terraform.io/hashicorp/aws" {
+ version = "6.35.1"
+ constraints = "6.35.1"
+ hashes = [
+ "h1:0/uXxSpL98lpRqjRhjAvvWZVnJZnbOehfAlTrcPXURI=",
+ "h1:xD+5zPhF0ry3sutriARfFVIg5m38VwYt66RveI3aUyI=",
+ "zh:0a16d1b0ba9379e5c5295e6b3caa42f0b8ba6b9f0a7cc9dbe58c232cf995db2d",
+ "zh:4b2e69907a1a2c557e45ef590f9fd6187ab5bf90378346ba9f723535e49ce908",
+ "zh:56bdafda0d629e15dc3dd9275b54f1fb953e2e09a3bc1a34e027da9d03ea4893",
+ "zh:5b84e933989150249036f84faad221dce0daa9d3043ff24401547e18f00b121e",
+ "zh:70bac98c27a14cb2cedabd741a1f7f1bab074c127efdcf02b54dbcf0d03db3cc",
+ "zh:7184f48bd077eaf68e184fd44f97e2d971cb77c59a68aedb95a0f8dc01b134fe",
+ "zh:7367589ae8b584bfcd83c973f5003e15010a453349c017a0d2cca8772d4fcfd9",
+ "zh:7ec9699dee49dd31bbc2d0e50fa1fff451eee5c1d9fd59bca7412acb49ce6594",
+ "zh:92dd139b96977a64af0e976cd06e84921033678ab97550f1b687c0ea54a8e82c",
+ "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
+ "zh:9f2df575a5b010db60068668c48806595a3d617a2c0305035283fe8b72f07b19",
+ "zh:a4602b7602c75c8f726bdc7e706dc5c26736e47cc8381be01386aa8d8d998403",
+ "zh:bc25fefeeee10425df7aebfc21dc6532d19acdf03fa97b9e6d8c113adffd0a1d",
+ "zh:f445592040b5fc368a12e6edeffc951b2eb41e86413c4074638a13376e25a9cc",
+ "zh:ff43962a48bd8f85e17188736bbd3c145b6a1320bd8303221f6b4f9ec861e1e6",
+ ]
+}
diff --git a/tests/ministack/setup/README.md b/tests/ministack/setup/README.md
new file mode 100644
index 0000000000..4c39a7450a
--- /dev/null
+++ b/tests/ministack/setup/README.md
@@ -0,0 +1,41 @@
+
+## Requirements
+
+| Name | Version |
+|------|---------|
+| [terraform](#requirement\_terraform) | >= 1.10.0 |
+| [archive](#requirement\_archive) | ~> 2.7 |
+| [aws](#requirement\_aws) | = 6.35.1 |
+
+## Providers
+
+| Name | Version |
+|------|---------|
+| [archive](#provider\_archive) | 2.8.0 |
+| [aws](#provider\_aws) | 6.35.1 |
+
+## Modules
+
+No modules.
+
+## Resources
+
+| Name | Type |
+|------|------|
+| [aws_ssm_parameter.al2023_arm64](https://registry.terraform.io/providers/hashicorp/aws/6.35.1/docs/resources/ssm_parameter) | resource |
+| [aws_ssm_parameter.al2023_x64](https://registry.terraform.io/providers/hashicorp/aws/6.35.1/docs/resources/ssm_parameter) | resource |
+| [aws_ssm_parameter.github_app_id](https://registry.terraform.io/providers/hashicorp/aws/6.35.1/docs/resources/ssm_parameter) | resource |
+| [aws_ssm_parameter.github_app_key](https://registry.terraform.io/providers/hashicorp/aws/6.35.1/docs/resources/ssm_parameter) | resource |
+| [aws_ssm_parameter.github_app_webhook_secret](https://registry.terraform.io/providers/hashicorp/aws/6.35.1/docs/resources/ssm_parameter) | resource |
+| [archive_file.lambda](https://registry.terraform.io/providers/hashicorp/archive/latest/docs/data-sources/file) | data source |
+
+## Inputs
+
+No inputs.
+
+## Outputs
+
+| Name | Description |
+|------|-------------|
+| [lambda\_archive](#output\_lambda\_archive) | Absolute path to the inert Lambda archive used by the example tests. |
+
\ No newline at end of file
diff --git a/tests/ministack/setup/index.mjs b/tests/ministack/setup/index.mjs
new file mode 100644
index 0000000000..57b6fbdf51
--- /dev/null
+++ b/tests/ministack/setup/index.mjs
@@ -0,0 +1,16 @@
+const response = async () => ({ statusCode: 200, body: "ministack" });
+
+export {
+ response as adjustPool,
+ response as deregisterRetry,
+ response as directWebhook,
+ response as dispatchToRunners,
+ response as eventBridgeWebhook,
+ response as handler,
+ response as interruptionWarning,
+ response as jobRetryCheck,
+ response as scaleDownHandler,
+ response as scaleUpHandler,
+ response as ssmHousekeeper,
+ response as termination,
+};
diff --git a/tests/ministack/setup/main.tf b/tests/ministack/setup/main.tf
new file mode 100644
index 0000000000..208e29c66d
--- /dev/null
+++ b/tests/ministack/setup/main.tf
@@ -0,0 +1,69 @@
+terraform {
+ required_version = ">= 1.10.0"
+
+ required_providers {
+ archive = {
+ source = "hashicorp/archive"
+ version = "~> 2.7"
+ }
+ aws = {
+ source = "hashicorp/aws"
+ version = "= 6.35.1"
+ }
+ }
+}
+
+provider "aws" {
+ region = "eu-west-1"
+
+ # These values are MiniStack test identifiers and cannot authenticate to AWS.
+ access_key = "000000000000"
+ secret_key = "ministack-test-only"
+
+ skip_credentials_validation = true
+ skip_metadata_api_check = true
+ skip_region_validation = true
+ skip_requesting_account_id = true
+ s3_use_path_style = true
+}
+
+data "archive_file" "lambda" {
+ type = "zip"
+ source_file = "${path.module}/index.mjs"
+ output_path = "${path.module}/.terraform/ministack/lambda.zip"
+}
+
+resource "aws_ssm_parameter" "al2023_x64" {
+ name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-x86_64"
+ type = "String"
+ value = "ami-0a1b2c3d4e5f67890"
+}
+
+resource "aws_ssm_parameter" "al2023_arm64" {
+ name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-arm64"
+ type = "String"
+ value = "ami-0a1b2c3d4e5f67890"
+}
+
+resource "aws_ssm_parameter" "github_app_id" {
+ name = "/ministack/terraform-aws-github-runner/github-app/id"
+ type = "String"
+ value = "0"
+}
+
+resource "aws_ssm_parameter" "github_app_key" {
+ name = "/ministack/terraform-aws-github-runner/github-app/key-base64"
+ type = "SecureString"
+ value = "ministack-invalid-github-app-key"
+}
+
+resource "aws_ssm_parameter" "github_app_webhook_secret" {
+ name = "/ministack/terraform-aws-github-runner/github-app/webhook-secret"
+ type = "SecureString"
+ value = "ministack-test-only"
+}
+
+output "lambda_archive" {
+ description = "Absolute path to the inert Lambda archive used by the example tests."
+ value = abspath(data.archive_file.lambda.output_path)
+}