feat(ansible/zfs): support file-backed pool (+ single/mirror modes)
Add zfs_pool_mode: file|single|mirror. - file (default): loopback disk image at zfs_pool_file_path — real ZFS with no spare disk, ideal for a cost-optimized test VM; wipes nothing. - single/mirror: whole spare disk(s); mirror for production redundancy. Guard/probe now loops over zfs_pool_disks. Update group_vars, README (modes, prerequisites, safety), and CLAUDE.md current-focus note. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7a65e7f7c6
commit
7b15fa9bca
4 changed files with 83 additions and 30 deletions
|
|
@ -30,6 +30,14 @@ host, Docker Compose per site, no orchestrator.
|
|||
- 🚧 Phase 6 — observability (Prometheus/Grafana + cAdvisor/node_exporter/Traefik metrics, Loki/Promtail, Uptime-Kuma)
|
||||
- ⬜ Phases 7–8 — migration, customer panel
|
||||
|
||||
### Current focus (Aug 2026)
|
||||
Bringing up Phases 1–5 on a **cost-optimized test VM** (Ubuntu). ZFS is
|
||||
**simulated via a file-backed pool** (`zfs_pool_mode: file`, default) since the
|
||||
VM has no spare disk; the `zfs` role also supports `single`/`mirror` for a real
|
||||
host later. Existing box `korat` (Hetzner, in prod) is NOT the target — a fresh
|
||||
VM is. Next: finish the VM playbook run, then Phase 2 stacks on the VM (or
|
||||
author Phase 6 observability).
|
||||
|
||||
### Phase 4 follow-ups (not yet done)
|
||||
- `reconfigure` + `rotate-secret` commands; git-commit of `deployments/` on provision; php-fpm umask for two-way SFTP editing (see control-panel/README.md).
|
||||
|
||||
|
|
|
|||
|
|
@ -8,8 +8,11 @@ nftables firewall + container egress filter → SSH hardening → backup/DR
|
|||
## Prerequisites
|
||||
|
||||
1. An Ubuntu 24.04 VM you can SSH into as a **sudo-capable user**.
|
||||
2. A **dedicated second virtual disk** attached to the VM for the ZFS pool
|
||||
(e.g. `/dev/sdb` or `/dev/vdb`) — separate from the OS disk.
|
||||
2. Storage for the ZFS pool — pick one via `zfs_pool_mode`:
|
||||
- **`file`** (default): a loopback disk image, **no spare disk needed** —
|
||||
ideal for a cost-optimized VM. Real ZFS, backed by a file on the OS disk.
|
||||
- **`single` / `mirror`**: a dedicated spare disk (or two, mirrored) for a
|
||||
real host.
|
||||
3. Ansible on your workstation, installed **one way only** — prefer
|
||||
`pipx install --include-deps ansible` (isolated). Mixing apt's `ansible`
|
||||
with a pip `ansible` causes version-skew errors such as
|
||||
|
|
@ -23,14 +26,16 @@ cd platform-infra/ansible
|
|||
ansible-galaxy collection install -r requirements.yml
|
||||
|
||||
cp inventory/hosts.yml.example inventory/hosts.yml # edit host/user (git-ignored)
|
||||
$EDITOR group_vars/all.yml # set zfs_pool_disk, keys, etc.
|
||||
$EDITOR group_vars/all.yml # set zfs_pool_mode, keys, etc.
|
||||
```
|
||||
|
||||
Key variables in `group_vars/all.yml`:
|
||||
|
||||
| Variable | Meaning |
|
||||
|----------|---------|
|
||||
| `zfs_pool_disk` | The dedicated disk for the pool. **Its contents will be destroyed.** |
|
||||
| `zfs_pool_mode` | `file` (loopback image, default), `single`, or `mirror`. |
|
||||
| `zfs_pool_file_path` / `zfs_pool_file_size` | File-mode image location + size (sparse). |
|
||||
| `zfs_pool_disks` | Disk(s) for `single`/`mirror` mode. **Contents destroyed.** |
|
||||
| `zfs_pool_force` | Must be `true` to create a pool on a non-empty disk (safety gate). |
|
||||
| `admin_authorized_keys` | Public keys for the admin — required before disabling passwords. |
|
||||
| `ssh_disable_password_auth` | Leave `false` until key login is verified, then flip to `true`. |
|
||||
|
|
@ -76,10 +81,12 @@ Pre-flight without a host: `ansible-playbook --syntax-check site.yml`
|
|||
|
||||
## Safety notes
|
||||
|
||||
- **ZFS is destructive:** the play refuses to create a pool on a disk that
|
||||
already has a filesystem/partition unless `zfs_pool_force: true`. Double-check
|
||||
`zfs_pool_disk` points at the empty spare disk, not the OS disk. For production
|
||||
prefer a stable `/dev/disk/by-id/...` path over `/dev/sdb`.
|
||||
- **ZFS disk modes are destructive:** in `single`/`mirror` mode the play refuses
|
||||
to create a pool on a disk that already has a filesystem/partition unless
|
||||
`zfs_pool_force: true`. Double-check `zfs_pool_disks` point at empty spare
|
||||
disks, not the OS disk; prefer stable `/dev/disk/by-id/...` paths in
|
||||
production. **`file` mode (default) wipes nothing** — it creates a loopback
|
||||
image at `zfs_pool_file_path`.
|
||||
- **SSH lock-out:** the play asserts that `admin_authorized_keys` is non-empty
|
||||
before it will disable password authentication. Verify you can log in with your
|
||||
key **before** setting `ssh_disable_password_auth: true`.
|
||||
|
|
|
|||
|
|
@ -7,14 +7,24 @@
|
|||
host_timezone: "Europe/Brussels"
|
||||
|
||||
# --- ZFS --------------------------------------------------------------------
|
||||
# The pool is created on a DEDICATED second virtual disk. Attach a disk to the
|
||||
# VM first (e.g. /dev/sdb or /dev/vdb) and set it here.
|
||||
#
|
||||
# ⚠️ zpool create is DESTRUCTIVE to the target disk. The playbook refuses to
|
||||
# touch a disk that already contains a filesystem/partition unless you set
|
||||
# zfs_pool_force: true. For production prefer a stable /dev/disk/by-id/... path.
|
||||
# The pool can be backed three ways:
|
||||
# file - a loopback disk image (NO spare disk needed). Real ZFS features,
|
||||
# backed by a file on the OS disk. Ideal for a cheap test VM.
|
||||
# single - one whole spare disk/partition.
|
||||
# mirror - two disks/partitions (redundant); use for production.
|
||||
# Disk modes are DESTRUCTIVE to every listed device and refuse a non-empty
|
||||
# device unless zfs_pool_force: true. File mode wipes nothing.
|
||||
zfs_pool_name: tank
|
||||
zfs_pool_disk: /dev/sdb
|
||||
zfs_pool_mode: file # file | single | mirror
|
||||
|
||||
# file mode:
|
||||
zfs_pool_file_path: /var/lib/heleos/tank.img
|
||||
zfs_pool_file_size: "30G" # sparse; grows as data is written
|
||||
|
||||
# single / mirror modes (prefer stable /dev/disk/by-id/... paths in production):
|
||||
zfs_pool_disks:
|
||||
- /dev/sdb
|
||||
|
||||
zfs_pool_force: false
|
||||
zfs_compression: lz4 # lz4 (fast) or zstd (denser)
|
||||
|
||||
|
|
|
|||
|
|
@ -20,32 +20,60 @@
|
|||
ansible.builtin.set_fact:
|
||||
zpool_exists: "{{ zpool_check.rc == 0 }}"
|
||||
|
||||
- name: Probe the target disk for existing data
|
||||
ansible.builtin.command: "lsblk -nro FSTYPE,MOUNTPOINT,PARTTYPE {{ zfs_pool_disk }}"
|
||||
register: disk_probe
|
||||
# --- File mode: loopback disk image (no spare disk needed) ------------------
|
||||
- name: Ensure the backing-file directory exists (file mode)
|
||||
ansible.builtin.file:
|
||||
path: "{{ zfs_pool_file_path | dirname }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
when: not zpool_exists and zfs_pool_mode == 'file'
|
||||
|
||||
- name: Create the sparse backing file (file mode)
|
||||
ansible.builtin.command: "truncate -s {{ zfs_pool_file_size }} {{ zfs_pool_file_path }}"
|
||||
args:
|
||||
creates: "{{ zfs_pool_file_path }}"
|
||||
when: not zpool_exists and zfs_pool_mode == 'file'
|
||||
|
||||
- name: Create the ZFS pool on the backing file (file mode)
|
||||
ansible.builtin.command: >-
|
||||
zpool create -o ashift=12
|
||||
-O compression={{ zfs_compression }}
|
||||
-O atime=off -O xattr=sa -O acltype=posixacl
|
||||
-O mountpoint=/{{ zfs_pool_name }}
|
||||
{{ zfs_pool_name }} {{ zfs_pool_file_path }}
|
||||
when: not zpool_exists and zfs_pool_mode == 'file'
|
||||
|
||||
# --- Disk modes: single / mirror (guarded & destructive) --------------------
|
||||
- name: Probe the target disks for existing data (disk modes)
|
||||
ansible.builtin.command: "lsblk -nro FSTYPE,MOUNTPOINT,PARTTYPE {{ item }}"
|
||||
register: disk_probes
|
||||
changed_when: false
|
||||
when: not zpool_exists
|
||||
loop: "{{ zfs_pool_disks }}"
|
||||
when: not zpool_exists and zfs_pool_mode in ['single', 'mirror']
|
||||
|
||||
- name: Refuse to create a pool on a non-empty disk unless forced
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- (disk_probe.stdout | trim | length == 0) or zfs_pool_force
|
||||
- (item.stdout | trim | length == 0) or zfs_pool_force
|
||||
fail_msg: >-
|
||||
{{ zfs_pool_disk }} appears to already contain data
|
||||
({{ disk_probe.stdout | trim }}). Refusing to create the pool. Verify you
|
||||
picked the right disk, then set zfs_pool_force=true to override.
|
||||
when: not zpool_exists
|
||||
{{ item.item }} appears to already contain data ({{ item.stdout | trim }}).
|
||||
Refusing to create the pool. Verify the device, then set zfs_pool_force=true.
|
||||
loop: "{{ disk_probes.results | default([]) }}"
|
||||
loop_control:
|
||||
label: "{{ item.item | default('') }}"
|
||||
when: not zpool_exists and zfs_pool_mode in ['single', 'mirror']
|
||||
|
||||
- name: Create the ZFS pool on the dedicated disk
|
||||
- name: Create the ZFS pool on the dedicated disk(s) (disk modes)
|
||||
ansible.builtin.command: >-
|
||||
zpool create {{ '-f ' if zfs_pool_force else '' }}-o ashift=12
|
||||
-O compression={{ zfs_compression }}
|
||||
-O atime=off
|
||||
-O xattr=sa
|
||||
-O acltype=posixacl
|
||||
-O atime=off -O xattr=sa -O acltype=posixacl
|
||||
-O mountpoint=/{{ zfs_pool_name }}
|
||||
{{ zfs_pool_name }} {{ zfs_pool_disk }}
|
||||
when: not zpool_exists
|
||||
{{ zfs_pool_name }}
|
||||
{{ 'mirror ' if zfs_pool_mode == 'mirror' else '' }}{{ zfs_pool_disks | join(' ') }}
|
||||
when: not zpool_exists and zfs_pool_mode in ['single', 'mirror']
|
||||
|
||||
# --- Datasets ---------------------------------------------------------------
|
||||
- name: List existing datasets
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue