Establish clean homelab infrastructure baseline
Reorganize the brownfield repository, remove retired and generated artifacts, harden ignore rules, and record the GitOps/IaC redesign.
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
---
|
||||
# Declarative-ish VM definitions. Uses `qm` over SSH rather than
|
||||
# community.general.proxmox_kvm: that module needs `proxmoxer` plus API-token
|
||||
# auth, while every other role here already drives pvesh/qm on the node. Staying
|
||||
# consistent beats adding a dependency for one role.
|
||||
#
|
||||
# Creation is guarded by `qm status <vmid>`, so re-runs never touch an existing
|
||||
# VM. Changing a definition here does NOT retro-fit a live VM — adjust it with
|
||||
# `qm set` deliberately, or destroy and recreate.
|
||||
|
||||
pve_vm_node: pve1 # where new VMs are created
|
||||
|
||||
# Anything a VM needs to be MANAGEABLE, installed by cloud-init on first boot —
|
||||
# before Ansible ever connects. qemu-guest-agent especially: without it PVE
|
||||
# cannot see guest IPs, do a clean shutdown, or freeze the fs for snapshots.
|
||||
pve_vm_base_packages:
|
||||
- qemu-guest-agent
|
||||
- openssh-server
|
||||
- python3 # Ansible needs an interpreter
|
||||
pve_vm_ssh_key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
|
||||
# PVE storage with `snippets` content enabled (see role pve_nfs).
|
||||
pve_vm_snippet_storage: laptop
|
||||
pve_vm_snippet_path: /mnt/pve/laptop/snippets
|
||||
|
||||
pve_vms:
|
||||
- vmid: 100
|
||||
name: vyos-rtr
|
||||
description: "VyOS router: OSPF peer to the NEC IX, gateway for the SDN VNets."
|
||||
cores: 2
|
||||
memory: 2048
|
||||
disk: "pve-rg:8"
|
||||
# Headless by design: UEFI + serial console, NO emulated VGA. VyOS's own
|
||||
# kernel cmdline carries console=ttyS0, and the installer was answered with
|
||||
# console=Serial, so `qm terminal 100` stays available out-of-band.
|
||||
bios: ovmf
|
||||
machine: q35
|
||||
vga: serial0
|
||||
serial: true
|
||||
nets:
|
||||
- { id: 0, bridge: vmbr0 } # LAN / OSPF
|
||||
- { id: 1, bridge: labnet }
|
||||
- { id: 2, bridge: retronet }
|
||||
onboot: 1
|
||||
iso: "laptop:iso/vyos-2025.11-generic-amd64.iso"
|
||||
|
||||
- vmid: 101
|
||||
name: retrolab
|
||||
description: "86Box host. RDP in when you want it. net1 = retronet (no IP), for the emulated NICs."
|
||||
# ⚠️ BELONGS ON pve2/pve3, NOT on the pve1 that `pve_vm_node` creates it on.
|
||||
# 86Box is a single-threaded recompiler and pve1 is an i3-6100U (2c/4t,
|
||||
# 2.3 GHz) that also carries vyos-rtr — emulation stuttered and the emulated
|
||||
# Sound Blaster glitched. The Ryzen 5 PRO 2400GE nodes (4c/8t, 3.2 GHz) do
|
||||
# not. Moved to pve3 on 2026-07-28. If this VM is ever recreated here,
|
||||
# migrate it back off pve1 afterwards; `cpu: host` (kept, it is most of the
|
||||
# win) makes that an OFFLINE migration, because pve1 is Intel and pve2/pve3
|
||||
# are AMD.
|
||||
cores: 4
|
||||
memory: 4096
|
||||
disk: "pve-rg:40"
|
||||
bios: ovmf
|
||||
machine: q35
|
||||
vga: virtio # a desktop, unlike the router
|
||||
serial: true
|
||||
# MACs are PINNED. Destroying and recreating a VM otherwise generates new
|
||||
# ones, so its DHCP reservation stops matching and its address moves -- which
|
||||
# is exactly what happened on the first rebuild.
|
||||
nets:
|
||||
- { id: 0, bridge: labnet, mac: "BC:24:11:68:A0:51" } # DHCP-reserved to 10.60.0.10
|
||||
- { id: 1, bridge: retronet, mac: "BC:24:11:9F:63:ED" } # no IP - handed to 86Box
|
||||
onboot: 0
|
||||
cloudinit: true
|
||||
ciuser: panxiao81
|
||||
# ⚠️ Do NOT use `qm importdisk` for this image. It reported success
|
||||
# ("transferred 3.5 GiB", exit 0) but left ZEROS where the partition table
|
||||
# belongs, producing a silently unbootable disk. Write it straight to the
|
||||
# DRBD device instead (see tasks/image.yml).
|
||||
image: "/mnt/pve/laptop/import/noble-server-cloudimg-amd64.img.raw"
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
# Write a base image onto a freshly created VM disk.
|
||||
#
|
||||
# ⚠️ Deliberately NOT `qm importdisk`. On this LINSTOR/DRBD storage it reported
|
||||
# success ("transferred 3.5 GiB", exit 0) but left ZEROS where the partition
|
||||
# table belongs, producing a silently unbootable disk. `qemu-img convert`
|
||||
# straight to the DRBD device works and takes ~40s. (Verified 2026-07-25.)
|
||||
#
|
||||
# Also note: the source is named *.img.raw but is actually QCOW2 — always let
|
||||
# qemu detect the format rather than trusting the extension.
|
||||
|
||||
- name: Resolve the DRBD device backing the VM disk
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
set -o pipefail
|
||||
# scsi0: <storage>:<volume>,opts... -> we want <volume>, i.e. field 3
|
||||
vol=$(qm config {{ item.vmid }} | sed -n 's/^scsi0: *[^:]*:\([^,]*\).*/\1/p')
|
||||
# PVE's volume is pm-xxxx_<vmid>, but the LINSTOR RESOURCE is just
|
||||
# pm-xxxx -- strip the _<vmid> suffix or nothing ever matches.
|
||||
res=${vol%_*}
|
||||
linstor resource list-volumes 2>/dev/null \
|
||||
| awk -v v="$res" -v n="$(hostname)" '$0 ~ v && $0 ~ n {print}' \
|
||||
| grep -oE '/dev/drbd[0-9]+' | head -1
|
||||
executable: /bin/bash
|
||||
register: _drbd_dev
|
||||
changed_when: false
|
||||
delegate_to: "{{ pve_vm_node }}"
|
||||
|
||||
- name: Fail if the device could not be resolved
|
||||
ansible.builtin.fail:
|
||||
msg: "Could not find the DRBD device for VM {{ item.vmid }} scsi0 — refusing to write an image blind."
|
||||
delegate_to: "{{ pve_vm_node }}"
|
||||
when: (_drbd_dev.stdout | trim) | length == 0
|
||||
|
||||
- name: Check whether the disk already has a partition table
|
||||
# Idempotency guard: never overwrite a disk that already looks installed.
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
python3 -c "
|
||||
import sys
|
||||
try:
|
||||
d=open('{{ _drbd_dev.stdout | trim }}','rb').read(512)
|
||||
sys.stdout.write('yes' if d[510:512]==b'\x55\xaa' else 'no')
|
||||
except Exception:
|
||||
sys.stdout.write('unreadable')"
|
||||
executable: /bin/bash
|
||||
register: _has_mbr
|
||||
changed_when: false
|
||||
delegate_to: "{{ pve_vm_node }}"
|
||||
|
||||
- name: Write the base image
|
||||
# DRBD must be Primary to accept writes; reading a Secondary returns ZEROS,
|
||||
# which is its own excellent way to misdiagnose an empty disk.
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
set -e
|
||||
drbdadm primary {{ _res }} 2>/dev/null || true
|
||||
qemu-img convert -O raw "{{ item.image }}" "{{ _dev }}"
|
||||
sync
|
||||
drbdadm secondary {{ _res }} 2>/dev/null || true
|
||||
executable: /bin/bash
|
||||
vars:
|
||||
_dev: "{{ _drbd_dev.stdout | trim }}"
|
||||
_res: "{{ _drbd_dev.stdout | trim | regex_replace('.*drbd', '') }}"
|
||||
when: _has_mbr.stdout is not search('yes')
|
||||
changed_when: true
|
||||
delegate_to: "{{ pve_vm_node }}"
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
# Create VMs that do not exist yet. Idempotent by existence check only — this
|
||||
# role does NOT reconcile the config of a live VM (see defaults for why).
|
||||
|
||||
- name: Check which VMs already exist
|
||||
ansible.builtin.command:
|
||||
cmd: "qm status {{ item.vmid }}"
|
||||
loop: "{{ pve_vms }}"
|
||||
loop_control:
|
||||
label: "{{ item.vmid }} {{ item.name }}"
|
||||
register: _vm_exists
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
run_once: true
|
||||
delegate_to: "{{ pve_vm_node }}"
|
||||
|
||||
- name: Render cloud-init user-data for VMs that use it
|
||||
# Written to the NFS `snippets` dir so every node can read it. Rendered even
|
||||
# for existing VMs so the file stays in sync with the definition — but see the
|
||||
# template header: cloud-init only APPLIES it on first boot.
|
||||
ansible.builtin.template:
|
||||
src: cloudinit-user.yml.j2
|
||||
dest: "{{ pve_vm_snippet_path }}/{{ item.name }}-user.yml"
|
||||
mode: "0644"
|
||||
loop: "{{ pve_vms | selectattr('cloudinit', 'defined') | selectattr('cloudinit') | list }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
vars:
|
||||
vm: "{{ item }}"
|
||||
run_once: true
|
||||
delegate_to: "{{ pve_vm_node }}"
|
||||
|
||||
|
||||
- name: Create missing VMs
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
qm create {{ item.item.vmid }}
|
||||
--name {{ item.item.name }}
|
||||
--description '{{ item.item.description }}'
|
||||
--machine {{ item.item.machine | default('q35') }}
|
||||
--bios {{ item.item.bios | default('ovmf') }}
|
||||
--memory {{ item.item.memory }} --cores {{ item.item.cores }} --cpu host
|
||||
--scsihw virtio-scsi-single
|
||||
--scsi0 {{ item.item.disk }},discard=on,ssd=1
|
||||
--efidisk0 {{ item.item.disk.split(':')[0] }}:1,efitype=4m,pre-enrolled-keys=0
|
||||
--vga {{ item.item.vga | default('virtio') }}
|
||||
{% if item.item.serial | default(false) %}--serial0 socket{% endif %}
|
||||
{% for n in item.item.nets %}--net{{ n.id }} virtio{% if n.mac is defined %}={{ n.mac }}{% endif %},bridge={{ n.bridge }} {% endfor %}
|
||||
{% if item.item.iso is defined %}--ide2 {{ item.item.iso }},media=cdrom --boot order=ide2;scsi0{% else %}--boot order=scsi0{% endif %}
|
||||
{% if item.item.cloudinit | default(false) %}--ide2 {{ item.item.disk.split(':')[0] }}:cloudinit --ipconfig0 ip=dhcp --ciuser {{ item.item.ciuser }}{% endif %}
|
||||
--agent enabled=1 --onboot {{ item.item.onboot | default(0) }} --ostype l26
|
||||
loop: "{{ _vm_exists.results }}"
|
||||
loop_control:
|
||||
label: "{{ item.item.vmid }} {{ item.item.name }}"
|
||||
when: item.rc != 0
|
||||
run_once: true
|
||||
delegate_to: "{{ pve_vm_node }}"
|
||||
|
||||
- name: Point those VMs at their user-data
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
qm set {{ item.vmid }} --cicustom user={{ pve_vm_snippet_storage }}:snippets/{{ item.name }}-user.yml
|
||||
loop: "{{ pve_vms | selectattr('cloudinit', 'defined') | selectattr('cloudinit') | list }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
register: _cicustom
|
||||
changed_when: "'update VM' in (_cicustom.stdout | default(''))"
|
||||
run_once: true
|
||||
delegate_to: "{{ pve_vm_node }}"
|
||||
|
||||
- name: Write base images onto newly created disks
|
||||
ansible.builtin.include_tasks: image.yml
|
||||
# ALL image-backed VMs, not just newly created ones: image.yml decides by
|
||||
# checking for an MBR on the disk. Gating on "was just created" meant a failed
|
||||
# image write could never be repaired by re-running.
|
||||
loop: "{{ pve_vms | selectattr('image', 'defined') | list }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
run_once: true
|
||||
|
||||
- name: Report
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
{{ _vm_exists.results
|
||||
| map(attribute='item')
|
||||
| zip(_vm_exists.results | map(attribute='rc'))
|
||||
| map('join', ' rc=') | list }}
|
||||
run_once: true
|
||||
@@ -0,0 +1,45 @@
|
||||
#cloud-config
|
||||
# {{ ansible_managed }}
|
||||
# Full cloud-init user-data for {{ vm.name }} (vmid {{ vm.vmid }}).
|
||||
#
|
||||
# PVE's built-in cloud-init options only cover user/password/keys/network. That
|
||||
# left qemu-guest-agent uninstalled and bootstrap state fragile. Anything a VM
|
||||
# needs to be MANAGEABLE should happen here, on first boot, before Ansible ever
|
||||
# connects.
|
||||
#
|
||||
# NOTE: cloud-init runs `packages`/`runcmd` ONCE per instance. Adding entries
|
||||
# here does not retro-fit an already-provisioned VM — that needs Ansible (or a
|
||||
# `cloud-init clean` + reboot).
|
||||
|
||||
hostname: {{ vm.name }}
|
||||
manage_etc_hosts: true
|
||||
|
||||
users:
|
||||
- name: {{ vm.ciuser }}
|
||||
groups: [adm, sudo]
|
||||
shell: /bin/bash
|
||||
sudo: "ALL=(ALL) NOPASSWD:ALL"
|
||||
lock_passwd: false
|
||||
ssh_authorized_keys:
|
||||
- "{{ pve_vm_ssh_key }}"
|
||||
|
||||
package_update: true
|
||||
# Deliberately NO dist-upgrade at first boot. cloud-init has no retry, and on
|
||||
# this WAN `apt-get dist-upgrade` exits 100 the moment the link blips — which
|
||||
# aborts the whole package module, so qemu-guest-agent never installs and the VM
|
||||
# comes up unmanageable. Keep first boot minimal; Ansible does the rest WITH
|
||||
# retries. (Observed 2026-07-25.)
|
||||
package_upgrade: false
|
||||
packages:
|
||||
{% for p in vm.cloudinit_packages | default(pve_vm_base_packages) %}
|
||||
- {{ p }}
|
||||
{% endfor %}
|
||||
|
||||
runcmd:
|
||||
# qemu-guest-agent gives PVE the guest's IPs, clean shutdown, and fsfreeze for
|
||||
# snapshots. Without it `qm agent` fails and PVE cannot see inside the guest.
|
||||
- [systemctl, enable, --now, qemu-guest-agent]
|
||||
- [systemctl, enable, --now, ssh]
|
||||
|
||||
# Keep the host keys stable across reboots so known_hosts does not churn.
|
||||
ssh_deletekeys: false
|
||||
Reference in New Issue
Block a user