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,168 @@
|
||||
---
|
||||
# LINSTOR cluster: controller on one node, satellites everywhere, storage pools,
|
||||
# resource group, and the PVE storage entry.
|
||||
#
|
||||
# All linstor CLI calls run ONCE against the controller — LINSTOR is itself a
|
||||
# cluster-wide database, so repeating them per-host would just race.
|
||||
|
||||
- name: Enable and start the satellite on every node
|
||||
ansible.builtin.systemd_service:
|
||||
name: linstor-satellite
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
- name: Enable and start the controller
|
||||
ansible.builtin.systemd_service:
|
||||
name: linstor-controller
|
||||
enabled: true
|
||||
state: started
|
||||
when: inventory_hostname == pve_linstor_controller
|
||||
|
||||
- name: Point the client at the controller
|
||||
# Without this, `linstor` talks to localhost and fails on the satellites.
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/linstor/linstor-client.conf
|
||||
mode: "0644"
|
||||
content: |
|
||||
# Managed by Ansible (role pve_linstor).
|
||||
[global]
|
||||
controllers=linstor://{{ hostvars[pve_linstor_controller].ansible_host }}
|
||||
|
||||
- name: Wait for the controller API
|
||||
ansible.builtin.command:
|
||||
cmd: linstor node list
|
||||
register: _lin_ready
|
||||
until: _lin_ready.rc == 0
|
||||
retries: 24
|
||||
delay: 5
|
||||
changed_when: false
|
||||
run_once: true
|
||||
delegate_to: "{{ pve_linstor_controller }}"
|
||||
|
||||
# ── nodes ─────────────────────────────────────────────────────────────────
|
||||
- name: Register each node with LINSTOR
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
linstor node create {{ item }} {{ hostvars[item].ansible_host }}
|
||||
--node-type {{ 'Combined' if item == pve_linstor_controller else 'Satellite' }}
|
||||
loop: "{{ groups['pve'] }}"
|
||||
register: _node_create
|
||||
changed_when: "'successfully' in (_node_create.stdout | default('') | lower)"
|
||||
failed_when:
|
||||
- _node_create.rc != 0
|
||||
- "'already exists' not in (_node_create.stdout | default('') + _node_create.stderr | default('')) | lower"
|
||||
run_once: true
|
||||
delegate_to: "{{ pve_linstor_controller }}"
|
||||
|
||||
- name: Wait for all nodes to come ONLINE
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
set -o pipefail
|
||||
linstor -m --output-version v1 node list \
|
||||
| python3 -c "import json,sys; print(all(n['connection_status']=='ONLINE' for n in json.load(sys.stdin)[0]))"
|
||||
executable: /bin/bash
|
||||
register: _nodes_online
|
||||
until: "'True' in _nodes_online.stdout"
|
||||
retries: 24
|
||||
delay: 5
|
||||
changed_when: false
|
||||
run_once: true
|
||||
delegate_to: "{{ pve_linstor_controller }}"
|
||||
|
||||
# ── storage pools ─────────────────────────────────────────────────────────
|
||||
- name: Create the storage pools
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
linstor storage-pool create lvm {{ item.0 }} {{ item.1.pool }} {{ item.1.vg }}
|
||||
loop: "{{ groups['pve'] | product([
|
||||
{'pool': pve_linstor_ssd_pool, 'vg': pve_linstor_ssd_vg},
|
||||
{'pool': pve_linstor_hdd_pool, 'vg': pve_linstor_hdd_vg}]) | list }}"
|
||||
register: _sp
|
||||
changed_when: "'successfully' in (_sp.stdout | default('') | lower)"
|
||||
failed_when:
|
||||
- _sp.rc != 0
|
||||
- "'already exists' not in (_sp.stdout | default('') + _sp.stderr | default('')) | lower"
|
||||
run_once: true
|
||||
delegate_to: "{{ pve_linstor_controller }}"
|
||||
|
||||
# ── resource groups ───────────────────────────────────────────────────────
|
||||
# One per storage pool — a resource group binds to exactly one pool, so the hdd
|
||||
# pool needs its own or that capacity is unusable from Proxmox.
|
||||
- name: Create the resource groups
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
linstor resource-group create {{ item.name }}
|
||||
--storage-pool {{ item.pool }}
|
||||
--place-count {{ item.place_count | default(pve_linstor_place_count) }}
|
||||
loop: "{{ pve_linstor_resource_groups }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }} -> {{ item.pool }}"
|
||||
register: _rg
|
||||
changed_when: "'successfully' in (_rg.stdout | default('') | lower)"
|
||||
failed_when:
|
||||
- _rg.rc != 0
|
||||
- "'already exists' not in (_rg.stdout | default('') + _rg.stderr | default('')) | lower"
|
||||
run_once: true
|
||||
delegate_to: "{{ pve_linstor_controller }}"
|
||||
|
||||
- name: Read existing volume-group definitions
|
||||
# `linstor volume-group create` is NOT idempotent: every call APPENDS another
|
||||
# volume number. Running the play three times left pve-rg with VlmNrs 0,1,2, so
|
||||
# PVE then failed every disk create with "has 3 Volume groups, but only 1 sizes
|
||||
# were provided". Check before creating.
|
||||
ansible.builtin.shell:
|
||||
# Count table rows. NOTE the leading char class: linstor draws its tables
|
||||
# with BOX-DRAWING pipes (U+2502), not ASCII '|', so a '^\\|' pattern never
|
||||
# matches and the count is always 0 -- which silently appends a NEW volume
|
||||
# group on every run.
|
||||
cmd: "linstor volume-group list {{ item.name }} 2>/dev/null | grep -cE '^.[[:space:]]*[0-9]+[[:space:]]' || true"
|
||||
loop: "{{ pve_linstor_resource_groups }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
register: _vg_count
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
run_once: true
|
||||
delegate_to: "{{ pve_linstor_controller }}"
|
||||
|
||||
- name: Create a volume group where none exists
|
||||
ansible.builtin.command:
|
||||
cmd: "linstor volume-group create {{ item.item.name }}"
|
||||
loop: "{{ _vg_count.results }}"
|
||||
loop_control:
|
||||
label: "{{ item.item.name }} (has {{ item.stdout | default('?') | trim }})"
|
||||
when: (item.stdout | default('0') | trim | int) == 0
|
||||
run_once: true
|
||||
delegate_to: "{{ pve_linstor_controller }}"
|
||||
|
||||
# ── PVE storage entries ───────────────────────────────────────────────────
|
||||
- name: Register each resource group as PVE storage
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
pvesm add drbd {{ item.name }}
|
||||
--content {{ item.content | default('images,rootdir') }}
|
||||
--controller {{ hostvars[pve_linstor_controller].ansible_host }}
|
||||
--resourcegroup {{ item.name }}
|
||||
loop: "{{ pve_linstor_resource_groups }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
register: _pvesm
|
||||
changed_when: _pvesm.rc == 0
|
||||
failed_when:
|
||||
- _pvesm.rc != 0
|
||||
- "'already defined' not in (_pvesm.stderr | default('') + _pvesm.stdout | default(''))"
|
||||
run_once: true
|
||||
delegate_to: "{{ pve_linstor_controller }}"
|
||||
|
||||
- name: Report LINSTOR state
|
||||
ansible.builtin.shell:
|
||||
cmd: "linstor storage-pool list; echo; linstor resource-group list; echo; pvesm status"
|
||||
register: _lin_state
|
||||
changed_when: false
|
||||
run_once: true
|
||||
delegate_to: "{{ pve_linstor_controller }}"
|
||||
|
||||
- name: Show it
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ _lin_state.stdout_lines }}"
|
||||
run_once: true
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
# LINSTOR/DRBD hyperconverged storage. Split into stages so the destructive part
|
||||
# (disk wiping) is separately gated and cannot run by accident.
|
||||
|
||||
- name: Install packages
|
||||
ansible.builtin.import_tasks: packages.yml
|
||||
tags: [linstor_pkgs]
|
||||
|
||||
- name: Prepare backing storage (LVM)
|
||||
ansible.builtin.import_tasks: storage.yml
|
||||
tags: [linstor_storage]
|
||||
|
||||
- name: Configure the LINSTOR cluster
|
||||
ansible.builtin.import_tasks: linstor.yml
|
||||
tags: [linstor_config]
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
# LINBIT's public repo. Free and needs no subscription — the subscription only
|
||||
# buys support and their prebuilt kernel modules; drbd-dkms builds locally.
|
||||
|
||||
- name: Install the LINBIT signing key
|
||||
ansible.builtin.get_url:
|
||||
url: "{{ pve_linstor_repo_key_url }}"
|
||||
dest: /tmp/linbit-pubkey.asc
|
||||
mode: "0644"
|
||||
retries: 3
|
||||
delay: 10
|
||||
|
||||
- name: Convert the key to a keyring
|
||||
ansible.builtin.shell:
|
||||
cmd: "gpg --dearmor < /tmp/linbit-pubkey.asc > {{ pve_linstor_keyring }}"
|
||||
creates: "{{ pve_linstor_keyring }}"
|
||||
|
||||
- name: Add the LINBIT repository
|
||||
ansible.builtin.deb822_repository:
|
||||
name: linbit
|
||||
types: [deb]
|
||||
uris: "{{ pve_linstor_repo_url }}"
|
||||
suites: "{{ pve_linstor_repo_suite }}"
|
||||
components: [drbd-9]
|
||||
signed_by: "{{ pve_linstor_keyring }}"
|
||||
enabled: true
|
||||
state: present
|
||||
register: _linbit_repo
|
||||
|
||||
- name: Update apt cache
|
||||
ansible.builtin.apt:
|
||||
update_cache: true
|
||||
register: _apt
|
||||
retries: 3
|
||||
delay: 15
|
||||
until: _apt is succeeded
|
||||
|
||||
- name: Install DRBD + LINSTOR packages
|
||||
# drbd-dkms COMPILES a kernel module against the running kernel, so the
|
||||
# headers must match. This is the step most likely to fail on a flaky uplink,
|
||||
# hence the retries.
|
||||
ansible.builtin.apt:
|
||||
name:
|
||||
- "pve-headers-{{ ansible_facts['kernel'] }}"
|
||||
- drbd-dkms
|
||||
- drbd-utils
|
||||
- linstor-controller
|
||||
- linstor-satellite
|
||||
- linstor-client
|
||||
- linstor-proxmox
|
||||
state: present
|
||||
register: _linstor_pkgs
|
||||
retries: 2
|
||||
delay: 30
|
||||
until: _linstor_pkgs is succeeded
|
||||
|
||||
- name: Load the DRBD module
|
||||
ansible.builtin.command:
|
||||
cmd: modprobe drbd
|
||||
changed_when: false
|
||||
|
||||
- name: Check which DRBD version is actually LOADED
|
||||
ansible.builtin.shell:
|
||||
cmd: "cat /proc/drbd 2>/dev/null | head -1"
|
||||
register: _drbd_ver
|
||||
changed_when: false
|
||||
|
||||
# The kernel ships an IN-TREE drbd 8.4. If anything loaded it before drbd-dkms
|
||||
# was installed, modprobe is a no-op and the running module stays 8.4 even
|
||||
# though dkms built 9.x correctly (modinfo will happily report 9.x from
|
||||
# .../updates/dkms/drbd.ko). LINSTOR needs 9. Swap it live when nothing is using
|
||||
# it; refuse and demand a reboot when something is.
|
||||
- name: Check the module refcount before swapping
|
||||
ansible.builtin.shell:
|
||||
cmd: "lsmod | awk '$1==\"drbd\" {print $3}' | head -1"
|
||||
register: _drbd_refs
|
||||
changed_when: false
|
||||
when: "'version: 9' not in _drbd_ver.stdout"
|
||||
|
||||
- name: Refuse to swap a DRBD module that is in use
|
||||
ansible.builtin.fail:
|
||||
msg: >-
|
||||
In-tree DRBD {{ _drbd_ver.stdout }} is loaded and IN USE
|
||||
(refcount {{ _drbd_refs.stdout | default('?') }}). Reboot
|
||||
{{ inventory_hostname }} to pick up the dkms-built DRBD 9.
|
||||
when:
|
||||
- "'version: 9' not in _drbd_ver.stdout"
|
||||
- (_drbd_refs.stdout | default('0') | trim | int) > 0
|
||||
|
||||
- name: Swap the in-tree DRBD 8.4 for the dkms-built DRBD 9
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
set -e
|
||||
modprobe -r drbd_transport_tcp 2>/dev/null || true
|
||||
modprobe -r drbd
|
||||
depmod -a
|
||||
modprobe drbd
|
||||
when:
|
||||
- "'version: 9' not in _drbd_ver.stdout"
|
||||
- (_drbd_refs.stdout | default('0') | trim | int) == 0
|
||||
|
||||
- name: Re-read the DRBD version after the swap
|
||||
ansible.builtin.shell:
|
||||
cmd: "cat /proc/drbd 2>/dev/null | head -1"
|
||||
register: _drbd_ver
|
||||
changed_when: false
|
||||
|
||||
- name: Assert DRBD 9
|
||||
ansible.builtin.assert:
|
||||
that: "'version: 9' in _drbd_ver.stdout"
|
||||
fail_msg: "Expected DRBD 9, got: {{ _drbd_ver.stdout }}"
|
||||
quiet: true
|
||||
@@ -0,0 +1,176 @@
|
||||
---
|
||||
# LVM volume groups that back the LINSTOR pools.
|
||||
#
|
||||
# DESTRUCTIVE. Everything that destroys data is gated behind
|
||||
# pve_linstor_wipe_hdd and guarded by an explicit "is this really free?" check.
|
||||
|
||||
# ── SSD pool: the space the installer left beyond the 60G `pve` VG ─────────
|
||||
- name: Find the SSD holding the pve VG
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
set -o pipefail
|
||||
pvs --noheadings -o pv_name --select 'vg_name=pve' | tr -d ' ' | head -1
|
||||
executable: /bin/bash
|
||||
register: _pve_pv
|
||||
changed_when: false
|
||||
check_mode: false # read-only discovery; must run in --check
|
||||
|
||||
- name: Derive the parent disk holding the pve PV
|
||||
# DO NOT use `lsblk -no PKNAME <pv>`: without --nodeps lsblk also lists the LVs
|
||||
# stacked on the partition, whose PKNAME is the PARTITION itself, so `head -1`
|
||||
# can return e.g. "nvme0n1p3" instead of "nvme0n1". That mistake made sgdisk
|
||||
# write a GPT INTO the LVM PV holding root on pve2/pve3 and destroyed their
|
||||
# `pve` VG metadata (2026-07-25). sysfs is unambiguous: the parent of
|
||||
# /sys/class/block/<part> IS the disk.
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
set -o pipefail
|
||||
PV="{{ _pve_pv.stdout | trim }}"
|
||||
PART="$(basename "$PV")"
|
||||
DISK="$(basename "$(readlink -f "/sys/class/block/${PART}/..")")"
|
||||
echo "/dev/${DISK}"
|
||||
executable: /bin/bash
|
||||
register: _ssd_disk
|
||||
changed_when: false
|
||||
check_mode: false # read-only discovery; must run in --check
|
||||
|
||||
- name: Refuse to proceed unless that really is a whole disk
|
||||
# Last line of defence: sgdisk against a partition is destructive, so verify
|
||||
# the derived device is TYPE=disk and not a partition before touching it.
|
||||
ansible.builtin.shell:
|
||||
cmd: "lsblk -dno TYPE {{ _ssd_disk.stdout | trim }}"
|
||||
register: _ssd_type
|
||||
changed_when: false
|
||||
check_mode: false # read-only discovery; must run in --check
|
||||
|
||||
- name: Assert it is a disk
|
||||
ansible.builtin.assert:
|
||||
that: "(_ssd_type.stdout | trim) == 'disk'"
|
||||
fail_msg: >-
|
||||
Derived SSD device {{ _ssd_disk.stdout | trim }} is TYPE
|
||||
'{{ _ssd_type.stdout | trim }}', not 'disk'. Refusing to partition it —
|
||||
running sgdisk against a partition destroys whatever is on it.
|
||||
quiet: true
|
||||
|
||||
- name: Check whether the SSD VG already exists
|
||||
ansible.builtin.command:
|
||||
cmd: "vgs {{ pve_linstor_ssd_vg }}"
|
||||
register: _ssd_vg
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Create a partition from the SSD's free tail
|
||||
# sgdisk -N uses ALL remaining free space for the next partition number.
|
||||
# Only touches unallocated space; the existing pve partitions are untouched.
|
||||
ansible.builtin.command:
|
||||
cmd: "sgdisk -N 0 -t 0:8e00 -c 0:linstor-ssd {{ _ssd_disk.stdout | trim }}"
|
||||
when: _ssd_vg.rc != 0
|
||||
register: _ssd_part
|
||||
|
||||
- name: Re-read the partition table
|
||||
# partx (util-linux) rather than partprobe: `parted` is NOT installed on a
|
||||
# stock PVE 9 node, so partprobe fails with "No such file or directory".
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
set -e
|
||||
partx -u "{{ _ssd_disk.stdout | trim }}"
|
||||
udevadm settle
|
||||
executable: /bin/bash
|
||||
when: _ssd_part is changed
|
||||
changed_when: true
|
||||
|
||||
- name: Find the new SSD partition
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
set -o pipefail
|
||||
lsblk -rno NAME,PARTLABEL "{{ _ssd_disk.stdout | trim }}" \
|
||||
| awk '$2=="linstor-ssd" {print "/dev/"$1}' | head -1
|
||||
executable: /bin/bash
|
||||
register: _ssd_partdev
|
||||
changed_when: false
|
||||
|
||||
- name: Create the SSD volume group
|
||||
ansible.builtin.command:
|
||||
cmd: "vgcreate {{ pve_linstor_ssd_vg }} {{ _ssd_partdev.stdout | trim }}"
|
||||
when:
|
||||
- _ssd_vg.rc != 0
|
||||
- (_ssd_partdev.stdout | trim) | length > 0
|
||||
|
||||
# ── HDD pool: the whole 1TB spindle ───────────────────────────────────────
|
||||
- name: Confirm the HDD target is a whole disk
|
||||
# Same guard as the SSD path. wipefs/vgcreate against a partition by mistake is
|
||||
# how the pve VG on pve2/pve3 got destroyed on 2026-07-25; assert the device
|
||||
# type rather than trusting the variable.
|
||||
ansible.builtin.shell:
|
||||
cmd: "lsblk -dno TYPE {{ pve_linstor_hdd_disk }}"
|
||||
register: _hdd_type
|
||||
changed_when: false
|
||||
check_mode: false # read-only discovery; must run in --check
|
||||
|
||||
- name: Assert the HDD target is a disk
|
||||
ansible.builtin.assert:
|
||||
that: "(_hdd_type.stdout | trim) == 'disk'"
|
||||
fail_msg: >-
|
||||
{{ pve_linstor_hdd_disk }} is TYPE '{{ _hdd_type.stdout | trim }}', not
|
||||
'disk'. Refusing to wipe it.
|
||||
quiet: true
|
||||
|
||||
- name: Inspect the HDD's current volume group
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
set -o pipefail
|
||||
pvs --noheadings -o vg_name {{ pve_linstor_hdd_disk }} 2>/dev/null | tr -d ' '
|
||||
executable: /bin/bash
|
||||
register: _hdd_vg
|
||||
changed_when: false
|
||||
check_mode: false # read-only discovery; must run in --check
|
||||
failed_when: false
|
||||
|
||||
- name: Refuse to wipe a HDD carrying anything other than a stale Ceph VG
|
||||
# The only VGs we expect here are the leftover ceph-<uuid> ones from the old
|
||||
# cluster. Anything else means this disk is not what we think it is.
|
||||
ansible.builtin.fail:
|
||||
msg: >-
|
||||
{{ pve_linstor_hdd_disk }} on {{ inventory_hostname }} holds VG
|
||||
'{{ _hdd_vg.stdout | trim }}', which is not a stale ceph-* VG.
|
||||
Refusing to wipe. Inspect it by hand.
|
||||
when:
|
||||
- (_hdd_vg.stdout | trim) | length > 0
|
||||
- not (_hdd_vg.stdout | trim).startswith('ceph-')
|
||||
- (_hdd_vg.stdout | trim) != pve_linstor_hdd_vg
|
||||
|
||||
- name: Remove the stale Ceph volume group
|
||||
ansible.builtin.command:
|
||||
cmd: "vgremove -f {{ _hdd_vg.stdout | trim }}"
|
||||
when:
|
||||
- pve_linstor_wipe_hdd | bool
|
||||
- (_hdd_vg.stdout | trim).startswith('ceph-')
|
||||
|
||||
- name: Wipe the HDD's signatures
|
||||
ansible.builtin.command:
|
||||
cmd: "wipefs -a {{ pve_linstor_hdd_disk }}"
|
||||
when:
|
||||
- pve_linstor_wipe_hdd | bool
|
||||
- (_hdd_vg.stdout | trim).startswith('ceph-')
|
||||
|
||||
- name: Check whether the HDD VG already exists
|
||||
ansible.builtin.command:
|
||||
cmd: "vgs {{ pve_linstor_hdd_vg }}"
|
||||
register: _hdd_vg_now
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Create the HDD volume group
|
||||
ansible.builtin.command:
|
||||
cmd: "vgcreate {{ pve_linstor_hdd_vg }} {{ pve_linstor_hdd_disk }}"
|
||||
when: _hdd_vg_now.rc != 0
|
||||
|
||||
- name: Report the resulting volume groups
|
||||
ansible.builtin.command:
|
||||
cmd: "vgs --noheadings -o vg_name,vg_size,vg_free"
|
||||
register: _vgs
|
||||
changed_when: false
|
||||
|
||||
- name: Show them
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ _vgs.stdout_lines }}"
|
||||
Reference in New Issue
Block a user