proxmox: add VM automation with OpenTofu and Ansible
Add automated workflow for building and deploying NixOS VMs on Proxmox including template2 host configuration, Ansible playbook for image building/deployment, and OpenTofu configuration for VM provisioning with cloud-init. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,2 +1,12 @@
|
||||
.direnv/
|
||||
result
|
||||
|
||||
# Terraform/OpenTofu
|
||||
terraform/.terraform/
|
||||
terraform/.terraform.lock.hcl
|
||||
terraform/*.tfstate
|
||||
terraform/*.tfstate.*
|
||||
terraform/terraform.tfvars
|
||||
terraform/*.auto.tfvars
|
||||
terraform/crash.log
|
||||
terraform/crash.*.log
|
||||
|
||||
60
CLAUDE.md
60
CLAUDE.md
@@ -44,6 +44,15 @@ nix develop
|
||||
|
||||
Secrets are handled by sops. Do not edit any `.sops.yaml` or any file within `secrets/`. Ask the user to modify if necessary.
|
||||
|
||||
### Git Commit Messages
|
||||
|
||||
Commit messages should follow the format: `topic: short description`
|
||||
|
||||
Examples:
|
||||
- `flake: add opentofu to devshell`
|
||||
- `template2: add proxmox image configuration`
|
||||
- `terraform: add VM deployment configuration`
|
||||
|
||||
## Architecture
|
||||
|
||||
### Directory Structure
|
||||
@@ -143,6 +152,57 @@ Configured in `/system/autoupgrade.nix`:
|
||||
- Auto-reboot after successful upgrade
|
||||
- Systemd service: `nixos-upgrade.service`
|
||||
|
||||
### Proxmox VM Provisioning with OpenTofu
|
||||
|
||||
The repository includes automated workflows for building Proxmox VM templates and deploying VMs using OpenTofu (Terraform).
|
||||
|
||||
#### Building and Deploying Templates
|
||||
|
||||
Template VMs are built from `hosts/template2` and deployed to Proxmox using Ansible:
|
||||
|
||||
```bash
|
||||
# Build NixOS image and deploy to Proxmox as template
|
||||
nix develop -c ansible-playbook -i playbooks/inventory.ini playbooks/build-and-deploy-template.yml
|
||||
```
|
||||
|
||||
This playbook:
|
||||
1. Builds the Proxmox image using `nixos-rebuild build-image --image-variant proxmox`
|
||||
2. Uploads the `.vma.zst` image to Proxmox at `/var/lib/vz/dump`
|
||||
3. Restores it as VM ID 9000
|
||||
4. Converts it to a template
|
||||
|
||||
Template configuration (`hosts/template2`):
|
||||
- Minimal base system with essential packages (age, vim, wget, git)
|
||||
- Cloud-init configured for NoCloud datasource (no EC2 metadata timeout)
|
||||
- DHCP networking on ens18
|
||||
- SSH key-based root login
|
||||
- `prepare-host.sh` script for cleaning machine-id, SSH keys, and regenerating age keys
|
||||
|
||||
#### Deploying VMs with OpenTofu
|
||||
|
||||
VMs are deployed from templates using OpenTofu in the `/terraform` directory:
|
||||
|
||||
```bash
|
||||
cd terraform
|
||||
tofu init # First time only
|
||||
tofu apply # Deploy VMs
|
||||
```
|
||||
|
||||
Configuration files:
|
||||
- `main.tf` - Proxmox provider configuration
|
||||
- `variables.tf` - Provider variables (API credentials)
|
||||
- `vm.tf` - VM resource definitions
|
||||
- `terraform.tfvars` - Actual credentials (gitignored)
|
||||
|
||||
Example VM deployment includes:
|
||||
- Clone from template VM
|
||||
- Cloud-init configuration (SSH keys, network, DNS)
|
||||
- Custom CPU/memory/disk sizing
|
||||
- VLAN tagging
|
||||
- QEMU guest agent
|
||||
|
||||
OpenTofu outputs the VM's IP address after deployment for easy SSH access.
|
||||
|
||||
### Adding a New Host
|
||||
|
||||
1. Create `/hosts/<hostname>/` directory
|
||||
|
||||
16
flake.nix
16
flake.nix
@@ -172,6 +172,22 @@
|
||||
sops-nix.nixosModules.sops
|
||||
];
|
||||
};
|
||||
template2 = nixpkgs.lib.nixosSystem {
|
||||
inherit system;
|
||||
specialArgs = {
|
||||
inherit inputs self sops-nix;
|
||||
};
|
||||
modules = [
|
||||
(
|
||||
{ config, pkgs, ... }:
|
||||
{
|
||||
nixpkgs.overlays = commonOverlays;
|
||||
}
|
||||
)
|
||||
./hosts/template2
|
||||
sops-nix.nixosModules.sops
|
||||
];
|
||||
};
|
||||
http-proxy = nixpkgs.lib.nixosSystem {
|
||||
inherit system;
|
||||
specialArgs = {
|
||||
|
||||
75
hosts/template2/configuration.nix
Normal file
75
hosts/template2/configuration.nix
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
|
||||
{
|
||||
imports = [
|
||||
./hardware-configuration.nix
|
||||
../../system/sshd.nix
|
||||
];
|
||||
|
||||
# Root user with no password but SSH key access for bootstrapping
|
||||
users.users.root = {
|
||||
hashedPassword = "";
|
||||
openssh.authorizedKeys.keys = [
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAwfb2jpKrBnCw28aevnH8HbE5YbcMXpdaVv2KmueDu6 torjus@gunter"
|
||||
];
|
||||
};
|
||||
|
||||
# Proxmox image-specific configuration
|
||||
# Configure storage to use local-zfs instead of local-lvm
|
||||
image.modules.proxmox = {
|
||||
proxmox.qemuConf.virtio0 = lib.mkForce "local-zfs:vm-9999-disk-0";
|
||||
proxmox.qemuConf.boot = lib.mkForce "order=virtio0";
|
||||
proxmox.cloudInit.defaultStorage = lib.mkForce "local-zfs";
|
||||
};
|
||||
|
||||
# Configure cloud-init to only use NoCloud datasource (no EC2 metadata service)
|
||||
services.cloud-init.settings = {
|
||||
datasource_list = [ "NoCloud" ];
|
||||
datasource = {
|
||||
NoCloud = {
|
||||
fs_label = "cidata";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
boot.loader.grub.enable = true;
|
||||
boot.loader.grub.device = "/dev/vda";
|
||||
networking.hostName = "nixos-template2";
|
||||
networking.domain = "home.2rjus.net";
|
||||
networking.useNetworkd = true;
|
||||
networking.useDHCP = false;
|
||||
services.resolved.enable = true;
|
||||
|
||||
systemd.network.enable = true;
|
||||
systemd.network.networks."ens18" = {
|
||||
matchConfig.Name = "ens18";
|
||||
networkConfig.DHCP = "ipv4";
|
||||
linkConfig.RequiredForOnline = "routable";
|
||||
};
|
||||
time.timeZone = "Europe/Oslo";
|
||||
|
||||
nix.settings.experimental-features = [
|
||||
"nix-command"
|
||||
"flakes"
|
||||
];
|
||||
nix.settings.tarball-ttl = 0;
|
||||
environment.systemPackages = with pkgs; [
|
||||
age
|
||||
vim
|
||||
wget
|
||||
git
|
||||
];
|
||||
|
||||
# Open ports in the firewall.
|
||||
# networking.firewall.allowedTCPPorts = [ ... ];
|
||||
# networking.firewall.allowedUDPPorts = [ ... ];
|
||||
# Or disable the firewall altogether.
|
||||
networking.firewall.enable = false;
|
||||
|
||||
system.stateVersion = "25.11";
|
||||
}
|
||||
9
hosts/template2/default.nix
Normal file
9
hosts/template2/default.nix
Normal file
@@ -0,0 +1,9 @@
|
||||
{ ... }:
|
||||
{
|
||||
imports = [
|
||||
./hardware-configuration.nix
|
||||
./configuration.nix
|
||||
./scripts.nix
|
||||
../../system/packages.nix
|
||||
];
|
||||
}
|
||||
36
hosts/template2/hardware-configuration.nix
Normal file
36
hosts/template2/hardware-configuration.nix
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
modulesPath,
|
||||
...
|
||||
}:
|
||||
|
||||
{
|
||||
imports = [
|
||||
(modulesPath + "/profiles/qemu-guest.nix")
|
||||
];
|
||||
boot.initrd.availableKernelModules = [
|
||||
"ata_piix"
|
||||
"uhci_hcd"
|
||||
"virtio_pci"
|
||||
"virtio_scsi"
|
||||
"sd_mod"
|
||||
"sr_mod"
|
||||
];
|
||||
boot.initrd.kernelModules = [ "dm-snapshot" ];
|
||||
boot.kernelModules = [
|
||||
"ptp_kvm"
|
||||
"virtio_rng" # Provides entropy from host for fast SSH key generation
|
||||
];
|
||||
boot.extraModulePackages = [ ];
|
||||
|
||||
# Enables DHCP on each ethernet and wireless interface. In case of scripted networking
|
||||
# (the default) this is the recommended approach. When using systemd-networkd it's
|
||||
# still possible to use this option, but it's recommended to use it in conjunction
|
||||
# with explicit per-interface declarations with `networking.interfaces.<interface>.useDHCP`.
|
||||
networking.useDHCP = lib.mkDefault true;
|
||||
# networking.interfaces.ens18.useDHCP = lib.mkDefault true;
|
||||
|
||||
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
|
||||
}
|
||||
33
hosts/template2/scripts.nix
Normal file
33
hosts/template2/scripts.nix
Normal file
@@ -0,0 +1,33 @@
|
||||
{ pkgs, ... }:
|
||||
let
|
||||
prepare-host-script = pkgs.writeShellScriptBin "prepare-host.sh"
|
||||
''
|
||||
echo "Removing machine-id"
|
||||
rm -f /etc/machine-id || true
|
||||
|
||||
echo "Removing SSH host keys"
|
||||
rm -f /etc/ssh/ssh_host_* || true
|
||||
|
||||
echo "Restarting SSH"
|
||||
systemctl restart sshd
|
||||
|
||||
echo "Removing temporary files"
|
||||
rm -rf /tmp/* || true
|
||||
|
||||
echo "Removing logs"
|
||||
journalctl --rotate || true
|
||||
journalctl --vacuum-time=1s || true
|
||||
|
||||
echo "Removing cache"
|
||||
rm -rf /var/cache/* || true
|
||||
|
||||
echo "Generate age key"
|
||||
rm -rf /var/lib/sops-nix || true
|
||||
mkdir -p /var/lib/sops-nix
|
||||
${pkgs.age}/bin/age-keygen -o /var/lib/sops-nix/key.txt
|
||||
'';
|
||||
in
|
||||
{
|
||||
environment.systemPackages = [ prepare-host-script ];
|
||||
users.motd = "Prepare host by running 'prepare-host.sh'.";
|
||||
}
|
||||
101
playbooks/build-and-deploy-template.yml
Normal file
101
playbooks/build-and-deploy-template.yml
Normal file
@@ -0,0 +1,101 @@
|
||||
---
|
||||
- name: Build and deploy NixOS Proxmox template
|
||||
hosts: localhost
|
||||
gather_facts: false
|
||||
|
||||
vars:
|
||||
template_name: "template2"
|
||||
nixos_config: "template2"
|
||||
proxmox_node: "pve1.home.2rjus.net" # Change to your Proxmox node name
|
||||
proxmox_host: "pve1.home.2rjus.net" # Change to your Proxmox host
|
||||
template_vmid: 9000 # Template VM ID
|
||||
storage: "local-zfs"
|
||||
|
||||
tasks:
|
||||
- name: Build NixOS image
|
||||
ansible.builtin.command:
|
||||
cmd: "nixos-rebuild build-image --image-variant proxmox --flake .#template2"
|
||||
chdir: "{{ playbook_dir }}/.."
|
||||
register: build_result
|
||||
changed_when: true
|
||||
|
||||
- name: Find built image file
|
||||
ansible.builtin.find:
|
||||
paths: "{{ playbook_dir}}/../result"
|
||||
patterns: "*.vma.zst"
|
||||
recurse: true
|
||||
register: image_files
|
||||
|
||||
- name: Fail if no image found
|
||||
ansible.builtin.fail:
|
||||
msg: "No QCOW2 image found in build output"
|
||||
when: image_files.matched == 0
|
||||
|
||||
- name: Set image path
|
||||
ansible.builtin.set_fact:
|
||||
image_path: "{{ image_files.files[0].path }}"
|
||||
|
||||
- name: Extract image filename
|
||||
ansible.builtin.set_fact:
|
||||
image_filename: "{{ image_path | basename }}"
|
||||
|
||||
- name: Display image info
|
||||
ansible.builtin.debug:
|
||||
msg: "Built image: {{ image_path }} ({{ image_filename }})"
|
||||
|
||||
- name: Deploy template to Proxmox
|
||||
hosts: proxmox
|
||||
gather_facts: false
|
||||
|
||||
vars:
|
||||
template_name: "template2"
|
||||
template_vmid: 9000
|
||||
storage: "local-zfs"
|
||||
|
||||
tasks:
|
||||
- name: Get image path and filename from localhost
|
||||
ansible.builtin.set_fact:
|
||||
image_path: "{{ hostvars['localhost']['image_path'] }}"
|
||||
image_filename: "{{ hostvars['localhost']['image_filename'] }}"
|
||||
|
||||
- name: Set destination path
|
||||
ansible.builtin.set_fact:
|
||||
image_dest: "/var/lib/vz/dump/{{ image_filename }}"
|
||||
|
||||
- name: Copy image to Proxmox
|
||||
ansible.builtin.copy:
|
||||
src: "{{ image_path }}"
|
||||
dest: "{{ image_dest }}"
|
||||
mode: '0644'
|
||||
|
||||
- name: Check if template VM already exists
|
||||
ansible.builtin.command:
|
||||
cmd: "qm status {{ template_vmid }}"
|
||||
register: vm_status
|
||||
failed_when: false
|
||||
changed_when: false
|
||||
|
||||
- name: Destroy existing template VM if it exists
|
||||
ansible.builtin.command:
|
||||
cmd: "qm destroy {{ template_vmid }} --purge"
|
||||
when: vm_status.rc == 0
|
||||
changed_when: true
|
||||
|
||||
- name: Import image
|
||||
ansible.builtin.command:
|
||||
cmd: "qmrestore {{ image_dest }} {{ template_vmid }}"
|
||||
changed_when: true
|
||||
|
||||
- name: Convert VM to template
|
||||
ansible.builtin.command:
|
||||
cmd: "qm template {{ template_vmid }}"
|
||||
changed_when: true
|
||||
|
||||
- name: Clean up uploaded image
|
||||
ansible.builtin.file:
|
||||
path: "{{ image_dest }}"
|
||||
state: absent
|
||||
|
||||
- name: Display success message
|
||||
ansible.builtin.debug:
|
||||
msg: "Template VM {{ template_vmid }} created successfully on {{ storage }}"
|
||||
5
playbooks/inventory.ini
Normal file
5
playbooks/inventory.ini
Normal file
@@ -0,0 +1,5 @@
|
||||
[proxmox]
|
||||
pve1.home.2rjus.net
|
||||
|
||||
[proxmox:vars]
|
||||
ansible_user=root
|
||||
37
terraform/README.md
Normal file
37
terraform/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# OpenTofu Configuration for Proxmox
|
||||
|
||||
This directory contains OpenTofu configuration for managing Proxmox VMs.
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Create a Proxmox API token:**
|
||||
- Log into Proxmox web UI
|
||||
- Go to Datacenter → Permissions → API Tokens
|
||||
- Click Add
|
||||
- User: `root@pam`, Token ID: `terraform`
|
||||
- Uncheck "Privilege Separation"
|
||||
- Save the token secret (shown only once)
|
||||
|
||||
2. **Configure credentials:**
|
||||
```bash
|
||||
cd terraform
|
||||
cp terraform.tfvars.example terraform.tfvars
|
||||
# Edit terraform.tfvars with your Proxmox details
|
||||
```
|
||||
|
||||
3. **Initialize OpenTofu:**
|
||||
```bash
|
||||
tofu init
|
||||
```
|
||||
|
||||
4. **Test connection:**
|
||||
```bash
|
||||
tofu plan
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
- `main.tf` - Provider configuration and test data source
|
||||
- `variables.tf` - Variable definitions
|
||||
- `terraform.tfvars.example` - Example credentials file
|
||||
- `terraform.tfvars` - Your actual credentials (gitignored)
|
||||
18
terraform/main.tf
Normal file
18
terraform/main.tf
Normal file
@@ -0,0 +1,18 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
required_providers {
|
||||
proxmox = {
|
||||
source = "telmate/proxmox"
|
||||
version = "3.0.2-rc07"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "proxmox" {
|
||||
pm_api_url = var.proxmox_api_url
|
||||
pm_api_token_id = var.proxmox_api_token_id
|
||||
pm_api_token_secret = var.proxmox_api_token_secret
|
||||
pm_tls_insecure = var.proxmox_tls_insecure
|
||||
}
|
||||
|
||||
# Provider configured - ready to add resources
|
||||
7
terraform/terraform.tfvars.example
Normal file
7
terraform/terraform.tfvars.example
Normal file
@@ -0,0 +1,7 @@
|
||||
# Copy this file to terraform.tfvars and fill in your values
|
||||
# terraform.tfvars is gitignored to keep credentials safe
|
||||
|
||||
proxmox_api_url = "https://your-proxmox-host.home.2rjus.net:8006/api2/json"
|
||||
proxmox_api_token_id = "root@pam!terraform"
|
||||
proxmox_api_token_secret = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
proxmox_tls_insecure = true
|
||||
22
terraform/variables.tf
Normal file
22
terraform/variables.tf
Normal file
@@ -0,0 +1,22 @@
|
||||
variable "proxmox_api_url" {
|
||||
description = "Proxmox API URL (e.g., https://proxmox.home.2rjus.net:8006/api2/json)"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "proxmox_api_token_id" {
|
||||
description = "Proxmox API Token ID (e.g., root@pam!terraform)"
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "proxmox_api_token_secret" {
|
||||
description = "Proxmox API Token Secret"
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "proxmox_tls_insecure" {
|
||||
description = "Skip TLS verification (set to true for self-signed certs)"
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
90
terraform/vm.tf
Normal file
90
terraform/vm.tf
Normal file
@@ -0,0 +1,90 @@
|
||||
# Example VM configuration - clone from template
|
||||
# Before using this, you need to:
|
||||
# 1. Upload the NixOS image to Proxmox
|
||||
# 2. Restore it as a template VM (e.g., ID 9000)
|
||||
# 3. Update the variables below
|
||||
|
||||
variable "target_node" {
|
||||
description = "Proxmox node to deploy to"
|
||||
type = string
|
||||
default = "pve1"
|
||||
}
|
||||
|
||||
variable "template_name" {
|
||||
description = "Template VM name to clone from"
|
||||
type = string
|
||||
default = "nixos-25.11.20260128.fa83fd8"
|
||||
}
|
||||
|
||||
variable "ssh_public_key" {
|
||||
description = "SSH public key for root user"
|
||||
type = string
|
||||
default = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAwfb2jpKrBnCw28aevnH8HbE5YbcMXpdaVv2KmueDu6 torjus@gunter"
|
||||
}
|
||||
|
||||
# Example test VM
|
||||
resource "proxmox_vm_qemu" "test_vm" {
|
||||
name = "nixos-test-tofu"
|
||||
target_node = var.target_node
|
||||
|
||||
# Clone from template
|
||||
clone = var.template_name
|
||||
|
||||
# Full clone (not linked)
|
||||
full_clone = true
|
||||
|
||||
# Boot configuration
|
||||
boot = "order=virtio0"
|
||||
scsihw = "virtio-scsi-single"
|
||||
|
||||
# VM settings
|
||||
cpu {
|
||||
cores = 2
|
||||
}
|
||||
memory = 2048
|
||||
|
||||
# Network
|
||||
network {
|
||||
id = 0
|
||||
model = "virtio"
|
||||
bridge = "vmbr0"
|
||||
tag = 13
|
||||
}
|
||||
|
||||
# Disk settings
|
||||
disks {
|
||||
virtio {
|
||||
virtio0 {
|
||||
disk {
|
||||
size = "20G"
|
||||
storage = "local-zfs"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Start on boot
|
||||
start_at_node_boot = true
|
||||
|
||||
# Agent
|
||||
agent = 1
|
||||
|
||||
# Cloud-init configuration
|
||||
ciuser = "root"
|
||||
sshkeys = var.ssh_public_key
|
||||
ipconfig0 = "ip=dhcp"
|
||||
nameserver = "10.69.13.5 10.69.13.6"
|
||||
searchdomain = "home.2rjus.net"
|
||||
|
||||
# Skip IPv6 since we don't use it
|
||||
skip_ipv6 = true
|
||||
|
||||
rng {
|
||||
source = "/dev/urandom"
|
||||
period = 1000
|
||||
}
|
||||
}
|
||||
|
||||
output "test_vm_ip" {
|
||||
value = proxmox_vm_qemu.test_vm.default_ipv4_address
|
||||
}
|
||||
Reference in New Issue
Block a user