diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 0f60e5f..74c32db 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -2,6 +2,15 @@ name: Deploy VM and App on: workflow_dispatch: + inputs: + tfvars_file: + description: "Which tfvars file to use" + required: true + default: "single.tfvars.example" + type: choice + options: + - single.tfvars.example + - multi.tfvars.example jobs: terraform: @@ -14,22 +23,24 @@ jobs: - uses: hashicorp/setup-terraform@v3 + - name: Select tfvars + run: cp "${{ inputs.tfvars_file }}" terraform.tfvars + - name: Terraform init run: terraform init - name: Terraform apply run: terraform apply -auto-approve - - name: Write inventory from output + - name: Write inventory run: | - IP=$(terraform output -raw vm_ipv4_address) mkdir -p ../ansible/inventory - printf '[app]\n%s ansible_user=cloud\n' "$IP" > ../ansible/inventory/hosts.ini + terraform output -json vm_ipv4_addresses | jq -r ' + to_entries[] | "[app]\n\(.value) ansible_user=cloud" + ' > ../ansible/inventory/hosts.ini - - name: Write tags file - run: | - TAGS=$(terraform output -json vm_tags) - echo "$TAGS" > ../terraform/vm_tags.json + - name: Write tags + run: terraform output -json vm_tags > ../ansible/vm_tags.json deploy: needs: terraform diff --git a/README.md b/README.md new file mode 100644 index 0000000..bccc673 --- /dev/null +++ b/README.md @@ -0,0 +1,145 @@ +# VM Service Template + +This repository is a starter template for deploying one or more service VMs in Proxmox using Terraform, then configuring and launching applications with Ansible and Docker Compose. + +It is designed to work with a shared Terraform module stored in a separate repository. This repo acts as the wrapper/root module and provides the service-specific inputs, deployment workflow, and app stack files. + +## What this repo does + +- Creates one or more Proxmox VMs from a reusable Terraform module. +- Uses cloud-init and QEMU guest agent in the template VM. +- Writes the VM IP address into Ansible inventory. +- Tags VMs with Terraform, Docker, service name, and IP metadata. +- Deploys an application stack with Ansible and Docker Compose. + +## Repository layout + +```text +terraform/ Terraform wrapper for the shared Proxmox module. +compose/ Docker Compose application files. +ansible/ Playbook and role used to configure and deploy the app. +.gitea/ Workflow files for apply and deploy. +``` + +## Requirements + +- Terraform installed locally or in CI. +- Access to the Proxmox API. +- A shared Proxmox VM module in a separate repository. +- Ansible installed for application deployment. +- Docker and Docker Compose available on the target VM. + +## How it works + +1. Terraform reads the instance configuration from `terraform.tfvars`. +2. Terraform calls the shared Proxmox module to create the VM. +3. Terraform outputs the VM IP address and tags. +4. The workflow writes the IP into `ansible/inventory/hosts.ini`. +5. Ansible connects to the VM and deploys the Compose stack. + +## Single instance + +Use `instance_mode = "single"` and define one `instance` object. + +Example: + +```hcl +instance_mode = "single" + +instance = { + service_name = "grafana" + vm_name = "grafana-01" + node_name = "pop" + app_port = 3000 + app_image = "grafana/grafana:latest" + vm_tags = ["monitoring"] +} +``` + +## Multi instance + +Use `instance_mode = "multi"` and define an `instances` map. + +Example: + +```hcl +instance_mode = "multi" + +instances = { + grafana = { + service_name = "grafana" + vm_name = "grafana-01" + node_name = "pop" + app_port = 3000 + app_image = "grafana/grafana:latest" + vm_tags = ["monitoring"] + } + + caddy = { + service_name = "caddy" + vm_name = "caddy-01" + node_name = "pop" + app_port = 80 + app_image = "caddy:latest" + vm_tags = ["proxy"] + } +} +``` + +## Defaults + +The template supports module defaults for items like: + +- datastore +- BIOS +- machine type +- SSH key +- bridge +- CPU, RAM, and disk size + +Override only what you need in the service repo. + +## Running locally + +```bash +cd terraform +cp single.tfvars.example terraform.tfvars +terraform init +terraform apply +``` + +After Terraform finishes, run: + +```bash +cd .. +ansible-playbook -i ansible/inventory/hosts.ini ansible/playbooks/deploy.yml +``` + +## Workflow trigger + +This template is intended to be run after the repository is created from it. + +The usual flow is: + +- create a new repo from this template, +- update the tfvars file, +- trigger the workflow manually with `workflow_dispatch`, +- or push the first commit to start deployment. + +## Notes + +- The shared Proxmox module should live in a separate repository. +- The service repo should contain the wrapper, not the internal Proxmox resource logic. +- VM IP tags are generated after Terraform apply. +- If the VM disk is resized manually in Proxmox, Terraform may see drift on the next run. + +## Example use case + +This template works well for service VMs such as: + +- Grafana +- Caddy +- Jellyfin +- Code Server +- Monitoring or proxy VMs + diff --git a/terraform/main.tf b/terraform/main.tf index 3fd5fbb..c2d633a 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -1,20 +1,25 @@ -module "vm" { - source = "git::https://your-git-server/infra/modules/proxmox_ubuntu_cloudinit.git//?ref=v1.0.0" - - vm_name = var.vm_name - node_name = var.node_name - node_datastore = var.node_datastore - bridge = var.bridge - vm_cpu = var.vm_cpu - vm_ram = var.vm_ram - vm_size = var.vm_size - vm_bios = var.vm_bios - vm_machine = var.vm_machine - vm_tags = local.all_tags - vm_user_sshkey = var.vm_user_sshkey -} - locals { - ip_tag = "ip-${replace(module.vm.vm_ipv4_address, ".", "-")}" - all_tags = concat(var.vm_tags, ["terraform", "docker"], [local.ip_tag]) + instance_map = var.instance_mode == "single" ? { + main = var.instance + } : var.instances +} + +module "vm" { + for_each = local.instance_map + source = "git::https://tea.charcarservices.uk/CC/TerraformModules.git//proxmox_ubuntu_cloudinit_template?ref=main" + + vm_name = each.value.vm_name + node_name = each.value.node_name + node_datastore = var.vm_defaults.node_datastore + bridge = var.vm_defaults.bridge + vm_cpu = coalesce(try(each.value.vm_cpu, null), var.vm_defaults.vm_cpu) + vm_ram = coalesce(try(each.value.vm_ram, null), var.vm_defaults.vm_ram) + vm_size = coalesce(try(each.value.vm_size, null), var.vm_defaults.vm_size) + vm_bios = var.vm_defaults.vm_bios + vm_machine = var.vm_defaults.vm_machine + vm_tags = concat( + try(each.value.vm_tags, []), + ["terraform", "docker", each.value.service_name] + ) + vm_user_sshkey = var.vm_defaults.vm_user_sshkey } diff --git a/terraform/multi.tfvars.example b/terraform/multi.tfvars.example new file mode 100644 index 0000000..a93000b --- /dev/null +++ b/terraform/multi.tfvars.example @@ -0,0 +1,21 @@ +instance_mode = "multi" + +instances = { + grafana = { + service_name = "grafana" + vm_name = "grafana-01" + node_name = "pop" + app_port = 3000 + app_image = "grafana/grafana:latest" + vm_tags = ["monitoring"] + } + + caddy = { + service_name = "caddy" + vm_name = "caddy-01" + node_name = "pop" + app_port = 80 + app_image = "caddy:latest" + vm_tags = ["proxy"] + } +} diff --git a/terraform/output.tf b/terraform/output.tf index a1c3517..79095bd 100644 --- a/terraform/output.tf +++ b/terraform/output.tf @@ -1,11 +1,20 @@ -output "vm_name" { - value = var.vm_name -} - -output "vm_ipv4_address" { - value = module.vm.vm_ipv4_address +output "vm_ipv4_addresses" { + value = { + for k, m in module.vm : k => m.vm_ipv4_address + } } output "vm_tags" { - value = local.all_tags + value = { + for k, m in module.vm : k => concat( + try(local.instance_map[k].vm_tags, []), + ["terraform", "docker", local.instance_map[k].service_name, "ip-${replace(m.vm_ipv4_address, ".", "-")}"] + ) + } +} + +output "service_names" { + value = { + for k, cfg in local.instance_map : k => cfg.service_name + } } diff --git a/terraform/single.tfvars.example b/terraform/single.tfvars.example new file mode 100644 index 0000000..2b46128 --- /dev/null +++ b/terraform/single.tfvars.example @@ -0,0 +1,21 @@ +instance_mode = "single" + +instance = { + service_name = "grafana" + vm_name = "grafana-01" + node_name = "pop" + app_port = 3000 + app_image = "grafana/grafana:latest" + vm_tags = ["monitoring"] +} + +vm_defaults = { + node_datastore = "hlst" + vm_bios = "ovmf" + vm_machine = "q35" + vm_user_sshkey = "ssh-ed25519 AAAA..." + bridge = "vmbr0" + vm_cpu = 2 + vm_ram = 4096 + vm_size = "20G" +} diff --git a/terraform/variables.tf b/terraform/variables.tf index 655b4e0..496ab55 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -1,13 +1,59 @@ -variable "vm_name" { type = string } -variable "node_name" { type = string } -variable "node_datastore" { type = string } -variable "bridge" { type = string, default = "vmbr0" } -variable "vm_cpu" { type = number, default = 2 } -variable "vm_ram" { type = number, default = 4096 } -variable "vm_size" { type = string, default = "20G" } -variable "vm_bios" { type = string, default = "ovmf" } -variable "vm_machine" { type = string, default = "q35" } -variable "vm_tags" { type = list(string), default = ["terraform"] } -variable "vm_user_sshkey" { type = string } -variable "app_name" { type = string, default = "myservice" } -variable "app_port" { type = number, default = 8080 } +variable "instance_mode" { + type = string + default = "single" +} + +variable "instance" { + description = "Single instance definition" + type = object({ + service_name = string + vm_name = string + node_name = string + vm_cpu = optional(number) + vm_ram = optional(number) + vm_size = optional(string) + app_port = number + app_image = string + vm_tags = optional(list(string)) + }) + default = null +} + +variable "instances" { + description = "Multiple instance definitions" + type = map(object({ + service_name = string + vm_name = string + node_name = string + vm_cpu = optional(number) + vm_ram = optional(number) + vm_size = optional(string) + app_port = number + app_image = string + vm_tags = optional(list(string)) + })) + default = {} +} + +variable "vm_defaults" { + type = object({ + node_datastore = string + vm_bios = string + vm_machine = string + vm_user_sshkey = string + bridge = string + vm_cpu = number + vm_ram = number + vm_size = string + }) + default = { + node_datastore = "hlst" + vm_bios = "ovmf" + vm_machine = "q35" + vm_user_sshkey = "" + bridge = "vmbr0" + vm_cpu = 1 + vm_ram = 2048 + vm_size = "20G" + } +}