Files
felhom.eu/documentation/architecture/16-storage-physical-identity.md
T
admin 920c1aaf84
gates / gates (push) Successful in 6m39s
storage identity build B: live proof, R-1068/R-1079/R-1082/R-1084 closed, R-1093/R-1094 opened, D15, golden 0.318.0 record, backup page
A fresh one-disk install (512 GB VM, installer 1.33.0, agent 0.158.1 + golden 0.318.0 vouched): data volume 100 GiB, data
area 213 GiB registered as the default drive with no press, Jellyfin with no extra step, nothing of the area in a whole-box
copy; partitions formatted and used; the honest backup offer on a VM; /mnt/sys_drive never offered (no box had it).
16 §13 build B status; capability map D15; poster facts (drawing needs a refresh, STATUS); the backup page HU+EN now that
it is true; REPORT-storage-identity-buildB-2026-10-16.md.

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-16 10:07:13 +02:00

241 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 16 — Storage that knows its physical disks
> **What this is:** the design that lets Felhom know, for every storage place, which physical disk(s) it sits on — and
> use that to make backup tiers honest, to give one-disk boxes a place for household data, and to allow partitions.
> Operator ruling 2026-10-15: „Felhom must support drives split into parts; the agent and the controller must know
> whether two storage places sit on the same physical disk." Triggered by the OMV test (`audits/omv-vm-2026-10-15/`,
> R-1068). Spike: `audits/storage-identity-2026-10-15/` (Part B there). Marks as in `01`: **[FACT]** read in a named
> file or measured; **[DESIGN]** a proposal. **Status: ACCEPTED 2026-10-15** — the operator ruled D1–D6 as recommended (§12). **Build A shipped 2026-10-15**
> (§3, §4, §5's D4 sentence, §8, §10); **build B shipped 2026-10-16** (§5's internal place, §6, §7, §9) — what is built,
> and where the build differs from this text, is §13.
> Paths: A = `felhom-agent/`, C = `felhom-controller/controller/`, E = `felhom.eu/`.
## 1. Today, in one table (Part A)
| Place | Found by | Identified by | Mounted | What lives there | The backup page says |
|---|---|---|---|---|---|
| Host root / PVE `local` | PVE storage `local` [FACT] A/internal/storage/observe.go:424; never a candidate [FACT] A/internal/storage/role.go:53-79 | `path:` / `store:local` [FACT] A/internal/storage/durableid.go:54-67; role system | host `/`, `/var/lib/vz` | the **local whole-guest copies** (vzdump, retention 3) [FACT] E/scripts/felhom-host-install.sh:730-734; `07` §6.1 | `backup.target.degraded` „…ugyanazon a lemezen van, mint a rendszer…" [FACT] C/internal/web/backup_target_offer.go:78-100 |
| Guest `mp0` `/var/lib/felhom` with folders `docker/` and `sys_drive/` (bound to `/var/lib/docker`, `/mnt/sys_drive`) | config default `system_data_path=/mnt/sys_drive` [FACT] C/internal/config/config.go:166 | path only; „same disk" = `st_dev` [FACT] C/internal/system/mounts_linux.go:225-245 | thin LV in `local-lvm`, `backup=1` [FACT] A/configs/build-golden.sh:105, :221-225; measured on all 3 boxes | app volumes; Tier-1 of apps with no drive [FACT] C/internal/backup/backup.go:429-438; Tier-2 state-only fallback [FACT] C/internal/backup/tier2.go:157-178; **inside every whole-guest copy** | „Csak egy másolat készül (nincs második meghajtó)…" — a Go constant, not i18n [FACT] backup.go:359-361, :1387-1410 |
| Enrolled drive (`/mnt/<name>` on the host → `/mnt/felhom-drives/<name>`) | `GET /disks/candidates`: **whole disks only** [FACT] A/internal/storage/candidates.go:33-53 | mount: `uuid:<fs-uuid>`; wipe: `byid:` wwn > nvme-eui > … [FACT] A/internal/storage/durable_device.go:32-51 | systemd unit by fs-UUID through `FELHOM_MOUNT` [FACT] A/internal/storage/mountunit.go:50-65; host bind into `mp8` | `HDD_PATH` apps, media binds [FACT] C/internal/stacks/media_library.go:24-33; Tier-1/Tier-2; optionally the whole-guest target `felhom-backup`; **not in vzdump** (`mp8` is a host bind) [FACT] `07`:477-480 | Tier-2 badges; „Nincs 2. (off-drive) másolat" [FACT] C/internal/i18n/locales/hu.json:303 |
| Network share | customer input [FACT] A/internal/localapi/netstorage.go:36-40 | none („no durable-id, no SMART") [FACT] netstorage.go:15-19 | `.automount` under `/mnt/felhom-drives/<name>` [FACT] A/internal/storage/netmount.go:18-31 | browse only; never media, never Tier-2 [FACT] media_library.go:83; tier2.go:141-143 | `err.backup.halozati_tarolo_nem_lehet_a_2` [FACT] tier2.go:37-40 |
| USB disk | same as an enrolled drive; transport never decides [FACT] C/internal/settings/settings.go:609-614 | same; the bridge may pass the real identity (N100: yes) [FACT] spike case 4a | same | same | same; SMART may be missing → „Nincs adat" |
**[FACT] What compares physical disks today:** the controller compares only paths and `st_dev` (its own comment:
„two partitions on one physical disk look "different" here", mounts_linux.go:240-242). The agent has a whole-disk
compare (`SameWholeDisk`, role.go:207-216) but uses it only for eject/decommission roles. **[FACT] What knows about
partitions:** the agent accepts `/dev/sdb1` for inspect and mkfs (A/internal/storage/validate.go:28-32) but judges the
whole disk (claim.go:144-163), lists only whole disks, and calls a disk with a partition table or any filesystem
„data-bearing" (candidates.go:66-85). Measured: an empty partition next to another is never offered (spike).
## 2. The spike in five lines (Part B)
**[FACT]** A read-only walk (partition → parent; dm/LVM → slaves; loop → backing file → its disk) named the right
physical disk in every case on real devices: one-disk N100 and demo-hp (every system place → the one SSD), two disks
(system vs enrolled drive), two partitions of one disk (both → the same disk), a USB bridge that passes the identity.
**Inside a VM (Tester 1) it can only say „unknown"**: virtual disks on the same host disk and on a different host disk
look identical (`QEMU HARDDISK`, serial = the slot name `drive-scsiN`). Not measured: a USB box that hides the serial
(R-1077). Seen: a cheap SSD with a vendor-less WWN (OUI 000000, R-1076). Full table: `audits/storage-identity-2026-10-15/README.md`.
## 3. The model
**[DESIGN]** A **storage place** is anything Felhom stores on: the system data (`mp0`), the new internal data place
(§5), an enrolled whole disk, an enrolled partition, a network share. Each place carries:
| Field | Meaning |
|---|---|
| `kind` | `system` · `internal` · `disk` · `partition` · `network` |
| `physical_disks` | the ids of the whole physical disks under it, e.g. `["serial:ATA:SanDisk_X600:182195804614"]` |
| `identity` | `proven` — every disk under it has a stable id the agent trusts; `unknown` — otherwise, with a reason code: `vm`, `no_serial`, `weak_wwn`, `usb_bridge`, `network` |
**Disk id rule [DESIGN]:** `serial:<bus>:<model>:<serial>` from udev (`ID_BUS`, `ID_MODEL`, `ID_SERIAL_SHORT`); NVMe
`eui`/`nguid` when present. A WWN is used only when its NAA OUI is not `000000` (spike case 4c). Model + serial, not WWN,
is the primary key: two disks are „the same" only when the key is equal.
**Unknown rule [DESIGN]:** `systemd-detect-virt` ≠ `none` → every disk is `unknown/vm`. A disk with no serial → `no_serial`.
A USB disk whose serial is the bridge's (all the same digit, or equal to a known bridge list) → `usb_bridge`. A network
share → `network`, `physical_disks: []`.
**How it is computed [DESIGN]:** by the **agent**, as root, from `/sys` and udev only (no controller input): the walk
the spike used, built on what exists — `walkSlaves`/`physicalDisksOf` (role.go:81-131), `parentDisk`
(A/internal/storage/hostread.go:183-201) — plus the loop step and the mapping guest path → host mount (`/mnt/felhom-drives/<name>` →
`/mnt/<name>` → device; `mp0` → its LV).
**When [DESIGN]:** at agent start, on every disk hot-plug event the agent already reacts to, on enrol/attach, and
**again right before each Tier-2 run** (a disk swapped in the same bay must not inherit the old verdict).
**Reported [DESIGN]:** `GET /disks` gains `physical_disks` + `identity` per place; the hub report's `storage_targets`
gains the same two fields (A/internal/hub/report.go:379-438), so the hub sees the agent's verdict directly. The controller
stores nothing of it; it asks.
## 4. Backup tiers
**[DESIGN] The rule:** a Tier-2 copy **counts as a second copy** only when the source place and the target place share
**no** physical disk **and** both identities are `proven`. The decision is the agent's (`POST /disks/same-disk` with the
two guest paths → `{different: bool, proven: bool, reason}`), replacing `sameDevice`/`st_dev` in `tier2.go:83-179` and
`hasOffDriveTarget` (backup.go:1390-1402).
| Situation | Tier-2 | The one-copy notice | Household sentence (HU / EN) |
|---|---|---|---|
| Different disks, both proven | made, counted | hidden | — |
| Same disk (two partitions, internal place + system) | per decision D1 | shown | „Csak egy helyi másolat készül: nincs második lemez, vagy ugyanazon a lemezen van, mint az adatok. Ez a véletlen törlés ellen véd, lemezhiba ellen nem. Lemezhiba ellen egy másik lemez vagy a távoli mentés véd." / „Only one local copy is made: there is no second disk, or it is on the same disk as the data. This protects against mistakes, not against a disk failure. A different disk or the off-site copy protects against a disk failure." |
| Unknown (VM, hidden serial) | per decision D2 | shown | „A Felhom nem tudja ellenőrizni, hogy a második meghajtó valóban másik lemez-e (virtuális gépben és egyes USB-házakban ez nem látszik), ezért nem számolja második másolatnak." / „Felhom cannot check that the second drive is really a different disk (inside a virtual machine and behind some USB enclosures this is not visible), so it does not count it as a second copy." |
| Network share | never (unchanged) | shown | unchanged |
The notice moves from a Go constant to i18n, in the informal voice (closes R-1072's half). Off-site (Tier-3, PBS) and
the local whole-guest copy are **unchanged**. **[FACT]** Today Tester 1 counts Tier-2 copies between virtual disks that
all sit on one host NVMe (R-1074).
## 5. One-disk boxes — the internal data place
**[FACT]** The install button for a data-folder app finds no drive on a one-disk box and refuses with the raw field
name (R-1068). **[FACT]** The only place on that disk today, `sys_drive`, is a folder of `mp0`, which is `backup=1`:
**anything put there rides in every local and off-site whole-guest copy** (`07` §6.1, §7.5.1). Films there would
overflow the local copy (R-548) and travel to ep0.
**[DESIGN]** The installer (new installs) creates a second thin LV on `local-lvm`, the **internal data place**
(„Belső tárhely"), formats it, and the agent mounts it **on the host** by fs-UUID at `/mnt/belso`, bound to
`/mnt/felhom-drives/belso` — exactly like an enrolled drive. So:
- it is **not** in vzdump (a host mount, like every drive) and not in PBS;
- the controller registers it as the default drive (`kind: internal`), so media and `HDD_PATH` apps install with no
extra step — **no sentence at all**; the media binds cover it like any drive;
- its `physical_disks` = the system disk, so a Tier-2 copy between it and `sys_drive` never counts (§4);
- the agent's format/wipe guard keeps refusing it as a **system** disk (role system, claim.go:72) — only the installer
makes or removes it.
On a one-disk box **without** an internal place (old boxes, §9), the install of a data-folder app answers in words:
„Ehhez az alkalmazáshoz egy adattároló kell. A Meghajtók oldalon adhatsz hozzá egyet." — never the field name.
Registering `sys_drive` as a drive (the „way through" R-1068 guessed) is **rejected** (§10).
## 6. Partitions
**[DESIGN]** A place may be one partition. The agent lists, under each whole disk, its partitions as separate
candidates with their own state:
| Partition state | Offered as | Who may |
|---|---|---|
| empty (no signature, no children) | „format this part" | the household, self-serve (it holds nothing) |
| a filesystem with no files (agent mounts read-only, counts entries) | „use this part" or „format this part" | the household |
| a filesystem with files | „use this part" (attach) only; format = the customer-confirmed data-bearing path, bound to the partition's durable id | the household with confirmation (today's user-data rule, A/internal/localapi/disks.go:912-946) |
| LVM / RAID / LUKS / swap / mounted elsewhere / on the system disk | not offered | nobody (claim.go:72-103, unchanged) |
The classifier judges **the partition** (its own signature, children, mounts) and still refuses on the whole disk's
system role and on any sibling that is foreign-mounted or a PV — those siblings make the **disk** unsafe, not the
partition. A partition's durable id = `<disk id>:part<N>` + its fs-UUID; the wipe re-resolve (wipe_reresolve.go:53-93)
binds both. **Creating or deleting partitions: never from the dashboard in this round — the installer only** (D5).
## 7. The installer
**[FACT]** Today `step_grows` gives the guest's one data volume (`mp0`) 70 / 130 / 250 GiB by the thin pool's free space
(<150 / ≥150 / ≥300 GiB), and nothing above 250 (E/scripts/felhom-host-install.sh:2261-2275). Measured pools: 128 GB
disk → 54 GiB, 200 GB → 113 GiB, 512 GB → 349 GiB (PVE default layout; root capped at 96 GiB).
**[DESIGN] The rule:** `mp0` = 70 GiB (pool < 300), 130 (300–800), 250 (≥ 800). Reserve = **max(16 GiB, 10 % of the
pool)**, kept free for thin snapshots during a whole-guest copy, image pulls during updates, and the restore test (which
sizes itself, A19). Internal place = pool − 32 (rootfs) − `mp0` − reserve, made only when ≥ 20 GiB. The local
whole-guest copies stay on host `local` (root), which the data place does not touch — R-548 is not made worse: `mp0`
does not grow.
| Disk | Thin pool (GiB) | Today: data volume / unused | Proposed `mp0` | Reserve | **Internal place** |
|---|---|---|---|---|---|
| 256 GB | ≈ 140 (estimate) | 70 / ≈ 38 | 70 | 16 | **≈ 22** (settings and small apps, not a film library) |
| 512 GB | 349 (N100, measured) | 250 / ≈ 67 | 130 | 35 | **≈ 152** |
| 1 TB | ≈ 800 (estimate) | 250 / ≈ 518 | 250 | 80 | **≈ 438** |
| 2 TB | ≈ 1,720 (estimate) | 250 / ≈ 1,438 | 250 | 172 | **≈ 1,266** |
**RULED 2026-10-16 (operator, after the build B space check `audits/storage-identity-buildB-2026-10-15/SPACE-CHECK.md`):
these numbers replace the table above.** `mp0` = **50 GiB** (pool < 300), **100** (300–800), **130** (≥ 800); reserve =
**max(16 GiB, 10 %), at most 48 GiB**; internal place = pool − 32 − `mp0` − reserve, only when ≥ 20 GiB; nothing promised
beyond pool − reserve. Result: ≈ 42 / 182 / 590 / 1,510 GiB for 256 GB / 512 GB / 1 TB / 2 TB. The host root is left as
the Proxmox installer makes it (SPACE-CHECK §3).
## 8. Virtual machines
**[FACT]** Inside a VM the agent cannot tell virtual disks apart by host disk (spike cases 5–6). **[DESIGN]** Every place
on a VM box is `identity: unknown/vm`; Tier-2 there never counts (D2), and the page says why. The OMV guide tells the
household: a second disk given to the Felhom VM must be **a different physical disk on the host**, passed whole, on bus
`sata` (R-1069); otherwise it adds space, not safety. Disk health is OMV's job there (R-1073).
## 9. Existing boxes
**[DESIGN]** The agent computes identities on its next update — no migration, no change to what is stored. A box with two
real disks keeps every Tier-2 copy and every sentence (both proven, different). A box where two places share a disk
(none known in the field; Tester 1 in its VM) shows the honest notice. One-disk boxes get no internal place by
themselves; adding one later is a signed operator job (an LV in free thin space), not a household button. Hub: the two
new fields appear in reports; nothing is backfilled.
## 10. Threat note
**[DESIGN]** „Different disks" is agent-computed evidence, like the data-bearing verdict (`03` §6): the controller can
only ask, never assert. A compromised controller could show a false sentence on its own page, but cannot make the agent
call two places different, cannot change the hub's copy of the verdict, and cannot format a partition the agent judges
data-bearing. The agent never trusts a guest path it cannot map to a host mount it made.
## 11. Rejected options (one line each)
- **Register `sys_drive` as the media drive** — it is inside `mp0`, so films enter every whole-guest copy, local and off-site.
- **Grow `mp0` without a cap** — the same problem, at full size.
- **Keep `st_dev` and add a partition check in the controller** — the controller cannot see LVM, loop or the host; the agent can.
- **WWN as the primary disk key** — cheap disks ship vendor-less WWNs (N100).
- **Ask the hypervisor (OMV/libvirt) which host disk backs a virtual disk** — Felhom has no access there, and it would trust a party it does not control.
- **Let the dashboard create partitions** — a new destructive power for the household with no recovery; not needed for the cases in hand.
## 12. Decisions for the operator (D1–D6) — RULED 2026-10-15: all six as recommended
| # | Question | Options | Recommendation | If you do nothing |
|---|---|---|---|---|
| D1 | A second copy on the **same** disk (two partitions, internal place → `sys_drive`)? | (a) do not make it; say „only one copy" honestly · (b) make it, label it „same disk: against mistakes, not against a disk failure" | **(a)** — the first copy already protects against mistakes; (b) spends space and invites a false sense of safety | today's `st_dev` rule stays: two partitions would count as two disks |
| D2 | **Unknown** identity (VM, hidden serial)? | (a) never count it; the copy is still made (it may be a real second disk) and the notice stays · (b) the household confirms „these are different disks" once, recorded with the disk ids | **(a)** for now; revisit (b) when a VM install is a supported shape | VM boxes keep counting same-disk copies as second copies (Tester 1 does today) |
| D3 | The installer's internal place on a one-disk box, and its reserve | (a) §7: `mp0` 70/130/250, reserve max(16 GiB, 10 %), the rest internal · (b) a larger reserve, 20 % · (c) no internal place; household adds a drive | **(a)** | new one-disk boxes keep refusing media apps (R-1068) and leave most of a big disk unused |
| D4 | Media and data-folder apps on a one-disk **old** box | (a) a plain sentence pointing to Meghajtók; internal place only by a signed operator job · (b) register `sys_drive` | **(a)** — (b) puts films into the whole-guest copies | the raw field error stays (R-1068) |
| D5 | Who creates or deletes partitions | (a) the installer only, this round · (b) also a dashboard wizard | **(a)** | — (nothing is built either way) |
| D6 | R-1071 — films from a NAS share, read-only, for Jellyfin | (a) a separate decision; this design only labels a NAS place `network`, never a copy target · (b) fold a read-only media bind kind into this build | **(a)** — the model makes it easier (a NAS becomes one more place kind with clear rules), but it is a media decision, not an identity one | stays open as R-1071 |
**After the rulings, the build task changes:** agent (place identity in `/disks` and the report, the same-disk call,
partition candidates, the loop step) · controller (Tier-2 and the notice ask the agent; i18n sentences; the install
refusal in words; register the internal place) · installer (`step_grows` + the internal LV) · hub (store and show the two
fields) · website and guides (the OMV guide's second-disk line; the backup explanation page).
## 13. Build status
**Build A — honest backup copies (D1, D2, D4), shipped 2026-10-15** (agent v0.157.0, controller v0.317.0 with MinAgent
0.157.0, hub v0.154.0; evidence `audits/storage-identity-buildA-2026-10-15/`):
| Section | Built | Proven |
|---|---|---|
| §3 model | the agent computes `kind`, `physical_disks`, `identity` + reason for every PVE storage and every own mount; `GET /disks` and the hub report carry them | live: N100 and demo-hp `proven`, Tester 1 `unknown/vm`, the PBS store `network`; the hub stores and shows them |
| §4 tiers | `POST /disks/same-disk`; Tier-2 choice, the pre-run guard and the one-copy notice ask it (D1: same disk → no copy; D2: unknown → copy made, not counted); a NAS never counts and never hides the notice (R-1075) | live: Tester 1 shows the „cannot check" notice and its copies are made, not counted; demo-hp and N100 count theirs, no notice. **Unit only:** the same-disk skip (no box has two places on one disk), the NAS notice (no box with a NAS can reach the agent, R-1083) |
| §5 D4 | the install of a data-folder app on a box with no drive answers „Ehhez az alkalmazáshoz egy adattároló kell. A Meghajtók oldalon adhatsz hozzá egyet." with a link to the Meghajtók page; never the field name | **unit only** — no one-disk box was at hand |
| §8 VMs | every place on a VM box is `unknown/vm` | live, Tester 1 |
| §10 | the agent maps only paths to mounts it made; an absent drive (its folder on the root disk) is `unmapped`, never „the system disk" | unit, and live (`unmapped` for an absent drive on demo-hp) |
**Where the build differs from the text above:**
- §3 „When": the identity is computed **on every read** (each `GET /disks`, each report, each same-disk call), not cached
at start and on hot-plug. The rule's purpose — a swapped disk never inherits an old verdict — holds with no cache.
- `usb_bridge`: built from the spike's rule, **never seen on a real device** (the N100's USB enclosure passes the real serial).
- §4 said the notice closes „R-1072's half": it closed all of R-1072 — the other sentence was already informal.
- An older agent (no same-disk call): the controller keeps the pre-build `st_dev` rule and logs it once.
- §11 rejects `sys_drive` for household data, but the drives page still offers `/mnt/sys_drive` to attach (R-280) — R-1082.
**Build B — the internal place, partitions, the installer (2026-10-16)** (agent v0.158.1 — v0.158.0 superseded; controller
v0.318.0; installer 1.33.0; evidence `audits/storage-identity-buildB-2026-10-15/`; the D3 space check: its `SPACE-CHECK.md`,
sizes ruled above in §7):
| Section | Built | Where it differs from the text above |
|---|---|---|
| §5 internal place | the root helper `felhom-internal-place` (bundle) makes `pve/felhom-belso` by the ruled rule; the installer's step 5b (appliance only) and the signed `add_internal_place` (an old box) run it; the agent mounts it at `/mnt/belso` like a drive (kind `internal`); the controller binds and registers it as the default drive | the place is made only in **appliance** mode (a byo host's pool is the owner's); every failing read in the helper refuses (security review of 0.158.0) |
| §6 partitions | each part of a free disk is listed (empty / filesystem / in_use); „format this part", „use this part"; a data-bearing part's wipe id binds disk id + part number + fs-UUID | the „filesystem with no files" state is folded into `filesystem` (no root read-only mount; the typed confirmation covers both). **A part can be formatted only while nothing on its disk is mounted**: the root mkfs wrapper refuses any mounted node and deliberately trusts no „it is Felhom's own drive" claim (the agent could fake it — security review of 0.158.0). So a household formats its free parts before using any part; a free part next to an in-use drive can still be USED, not formatted |
| §7 installer | `step_grows` by the ruled table; step 5b | the host root is left as Proxmox makes it (SPACE-CHECK §3) |
| §9 old boxes | the signed `add_internal_place` (no parameters; the size is the rule's) | — |
**Proven live 2026-10-16** (`audits/storage-identity-buildB-2026-10-15/README.md`): a fresh one-disk install (512 GB VM,
the public installer 1.33.0, agent 0.158.1 and golden 0.318.0 vouched) → data volume 100 GiB, internal place 213 GiB,
registered as the default drive with no press; Jellyfin installed with no extra step onto it; a whole-box copy holds nothing
of it; partitions formatted and used through the wizard; the honest backup offer on a VM. **Not as written:** an empty part
asks for the typed confirmation (the probe reads the parent's partition table — R-1093); a 200 GB disk gets no internal
place under the ruled sizes (15 GiB < 20).
Also in build B: R-1084 (the whole-box backup offer asks the same-disk question) and R-1082 (`/mnt/sys_drive` is never
offered to attach, by device). A bundle that adds a path needs a step bundle on boxes with an older wrapper (R16, R-880):
0.158.1 was delivered through `0.158.1-step1`.