From 9a8181ef1870588a6f59d71276a1c78b427d7d8d Mon Sep 17 00:00:00 2001 From: sjat Date: Sat, 30 May 2026 14:10:01 +0200 Subject: [PATCH] Add Terraform VM-provisioning skeleton Co-Authored-By: Claude Opus 4.8 (1M context) --- terraform/README.md | 13 ++++ terraform/environments/production/backend.tf | 17 +++++ terraform/environments/production/main.tf | 41 ++++++++++ terraform/environments/production/outputs.tf | 9 +++ .../environments/production/providers.tf | 16 ++++ .../production/terraform.tfvars.example | 23 ++++++ .../environments/production/variables.tf | 58 ++++++++++++++ terraform/environments/staging/backend.tf | 17 +++++ terraform/environments/staging/main.tf | 35 +++++++++ terraform/environments/staging/outputs.tf | 9 +++ terraform/environments/staging/providers.tf | 16 ++++ .../staging/terraform.tfvars.example | 23 ++++++ terraform/environments/staging/variables.tf | 58 ++++++++++++++ terraform/modules/proxmox_vm/main.tf | 66 ++++++++++++++++ terraform/modules/proxmox_vm/outputs.tf | 14 ++++ terraform/modules/proxmox_vm/variables.tf | 76 +++++++++++++++++++ 16 files changed, 491 insertions(+) create mode 100644 terraform/README.md create mode 100644 terraform/environments/production/backend.tf create mode 100644 terraform/environments/production/main.tf create mode 100644 terraform/environments/production/outputs.tf create mode 100644 terraform/environments/production/providers.tf create mode 100644 terraform/environments/production/terraform.tfvars.example create mode 100644 terraform/environments/production/variables.tf create mode 100644 terraform/environments/staging/backend.tf create mode 100644 terraform/environments/staging/main.tf create mode 100644 terraform/environments/staging/outputs.tf create mode 100644 terraform/environments/staging/providers.tf create mode 100644 terraform/environments/staging/terraform.tfvars.example create mode 100644 terraform/environments/staging/variables.tf create mode 100644 terraform/modules/proxmox_vm/main.tf create mode 100644 terraform/modules/proxmox_vm/outputs.tf create mode 100644 terraform/modules/proxmox_vm/variables.tf diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 0000000..0541049 --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,13 @@ +# terraform/ + +Infrastructure provisioning. Terraform owns **VM existence only** — creating and +destroying Proxmox VMs. It writes no DNS records and configures nothing inside a +VM; Ansible owns all of that. + +- `modules/proxmox_vm/` — reusable VM module (Proxmox only). +- `environments/{staging,production}/` — separate state per environment. Add a VM by + editing `local.vms` in that env's `main.tf`, then `make tf-plan` → `tf-apply` → + `tf-inventory`. + +Rationale: **ADR-006**. Handoff to Ansible: **ADR-009**. Secrets via `TF_VAR_*` +only — never in `.tfvars`. Not yet `terraform init`ed — see `STATUS.md`. diff --git a/terraform/environments/production/backend.tf b/terraform/environments/production/backend.tf new file mode 100644 index 0000000..00abe3e --- /dev/null +++ b/terraform/environments/production/backend.tf @@ -0,0 +1,17 @@ +terraform { + backend "http" { + # Forgejo HTTP state backend. + # Replace and with your Forgejo organisation and repository name. + # Verify the exact path format against your running Forgejo instance's API docs. + # Authentication: set TF_HTTP_USERNAME (Forgejo username) and + # TF_HTTP_PASSWORD (Forgejo personal access token) as environment variables. + # + # If Forgejo's HTTP state endpoint is unavailable, remove this block entirely + # to fall back to local state on the control node. + address = "https://git.baobab.band/api/v1/repos///raw/terraform/state/production.tfstate" + lock_address = "https://git.baobab.band/api/v1/repos///raw/terraform/state/production.tfstate/lock" + unlock_address = "https://git.baobab.band/api/v1/repos///raw/terraform/state/production.tfstate/lock" + lock_method = "POST" + unlock_method = "DELETE" + } +} diff --git a/terraform/environments/production/main.tf b/terraform/environments/production/main.tf new file mode 100644 index 0000000..97006bc --- /dev/null +++ b/terraform/environments/production/main.tf @@ -0,0 +1,41 @@ +# production/main.tf — Production VM definitions +# Add entries to local.vms to provision VMs; remove to destroy them. +# ALWAYS run `make tf-plan TF_ENV=production` and review before `make tf-apply TF_ENV=production`. + +locals { + vms = { + # control01 = { + # ip = "192.168.1.10/24" + # group = "control" + # cores = 2 + # memory_mb = 2048 + # } + # docker01 = { + # ip = "192.168.1.11/24" + # group = "docker_hosts" + # cores = 4 + # memory_mb = 4096 + # } + } +} + +module "vms" { + for_each = local.vms + source = "../../modules/proxmox_vm" + + vm_name = each.key + target_node = var.proxmox_node + clone_template_id = var.vm_template_id + datastore_id = var.vm_datastore_id + ip_address = each.value.ip + gateway = var.gateway + dns_servers = var.dns_servers + dns_domain = var.dns_domain + ssh_public_keys = var.ssh_public_keys + cores = each.value.cores + memory_mb = each.value.memory_mb + tags = ["production", each.value.group] +} + +# Internal DNS records are NOT managed here. Terraform owns VM existence only; +# the Ansible `dns` role renders the internal zone from inventory. See ADR-009. diff --git a/terraform/environments/production/outputs.tf b/terraform/environments/production/outputs.tf new file mode 100644 index 0000000..89df494 --- /dev/null +++ b/terraform/environments/production/outputs.tf @@ -0,0 +1,9 @@ +output "vms" { + description = "Map of hostname to IP and Ansible group — consumed by make tf-inventory" + value = { + for name, cfg in local.vms : name => { + ip = split("/", cfg.ip)[0] + group = cfg.group + } + } +} diff --git a/terraform/environments/production/providers.tf b/terraform/environments/production/providers.tf new file mode 100644 index 0000000..a57f5dc --- /dev/null +++ b/terraform/environments/production/providers.tf @@ -0,0 +1,16 @@ +terraform { + required_version = ">= 1.9" + + required_providers { + proxmox = { + source = "bpg/proxmox" + version = "~> 0.70" + } + } +} + +provider "proxmox" { + endpoint = var.proxmox_endpoint + api_token = var.proxmox_api_token + insecure = var.proxmox_insecure +} diff --git a/terraform/environments/production/terraform.tfvars.example b/terraform/environments/production/terraform.tfvars.example new file mode 100644 index 0000000..7501906 --- /dev/null +++ b/terraform/environments/production/terraform.tfvars.example @@ -0,0 +1,23 @@ +# Production environment — non-secret values +# Copy to terraform.tfvars and fill in your values. +# +# Secrets must be exported as environment variables before running Terraform: +# export TF_VAR_proxmox_api_token="terraform@pve!tokenid=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +# +# Forgejo backend credentials: +# export TF_HTTP_USERNAME="your-forgejo-username" +# export TF_HTTP_PASSWORD="your-forgejo-personal-access-token" + +proxmox_endpoint = "https://pve01.baobab.band:8006/" +proxmox_insecure = false +proxmox_node = "pve01" +vm_template_id = 9000 # Proxmox VM ID of the Debian 13 cloud-init template +vm_datastore_id = "local-lvm" + +gateway = "10.20.0.1" +dns_servers = ["10.20.0.10", "10.20.0.11"] +dns_domain = "boma.baobab.band" + +ssh_public_keys = [ + # "ssh-ed25519 AAAA... user@host", +] diff --git a/terraform/environments/production/variables.tf b/terraform/environments/production/variables.tf new file mode 100644 index 0000000..dd64343 --- /dev/null +++ b/terraform/environments/production/variables.tf @@ -0,0 +1,58 @@ +# ── Proxmox ─────────────────────────────────────────────────────────────────── + +variable "proxmox_endpoint" { + description = "Proxmox API URL, e.g. https://pve01.baobab.band:8006/" + type = string +} + +variable "proxmox_api_token" { + description = "Proxmox API token (user@realm!tokenid=secret) — set via TF_VAR_proxmox_api_token" + type = string + sensitive = true +} + +variable "proxmox_insecure" { + description = "Skip TLS verification for the Proxmox API (true while using a self-signed cert)" + type = bool + default = false +} + +variable "proxmox_node" { + description = "Default Proxmox node name to place VMs on" + type = string +} + +variable "vm_template_id" { + description = "Proxmox VM ID of the Debian 13 cloud-init template to clone" + type = number +} + +variable "vm_datastore_id" { + description = "Proxmox datastore for VM disks and cloud-init drives" + type = string + default = "local-lvm" +} + +variable "gateway" { + description = "Default IPv4 gateway for all VMs" + type = string +} + +variable "dns_servers" { + description = "DNS servers provided to VMs via cloud-init" + type = list(string) +} + +variable "dns_domain" { + description = "Search domain provided to VMs via cloud-init" + type = string +} + +variable "ssh_public_keys" { + description = "Public SSH keys provisioned for the ansible user on each VM" + type = list(string) +} + +# Note: `dns_servers` / `dns_domain` above are the cloud-init *resolver* settings +# (which DNS server a VM queries). Terraform does not write DNS *records* — the +# Ansible `dns` role owns the internal zone. See ADR-009. diff --git a/terraform/environments/staging/backend.tf b/terraform/environments/staging/backend.tf new file mode 100644 index 0000000..0167f1c --- /dev/null +++ b/terraform/environments/staging/backend.tf @@ -0,0 +1,17 @@ +terraform { + backend "http" { + # Forgejo HTTP state backend. + # Replace and with your Forgejo organisation and repository name. + # Verify the exact path format against your running Forgejo instance's API docs. + # Authentication: set TF_HTTP_USERNAME (Forgejo username) and + # TF_HTTP_PASSWORD (Forgejo personal access token) as environment variables. + # + # If Forgejo's HTTP state endpoint is unavailable, remove this block entirely + # to fall back to local state on the control node. + address = "https://git.baobab.band/api/v1/repos///raw/terraform/state/staging.tfstate" + lock_address = "https://git.baobab.band/api/v1/repos///raw/terraform/state/staging.tfstate/lock" + unlock_address = "https://git.baobab.band/api/v1/repos///raw/terraform/state/staging.tfstate/lock" + lock_method = "POST" + unlock_method = "DELETE" + } +} diff --git a/terraform/environments/staging/main.tf b/terraform/environments/staging/main.tf new file mode 100644 index 0000000..052be51 --- /dev/null +++ b/terraform/environments/staging/main.tf @@ -0,0 +1,35 @@ +# staging/main.tf — Staging VM definitions +# Add entries to local.vms to provision VMs; remove to destroy them. +# Run `make tf-plan TF_ENV=staging` before `make tf-apply TF_ENV=staging`. + +locals { + vms = { + # staging01 = { + # ip = "192.168.1.20/24" + # group = "docker_hosts" + # cores = 2 + # memory_mb = 2048 + # } + } +} + +module "vms" { + for_each = local.vms + source = "../../modules/proxmox_vm" + + vm_name = each.key + target_node = var.proxmox_node + clone_template_id = var.vm_template_id + datastore_id = var.vm_datastore_id + ip_address = each.value.ip + gateway = var.gateway + dns_servers = var.dns_servers + dns_domain = var.dns_domain + ssh_public_keys = var.ssh_public_keys + cores = each.value.cores + memory_mb = each.value.memory_mb + tags = ["staging", each.value.group] +} + +# Internal DNS records are NOT managed here. Terraform owns VM existence only; +# the Ansible `dns` role renders the internal zone from inventory. See ADR-009. diff --git a/terraform/environments/staging/outputs.tf b/terraform/environments/staging/outputs.tf new file mode 100644 index 0000000..89df494 --- /dev/null +++ b/terraform/environments/staging/outputs.tf @@ -0,0 +1,9 @@ +output "vms" { + description = "Map of hostname to IP and Ansible group — consumed by make tf-inventory" + value = { + for name, cfg in local.vms : name => { + ip = split("/", cfg.ip)[0] + group = cfg.group + } + } +} diff --git a/terraform/environments/staging/providers.tf b/terraform/environments/staging/providers.tf new file mode 100644 index 0000000..a57f5dc --- /dev/null +++ b/terraform/environments/staging/providers.tf @@ -0,0 +1,16 @@ +terraform { + required_version = ">= 1.9" + + required_providers { + proxmox = { + source = "bpg/proxmox" + version = "~> 0.70" + } + } +} + +provider "proxmox" { + endpoint = var.proxmox_endpoint + api_token = var.proxmox_api_token + insecure = var.proxmox_insecure +} diff --git a/terraform/environments/staging/terraform.tfvars.example b/terraform/environments/staging/terraform.tfvars.example new file mode 100644 index 0000000..898a374 --- /dev/null +++ b/terraform/environments/staging/terraform.tfvars.example @@ -0,0 +1,23 @@ +# Staging environment — non-secret values +# Copy to terraform.tfvars and fill in your values. +# +# Secrets must be exported as environment variables before running Terraform: +# export TF_VAR_proxmox_api_token="terraform@pve!tokenid=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +# +# Forgejo backend credentials: +# export TF_HTTP_USERNAME="your-forgejo-username" +# export TF_HTTP_PASSWORD="your-forgejo-personal-access-token" + +proxmox_endpoint = "https://pve01.baobab.band:8006/" +proxmox_insecure = true # set false once a valid TLS cert is in place +proxmox_node = "pve01" +vm_template_id = 9000 # Proxmox VM ID of the Debian 13 cloud-init template +vm_datastore_id = "local-lvm" + +gateway = "10.20.0.1" +dns_servers = ["10.20.0.10", "10.20.0.11"] +dns_domain = "boma.baobab.band" + +ssh_public_keys = [ + # "ssh-ed25519 AAAA... user@host", +] diff --git a/terraform/environments/staging/variables.tf b/terraform/environments/staging/variables.tf new file mode 100644 index 0000000..dd64343 --- /dev/null +++ b/terraform/environments/staging/variables.tf @@ -0,0 +1,58 @@ +# ── Proxmox ─────────────────────────────────────────────────────────────────── + +variable "proxmox_endpoint" { + description = "Proxmox API URL, e.g. https://pve01.baobab.band:8006/" + type = string +} + +variable "proxmox_api_token" { + description = "Proxmox API token (user@realm!tokenid=secret) — set via TF_VAR_proxmox_api_token" + type = string + sensitive = true +} + +variable "proxmox_insecure" { + description = "Skip TLS verification for the Proxmox API (true while using a self-signed cert)" + type = bool + default = false +} + +variable "proxmox_node" { + description = "Default Proxmox node name to place VMs on" + type = string +} + +variable "vm_template_id" { + description = "Proxmox VM ID of the Debian 13 cloud-init template to clone" + type = number +} + +variable "vm_datastore_id" { + description = "Proxmox datastore for VM disks and cloud-init drives" + type = string + default = "local-lvm" +} + +variable "gateway" { + description = "Default IPv4 gateway for all VMs" + type = string +} + +variable "dns_servers" { + description = "DNS servers provided to VMs via cloud-init" + type = list(string) +} + +variable "dns_domain" { + description = "Search domain provided to VMs via cloud-init" + type = string +} + +variable "ssh_public_keys" { + description = "Public SSH keys provisioned for the ansible user on each VM" + type = list(string) +} + +# Note: `dns_servers` / `dns_domain` above are the cloud-init *resolver* settings +# (which DNS server a VM queries). Terraform does not write DNS *records* — the +# Ansible `dns` role owns the internal zone. See ADR-009. diff --git a/terraform/modules/proxmox_vm/main.tf b/terraform/modules/proxmox_vm/main.tf new file mode 100644 index 0000000..b2e13d8 --- /dev/null +++ b/terraform/modules/proxmox_vm/main.tf @@ -0,0 +1,66 @@ +resource "proxmox_virtual_environment_vm" "this" { + name = var.vm_name + node_name = var.target_node + tags = var.tags + + clone { + vm_id = var.clone_template_id + full = true + } + + agent { + enabled = true + } + + cpu { + cores = var.cores + type = "x86-64-v2-AES" + } + + memory { + dedicated = var.memory_mb + } + + disk { + datastore_id = var.datastore_id + interface = "scsi0" + size = var.disk_size_gb + file_format = "raw" + discard = "on" + + lifecycle { + # Proxmox disallows disk shrinks; ignore if disk was grown outside Terraform. + ignore_changes = [size] + } + } + + network_device { + bridge = "vmbr0" + vlan_id = var.vlan_tag + } + + initialization { + datastore_id = var.datastore_id + + ip_config { + ipv4 { + address = var.ip_address + gateway = var.gateway + } + } + + dns { + domain = var.dns_domain + servers = var.dns_servers + } + + user_account { + username = "ansible" + keys = var.ssh_public_keys + } + } + + operating_system { + type = "l26" + } +} diff --git a/terraform/modules/proxmox_vm/outputs.tf b/terraform/modules/proxmox_vm/outputs.tf new file mode 100644 index 0000000..5b0a106 --- /dev/null +++ b/terraform/modules/proxmox_vm/outputs.tf @@ -0,0 +1,14 @@ +output "vm_id" { + description = "Proxmox VM ID" + value = proxmox_virtual_environment_vm.this.vm_id +} + +output "ipv4_address" { + description = "VM's static IPv4 address (without prefix length)" + value = split("/", var.ip_address)[0] +} + +output "hostname" { + description = "VM hostname" + value = var.vm_name +} diff --git a/terraform/modules/proxmox_vm/variables.tf b/terraform/modules/proxmox_vm/variables.tf new file mode 100644 index 0000000..978e916 --- /dev/null +++ b/terraform/modules/proxmox_vm/variables.tf @@ -0,0 +1,76 @@ +variable "vm_name" { + description = "Hostname and Proxmox display name" + type = string +} + +variable "target_node" { + description = "Proxmox node name to place the VM on" + type = string +} + +variable "clone_template_id" { + description = "Proxmox VM ID of the cloud-init template to clone" + type = number +} + +variable "cores" { + description = "CPU cores" + type = number + default = 2 +} + +variable "memory_mb" { + description = "RAM in MB" + type = number + default = 2048 +} + +variable "disk_size_gb" { + description = "Boot disk size in GB" + type = number + default = 20 +} + +variable "datastore_id" { + description = "Proxmox datastore for disk and cloud-init drive" + type = string + default = "local-lvm" +} + +variable "ip_address" { + description = "Static IPv4 address in CIDR notation, e.g. 192.168.1.10/24" + type = string +} + +variable "gateway" { + description = "Default IPv4 gateway" + type = string +} + +variable "dns_servers" { + description = "DNS server addresses passed to cloud-init" + type = list(string) +} + +variable "dns_domain" { + description = "Search domain passed to cloud-init" + type = string + default = "" +} + +variable "ssh_public_keys" { + description = "Public SSH keys provisioned for the ansible user via cloud-init" + type = list(string) +} + +variable "vlan_tag" { + description = "802.1q VLAN tag; null disables tagging" + type = number + default = null +} + +variable "tags" { + description = "Proxmox UI tags" + type = list(string) + default = [] +}