Add Terraform VM-provisioning skeleton

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sjat 2026-05-30 14:10:01 +02:00
parent fe4228fb38
commit 9a8181ef18
16 changed files with 491 additions and 0 deletions

13
terraform/README.md Normal file
View file

@ -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`.

View file

@ -0,0 +1,17 @@
terraform {
backend "http" {
# Forgejo HTTP state backend.
# Replace <owner> and <repo> 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/<owner>/<repo>/raw/terraform/state/production.tfstate"
lock_address = "https://git.baobab.band/api/v1/repos/<owner>/<repo>/raw/terraform/state/production.tfstate/lock"
unlock_address = "https://git.baobab.band/api/v1/repos/<owner>/<repo>/raw/terraform/state/production.tfstate/lock"
lock_method = "POST"
unlock_method = "DELETE"
}
}

View file

@ -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.

View file

@ -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
}
}
}

View file

@ -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
}

View file

@ -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",
]

View file

@ -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.

View file

@ -0,0 +1,17 @@
terraform {
backend "http" {
# Forgejo HTTP state backend.
# Replace <owner> and <repo> 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/<owner>/<repo>/raw/terraform/state/staging.tfstate"
lock_address = "https://git.baobab.band/api/v1/repos/<owner>/<repo>/raw/terraform/state/staging.tfstate/lock"
unlock_address = "https://git.baobab.band/api/v1/repos/<owner>/<repo>/raw/terraform/state/staging.tfstate/lock"
lock_method = "POST"
unlock_method = "DELETE"
}
}

View file

@ -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.

View file

@ -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
}
}
}

View file

@ -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
}

View file

@ -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",
]

View file

@ -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.

View file

@ -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"
}
}

View file

@ -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
}

View file

@ -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 = []
}