Provisioning patterns¶
The patterns below are what make the infrastructure side trigger-driven rather than hand-edited per run.
Module composition¶
Each topology has a thin root module that composes shared modules. The shared modules are small and single-purpose:
| Module | Responsibility |
|---|---|
| Networking | VPC, public and private subnets, routing, optional NAT |
| Access | Security groups defined as data, so each topology declares its own ingress rules |
| Key pair | A key generated per run, written locally with restricted permissions, and destroyed with the run (a key generated by Terraform is also stored in state, so the state must be private) |
| Compute | Instances created from a map of triggers |
| Kubernetes | A minimal self-managed cluster: one control-plane node and N workers |
Root modules contain almost no logic. They wire modules together and pass through the variables generated by the framework.
Module patterns¶
The infrastructure code follows a handful of familiar design patterns, adapted for code that describes infrastructure rather than objects.
| Pattern | What it does | Where it appears |
|---|---|---|
| Composite | Groups small modules into one deployable unit | Each topology root module composes networking, access, key pair and compute |
| Factory | Creates many similar resources from a list of inputs | The compute module creates one host per trigger with for_each |
| Builder | Assembles a configuration step by step, with options | The configuration layer builds a run's variables from triggers, defaults and topology choices |
| Prototype | Starts from a shared baseline and overrides only what differs | Common tags, sizes and disk defaults shared by every topology |
| Mediator | Decides the order in which dependent pieces are created | The framework: network, then bastion, then cluster, then install, then checks |
Inject dependencies, don't look them up¶
Modules receive the identifiers they depend on as inputs, such as a subnet ID, a security group or the bastion's address. They don't search the cloud for them by name. The root module wires outputs to inputs:
module "compute" {
source = "../../modules/compute"
subnet_id = module.networking.public_subnet_ids[0] # injected, not discovered
access_ids = [module.access.runner_ssh_id]
hosts = var.hosts
}
That keeps each module testable on its own, makes the dependency graph visible in one file, and lets a different topology hand the same module a private subnet instead.
Terraform is already a mediator¶
Inside a single apply, Terraform orders resources from their references. The framework only mediates across steps Terraform can't see: waiting for a cluster to become ready before installing the product, or running checks before deciding whether to destroy. Keep the framework's mediation to those gaps, and let Terraform handle the rest.
The JSON contract¶
The framework never templates HCL. It writes a JSON variables file that Terraform loads automatically, and the root module consumes it with for_each:
variable "hosts" {
type = map(object({
image_id = string
size = string
}))
}
resource "aws_instance" "host" {
for_each = var.hosts # one instance per trigger ID
ami = each.value.image_id
instance_type = each.value.size
subnet_id = var.subnet_id
vpc_security_group_ids = var.access_ids
user_data = var.install_script
tags = merge(var.tags, { RunTrigger = each.key })
}
Keying resources by trigger ID rather than list position means adding or removing a trigger only touches that trigger's resources.
Image resolution¶
Requesters provide only an OS name, version and architecture. The framework resolves these to a concrete machine image:
- Look up the image publisher for that OS (each vendor publishes images from a known account).
- Build a name pattern from the OS, version and architecture.
- Ask the cloud provider for matching images and take the most recent.
The resolved image ID goes into the JSON contract and onto the tracking ticket, so every run records exactly which image it tested.
Prefer vendor-published pointers
Name patterns break when vendors rename their images. Where a vendor publishes a "latest image" pointer (for example as a public parameter in the cloud provider's parameter store), use that first and fall back to pattern matching.
Access scoped to the runner¶
Test hosts need SSH for setup and debugging, but they shouldn't be reachable from anywhere else. At plan time, the framework looks up the public IP of the machine running it and passes it to the access module as the only allowed source.
The same configuration works on a CI runner and on a laptop, with no hard-coded address ranges.
Shared egress IPs
On hosted CI runners or behind a corporate NAT, the egress IP is shared with other users, so a single-IP rule doesn't isolate you. Self-hosted runners, or a session-based connection with no inbound ports at all (such as a cloud provider's session manager), are stronger options.
On cluster topologies, the bastion is the only host with that rule. The control-plane node and workers only accept traffic from inside the network.
Remote state and locking¶
State lives in a remote backend with locking (for example, object storage with native lock files, or a lock table on older Terraform versions), so a crashed run can still be destroyed later from another machine.
Parallelism comes from fan-out inside a run: ten OS image triggers become ten small hosts, built in one apply and validated side by side. State is keyed per topology, which is all you need while one run per topology is in flight. If two runs for the same topology have to overlap, give each its own state key, derived from the topology and run ID.
Credentials¶
Automation should authenticate with short-lived role credentials (for example, through single sign-on or an assumed role), not long-lived access keys. Product secrets needed on hosts, such as an enrolment token, come from the CI provider's secret store or a cloud secrets manager, never from the repository. Once a secret reaches a host, keep it out of saved plan files and logs.