diff --git a/.gitignore b/.gitignore index d53e06f..8068363 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index dd02cd7..8443a48 100644 --- a/CLAUDE.md +++ b/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//` directory diff --git a/flake.nix b/flake.nix index 779eabb..4db4e11 100644 --- a/flake.nix +++ b/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 = { diff --git a/hosts/template2/configuration.nix b/hosts/template2/configuration.nix new file mode 100644 index 0000000..7daad62 --- /dev/null +++ b/hosts/template2/configuration.nix @@ -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"; +} diff --git a/hosts/template2/default.nix b/hosts/template2/default.nix new file mode 100644 index 0000000..711cc51 --- /dev/null +++ b/hosts/template2/default.nix @@ -0,0 +1,9 @@ +{ ... }: +{ + imports = [ + ./hardware-configuration.nix + ./configuration.nix + ./scripts.nix + ../../system/packages.nix + ]; +} diff --git a/hosts/template2/hardware-configuration.nix b/hosts/template2/hardware-configuration.nix new file mode 100644 index 0000000..7086fe9 --- /dev/null +++ b/hosts/template2/hardware-configuration.nix @@ -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..useDHCP`. + networking.useDHCP = lib.mkDefault true; + # networking.interfaces.ens18.useDHCP = lib.mkDefault true; + + nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux"; +} diff --git a/hosts/template2/scripts.nix b/hosts/template2/scripts.nix new file mode 100644 index 0000000..9ee1e75 --- /dev/null +++ b/hosts/template2/scripts.nix @@ -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'."; +} diff --git a/playbooks/build-and-deploy-template.yml b/playbooks/build-and-deploy-template.yml new file mode 100644 index 0000000..fdf29bd --- /dev/null +++ b/playbooks/build-and-deploy-template.yml @@ -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 }}" diff --git a/playbooks/inventory.ini b/playbooks/inventory.ini new file mode 100644 index 0000000..d8c057d --- /dev/null +++ b/playbooks/inventory.ini @@ -0,0 +1,5 @@ +[proxmox] +pve1.home.2rjus.net + +[proxmox:vars] +ansible_user=root diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 0000000..be4ebb7 --- /dev/null +++ b/terraform/README.md @@ -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) diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000..2def9ff --- /dev/null +++ b/terraform/main.tf @@ -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 diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example new file mode 100644 index 0000000..fa6286e --- /dev/null +++ b/terraform/terraform.tfvars.example @@ -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 diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..a1ec455 --- /dev/null +++ b/terraform/variables.tf @@ -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 +} diff --git a/terraform/vm.tf b/terraform/vm.tf new file mode 100644 index 0000000..710c1fb --- /dev/null +++ b/terraform/vm.tf @@ -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 +}