Establish clean homelab infrastructure baseline
lint / yaml (push) Has been cancelled
lint / ansible (push) Has been cancelled
lint / terraform (push) Has been cancelled

Reorganize the brownfield repository, remove retired and generated artifacts, harden ignore rules, and record the GitOps/IaC redesign.
This commit is contained in:
2026-09-09 16:47:20 +00:00
commit 88a02ababa
418 changed files with 50579 additions and 0 deletions
@@ -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