diff --git a/CLAUDE.md b/CLAUDE.md index 95a1ad5..424d948 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). diff --git a/platform-infra/ansible/README.md b/platform-infra/ansible/README.md index 7b0b16e..6ad642d 100644 --- a/platform-infra/ansible/README.md +++ b/platform-infra/ansible/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`. diff --git a/platform-infra/ansible/group_vars/all.yml b/platform-infra/ansible/group_vars/all.yml index d41f899..1e0e065 100644 --- a/platform-infra/ansible/group_vars/all.yml +++ b/platform-infra/ansible/group_vars/all.yml @@ -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) diff --git a/platform-infra/ansible/roles/zfs/tasks/main.yml b/platform-infra/ansible/roles/zfs/tasks/main.yml index d6ee406..02e5c3a 100644 --- a/platform-infra/ansible/roles/zfs/tasks/main.yml +++ b/platform-infra/ansible/roles/zfs/tasks/main.yml @@ -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