diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bda6c9..6b998fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: strategy: fail-fast: false matrix: - component: [vpc, postgres-instance, app-alb, dummy, github] + component: [network, compute-engine, github] steps: - uses: actions/checkout@v6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6369710..0ef3c5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,20 +17,51 @@ pins that tag, so this file is the human-readable answer to "what's in v0.2.0?". ## [Unreleased] +> **Cloud pivot:** the org is moving to **GCP**. New components target `hashicorp/google`; the +> AWS modules have been removed (see **Removed** below). + +### Added +- `compute-engine` — first GCP compute module (`google_compute_instance`). **Bootstrap-agnostic**: + runs a caller-supplied `startup_script` (userdata) on first boot, `""` = none. The Docker + bootstrap and its on/off switch live in the consuming environment (`infra-environments-dev`), + not in the module (no Ansible, no COS). **No external IP** by default — access is "SSM-like": + **OS Login + IAP TCP forwarding** + (`gcloud compute ssh --tunnel-through-iap`), governed by IAM. Grants `roles/compute.osLogin` + and `roles/iap.tunnelResourceAccessor` to `access_members`. Opts into VPC firewall rules via + `network_tags` (e.g. `[network.ssh_tag]`); empty default = no tag-scoped inbound. Consumes + `network`/`subnetwork` from the `network` component. Built for cheap teardown/redeploy (no deletion protection, boot disk + auto-deletes, `allow_stopping_for_update`), so the VM can be destroyed when idle to save credits. + Outputs `instance_name`, `internal_ip`, `ssh_command`. +- `github` — repository factory component (`integrations/github` provider). Manages GitHub + repos as code via a `repositories` map: visibility, description, topics, default branch, and + optional branch protection. First component requiring a credential (`GITHUB_TOKEN`); intended + to be owned by `infra-environments-dev` only, since repos are org-scoped. + ### Changed +- **`network` (replaces AWS `vpc`) — GCP network foundation built on registry modules.** The old + AWS `vpc` (IGW, public/private subnets per AZ) is gone; the new `network` component is a thin + wrapper over the verified CFT modules `terraform-google-modules/network/google` (`~> 18.0`) and + `terraform-google-modules/cloud-router/google` (`~> 9.0`). It creates a custom-mode VPC network + + one **regional** subnetwork (Private Google Access on), optional **Cloud Router + NAT** + (`enable_cloud_nat`, default `true`) for private-instance egress, and an **allow-IAP-SSH** rule + from `35.235.240.0/20` (`enable_iap_ssh`, default `true`) **scoped by `target_tags` to VMs + wearing the exported `ssh_tag`** (multi-VM ready). Inputs `project_id`, `subnet_cidr`. Outputs + `network_self_link`, `subnetwork_self_link`, `network_name`, `subnetwork_name`, `region`, + `ssh_tag`. Pulls in the `google-beta` provider (required by the network module). Replaces the + hand-written `vpc` rewrite that previously lived on this branch. - `github` — added per-repo `delete_branch_on_merge` (auto-deletes the head branch on merge, default `true`) and an org-wide default-team grant: `default_team` (default `engineers`) is granted `default_team_permission` (default `push`) on every managed repo via `github_team_repository`; set `default_team = ""` to opt out. Adds a `team_grants` output. The team grant requires `GITHUB_TOKEN` with `admin:org`. -## [0.3.0] - 2026-06-08 - -### Added -- `github` — repository factory component (`integrations/github` provider). Manages GitHub - repos as code via a `repositories` map: visibility, description, topics, default branch, and - optional branch protection. First component requiring a credential (`GITHUB_TOKEN`); intended - to be owned by `infra-environments-dev` only, since repos are org-scoped. +### Removed +- **`app-alb` and `postgres-instance` (AWS) — deleted.** Both consumed the old AWS `vpc` + outputs and are not used by any environment (already dropped from `infra-environments-dev`). + Removed as part of the GCP pivot rather than left as dead AWS modules. Recoverable from git + history; will be replaced by GCP equivalents (Cloud Load Balancing / Cloud SQL) if needed. +- **`dummy` — deleted.** The credential-free pipeline-test stub (random/local/null) has served + its purpose now that real GCP components (`vpc`, `compute-engine`) exercise the pipeline. ## [0.2.0] - 2026-06-03 diff --git a/README.md b/README.md index ba871e7..f6e3112 100644 --- a/README.md +++ b/README.md @@ -22,18 +22,18 @@ and **[CHANGELOG.md](./CHANGELOG.md)** for what changed in each tagged version. ## Components -| Component | Purpose | Key outputs | -| ------------------- | ------------------------------------------------ | --------------------------------------------- | -| `vpc` | Network foundation (VPC, subnets) | `vpc_id`, `subnet_ids_list_by_name` | -| `postgres-instance` | RDS PostgreSQL instance | `database_address`, `database_arn` (+secrets) | -| `app-alb` | Public Application Load Balancer | `alb_dns_name`, `alb_arn`, `target_group_arn` | -| `dummy` | Credential-free CI/CD test (random/local/null) | `pet_name`, `artifact_path` | -| `github` | GitHub repositories as code (repo factory) | `repository_names`, `repository_urls` | - -`vpc`, `postgres-instance`, and `app-alb` form a dependency chain: -**`vpc` → `postgres-instance` / `app-alb`**. `dummy` has no dependencies — it exists only to -exercise the pipeline (plan → PR comment → gated apply) without a cloud account, and can be -removed once real components are flowing. +> **Cloud:** GCP (`hashicorp/google`). The original AWS modules have been removed in the GCP +> pivot — see [CHANGELOG.md](./CHANGELOG.md). + +| Component | Cloud | Purpose | Key outputs | +| ---------------- | ------ | ------------------------------------------------ | ----------------------------------------------- | +| `network` | GCP | Network foundation — wraps CFT network + cloud-router modules | `network_self_link`, `subnetwork_self_link`, `ssh_tag` | +| `compute-engine` | GCP | VM (bootstrap-agnostic); OS Login + IAP access, no public IP | `instance_name`, `internal_ip`, `ssh_command` | +| `github` | GitHub | GitHub repositories as code (repo factory) | `repository_names`, `repository_urls` | + +`network` and `compute-engine` form a dependency chain: +**`network` → `compute-engine`** (the VM attaches to the network/subnetwork the `network` outputs). +`github` is standalone (org-scoped, no network). ## Anatomy of a component @@ -77,6 +77,7 @@ process is in [CONVENTIONS.md](./CONVENTIONS.md#versioning--releasing). ## Notes -- All values are placeholders — no real AWS account IDs, credentials, or hostnames. +- All values are placeholders — no real GCP project IDs, credentials, or hostnames. - Modules are minimal but **valid and applyable** (real resource blocks), so you can grow them. -- A real apply requires AWS credentials and a state backend, both configured in the env repos. +- A real apply requires GCP credentials (a project + enabled APIs) and a state backend, both + configured in the env repos. diff --git a/app-alb/README.md b/app-alb/README.md deleted file mode 100644 index 2e30a0d..0000000 --- a/app-alb/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# app-alb - -A public Application Load Balancer with a security group, a target group, and an HTTP listener. -Mirrors the "ingress / ALB" role from the reference setup. - -## Inputs - -| Name | Type | Default | Description | -| ------------------- | ------------ | ---------- | -------------------------------------------- | -| `global` | object | — | Env-wide context. | -| `vpc_id` | string | — | VPC for the ALB and target group. | -| `public_subnet_ids` | list(string) | — | Public subnets to attach the ALB to. | -| `target_port` | number | `8080` | Target group forwarding port. | -| `health_check_path` | string | `/health` | Health check path. | -| `internal` | bool | `false` | Internal vs internet-facing. | - -## Outputs - -| Name | Description | -| ------------------- | ------------------------------ | -| `alb_arn` | Load balancer ARN. | -| `alb_dns_name` | Load balancer DNS name. | -| `target_group_arn` | Default target group ARN. | -| `security_group_id` | Load balancer security group. | - -Depends on `vpc` for `vpc_id` and `public_subnet_ids` (public). diff --git a/app-alb/terraform/main.tf b/app-alb/terraform/main.tf deleted file mode 100644 index 989b4da..0000000 --- a/app-alb/terraform/main.tf +++ /dev/null @@ -1,76 +0,0 @@ -provider "aws" { - region = var.global.deploy_region -} - -locals { - name_prefix = "${var.global.environment_name}-alb" - - common_tags = merge(var.global.tags, { - ManagedBy = "terraform" - Environment = var.global.environment_name - }) -} - -resource "aws_security_group" "this" { - name = "${local.name_prefix}-sg" - description = "Security group for the ${local.name_prefix} load balancer." - vpc_id = var.vpc_id - - ingress { - description = "HTTP from anywhere." - from_port = 80 - to_port = 80 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] - } - - egress { - description = "Allow all outbound." - from_port = 0 - to_port = 0 - protocol = "-1" - cidr_blocks = ["0.0.0.0/0"] - } - - tags = merge(local.common_tags, { Name = "${local.name_prefix}-sg" }) -} - -resource "aws_lb" "this" { - name = local.name_prefix - internal = var.internal - load_balancer_type = "application" - security_groups = [aws_security_group.this.id] - subnets = var.public_subnet_ids - - tags = merge(local.common_tags, { Name = local.name_prefix }) -} - -resource "aws_lb_target_group" "this" { - name = "${local.name_prefix}-tg" - port = var.target_port - protocol = "HTTP" - vpc_id = var.vpc_id - target_type = "ip" - - health_check { - path = var.health_check_path - healthy_threshold = 2 - unhealthy_threshold = 3 - timeout = 5 - interval = 30 - matcher = "200" - } - - tags = merge(local.common_tags, { Name = "${local.name_prefix}-tg" }) -} - -resource "aws_lb_listener" "http" { - load_balancer_arn = aws_lb.this.arn - port = 80 - protocol = "HTTP" - - default_action { - type = "forward" - target_group_arn = aws_lb_target_group.this.arn - } -} diff --git a/app-alb/terraform/outputs.tf b/app-alb/terraform/outputs.tf deleted file mode 100644 index e636b25..0000000 --- a/app-alb/terraform/outputs.tf +++ /dev/null @@ -1,19 +0,0 @@ -output "alb_arn" { - value = aws_lb.this.arn - description = "ARN of the load balancer." -} - -output "alb_dns_name" { - value = aws_lb.this.dns_name - description = "DNS name of the load balancer." -} - -output "target_group_arn" { - value = aws_lb_target_group.this.arn - description = "ARN of the default target group." -} - -output "security_group_id" { - value = aws_security_group.this.id - description = "Security group ID of the load balancer." -} diff --git a/app-alb/terraform/variables.tf b/app-alb/terraform/variables.tf deleted file mode 100644 index 04f3c0e..0000000 --- a/app-alb/terraform/variables.tf +++ /dev/null @@ -1,36 +0,0 @@ -variable "global" { - type = object({ - environment_name = string - deploy_region = string - tags = map(string) - }) - description = "Environment-wide context injected by the environments repo (name, region, tags)." -} - -variable "vpc_id" { - type = string - description = "VPC the load balancer and target group live in." -} - -variable "public_subnet_ids" { - type = list(string) - description = "Public subnet IDs to attach the load balancer to." -} - -variable "target_port" { - type = number - description = "Port the target group forwards to." - default = 8080 -} - -variable "health_check_path" { - type = string - description = "HTTP path used for target group health checks." - default = "/health" -} - -variable "internal" { - type = bool - description = "Whether the load balancer is internal (true) or internet-facing (false)." - default = false -} diff --git a/compute-engine/README.md b/compute-engine/README.md new file mode 100644 index 0000000..23e79bd --- /dev/null +++ b/compute-engine/README.md @@ -0,0 +1,103 @@ +# compute-engine + +A **GCP Compute Engine VM with Docker** installed on first boot. The VM has **no external IP** — +access follows the same model as AWS SSM Session Manager: **OS Login + IAP TCP forwarding**, where +you connect through Google's infrastructure with your own identity and short-lived, +IAM-governed keys (no public SSH port, no key files to manage). + +## What it creates + +- `google_compute_instance` — the VM. No external IP by default; `enable-oslogin = TRUE` so SSH + access is governed by IAM, not project metadata keys. Runs the caller-supplied `startup_script` + on first boot (empty string = no bootstrap). **This module is bootstrap-agnostic** — the actual + first-boot script (e.g. installing Docker) is **userdata owned by the consuming environment**, + not baked into the module. See "Bootstrap / userdata" below. +- `google_compute_instance_iam_member` (per member) — grants **`roles/compute.osLogin`** to each + `access_members` principal, letting them log in over SSH. +- `google_iap_tunnel_instance_iam_member` (per member) — grants **`roles/iap.tunnelResourceAccessor`** + to each `access_members` principal, letting them open an IAP tunnel to the VM. + +Inbound SSH from the IAP range is allowed by the **`network` component's** `allow-iap-ssh` firewall rule, +and outbound internet (for the Docker install) comes from the `network`'s Cloud NAT — so this module +assumes it is attached to a `network` created by that component (or an equivalent network). + +## Access model ("SSM-like") + +``` +you / CI service account + │ (identity + IAM: compute.osLogin + iap.tunnelResourceAccessor) + ▼ +IAP ──tunnel──▶ VM:22 (no external IP; firewall allows only 35.235.240.0/20) +``` + +Connect with: + +```bash +gcloud compute ssh \ + --zone --tunnel-through-iap +``` + +The `ssh_command` output prints this for you. To grant someone access, add their principal to +`access_members` (e.g. `user:alice@officialdad.com`, `serviceAccount:ci@.iam.gserviceaccount.com`) +and re-apply — no keys to distribute, and access is revoked the moment IAM is removed. + +## Bootstrap / userdata + +The module does **not** know what to install — it just runs whatever `startup_script` string the +caller passes, on first boot, as the instance's `metadata_startup_script`. Pass `""` (the default) +for a plain VM. + +The **consuming environment owns the bootstrap**. In `infra-environments-dev` the Docker install +lives as a script file (e.g. `compute-engine/userdata/docker-bootstrap.sh`) and a switch in the +unit's `terragrunt.hcl` decides whether to pass it: + +```hcl +locals { + install_docker = true +} +inputs = { + startup_script = local.install_docker ? file("${get_terragrunt_dir()}/userdata/docker-bootstrap.sh") : "" +} +``` + +This keeps the cookbook module generic (any VM, any bootstrap) and puts the "what runs on boot" +decision where environment-specific choices belong. A Docker bootstrap needs outbound internet +(apt + docker.com), which the `network`'s Cloud NAT provides to this otherwise-private VM. + +## Auth + +Provider needs `project_id` + credentials (ADC / `GOOGLE_APPLICATION_CREDENTIALS` / CI service +account); region from `var.global.deploy_region`. The principal running `apply` needs rights to +create instances and set IAM on them, and the **OS Login API**, **Compute API**, and **IAP API** +must be enabled on the project. + +## Inputs + +| Name | Type | Default | Description | +| ------------------- | ------------ | ------------------------ | ------------------------------------------------------------------------ | +| `global` | object | — | Env-wide context (`environment_name`, `deploy_region`, `tags`). | +| `project_id` | string | — | GCP project the VM is created in. | +| `network` | string | — | Network self link / name (from `network.network_self_link`). | +| `subnetwork` | string | — | Subnetwork self link / name (from `network.subnetwork_self_link`). | +| `zone` | string | `""` | Zone for the VM. Empty → `"-a"`. | +| `machine_type` | string | `e2-micro` | Machine type. | +| `boot_image` | string | `debian-cloud/debian-12` | Boot image (`project/family` or full self link). | +| `boot_disk_size_gb` | number | `20` | Boot disk size in GB. | +| `startup_script` | string | `""` | First-boot script (userdata). Empty = no bootstrap. Supplied by the env. | +| `assign_public_ip` | bool | `false` | Attach an ephemeral external IP. Leave `false` for the IAP-only model. | +| `access_members` | list(string) | `[]` | IAM principals granted OS Login + IAP tunnel access (see access model). | + +## Outputs + +| Name | Description | +| --------------- | ---------------------------------------------------------------- | +| `instance_name` | The VM name (`-compute-engine`). | +| `instance_id` | The instance ID. | +| `internal_ip` | The VM's internal IP. | +| `zone` | The zone the VM runs in. | +| `ssh_command` | Ready-to-run `gcloud compute ssh ... --tunnel-through-iap` line. | + +## Dependencies + +Consumes `network` and `subnetwork` from the `network` component. Relies on the `network`'s `allow-iap-ssh` +firewall rule (inbound SSH from IAP) and Cloud NAT (outbound, for the Docker install). diff --git a/compute-engine/terraform/main.tf b/compute-engine/terraform/main.tf new file mode 100644 index 0000000..435896b --- /dev/null +++ b/compute-engine/terraform/main.tf @@ -0,0 +1,76 @@ +provider "google" { + project = var.project_id + region = var.global.deploy_region +} + +locals { + name_prefix = "${var.global.environment_name}-compute-engine" + + # Default to zone "a" in the deploy region unless the caller pins one. + zone = var.zone != "" ? var.zone : "${var.global.deploy_region}-a" + + # GCP labels must be lowercase; only this subset of the tags convention maps cleanly. + labels = { + environment = lower(var.global.environment_name) + managed_by = "terraform" + } +} + +resource "google_compute_instance" "this" { + name = local.name_prefix + machine_type = var.machine_type + zone = local.zone + labels = local.labels + tags = var.network_tags + + # Destroy-friendly: no accidental lock, and changing machine_type stops the VM + # instead of forcing a full recreate. + deletion_protection = false + allow_stopping_for_update = true + + boot_disk { + auto_delete = true # disk is deleted with the VM -> no orphaned disk cost + initialize_params { + image = var.boot_image + size = var.boot_disk_size_gb + } + } + + network_interface { + network = var.network + subnetwork = var.subnetwork + + # Emitting access_config = an external IP. Omit it (default) = no public IP. + dynamic "access_config" { + for_each = var.assign_public_ip ? [1] : [] + content {} + } + } + + metadata = { + enable-oslogin = "TRUE" # SSH access governed by IAM, not metadata keys. + } + + # Caller-supplied userdata; null (unset) when empty. + metadata_startup_script = var.startup_script != "" ? var.startup_script : null +} + +# OS Login: who may SSH in (identity-based). +resource "google_compute_instance_iam_member" "os_login" { + for_each = toset(var.access_members) + project = var.project_id + zone = local.zone + instance_name = google_compute_instance.this.name + role = "roles/compute.osLogin" + member = each.value +} + +# IAP: who may open the tunnel that reaches the (private) VM's SSH port. +resource "google_iap_tunnel_instance_iam_member" "tunnel" { + for_each = toset(var.access_members) + project = var.project_id + zone = local.zone + instance = google_compute_instance.this.name + role = "roles/iap.tunnelResourceAccessor" + member = each.value +} diff --git a/compute-engine/terraform/outputs.tf b/compute-engine/terraform/outputs.tf new file mode 100644 index 0000000..accd214 --- /dev/null +++ b/compute-engine/terraform/outputs.tf @@ -0,0 +1,24 @@ +output "instance_name" { + value = google_compute_instance.this.name + description = "The VM name." +} + +output "instance_id" { + value = google_compute_instance.this.instance_id + description = "The instance ID." +} + +output "internal_ip" { + value = google_compute_instance.this.network_interface[0].network_ip + description = "The VM's internal IP." +} + +output "zone" { + value = google_compute_instance.this.zone + description = "The zone the VM runs in." +} + +output "ssh_command" { + value = "gcloud compute ssh ${google_compute_instance.this.name} --zone ${google_compute_instance.this.zone} --project ${var.project_id} --tunnel-through-iap" + description = "Ready-to-run IAP SSH command." +} diff --git a/compute-engine/terraform/variables.tf b/compute-engine/terraform/variables.tf new file mode 100644 index 0000000..070873d --- /dev/null +++ b/compute-engine/terraform/variables.tf @@ -0,0 +1,71 @@ +variable "global" { + type = object({ + environment_name = string + deploy_region = string + tags = map(string) + }) + description = "Environment-wide context injected by the environments repo (name, region, tags)." +} + +variable "project_id" { + type = string + description = "GCP project the VM is created in." +} + +variable "network" { + type = string + description = "Network self link or name (from network.network_self_link)." +} + +variable "subnetwork" { + type = string + description = "Subnetwork self link or name (from network.subnetwork_self_link)." +} + +variable "zone" { + type = string + description = "Zone for the VM. Empty string -> \"-a\"." + default = "" +} + +variable "machine_type" { + type = string + description = "Machine type." + default = "e2-micro" +} + +variable "boot_image" { + type = string + description = "Boot image as project/family or a full self link." + default = "debian-cloud/debian-12" +} + +variable "boot_disk_size_gb" { + type = number + description = "Boot disk size in GB." + default = 20 +} + +variable "startup_script" { + type = string + description = "First-boot script (userdata) run via metadata_startup_script. \"\" = no bootstrap." + default = "" +} + +variable "assign_public_ip" { + type = bool + description = "Attach an ephemeral external IP. Leave false for the IAP-only model." + default = false +} + +variable "access_members" { + type = list(string) + description = "IAM principals granted OS Login + IAP tunnel access (e.g. user:me@x.com)." + default = [] +} + +variable "network_tags" { + type = list(string) + description = "Network tags applied to the VM. Each tag opts the VM into the VPC firewall rules that target it (e.g. [module.network.ssh_tag] to allow IAP SSH). Empty = no tag-scoped inbound." + default = [] +} diff --git a/app-alb/terraform/versions.tf b/compute-engine/terraform/versions.tf similarity index 50% rename from app-alb/terraform/versions.tf rename to compute-engine/terraform/versions.tf index 946e64b..30e8b3f 100644 --- a/app-alb/terraform/versions.tf +++ b/compute-engine/terraform/versions.tf @@ -2,9 +2,9 @@ terraform { required_version = ">= 1.5" required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 5.0" + google = { + source = "hashicorp/google" + version = "~> 7.0" } } } diff --git a/dummy/README.md b/dummy/README.md deleted file mode 100644 index ee353b5..0000000 --- a/dummy/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# dummy - -A **credential-free** component for testing the CI/CD pipeline end to end (plan → PR comment → -gated apply) with no AWS account. It uses only the `random`, `local`, and `null` providers, so -`terraform plan`/`apply` produce real, meaningful output on a CI runner with zero secrets. - -## What it creates - -- `random_pet.name` — a random name prefixed with `-dummy`. -- `local_file.artifact` — a small text file under `generated/` describing the environment. -- `null_resource.marker` — a trigger that changes with the pet name (demonstrates replacement). - -## Inputs - -| Name | Type | Default | Description | -| ------------ | ------ | -------------------------------- | --------------------------------- | -| `global` | object | — | Env-wide context. | -| `pet_length` | number | `2` | Words in the generated pet name. | -| `message` | string | `hello from the dummy component` | Message written into the artifact. | - -## Outputs - -| Name | Description | -| --------------- | --------------------------------- | -| `pet_name` | The generated pet name. | -| `artifact_path` | Path of the generated local file. | - -## Why it exists - -It lets you validate the whole pipeline — git-sourced module fetch, Terragrunt wiring, plan -summary posted to the PR, and the gated apply-on-merge — before wiring real AWS credentials. -Once the pipeline is proven, you can remove this component (and its leaves in the env repos). diff --git a/dummy/terraform/.gitignore b/dummy/terraform/.gitignore deleted file mode 100644 index 9ab870d..0000000 --- a/dummy/terraform/.gitignore +++ /dev/null @@ -1 +0,0 @@ -generated/ diff --git a/dummy/terraform/main.tf b/dummy/terraform/main.tf deleted file mode 100644 index 62b8a1e..0000000 --- a/dummy/terraform/main.tf +++ /dev/null @@ -1,30 +0,0 @@ -# A credential-free component used to exercise the full CI/CD pipeline (plan -> PR -# comment -> gated apply) without any cloud account. It creates real Terraform -# resources, just local ones: a random name, a local file, and a null trigger. - -locals { - name_prefix = "${var.global.environment_name}-dummy" -} - -resource "random_pet" "name" { - length = var.pet_length - prefix = local.name_prefix -} - -resource "local_file" "artifact" { - filename = "${path.module}/generated/${local.name_prefix}.txt" - content = <<-EOT - environment : ${var.global.environment_name} - region : ${var.global.deploy_region} - pet : ${random_pet.name.id} - message : ${var.message} - EOT -} - -# A null_resource whose trigger changes when the pet name changes — gives the plan -# a visible "must replace" example when inputs change. -resource "null_resource" "marker" { - triggers = { - pet = random_pet.name.id - } -} diff --git a/dummy/terraform/outputs.tf b/dummy/terraform/outputs.tf deleted file mode 100644 index 62a1b9b..0000000 --- a/dummy/terraform/outputs.tf +++ /dev/null @@ -1,9 +0,0 @@ -output "pet_name" { - value = random_pet.name.id - description = "The generated pet name." -} - -output "artifact_path" { - value = local_file.artifact.filename - description = "Path of the generated local artifact file." -} diff --git a/dummy/terraform/variables.tf b/dummy/terraform/variables.tf deleted file mode 100644 index db51dcb..0000000 --- a/dummy/terraform/variables.tf +++ /dev/null @@ -1,20 +0,0 @@ -variable "global" { - type = object({ - environment_name = string - deploy_region = string - tags = map(string) - }) - description = "Environment-wide context injected by the environments repo (name, region, tags)." -} - -variable "pet_length" { - type = number - description = "Number of words in the generated pet name." - default = 2 -} - -variable "message" { - type = string - description = "A message written into the generated artifact file." - default = "hello from the dummy component" -} diff --git a/dummy/terraform/versions.tf b/dummy/terraform/versions.tf deleted file mode 100644 index a067b93..0000000 --- a/dummy/terraform/versions.tf +++ /dev/null @@ -1,18 +0,0 @@ -terraform { - required_version = ">= 1.5" - - required_providers { - random = { - source = "hashicorp/random" - version = "~> 3.4" - } - local = { - source = "hashicorp/local" - version = "~> 2.4" - } - null = { - source = "hashicorp/null" - version = "~> 3.2" - } - } -} diff --git a/network/README.md b/network/README.md new file mode 100644 index 0000000..b21cfcd --- /dev/null +++ b/network/README.md @@ -0,0 +1,78 @@ +# network + +Network foundation component (**GCP**): a custom-mode VPC network with one **regional** +subnetwork, **Private Google Access**, optional **Cloud NAT** for private-instance egress, and a +baseline firewall (intra-network traffic + IAP SSH). + +This component is a **thin wrapper** over two verified Google Cloud Foundation Toolkit modules — +it manages no raw `google_compute_*` resources itself. It bakes in *our* opinions (the IAP-SSH +rule, the `ssh_tag` contract, naming, the `global` convention) so the environments repos consume a +small, stable interface and never see the upstream modules' full input surface. + +| Wraps | Version | Provides | +| ----- | ------- | -------- | +| [`terraform-google-modules/network/google`](https://registry.terraform.io/modules/terraform-google-modules/network/google) | `~> 18.0` | network + regional subnet + firewall rules | +| [`terraform-google-modules/cloud-router/google`](https://registry.terraform.io/modules/terraform-google-modules/cloud-router/google) | `~> 9.0` | Cloud Router + NAT (only when `enable_cloud_nat`) | + +GCP networking differs from AWS in ways that make this component simpler: + +- A subnetwork is **regional** (every zone in the region shares it), so one subnetwork covers the + whole region — no per-AZ subnet fan-out, no route tables to associate. +- There is **no "public subnet."** Whether an instance is reachable from the internet depends on + whether it has an external IP, not on the subnet. This component keeps instances **private** and + provides egress via Cloud NAT plus inbound SSH via IAP (see the `compute-engine` component). + +## What it creates + +- A **custom-mode VPC network** (`auto_create_subnetworks = false`) with REGIONAL routing. +- One **regional subnetwork** (`subnet_cidr`) with `private_ip_google_access` on, so instances can + reach Google APIs without an external IP. +- **Cloud Router + NAT** — only when `enable_cloud_nat` (default `true`). Outbound internet for + instances with no external IP (e.g. so a VM's bootstrap can `apt-get install docker`). This is the + one piece that costs money at idle (~\$1/day) — set `enable_cloud_nat = false` to stop charges. +- Firewall **`allow-internal`** — intra-network traffic (tcp/udp/icmp) from `subnet_cidr`. +- Firewall **`allow-iap-ssh`** — only when `enable_iap_ssh` (default `true`). Allows tcp:22 from + Google's IAP range `35.235.240.0/20`, **scoped via `target_tags` to VMs wearing the exported + `ssh_tag`** — so only opted-in instances accept SSH. `gcloud compute ssh --tunnel-through-iap` + then works without exposing SSH to the internet. + +Firewall rules are passed to the network module as `ingress_rules` **data**, not declared as +separate `google_compute_firewall` resources — the module renders them. + +## Auth + +The provider needs a GCP **project** and credentials. Set the project via `project_id`; supply +credentials out-of-band (Application Default Credentials, `GOOGLE_APPLICATION_CREDENTIALS`, or a +service account in CI). No key is stored in this component. Region comes from +`var.global.deploy_region`. + +## A note on tags vs. labels + +The repo convention is to tag every resource with `Environment` / `ManagedBy`. GCP **networking** +resources (network, subnetwork, router, firewall) **don't support labels**, so the convention is +only honored where the provider allows it (e.g. the instance in `compute-engine`). Naming still +follows `-network[-purpose]`. + +## Inputs + +| Name | Type | Default | Description | +| ----------------- | ------ | -------------- | ------------------------------------------------------------------ | +| `global` | object | — | Env-wide context (`environment_name`, `deploy_region`, `tags`). | +| `project_id` | string | — | GCP project the network is created in. | +| `subnet_cidr` | string | `10.0.0.0/16` | Primary IPv4 range of the regional subnetwork. | +| `enable_cloud_nat`| bool | `true` | Create Cloud Router + NAT for egress from private instances. | +| `enable_iap_ssh` | bool | `true` | Add a firewall rule allowing IAP (`35.235.240.0/20`) on tcp:22. | + +## Outputs + +| Name | Description | +| ---------------------- | ------------------------------------------------------------ | +| `network_name` | The VPC network name. | +| `network_self_link` | The network self link (pass to `compute-engine.network`). | +| `subnetwork_name` | The regional subnetwork name. | +| `subnetwork_self_link` | The subnetwork self link (pass to `compute-engine.subnetwork`). | +| `region` | The region the subnetwork lives in. | +| `ssh_tag` | Network tag granting IAP SSH; pass into a VM's `network_tags`. | + +Consumed by `compute-engine` (`network_self_link` → `network`, `subnetwork_self_link` → +`subnetwork`, `ssh_tag` → one of `network_tags`). diff --git a/network/terraform/main.tf b/network/terraform/main.tf new file mode 100644 index 0000000..60be517 --- /dev/null +++ b/network/terraform/main.tf @@ -0,0 +1,75 @@ +provider "google" { + project = var.project_id + region = var.global.deploy_region +} + +# The network module declares google-beta; configure it so its beta-backed +# paths have a project/region even though we write no beta resources ourselves. +provider "google-beta" { + project = var.project_id + region = var.global.deploy_region +} + +locals { + name_prefix = "${var.global.environment_name}-network" + ssh_tag = "${var.global.environment_name}-ssh" +} + +# Network + subnet + firewall via the verified CFT network module. +# Firewall baseline is expressed as ingress_rules (no hand-written +# google_compute_firewall); the IAP-SSH rule is scoped to ssh_tag. +module "network" { + source = "terraform-google-modules/network/google" + version = "~> 18.0" + + project_id = var.project_id + network_name = local.name_prefix + routing_mode = "REGIONAL" + + subnets = [{ + subnet_name = "${local.name_prefix}-subnet" + subnet_ip = var.subnet_cidr + subnet_region = var.global.deploy_region + subnet_private_access = "true" + }] + + ingress_rules = concat( + [{ + name = "${local.name_prefix}-allow-internal" + source_ranges = [var.subnet_cidr] + target_tags = null + allow = [ + { protocol = "tcp", ports = [] }, + { protocol = "udp", ports = [] }, + { protocol = "icmp", ports = [] }, + ] + }], + var.enable_iap_ssh ? [{ + name = "${local.name_prefix}-allow-iap-ssh" + source_ranges = ["35.235.240.0/20"] # IArange. + target_tags = [local.ssh_tag] + allow = [ + { protocol = "tcp", ports = ["22"] }, + ] + }] : [] + ) +} + +# Cloud Router + NAT = outbound internet for private VMs (e.g. to apt-install Docker). +# Router is free; NAT bills while it exists — set enable_cloud_nat=false to stop idle charges. +module "cloud_router" { + source = "terraform-google-modules/cloud-router/google" + version = "~> 9.0" + count = var.enable_cloud_nat ? 1 : 0 + + name = "${local.name_prefix}-router" + project_id = var.project_id + region = var.global.deploy_region + network = module.network.network_name + + nats = [{ + name = "${local.name_prefix}-nat" + source_subnetwork_ip_ranges_to_nat = "ALL_SUBNETWORKS_ALL_IP_RANGES" + nat_ip_allocate_option = "AUTO_ONLY" + }] +} diff --git a/network/terraform/outputs.tf b/network/terraform/outputs.tf new file mode 100644 index 0000000..80e9ebe --- /dev/null +++ b/network/terraform/outputs.tf @@ -0,0 +1,29 @@ +output "network_name" { + value = module.network.network_name + description = "The VPC network name." +} + +output "network_self_link" { + value = module.network.network_self_link + description = "Network self link (pass to compute-engine.network)." +} + +output "subnetwork_name" { + value = module.network.subnets_names[0] + description = "The regional subnetwork name." +} + +output "subnetwork_self_link" { + value = module.network.subnets_self_links[0] + description = "Subnetwork self link (pass to compute-engine.subnetwork)." +} + +output "region" { + value = var.global.deploy_region + description = "Region the subnetwork lives in." +} + +output "ssh_tag" { + value = local.ssh_tag + description = "Network tag that grants IAP SSH. Pass into a VM's network_tags to let it accept SSH (e.g. network_tags = [module.network.ssh_tag])." +} diff --git a/network/terraform/variables.tf b/network/terraform/variables.tf new file mode 100644 index 0000000..1866379 --- /dev/null +++ b/network/terraform/variables.tf @@ -0,0 +1,31 @@ +variable "global" { + type = object({ + environment_name = string + deploy_region = string + tags = map(string) + }) + description = "Environment-wide context injected by the environments repo (name, region, tags)." +} + +variable "project_id" { + type = string + description = "GCP project the network is created in." +} + +variable "subnet_cidr" { + type = string + description = "Primary IPv4 range of the regional subnetwork." + default = "10.0.0.0/16" +} + +variable "enable_cloud_nat" { + type = bool + description = "Create Cloud Router + NAT so instances with no external IP get egress." + default = true +} + +variable "enable_iap_ssh" { + type = bool + description = "Allow SSH (tcp:22) from Google's IAP range so --tunnel-through-iap works." + default = true +} diff --git a/network/terraform/versions.tf b/network/terraform/versions.tf new file mode 100644 index 0000000..9604be5 --- /dev/null +++ b/network/terraform/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.5" + + required_providers { + google = { + source = "hashicorp/google" + version = "~> 7.0" + } + google-beta = { + source = "hashicorp/google-beta" + version = "~> 7.0" + } + } +} diff --git a/postgres-instance/README.md b/postgres-instance/README.md deleted file mode 100644 index 5307656..0000000 --- a/postgres-instance/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# postgres-instance - -An RDS PostgreSQL instance with a dedicated subnet group and security group. The master -password is generated with `random_password` and exposed as a **sensitive** output (in a real -setup you'd store it in AWS Secrets Manager or Vault, as the reference component does). - -## Inputs - -| Name | Type | Default | Description | -| ------------------------- | ------------ | -------------- | ---------------------------------------- | -| `global` | object | — | Env-wide context. | -| `database_identifier` | string | — | Instance identifier (e.g. `app`). | -| `subnet_ids` | list(string) | — | DB subnet group subnets (private). | -| `vpc_id` | string | — | VPC for the security group. | -| `database_instance_class` | string | `db.t3.micro` | RDS instance class. | -| `allocated_storage` | number | `20` | Storage in GB. | -| `engine_version` | string | `16.3` | PostgreSQL version. | -| `database_name` | string | `appdb` | Initial database name. | -| `database_username` | string | `appadmin` | Master username. | -| `multi_az` | bool | `false` | Multi-AZ deployment. | - -## Outputs - -| Name | Sensitive | Description | -| ------------------- | --------- | --------------------------------- | -| `database_address` | no | RDS hostname. | -| `database_endpoint` | no | host:port. | -| `database_arn` | no | Instance ARN. | -| `security_group_id` | no | DB security group ID. | -| `database_username` | no | Master username. | -| `database_password` | **yes** | Generated master password. | - -Depends on `vpc` for `subnet_ids` (private) and `vpc_id`. diff --git a/postgres-instance/terraform/main.tf b/postgres-instance/terraform/main.tf deleted file mode 100644 index 34fe94c..0000000 --- a/postgres-instance/terraform/main.tf +++ /dev/null @@ -1,65 +0,0 @@ -provider "aws" { - region = var.global.deploy_region -} - -locals { - name_prefix = "${var.global.environment_name}-${var.database_identifier}" - - common_tags = merge(var.global.tags, { - ManagedBy = "terraform" - Environment = var.global.environment_name - }) -} - -# Generate a master password rather than accepting one as plaintext input. -# In a real setup this would typically be stored in Secrets Manager / Vault. -resource "random_password" "master" { - length = 24 - special = false -} - -resource "aws_db_subnet_group" "this" { - name = "${local.name_prefix}-subnets" - subnet_ids = var.subnet_ids - - tags = merge(local.common_tags, { Name = "${local.name_prefix}-subnets" }) -} - -resource "aws_security_group" "this" { - name = "${local.name_prefix}-sg" - description = "Security group for the ${local.name_prefix} PostgreSQL instance." - vpc_id = var.vpc_id - - egress { - description = "Allow all outbound." - from_port = 0 - to_port = 0 - protocol = "-1" - cidr_blocks = ["0.0.0.0/0"] - } - - tags = merge(local.common_tags, { Name = "${local.name_prefix}-sg" }) -} - -resource "aws_db_instance" "this" { - identifier = local.name_prefix - engine = "postgres" - engine_version = var.engine_version - instance_class = var.database_instance_class - - allocated_storage = var.allocated_storage - storage_encrypted = true - - db_name = var.database_name - username = var.database_username - password = random_password.master.result - - db_subnet_group_name = aws_db_subnet_group.this.name - vpc_security_group_ids = [aws_security_group.this.id] - multi_az = var.multi_az - - skip_final_snapshot = true - apply_immediately = true - - tags = merge(local.common_tags, { Name = local.name_prefix }) -} diff --git a/postgres-instance/terraform/outputs.tf b/postgres-instance/terraform/outputs.tf deleted file mode 100644 index 9cdff72..0000000 --- a/postgres-instance/terraform/outputs.tf +++ /dev/null @@ -1,30 +0,0 @@ -output "database_address" { - value = aws_db_instance.this.address - description = "The hostname of the RDS instance." -} - -output "database_endpoint" { - value = aws_db_instance.this.endpoint - description = "The connection endpoint (host:port)." -} - -output "database_arn" { - value = aws_db_instance.this.arn - description = "The ARN of the RDS instance." -} - -output "security_group_id" { - value = aws_security_group.this.id - description = "The security group ID protecting the database." -} - -output "database_username" { - value = aws_db_instance.this.username - description = "Master username." -} - -output "database_password" { - value = random_password.master.result - description = "Generated master password." - sensitive = true -} diff --git a/postgres-instance/terraform/variables.tf b/postgres-instance/terraform/variables.tf deleted file mode 100644 index 13b85ae..0000000 --- a/postgres-instance/terraform/variables.tf +++ /dev/null @@ -1,59 +0,0 @@ -variable "global" { - type = object({ - environment_name = string - deploy_region = string - tags = map(string) - }) - description = "Environment-wide context injected by the environments repo (name, region, tags)." -} - -variable "database_identifier" { - type = string - description = "The identifier of the database instance." -} - -variable "subnet_ids" { - type = list(string) - description = "Subnet IDs for the DB subnet group (typically the VPC's private subnets)." -} - -variable "vpc_id" { - type = string - description = "VPC ID the database security group is created in." -} - -variable "database_instance_class" { - type = string - description = "The RDS instance class." - default = "db.t3.micro" -} - -variable "allocated_storage" { - type = number - description = "Allocated storage in GB." - default = 20 -} - -variable "engine_version" { - type = string - description = "PostgreSQL engine version." - default = "16.3" -} - -variable "database_name" { - type = string - description = "Name of the initial database to create." - default = "appdb" -} - -variable "database_username" { - type = string - description = "Master username." - default = "appadmin" -} - -variable "multi_az" { - type = bool - description = "Enable Multi-AZ deployment." - default = false -} diff --git a/postgres-instance/terraform/versions.tf b/postgres-instance/terraform/versions.tf deleted file mode 100644 index 62651f7..0000000 --- a/postgres-instance/terraform/versions.tf +++ /dev/null @@ -1,14 +0,0 @@ -terraform { - required_version = ">= 1.5" - - required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 5.0" - } - random = { - source = "hashicorp/random" - version = "~> 3.4" - } - } -} diff --git a/vpc/README.md b/vpc/README.md deleted file mode 100644 index 6c6cef3..0000000 --- a/vpc/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# vpc - -Network foundation component: a VPC with public and private subnets spread across -availability zones, an internet gateway, and public routing. - -## Inputs - -| Name | Type | Default | Description | -| ------------------------ | -------- | ------------- | --------------------------------------------- | -| `global` | object | — | Env-wide context (environment_name, deploy_region, tags). | -| `cidr_block` | string | `10.0.0.0/16` | VPC CIDR block. | -| `az_count` | number | `3` | Number of AZs (1–3). | -| `public_subnet_newbits` | number | `8` | cidrsubnet newbits for public subnets. | -| `private_subnet_newbits` | number | `8` | cidrsubnet newbits for private subnets. | - -## Outputs - -| Name | Description | -| ------------------------- | ---------------------------------------------------- | -| `vpc_id` | The VPC ID. | -| `cidr_block` | The VPC CIDR. | -| `subnet_ids_list_by_name` | Map of tier → list of subnet IDs (`public`, `private`). | - -Consumed by `postgres-instance` (private subnets) and `app-alb` (public subnets). diff --git a/vpc/terraform/main.tf b/vpc/terraform/main.tf deleted file mode 100644 index 50ddf56..0000000 --- a/vpc/terraform/main.tf +++ /dev/null @@ -1,79 +0,0 @@ -provider "aws" { - region = var.global.deploy_region -} - -data "aws_availability_zones" "available" { - state = "available" -} - -locals { - azs = slice(data.aws_availability_zones.available.names, 0, var.az_count) - - name_prefix = "${var.global.environment_name}-vpc" - - common_tags = merge(var.global.tags, { - ManagedBy = "terraform" - Environment = var.global.environment_name - }) -} - -resource "aws_vpc" "this" { - cidr_block = var.cidr_block - enable_dns_support = true - enable_dns_hostnames = true - - tags = merge(local.common_tags, { Name = local.name_prefix }) -} - -resource "aws_internet_gateway" "this" { - vpc_id = aws_vpc.this.id - - tags = merge(local.common_tags, { Name = "${local.name_prefix}-igw" }) -} - -# Public subnets — one per AZ, carved from the start of the VPC range. -resource "aws_subnet" "public" { - count = var.az_count - - vpc_id = aws_vpc.this.id - cidr_block = cidrsubnet(var.cidr_block, var.public_subnet_newbits, count.index) - availability_zone = local.azs[count.index] - map_public_ip_on_launch = true - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-public-${local.azs[count.index]}" - Tier = "public" - }) -} - -# Private subnets — offset past the public range to avoid overlap. -resource "aws_subnet" "private" { - count = var.az_count - - vpc_id = aws_vpc.this.id - cidr_block = cidrsubnet(var.cidr_block, var.private_subnet_newbits, count.index + var.az_count) - availability_zone = local.azs[count.index] - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-private-${local.azs[count.index]}" - Tier = "private" - }) -} - -resource "aws_route_table" "public" { - vpc_id = aws_vpc.this.id - - route { - cidr_block = "0.0.0.0/0" - gateway_id = aws_internet_gateway.this.id - } - - tags = merge(local.common_tags, { Name = "${local.name_prefix}-public-rt" }) -} - -resource "aws_route_table_association" "public" { - count = var.az_count - - subnet_id = aws_subnet.public[count.index].id - route_table_id = aws_route_table.public.id -} diff --git a/vpc/terraform/outputs.tf b/vpc/terraform/outputs.tf deleted file mode 100644 index ba06eed..0000000 --- a/vpc/terraform/outputs.tf +++ /dev/null @@ -1,19 +0,0 @@ -output "vpc_id" { - value = aws_vpc.this.id - description = "The ID of the VPC." -} - -output "cidr_block" { - value = aws_vpc.this.cidr_block - description = "The CIDR block of the VPC." -} - -# Map of tier name -> list of subnet IDs. Downstream components index into this -# (e.g. subnet_ids_list_by_name.private) the same way the reference setup does. -output "subnet_ids_list_by_name" { - value = { - public = aws_subnet.public[*].id - private = aws_subnet.private[*].id - } - description = "Subnet IDs grouped by tier name (public/private)." -} diff --git a/vpc/terraform/variables.tf b/vpc/terraform/variables.tf deleted file mode 100644 index 69dbf64..0000000 --- a/vpc/terraform/variables.tf +++ /dev/null @@ -1,37 +0,0 @@ -variable "global" { - type = object({ - environment_name = string - deploy_region = string - tags = map(string) - }) - description = "Environment-wide context injected by the environments repo (name, region, tags)." -} - -variable "cidr_block" { - type = string - description = "The CIDR block for the VPC." - default = "10.0.0.0/16" -} - -variable "az_count" { - type = number - description = "Number of availability zones to spread subnets across." - default = 3 - - validation { - condition = var.az_count >= 1 && var.az_count <= 3 - error_message = "az_count must be between 1 and 3." - } -} - -variable "public_subnet_newbits" { - type = number - description = "Additional bits to extend the VPC prefix by for each public subnet (cidrsubnet)." - default = 8 -} - -variable "private_subnet_newbits" { - type = number - description = "Additional bits to extend the VPC prefix by for each private subnet (cidrsubnet)." - default = 8 -} diff --git a/vpc/terraform/versions.tf b/vpc/terraform/versions.tf deleted file mode 100644 index 946e64b..0000000 --- a/vpc/terraform/versions.tf +++ /dev/null @@ -1,10 +0,0 @@ -terraform { - required_version = ">= 1.5" - - required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 5.0" - } - } -}