Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 912f7c127e | |||
| e569ec6c5f | |||
| 52ed52c131 | |||
| c5daa9480d | |||
| d2053e7d47 | |||
| 7d4e564758 | |||
| 9b749ee7ad |
@@ -44,8 +44,12 @@ it **first** in the morning note. A decision you cannot write in that shape is o
|
|||||||
Throwaway apps only; the standing apps and `bentopdf` stay.
|
Throwaway apps only; the standing apps and `bentopdf` stay.
|
||||||
5. **Evidence off the machine at the end of each phase**, before any revert (R-320).
|
5. **Evidence off the machine at the end of each phase**, before any revert (R-320).
|
||||||
6. **One release per repo per session**, with a CHANGELOG entry (controller: with its `MinAgent`
|
6. **One release per repo per session**, with a CHANGELOG entry (controller: with its `MinAgent`
|
||||||
line), REPORT overwritten, floor raised to deliver it. **No golden unless a drill or fresh install
|
line) and REPORT overwritten. **A controller release needs no golden** (operator ruling 2026-10-15
|
||||||
needs one** (the waiver, R-468). **No `--no-verify`.**
|
11:26, `09` §3 decision 250; decision 7 stands): push the image only when it is ready for EVERY box (the
|
||||||
|
household's update button offers the highest tag); raise the floor, MinAgent declared, only after it ran
|
||||||
|
on Tester 1, demo-hp and demo-felhom through the normal update path and the box walk's checks are green
|
||||||
|
on all three — not at night unless the night brief says so. **Agent releases still stop for the
|
||||||
|
operator's vouch.** The golden is baked weekly and vouched by the operator; CC never vouches. **No `--no-verify`.**
|
||||||
7. **An enumerated gap becomes a row in the same session — or, if it is small, is fixed in it** (the size rule).
|
7. **An enumerated gap becomes a row in the same session — or, if it is small, is fixed in it** (the size rule).
|
||||||
Prose is not a record.
|
Prose is not a record.
|
||||||
8. **Hungarian text is searched with ASCII fragments**, with a positive and a negative control.
|
8. **Hungarian text is searched with ASCII fragments**, with a positive and a negative control.
|
||||||
|
|||||||
@@ -1,3 +1,60 @@
|
|||||||
|
## v0.157.0 — every storage place knows its physical disk(s); POST /disks/same-disk (2026-10-15)
|
||||||
|
|
||||||
|
Design: felhom.eu `architecture/16-storage-physical-identity.md` §3, §4, §10 (operator rulings D1–D6, 2026-10-15). Spike:
|
||||||
|
felhom.eu `audits/storage-identity-2026-10-15/`.
|
||||||
|
|
||||||
|
- **Identity of a storage place** (`internal/storage/identity.go`): the physical disk(s) under it, from `/sys`, `/run/udev/data`
|
||||||
|
and `/proc/mounts` only — partition → disk, device-mapper / LVM thin → slaves, loop → backing file → its disk. Keys:
|
||||||
|
`eui:` (NVMe), `serial:<bus>:<model>:<serial>`, `wwn:` only without a serial and only with a vendor (OUI ≠ 000000).
|
||||||
|
`proven`, or `unknown` with one reason: `vm` (systemd-detect-virt), `network`, `usb_bridge` (a placeholder serial — the rule
|
||||||
|
is the design's, UNPROVEN on a real enclosure, R-1077), `weak_wwn`, `no_serial`. No block I/O.
|
||||||
|
- **Reported:** `GET /disks` rows and the hub report's `storage_targets` gain `kind`, `physical_disks`, `identity`,
|
||||||
|
`identity_reason` (additive; the golden and the hub's copy change together). A dir storage is judged only on its OWN mount — an
|
||||||
|
absent drive is "not computed", never the root disk.
|
||||||
|
- **`POST /disks/same-disk`** `{a, b}` (guest paths) → `{different, proven, reason, a, b}`. Maps `/mnt/felhom-drives/<name>` to
|
||||||
|
the host mount at exactly that path, and `/mnt/sys_drive`, `/var/lib/felhom`, `/var/lib/docker` to the guest's data volume's
|
||||||
|
storage. Anything else, or an absent drive → `unmapped`. A shared disk key is "same, proven" even when the disk is unknown.
|
||||||
|
Scoped like every `/disks` call.
|
||||||
|
- Tests: fixture `/sys` + udev trees for every spike case (one disk LVM thin, two disks, two partitions, a loop on a file, NVMe eui,
|
||||||
|
vendor-less WWN, a VM, a USB placeholder serial, a network share) and the same-disk call (equal, different, VM, NAS, absent
|
||||||
|
drive, scope); red-proofed (the LVM "diamond" bug was seen failing first; the VM, WWN, own-mount and sys_drive rules broken on purpose).
|
||||||
|
- `configs/` unchanged — no config-bundle job needed. **Not vouched** (the operator's act).
|
||||||
|
|
||||||
|
## Unreleased (2026-10-15, afternoon) — the shared rule file; no code change
|
||||||
|
|
||||||
|
- `.claude/rules/unprompted-work.md` §3 item 6: **a controller release needs no golden** — the floor carries it after the
|
||||||
|
three test boxes; agent releases still wait for the operator's vouch; the golden is weekly (operator ruling 2026-10-15
|
||||||
|
11:26, `09` §3 decision 250; the same morning's „bake first", decision 247, reversed). Same text in all five copies.
|
||||||
|
|
||||||
|
## Unreleased (2026-10-15) — the shared rule file; no code change
|
||||||
|
|
||||||
|
- `.claude/rules/unprompted-work.md` §3 item 6: **bake first** — a controller release is released only when its golden is
|
||||||
|
baked; bake → the operator vouches → the operator raises the floor; never ask for a floor for a version with no golden
|
||||||
|
(operator ruling 2026-10-15, `09` §3 decision 247, superseding decision 7). Same text in all five copies.
|
||||||
|
|
||||||
|
## v0.156.1 — R-1008 by process liveness, not the clock; 0.156.0 superseded — do not vouch it (2026-10-14)
|
||||||
|
|
||||||
|
Released by `scripts/release-agent.sh`: binary sha256 `83c7da27d457b63ba8a4cde5a8717b3e5f9db9f83a46e63f38902dc7471bffb9`, bundle
|
||||||
|
`5a678def792c70ec1cbbd198c14542161fac91d11a69bd0ce23dd86469e96ec3` (tag `v0.156.1` = `7ae3081`). Step bundle `0.156.1-step1`
|
||||||
|
sha256 `ac83ea41507616d4c1056cccfd35de1410ff01e0296f4e64ff925207a43a8490` (the 0.155.0 bundle with only `felhom-os-apply`
|
||||||
|
replaced), published as package version `0.156.1-step1`. **Not vouched** (the operator's act). Delivery as 0.156.0: per box
|
||||||
|
`agent_update 0.156.1` → `agent_config_update 0.156.1-step1` → `agent_config_update 0.156.1`.
|
||||||
|
|
||||||
|
- **0.156.0 is superseded — never vouch it.** A background security review of the pushed commit flagged „fail-open state
|
||||||
|
drift": 0.156.0 called a listed vzdump task dead when its wall-clock start was before the kernel's `btime`. `btime` is
|
||||||
|
derived from the wall clock and moves when the clock is corrected after the boot (an NTP step after a power cut), so a
|
||||||
|
backup started between the boot and the correction could look „from before the boot" — and the stale-lock pass would
|
||||||
|
clear a LIVE backup's lock.
|
||||||
|
- **Now:** `taskAlive` is Proxmox's own rule (`PVE::ProcFSTools::check_process_running`, read on demo-hp): the UPID's pid and
|
||||||
|
pstart against `/proc/<pid>/stat` — same start tick (field 22) and not a zombie. No clock is involved. Fails safe (running)
|
||||||
|
when the UPID does not parse, the stat cannot be read, or this agent cannot see root's processes at all (PID 1 not visible:
|
||||||
|
a `hidepid` /proc or `ProtectProc=` would hide every worker — demo-hp: `/proc` plain, `ProtectProc=default`).
|
||||||
|
- Tests: worker gone, pid reused, zombie, live worker, a „clock step" (wall-clock start absurdly early, worker alive → still
|
||||||
|
running), doubt, hidden /proc — each red-proofed; 19 guards in all (`power-cut-fixes-2026-10-14/redproof.txt`).
|
||||||
|
- R-1006 (the cleanup) is unchanged; its „before this boot" rule still reads `btime`, but a drifted `btime` cannot remove a
|
||||||
|
live partial: the Go side asks this liveness rule, and the root helper refuses while any vzdump runs or anything holds
|
||||||
|
the file open.
|
||||||
|
|
||||||
## v0.156.0 — after a power cut: the dead half-written backup is removed (R-1006); a backup task from before the boot is not „in flight" (R-1008) (2026-10-14)
|
## v0.156.0 — after a power cut: the dead half-written backup is removed (R-1006); a backup task from before the boot is not „in flight" (R-1008) (2026-10-14)
|
||||||
|
|
||||||
Released by `scripts/release-agent.sh`: binary sha256 `8db2278838dcb733d5201fba84b75a829b9adebdf899c89dd7de0939ec10d51a`, bundle
|
Released by `scripts/release-agent.sh`: binary sha256 `8db2278838dcb733d5201fba84b75a829b9adebdf899c89dd7de0939ec10d51a`, bundle
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# CONTEXT — felhom-agent working state
|
# CONTEXT — felhom-agent working state
|
||||||
|
|
||||||
|
> **2026-10-15 (night) — v0.157.0 RELEASED, NOT VOUCHED (storage identity build A, felhom.eu `16` §13).** Every storage
|
||||||
|
> place carries `kind`, `physical_disks`, `identity` (+ reason) in `GET /disks` and the hub report; `POST /disks/same-disk`
|
||||||
|
> answers the controller (v0.317.0, MinAgent 0.157.0). Computed on every read, never cached. Delivered by signed jobs to
|
||||||
|
> Tester 1, demo-hp, N100 only. Next: the operator vouches 0.157.0 → the global floor 0.317.0 / MinAgent 0.157.0. Build B
|
||||||
|
> (partition candidates, the internal place) waits for a task file (R-1079).
|
||||||
|
|
||||||
> **2026-10-08 (day) — UNRELEASED on main, ships with tomorrow's release (decision 178).** R-899: `POST /backup?trigger=manual`
|
> **2026-10-08 (day) — UNRELEASED on main, ships with tomorrow's release (decision 178).** R-899: `POST /backup?trigger=manual`
|
||||||
> runs no OS leg (`localapi/server.go`); after-boot kernel reports carry the saved block's ring (`kernelReportRing`). R-304:
|
> runs no OS leg (`localapi/server.go`); after-boot kernel reports carry the saved block's ring (`kernelReportRing`). R-304:
|
||||||
> `escrow.ErrRetainedUnchecked` → HTTP 424 `older_unchecked` when not every earlier package was tried (withheld, caps,
|
> `escrow.ErrRetainedUnchecked` → HTTP 424 `older_unchecked` when not every earlier package was tried (withheld, caps,
|
||||||
|
|||||||
@@ -1,38 +1,43 @@
|
|||||||
# REPORT — v0.156.1: after a power cut the box clears up (R-1006, R-1008) (2026-10-14)
|
# REPORT — v0.157.0: every storage place knows its physical disk(s); POST /disks/same-disk (2026-10-15)
|
||||||
|
|
||||||
Evidence: felhom.eu `documentation/audits/power-cut-fixes-2026-10-14/` — the spike (C1 the night's leftover, C2 a killed
|
Written in ASD-STE100 Simplified Technical English. Design read first: felhom.eu `architecture/16-storage-physical-identity.md`
|
||||||
worker, C3 a real power cut, C4 a full disk), the 17 red-proofs.
|
(§3, §4, §8, §10; ACCEPTED 2026-10-15, §13 = what was built), `03-host-agent.md`. Task: storage identity build A (operator
|
||||||
|
rulings D1–D6). Controller counterpart: felhom-controller v0.317.0 (MinAgent 0.157.0); hub v0.154.0 shows the fields.
|
||||||
## What the spike showed
|
|
||||||
|
|
||||||
- A power cut mid-backup leaves `vzdump-lxc-<vmid>-<date>.tar.dat` + `.tmp/` (root, in `/var/lib/vz/dump`); Proxmox never
|
|
||||||
lists it as a backup; nothing removes it; every later backup is skipped „not enough space".
|
|
||||||
- The dead vzdump task stays in Proxmox's active list ~30–60 s after the boot — long enough for the agent's start-up pass
|
|
||||||
to call it „genuinely in flight" (R-1008). Short, so R-1008's harm is small.
|
|
||||||
- A killed worker leaves `tar`/`zstd` writing into the `.tar.dat` for minutes, none named „vzdump" — the reason the root
|
|
||||||
helper checks open files, not only process names.
|
|
||||||
- On „No space left" vzdump removes its own partial (both paths): the preflight's sizing stays. But a full host root breaks
|
|
||||||
LVM's own metadata writes (a half-removed snapshot; the janitor cannot clear it — new row).
|
|
||||||
|
|
||||||
## 0.156.0 is superseded — do not vouch it
|
|
||||||
|
|
||||||
A background security review of the pushed 0.156.0 flagged „fail-open state drift": its in-flight rule compared a task's
|
|
||||||
wall-clock start with the kernel's `btime`, which moves when the clock is corrected after the boot (an NTP step after a
|
|
||||||
power cut) — a live backup could then look „from before the boot" and its lock be cleared. 0.156.1 uses Proxmox's own
|
|
||||||
liveness rule instead (the UPID's pid + pstart against `/proc/<pid>/stat` start tick and state; no clock), and fails safe
|
|
||||||
when this agent cannot see root's processes (PID 1 not visible).
|
|
||||||
|
|
||||||
## What changed
|
## What changed
|
||||||
|
|
||||||
See `CHANGELOG.md` v0.156.0: `vzdumpInFlight` behind the one `BackupRunning`; `internal/vzpartial` at start-up and before
|
- `internal/storage/identity.go`: the physical disk(s) under a place, from `/sys`, `/run/udev/data`, `/proc/mounts` only
|
||||||
each backup; the root helper `felhom-vzdump-partial-clean` + one sudoers line + the bundle entry.
|
(no block I/O). `proven`, or `unknown` with `vm` / `network` / `usb_bridge` / `weak_wwn` / `no_serial`.
|
||||||
|
- `GET /disks` rows and the hub report's `storage_targets` gain `kind`, `physical_disks`, `identity`, `identity_reason`.
|
||||||
|
- `POST /disks/same-disk` `{a, b}` → `{different, proven, reason, a, b}`; scoped like every `/disks` call; a path the agent
|
||||||
|
did not mount itself is `unmapped`.
|
||||||
|
- `configs/` unchanged.
|
||||||
|
|
||||||
## Gates
|
## Tests and red-proofs
|
||||||
|
|
||||||
`go build ./... && go vet` clean; `go test ./... -count=1` rc 0; `agent_gates.py` all OK; `visudo -cf` parsed OK; the
|
Fixture `/sys` + udev trees for every spike case and five same-disk handler tests. Seen failing first: the LVM thin
|
||||||
config bundle tests (55) and the helper's tests (21) pass. 17 mutations, 17 red.
|
„diamond" (tmeta/tdata under one pool) read as a failure — fixed by counting a seen node once. Broken on purpose and seen
|
||||||
|
failing: the VM rule, the vendor-less WWN rule, the own-mount rule (an absent drive), the `sys_drive` mapping.
|
||||||
|
`python3 scripts/agent_gates.py`: all OK.
|
||||||
|
|
||||||
## Next
|
## Release and delivery
|
||||||
|
|
||||||
Released: tag `v0.156.0`, binary sha256 `8db22788…`, bundle `a2160521…`, step bundle `0.156.0-step1` `8be2bb8f…`. Then the operator's vouch + floor, then the live proof on demo-hp (a cut mid-backup) and
|
- `scripts/release-agent.sh 0.157.0`: tag `v0.157.0`, sha256 `addd4041…0aef`, bundle `0604bc60…397a`.
|
||||||
delivery to Tester 1 and the N100.
|
- Delivered by signed `agent_update` jobs to Tester 1, demo-hp and the N100 (the three test boxes). **NOT VOUCHED** — the
|
||||||
|
operator's act. Until the vouch, the global floor cannot declare MinAgent 0.157.0.
|
||||||
|
|
||||||
|
## Live proof (method: the agent's local API on each PVE host, with the guest's own token)
|
||||||
|
|
||||||
|
| Box | `GET /disks` | same-disk: drive vs system data |
|
||||||
|
|---|---|---|
|
||||||
|
| demo-hp | `local`/`local-lvm` proven (SATA SSD), NVMe drive proven (`eui:`), PBS `network` | different, proven |
|
||||||
|
| N100 | system proven (SATA SSD serial), USB drive proven (the bridge passes the real serial) | different, proven |
|
||||||
|
| Tester 1 (VM) | every place `unknown/vm` | not proven (`vm`) |
|
||||||
|
|
||||||
|
On all three: system data vs `/var/lib/docker` → same disk, proven; a drive name that is not mounted → `unmapped`. The hub
|
||||||
|
stored the same fields (read from its database with the WAL). Evidence: felhom.eu `audits/storage-identity-buildA-2026-10-15/`.
|
||||||
|
|
||||||
|
## Observations
|
||||||
|
|
||||||
|
1. FILED: R-1083 — scratch box 9202 has no `local_api` in its `bootstrap.json`, so it cannot use any agent call.
|
||||||
|
2. NOT-A-FINDING: `usb_bridge` is built from the design's rule and was not seen on a real enclosure; that is R-1077, already open.
|
||||||
|
|||||||
@@ -41,6 +41,11 @@
|
|||||||
| `storage.ResolveStorageDevice` | internal/storage/durable_device.go | `ResolveStorageDevice("uuid:<fs-uuid>") (dev, err)` | re-resolve enrolled STORAGE drives (remount) | `uuid:` scheme ONLY — distinct from the wipe schemes; never trust a remembered /dev node |
|
| `storage.ResolveStorageDevice` | internal/storage/durable_device.go | `ResolveStorageDevice("uuid:<fs-uuid>") (dev, err)` | re-resolve enrolled STORAGE drives (remount) | `uuid:` scheme ONLY — distinct from the wipe schemes; never trust a remembered /dev node |
|
||||||
| `deriveDurableID` | internal/storage/durableid.go | `deriveDurableID(typ, s, backingDevice, uuid) string` | storage-target durable id (DR re-attach key) | Deterministic per type; `uuid:` for usb/local-dir; PBS id includes `#<fingerprint>` |
|
| `deriveDurableID` | internal/storage/durableid.go | `deriveDurableID(typ, s, backingDevice, uuid) string` | storage-target durable id (DR re-attach key) | Deterministic per type; `uuid:` for usb/local-dir; PBS id includes `#<fingerprint>` |
|
||||||
| `SystemDisks` / `isSystemBacked` / `RoleForStorage` / `RoleForRawDevice` | internal/storage/role.go | `RoleForRawDevice(device, sysDisks, sysKnown) DeviceRole` | protection-tier classification | Fails safe to `system` (most protected) on any ambiguity; role is AGENT-derived, never caller-supplied |
|
| `SystemDisks` / `isSystemBacked` / `RoleForStorage` / `RoleForRawDevice` | internal/storage/role.go | `RoleForRawDevice(device, sysDisks, sysKnown) DeviceRole` | protection-tier classification | Fails safe to `system` (most protected) on any ambiguity; role is AGENT-derived, never caller-supplied |
|
||||||
|
| `storage.IdentityResolver` / `Relation` / `MountFor` | internal/storage/identity.go | `HostPath(path) PlaceIdentity`, `Device(dev) PlaceIdentity`, `Relation(a, b) (different, proven, reason)` | physical-disk identity of a storage place (felhom.eu `16-storage-physical-identity.md` §3, v0.157.0) | Reads /sys + /run/udev/data + /proc/mounts only (no block I/O). Judge a drive ONLY on its own mount (`localapi.ownMountIdentity`) — never the containing fs, or an absent drive reads as the root disk. A shared disk key is "same, proven" even when unknown. Pinned by identity_test.go (fixture trees) + samedisk_test.go |
|
||||||
|
| `localapi.Server.EnsureInternalPlace` / `InternalPlaceRecord` | internal/localapi/internalplace.go | `EnsureInternalPlace(ctx)` (startup + 20 s tick); `ReadInternalPlace` / `WriteInternalPlace` | the one-disk box's internal data place (felhom.eu `16` §5, v0.158.0): mounts it at /mnt/belso by fs-UUID like an enrolled drive, records it `enrolled`; the controller binds + registers it | Never takes /mnt/belso from a household drive (a foreign mount, or a unit naming another UUID, waits). Never formats/ejects (dm on the system disk → role system). The ONLY maker is the root helper `configs/felhom-internal-place` (installer + signed `add_internal_place`). Pinned by internalplace_test.go |
|
||||||
|
| `configs/felhom-internal-place` | configs/ (bundle → /usr/local/sbin) | `plan\|create <planned GiB>` | the ruled size rule (reserve max(16, 10 %) ≤ 48 GiB; ≥ 20 GiB or nothing) + the only lvcreate/mkfs of a system-disk volume | Formats only a felhom-belso with NO signature; refuses a foreign one; never resizes/removes. Sudoers: `^(plan\|create) 0$` only. Pinned by configs/test_felhom_internal_place.py |
|
||||||
|
| `storage.candidatePartitions` / `CandidatePartition` | internal/storage/partitions.go | `CandidateDisk.Partitions` | partitions as places (`16` §6): empty / filesystem / in_use per part of a free disk | A part with children or a member type is never listed; a raw /mnt mount counts as Felhom's only when corroborated (felhomOwnedMounts). Pinned by partitions_test.go |
|
||||||
|
| partition wipe id `byid:<…-partN>+fsuuid:<uuid>` | internal/storage/durable_device.go | `DeviceDurableID` / `ResolveDurableDevice` | a data-bearing PART binds disk id + part number + filesystem (`16` §6) | Both halves must resolve to the SAME device or the wipe refuses. Whole disks keep the plain id. Pinned by partition_durable_test.go |
|
||||||
| `ValidateUUID/MountPath/BlockDevice/FSType/SMARTDevice/LVMName`, `UnitNameForMount` | internal/storage/validate.go | `Validate*(v) error` | EVERY arg that reaches a root shell-out | The security boundary; strict whitelists (no by-* symlinks, no dm, no traversal); `systemdEscapePath` computed in-process |
|
| `ValidateUUID/MountPath/BlockDevice/FSType/SMARTDevice/LVMName`, `UnitNameForMount` | internal/storage/validate.go | `Validate*(v) error` | EVERY arg that reaches a root shell-out | The security boundary; strict whitelists (no by-* symlinks, no dm, no traversal); `systemdEscapePath` computed in-process |
|
||||||
| `ValidateNetworkMountSpec` | internal/storage/netmount.go | `ValidateNetworkMountSpec(spec) error` | NAS mount input boundary | Same discipline as validate.go; SMB requires a creds ref; mountpoint confined under `NetworkMountRoot` |
|
| `ValidateNetworkMountSpec` | internal/storage/netmount.go | `ValidateNetworkMountSpec(spec) error` | NAS mount input boundary | Same discipline as validate.go; SMB requires a creds ref; mountpoint confined under `NetworkMountRoot` |
|
||||||
|
|
||||||
@@ -158,6 +163,7 @@
|
|||||||
| `proxmox.Runner` | internal/proxmox/privileged.go | `*ExecRunner` (direct/sudo) | `mockRunner` internal/proxmox/mock_test.go; runner fakes in storage tests |
|
| `proxmox.Runner` | internal/proxmox/privileged.go | `*ExecRunner` (direct/sudo) | `mockRunner` internal/proxmox/mock_test.go; runner fakes in storage tests |
|
||||||
| `storage.HostOps` | internal/storage/hostops.go | `*SudoHostOps` (prod), `NoopHostOps` (degraded) | fakes in internal/storage/observe_test.go, watchdog_test.go |
|
| `storage.HostOps` | internal/storage/hostops.go | `*SudoHostOps` (prod), `NoopHostOps` (degraded) | fakes in internal/storage/observe_test.go, watchdog_test.go |
|
||||||
| `storage.HostReader` | internal/storage/hostread.go | `*ProcHostReader` | `fakeHostReader` internal/localapi/disks_test.go; internal/storage/role_test.go. v0.87.0: `BlockSlaves(name)` lists `/sys/block/<name>/slaves` (root-free) — backs the `SystemDisks` dm/md walk (`physicalDisksOf`/`walkSlaves`, role.go); per-branch conservatism: an unresolvable slave fails the WHOLE walk → all-system fail-safe. NEVER weaken the signature test `TestSystemDisks_WalkTopologies` (root-backing disk always in the system set). |
|
| `storage.HostReader` | internal/storage/hostread.go | `*ProcHostReader` | `fakeHostReader` internal/localapi/disks_test.go; internal/storage/role_test.go. v0.87.0: `BlockSlaves(name)` lists `/sys/block/<name>/slaves` (root-free) — backs the `SystemDisks` dm/md walk (`physicalDisksOf`/`walkSlaves`, role.go); per-branch conservatism: an unresolvable slave fails the WHOLE walk → all-system fail-safe. NEVER weaken the signature test `TestSystemDisks_WalkTopologies` (root-backing disk always in the system set). |
|
||||||
|
| `storage.IdentityHost` | internal/storage/identity.go | `*SysfsIdentityHost` | fixture /sys + udev trees in identity_test.go (`newFx`); `fakeIdentHost` in internal/localapi/samedisk_test.go. A SEPARATE interface from HostReader on purpose — adding methods to HostReader would break every existing fake. |
|
||||||
| `localapi.DiskOps` / `StorageGate` / `GuestAttacher` / `GuestLister` | internal/localapi/disks.go | `*storage.SudoHostOps`; `storageGateAdapter` (cmd/felhom-agent/main.go); `*GuestBinder`; `*proxmox.Client` | `fakeDiskOps`/`fakeGate`/`fakeGuestAttacher`/`fakeGuestList` internal/localapi/disks_test.go |
|
| `localapi.DiskOps` / `StorageGate` / `GuestAttacher` / `GuestLister` | internal/localapi/disks.go | `*storage.SudoHostOps`; `storageGateAdapter` (cmd/felhom-agent/main.go); `*GuestBinder`; `*proxmox.Client` | `fakeDiskOps`/`fakeGate`/`fakeGuestAttacher`/`fakeGuestList` internal/localapi/disks_test.go |
|
||||||
| `lanresolver.hostRoot` + `dnsmasqUnitPaths` (data seam, R-317) | internal/lanresolver/lanresolver.go | prod `hostRoot = "/"`; probe = the `dnsmasq` package's systemd UNIT, never `/usr/sbin/dnsmasq` (owned by `dnsmasq-base`) | internal/lanresolver/ensure_dnsmasq_test.go — fixture root tree + recording `proxmox.Runner`; the REAL `os.Stat` probe and `EnsureDnsmasq` run. `TestEnsureDnsmasq_ProductionProbeIsTheUnit` pins the production wiring |
|
| `lanresolver.hostRoot` + `dnsmasqUnitPaths` (data seam, R-317) | internal/lanresolver/lanresolver.go | prod `hostRoot = "/"`; probe = the `dnsmasq` package's systemd UNIT, never `/usr/sbin/dnsmasq` (owned by `dnsmasq-base`) | internal/lanresolver/ensure_dnsmasq_test.go — fixture root tree + recording `proxmox.Runner`; the REAL `os.Stat` probe and `EnsureDnsmasq` run. `TestEnsureDnsmasq_ProductionProbeIsTheUnit` pins the production wiring |
|
||||||
| `localapi.GuestAPI` / `BackupService` / `BackupStore` / `TokenAuthority` | internal/localapi/server.go | `*proxmox.Client`, `*backup.BackupRunner`, `*backup.Store`, `*TokenStore` | `fakeGuests`/`fakeBackups`/`fakeStore` internal/localapi/server_test.go |
|
| `localapi.GuestAPI` / `BackupService` / `BackupStore` / `TokenAuthority` | internal/localapi/server.go | `*proxmox.Client`, `*backup.BackupRunner`, `*backup.Store`, `*TokenStore` | `fakeGuests`/`fakeBackups`/`fakeStore` internal/localapi/server_test.go |
|
||||||
|
|||||||
@@ -777,6 +777,7 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int
|
|||||||
hostReader := storage.NewProcHostReader()
|
hostReader := storage.NewProcHostReader()
|
||||||
hostOps := newHostOps(cfg, logger)
|
hostOps := newHostOps(cfg, logger)
|
||||||
observer := storage.NewObserver(px, hostReader, hostOps, logger)
|
observer := storage.NewObserver(px, hostReader, hostOps, logger)
|
||||||
|
observer.SetIdentity(storage.NewIdentityResolver()) // `16` §3 physical-disk identity
|
||||||
// Backup + restore-test state store (slice 6): holds the latest backup-per-target +
|
// Backup + restore-test state store (slice 6): holds the latest backup-per-target +
|
||||||
// latest restore-test result; the collector reads it via the BackupReporter /
|
// latest restore-test result; the collector reads it via the BackupReporter /
|
||||||
// RestoreTestReporter seams; the cadence scheduler writes it.
|
// RestoreTestReporter seams; the cadence scheduler writes it.
|
||||||
@@ -958,10 +959,7 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int
|
|||||||
// state. Gates the watchdog's self-heal re-mount to ENROLLED drives, and the local API records
|
// state. Gates the watchdog's self-heal re-mount to ENROLLED drives, and the local API records
|
||||||
// enroll/eject into it. Open failure → ungated legacy remount (logged) so the daemon still runs.
|
// enroll/eject into it. Open failure → ungated legacy remount (logged) so the daemon still runs.
|
||||||
var intentReader storage.IntentReader
|
var intentReader storage.IntentReader
|
||||||
intentStateDir := "/var/lib/felhom-agent"
|
intentStateDir := agentStateDir(cfg)
|
||||||
if cfg.Authz.NonceStorePath != "" {
|
|
||||||
intentStateDir = filepath.Dir(cfg.Authz.NonceStorePath) // share the agent's durable state dir
|
|
||||||
}
|
|
||||||
intentStore, ierr := storage.OpenIntentStore(filepath.Join(intentStateDir, "drive-intents.json"))
|
intentStore, ierr := storage.OpenIntentStore(filepath.Join(intentStateDir, "drive-intents.json"))
|
||||||
if ierr != nil {
|
if ierr != nil {
|
||||||
logger.Warn("storage: intent store unavailable — self-heal runs UNGATED (legacy remount-any)", "err", ierr)
|
logger.Warn("storage: intent store unavailable — self-heal runs UNGATED (legacy remount-any)", "err", ierr)
|
||||||
@@ -1167,7 +1165,11 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int
|
|||||||
}
|
}
|
||||||
logger.Warn("osupdate: capability probe after the config bundle", "ok", ok, "total", total, "degraded", strings.Join(names, ","))
|
logger.Warn("osupdate: capability probe after the config bundle", "ok", ok, "total", total, "degraded", strings.Join(names, ","))
|
||||||
}}
|
}}
|
||||||
jobsRunner := signedjobs.NewRunner(client, gate, signedjobs.ExecutorChain{wipeExec, decommExec, updateExec, dockerExec, pveExec, kernelExec, bundleExec}, cfg.Hub.HostID, logger)
|
// v0.158.0 (felhom.eu `16` §9): the internal data place on an old box — the root helper, then the mount (late-bound
|
||||||
|
// to the local API server built below).
|
||||||
|
internalPlaceExec := signedjobs.NewInternalPlaceExecutor(suRunner,
|
||||||
|
localapi.InternalPlaceFileIn(intentStateDir), logger)
|
||||||
|
jobsRunner := signedjobs.NewRunner(client, gate, signedjobs.ExecutorChain{wipeExec, decommExec, updateExec, dockerExec, pveExec, kernelExec, bundleExec, internalPlaceExec}, cfg.Hub.HostID, logger)
|
||||||
loop.SetEnvelopeObserver(hub.MultiObserver(desiredSyncer, jobsRunner))
|
loop.SetEnvelopeObserver(hub.MultiObserver(desiredSyncer, jobsRunner))
|
||||||
|
|
||||||
// Controller-driven escrow ceremony (v0.88.0): static config facts + the LATE-BOUND DR gate —
|
// Controller-driven escrow ceremony (v0.88.0): static config facts + the LATE-BOUND DR gate —
|
||||||
@@ -1200,6 +1202,7 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int
|
|||||||
}
|
}
|
||||||
localSrv := buildLocalAPIServer(cfg, px, backupStore, heavyOps, observer, driveKnown, hostOps, gate, collector, client, intentRec, guestBindStore, formatJobStore, logRing, escrowCeremonyCfg, logger, &localTokens)
|
localSrv := buildLocalAPIServer(cfg, px, backupStore, heavyOps, observer, driveKnown, hostOps, gate, collector, client, intentRec, guestBindStore, formatJobStore, logRing, escrowCeremonyCfg, logger, &localTokens)
|
||||||
if localSrv != nil {
|
if localSrv != nil {
|
||||||
|
internalPlaceExec.SetEnsure(localSrv.EnsureInternalPlace)
|
||||||
// R-998 (doc 14): POST /host/poweroff — guest shutdown, then host power-off; guards in hostpoweroff.go.
|
// R-998 (doc 14): POST /host/poweroff — guest shutdown, then host power-off; guards in hostpoweroff.go.
|
||||||
pwMode := proxmox.RunnerMode(cfg.Privileged.Mode)
|
pwMode := proxmox.RunnerMode(cfg.Privileged.Mode)
|
||||||
if pwMode == "" {
|
if pwMode == "" {
|
||||||
@@ -1418,6 +1421,9 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int
|
|||||||
// filesystem UUID and re-mounts (idempotent `enable --now`) so a letter reshuffle is a no-op.
|
// filesystem UUID and re-mounts (idempotent `enable --now`) so a letter reshuffle is a no-op.
|
||||||
// Type-asserted (the concrete op exposes it; the interface stays lean).
|
// Type-asserted (the concrete op exposes it; the interface stays lean).
|
||||||
mountReasserter := hostOps // concrete *storage.SudoHostOps — exposes ReassertEnrolledMounts
|
mountReasserter := hostOps // concrete *storage.SudoHostOps — exposes ReassertEnrolledMounts
|
||||||
|
// v0.158.0 (felhom.eu `16` §5): the internal data place is mounted like an enrolled drive — first, so the
|
||||||
|
// re-assert below sees its unit.
|
||||||
|
localSrv.EnsureInternalPlace(ctx)
|
||||||
if mountReasserter != nil {
|
if mountReasserter != nil {
|
||||||
mountReasserter.ReassertEnrolledMounts(ctx)
|
mountReasserter.ReassertEnrolledMounts(ctx)
|
||||||
}
|
}
|
||||||
@@ -1455,6 +1461,7 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int
|
|||||||
case <-t.C:
|
case <-t.C:
|
||||||
// Re-assert mounts first (handles a USB that enumerated late after a host reboot, or a
|
// Re-assert mounts first (handles a USB that enumerated late after a host reboot, or a
|
||||||
// unit re-disabled at runtime), then re-assert the guest binds against the now-live mounts.
|
// unit re-disabled at runtime), then re-assert the guest binds against the now-live mounts.
|
||||||
|
localSrv.EnsureInternalPlace(ctx) // `16` §5: a record written since the last tick (the signed job)
|
||||||
if mountReasserter != nil {
|
if mountReasserter != nil {
|
||||||
mountReasserter.ReassertEnrolledMounts(ctx)
|
mountReasserter.ReassertEnrolledMounts(ctx)
|
||||||
}
|
}
|
||||||
@@ -2000,6 +2007,7 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St
|
|||||||
DriveTargets: driveTargets, // Impl-2a: registry+units drives for the /disks view (union w/ Observe storages)
|
DriveTargets: driveTargets, // Impl-2a: registry+units drives for the /disks view (union w/ Observe storages)
|
||||||
Smart: storage.NewSmartReader(hostOps), // v0.95.0 Fix B: SMART for the union-path drives
|
Smart: storage.NewSmartReader(hostOps), // v0.95.0 Fix B: SMART for the union-path drives
|
||||||
HostReader: storage.NewProcHostReader(), // Impl-2b: durableIDForMount raw-mount fallback + role gate
|
HostReader: storage.NewProcHostReader(), // Impl-2b: durableIDForMount raw-mount fallback + role gate
|
||||||
|
Identity: storage.NewIdentityResolver(), // v0.157.0: `16` §3 identity on /disks + POST /disks/same-disk
|
||||||
Tokens: tokens,
|
Tokens: tokens,
|
||||||
BackupCadence: cfg.Backup.BackupCadence(),
|
BackupCadence: cfg.Backup.BackupCadence(),
|
||||||
// Disk management (slice 8C): the privileged host surface + the data-bearing wipe gate.
|
// Disk management (slice 8C): the privileged host surface + the data-bearing wipe gate.
|
||||||
@@ -2021,10 +2029,12 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St
|
|||||||
// F2-b: recover a guest left with a stale vzdump lock by a reboot-during-backup. Reads + start
|
// F2-b: recover a guest left with a stale vzdump lock by a reboot-during-backup. Reads + start
|
||||||
// go through the API client; the `pct unlock` is the one fenced root-CLI op (no API equivalent).
|
// go through the API client; the `pct unlock` is the one fenced root-CLI op (no API equivalent).
|
||||||
// A1 (v0.62.0): the scan is restricted to felhom-pool members (ownership proven, not assumed).
|
// A1 (v0.62.0): the scan is restricted to felhom-pool members (ownership proven, not assumed).
|
||||||
StaleLock: localapi.NewStaleLockController(px, &proxmox.ExecRunner{Mode: gaMode, SudoPath: cfg.Privileged.SudoPath}, reconcile.DefaultPool, logger),
|
StaleLock: localapi.NewStaleLockController(px, &proxmox.ExecRunner{Mode: gaMode, SudoPath: cfg.Privileged.SudoPath}, reconcile.DefaultPool, logger),
|
||||||
Intent: intent, // slice 10 P3: record enroll/eject intent for self-heal
|
Intent: intent, // slice 10 P3: record enroll/eject intent for self-heal
|
||||||
GuestBinds: guestBinds, // F9: per-guest bind record for the startup re-assert
|
// v0.158.0 (felhom.eu `16` §5): the internal data place record, beside the intent store.
|
||||||
FormatJobs: formatJobs, // F20-BUG3: detached-format job record + restart recovery
|
InternalPlaceFile: localapi.InternalPlaceFileIn(agentStateDir(cfg)),
|
||||||
|
GuestBinds: guestBinds, // F9: per-guest bind record for the startup re-assert
|
||||||
|
FormatJobs: formatJobs, // F20-BUG3: detached-format job record + restart recovery
|
||||||
|
|
||||||
// Host metrics (slice 9): the shared collector serves GET /host/metrics — a fresh host +
|
// Host metrics (slice 9): the shared collector serves GET /host/metrics — a fresh host +
|
||||||
// per-storage view to the customer's monitoring page (reuses the slice-4 collector).
|
// per-storage view to the customer's monitoring page (reuses the slice-4 collector).
|
||||||
@@ -2188,6 +2198,7 @@ func runSelftestHub(ctx context.Context, cfg config.Config, logger *slog.Logger)
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
observer := storage.NewObserver(px, storage.NewProcHostReader(), newHostOps(cfg, logger), logger)
|
observer := storage.NewObserver(px, storage.NewProcHostReader(), newHostOps(cfg, logger), logger)
|
||||||
|
observer.SetIdentity(storage.NewIdentityResolver()) // `16` §3 physical-disk identity
|
||||||
// Wire the LIVE PBS reporter here too (v0.39.0): selftest=hub is a separate one-shot process — no
|
// Wire the LIVE PBS reporter here too (v0.39.0): selftest=hub is a separate one-shot process — no
|
||||||
// verify loop runs — so a nil reporter previously yielded pbs_snapshots:[] and an absent dr_recipe
|
// verify loop runs — so a nil reporter previously yielded pbs_snapshots:[] and an absent dr_recipe
|
||||||
// pbs coord. The live reporter lists snapshots directly (fresh store, last-known-good fallback) so
|
// pbs coord. The live reporter lists snapshots directly (fresh store, last-known-good fallback) so
|
||||||
@@ -2244,6 +2255,7 @@ func runSelftestStorage(ctx context.Context, cfg config.Config, logger *slog.Log
|
|||||||
hostReader := storage.NewProcHostReader()
|
hostReader := storage.NewProcHostReader()
|
||||||
hostOps := newHostOps(cfg, logger)
|
hostOps := newHostOps(cfg, logger)
|
||||||
observer := storage.NewObserver(px, hostReader, hostOps, logger)
|
observer := storage.NewObserver(px, hostReader, hostOps, logger)
|
||||||
|
observer.SetIdentity(storage.NewIdentityResolver()) // `16` §3 physical-disk identity
|
||||||
|
|
||||||
octx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
octx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||||
fmt.Printf("=== felhom-agent %s selftest=storage ===\n", version)
|
fmt.Printf("=== felhom-agent %s selftest=storage ===\n", version)
|
||||||
@@ -3901,3 +3913,12 @@ func (f *factsReporter) SystemFacts(ctx context.Context) (int, json.RawMessage,
|
|||||||
f.facts, f.err = f.leg.Facts(ctx, f.vmid)
|
f.facts, f.err = f.leg.Facts(ctx, f.vmid)
|
||||||
return f.vmid, f.facts, f.err
|
return f.vmid, f.facts, f.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// agentStateDir is the agent's durable state dir: beside the nonce store when one is configured, else the default.
|
||||||
|
// The intent store, the guest-bind record and the internal place record (v0.158.0) all live here.
|
||||||
|
func agentStateDir(cfg config.Config) string {
|
||||||
|
if cfg.Authz.NonceStorePath != "" {
|
||||||
|
return filepath.Dir(cfg.Authz.NonceStorePath)
|
||||||
|
}
|
||||||
|
return "/var/lib/felhom-agent"
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func main() {
|
|||||||
|
|
||||||
func run() error {
|
func run() error {
|
||||||
var (
|
var (
|
||||||
op = flag.String("op", "", "op class to sign, e.g. storage_wipe | guest_destroy | decommission | agent_update | os_docker_step | os_pve_step | os_kernel_step | agent_config_update")
|
op = flag.String("op", "", "op class to sign, e.g. storage_wipe | guest_destroy | decommission | agent_update | os_docker_step | os_pve_step | os_kernel_step | agent_config_update | add_internal_place")
|
||||||
host = flag.String("host", "", "target host_id (anti-retarget — the op runs ONLY on this host)")
|
host = flag.String("host", "", "target host_id (anti-retarget — the op runs ONLY on this host)")
|
||||||
guest = flag.String("guest", "", "target guest_id (\"\" = host-scoped op)")
|
guest = flag.String("guest", "", "target guest_id (\"\" = host-scoped op)")
|
||||||
keyID = flag.String("key-id", "", "key id of the signing key (must match a pinned agent signer)")
|
keyID = flag.String("key-id", "", "key id of the signing key (must match a pinned agent signer)")
|
||||||
|
|||||||
@@ -96,6 +96,14 @@ Cmnd_Alias FELHOM_POWEROFF = \
|
|||||||
Cmnd_Alias FELHOM_VZPARTIAL = \
|
Cmnd_Alias FELHOM_VZPARTIAL = \
|
||||||
/usr/local/sbin/felhom-vzdump-partial-clean ^[A-Za-z][A-Za-z0-9_-]{0\,63} [1-9][0-9]{2\,8} vzdump-(lxc|qemu)-[1-9][0-9]{2\,8}-[0-9]{4}_[0-9]{2}_[0-9]{2}-[0-9]{2}_[0-9]{2}_[0-9]{2}\.(tar\.dat|vma\.dat|tmp)$
|
/usr/local/sbin/felhom-vzdump-partial-clean ^[A-Za-z][A-Za-z0-9_-]{0\,63} [1-9][0-9]{2\,8} vzdump-(lxc|qemu)-[1-9][0-9]{2\,8}-[0-9]{4}_[0-9]{2}_[0-9]{2}-[0-9]{2}_[0-9]{2}_[0-9]{2}\.(tar\.dat|vma\.dat|tmp)$
|
||||||
|
|
||||||
|
# Internal data place („Belső tárhely", felhom.eu architecture/16 §5, §7; agent v0.158.0). ONE root helper
|
||||||
|
# (configs/felhom-internal-place, from the config bundle) creates <vg>/felhom-belso in the local-lvm thin pool by the
|
||||||
|
# ruled size rule and formats ONLY that volume, ONLY when it carries no signature. The agent calls it from the signed
|
||||||
|
# `add_internal_place` job (an old box), always with 0 GiB planned: its guest's volumes already exist. `plan` changes
|
||||||
|
# nothing. The installer runs the helper as root itself (a new box).
|
||||||
|
Cmnd_Alias FELHOM_INTERNALPLACE = \
|
||||||
|
/usr/local/sbin/felhom-internal-place ^(plan|create) 0$
|
||||||
|
|
||||||
# Intermediary mount model (the drive hot-swap re-architecture). The agent keeps a SHARED host parent
|
# Intermediary mount model (the drive hot-swap re-architecture). The agent keeps a SHARED host parent
|
||||||
# /mnt/felhom-drives (self-bind + make-shared) and binds/unbinds each drive's felhom-data namespace UNDERNEATH it so the
|
# /mnt/felhom-drives (self-bind + make-shared) and binds/unbinds each drive's felhom-data namespace UNDERNEATH it so the
|
||||||
# change propagates into the running guest live. The boot-persistence script + unit are FIXED files from the config
|
# change propagates into the running guest live. The boot-persistence script + unit are FIXED files from the config
|
||||||
@@ -328,4 +336,4 @@ Cmnd_Alias FELHOM_GUESTNET = \
|
|||||||
/usr/sbin/pct ^exec [0-9]+ -- pgrep -x dhclient$, \
|
/usr/sbin/pct ^exec [0-9]+ -- pgrep -x dhclient$, \
|
||||||
/usr/sbin/pct ^exec [0-9]+ -- dhclient -pf /run/dhclient\.eth0\.pid -lf /var/lib/dhcp/dhclient\.eth0\.leases eth0$
|
/usr/sbin/pct ^exec [0-9]+ -- dhclient -pf /run/dhclient\.eth0\.pid -lf /var/lib/dhcp/dhclient\.eth0\.leases eth0$
|
||||||
|
|
||||||
felhom-agent ALL=(root) NOPASSWD: FELHOM_MOUNT, FELHOM_DISK, FELHOM_PROVISION, FELHOM_FORMAT, FELHOM_DNSMASQ, FELHOM_GUESTHOOK, FELHOM_INTERMEDIARY, FELHOM_CONTROLLERSWAP, FELHOM_STALELOCK, FELHOM_FSTRIM, FELHOM_NETMOUNT, FELHOM_WG, FELHOM_SELFUPDATE, FELHOM_SSHD, FELHOM_OOB, FELHOM_PBSDR, FELHOM_BACKUPTARGET, FELHOM_SELFHEAL, FELHOM_ESCROW, FELHOM_GUESTNET, FELHOM_SCRATCH_TEARDOWN, FELHOM_OSAPPLY, FELHOM_POWEROFF, FELHOM_VZPARTIAL
|
felhom-agent ALL=(root) NOPASSWD: FELHOM_MOUNT, FELHOM_DISK, FELHOM_PROVISION, FELHOM_FORMAT, FELHOM_DNSMASQ, FELHOM_GUESTHOOK, FELHOM_INTERMEDIARY, FELHOM_CONTROLLERSWAP, FELHOM_STALELOCK, FELHOM_FSTRIM, FELHOM_NETMOUNT, FELHOM_WG, FELHOM_SELFUPDATE, FELHOM_SSHD, FELHOM_OOB, FELHOM_PBSDR, FELHOM_BACKUPTARGET, FELHOM_SELFHEAL, FELHOM_ESCROW, FELHOM_GUESTNET, FELHOM_SCRATCH_TEARDOWN, FELHOM_OSAPPLY, FELHOM_POWEROFF, FELHOM_VZPARTIAL, FELHOM_INTERNALPLACE
|
||||||
|
|||||||
Executable
+103
@@ -0,0 +1,103 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# felhom-internal-place — make the one-disk box's internal data place („Belső tárhely"), felhom.eu
|
||||||
|
# architecture/16-storage-physical-identity.md §5, §7 (sizes ruled by the operator 2026-10-16). Installed as
|
||||||
|
# /usr/local/sbin/felhom-internal-place (config bundle). Called by felhom-host-install.sh (a new box) and by the agent's
|
||||||
|
# signed `add_internal_place` job (an old box) — ONE implementation of the size rule and of the only mkfs it does.
|
||||||
|
#
|
||||||
|
# felhom-internal-place plan <planned GiB> → prints the numbers, changes nothing
|
||||||
|
# felhom-internal-place create <planned GiB> → makes the place (or finds it), prints the result
|
||||||
|
#
|
||||||
|
# <planned GiB> is thin space still to be promised to the guest (rootfs + data volume) that does not exist yet:
|
||||||
|
# the installer passes 32 + the data volume; an old box passes 0 (its guest's volumes are already counted).
|
||||||
|
#
|
||||||
|
# The rule (16 §7, ruled): reserve = max(16 GiB, 10 % of the pool), at most 48 GiB; internal = pool − reserve −
|
||||||
|
# every thin volume already promised in the pool − <planned>, made only when ≥ 20 GiB. So nothing in the pool is ever
|
||||||
|
# promised beyond pool − reserve: a pool at 100 % stops every volume on the box.
|
||||||
|
#
|
||||||
|
# It trusts nothing it is told. Every rule is checked here, as root, and any doubt REFUSES (exit 1):
|
||||||
|
# - the pool is the `lvmthin: local-lvm` section of /etc/pve/storage.cfg (its vgname/thinpool), nothing else;
|
||||||
|
# - the only volume it creates, formats or reads is <vg>/felhom-belso;
|
||||||
|
# - it formats ONLY a felhom-belso that carries no signature at all (blkid -p finds nothing) — the one it just made,
|
||||||
|
# or one a crash left between lvcreate and mkfs; any other content REFUSES; it never resizes or removes anything.
|
||||||
|
# Output, one line: „created uuid=<fs-uuid> gib=<n>", „formatted uuid=… gib=…" (a crash left it empty), „existing uuid=<fs-uuid> gib=<n>", „skipped <why>" (exit 0),
|
||||||
|
# or „plan pool=… reserve=… promised=… planned=… internal=…". Pinned by configs/test_felhom_internal_place.py.
|
||||||
|
set -u
|
||||||
|
STORAGE_CFG=${FELHOM_STORAGE_CFG:-/etc/pve/storage.cfg}
|
||||||
|
LV_NAME=felhom-belso
|
||||||
|
LABEL=felhom-belso
|
||||||
|
MIN_GIB=20
|
||||||
|
|
||||||
|
die() { echo "felhom-internal-place: REFUSED: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
pool_of_local_lvm() { # prints "<vg> <thinpool>" of the lvmthin storage local-lvm, or nothing
|
||||||
|
awk '
|
||||||
|
/^[a-z]+:[ \t]/ { split($0, h, /:[ \t]+/); type = h[1]; id = h[2]; sub(/[ \t]+$/, "", id); in_s = (type == "lvmthin" && id == "local-lvm"); next }
|
||||||
|
in_s && /^[ \t]+vgname[ \t]/ { vg = $2 }
|
||||||
|
in_s && /^[ \t]+thinpool[ \t]/ { tp = $2 }
|
||||||
|
END { if (vg != "" && tp != "") print vg, tp }
|
||||||
|
' "$STORAGE_CFG"
|
||||||
|
}
|
||||||
|
|
||||||
|
gib_floor() { echo $(( $1 / 1073741824 )); }
|
||||||
|
|
||||||
|
[[ $# -eq 2 ]] || die "usage: felhom-internal-place plan|create <planned GiB>"
|
||||||
|
MODE=$1; PLANNED=$2
|
||||||
|
[[ "$MODE" == plan || "$MODE" == create ]] || die "mode must be plan or create"
|
||||||
|
[[ "$PLANNED" =~ ^(0|[1-9][0-9]{0,5})$ ]] || die "planned GiB must be a whole number"
|
||||||
|
|
||||||
|
read -r VG POOL < <(pool_of_local_lvm) || true
|
||||||
|
[[ -n "${VG:-}" && -n "${POOL:-}" ]] || die "no lvmthin storage local-lvm in $STORAGE_CFG"
|
||||||
|
[[ "$VG" =~ ^[A-Za-z0-9_.+-]+$ && "$POOL" =~ ^[A-Za-z0-9_.+-]+$ ]] || die "unexpected vg/pool name"
|
||||||
|
|
||||||
|
pool_b=$(lvs --noheadings --nosuffix --units b -o lv_size "$VG/$POOL" 2>/dev/null | tr -d ' ')
|
||||||
|
[[ "$pool_b" =~ ^[0-9]+$ ]] || die "cannot read the size of $VG/$POOL"
|
||||||
|
pool_gib=$(gib_floor "$pool_b")
|
||||||
|
|
||||||
|
# Every thin volume promised in the pool (its virtual size) — except the place itself and vzdump's short-lived
|
||||||
|
# snapshots (snap_*_vzdump), which exist only during a whole-box copy and are what the reserve is for.
|
||||||
|
promised_b=0; have_lv=false
|
||||||
|
while read -r name pl size; do
|
||||||
|
[[ -n "${name:-}" ]] || continue
|
||||||
|
[[ "$pl" == "$POOL" ]] || continue
|
||||||
|
if [[ "$name" == "$LV_NAME" ]]; then have_lv=true; continue; fi
|
||||||
|
[[ "$name" =~ ^snap_.*_vzdump$ ]] && continue
|
||||||
|
[[ "$size" =~ ^[0-9]+$ ]] || die "cannot read the size of $VG/$name"
|
||||||
|
promised_b=$(( promised_b + size ))
|
||||||
|
done < <(lvs --noheadings --nosuffix --units b -o lv_name,pool_lv,lv_size "$VG" 2>/dev/null)
|
||||||
|
promised_gib=$(( (promised_b + 1073741823) / 1073741824 )) # rounded UP: never promise a partial GiB twice
|
||||||
|
|
||||||
|
reserve=$(( pool_gib / 10 )); (( reserve < 16 )) && reserve=16; (( reserve > 48 )) && reserve=48
|
||||||
|
internal=$(( pool_gib - reserve - promised_gib - PLANNED ))
|
||||||
|
|
||||||
|
if [[ "$MODE" == plan ]]; then
|
||||||
|
echo "plan pool=$pool_gib reserve=$reserve promised=$promised_gib planned=$PLANNED internal=$internal exists=$have_lv"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
DEV=/dev/$VG/$LV_NAME
|
||||||
|
is_block() { [[ "$(stat -L -c %F "$1" 2>/dev/null)" == "block special file" ]]; }
|
||||||
|
fs_uuid() { blkid -p -s UUID -o value "$DEV" 2>/dev/null; }
|
||||||
|
fs_type() { blkid -p -s TYPE -o value "$DEV" 2>/dev/null; }
|
||||||
|
fs_label() { blkid -p -s LABEL -o value "$DEV" 2>/dev/null; }
|
||||||
|
lv_gib() { gib_floor "$(lvs --noheadings --nosuffix --units b -o lv_size "$VG/$LV_NAME" 2>/dev/null | tr -d ' ')"; }
|
||||||
|
|
||||||
|
if $have_lv; then
|
||||||
|
is_block "$DEV" || die "$DEV is not an active block device"
|
||||||
|
t=$(fs_type)
|
||||||
|
if [[ -n "$t" ]]; then
|
||||||
|
[[ "$t" == ext4 && "$(fs_label)" == "$LABEL" ]] || die "$DEV holds something that is not Felhom's ($t) — left alone"
|
||||||
|
echo "existing uuid=$(fs_uuid) gib=$(lv_gib)"; exit 0
|
||||||
|
fi
|
||||||
|
# A felhom-belso with NO signature: a crash between lvcreate and mkfs. Nothing can be on it — format it.
|
||||||
|
[[ -z "$(blkid -p -o value "$DEV" 2>/dev/null)" ]] || die "$DEV carries a signature blkid cannot name — left alone"
|
||||||
|
else
|
||||||
|
(( internal >= MIN_GIB )) || { echo "skipped only $internal GiB free for it (pool $pool_gib, reserve $reserve, promised $promised_gib, planned $PLANNED; needs $MIN_GIB)"; exit 0; }
|
||||||
|
lvcreate -q -y -V "${internal}G" -T "$VG/$POOL" -n "$LV_NAME" >/dev/null || die "lvcreate failed"
|
||||||
|
is_block "$DEV" || die "$DEV did not appear after lvcreate"
|
||||||
|
[[ -z "$(blkid -p -o value "$DEV" 2>/dev/null)" ]] || die "the new $DEV already carries a signature — left alone"
|
||||||
|
made=created
|
||||||
|
fi
|
||||||
|
mkfs.ext4 -q -L "$LABEL" "$DEV" >/dev/null || die "mkfs.ext4 failed on $DEV"
|
||||||
|
u=$(fs_uuid)
|
||||||
|
[[ "$u" =~ ^[0-9a-fA-F-]{36}$ ]] || die "no filesystem UUID after mkfs on $DEV"
|
||||||
|
echo "${made:-formatted} uuid=$u gib=$(lv_gib)"
|
||||||
@@ -58,14 +58,34 @@ fi
|
|||||||
|
|
||||||
# 3) mounted OUTSIDE Felhom's own drive area = a live foreign filesystem → catastrophic. Mounts under
|
# 3) mounted OUTSIDE Felhom's own drive area = a live foreign filesystem → catastrophic. Mounts under
|
||||||
# /mnt/felhom-drives are our own drives (the agent detaches before a re-init) → allowed.
|
# /mnt/felhom-drives are our own drives (the agent detaches before a re-init) → allowed.
|
||||||
while read -r mp; do
|
# Agent v0.158.0 (felhom.eu architecture/16 §6, partitions as places): a SIBLING partition that is Felhom's own
|
||||||
[[ -z "$mp" ]] && continue
|
# enrolled drive is mounted TWICE — raw at /mnt/<name> and under /mnt/felhom-drives (R-220). That raw mount is
|
||||||
case "$mp" in
|
# allowed only when the same device is ALSO mounted under /mnt/felhom-drives, a state only Felhom's enrolment
|
||||||
/mnt/felhom-drives|/mnt/felhom-drives/*) : ;;
|
# produces. The TARGET itself mounted anywhere outside /mnt/felhom-drives, or any mount elsewhere (/srv, /media,
|
||||||
*) die "device (or a partition) is mounted at $mp ($dev)" ;;
|
# /mnt/a/b), still REFUSES. Read from the kernel's mount table, every node of the disk (partitions and children).
|
||||||
esac
|
PROC_MOUNTS=/proc/mounts
|
||||||
done < <(lsblk -nro MOUNTPOINT "$whole" 2>/dev/null || true)
|
node_mounts() { awk -v d="$1" '$1 == d { print $2 }' "$PROC_MOUNTS"; }
|
||||||
|
disk_nodes="$(lsblk -nrpo NAME "$whole" 2>/dev/null || true)"
|
||||||
|
[[ -n "$disk_nodes" ]] || die "cannot list the nodes of $whole — refusing"
|
||||||
|
while read -r node; do
|
||||||
|
[[ -z "$node" ]] && continue
|
||||||
|
own=false
|
||||||
|
while read -r mp; do
|
||||||
|
case "$mp" in /mnt/felhom-drives/*) own=true ;; esac
|
||||||
|
done < <(node_mounts "$node")
|
||||||
|
while read -r mp; do
|
||||||
|
[[ -z "$mp" ]] && continue
|
||||||
|
case "$mp" in
|
||||||
|
/mnt/felhom-drives|/mnt/felhom-drives/*) : ;;
|
||||||
|
*)
|
||||||
|
if [[ "$node" != "$dev" && "$own" == true && "$mp" =~ ^/mnt/[A-Za-z0-9_][A-Za-z0-9_.-]*$ ]]; then
|
||||||
|
: # a sibling that is Felhom's own enrolled drive (raw mount + managed mount)
|
||||||
|
else
|
||||||
|
die "device (or a partition) is mounted at $mp ($node)"
|
||||||
|
fi ;;
|
||||||
|
esac
|
||||||
|
done < <(node_mounts "$node")
|
||||||
|
done <<< "$disk_nodes"
|
||||||
# 4) read-only device (audit D1): a device the kernel marks RO is never a formattable data disk.
|
# 4) read-only device (audit D1): a device the kernel marks RO is never a formattable data disk.
|
||||||
wbase="${whole#/dev/}"
|
wbase="${whole#/dev/}"
|
||||||
rof="/sys/block/$wbase/ro"
|
rof="/sys/block/$wbase/ro"
|
||||||
|
|||||||
@@ -191,6 +191,8 @@ BUNDLE_FILES = [
|
|||||||
("/usr/local/sbin/felhom-backup-target-apply", "felhom-backup-target-apply", 0o755, "bash", "replace"),
|
("/usr/local/sbin/felhom-backup-target-apply", "felhom-backup-target-apply", 0o755, "bash", "replace"),
|
||||||
# R-1006 (agent v0.156.0): the only delete of backup files — a dead half-written vzdump, re-checked as root.
|
# R-1006 (agent v0.156.0): the only delete of backup files — a dead half-written vzdump, re-checked as root.
|
||||||
("/usr/local/sbin/felhom-vzdump-partial-clean", "felhom-vzdump-partial-clean", 0o755, "bash", "replace"),
|
("/usr/local/sbin/felhom-vzdump-partial-clean", "felhom-vzdump-partial-clean", 0o755, "bash", "replace"),
|
||||||
|
# felhom.eu architecture/16 §5 (agent v0.158.0): the internal data place — the only lvcreate/mkfs of a system-disk volume.
|
||||||
|
("/usr/local/sbin/felhom-internal-place", "felhom-internal-place", 0o755, "bash", "replace"),
|
||||||
("/usr/local/sbin/felhom-os-apply", "felhom-os-apply", 0o755, "python", "replace"),
|
("/usr/local/sbin/felhom-os-apply", "felhom-os-apply", 0o755, "python", "replace"),
|
||||||
("/usr/local/sbin/felhom-crash-guard", "felhom-crash-guard", 0o755, "python", "replace"),
|
("/usr/local/sbin/felhom-crash-guard", "felhom-crash-guard", 0o755, "python", "replace"),
|
||||||
# R-861 (agent v0.146.0): the content checker for every agent-staged file a root program reads, and the two FIXED
|
# R-861 (agent v0.146.0): the content checker for every agent-staged file a root program reads, and the two FIXED
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for configs/felhom-internal-place (felhom.eu architecture/16 §5, §7 — sizes ruled 2026-10-16).
|
||||||
|
|
||||||
|
The helper is run for real under bash; `lvs`, `lvcreate`, `blkid`, `mkfs.ext4` and `stat` are PATH stubs that read and
|
||||||
|
write one JSON state file, so nothing on this machine is touched. Every refusal has a positive control: the same setup
|
||||||
|
without the one condition goes through — so a test cannot pass because the helper refuses everything.
|
||||||
|
Run: python3 configs/test_felhom_internal_place.py (also run by the Go suite, internal/internalplace)
|
||||||
|
"""
|
||||||
|
import json, os, pathlib, stat, subprocess, tempfile, unittest
|
||||||
|
|
||||||
|
HELPER = pathlib.Path(__file__).resolve().parent / "felhom-internal-place"
|
||||||
|
GIB = 1 << 30
|
||||||
|
|
||||||
|
STUB_LVS = r'''#!/usr/bin/env python3
|
||||||
|
import json, os, sys
|
||||||
|
st = json.load(open(os.environ["IP_STATE"]))
|
||||||
|
args = [a for a in sys.argv[1:] if not a.startswith("-") and a not in ("b",)]
|
||||||
|
fields = sys.argv[sys.argv.index("-o") + 1].split(",")
|
||||||
|
target = args[-1]
|
||||||
|
for lv in st["lvs"]:
|
||||||
|
full = st["vg"] + "/" + lv["name"]
|
||||||
|
if target == st["vg"] or target == full:
|
||||||
|
row = {"lv_name": lv["name"], "pool_lv": lv.get("pool", ""), "lv_size": str(lv["size"])}
|
||||||
|
print(" " + " ".join(row[f] for f in fields))
|
||||||
|
'''
|
||||||
|
STUB_LVCREATE = r'''#!/usr/bin/env python3
|
||||||
|
import json, os, sys
|
||||||
|
p = os.environ["IP_STATE"]; st = json.load(open(p))
|
||||||
|
a = sys.argv[1:]
|
||||||
|
size = int(a[a.index("-V") + 1].rstrip("G")) * (1 << 30)
|
||||||
|
vg, pool = a[a.index("-T") + 1].split("/")
|
||||||
|
st["lvs"].append({"name": a[a.index("-n") + 1], "pool": pool, "size": size, "active": True})
|
||||||
|
st.setdefault("calls", []).append("lvcreate " + " ".join(a))
|
||||||
|
if st.get("dirty_new"): st["fs"]["felhom-belso"] = {"TYPE": "", "RAW": "junk"}
|
||||||
|
json.dump(st, open(p, "w"))
|
||||||
|
'''
|
||||||
|
STUB_BLKID = r'''#!/usr/bin/env python3
|
||||||
|
import json, os, sys
|
||||||
|
st = json.load(open(os.environ["IP_STATE"]))
|
||||||
|
fs = st["fs"].get(os.path.basename(sys.argv[-1]), {})
|
||||||
|
if "-s" in sys.argv:
|
||||||
|
v = fs.get(sys.argv[sys.argv.index("-s") + 1], "")
|
||||||
|
if v: print(v)
|
||||||
|
else:
|
||||||
|
vals = [fs.get(k, "") for k in ("TYPE", "LABEL", "UUID", "RAW") if fs.get(k)]
|
||||||
|
if vals: print("\n".join(vals))
|
||||||
|
'''
|
||||||
|
STUB_MKFS = r'''#!/usr/bin/env python3
|
||||||
|
import json, os, sys
|
||||||
|
p = os.environ["IP_STATE"]; st = json.load(open(p))
|
||||||
|
dev = os.path.basename(sys.argv[-1])
|
||||||
|
st["fs"][dev] = {"TYPE": "ext4", "LABEL": sys.argv[sys.argv.index("-L") + 1], "UUID": "0b6e4c5a-1d2e-4f3a-9b8c-7d6e5f4a3b2c"}
|
||||||
|
st.setdefault("calls", []).append("mkfs.ext4 " + " ".join(sys.argv[1:]))
|
||||||
|
json.dump(st, open(p, "w"))
|
||||||
|
'''
|
||||||
|
STUB_STAT = r'''#!/usr/bin/env python3
|
||||||
|
import json, os, sys
|
||||||
|
st = json.load(open(os.environ["IP_STATE"]))
|
||||||
|
name = os.path.basename(sys.argv[-1])
|
||||||
|
if any(lv["name"] == name and lv.get("active", True) for lv in st["lvs"]): print("block special file")
|
||||||
|
else: sys.exit(1)
|
||||||
|
'''
|
||||||
|
|
||||||
|
CFG = "dir: local\n\tpath /var/lib/vz\n\tcontent iso,backup\n\nlvmthin: local-lvm\n\tthinpool data\n\tvgname pve\n\tcontent rootdir,images\n"
|
||||||
|
|
||||||
|
|
||||||
|
class Box:
|
||||||
|
def __init__(self, pool_gib, lvs=(), fs=None, cfg=CFG, **extra):
|
||||||
|
self.t = tempfile.TemporaryDirectory()
|
||||||
|
r = pathlib.Path(self.t.name)
|
||||||
|
self.bin = r / "bin"; self.bin.mkdir()
|
||||||
|
for name, body in (("lvs", STUB_LVS), ("lvcreate", STUB_LVCREATE), ("blkid", STUB_BLKID),
|
||||||
|
("mkfs.ext4", STUB_MKFS), ("stat", STUB_STAT)):
|
||||||
|
f = self.bin / name; f.write_text(body); f.chmod(0o755)
|
||||||
|
self.cfg = r / "storage.cfg"; self.cfg.write_text(cfg)
|
||||||
|
self.state = r / "state.json"
|
||||||
|
st = {"vg": "pve", "lvs": [{"name": "data", "pool": "", "size": pool_gib * GIB}] + [dict(l) for l in lvs],
|
||||||
|
"fs": fs or {}}
|
||||||
|
st.update(extra)
|
||||||
|
self.state.write_text(json.dumps(st))
|
||||||
|
|
||||||
|
def run(self, *args):
|
||||||
|
env = {"PATH": f"{self.bin}:/usr/bin:/bin", "IP_STATE": str(self.state), "FELHOM_STORAGE_CFG": str(self.cfg)}
|
||||||
|
p = subprocess.run(["bash", str(HELPER), *args], env=env, capture_output=True, text=True)
|
||||||
|
return p.returncode, p.stdout.strip(), p.stderr.strip()
|
||||||
|
|
||||||
|
def st(self):
|
||||||
|
return json.loads(self.state.read_text())
|
||||||
|
|
||||||
|
def calls(self):
|
||||||
|
return self.st().get("calls", [])
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.t.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
def thin(name, gib, pool="data"):
|
||||||
|
return {"name": name, "pool": pool, "size": gib * GIB}
|
||||||
|
|
||||||
|
|
||||||
|
class Sizes(unittest.TestCase):
|
||||||
|
"""The operator's table (SPACE-CHECK §4, ruled 2026-10-16), as the helper computes it on a fresh box."""
|
||||||
|
|
||||||
|
def plan(self, pool, planned, lvs=()):
|
||||||
|
b = Box(pool, lvs)
|
||||||
|
try:
|
||||||
|
rc, out, err = b.run("plan", str(planned))
|
||||||
|
self.assertEqual(rc, 0, err)
|
||||||
|
self.assertEqual(b.calls(), [], "plan must change nothing")
|
||||||
|
return dict(kv.split("=") for kv in out.split()[1:])
|
||||||
|
finally:
|
||||||
|
b.close()
|
||||||
|
|
||||||
|
def test_the_ruled_table(self):
|
||||||
|
# disk → pool (GiB), data volume (planned = 32 + mp0), expected internal
|
||||||
|
for pool, mp0, reserve, internal in ((140, 50, 16, 42), (349, 100, 34, 183), (800, 130, 48, 590), (1720, 130, 48, 1510)):
|
||||||
|
got = self.plan(pool, 32 + mp0)
|
||||||
|
self.assertEqual((int(got["reserve"]), int(got["internal"])), (reserve, internal), (pool, got))
|
||||||
|
|
||||||
|
def test_promised_volumes_count_but_vzdump_snapshots_and_other_pools_do_not(self):
|
||||||
|
got = self.plan(349, 0, [thin("vm-9201-disk-0", 32), thin("vm-9201-disk-1", 250),
|
||||||
|
thin("snap_vm-9201-disk-1_vzdump", 250), thin("vm-500-disk-0", 40, pool="other")])
|
||||||
|
self.assertEqual((int(got["promised"]), int(got["internal"])), (282, 33), got)
|
||||||
|
|
||||||
|
|
||||||
|
class Create(unittest.TestCase):
|
||||||
|
def test_a_fresh_box_gets_the_place(self):
|
||||||
|
b = Box(349)
|
||||||
|
try:
|
||||||
|
rc, out, err = b.run("create", "132")
|
||||||
|
self.assertEqual(rc, 0, err)
|
||||||
|
self.assertRegex(out, r"^created uuid=[0-9a-f-]{36} gib=183$")
|
||||||
|
self.assertEqual(len([c for c in b.calls() if c.startswith("lvcreate")]), 1)
|
||||||
|
self.assertIn("-V 183G -T pve/data -n felhom-belso", b.calls()[0])
|
||||||
|
self.assertIn("-L felhom-belso", b.calls()[1])
|
||||||
|
finally:
|
||||||
|
b.close()
|
||||||
|
|
||||||
|
def test_too_little_space_makes_nothing(self):
|
||||||
|
b = Box(53) # demo-hp's 128 GB disk
|
||||||
|
try:
|
||||||
|
rc, out, err = b.run("create", str(32 + 50))
|
||||||
|
self.assertEqual(rc, 0, err)
|
||||||
|
self.assertTrue(out.startswith("skipped "), out)
|
||||||
|
self.assertEqual(b.calls(), [])
|
||||||
|
finally:
|
||||||
|
b.close()
|
||||||
|
|
||||||
|
def test_an_existing_place_is_found_never_formatted(self):
|
||||||
|
fs = {"felhom-belso": {"TYPE": "ext4", "LABEL": "felhom-belso", "UUID": "11111111-2222-3333-4444-555555555555"}}
|
||||||
|
b = Box(349, [thin("felhom-belso", 183)], fs)
|
||||||
|
try:
|
||||||
|
rc, out, err = b.run("create", "0")
|
||||||
|
self.assertEqual(rc, 0, err)
|
||||||
|
self.assertEqual(out, "existing uuid=11111111-2222-3333-4444-555555555555 gib=183")
|
||||||
|
self.assertEqual(b.calls(), [], "an existing place must never be created or formatted again")
|
||||||
|
finally:
|
||||||
|
b.close()
|
||||||
|
|
||||||
|
def test_a_foreign_filesystem_on_the_name_is_refused(self):
|
||||||
|
# positive control: test_an_existing_place_is_found_never_formatted (same LV, Felhom's label) goes through
|
||||||
|
for fs in ({"TYPE": "xfs", "LABEL": "felhom-belso", "UUID": "x"}, {"TYPE": "ext4", "LABEL": "films", "UUID": "y"}):
|
||||||
|
b = Box(349, [thin("felhom-belso", 183)], {"felhom-belso": fs})
|
||||||
|
try:
|
||||||
|
rc, out, err = b.run("create", "0")
|
||||||
|
self.assertEqual(rc, 1, (fs, out))
|
||||||
|
self.assertIn("REFUSED", err)
|
||||||
|
self.assertEqual(b.calls(), [], fs)
|
||||||
|
finally:
|
||||||
|
b.close()
|
||||||
|
|
||||||
|
def test_a_crash_between_lvcreate_and_mkfs_is_finished(self):
|
||||||
|
b = Box(349, [thin("felhom-belso", 183)], {})
|
||||||
|
try:
|
||||||
|
rc, out, err = b.run("create", "0")
|
||||||
|
self.assertEqual(rc, 0, err)
|
||||||
|
self.assertRegex(out, r"^formatted uuid=")
|
||||||
|
self.assertEqual([c.split()[0] for c in b.calls()], ["mkfs.ext4"])
|
||||||
|
finally:
|
||||||
|
b.close()
|
||||||
|
|
||||||
|
def test_an_unnamed_signature_is_refused(self):
|
||||||
|
b = Box(349, [thin("felhom-belso", 183)], {"felhom-belso": {"RAW": "something"}})
|
||||||
|
try:
|
||||||
|
rc, out, err = b.run("create", "0")
|
||||||
|
self.assertEqual(rc, 1, out)
|
||||||
|
self.assertEqual(b.calls(), [])
|
||||||
|
finally:
|
||||||
|
b.close()
|
||||||
|
|
||||||
|
def test_a_new_volume_that_is_not_empty_is_not_formatted(self):
|
||||||
|
b = Box(349, dirty_new=True)
|
||||||
|
try:
|
||||||
|
rc, out, err = b.run("create", "132")
|
||||||
|
self.assertEqual(rc, 1, out)
|
||||||
|
self.assertNotIn("mkfs", " ".join(b.calls()))
|
||||||
|
finally:
|
||||||
|
b.close()
|
||||||
|
|
||||||
|
def test_bad_arguments_and_no_local_lvm_are_refused(self):
|
||||||
|
b = Box(349, cfg="dir: local\n\tpath /var/lib/vz\n")
|
||||||
|
try:
|
||||||
|
self.assertEqual(b.run("create", "0")[0], 1)
|
||||||
|
self.assertEqual(b.calls(), [])
|
||||||
|
finally:
|
||||||
|
b.close()
|
||||||
|
b = Box(349)
|
||||||
|
try:
|
||||||
|
for args in (("create", "-1"), ("create", "1x"), ("wipe", "0"), ("create",)):
|
||||||
|
self.assertEqual(b.run(*args)[0], 1, args)
|
||||||
|
self.assertEqual(b.calls(), [])
|
||||||
|
finally:
|
||||||
|
b.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for configs/felhom-mkfs-guarded.sh — the ONLY mkfs path the agent's sudoers permits.
|
||||||
|
|
||||||
|
First tests of this wrapper (agent v0.158.0), written with its change for partitions as places (felhom.eu
|
||||||
|
architecture/16 §6): a SIBLING partition that is Felhom's own enrolled drive (mounted raw at /mnt/<name> AND under
|
||||||
|
/mnt/felhom-drives) no longer blocks formatting the empty partition next to it; every other mount still refuses.
|
||||||
|
|
||||||
|
HOW IT RUNS ANYWHERE: the wrapper is copied with five literal host paths replaced (each replacement is asserted, so a
|
||||||
|
drifted wrapper fails here instead of silently testing nothing): /proc/mounts → a fake mount table, the block-device
|
||||||
|
test → true (no real /dev nodes here), the pvs candidates → a stub, /sys/block → a fake tree, the mkfs binaries → a
|
||||||
|
recorder. `lsblk` is a PATH stub answering the three calls the wrapper makes from one JSON disk description.
|
||||||
|
Every refusal has a positive control (the same disk without the one condition is formatted).
|
||||||
|
Run: python3 configs/test_felhom_mkfs_guarded.py (also run by the Go suite, internal/storage)
|
||||||
|
"""
|
||||||
|
import json, os, pathlib, subprocess, tempfile, unittest
|
||||||
|
|
||||||
|
WRAPPER = pathlib.Path(__file__).resolve().parent / "felhom-mkfs-guarded.sh"
|
||||||
|
|
||||||
|
STUB_LSBLK = r'''#!/usr/bin/env python3
|
||||||
|
import json, os, sys
|
||||||
|
st = json.load(open(os.environ["MG_STATE"]))
|
||||||
|
a = sys.argv[1:]; dev = a[-1]
|
||||||
|
nodes = st["nodes"] # [{"name": "/dev/sdb", "pk": "", "fstype": ""}, ...]
|
||||||
|
if a[0] == "-ndo" and a[1] == "PKNAME":
|
||||||
|
for n in nodes:
|
||||||
|
if n["name"] == dev and n.get("pk"): print(n["pk"])
|
||||||
|
elif a[0] == "-nrpo" and a[1] == "NAME":
|
||||||
|
if not st.get("lsblk_empty"):
|
||||||
|
for n in nodes: print(n["name"])
|
||||||
|
elif a[0] == "-nro" and a[1] == "FSTYPE":
|
||||||
|
for n in nodes: print(n.get("fstype", ""))
|
||||||
|
elif a[0] == "-nro" and a[1] == "MOUNTPOINT": # the pre-v0.158.0 wrapper's call: one mountpoint per node, like lsblk
|
||||||
|
mt = [l.split()[:2] for l in open(os.environ["MG_MOUNTS"]) if l.strip()]
|
||||||
|
for n in nodes:
|
||||||
|
mps = [m for d, m in mt if d == n["name"]]
|
||||||
|
print(mps[0] if mps else "")
|
||||||
|
'''
|
||||||
|
STUB_MKFS = '#!/bin/sh\necho "$0 $*" >> "$MG_CALLS"\n'
|
||||||
|
STUB_PVS = '#!/bin/sh\nprintf "%s\\n" $MG_PVS\n'
|
||||||
|
|
||||||
|
|
||||||
|
class Disk:
|
||||||
|
def __init__(self, nodes, mounts, ro="0", pvs="", lsblk_empty=False):
|
||||||
|
self.t = tempfile.TemporaryDirectory()
|
||||||
|
r = pathlib.Path(self.t.name)
|
||||||
|
self.bin = r / "bin"; self.bin.mkdir()
|
||||||
|
(self.bin / "lsblk").write_text(STUB_LSBLK); (self.bin / "lsblk").chmod(0o755)
|
||||||
|
for n in ("mkfs.ext4", "mkfs.xfs"):
|
||||||
|
(self.bin / n).write_text(STUB_MKFS); (self.bin / n).chmod(0o755)
|
||||||
|
(self.bin / "pvs").write_text(STUB_PVS); (self.bin / "pvs").chmod(0o755)
|
||||||
|
self.state = r / "state.json"
|
||||||
|
self.state.write_text(json.dumps({"nodes": nodes, "lsblk_empty": lsblk_empty}))
|
||||||
|
self.mounts = r / "mounts"
|
||||||
|
self.mounts.write_text("".join(f"{d} {m} ext4 rw 0 0\n" for d, m in mounts))
|
||||||
|
self.sys = r / "sys"
|
||||||
|
for n in nodes:
|
||||||
|
if not n.get("pk"):
|
||||||
|
d = self.sys / os.path.basename(n["name"]); d.mkdir(parents=True); (d / "ro").write_text(ro + "\n")
|
||||||
|
self.calls = r / "calls"
|
||||||
|
self.pvs = pvs
|
||||||
|
src = WRAPPER.read_text()
|
||||||
|
subs = [
|
||||||
|
("/proc/mounts", str(self.mounts)),
|
||||||
|
('[[ -b "$dev" ]]', "true"),
|
||||||
|
("for c in /usr/sbin/pvs /sbin/pvs; do", f"for c in {self.bin}/pvs; do"),
|
||||||
|
('rof="/sys/block/', f'rof="{self.sys}/'),
|
||||||
|
("exec /usr/sbin/mkfs.ext4", f"exec {self.bin}/mkfs.ext4"),
|
||||||
|
("exec /usr/sbin/mkfs.xfs ", f"exec {self.bin}/mkfs.xfs "),
|
||||||
|
]
|
||||||
|
for a, b in subs:
|
||||||
|
assert a in src, f"the wrapper no longer contains {a!r} — update this test's substitutions"
|
||||||
|
src = src.replace(a, b)
|
||||||
|
self.copy = r / "mkfs-guarded"; self.copy.write_text(src)
|
||||||
|
|
||||||
|
def run(self, dev, fstype="ext4"):
|
||||||
|
env = {"PATH": f"{self.bin}:/usr/bin:/bin", "MG_STATE": str(self.state), "MG_CALLS": str(self.calls),
|
||||||
|
"MG_MOUNTS": str(self.mounts),
|
||||||
|
"MG_PVS": self.pvs}
|
||||||
|
p = subprocess.run(["bash", str(self.copy), dev, fstype], env=env, capture_output=True, text=True)
|
||||||
|
made = self.calls.read_text().split("\n")[0] if self.calls.exists() else ""
|
||||||
|
return p.returncode, p.stderr.strip(), made
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.t.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
def disk_sdb(sdb1_fstype="ext4", sdb2_fstype=""):
|
||||||
|
return [{"name": "/dev/sdb", "pk": "", "fstype": ""},
|
||||||
|
{"name": "/dev/sdb1", "pk": "sdb", "fstype": sdb1_fstype},
|
||||||
|
{"name": "/dev/sdb2", "pk": "sdb", "fstype": sdb2_fstype}]
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = ("/dev/sda2", "/")
|
||||||
|
OWN_SDB1 = [("/dev/sdb1", "/mnt/hdd_1"), ("/dev/sdb1", "/mnt/felhom-drives/hdd_1")]
|
||||||
|
|
||||||
|
|
||||||
|
class Partitions(unittest.TestCase):
|
||||||
|
def check(self, mounts, dev="/dev/sdb2", nodes=None, want_ok=True, **kw):
|
||||||
|
d = Disk(nodes or disk_sdb(), [ROOT] + mounts, **kw)
|
||||||
|
try:
|
||||||
|
rc, err, made = d.run(dev)
|
||||||
|
if want_ok:
|
||||||
|
self.assertEqual(rc, 0, err)
|
||||||
|
self.assertTrue(made.endswith(f"mkfs.ext4 -F {dev}"), made)
|
||||||
|
else:
|
||||||
|
self.assertEqual(rc, 1, (mounts, made))
|
||||||
|
self.assertIn("REFUSED", err)
|
||||||
|
self.assertEqual(made, "", "a refused format must not run mkfs")
|
||||||
|
return err
|
||||||
|
finally:
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
def test_the_empty_part_next_to_felhoms_own_drive_is_formatted(self):
|
||||||
|
self.check(OWN_SDB1)
|
||||||
|
|
||||||
|
def test_a_sibling_mounted_raw_only_is_refused(self):
|
||||||
|
self.check([("/dev/sdb1", "/mnt/hdd_1")], want_ok=False)
|
||||||
|
|
||||||
|
def test_a_sibling_mounted_elsewhere_is_refused_even_with_a_managed_mount(self):
|
||||||
|
for elsewhere in ("/srv/data", "/media/usb", "/mnt/a/b", "/home/x"):
|
||||||
|
self.check([("/dev/sdb1", elsewhere), ("/dev/sdb1", "/mnt/felhom-drives/hdd_1")], want_ok=False)
|
||||||
|
|
||||||
|
def test_the_target_itself_mounted_raw_is_refused(self):
|
||||||
|
self.check([("/dev/sdb2", "/mnt/films"), ("/dev/sdb2", "/mnt/felhom-drives/films")], want_ok=False)
|
||||||
|
|
||||||
|
def test_the_target_mounted_only_under_felhom_drives_is_formatted(self):
|
||||||
|
self.check([("/dev/sdb2", "/mnt/felhom-drives/films")])
|
||||||
|
|
||||||
|
def test_an_unlistable_disk_is_refused(self):
|
||||||
|
self.check([], want_ok=False, lsblk_empty=True)
|
||||||
|
|
||||||
|
def test_the_catastrophic_cases_still_refuse(self):
|
||||||
|
self.check([("/dev/sdb1", "/")], want_ok=False) # system disk
|
||||||
|
self.check(OWN_SDB1, nodes=disk_sdb(sdb1_fstype="LVM2_member"), want_ok=False) # a member signature
|
||||||
|
self.check(OWN_SDB1, pvs="/dev/sdb1", want_ok=False) # an LVM PV (pvs)
|
||||||
|
self.check(OWN_SDB1, ro="1", want_ok=False) # read-only
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -118,6 +118,8 @@ var manifest = []Capability{
|
|||||||
{"guest-shutdown", "household power-off: guest shutdown (R-998)", "/usr/sbin/pct", []string{"shutdown", "9201", "--timeout", "120"}, false, ""},
|
{"guest-shutdown", "household power-off: guest shutdown (R-998)", "/usr/sbin/pct", []string{"shutdown", "9201", "--timeout", "120"}, false, ""},
|
||||||
{"host-poweroff", "household power-off: host power-off (R-998)", "/usr/bin/systemctl", []string{"poweroff"}, false, ""},
|
{"host-poweroff", "household power-off: host power-off (R-998)", "/usr/bin/systemctl", []string{"poweroff"}, false, ""},
|
||||||
{"vzdump-partial-clean", "dead half-written vzdump removal (R-1006)", "/usr/local/sbin/felhom-vzdump-partial-clean", []string{"local", "9201", "vzdump-lxc-9201-2026_10_14-04_42_18.tar.dat"}, false, ""},
|
{"vzdump-partial-clean", "dead half-written vzdump removal (R-1006)", "/usr/local/sbin/felhom-vzdump-partial-clean", []string{"local", "9201", "vzdump-lxc-9201-2026_10_14-04_42_18.tar.dat"}, false, ""},
|
||||||
|
{"internal-place-plan", "internal data place: the size plan (16 §7)", "/usr/local/sbin/felhom-internal-place", []string{"plan", "0"}, false, ""},
|
||||||
|
{"internal-place-create", "internal data place: create on an old box (signed add_internal_place)", "/usr/local/sbin/felhom-internal-place", []string{"create", "0"}, false, ""},
|
||||||
|
|
||||||
// ---- LAN split-horizon resolver (dnsmasq) ----
|
// ---- LAN split-horizon resolver (dnsmasq) ----
|
||||||
{"dnsmasq-install", "dnsmasq package install", "/usr/bin/apt-get", []string{"install", "-y", "-q", "dnsmasq"}, false, ""},
|
{"dnsmasq-install", "dnsmasq package install", "/usr/bin/apt-get", []string{"install", "-y", "-q", "dnsmasq"}, false, ""},
|
||||||
|
|||||||
@@ -72,6 +72,11 @@ var r861Injections = []string{
|
|||||||
"/usr/local/sbin/felhom-vzdump-partial-clean local;id 9201 vzdump-lxc-9201-2026_10_14-04_42_18.tar.dat",
|
"/usr/local/sbin/felhom-vzdump-partial-clean local;id 9201 vzdump-lxc-9201-2026_10_14-04_42_18.tar.dat",
|
||||||
"/usr/local/sbin/felhom-vzdump-partial-clean local 9201",
|
"/usr/local/sbin/felhom-vzdump-partial-clean local 9201",
|
||||||
"/usr/local/sbin/felhom-vzdump-partial-clean --help",
|
"/usr/local/sbin/felhom-vzdump-partial-clean --help",
|
||||||
|
"/usr/local/sbin/felhom-internal-place create 132",
|
||||||
|
"/usr/local/sbin/felhom-internal-place create 0 extra",
|
||||||
|
"/usr/local/sbin/felhom-internal-place create 0;id",
|
||||||
|
"/usr/local/sbin/felhom-internal-place wipe 0",
|
||||||
|
"/usr/local/sbin/felhom-internal-place create",
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSudoersRefusesTheR861Injections(t *testing.T) {
|
func TestSudoersRefusesTheR861Injections(t *testing.T) {
|
||||||
|
|||||||
@@ -39,11 +39,14 @@ func TestHostReport_ContractMatchesGolden(t *testing.T) {
|
|||||||
State: StorageStateAttached, Reachable: true, ClassHint: "fast",
|
State: StorageStateAttached, Reachable: true, ClassHint: "fast",
|
||||||
ThinPool: &ThinPoolFill{DataUsedFraction: 0.42},
|
ThinPool: &ThinPoolFill{DataUsedFraction: 0.42},
|
||||||
Smart: SmartSummary{Health: SmartUnknown},
|
Smart: SmartSummary{Health: SmartUnknown},
|
||||||
|
// v0.157.0 (`16` §3): the identity keys ride on both rows so the key-set guard sees them.
|
||||||
|
Kind: "system", PhysicalDisks: []string{"serial:ata:SanDisk_X600:182195804614"}, Identity: "proven",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "usb-backup", Type: StorageTypeUSB, DurableID: "uuid:x",
|
Name: "usb-backup", Type: StorageTypeUSB, DurableID: "uuid:x",
|
||||||
State: StorageStateAttached, Reachable: true, MountPath: "/mnt/usb-backup", TotalBytes: 2000000000000,
|
State: StorageStateAttached, Reachable: true, MountPath: "/mnt/usb-backup", TotalBytes: 2000000000000,
|
||||||
Smart: SmartSummary{Health: SmartUnknown},
|
Smart: SmartSummary{Health: SmartUnknown},
|
||||||
|
Kind: "disk", PhysicalDisks: []string{"serial:usb:External:000000000000"}, Identity: "unknown", IdentityReason: "usb_bridge",
|
||||||
},
|
},
|
||||||
// A pbs target so the recipe's pbs coord has a storage.cfg row to resolve its namespace
|
// A pbs target so the recipe's pbs coord has a storage.cfg row to resolve its namespace
|
||||||
// from (R-106). Without one the fixture produces namespace_state=unknown while the golden
|
// from (R-106). Without one the fixture produces namespace_state=unknown while the golden
|
||||||
|
|||||||
@@ -431,6 +431,15 @@ type StorageTarget struct {
|
|||||||
// metadata fill is null until Phase B's privileged `lvs` read.
|
// metadata fill is null until Phase B's privileged `lvs` read.
|
||||||
ThinPool *ThinPoolFill `json:"thin_pool,omitempty"`
|
ThinPool *ThinPoolFill `json:"thin_pool,omitempty"`
|
||||||
|
|
||||||
|
// Physical-disk identity (felhom.eu architecture/16-storage-physical-identity.md §3, agent v0.157.0):
|
||||||
|
// the place's kind, the keys of the whole physical disks under it, and whether the agent can prove
|
||||||
|
// them (proven | unknown + identity_reason vm|network|usb_bridge|weak_wwn|no_serial). Agent-computed
|
||||||
|
// from /sys + udev only; empty when not computed (an absent device, an older agent). Additive.
|
||||||
|
Kind string `json:"kind,omitempty"`
|
||||||
|
PhysicalDisks []string `json:"physical_disks,omitempty"`
|
||||||
|
Identity string `json:"identity,omitempty"`
|
||||||
|
IdentityReason string `json:"identity_reason,omitempty"`
|
||||||
|
|
||||||
// Smart is the disk-health summary, populated in Phase B via smartctl (sudoers). In
|
// Smart is the disk-health summary, populated in Phase B via smartctl (sudoers). In
|
||||||
// Phase A it is {health:"UNKNOWN", <counters null>} — the keys are committed now so the
|
// Phase A it is {health:"UNKNOWN", <counters null>} — the keys are committed now so the
|
||||||
// contract is stable across both phases.
|
// contract is stable across both phases.
|
||||||
|
|||||||
@@ -46,6 +46,9 @@
|
|||||||
"backing_device": "",
|
"backing_device": "",
|
||||||
"class_hint": "fast",
|
"class_hint": "fast",
|
||||||
"role": "",
|
"role": "",
|
||||||
|
"kind": "system",
|
||||||
|
"physical_disks": ["serial:ata:SanDisk_X600:182195804614"],
|
||||||
|
"identity": "proven",
|
||||||
"thin_pool": { "data_used_fraction": 0.42, "metadata_used_fraction": null },
|
"thin_pool": { "data_used_fraction": 0.42, "metadata_used_fraction": null },
|
||||||
"smart": {
|
"smart": {
|
||||||
"health": "UNKNOWN",
|
"health": "UNKNOWN",
|
||||||
@@ -74,6 +77,10 @@
|
|||||||
"backing_device": "/dev/sdb1",
|
"backing_device": "/dev/sdb1",
|
||||||
"class_hint": "slow",
|
"class_hint": "slow",
|
||||||
"role": "",
|
"role": "",
|
||||||
|
"kind": "disk",
|
||||||
|
"physical_disks": ["serial:usb:External:000000000000"],
|
||||||
|
"identity": "unknown",
|
||||||
|
"identity_reason": "usb_bridge",
|
||||||
"smart": {
|
"smart": {
|
||||||
"health": "UNKNOWN",
|
"health": "UNKNOWN",
|
||||||
"temperature_c": null,
|
"temperature_c": null,
|
||||||
|
|||||||
@@ -188,6 +188,13 @@ type DiskInfo struct {
|
|||||||
// device that exposes no SMART (USB bridge, unread) is ABSENT, not a misleading zero-value UNKNOWN;
|
// device that exposes no SMART (USB bridge, unread) is ABSENT, not a misleading zero-value UNKNOWN;
|
||||||
// the controller feature-detects presence and renders "Nincs adat" when nil (never alarms).
|
// the controller feature-detects presence and renders "Nincs adat" when nil (never alarms).
|
||||||
Smart *hub.SmartSummary `json:"smart,omitempty"`
|
Smart *hub.SmartSummary `json:"smart,omitempty"`
|
||||||
|
// Physical-disk identity (v0.157.0, felhom.eu architecture/16-storage-physical-identity.md §3) — the
|
||||||
|
// same agent-computed verdict the hub report carries. Empty when not computed (an absent drive, no
|
||||||
|
// resolver). Additive; an older controller ignores it.
|
||||||
|
Kind string `json:"kind,omitempty"`
|
||||||
|
PhysicalDisks []string `json:"physical_disks,omitempty"`
|
||||||
|
Identity string `json:"identity,omitempty"`
|
||||||
|
IdentityReason string `json:"identity_reason,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDisks lists the host's drives + data-bearing flags (read-only/benign).
|
// handleDisks lists the host's drives + data-bearing flags (read-only/benign).
|
||||||
@@ -227,6 +234,8 @@ func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) {
|
|||||||
// really is the target. Reported here so the controller can name the drive in an
|
// really is the target. Reported here so the controller can name the drive in an
|
||||||
// absent-target alarm and hide the destructive controls the agent would refuse anyway.
|
// absent-target alarm and hide the destructive controls the agent would refuse anyway.
|
||||||
BackupTarget: t.Name == primaryTargetID,
|
BackupTarget: t.Name == primaryTargetID,
|
||||||
|
// `16` §3: copied from the observer, which computed it from /sys + udev.
|
||||||
|
Kind: t.Kind, PhysicalDisks: t.PhysicalDisks, Identity: t.Identity, IdentityReason: t.IdentityReason,
|
||||||
}
|
}
|
||||||
// Intermediary model: the stable in-guest path + whether felhom-data is bound under the parent.
|
// Intermediary model: the stable in-guest path + whether felhom-data is bound under the parent.
|
||||||
// Only user-data /mnt/<name> drives have a guest path (system/backup mounts never cross in).
|
// Only user-data /mnt/<name> drives have a guest path (system/backup mounts never cross in).
|
||||||
@@ -398,6 +407,10 @@ func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) {
|
|||||||
di.Smart = &sm
|
di.Smart = &sm
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// `16` §3: a registry drive's identity, judged on its OWN mount only (never root's).
|
||||||
|
if p, kind, ok := s.ownMountIdentity(d.MountPath); ok {
|
||||||
|
di.Kind, di.PhysicalDisks, di.Identity, di.IdentityReason = kind, p.PhysicalDisks, p.Identity, p.Reason
|
||||||
|
}
|
||||||
// R-118: statfs ONLY while the drive's device is present. With the device gone the raw
|
// R-118: statfs ONLY while the drive's device is present. With the device gone the raw
|
||||||
// mountpoint reverts to a bare directory on the ROOT filesystem, and statfs would report
|
// mountpoint reverts to a bare directory on the ROOT filesystem, and statfs would report
|
||||||
// pve-root's size as this drive's (measured: a 4 GB drive advertising 46 GiB). Same trap
|
// pve-root's size as this drive's (measured: a 4 GB drive advertising 46 GiB). Same trap
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package localapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The internal data place („Belső tárhely", agent v0.158.0) — felhom.eu architecture/16-storage-physical-identity.md
|
||||||
|
// §5, §7. On a one-disk box the root helper configs/felhom-internal-place makes a second thin volume in local-lvm
|
||||||
|
// (<vg>/felhom-belso, ext4, label felhom-belso). The installer (a new box) or the signed add_internal_place job (an old
|
||||||
|
// box) then writes InternalPlaceRecord to the agent's state dir. From then on the agent treats the volume exactly like
|
||||||
|
// an enrolled drive: a Felhom mount unit by fs-UUID at /mnt/belso (EnsureMount), the `enrolled` intent, and — when the
|
||||||
|
// controller asks with its own token (POST /disks/guest-attach) — the bind at /mnt/felhom-drives/belso. So it is a
|
||||||
|
// HOST mount under the guest's mp8 parent: not in the guest config, not in vzdump, not in PBS (`16` §5).
|
||||||
|
//
|
||||||
|
// What it is NOT: a drive the household can format, wipe, eject or decommission. Its device is a device-mapper volume
|
||||||
|
// on the system disk, so the role gate (roleForMountPath → system) and the format path (ValidateBlockDevice refuses
|
||||||
|
// dm) refuse all four — pinned by internalplace_test.go. Its identity is the system disk's (build A), so a Tier-2 copy
|
||||||
|
// between it and sys_drive is never a second copy.
|
||||||
|
const (
|
||||||
|
InternalPlaceName = "belso"
|
||||||
|
InternalPlaceWhere = "/mnt/belso"
|
||||||
|
// InternalPlaceFile is the record's name inside the agent's state dir.
|
||||||
|
InternalPlaceFile = "internal-place.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
var reFSUUID = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
|
||||||
|
|
||||||
|
// InternalPlaceRecord is the state file the maker writes once the volume exists and is formatted.
|
||||||
|
type InternalPlaceRecord struct {
|
||||||
|
FSUUID string `json:"fs_uuid"`
|
||||||
|
GiB int `json:"gib,omitempty"`
|
||||||
|
MadeBy string `json:"made_by,omitempty"` // "installer" | "signed-job"
|
||||||
|
MadeAt string `json:"made_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadInternalPlace reads the record; ok=false when there is none (the common case: a box with no internal place).
|
||||||
|
func ReadInternalPlace(file string) (InternalPlaceRecord, bool, error) {
|
||||||
|
var rec InternalPlaceRecord
|
||||||
|
b, err := os.ReadFile(file)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return rec, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return rec, false, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &rec); err != nil {
|
||||||
|
return rec, false, fmt.Errorf("internal place record %s: %w", file, err)
|
||||||
|
}
|
||||||
|
if !reFSUUID.MatchString(rec.FSUUID) {
|
||||||
|
return rec, false, fmt.Errorf("internal place record %s: fs_uuid %q is not a filesystem UUID", file, rec.FSUUID)
|
||||||
|
}
|
||||||
|
return rec, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteInternalPlace writes the record atomically (0600).
|
||||||
|
func WriteInternalPlace(file string, rec InternalPlaceRecord) error {
|
||||||
|
if !reFSUUID.MatchString(rec.FSUUID) {
|
||||||
|
return fmt.Errorf("internal place: fs_uuid %q is not a filesystem UUID", rec.FSUUID)
|
||||||
|
}
|
||||||
|
b, err := json.MarshalIndent(rec, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := file + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// internalPlaceState memoises what EnsureInternalPlace last saw, so the 20-second tick logs a state CHANGE once and
|
||||||
|
// does no work on the steady state.
|
||||||
|
type internalPlaceState struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
done string // the fs-UUID that is mounted and recorded enrolled
|
||||||
|
lastWhy string // the last reason it could not be ensured (logged once per change)
|
||||||
|
}
|
||||||
|
|
||||||
|
// internalPlaceUUID returns the configured internal place's fs-UUID, or "" when this box has none (or the record is
|
||||||
|
// unreadable — logged by EnsureInternalPlace).
|
||||||
|
func (s *Server) internalPlaceUUID() string {
|
||||||
|
if s.internalPlaceFile == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rec, ok, err := ReadInternalPlace(s.internalPlaceFile)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return rec.FSUUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// isInternalPlace reports whether a host path is this box's internal place: its raw mount (/mnt/belso) or its bind
|
||||||
|
// under the guests' shared parent (/mnt/felhom-drives/belso — the path POST /disks/same-disk judges).
|
||||||
|
func (s *Server) isInternalPlace(hostPath string) bool {
|
||||||
|
p := path.Clean(hostPath)
|
||||||
|
return (p == InternalPlaceWhere || p == StableParentDir+"/"+InternalPlaceName) && s.internalPlaceUUID() != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureInternalPlace mounts the internal place on the host at /mnt/belso by fs-UUID and records it `enrolled`,
|
||||||
|
// when this box has one. Idempotent and cheap on the steady state; safe on the 20-second tick. It never formats,
|
||||||
|
// never unmounts, and never mounts anything at /mnt/belso that is not the recorded UUID: a household drive that
|
||||||
|
// happens to be named „belso" keeps its mount, and the internal place waits (logged once).
|
||||||
|
func (s *Server) EnsureInternalPlace(ctx context.Context) {
|
||||||
|
if s.internalPlaceFile == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rec, ok, err := ReadInternalPlace(s.internalPlaceFile)
|
||||||
|
if err != nil {
|
||||||
|
s.internalPlaceNote("record unreadable: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uuid := rec.FSUUID
|
||||||
|
st := &s.ipState
|
||||||
|
st.mu.Lock()
|
||||||
|
done := st.done == uuid
|
||||||
|
st.mu.Unlock()
|
||||||
|
|
||||||
|
// What is at /mnt/belso now, if anything?
|
||||||
|
mountedUUID, mounted := "", false
|
||||||
|
if mounts, merr := s.hostReader().Mounts(); merr == nil {
|
||||||
|
for _, m := range mounts {
|
||||||
|
if path.Clean(m.MountPoint) == InternalPlaceWhere {
|
||||||
|
mounted = true
|
||||||
|
if u, okU := s.hostReader().ResolveUUID(m.Device); okU {
|
||||||
|
mountedUUID = u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mounted && mountedUUID != "" && mountedUUID != uuid {
|
||||||
|
s.internalPlaceNote("another filesystem (uuid " + mountedUUID + ") is mounted at " + InternalPlaceWhere + " — the internal place waits")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A Felhom unit for /mnt/belso that names another UUID (a household drive called „belso", unplugged): never
|
||||||
|
// overwrite it — that would silently take the drive's name and mount point.
|
||||||
|
if s.driveTargets != nil {
|
||||||
|
if drives, derr := s.driveTargets.Known(ctx); derr == nil {
|
||||||
|
for _, d := range drives {
|
||||||
|
if path.Clean(d.MountPath) == InternalPlaceWhere && d.UUID != "" && d.UUID != uuid {
|
||||||
|
s.internalPlaceNote("a drive unit for " + InternalPlaceWhere + " names another uuid (" + d.UUID + ") — the internal place waits")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mounted && mountedUUID == uuid {
|
||||||
|
if !done {
|
||||||
|
s.recordIntent(ctx, InternalPlaceWhere, "enrolled")
|
||||||
|
st.mu.Lock()
|
||||||
|
st.done, st.lastWhy = uuid, ""
|
||||||
|
st.mu.Unlock()
|
||||||
|
s.logger.Info("internal place: mounted and enrolled", "where", InternalPlaceWhere, "fs_uuid", uuid, "gib", rec.GiB)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.disks == nil {
|
||||||
|
s.internalPlaceNote("disk management is not configured on this host")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.resolveStorageDevice != nil {
|
||||||
|
if _, rerr := s.resolveStorageDevice("uuid:" + uuid); rerr != nil {
|
||||||
|
s.internalPlaceNote("its volume is not present (uuid " + uuid + ")")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
if err := s.disks.EnsureMount(ctx, storage.MountSpec{Name: InternalPlaceName, UUID: uuid, Where: InternalPlaceWhere, FSType: "ext4"}); err != nil {
|
||||||
|
s.internalPlaceNote("mount failed: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.recordIntent(ctx, InternalPlaceWhere, "enrolled")
|
||||||
|
st.mu.Lock()
|
||||||
|
st.done, st.lastWhy = uuid, ""
|
||||||
|
st.mu.Unlock()
|
||||||
|
s.logger.Info("internal place: mounted on the host", "where", InternalPlaceWhere, "fs_uuid", uuid, "gib", rec.GiB,
|
||||||
|
"duration_ms", time.Since(start).Milliseconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) internalPlaceNote(why string) {
|
||||||
|
st := &s.ipState
|
||||||
|
st.mu.Lock()
|
||||||
|
changed := st.lastWhy != why
|
||||||
|
st.lastWhy, st.done = why, ""
|
||||||
|
st.mu.Unlock()
|
||||||
|
if changed {
|
||||||
|
s.logger.Warn("internal place: not ensured", "why", why)
|
||||||
|
} else {
|
||||||
|
s.logger.Debug("internal place: still not ensured", "why", why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InternalPlaceFileIn is the record's path inside a state dir.
|
||||||
|
func InternalPlaceFileIn(stateDir string) string { return filepath.Join(stateDir, InternalPlaceFile) }
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
package localapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The internal data place (felhom.eu architecture/16 §5, agent v0.158.0): mounted like an enrolled drive, but never
|
||||||
|
// at the cost of a household drive, never formatted, ejected or decommissioned, and the same disk as sys_drive.
|
||||||
|
|
||||||
|
const ipUUID = "0b6e4c5a-1d2e-4f3a-9b8c-7d6e5f4a3b2c"
|
||||||
|
const ipDev = "/dev/mapper/pve-felhom--belso"
|
||||||
|
|
||||||
|
// ipHost is a HostReader whose mount table the test changes, and whose by-uuid map it controls.
|
||||||
|
type ipHost struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
mounts []storage.Mount
|
||||||
|
uuids map[string]string // device → fs-UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ipHost) Mounts() ([]storage.Mount, error) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
return append([]storage.Mount(nil), h.mounts...), nil
|
||||||
|
}
|
||||||
|
func (h *ipHost) ResolveUUID(dev string) (string, bool) { u, ok := h.uuids[dev]; return u, ok }
|
||||||
|
func (h *ipHost) DeviceExists(string) bool { return true }
|
||||||
|
func (h *ipHost) Rotational(string) (bool, bool) { return false, false }
|
||||||
|
func (h *ipHost) Removable(string) (bool, bool) { return false, false }
|
||||||
|
func (h *ipHost) BlockSlaves(string) ([]string, bool) { return nil, false }
|
||||||
|
|
||||||
|
func newIPHost(extra ...storage.Mount) *ipHost {
|
||||||
|
return &ipHost{
|
||||||
|
// root on a whole-disk partition (as sysOnSDA): the system disk resolves, so a USB drive is user-data.
|
||||||
|
mounts: append([]storage.Mount{{Device: "/dev/sda1", MountPoint: "/", FSType: "ext4"}}, extra...),
|
||||||
|
uuids: map[string]string{ipDev: ipUUID, "/dev/sdb1": "99999999-aaaa-bbbb-cccc-dddddddddddd"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ipRig struct {
|
||||||
|
srv *Server
|
||||||
|
disks *fakeDiskOps
|
||||||
|
intent *fakeIntent
|
||||||
|
}
|
||||||
|
|
||||||
|
func newIPRig(t *testing.T, host *ipHost, known []storage.KnownTarget, record bool) ipRig {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
file := filepath.Join(dir, InternalPlaceFile)
|
||||||
|
if record {
|
||||||
|
if err := WriteInternalPlace(file, InternalPlaceRecord{FSUUID: ipUUID, GiB: 183, MadeBy: "installer"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d, in := &fakeDiskOps{}, newFakeIntent()
|
||||||
|
srv, err := NewServer(Options{
|
||||||
|
ListenAddr: "127.0.0.1:0", Guests: &fakeGuests{}, Backups: &fakeBackups{}, Store: &fakeStore{},
|
||||||
|
Storage: fakeStorage{}, Tokens: staticTokens{"A": 8200}, Disks: d, DiskGate: &fakeGate{},
|
||||||
|
HostReader: host, Intent: in, DriveTargets: fakeKnownTargets{drives: known},
|
||||||
|
InternalPlaceFile: file, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv.baseCtx = context.Background()
|
||||||
|
srv.resolveStorageDevice = func(id string) (string, error) {
|
||||||
|
if id == "uuid:"+ipUUID {
|
||||||
|
return "/dev/dm-7", nil
|
||||||
|
}
|
||||||
|
return "", errors.New("absent")
|
||||||
|
}
|
||||||
|
return ipRig{srv, d, in}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInternalPlace_NoRecord_DoesNothing(t *testing.T) {
|
||||||
|
r := newIPRig(t, newIPHost(), nil, false)
|
||||||
|
r.srv.EnsureInternalPlace(context.Background())
|
||||||
|
if len(r.disks.mountCalls) != 0 {
|
||||||
|
t.Fatalf("no record: no mount may be made, got %+v", r.disks.mountCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInternalPlace_Record_MountsByUUIDAtMntBelso(t *testing.T) {
|
||||||
|
r := newIPRig(t, newIPHost(), nil, true)
|
||||||
|
r.srv.EnsureInternalPlace(context.Background())
|
||||||
|
if len(r.disks.mountCalls) != 1 {
|
||||||
|
t.Fatalf("want exactly one EnsureMount, got %+v", r.disks.mountCalls)
|
||||||
|
}
|
||||||
|
got := r.disks.mountCalls[0]
|
||||||
|
if got.UUID != ipUUID || got.Where != InternalPlaceWhere || got.FSType != "ext4" || got.Name != InternalPlaceName {
|
||||||
|
t.Fatalf("EnsureMount spec: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInternalPlace_AlreadyMounted_EnrolledOnceNoRemount(t *testing.T) {
|
||||||
|
host := newIPHost(storage.Mount{Device: ipDev, MountPoint: InternalPlaceWhere, FSType: "ext4"})
|
||||||
|
r := newIPRig(t, host, nil, true)
|
||||||
|
r.srv.EnsureInternalPlace(context.Background())
|
||||||
|
r.srv.EnsureInternalPlace(context.Background())
|
||||||
|
if len(r.disks.mountCalls) != 0 {
|
||||||
|
t.Fatalf("already mounted: no EnsureMount, got %+v", r.disks.mountCalls)
|
||||||
|
}
|
||||||
|
if r.intent.Get("uuid:"+ipUUID) != storage.IntentEnrolled {
|
||||||
|
t.Fatalf("the internal place must be recorded enrolled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A household drive called „belso" keeps its mount point: the internal place never takes it.
|
||||||
|
// Positive control: TestInternalPlace_Record_MountsByUUIDAtMntBelso (the same record, /mnt/belso free).
|
||||||
|
func TestInternalPlace_NeverTakesAHouseholdDriveNamedBelso(t *testing.T) {
|
||||||
|
mounted := newIPHost(storage.Mount{Device: "/dev/sdb1", MountPoint: InternalPlaceWhere, FSType: "ext4"})
|
||||||
|
r := newIPRig(t, mounted, nil, true)
|
||||||
|
r.srv.EnsureInternalPlace(context.Background())
|
||||||
|
if len(r.disks.mountCalls) != 0 || r.intent.Get("uuid:"+ipUUID) != "" {
|
||||||
|
t.Fatalf("a foreign filesystem at /mnt/belso: nothing may be mounted or recorded, got %+v", r.disks.mountCalls)
|
||||||
|
}
|
||||||
|
unplugged := []storage.KnownTarget{{Name: "belso", UUID: "99999999-aaaa-bbbb-cccc-dddddddddddd", MountPath: InternalPlaceWhere, MountBacked: true}}
|
||||||
|
r2 := newIPRig(t, newIPHost(), unplugged, true)
|
||||||
|
r2.srv.EnsureInternalPlace(context.Background())
|
||||||
|
if len(r2.disks.mountCalls) != 0 {
|
||||||
|
t.Fatalf("an unplugged household drive's unit for /mnt/belso must not be overwritten, got %+v", r2.disks.mountCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInternalPlace_VolumeAbsent_NoMount(t *testing.T) {
|
||||||
|
r := newIPRig(t, newIPHost(), nil, true)
|
||||||
|
r.srv.resolveStorageDevice = func(string) (string, error) { return "", errors.New("absent") }
|
||||||
|
r.srv.EnsureInternalPlace(context.Background())
|
||||||
|
if len(r.disks.mountCalls) != 0 {
|
||||||
|
t.Fatalf("absent volume: no mount, got %+v", r.disks.mountCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The household cannot eject or decommission it: its device is a device-mapper volume on the system disk.
|
||||||
|
// Positive control: the same calls for a whole-disk USB drive (/dev/sdb1) are not refused by role.
|
||||||
|
func TestInternalPlace_EjectAndDecommissionRefused(t *testing.T) {
|
||||||
|
host := newIPHost(
|
||||||
|
storage.Mount{Device: ipDev, MountPoint: InternalPlaceWhere, FSType: "ext4"},
|
||||||
|
storage.Mount{Device: "/dev/sdb1", MountPoint: "/mnt/hdd_1", FSType: "ext4"},
|
||||||
|
)
|
||||||
|
r := newIPRig(t, host, nil, true)
|
||||||
|
h := r.srv.Handler()
|
||||||
|
for _, route := range []string{"/disks/eject", "/disks/decommission"} {
|
||||||
|
if w := do(t, h, "POST", route, "A", `{"where":"/mnt/belso"}`); w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("%s /mnt/belso: want 403, got %d %s", route, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if w := do(t, h, "POST", "/disks/eject", "A", `{"where":"/mnt/hdd_1"}`); w.Code == http.StatusForbidden {
|
||||||
|
t.Fatalf("positive control: a USB drive's eject must not be refused by role, got %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
for _, u := range r.disks.unmountCalls {
|
||||||
|
if u == InternalPlaceWhere {
|
||||||
|
t.Fatalf("the internal place must never be unmounted, got %+v", r.disks.unmountCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The consequence `16` §5 needs: a copy between the internal place and sys_drive is the SAME disk (build A then never
|
||||||
|
// counts it), and the place is reported as kind internal.
|
||||||
|
type ipIdentHost struct{ fakeIdentHost }
|
||||||
|
|
||||||
|
func (h *ipIdentHost) KernelName(dev string) (string, bool) {
|
||||||
|
if dev == ipDev {
|
||||||
|
return "dm-7", true
|
||||||
|
}
|
||||||
|
return h.fakeIdentHost.KernelName(dev)
|
||||||
|
}
|
||||||
|
func (h *ipIdentHost) Slaves(n string) []string {
|
||||||
|
if n == "dm-7" {
|
||||||
|
return []string{"dm-3"} // felhom-belso → the thin pool
|
||||||
|
}
|
||||||
|
if n == "dm-3" {
|
||||||
|
return []string{"sda3"} // the pool → the system disk's LVM partition
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInternalPlace_IsTheSystemDiskAndKindInternal(t *testing.T) {
|
||||||
|
ih := &ipIdentHost{fakeIdentHost{
|
||||||
|
virt: "none",
|
||||||
|
parent: map[string]string{"sda3": "sda"},
|
||||||
|
props: map[string]map[string]string{"sda": {"ID_BUS": "ata", "ID_MODEL": "SanDisk", "ID_SERIAL_SHORT": "1"}},
|
||||||
|
mounts: []storage.Mount{
|
||||||
|
{Device: "/dev/mapper/pve-root", MountPoint: "/", FSType: "ext4"},
|
||||||
|
{Device: "/dev/mapper/pve-root", MountPoint: "/mnt/felhom-drives", FSType: "ext4"},
|
||||||
|
{Device: ipDev, MountPoint: InternalPlaceWhere, FSType: "ext4"},
|
||||||
|
{Device: ipDev, MountPoint: "/mnt/felhom-drives/belso", FSType: "ext4"},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
fg := &fakeGuestsCfg{mounts: map[int]map[string]string{8200: {
|
||||||
|
"mp0": "local-lvm:vm-8200-disk-1,mp=/var/lib/felhom,backup=1,size=100G",
|
||||||
|
"mp8": "/mnt/felhom-drives,mp=/mnt/felhom-drives",
|
||||||
|
}}}
|
||||||
|
dir := t.TempDir()
|
||||||
|
file := filepath.Join(dir, InternalPlaceFile)
|
||||||
|
if err := WriteInternalPlace(file, InternalPlaceRecord{FSUUID: ipUUID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv, err := NewServer(Options{
|
||||||
|
ListenAddr: "127.0.0.1:0", Guests: fg, Backups: &fakeBackups{}, Store: &fakeStore{},
|
||||||
|
Storage: fakeStorage{targets: []hub.StorageTarget{provenSSD}}, Tokens: staticTokens{"A": 8200},
|
||||||
|
HostReader: fakeHostReader{mounts: ih.mounts}, Identity: &storage.IdentityResolver{Host: ih},
|
||||||
|
InternalPlaceFile: file, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv.baseCtx = context.Background()
|
||||||
|
got, code := sameDisk(t, srv.Handler(), "/mnt/felhom-drives/belso/felhom-data/jellyfin", "/mnt/sys_drive")
|
||||||
|
if code != http.StatusOK || got.Different || !got.Proven || got.Reason != "same_disk" {
|
||||||
|
t.Fatalf("internal place vs sys_drive: want same disk, proven; got %d %+v", code, got)
|
||||||
|
}
|
||||||
|
if got.A.Kind != storage.PlaceKindInternal {
|
||||||
|
t.Fatalf("kind: want %q, got %q", storage.PlaceKindInternal, got.A.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInternalPlaceRecord_RefusesANonUUID(t *testing.T) {
|
||||||
|
file := filepath.Join(t.TempDir(), InternalPlaceFile)
|
||||||
|
if err := WriteInternalPlace(file, InternalPlaceRecord{FSUUID: "../../etc"}); err == nil {
|
||||||
|
t.Fatal("a non-UUID must not be written")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The root helper's own tests (configs/test_felhom_internal_place.py — the ruled size table, never formatting what
|
||||||
|
// holds anything) run with the Go suite.
|
||||||
|
func TestInternalPlaceHelperPythonSuite(t *testing.T) {
|
||||||
|
py, err := exec.LookPath("python3")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("python3 not found")
|
||||||
|
}
|
||||||
|
_, file, _, _ := runtime.Caller(0)
|
||||||
|
script := filepath.Join(filepath.Dir(file), "..", "..", "configs", "test_felhom_internal_place.py")
|
||||||
|
out, err := exec.Command(py, script).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("helper suite failed: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package localapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// POST /disks/same-disk (v0.157.0) — felhom.eu architecture/16-storage-physical-identity.md §4.
|
||||||
|
//
|
||||||
|
// The controller asks whether two of ITS paths sit on the same physical disk; the agent answers from its
|
||||||
|
// own /sys + udev read, never from the caller's claim (`16` §10). A guest path is mapped only to a place
|
||||||
|
// the agent itself made:
|
||||||
|
// - /mnt/felhom-drives/<name>[/…] → the host mount at exactly /mnt/felhom-drives/<name> (the drive's
|
||||||
|
// felhom-data bind, or a network share's mount). A path whose <name> is not mounted there (an absent
|
||||||
|
// drive: the bare directory on the root disk) is UNMAPPED — never "the system disk".
|
||||||
|
// - /mnt/sys_drive, /var/lib/felhom, /var/lib/docker [/…] → the guest's data volume (mp0, mp=/var/lib/felhom;
|
||||||
|
// or a legacy mp at that exact path) → the PVE storage holding it → that storage's identity.
|
||||||
|
// - anything else → unmapped.
|
||||||
|
// Answer: {different, proven, reason, a, b}. Act on `proven` first: different+proven = a counted second
|
||||||
|
// copy; same_disk+proven = not a second copy; proven=false = unknown (reason vm|network|usb_bridge|
|
||||||
|
// weak_wwn|no_serial|unmapped). Pinned by samedisk_test.go.
|
||||||
|
|
||||||
|
type sameDiskRequest struct {
|
||||||
|
VMID int `json:"vmid,omitempty"`
|
||||||
|
A string `json:"a"`
|
||||||
|
B string `json:"b"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SamePlace is one side of the answer.
|
||||||
|
type SamePlace struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Mapped bool `json:"mapped"`
|
||||||
|
Kind string `json:"kind,omitempty"`
|
||||||
|
PhysicalDisks []string `json:"physical_disks,omitempty"`
|
||||||
|
Identity string `json:"identity,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SameDiskResponse is the wire answer.
|
||||||
|
type SameDiskResponse struct {
|
||||||
|
Different bool `json:"different"`
|
||||||
|
Proven bool `json:"proven"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
A SamePlace `json:"a"`
|
||||||
|
B SamePlace `json:"b"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// sysDataPaths are the guest paths that live on the guest's one data volume (golden v3: mp0 at
|
||||||
|
// /var/lib/felhom, its docker/ and sys_drive/ folders bound in the guest — 07 §7.5.1).
|
||||||
|
var sysDataPaths = []string{"/var/lib/felhom", "/mnt/sys_drive", "/var/lib/docker"}
|
||||||
|
|
||||||
|
func (s *Server) handleSameDisk(w http.ResponseWriter, r *http.Request, vmid int) {
|
||||||
|
if s.ident == nil {
|
||||||
|
writeErr(w, http.StatusServiceUnavailable, "disk identity not configured on this host")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req sameDiskRequest
|
||||||
|
if !decodeBody(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !path.IsAbs(req.A) || !path.IsAbs(req.B) {
|
||||||
|
writeErr(w, http.StatusBadRequest, "a and b must be absolute guest paths")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a := s.mapGuestPlace(r.Context(), vmid, req.A)
|
||||||
|
b := s.mapGuestPlace(r.Context(), vmid, req.B)
|
||||||
|
resp := SameDiskResponse{A: a.view, B: b.view}
|
||||||
|
resp.Different, resp.Proven, resp.Reason = storage.Relation(a.id, b.id)
|
||||||
|
s.logger.Info("local-api: same-disk", "vmid", vmid, "a", req.A, "b", req.B,
|
||||||
|
"different", resp.Different, "proven", resp.Proven, "reason", resp.Reason)
|
||||||
|
writeOK(w, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
type mappedPlace struct {
|
||||||
|
view SamePlace
|
||||||
|
id storage.PlaceIdentity
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) mapGuestPlace(ctx context.Context, vmid int, guestPath string) mappedPlace {
|
||||||
|
p := path.Clean(guestPath)
|
||||||
|
out := mappedPlace{view: SamePlace{Path: p}}
|
||||||
|
set := func(kind string, id storage.PlaceIdentity) mappedPlace {
|
||||||
|
if !id.Computed() {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
out.id = id
|
||||||
|
out.view.Mapped, out.view.Kind = true, kind
|
||||||
|
out.view.PhysicalDisks, out.view.Identity, out.view.Reason = id.PhysicalDisks, id.Identity, id.Reason
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(p, StableParentDir+"/") {
|
||||||
|
name := strings.SplitN(strings.TrimPrefix(p, StableParentDir+"/"), "/", 2)[0]
|
||||||
|
if id, kind, ok := s.ownMountIdentity(StableParentDir + "/" + name); ok {
|
||||||
|
return set(kind, id)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, root := range sysDataPaths {
|
||||||
|
if p != root && !strings.HasPrefix(p, root+"/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id := s.guestDataVolumeIdentity(ctx, vmid, root)
|
||||||
|
return set(storage.PlaceKindSystem, id)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownMountIdentity judges a HOST path only when it is ITSELF a mountpoint (an enrolled drive's raw mount
|
||||||
|
// or its felhom-data bind, a network share). A path that is not its own mount — an absent drive, whose
|
||||||
|
// directory now sits on the root disk — is not computed, never root's identity (the R-118 trap).
|
||||||
|
func (s *Server) ownMountIdentity(hostPath string) (storage.PlaceIdentity, string, bool) {
|
||||||
|
if s.ident == nil || hostPath == "" {
|
||||||
|
return storage.PlaceIdentity{}, "", false
|
||||||
|
}
|
||||||
|
mounts, err := s.hostReader().Mounts()
|
||||||
|
if err != nil {
|
||||||
|
return storage.PlaceIdentity{}, "", false
|
||||||
|
}
|
||||||
|
m, ok := storage.MountFor(mounts, hostPath)
|
||||||
|
if !ok || path.Clean(m.MountPoint) != path.Clean(hostPath) {
|
||||||
|
return storage.PlaceIdentity{}, "", false
|
||||||
|
}
|
||||||
|
id := s.ident.HostPath(hostPath)
|
||||||
|
if !id.Computed() {
|
||||||
|
return id, "", false
|
||||||
|
}
|
||||||
|
kind := storage.PlaceKindDisk
|
||||||
|
switch {
|
||||||
|
case s.isInternalPlace(hostPath): // v0.158.0 `16` §5: the one-disk box's data place, on the system disk
|
||||||
|
kind = storage.PlaceKindInternal
|
||||||
|
case id.Reason == storage.ReasonNetwork:
|
||||||
|
kind = storage.PlaceKindNetwork
|
||||||
|
case strings.HasPrefix(m.Device, "/dev/") && !strings.HasPrefix(m.Device, "/dev/mapper/") && isPartitionPath(m.Device):
|
||||||
|
kind = storage.PlaceKindPartition
|
||||||
|
}
|
||||||
|
return id, kind, true
|
||||||
|
}
|
||||||
|
|
||||||
|
var rePartitionName = regexp.MustCompile(`^(nvme[0-9]+n[0-9]+p[0-9]+|(sd|vd|hd)[a-z]+[0-9]+)$`)
|
||||||
|
|
||||||
|
func isPartitionPath(dev string) bool { return rePartitionName.MatchString(path.Base(dev)) }
|
||||||
|
|
||||||
|
// guestDataVolumeIdentity finds the guest mountpoint that holds root (mp=/var/lib/felhom on golden v3, or a
|
||||||
|
// legacy mp at that exact path) and returns the identity of the PVE storage that holds its volume.
|
||||||
|
func (s *Server) guestDataVolumeIdentity(ctx context.Context, vmid int, root string) storage.PlaceIdentity {
|
||||||
|
if s.guests == nil || s.storage == nil {
|
||||||
|
return storage.PlaceIdentity{}
|
||||||
|
}
|
||||||
|
cfg, err := s.guests.GuestConfig(ctx, vmid)
|
||||||
|
if err != nil {
|
||||||
|
return storage.PlaceIdentity{}
|
||||||
|
}
|
||||||
|
storeID, fallback := "", ""
|
||||||
|
for _, spec := range cfg.MountPoints() {
|
||||||
|
st, mp, _ := parseMount(spec)
|
||||||
|
if strings.HasPrefix(st, "/") {
|
||||||
|
continue // a host bind (mp8, mp9), not a volume
|
||||||
|
}
|
||||||
|
switch mp {
|
||||||
|
case root:
|
||||||
|
storeID = st // a legacy mp at exactly this path wins
|
||||||
|
case "/var/lib/felhom":
|
||||||
|
fallback = st // golden v3: the one data volume
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if storeID == "" {
|
||||||
|
storeID = fallback
|
||||||
|
}
|
||||||
|
if storeID == "" {
|
||||||
|
return storage.PlaceIdentity{}
|
||||||
|
}
|
||||||
|
targets, err := s.storage.Observe(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return storage.PlaceIdentity{}
|
||||||
|
}
|
||||||
|
for _, t := range targets {
|
||||||
|
if t.Name == storeID {
|
||||||
|
return identityOfTarget(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return storage.PlaceIdentity{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func identityOfTarget(t hub.StorageTarget) storage.PlaceIdentity {
|
||||||
|
if t.Identity == "" {
|
||||||
|
return storage.PlaceIdentity{}
|
||||||
|
}
|
||||||
|
disks := t.PhysicalDisks
|
||||||
|
if disks == nil {
|
||||||
|
disks = []string{}
|
||||||
|
}
|
||||||
|
return storage.PlaceIdentity{PhysicalDisks: disks, Identity: t.Identity, Reason: t.IdentityReason}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package localapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// POST /disks/same-disk (`16` §4) — the controller's Tier-2 question, answered from the agent's own read.
|
||||||
|
|
||||||
|
type fakeIdentHost struct {
|
||||||
|
mounts []storage.Mount
|
||||||
|
parent map[string]string
|
||||||
|
props map[string]map[string]string
|
||||||
|
virt string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeIdentHost) Mounts() ([]storage.Mount, error) { return f.mounts, nil }
|
||||||
|
func (f *fakeIdentHost) KernelName(dev string) (string, bool) {
|
||||||
|
if len(dev) > 5 && dev[:5] == "/dev/" {
|
||||||
|
return dev[5:], true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
func (f *fakeIdentHost) Parent(n string) (string, bool) { p, ok := f.parent[n]; return p, ok }
|
||||||
|
func (f *fakeIdentHost) Slaves(string) []string { return nil }
|
||||||
|
func (f *fakeIdentHost) LoopBackingFile(string) (string, bool) { return "", false }
|
||||||
|
func (f *fakeIdentHost) UdevProps(n string) map[string]string { return f.props[n] }
|
||||||
|
func (f *fakeIdentHost) Virt() string { return f.virt }
|
||||||
|
|
||||||
|
// sameDiskRig: guest 8200 (token A) with mp0 on local-lvm (the system SSD, proven) + the given mounts.
|
||||||
|
func sameDiskRig(t *testing.T, host *fakeIdentHost, localLVM hub.StorageTarget) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
fg := &fakeGuestsCfg{mounts: map[int]map[string]string{8200: {
|
||||||
|
"mp0": "local-lvm:vm-8200-disk-1,mp=/var/lib/felhom,backup=1,size=70G",
|
||||||
|
"mp8": "/mnt/felhom-drives,mp=/mnt/felhom-drives",
|
||||||
|
}}}
|
||||||
|
srv, err := NewServer(Options{
|
||||||
|
ListenAddr: "127.0.0.1:0", Guests: fg, Backups: &fakeBackups{}, Store: &fakeStore{},
|
||||||
|
Storage: fakeStorage{targets: []hub.StorageTarget{localLVM}}, Tokens: staticTokens{"A": 8200},
|
||||||
|
HostReader: fakeHostReader{mounts: host.mounts},
|
||||||
|
Identity: &storage.IdentityResolver{Host: host},
|
||||||
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv.baseCtx = context.Background()
|
||||||
|
return srv.Handler()
|
||||||
|
}
|
||||||
|
|
||||||
|
var provenSSD = hub.StorageTarget{Name: "local-lvm", Type: hub.StorageTypeLVMThin, Kind: "system",
|
||||||
|
PhysicalDisks: []string{"serial:ata:SanDisk:1"}, Identity: "proven"}
|
||||||
|
|
||||||
|
func baseHost() *fakeIdentHost {
|
||||||
|
return &fakeIdentHost{
|
||||||
|
virt: "none",
|
||||||
|
parent: map[string]string{"sdc1": "sdc", "sdc2": "sdc"},
|
||||||
|
props: map[string]map[string]string{
|
||||||
|
"sdb": {"ID_BUS": "ata", "ID_MODEL": "TOSHIBA", "ID_SERIAL_SHORT": "65NOP3HDT"},
|
||||||
|
"sdc": {"ID_BUS": "usb", "ID_MODEL": "Portable", "ID_SERIAL_SHORT": "NAAB7C21"},
|
||||||
|
},
|
||||||
|
mounts: []storage.Mount{
|
||||||
|
{Device: "/dev/mapper/pve-root", MountPoint: "/", FSType: "ext4"},
|
||||||
|
{Device: "/dev/mapper/pve-root", MountPoint: "/mnt/felhom-drives", FSType: "ext4"},
|
||||||
|
{Device: "/dev/sdb", MountPoint: "/mnt/felhom-drives/hdd_1", FSType: "ext4"},
|
||||||
|
{Device: "/dev/sdc1", MountPoint: "/mnt/felhom-drives/a", FSType: "ext4"},
|
||||||
|
{Device: "/dev/sdc2", MountPoint: "/mnt/felhom-drives/b", FSType: "ext4"},
|
||||||
|
{Device: "//nas/films", MountPoint: "/mnt/felhom-drives/nas", FSType: "cifs"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameDisk(t *testing.T, h http.Handler, a, b string) (SameDiskResponse, int) {
|
||||||
|
t.Helper()
|
||||||
|
body, _ := json.Marshal(map[string]string{"a": a, "b": b})
|
||||||
|
w := do(t, h, "POST", "/disks/same-disk", "A", string(body))
|
||||||
|
var env struct {
|
||||||
|
Data SameDiskResponse `json:"data"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &env)
|
||||||
|
return env.Data, w.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSameDisk_DriveVsSystemData_DifferentProven(t *testing.T) {
|
||||||
|
h := sameDiskRig(t, baseHost(), provenSSD)
|
||||||
|
got, code := sameDisk(t, h, "/mnt/felhom-drives/hdd_1/felhom-data/paperless", "/mnt/sys_drive")
|
||||||
|
if code != http.StatusOK || !got.Different || !got.Proven || got.Reason != "" {
|
||||||
|
t.Fatalf("drive vs sys_drive: want different+proven, got %d %+v", code, got)
|
||||||
|
}
|
||||||
|
if got.B.Kind != "system" || got.A.Kind != "disk" {
|
||||||
|
t.Fatalf("kinds: got a=%q b=%q", got.A.Kind, got.B.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSameDisk_TwoPartitionsOfOneDisk_SameProven(t *testing.T) {
|
||||||
|
h := sameDiskRig(t, baseHost(), provenSSD)
|
||||||
|
got, _ := sameDisk(t, h, "/mnt/felhom-drives/a", "/mnt/felhom-drives/b/x")
|
||||||
|
if got.Different || !got.Proven || got.Reason != "same_disk" || got.A.Kind != "partition" {
|
||||||
|
t.Fatalf("two partitions: want same+proven, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSameDisk_InsideAVM_NotProven(t *testing.T) {
|
||||||
|
host := baseHost()
|
||||||
|
host.virt = "kvm"
|
||||||
|
h := sameDiskRig(t, host, hub.StorageTarget{Name: "local-lvm", Identity: "unknown", IdentityReason: "vm",
|
||||||
|
PhysicalDisks: []string{"serial:scsi:QEMU_HARDDISK:drive-scsi0"}})
|
||||||
|
got, _ := sameDisk(t, h, "/mnt/felhom-drives/hdd_1", "/mnt/sys_drive")
|
||||||
|
if got.Proven || got.Reason != "vm" {
|
||||||
|
t.Fatalf("inside a VM: want not proven (vm), got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSameDisk_NetworkAndUnmapped(t *testing.T) {
|
||||||
|
h := sameDiskRig(t, baseHost(), provenSSD)
|
||||||
|
if got, _ := sameDisk(t, h, "/mnt/felhom-drives/nas", "/mnt/sys_drive"); got.Proven || got.Reason != "network" {
|
||||||
|
t.Fatalf("a NAS: want not proven (network), got %+v", got)
|
||||||
|
}
|
||||||
|
// An ABSENT drive: its directory sits on the root disk — must be unmapped, never "the system disk".
|
||||||
|
if got, _ := sameDisk(t, h, "/mnt/felhom-drives/gone", "/mnt/sys_drive"); got.Proven || got.Reason != "unmapped" || got.A.Mapped {
|
||||||
|
t.Fatalf("an absent drive: want unmapped, got %+v", got)
|
||||||
|
}
|
||||||
|
if got, _ := sameDisk(t, h, "/opt/elsewhere", "/mnt/sys_drive"); got.Proven || got.Reason != "unmapped" {
|
||||||
|
t.Fatalf("an unknown guest path: want unmapped, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSameDisk_ScopedAndValidated(t *testing.T) {
|
||||||
|
h := sameDiskRig(t, baseHost(), provenSSD)
|
||||||
|
if w := do(t, h, "POST", "/disks/same-disk", "A", `{"vmid":9300,"a":"/mnt/sys_drive","b":"/mnt/sys_drive"}`); w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("another guest's vmid: got %d want 403", w.Code)
|
||||||
|
}
|
||||||
|
if w := do(t, h, "POST", "/disks/same-disk", "A", `{"a":"relative","b":"/mnt/sys_drive"}`); w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("a relative path: got %d want 400", w.Code)
|
||||||
|
}
|
||||||
|
if w := do(t, h, "POST", "/disks/same-disk", "", `{"a":"/x","b":"/y"}`); w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("no token: got %d want 401", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
-43
@@ -208,6 +208,9 @@ type Options struct {
|
|||||||
// Intent records drive enroll/eject intent for the self-heal watchdog (slice 10 P3). OPTIONAL —
|
// Intent records drive enroll/eject intent for the self-heal watchdog (slice 10 P3). OPTIONAL —
|
||||||
// when nil, no intent is recorded (self-heal runs ungated).
|
// when nil, no intent is recorded (self-heal runs ungated).
|
||||||
Intent IntentRecorder
|
Intent IntentRecorder
|
||||||
|
// InternalPlaceFile (v0.158.0, felhom.eu `16` §5) is the path of the internal data place record
|
||||||
|
// (InternalPlaceRecord). OPTIONAL — "" means this host has no internal place and EnsureInternalPlace is a no-op.
|
||||||
|
InternalPlaceFile string
|
||||||
// GuestBinds persists which user-data drives (by durable-id) are enrolled into each guest, so the
|
// GuestBinds persists which user-data drives (by durable-id) are enrolled into each guest, so the
|
||||||
// startup re-assert (ReassertGuestBinds) can restore a bind that a re-provision dropped (F9).
|
// startup re-assert (ReassertGuestBinds) can restore a bind that a re-provision dropped (F9).
|
||||||
// OPTIONAL — when nil, guest binds are not recorded and the startup re-assert is a no-op.
|
// OPTIONAL — when nil, guest binds are not recorded and the startup re-assert is a no-op.
|
||||||
@@ -220,6 +223,10 @@ type Options struct {
|
|||||||
// ROLE (it backs SystemDisks for the eject role-gate + the /disks role hints). OPTIONAL — when nil
|
// ROLE (it backs SystemDisks for the eject role-gate + the /disks role hints). OPTIONAL — when nil
|
||||||
// it defaults to the production *storage.ProcHostReader. Injectable so the role-gate is testable.
|
// it defaults to the production *storage.ProcHostReader. Injectable so the role-gate is testable.
|
||||||
HostReader storage.HostReader
|
HostReader storage.HostReader
|
||||||
|
// Identity computes physical-disk identity (felhom.eu architecture/16-storage-physical-identity.md §3)
|
||||||
|
// for the /disks rows and POST /disks/same-disk. OPTIONAL — when nil, /disks carries no identity and
|
||||||
|
// same-disk answers 503 "not configured" (the controller then keeps its old st_dev rule).
|
||||||
|
Identity *storage.IdentityResolver
|
||||||
// HostMetrics serves GET /host/metrics (slice 9) — host-wide health (cpu%/mem/load/uptime/
|
// HostMetrics serves GET /host/metrics (slice 9) — host-wide health (cpu%/mem/load/uptime/
|
||||||
// cpu-temp) + per-storage capacity, host-wide and token-authed (one-customer-per-host). When
|
// cpu-temp) + per-storage capacity, host-wide and token-authed (one-customer-per-host). When
|
||||||
// nil the endpoint reports "not configured" (host still reports/reconciles).
|
// nil the endpoint reports "not configured" (host still reports/reconciles).
|
||||||
@@ -312,12 +319,14 @@ type Server struct {
|
|||||||
// identity blob from the hub, unseal it with the customer's recovery code, return ONLY the
|
// identity blob from the hub, unseal it with the customer's recovery code, return ONLY the
|
||||||
// offsite repository password. OPTIONAL — nil (no hub client configured) makes
|
// offsite repository password. OPTIONAL — nil (no hub client configured) makes
|
||||||
// POST /escrow/recover-offsite-password answer 503 rather than pretending.
|
// POST /escrow/recover-offsite-password answer 503 rather than pretending.
|
||||||
escrowRecovery EscrowRecoverer
|
escrowRecovery EscrowRecoverer
|
||||||
intent IntentRecorder // slice 10 P3 (optional)
|
intent IntentRecorder // slice 10 P3 (optional)
|
||||||
guestBinds *GuestBindStore // F9 startup bind re-assert record (optional)
|
internalPlaceFile string // v0.158.0 `16` §5 (optional)
|
||||||
formatJobs *FormatJobStore // F20-BUG3 detached-format job record (optional)
|
ipState internalPlaceState // EnsureInternalPlace's memo
|
||||||
staleLock StaleLockController // F2-b startup stale-lock recovery (optional)
|
guestBinds *GuestBindStore // F9 startup bind re-assert record (optional)
|
||||||
partialSweep func(ctx context.Context, vmid int) // R-1006 start-up sweep of dead vzdump leftovers (optional)
|
formatJobs *FormatJobStore // F20-BUG3 detached-format job record (optional)
|
||||||
|
staleLock StaleLockController // F2-b startup stale-lock recovery (optional)
|
||||||
|
partialSweep func(ctx context.Context, vmid int) // R-1006 start-up sweep of dead vzdump leftovers (optional)
|
||||||
// guestPower (F-REBOOT) is per-guest start-attempt state for the guest-power watchdog.
|
// guestPower (F-REBOOT) is per-guest start-attempt state for the guest-power watchdog.
|
||||||
// Guarded by guestPowerMu in guestpower.go; in-memory on purpose (see guestPowerState).
|
// Guarded by guestPowerMu in guestpower.go; in-memory on purpose (see guestPowerState).
|
||||||
guestPower map[int]guestPowerState
|
guestPower map[int]guestPowerState
|
||||||
@@ -325,7 +334,8 @@ type Server struct {
|
|||||||
// guestPowerSweeps counts completed guest-power sweeps, for the liveness observable. Touched only
|
// guestPowerSweeps counts completed guest-power sweeps, for the liveness observable. Touched only
|
||||||
// from GuestPowerTick, which the ticker calls serially.
|
// from GuestPowerTick, which the ticker calls serially.
|
||||||
guestPowerSweeps int
|
guestPowerSweeps int
|
||||||
host storage.HostReader // role classification source (optional; defaults to ProcHostReader)
|
host storage.HostReader // role classification source (optional; defaults to ProcHostReader)
|
||||||
|
ident *storage.IdentityResolver // `16` §3 physical-disk identity (optional)
|
||||||
|
|
||||||
hostMetrics HostMetricsProvider // slice 9 (optional)
|
hostMetrics HostMetricsProvider // slice 9 (optional)
|
||||||
hostID string // slice 10B: for the data-bearing-format pending-op hint
|
hostID string // slice 10B: for the data-bearing-format pending-op hint
|
||||||
@@ -450,42 +460,44 @@ func NewServer(o Options) (*Server, error) {
|
|||||||
cadence = defaultBackupCadence
|
cadence = defaultBackupCadence
|
||||||
}
|
}
|
||||||
s := &Server{
|
s := &Server{
|
||||||
addr: o.ListenAddr,
|
addr: o.ListenAddr,
|
||||||
cert: o.Cert,
|
cert: o.Cert,
|
||||||
guests: o.Guests,
|
guests: o.Guests,
|
||||||
backups: o.Backups,
|
backups: o.Backups,
|
||||||
store: o.Store,
|
store: o.Store,
|
||||||
storage: o.Storage,
|
storage: o.Storage,
|
||||||
driveTargets: o.DriveTargets,
|
driveTargets: o.DriveTargets,
|
||||||
smart: o.Smart,
|
smart: o.Smart,
|
||||||
tokens: o.Tokens,
|
tokens: o.Tokens,
|
||||||
cadence: cadence,
|
cadence: cadence,
|
||||||
logger: o.Logger,
|
logger: o.Logger,
|
||||||
now: func() time.Time { return time.Now().UTC() },
|
now: func() time.Time { return time.Now().UTC() },
|
||||||
disks: o.Disks,
|
disks: o.Disks,
|
||||||
diskGate: o.DiskGate,
|
diskGate: o.DiskGate,
|
||||||
guestList: o.Guests2,
|
guestList: o.Guests2,
|
||||||
guestAttach: o.GuestAttach,
|
guestAttach: o.GuestAttach,
|
||||||
mem: o.Memory,
|
mem: o.Memory,
|
||||||
netStorage: o.NetStorage,
|
netStorage: o.NetStorage,
|
||||||
privileged: o.Privileged,
|
privileged: o.Privileged,
|
||||||
configPath: o.ConfigPath,
|
configPath: o.ConfigPath,
|
||||||
stateDir: o.StateDir,
|
stateDir: o.StateDir,
|
||||||
netMountRoot: storage.NetworkMountRoot,
|
netMountRoot: storage.NetworkMountRoot,
|
||||||
smbCredsDir: o.SmbCredsDir,
|
smbCredsDir: o.SmbCredsDir,
|
||||||
escrowStagePath: o.EscrowStagePath,
|
escrowStagePath: o.EscrowStagePath,
|
||||||
escrowRecovery: o.EscrowRecovery,
|
escrowRecovery: o.EscrowRecovery,
|
||||||
intent: o.Intent,
|
intent: o.Intent,
|
||||||
guestBinds: o.GuestBinds,
|
internalPlaceFile: o.InternalPlaceFile,
|
||||||
formatJobs: o.FormatJobs,
|
guestBinds: o.GuestBinds,
|
||||||
staleLock: o.StaleLock,
|
formatJobs: o.FormatJobs,
|
||||||
host: o.HostReader,
|
staleLock: o.StaleLock,
|
||||||
hostMetrics: o.HostMetrics,
|
host: o.HostReader,
|
||||||
hostID: o.HostID,
|
ident: o.Identity,
|
||||||
agentVersion: o.AgentVersion,
|
hostMetrics: o.HostMetrics,
|
||||||
logRing: o.LogRing,
|
hostID: o.HostID,
|
||||||
jobs: map[backupJobKey]*backupJob{},
|
agentVersion: o.AgentVersion,
|
||||||
swapInFlight: map[int]bool{},
|
logRing: o.LogRing,
|
||||||
|
jobs: map[backupJobKey]*backupJob{},
|
||||||
|
swapInFlight: map[int]bool{},
|
||||||
}
|
}
|
||||||
// R-82 tier resolution. Options.BackupTiers is authoritative when supplied; otherwise ONE tier
|
// R-82 tier resolution. Options.BackupTiers is authoritative when supplied; otherwise ONE tier
|
||||||
// is synthesized from Backups + BackupCadence — the pre-R-82 shape, so every existing caller
|
// is synthesized from Backups + BackupCadence — the pre-R-82 shape, so every existing caller
|
||||||
@@ -546,6 +558,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
// Disk management (slice 8C) — self-scoped; format routes through the data-bearing classifier+gate.
|
// Disk management (slice 8C) — self-scoped; format routes through the data-bearing classifier+gate.
|
||||||
mux.HandleFunc("GET /disks", s.withGuest(s.handleDisks))
|
mux.HandleFunc("GET /disks", s.withGuest(s.handleDisks))
|
||||||
mux.HandleFunc("GET /disks/candidates", s.withGuest(s.handleDiskCandidates))
|
mux.HandleFunc("GET /disks/candidates", s.withGuest(s.handleDiskCandidates))
|
||||||
|
mux.HandleFunc("POST /disks/same-disk", s.withGuest(s.handleSameDisk)) // `16` §4 (v0.157.0)
|
||||||
mux.HandleFunc("POST /disks/assign", s.withGuest(s.handleDiskAssign))
|
mux.HandleFunc("POST /disks/assign", s.withGuest(s.handleDiskAssign))
|
||||||
mux.HandleFunc("POST /disks/eject", s.withGuest(s.handleDiskEject))
|
mux.HandleFunc("POST /disks/eject", s.withGuest(s.handleDiskEject))
|
||||||
mux.HandleFunc("POST /disks/decommission", s.withGuest(s.handleDiskDecommission))
|
mux.HandleFunc("POST /disks/decommission", s.withGuest(s.handleDiskDecommission))
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ const (
|
|||||||
// The config bundle (agent v0.143.0, R-840, `11` §5.4.2): the box's ROOT-OWNED files (sudoers, wrappers, units).
|
// The config bundle (agent v0.143.0, R-840, `11` §5.4.2): the box's ROOT-OWNED files (sudoers, wrappers, units).
|
||||||
// Destructive-class (signed, operational key) like agent_update; the root wrapper re-verifies the signature itself.
|
// Destructive-class (signed, operational key) like agent_update; the root wrapper re-verifies the signature itself.
|
||||||
ClassAgentConfigUpdate OpClass = "agent_config_update"
|
ClassAgentConfigUpdate OpClass = "agent_config_update"
|
||||||
|
|
||||||
|
// The internal data place on an OLD one-disk box (agent v0.158.0, felhom.eu `16` §9): thin space on the system
|
||||||
|
// disk, made by the root helper felhom-internal-place. Destructive-class (signed, operational key): it is an
|
||||||
|
// operator act, never a household button, and no agent-internal provenance may start it.
|
||||||
|
ClassAddInternalPlace OpClass = "add_internal_place"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Disposition is the classifier verdict.
|
// Disposition is the classifier verdict.
|
||||||
@@ -132,7 +137,7 @@ func Classify(class OpClass, prov Provenance) Disposition {
|
|||||||
return Destructive
|
return Destructive
|
||||||
case ClassKeyRotation:
|
case ClassKeyRotation:
|
||||||
return Destructive
|
return Destructive
|
||||||
case ClassAgentUpdate, ClassOSDockerStep, ClassOSPVEStep, ClassOSKernelStep, ClassAgentConfigUpdate:
|
case ClassAgentUpdate, ClassOSDockerStep, ClassOSPVEStep, ClassOSKernelStep, ClassAgentConfigUpdate, ClassAddInternalPlace:
|
||||||
// Never benign — no agent-internal provenance can make replacing the agent binary
|
// Never benign — no agent-internal provenance can make replacing the agent binary
|
||||||
// unsigned-safe (a compromised process must not be able to self-bless an update).
|
// unsigned-safe (a compromised process must not be able to self-bless an update).
|
||||||
return Destructive
|
return Destructive
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package signedjobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/localapi"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
||||||
|
)
|
||||||
|
|
||||||
|
// opAddInternalPlace is the op class this executor serves (mirrors reconcile.ClassAddInternalPlace).
|
||||||
|
const opAddInternalPlace = "add_internal_place"
|
||||||
|
|
||||||
|
// InternalPlaceHelper is the root helper's path (configs/felhom-internal-place, from the config bundle).
|
||||||
|
const InternalPlaceHelper = "/usr/local/sbin/felhom-internal-place"
|
||||||
|
|
||||||
|
var reHelperResult = regexp.MustCompile(`^(created|existing|formatted) uuid=([0-9a-fA-F-]{36}) gib=([0-9]+)$`)
|
||||||
|
|
||||||
|
// InternalPlaceExecutor adds the internal data place to an OLD one-disk box (felhom.eu architecture/16 §9: „one-disk
|
||||||
|
// boxes get no internal place by themselves; adding one later is a signed operator job, not a household button").
|
||||||
|
// It runs the SAME root helper the installer runs — `felhom-internal-place create 0` (0 GiB planned: the guest's
|
||||||
|
// volumes already exist and are counted) — writes the record the agent mounts from, and asks the local API to mount
|
||||||
|
// it now. Every safety rule (the ruled size, never format what holds anything) lives in the helper, as root.
|
||||||
|
//
|
||||||
|
// The op carries no parameters: the size is the rule's, never the signer's. Destructive-class by default (the
|
||||||
|
// classifier's fail-safe; ClassAddInternalPlace documents it): it takes thin space on the system disk, so only an
|
||||||
|
// operator signature may start it.
|
||||||
|
type InternalPlaceExecutor struct {
|
||||||
|
runner proxmox.Runner
|
||||||
|
recordFile string
|
||||||
|
ensure func(context.Context) // late-bound: the local API server is built after the jobs runner
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInternalPlaceExecutor wires the executor. ensure may be nil at construction and set later through SetEnsure.
|
||||||
|
func NewInternalPlaceExecutor(runner proxmox.Runner, recordFile string, logger *slog.Logger) *InternalPlaceExecutor {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
return &InternalPlaceExecutor{runner: runner, recordFile: recordFile, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetEnsure late-binds the mount step (localapi.Server.EnsureInternalPlace).
|
||||||
|
func (e *InternalPlaceExecutor) SetEnsure(f func(context.Context)) { e.ensure = f }
|
||||||
|
|
||||||
|
// Execute implements signedjobs.Executor for add_internal_place.
|
||||||
|
func (e *InternalPlaceExecutor) Execute(ctx context.Context, op string, params json.RawMessage) error {
|
||||||
|
if op != opAddInternalPlace {
|
||||||
|
return ErrNoExecutor
|
||||||
|
}
|
||||||
|
if p := bytes.TrimSpace(params); len(p) > 0 && !bytes.Equal(p, []byte("{}")) && !bytes.Equal(p, []byte("null")) {
|
||||||
|
return fmt.Errorf("add_internal_place: takes no parameters (the size is the ruled rule's), got %s", p)
|
||||||
|
}
|
||||||
|
if e.runner == nil || e.recordFile == "" {
|
||||||
|
return fmt.Errorf("add_internal_place: not wired on this host (no privileged runner or state dir)")
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
e.logger.Warn("add_internal_place: executing operator-signed job (the internal data place, `16` §9)")
|
||||||
|
out, stderr, err := e.runner.Run(ctx, InternalPlaceHelper, "create", "0")
|
||||||
|
line := strings.TrimSpace(string(out))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("add_internal_place: helper refused: %v: %s", err, strings.TrimSpace(string(stderr)))
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "skipped ") {
|
||||||
|
// Not an error the operator can retry away: the pool has no room by the ruled rule. Fail the job with the
|
||||||
|
// helper's own numbers so the hub shows why.
|
||||||
|
return fmt.Errorf("add_internal_place: %s", line)
|
||||||
|
}
|
||||||
|
m := reHelperResult.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("add_internal_place: unexpected helper output %q", line)
|
||||||
|
}
|
||||||
|
gib, _ := strconv.Atoi(m[3])
|
||||||
|
rec := localapi.InternalPlaceRecord{FSUUID: m[2], GiB: gib, MadeBy: "signed-job", MadeAt: time.Now().UTC().Format(time.RFC3339)}
|
||||||
|
if err := localapi.WriteInternalPlace(e.recordFile, rec); err != nil {
|
||||||
|
return fmt.Errorf("add_internal_place: writing the record: %w", err)
|
||||||
|
}
|
||||||
|
if e.ensure != nil {
|
||||||
|
e.ensure(ctx)
|
||||||
|
}
|
||||||
|
e.logger.Warn("add_internal_place: complete", "result", m[1], "fs_uuid", m[2], "gib", gib,
|
||||||
|
"duration_ms", time.Since(start).Milliseconds())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package signedjobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/localapi"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// add_internal_place (felhom.eu architecture/16 §9, agent v0.158.0): the operator's job for an OLD one-disk box.
|
||||||
|
|
||||||
|
type ipRunner struct {
|
||||||
|
out, stderr string
|
||||||
|
err error
|
||||||
|
calls [][]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ipRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
||||||
|
r.calls = append(r.calls, append([]string{name}, args...))
|
||||||
|
return []byte(r.out), []byte(r.stderr), r.err
|
||||||
|
}
|
||||||
|
func (r *ipRunner) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) {
|
||||||
|
return r.Run(ctx, name, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ipExec(t *testing.T, r *ipRunner) (*InternalPlaceExecutor, string, *int) {
|
||||||
|
t.Helper()
|
||||||
|
file := filepath.Join(t.TempDir(), localapi.InternalPlaceFile)
|
||||||
|
e := NewInternalPlaceExecutor(r, file, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
n := 0
|
||||||
|
e.SetEnsure(func(context.Context) { n++ })
|
||||||
|
return e, file, &n
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddInternalPlace_RunsTheHelperWritesTheRecordAndMounts(t *testing.T) {
|
||||||
|
r := &ipRunner{out: "created uuid=0b6e4c5a-1d2e-4f3a-9b8c-7d6e5f4a3b2c gib=33\n"}
|
||||||
|
e, file, ensured := ipExec(t, r)
|
||||||
|
if err := e.Execute(context.Background(), "add_internal_place", []byte(`{}`)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(r.calls) != 1 || strings.Join(r.calls[0], " ") != InternalPlaceHelper+" create 0" {
|
||||||
|
t.Fatalf("the helper must be called exactly as `create 0`, got %v", r.calls)
|
||||||
|
}
|
||||||
|
rec, ok, err := localapi.ReadInternalPlace(file)
|
||||||
|
if err != nil || !ok || rec.FSUUID != "0b6e4c5a-1d2e-4f3a-9b8c-7d6e5f4a3b2c" || rec.GiB != 33 || rec.MadeBy != "signed-job" {
|
||||||
|
t.Fatalf("record: %+v ok=%v err=%v", rec, ok, err)
|
||||||
|
}
|
||||||
|
if *ensured != 1 {
|
||||||
|
t.Fatalf("the mount must be asked for once, got %d", *ensured)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddInternalPlace_NoRoomOrRefusalOrGarbage_WritesNothing(t *testing.T) {
|
||||||
|
for name, r := range map[string]*ipRunner{
|
||||||
|
"skipped": {out: "skipped only 7 GiB free for it (pool 53, reserve 16, promised 102, planned 0; needs 20)"},
|
||||||
|
"refused": {err: errors.New("exit status 1"), stderr: "felhom-internal-place: REFUSED: holds something"},
|
||||||
|
"garbage": {out: "created uuid=../../etc gib=1"},
|
||||||
|
} {
|
||||||
|
e, file, ensured := ipExec(t, r)
|
||||||
|
if err := e.Execute(context.Background(), "add_internal_place", nil); err == nil {
|
||||||
|
t.Fatalf("%s: want an error", name)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(file); !os.IsNotExist(statErr) || *ensured != 0 {
|
||||||
|
t.Fatalf("%s: no record and no mount may follow, stat=%v ensured=%d", name, statErr, *ensured)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddInternalPlace_TakesNoParametersAndIgnoresOtherOps(t *testing.T) {
|
||||||
|
r := &ipRunner{out: "created uuid=0b6e4c5a-1d2e-4f3a-9b8c-7d6e5f4a3b2c gib=33"}
|
||||||
|
e, _, _ := ipExec(t, r)
|
||||||
|
if err := e.Execute(context.Background(), "add_internal_place", []byte(`{"gib":900}`)); err == nil || len(r.calls) != 0 {
|
||||||
|
t.Fatalf("a size from the signer must be refused before the helper runs, err=%v calls=%v", err, r.calls)
|
||||||
|
}
|
||||||
|
if err := e.Execute(context.Background(), "decommission", []byte(`{}`)); !errors.Is(err, ErrNoExecutor) {
|
||||||
|
t.Fatalf("another op: want ErrNoExecutor, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddInternalPlace_IsSignatureOnly(t *testing.T) {
|
||||||
|
if got := reconcile.Classify(reconcile.ClassAddInternalPlace, reconcile.Provenance{}); got != reconcile.Destructive {
|
||||||
|
t.Fatalf("add_internal_place must need an operator signature, classified %v", got)
|
||||||
|
}
|
||||||
|
if string(reconcile.ClassAddInternalPlace) != opAddInternalPlace {
|
||||||
|
t.Fatalf("op string drift: %q vs %q", reconcile.ClassAddInternalPlace, opAddInternalPlace)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,14 +13,16 @@ import (
|
|||||||
// (guarded, Impl-1) and/or mounts + binds. Blank disks are "initialize"-only; disks already carrying a
|
// (guarded, Impl-1) and/or mounts + binds. Blank disks are "initialize"-only; disks already carrying a
|
||||||
// mountable FS are ALSO "attach" candidates.
|
// mountable FS are ALSO "attach" candidates.
|
||||||
type CandidateDisk struct {
|
type CandidateDisk struct {
|
||||||
Device string `json:"device"` // whole disk, e.g. /dev/sdd
|
Device string `json:"device"` // whole disk, e.g. /dev/sdd
|
||||||
SizeBytes int64 `json:"size_bytes"` // 0 when unreadable
|
SizeBytes int64 `json:"size_bytes"` // 0 when unreadable
|
||||||
Model string `json:"model,omitempty"`
|
Model string `json:"model,omitempty"`
|
||||||
FSType string `json:"fstype,omitempty"` // first filesystem found ("" = blank)
|
FSType string `json:"fstype,omitempty"` // first filesystem found ("" = blank)
|
||||||
DataBearing bool `json:"data_bearing"` // has any FS / partition (→ wipe-confirm downstream)
|
DataBearing bool `json:"data_bearing"` // has any FS / partition (→ wipe-confirm downstream)
|
||||||
Mountable bool `json:"mountable"` // carries an ext4/xfs FS the agent can mount as-is
|
Mountable bool `json:"mountable"` // carries an ext4/xfs FS the agent can mount as-is
|
||||||
MountSource string `json:"mount_source,omitempty"` // the node to mount for attach (e.g. /dev/sdd1)
|
MountSource string `json:"mount_source,omitempty"` // the node to mount for attach (e.g. /dev/sdd1)
|
||||||
DurableID string `json:"durable_id,omitempty"` // "uuid:<fs-uuid>" if it has an FS; "" for a blank disk
|
DurableID string `json:"durable_id,omitempty"` // "uuid:<fs-uuid>" if it has an FS; "" for a blank disk
|
||||||
|
// Partitions (v0.158.0, felhom.eu `16` §6): each partition of this disk as its own place — see partitions.go.
|
||||||
|
Partitions []CandidatePartition `json:"partitions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// mountableFSTypes are the filesystems the agent can mount as-is (EnsureMount) → an "attach" candidate.
|
// mountableFSTypes are the filesystems the agent can mount as-is (EnsureMount) → an "attach" candidate.
|
||||||
@@ -83,6 +85,8 @@ func (h *SudoHostOps) buildCandidate(dev, name string) CandidateDisk {
|
|||||||
if len(nodes) > 1 {
|
if len(nodes) > 1 {
|
||||||
c.DataBearing = true
|
c.DataBearing = true
|
||||||
}
|
}
|
||||||
|
c.Partitions = candidatePartitions(lout, name, readSysPartSize, readSysPartNumber, h.host.ResolveUUID,
|
||||||
|
felhomOwnedMounts(dev, nodes, h.mountTable))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return c
|
return c
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -24,8 +25,13 @@ var devDiskRoot = "/dev/disk"
|
|||||||
const (
|
const (
|
||||||
durableByID = "byid:"
|
durableByID = "byid:"
|
||||||
durableByUUID = "byuuid:"
|
durableByUUID = "byuuid:"
|
||||||
|
// partFSUUIDSep joins a partition's by-id link and its fs-UUID in one wipe id (v0.158.0, `16` §6).
|
||||||
|
partFSUUIDSep = "+fsuuid:"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// rePartLink matches a by-id link of a PARTITION (udev appends -part<N> to the disk's link).
|
||||||
|
var rePartLink = regexp.MustCompile(`-part[1-9][0-9]{0,2}$`)
|
||||||
|
|
||||||
// byIDPriority ranks /dev/disk/by-id link prefixes most-stable-first: wwn (hardware world-wide
|
// byIDPriority ranks /dev/disk/by-id link prefixes most-stable-first: wwn (hardware world-wide
|
||||||
// name) > nvme-eui (NVMe EUI) > nvme-/ata-/scsi-/usb- (model+serial). dm-/lvm-/md- names are
|
// name) > nvme-eui (NVMe EUI) > nvme-/ata-/scsi-/usb- (model+serial). dm-/lvm-/md- names are
|
||||||
// excluded (they are mapper constructs, not the physical disk we want to bind a wipe to).
|
// excluded (they are mapper constructs, not the physical disk we want to bind a wipe to).
|
||||||
@@ -41,6 +47,14 @@ func DeviceDurableID(device string) (string, error) {
|
|||||||
}
|
}
|
||||||
// 1. best by-id link pointing at this device.
|
// 1. best by-id link pointing at this device.
|
||||||
if name := bestByIDLink(target); name != "" {
|
if name := bestByIDLink(target); name != "" {
|
||||||
|
// v0.158.0 (felhom.eu `16` §6): a PARTITION's link already carries the disk id + the part number
|
||||||
|
// (wwn-…-part2). When the part holds a filesystem, its fs-UUID is bound too, so a part that was reformatted —
|
||||||
|
// or another disk's part2 that took the link — derives a different id and the wipe refuses.
|
||||||
|
if rePartLink.MatchString(name) {
|
||||||
|
if uuid := byUUIDFor(target); uuid != "" {
|
||||||
|
return durableByID + name + partFSUUIDSep + uuid, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
return durableByID + name, nil
|
return durableByID + name, nil
|
||||||
}
|
}
|
||||||
// 2. fallback: a filesystem UUID symlink.
|
// 2. fallback: a filesystem UUID symlink.
|
||||||
@@ -56,10 +70,29 @@ func ResolveDurableDevice(durableID string) (string, error) {
|
|||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(durableID, durableByID):
|
case strings.HasPrefix(durableID, durableByID):
|
||||||
name := strings.TrimPrefix(durableID, durableByID)
|
name := strings.TrimPrefix(durableID, durableByID)
|
||||||
|
fsUUID := ""
|
||||||
|
if i := strings.Index(name, partFSUUIDSep); i >= 0 {
|
||||||
|
name, fsUUID = name[:i], name[i+len(partFSUUIDSep):]
|
||||||
|
if !rePartLink.MatchString(name) || !safeLinkName(fsUUID) {
|
||||||
|
return "", fmt.Errorf("storage: unsafe durable id %q", durableID)
|
||||||
|
}
|
||||||
|
}
|
||||||
if !safeLinkName(name) {
|
if !safeLinkName(name) {
|
||||||
return "", fmt.Errorf("storage: unsafe durable id %q", durableID)
|
return "", fmt.Errorf("storage: unsafe durable id %q", durableID)
|
||||||
}
|
}
|
||||||
return filepath.EvalSymlinks(filepath.Join(devDiskRoot, "by-id", name))
|
dev, err := filepath.EvalSymlinks(filepath.Join(devDiskRoot, "by-id", name))
|
||||||
|
if err != nil || fsUUID == "" {
|
||||||
|
return dev, err
|
||||||
|
}
|
||||||
|
// Both halves must name the SAME device now (`16` §6): the part by its disk id + number, and its filesystem.
|
||||||
|
byFS, ferr := filepath.EvalSymlinks(filepath.Join(devDiskRoot, "by-uuid", fsUUID))
|
||||||
|
if ferr != nil {
|
||||||
|
return "", fmt.Errorf("storage: durable id %q: its filesystem %s no longer resolves: %w", durableID, fsUUID, ferr)
|
||||||
|
}
|
||||||
|
if byFS != dev {
|
||||||
|
return "", fmt.Errorf("storage: durable id %q: the part (%s) and its filesystem (%s) are different devices now", durableID, dev, byFS)
|
||||||
|
}
|
||||||
|
return dev, nil
|
||||||
case strings.HasPrefix(durableID, durableByUUID):
|
case strings.HasPrefix(durableID, durableByUUID):
|
||||||
uuid := strings.TrimPrefix(durableID, durableByUUID)
|
uuid := strings.TrimPrefix(durableID, durableByUUID)
|
||||||
if !safeLinkName(uuid) {
|
if !safeLinkName(uuid) {
|
||||||
|
|||||||
@@ -0,0 +1,445 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Physical-disk identity of a storage place — felhom.eu architecture/16-storage-physical-identity.md §3.
|
||||||
|
//
|
||||||
|
// A storage place (a PVE storage, an enrolled drive, the guest's data volume, a network share) is
|
||||||
|
// reduced to the PHYSICAL whole disks under it — partition → parent disk, device-mapper / LVM thin →
|
||||||
|
// sysfs slaves (recursive), loop → its backing file → the filesystem holding that file — and each disk
|
||||||
|
// is named by a key the agent trusts: `eui:` for NVMe, `serial:<bus>:<model>:<serial>` from udev,
|
||||||
|
// `wwn:` only when no serial exists and the WWN carries a vendor (OUI ≠ 000000).
|
||||||
|
//
|
||||||
|
// The verdict is AGENT-COMPUTED evidence (`16` §10): it is read from /sys, /run/udev/data and
|
||||||
|
// /proc/mounts only, never from a caller's claim, so a compromised controller cannot make two places
|
||||||
|
// "different disks". `proven` means every disk under the place has a key the agent trusts; anything
|
||||||
|
// else is `unknown` with ONE reason, chosen by precedence (vm > network > usb_bridge > weak_wwn >
|
||||||
|
// no_serial). Nothing is cached: every call reads the current topology, so a disk swapped in the same
|
||||||
|
// bay is re-judged on the next read (the "right before each Tier-2 run" rule is the caller calling).
|
||||||
|
//
|
||||||
|
// Pinned by identity_test.go (fixture /sys + udev trees, one per §3 case) — the spike that proved the
|
||||||
|
// walk on real devices: felhom.eu documentation/audits/storage-identity-2026-10-15/.
|
||||||
|
|
||||||
|
const (
|
||||||
|
IdentityProven = "proven"
|
||||||
|
IdentityUnknown = "unknown"
|
||||||
|
|
||||||
|
ReasonVM = "vm"
|
||||||
|
ReasonNetwork = "network"
|
||||||
|
ReasonUSBBridge = "usb_bridge"
|
||||||
|
ReasonWeakWWN = "weak_wwn"
|
||||||
|
ReasonNoSerial = "no_serial"
|
||||||
|
|
||||||
|
PlaceKindSystem = "system"
|
||||||
|
PlaceKindInternal = "internal"
|
||||||
|
PlaceKindDisk = "disk"
|
||||||
|
PlaceKindPartition = "partition"
|
||||||
|
PlaceKindNetwork = "network"
|
||||||
|
)
|
||||||
|
|
||||||
|
// reasonRank orders the unknown reasons: the first one a place hits, by this precedence, is reported.
|
||||||
|
var reasonRank = map[string]int{ReasonVM: 0, ReasonNetwork: 1, ReasonUSBBridge: 2, ReasonWeakWWN: 3, ReasonNoSerial: 4}
|
||||||
|
|
||||||
|
// PlaceIdentity is what a storage place reports.
|
||||||
|
type PlaceIdentity struct {
|
||||||
|
PhysicalDisks []string // disk keys, sorted, deduplicated
|
||||||
|
Identity string // proven | unknown | "" (not computed — e.g. the device is absent)
|
||||||
|
Reason string // set only when Identity == unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed reports whether a verdict was reached at all.
|
||||||
|
func (p PlaceIdentity) Computed() bool { return p.Identity != "" }
|
||||||
|
|
||||||
|
// IdentityHost is the read surface the resolver needs. SysfsIdentityHost is production; tests use the
|
||||||
|
// same struct pointed at fixture trees.
|
||||||
|
type IdentityHost interface {
|
||||||
|
Mounts() ([]Mount, error)
|
||||||
|
// KernelName maps a /dev path (symlinks allowed: /dev/mapper/x, /dev/pve/y) to its kernel block name.
|
||||||
|
KernelName(dev string) (string, bool)
|
||||||
|
// Parent returns the parent disk's kernel name when name is a partition.
|
||||||
|
Parent(name string) (string, bool)
|
||||||
|
// Slaves lists /sys/class/block/<name>/slaves.
|
||||||
|
Slaves(name string) []string
|
||||||
|
// LoopBackingFile returns a loop device's backing file.
|
||||||
|
LoopBackingFile(name string) (string, bool)
|
||||||
|
// UdevProps returns the udev E: properties of a block device.
|
||||||
|
UdevProps(name string) map[string]string
|
||||||
|
// Virt returns the VM technology ("none" on bare metal).
|
||||||
|
Virt() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SysfsIdentityHost reads the real host. Every path is a field so tests can point it at fixtures.
|
||||||
|
type SysfsIdentityHost struct {
|
||||||
|
SysClass string // default /sys/class/block
|
||||||
|
UdevData string // default /run/udev/data
|
||||||
|
ProcMounts string // default /proc/mounts
|
||||||
|
DevResolve func(string) (string, error) // default filepath.EvalSymlinks
|
||||||
|
VirtFn func() string // default `systemd-detect-virt --vm`, read once
|
||||||
|
|
||||||
|
virtOnce sync.Once
|
||||||
|
virt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSysfsIdentityHost returns the production reader.
|
||||||
|
func NewSysfsIdentityHost() *SysfsIdentityHost { return &SysfsIdentityHost{} }
|
||||||
|
|
||||||
|
func (h *SysfsIdentityHost) sysClass() string {
|
||||||
|
if h.SysClass != "" {
|
||||||
|
return h.SysClass
|
||||||
|
}
|
||||||
|
return "/sys/class/block"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SysfsIdentityHost) Mounts() ([]Mount, error) {
|
||||||
|
return (&ProcHostReader{ProcMounts: h.ProcMounts}).Mounts()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SysfsIdentityHost) KernelName(dev string) (string, bool) {
|
||||||
|
if !strings.HasPrefix(dev, "/dev/") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
resolve := h.DevResolve
|
||||||
|
if resolve == nil {
|
||||||
|
resolve = filepath.EvalSymlinks
|
||||||
|
}
|
||||||
|
real := dev
|
||||||
|
if r, err := resolve(dev); err == nil {
|
||||||
|
real = r
|
||||||
|
}
|
||||||
|
name := filepath.Base(real)
|
||||||
|
if _, err := os.Stat(filepath.Join(h.sysClass(), name)); err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return name, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SysfsIdentityHost) Parent(name string) (string, bool) {
|
||||||
|
p := filepath.Join(h.sysClass(), name)
|
||||||
|
if _, err := os.Stat(filepath.Join(p, "partition")); err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
real, err := filepath.EvalSymlinks(p)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return filepath.Base(filepath.Dir(real)), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SysfsIdentityHost) Slaves(name string) []string {
|
||||||
|
entries, err := os.ReadDir(filepath.Join(h.sysClass(), name, "slaves"))
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
out = append(out, e.Name())
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SysfsIdentityHost) LoopBackingFile(name string) (string, bool) {
|
||||||
|
if !strings.HasPrefix(name, "loop") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(filepath.Join(h.sysClass(), name, "loop", "backing_file"))
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
f := strings.TrimSpace(string(b))
|
||||||
|
return f, f != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SysfsIdentityHost) UdevProps(name string) map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
devB, err := os.ReadFile(filepath.Join(h.sysClass(), name, "dev"))
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
dir := h.UdevData
|
||||||
|
if dir == "" {
|
||||||
|
dir = "/run/udev/data"
|
||||||
|
}
|
||||||
|
f, err := os.Open(filepath.Join(dir, "b"+strings.TrimSpace(string(devB))))
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := sc.Text()
|
||||||
|
if !strings.HasPrefix(line, "E:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k, v, ok := strings.Cut(line[2:], "=")
|
||||||
|
if ok {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SysfsIdentityHost) Virt() string {
|
||||||
|
h.virtOnce.Do(func() {
|
||||||
|
if h.VirtFn != nil {
|
||||||
|
h.virt = h.VirtFn()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
// --vm: a container is not a reason (the agent runs on the host); exit 1 + "none" on metal.
|
||||||
|
out, _ := exec.CommandContext(ctx, "systemd-detect-virt", "--vm").Output()
|
||||||
|
h.virt = strings.TrimSpace(string(out))
|
||||||
|
if h.virt == "" {
|
||||||
|
h.virt = "unknown"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return h.virt
|
||||||
|
}
|
||||||
|
|
||||||
|
// IdentityResolver turns devices and host paths into PlaceIdentity.
|
||||||
|
type IdentityResolver struct {
|
||||||
|
Host IdentityHost
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewIdentityResolver returns a resolver over the real host.
|
||||||
|
func NewIdentityResolver() *IdentityResolver {
|
||||||
|
return &IdentityResolver{Host: NewSysfsIdentityHost()}
|
||||||
|
}
|
||||||
|
|
||||||
|
var networkFSTypes = map[string]bool{
|
||||||
|
"nfs": true, "nfs4": true, "cifs": true, "smb3": true, "fuse.sshfs": true, "9p": true, "autofs": true, "ceph": true, "glusterfs": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetworkPlace is the fixed verdict for a network share or a remote store.
|
||||||
|
func NetworkPlace() PlaceIdentity {
|
||||||
|
return PlaceIdentity{PhysicalDisks: []string{}, Identity: IdentityUnknown, Reason: ReasonNetwork}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MountFor returns the mount that holds path (longest mountpoint prefix; the LAST entry wins on an equal
|
||||||
|
// mountpoint, which is the one stacked on top — an automount's real filesystem over its autofs trigger).
|
||||||
|
func MountFor(mounts []Mount, path string) (Mount, bool) {
|
||||||
|
p := filepath.Clean(path)
|
||||||
|
var best Mount
|
||||||
|
found := false
|
||||||
|
for _, m := range mounts {
|
||||||
|
mp := filepath.Clean(m.MountPoint)
|
||||||
|
if p != mp && !strings.HasPrefix(p, strings.TrimSuffix(mp, "/")+"/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !found || len(mp) >= len(filepath.Clean(best.MountPoint)) {
|
||||||
|
best, found = m, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best, found
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostPath resolves the filesystem holding a HOST path.
|
||||||
|
func (r *IdentityResolver) HostPath(path string) PlaceIdentity {
|
||||||
|
return r.hostPath(path, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *IdentityResolver) hostPath(path string, depth int) PlaceIdentity {
|
||||||
|
if r == nil || r.Host == nil || depth > 4 {
|
||||||
|
return PlaceIdentity{}
|
||||||
|
}
|
||||||
|
mounts, err := r.Host.Mounts()
|
||||||
|
if err != nil {
|
||||||
|
return PlaceIdentity{}
|
||||||
|
}
|
||||||
|
m, ok := MountFor(mounts, path)
|
||||||
|
if !ok {
|
||||||
|
return PlaceIdentity{}
|
||||||
|
}
|
||||||
|
if networkFSTypes[m.FSType] || !strings.HasPrefix(m.Device, "/dev/") {
|
||||||
|
return NetworkPlace()
|
||||||
|
}
|
||||||
|
return r.device(m.Device, depth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device resolves a block device (a /dev path, symlinks allowed).
|
||||||
|
func (r *IdentityResolver) Device(dev string) PlaceIdentity {
|
||||||
|
return r.device(dev, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *IdentityResolver) device(dev string, depth int) PlaceIdentity {
|
||||||
|
if r == nil || r.Host == nil || dev == "" {
|
||||||
|
return PlaceIdentity{}
|
||||||
|
}
|
||||||
|
name, ok := r.Host.KernelName(dev)
|
||||||
|
if !ok {
|
||||||
|
return PlaceIdentity{}
|
||||||
|
}
|
||||||
|
keys := map[string]bool{}
|
||||||
|
reason := ""
|
||||||
|
if !r.walk(name, depth, map[string]bool{}, keys, &reason) {
|
||||||
|
return PlaceIdentity{}
|
||||||
|
}
|
||||||
|
out := PlaceIdentity{PhysicalDisks: sortedKeys(keys), Identity: IdentityProven}
|
||||||
|
if reason != "" {
|
||||||
|
out.Identity, out.Reason = IdentityUnknown, reason
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// walk adds the keys of every physical disk under name; ok=false when a branch cannot be grounded.
|
||||||
|
func (r *IdentityResolver) walk(name string, depth int, seen, keys map[string]bool, reason *string) bool {
|
||||||
|
if seen[name] {
|
||||||
|
return true // a diamond (a thin pool's tmeta and tdata on one partition): already counted
|
||||||
|
}
|
||||||
|
if len(seen) > 64 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
if parent, ok := r.Host.Parent(name); ok {
|
||||||
|
return r.walk(parent, depth, seen, keys, reason)
|
||||||
|
}
|
||||||
|
if file, ok := r.Host.LoopBackingFile(name); ok {
|
||||||
|
p := r.hostPath(file, depth+1)
|
||||||
|
if !p.Computed() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, k := range p.PhysicalDisks {
|
||||||
|
keys[k] = true
|
||||||
|
}
|
||||||
|
noteReason(reason, p.Reason)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if slaves := r.Host.Slaves(name); len(slaves) > 0 {
|
||||||
|
for _, s := range slaves {
|
||||||
|
if !r.walk(s, depth, seen, keys, reason) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(name, "dm-") || strings.HasPrefix(name, "md") || strings.HasPrefix(name, "loop") {
|
||||||
|
return false // a virtual device with nothing beneath it cannot be grounded
|
||||||
|
}
|
||||||
|
key, why := r.diskKey(name)
|
||||||
|
keys[key] = true
|
||||||
|
noteReason(reason, why)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func noteReason(cur *string, why string) {
|
||||||
|
if why == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if *cur == "" || reasonRank[why] < reasonRank[*cur] {
|
||||||
|
*cur = why
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// diskKey names one whole disk and says why it is not trusted ("" when it is).
|
||||||
|
func (r *IdentityResolver) diskKey(name string) (key, reason string) {
|
||||||
|
p := r.Host.UdevProps(name)
|
||||||
|
serial := strings.TrimSpace(p["ID_SERIAL_SHORT"])
|
||||||
|
model := strings.TrimSpace(p["ID_MODEL"])
|
||||||
|
bus := strings.TrimSpace(p["ID_BUS"])
|
||||||
|
wwn := strings.ToLower(strings.TrimSpace(p["ID_WWN"]))
|
||||||
|
if bus == "" && strings.HasPrefix(name, "nvme") {
|
||||||
|
bus = "nvme"
|
||||||
|
}
|
||||||
|
usb := bus == "usb" || strings.Contains(p["DEVPATH"], "/usb")
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(name, "nvme") && (strings.HasPrefix(wwn, "eui.") || strings.HasPrefix(wwn, "nvme.")):
|
||||||
|
key = "eui:" + strings.TrimPrefix(strings.TrimPrefix(wwn, "eui."), "nvme.")
|
||||||
|
case serial != "":
|
||||||
|
key = "serial:" + bus + ":" + model + ":" + serial
|
||||||
|
if usb && FakeBridgeSerial(serial) {
|
||||||
|
reason = ReasonUSBBridge
|
||||||
|
}
|
||||||
|
case wwn != "" && WWNHasVendor(wwn):
|
||||||
|
key = "wwn:" + wwn
|
||||||
|
case wwn != "":
|
||||||
|
key, reason = "wwn:"+wwn, ReasonWeakWWN
|
||||||
|
default:
|
||||||
|
key, reason = "dev:"+name, ReasonNoSerial
|
||||||
|
}
|
||||||
|
if v := r.Host.Virt(); v != "none" {
|
||||||
|
reason = ReasonVM // inside a VM a virtual disk says nothing about the host disk (spike cases 5–6)
|
||||||
|
}
|
||||||
|
return key, reason
|
||||||
|
}
|
||||||
|
|
||||||
|
// WWNHasVendor reports whether an NAA-5/6 WWN carries a registered vendor (OUI ≠ 000000). Other NAA
|
||||||
|
// formats are taken as they are. Measured: the N100's AirDisk SSD reports 0x5000000123456f1f.
|
||||||
|
func WWNHasVendor(wwn string) bool {
|
||||||
|
h := strings.TrimPrefix(strings.ToLower(wwn), "0x")
|
||||||
|
if len(h) < 7 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if h[0] == '5' || h[0] == '6' {
|
||||||
|
return h[1:7] != "000000"
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// FakeBridgeSerial reports a serial that a USB bridge reports for any disk: too short, one repeated
|
||||||
|
// character, or a known placeholder. UNPROVEN on a real enclosure (R-1077) — the rule is the design's.
|
||||||
|
func FakeBridgeSerial(s string) bool {
|
||||||
|
s = strings.ToUpper(strings.TrimSpace(s))
|
||||||
|
if len(s) < 6 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.Count(s, s[:1]) == len(s) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch s {
|
||||||
|
case "123456789", "1234567890", "123456789ABC", "0123456789ABCDEF", "1234567890ABCDEF", "DEADBEEF":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKeys(m map[string]bool) []string {
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relation compares two places (`16` §4). A SHARED disk key is a positive fact, so two places on one disk
|
||||||
|
// are "same, proven" even when that disk is unknown (inside a VM, two parts of one virtual disk). Otherwise
|
||||||
|
// proven is true only when both verdicts are proven; different then means "no disk in common". When proven
|
||||||
|
// is false, different only says the known keys do not overlap — callers must act on proven first.
|
||||||
|
// reason: "" (proven, different), "same_disk", "unmapped" (a side has no verdict), or the unknown reason.
|
||||||
|
func Relation(a, b PlaceIdentity) (different, proven bool, reason string) {
|
||||||
|
if !a.Computed() || !b.Computed() {
|
||||||
|
return false, false, "unmapped"
|
||||||
|
}
|
||||||
|
set := map[string]bool{}
|
||||||
|
for _, k := range a.PhysicalDisks {
|
||||||
|
set[k] = true
|
||||||
|
}
|
||||||
|
shared := false
|
||||||
|
for _, k := range b.PhysicalDisks {
|
||||||
|
if set[k] {
|
||||||
|
shared = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if shared {
|
||||||
|
return false, true, "same_disk"
|
||||||
|
}
|
||||||
|
if a.Identity == IdentityProven && b.Identity == IdentityProven {
|
||||||
|
return true, true, ""
|
||||||
|
}
|
||||||
|
why := a.Reason
|
||||||
|
if why == "" || (b.Reason != "" && reasonRank[b.Reason] < reasonRank[why]) {
|
||||||
|
why = b.Reason
|
||||||
|
}
|
||||||
|
return true, false, why
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fixture /sys/class/block + /run/udev/data + /proc/mounts trees, one per `16` §3 case. The tree mimics the
|
||||||
|
// kernel: /sys/devices/.../<disk>/<part> real dirs (a partition carries a "partition" file), /sys/class/block
|
||||||
|
// symlinks, a "dev" file per node, udev "E:" lines keyed by major:minor. The cases are the ones the spike
|
||||||
|
// measured on real devices (felhom.eu documentation/audits/storage-identity-2026-10-15/).
|
||||||
|
|
||||||
|
type fx struct {
|
||||||
|
t *testing.T
|
||||||
|
root string
|
||||||
|
minor int
|
||||||
|
mounts []string
|
||||||
|
links map[string]string // /dev path -> kernel name
|
||||||
|
virt string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFx(t *testing.T) *fx {
|
||||||
|
t.Helper()
|
||||||
|
r := t.TempDir()
|
||||||
|
for _, d := range []string{"sys/class/block", "sys/devices", "udev"} {
|
||||||
|
if err := os.MkdirAll(filepath.Join(r, d), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &fx{t: t, root: r, links: map[string]string{}, virt: "none"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fx) write(p, s string) {
|
||||||
|
f.t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||||
|
f.t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
|
||||||
|
f.t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// node makes a block node. parent "" → a top-level device. udev lines are "KEY=VAL".
|
||||||
|
func (f *fx) node(name, parent string, udev ...string) {
|
||||||
|
f.t.Helper()
|
||||||
|
real := filepath.Join(f.root, "sys/devices", name)
|
||||||
|
if parent != "" {
|
||||||
|
pr, _ := filepath.EvalSymlinks(filepath.Join(f.root, "sys/class/block", parent))
|
||||||
|
real = filepath.Join(pr, name)
|
||||||
|
f.write(filepath.Join(real, "partition"), "1\n")
|
||||||
|
}
|
||||||
|
f.minor++
|
||||||
|
dev := "8:" + strconv.Itoa(f.minor)
|
||||||
|
f.write(filepath.Join(real, "dev"), dev+"\n")
|
||||||
|
if err := os.Symlink(real, filepath.Join(f.root, "sys/class/block", name)); err != nil {
|
||||||
|
f.t.Fatal(err)
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, u := range udev {
|
||||||
|
b.WriteString("E:" + u + "\n")
|
||||||
|
}
|
||||||
|
f.write(filepath.Join(f.root, "udev", "b"+dev), b.String())
|
||||||
|
f.links["/dev/"+name] = name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fx) slaves(name string, slaves ...string) {
|
||||||
|
f.t.Helper()
|
||||||
|
real, _ := filepath.EvalSymlinks(filepath.Join(f.root, "sys/class/block", name))
|
||||||
|
for _, s := range slaves {
|
||||||
|
if err := os.MkdirAll(filepath.Join(real, "slaves", s), 0o755); err != nil {
|
||||||
|
f.t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fx) loop(name, file string) {
|
||||||
|
real, _ := filepath.EvalSymlinks(filepath.Join(f.root, "sys/class/block", name))
|
||||||
|
f.write(filepath.Join(real, "loop", "backing_file"), file+"\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fx) mount(dev, mp, fstype string) {
|
||||||
|
f.mounts = append(f.mounts, dev+" "+mp+" "+fstype+" rw 0 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fx) resolver() *IdentityResolver {
|
||||||
|
f.write(filepath.Join(f.root, "mounts"), strings.Join(f.mounts, "\n")+"\n")
|
||||||
|
return &IdentityResolver{Host: &SysfsIdentityHost{
|
||||||
|
SysClass: filepath.Join(f.root, "sys/class/block"),
|
||||||
|
UdevData: filepath.Join(f.root, "udev"),
|
||||||
|
ProcMounts: filepath.Join(f.root, "mounts"),
|
||||||
|
DevResolve: func(p string) (string, error) {
|
||||||
|
if n, ok := f.links[p]; ok {
|
||||||
|
return "/dev/" + n, nil
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
},
|
||||||
|
VirtFn: func() string { return f.virt },
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sanDisk = []string{"ID_BUS=ata", "ID_MODEL=SanDisk_X600", "ID_SERIAL_SHORT=182195804614", "ID_WWN=0x5001b448b60a9d6e"}
|
||||||
|
|
||||||
|
// oneDiskLVM builds the PVE default layout on one SATA SSD: sda3 → pve-root, pve-data tmeta/tdata → tpool →
|
||||||
|
// a guest volume (the shape of all three test boxes).
|
||||||
|
func oneDiskLVM(f *fx) {
|
||||||
|
f.node("sda", "", sanDisk...)
|
||||||
|
f.node("sda3", "sda")
|
||||||
|
for _, d := range []string{"dm-1", "dm-2", "dm-3", "dm-4", "dm-6"} {
|
||||||
|
f.node(d, "")
|
||||||
|
}
|
||||||
|
f.slaves("dm-1", "sda3")
|
||||||
|
f.slaves("dm-2", "sda3")
|
||||||
|
f.slaves("dm-3", "sda3")
|
||||||
|
f.slaves("dm-4", "dm-2", "dm-3")
|
||||||
|
f.slaves("dm-6", "dm-4")
|
||||||
|
f.links["/dev/mapper/pve-root"] = "dm-1"
|
||||||
|
f.links["/dev/mapper/pve-data-tpool"] = "dm-4"
|
||||||
|
f.links["/dev/pve/vm-9201-disk-1"] = "dm-6"
|
||||||
|
f.mount("/dev/mapper/pve-root", "/", "ext4")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentity_OneDiskLVM_AllPlacesSameProvenDisk(t *testing.T) {
|
||||||
|
f := newFx(t)
|
||||||
|
oneDiskLVM(f)
|
||||||
|
r := f.resolver()
|
||||||
|
want := []string{"serial:ata:SanDisk_X600:182195804614"}
|
||||||
|
for _, p := range []PlaceIdentity{r.HostPath("/var/lib/vz"), r.Device("/dev/mapper/pve-data-tpool"), r.Device("/dev/pve/vm-9201-disk-1")} {
|
||||||
|
if p.Identity != IdentityProven || !reflect.DeepEqual(p.PhysicalDisks, want) {
|
||||||
|
t.Fatalf("want proven %v, got %+v", want, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
diff, proven, why := Relation(r.HostPath("/var/lib/vz"), r.Device("/dev/pve/vm-9201-disk-1"))
|
||||||
|
if diff || !proven || why != "same_disk" {
|
||||||
|
t.Fatalf("root vs guest volume: want same+proven, got diff=%v proven=%v why=%q", diff, proven, why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentity_TwoDisks_ProvenDifferent(t *testing.T) {
|
||||||
|
f := newFx(t)
|
||||||
|
oneDiskLVM(f)
|
||||||
|
f.node("sdb", "", "ID_BUS=ata", "ID_MODEL=TOSHIBA_MQ04ABF100", "ID_SERIAL_SHORT=65NOP3HDT", "ID_WWN=0x5000039ddb108568", "DEVPATH=/devices/pci0000:00/0000:00:14.0/usb1/1-3/block/sdb")
|
||||||
|
f.mount("/dev/sdb", "/mnt/hdd_1", "ext4")
|
||||||
|
f.mount("/dev/sdb", "/mnt/felhom-drives/hdd_1", "ext4") // the felhom-data bind shows the source device
|
||||||
|
r := f.resolver()
|
||||||
|
drive := r.HostPath("/mnt/felhom-drives/hdd_1/userdata")
|
||||||
|
if drive.Identity != IdentityProven || drive.PhysicalDisks[0] != "serial:ata:TOSHIBA_MQ04ABF100:65NOP3HDT" {
|
||||||
|
t.Fatalf("USB drive passing its identity: want proven, got %+v", drive)
|
||||||
|
}
|
||||||
|
diff, proven, why := Relation(r.Device("/dev/pve/vm-9201-disk-1"), drive)
|
||||||
|
if !diff || !proven || why != "" {
|
||||||
|
t.Fatalf("system vs drive: want different+proven, got %v %v %q", diff, proven, why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentity_TwoPartitionsOneDisk_Same(t *testing.T) {
|
||||||
|
f := newFx(t)
|
||||||
|
f.node("sdc", "", "ID_BUS=usb", "ID_MODEL=Portable", "ID_SERIAL_SHORT=NAAB7C21")
|
||||||
|
f.node("sdc1", "sdc")
|
||||||
|
f.node("sdc2", "sdc")
|
||||||
|
f.mount("/dev/sdc1", "/mnt/a", "ext4")
|
||||||
|
f.mount("/dev/sdc2", "/mnt/b", "ext4")
|
||||||
|
r := f.resolver()
|
||||||
|
diff, proven, why := Relation(r.HostPath("/mnt/a"), r.HostPath("/mnt/b"))
|
||||||
|
if diff || !proven || why != "same_disk" {
|
||||||
|
t.Fatalf("two partitions of one disk: want same+proven, got %v %v %q", diff, proven, why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentity_LoopPartitionsOnAFile_ResolveToTheFilesDisk(t *testing.T) {
|
||||||
|
f := newFx(t)
|
||||||
|
f.node("nvme0n1", "", "ID_MODEL=KXG50PNV1T02", "ID_SERIAL_SHORT=58BS11AFT8MQ", "ID_WWN=eui.000000000000001000080d020046c07b")
|
||||||
|
f.mount("/dev/nvme0n1", "/mnt/hdd_1", "ext4")
|
||||||
|
f.node("loop2", "")
|
||||||
|
f.loop("loop2", "/mnt/hdd_1/scratch.img")
|
||||||
|
f.node("loop2p1", "loop2") // the partition step must run BEFORE the loop step (the spike's probe bug)
|
||||||
|
f.mount("/dev/loop2p1", "/tmp/scrA", "ext4")
|
||||||
|
r := f.resolver()
|
||||||
|
p := r.HostPath("/tmp/scrA")
|
||||||
|
if p.Identity != IdentityProven || !reflect.DeepEqual(p.PhysicalDisks, []string{"eui:000000000000001000080d020046c07b"}) {
|
||||||
|
t.Fatalf("loop partition: want the NVMe's eui, got %+v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentity_WWNWithoutVendor(t *testing.T) {
|
||||||
|
f := newFx(t)
|
||||||
|
f.node("sda", "", "ID_BUS=ata", "ID_MODEL=AirDisk_512GB_SSD", "ID_SERIAL_SHORT=QDF922W009654S30EX", "ID_WWN=0x5000000123456f1f")
|
||||||
|
f.node("sdb", "", "ID_BUS=ata", "ID_MODEL=Cheap", "ID_WWN=0x5000000123456f1f")
|
||||||
|
r := f.resolver()
|
||||||
|
if p := r.Device("/dev/sda"); p.Identity != IdentityProven || p.PhysicalDisks[0] != "serial:ata:AirDisk_512GB_SSD:QDF922W009654S30EX" {
|
||||||
|
t.Fatalf("a real serial wins over a vendor-less WWN: got %+v", p)
|
||||||
|
}
|
||||||
|
if p := r.Device("/dev/sdb"); p.Identity != IdentityUnknown || p.Reason != ReasonWeakWWN {
|
||||||
|
t.Fatalf("no serial + vendor-less WWN: want unknown/weak_wwn, got %+v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentity_NoSerial(t *testing.T) {
|
||||||
|
f := newFx(t)
|
||||||
|
f.node("vdb", "", "ID_MODEL=")
|
||||||
|
if p := f.resolver().Device("/dev/vdb"); p.Identity != IdentityUnknown || p.Reason != ReasonNoSerial {
|
||||||
|
t.Fatalf("want unknown/no_serial, got %+v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentity_InsideAVM_EverythingUnknown(t *testing.T) {
|
||||||
|
f := newFx(t)
|
||||||
|
oneDiskLVM(f)
|
||||||
|
f.virt = "kvm"
|
||||||
|
f.node("sdb", "", "ID_BUS=scsi", "ID_MODEL=QEMU_HARDDISK", "ID_SERIAL_SHORT=drive-scsi1")
|
||||||
|
f.mount("/dev/sdb", "/mnt/felhom-drives/adatlemez", "ext4")
|
||||||
|
r := f.resolver()
|
||||||
|
a, b := r.Device("/dev/pve/vm-9201-disk-1"), r.HostPath("/mnt/felhom-drives/adatlemez")
|
||||||
|
for _, p := range []PlaceIdentity{a, b} {
|
||||||
|
if p.Identity != IdentityUnknown || p.Reason != ReasonVM {
|
||||||
|
t.Fatalf("inside a VM: want unknown/vm, got %+v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
diff, proven, why := Relation(a, b)
|
||||||
|
if proven || why != ReasonVM || !diff {
|
||||||
|
t.Fatalf("two virtual disks: want not proven (vm), got %v %v %q", diff, proven, why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentity_USBBridgeSerial(t *testing.T) {
|
||||||
|
f := newFx(t)
|
||||||
|
f.node("sdd", "", "ID_BUS=usb", "ID_MODEL=External_USB3.0", "ID_SERIAL_SHORT=000000000000")
|
||||||
|
f.node("sde", "", "ID_BUS=usb", "ID_MODEL=External_USB3.0", "ID_SERIAL_SHORT=20231106A1B2")
|
||||||
|
r := f.resolver()
|
||||||
|
if p := r.Device("/dev/sdd"); p.Identity != IdentityUnknown || p.Reason != ReasonUSBBridge {
|
||||||
|
t.Fatalf("a bridge placeholder serial: want unknown/usb_bridge, got %+v", p)
|
||||||
|
}
|
||||||
|
if p := r.Device("/dev/sde"); p.Identity != IdentityProven {
|
||||||
|
t.Fatalf("a USB disk with a plausible serial: want proven, got %+v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentity_NetworkShareAndUnmapped(t *testing.T) {
|
||||||
|
f := newFx(t)
|
||||||
|
oneDiskLVM(f)
|
||||||
|
f.mount("//192.168.0.123/TesztFilmek", "/mnt/felhom-drives/omv-filmek", "cifs")
|
||||||
|
r := f.resolver()
|
||||||
|
n := r.HostPath("/mnt/felhom-drives/omv-filmek/x")
|
||||||
|
if n.Identity != IdentityUnknown || n.Reason != ReasonNetwork || len(n.PhysicalDisks) != 0 {
|
||||||
|
t.Fatalf("network share: want unknown/network with no disks, got %+v", n)
|
||||||
|
}
|
||||||
|
_, proven, why := Relation(r.HostPath("/"), n)
|
||||||
|
if proven || why != ReasonNetwork {
|
||||||
|
t.Fatalf("system vs network: want not proven (network), got %v %q", proven, why)
|
||||||
|
}
|
||||||
|
if _, proven, why := Relation(PlaceIdentity{}, n); proven || why != "unmapped" {
|
||||||
|
t.Fatalf("an uncomputed side: want unmapped, got %v %q", proven, why)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The root mkfs wrapper's own tests (configs/test_felhom_mkfs_guarded.py, agent v0.158.0 — a sibling partition that
|
||||||
|
// is Felhom's own drive no longer blocks formatting the part next to it; every other mount still refuses) run with
|
||||||
|
// the Go suite.
|
||||||
|
func TestMkfsGuardedPythonSuite(t *testing.T) {
|
||||||
|
py, err := exec.LookPath("python3")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("python3 not found")
|
||||||
|
}
|
||||||
|
_, file, _, _ := runtime.Caller(0)
|
||||||
|
script := filepath.Join(filepath.Dir(file), "..", "..", "configs", "test_felhom_mkfs_guarded.py")
|
||||||
|
out, err := exec.Command(py, script).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("wrapper suite failed: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,10 @@ type Observer struct {
|
|||||||
// R-672 (v0.133.0): a thin pool crossing thinPoolAlarmFraction (data OR metadata) requests an
|
// R-672 (v0.133.0): a thin pool crossing thinPoolAlarmFraction (data OR metadata) requests an
|
||||||
// out-of-band host report at once, so the hub's storage-fill alarm sees it in seconds instead of at
|
// out-of-band host report at once, so the hub's storage-fill alarm sees it in seconds instead of at
|
||||||
// the next 15-minute report. Rising edge per pool; re-armed below thinPoolRearmFraction.
|
// the next 15-minute report. Rising edge per pool; re-armed below thinPoolRearmFraction.
|
||||||
|
// ident (agent v0.157.0, `16` §3) computes each target's physical-disk identity. nil → the fields stay
|
||||||
|
// empty (the pre-0.157 shape), which every consumer reads as "not computed".
|
||||||
|
ident *IdentityResolver
|
||||||
|
|
||||||
highMu sync.Mutex
|
highMu sync.Mutex
|
||||||
high map[string]bool
|
high map[string]bool
|
||||||
onThinHigh func()
|
onThinHigh func()
|
||||||
@@ -50,6 +54,51 @@ const (
|
|||||||
thinPoolRearmFraction = 0.85
|
thinPoolRearmFraction = 0.85
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SetIdentity wires the physical-disk identity resolver (main: NewIdentityResolver()).
|
||||||
|
func (o *Observer) SetIdentity(r *IdentityResolver) { o.ident = r }
|
||||||
|
|
||||||
|
// placeIdentity fills a target's identity fields (`16` §3). Network and remote stores are fixed
|
||||||
|
// "unknown/network"; a dir storage is judged only on its OWN mount (the same rule as backing_device —
|
||||||
|
// never the containing filesystem, so an absent drive is "not computed", never the root disk); the
|
||||||
|
// builtin local and an lvmthin pool are the system disk(s) they sit on.
|
||||||
|
func (o *Observer) placeIdentity(tgt *hub.StorageTarget, s proxmox.Storage) {
|
||||||
|
if o.ident == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var p PlaceIdentity
|
||||||
|
switch tgt.Type {
|
||||||
|
case hub.StorageTypeNFS, hub.StorageTypeCIFS, hub.StorageTypePBS:
|
||||||
|
tgt.Kind = PlaceKindNetwork
|
||||||
|
p = NetworkPlace()
|
||||||
|
case hub.StorageTypeLocal:
|
||||||
|
tgt.Kind = PlaceKindSystem
|
||||||
|
if s.Path != "" {
|
||||||
|
p = o.ident.HostPath(s.Path)
|
||||||
|
}
|
||||||
|
case hub.StorageTypeLVMThin:
|
||||||
|
tgt.Kind = PlaceKindSystem
|
||||||
|
if s.VGName != "" && s.ThinPool != "" {
|
||||||
|
p = o.ident.Device("/dev/mapper/" + dmEscape(s.VGName) + "-" + dmEscape(s.ThinPool) + "-tpool")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if tgt.BackingDevice == "" {
|
||||||
|
return // not its own mount (absent, or a dir on root): not computed
|
||||||
|
}
|
||||||
|
tgt.Kind = PlaceKindDisk
|
||||||
|
if _, known := wholeDiskOf(tgt.BackingDevice); known && !reWholeDisk.MatchString(tgt.BackingDevice) {
|
||||||
|
tgt.Kind = PlaceKindPartition
|
||||||
|
}
|
||||||
|
p = o.ident.Device(tgt.BackingDevice)
|
||||||
|
}
|
||||||
|
if !p.Computed() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tgt.PhysicalDisks, tgt.Identity, tgt.IdentityReason = p.PhysicalDisks, p.Identity, p.Reason
|
||||||
|
}
|
||||||
|
|
||||||
|
// dmEscape doubles dashes the way device-mapper names an LV (pve + vm-1-disk-0 → pve-vm--1--disk--0).
|
||||||
|
func dmEscape(s string) string { return strings.ReplaceAll(s, "-", "--") }
|
||||||
|
|
||||||
// SetThinHighTrigger wires the out-of-band report request (main: the storage trigger channel).
|
// SetThinHighTrigger wires the out-of-band report request (main: the storage trigger channel).
|
||||||
func (o *Observer) SetThinHighTrigger(f func()) { o.onThinHigh = f }
|
func (o *Observer) SetThinHighTrigger(f func()) { o.onThinHigh = f }
|
||||||
|
|
||||||
@@ -303,6 +352,9 @@ func (o *Observer) build(s proxmox.Storage, mounts []Mount) observed {
|
|||||||
o.noteThinFill(s.Storage+"/data", frac)
|
o.noteThinFill(s.Storage+"/data", frac)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Physical-disk identity (`16` §3) — after typ/backing are settled; never from a caller's claim.
|
||||||
|
o.placeIdentity(&tgt, s)
|
||||||
|
|
||||||
// SMART-only device hint (v0.95.0): a dir-storage that lives INSIDE a shared filesystem (the
|
// SMART-only device hint (v0.95.0): a dir-storage that lives INSIDE a shared filesystem (the
|
||||||
// builtin `local` on the LVM root) has empty backing by design, yet we can still read the PHYSICAL
|
// builtin `local` on the LVM root) has empty backing by design, yet we can still read the PHYSICAL
|
||||||
// disk's SMART by resolving its containing filesystem. Gated to catDir + no own backing + reachable,
|
// disk's SMART by resolving its containing filesystem. Gated to catDir + no own backing + reachable,
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The report rows carry the `16` §3 identity per storage type: local + lvmthin → the system disk; a dir
|
||||||
|
// on its own partition → that disk, kind "partition"; nfs/pbs → network; an UNMOUNTED dir → not computed
|
||||||
|
// (never the root disk — the same rule as backing_device).
|
||||||
|
func TestObserve_FillsPhysicalDiskIdentity(t *testing.T) {
|
||||||
|
f := newFx(t)
|
||||||
|
oneDiskLVM(f)
|
||||||
|
f.node("sdb", "", "ID_BUS=ata", "ID_MODEL=TOSHIBA_MQ04ABF100", "ID_SERIAL_SHORT=65NOP3HDT")
|
||||||
|
f.node("sdb1", "sdb")
|
||||||
|
f.mount("/dev/sdb1", "/mnt/usb-backup", "ext4")
|
||||||
|
res := f.resolver()
|
||||||
|
|
||||||
|
api := &fakeStorageAPI{
|
||||||
|
node: "demo",
|
||||||
|
cluster: []proxmox.Storage{
|
||||||
|
{Storage: "local", Type: "dir", Path: "/var/lib/vz"},
|
||||||
|
{Storage: "local-lvm", Type: "lvmthin", VGName: "pve", ThinPool: "data"},
|
||||||
|
{Storage: "usb-backup", Type: "dir", Path: "/mnt/usb-backup"},
|
||||||
|
{Storage: "gone", Type: "dir", Path: "/mnt/gone"},
|
||||||
|
{Storage: "nfs-arch", Type: "nfs", Server: "10.0.0.5", Export: "/x"},
|
||||||
|
},
|
||||||
|
nodeSt: []proxmox.Storage{
|
||||||
|
{Storage: "local", Type: "dir", Path: "/var/lib/vz", Active: 1},
|
||||||
|
{Storage: "local-lvm", Type: "lvmthin", Active: 1},
|
||||||
|
{Storage: "usb-backup", Type: "dir", Path: "/mnt/usb-backup", Active: 1},
|
||||||
|
{Storage: "gone", Type: "dir", Path: "/mnt/gone", Active: 1},
|
||||||
|
{Storage: "nfs-arch", Type: "nfs", Active: 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
host := &fakeHostReader{
|
||||||
|
mounts: []Mount{
|
||||||
|
{Device: "/dev/mapper/pve-root", MountPoint: "/", FSType: "ext4"},
|
||||||
|
{Device: "/dev/sdb1", MountPoint: "/mnt/usb-backup", FSType: "ext4"},
|
||||||
|
},
|
||||||
|
exists: map[string]bool{"/dev/sdb1": true, "/dev/mapper/pve-root": true},
|
||||||
|
}
|
||||||
|
ob := NewObserver(api, host, nil, quietLogger())
|
||||||
|
ob.SetIdentity(res)
|
||||||
|
got, err := ob.Observe(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := byName(got)
|
||||||
|
ssd := []string{"serial:ata:SanDisk_X600:182195804614"}
|
||||||
|
for _, n := range []string{"local", "local-lvm"} {
|
||||||
|
if tg := m[n]; tg.Kind != "system" || tg.Identity != "proven" || !reflect.DeepEqual(tg.PhysicalDisks, ssd) {
|
||||||
|
t.Errorf("%s: want system/proven on the SSD, got kind=%q id=%q disks=%v", n, tg.Kind, tg.Identity, tg.PhysicalDisks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tg := m["usb-backup"]; tg.Kind != "partition" || tg.Identity != "proven" || tg.PhysicalDisks[0] != "serial:ata:TOSHIBA_MQ04ABF100:65NOP3HDT" {
|
||||||
|
t.Errorf("usb-backup: got %+v", tg)
|
||||||
|
}
|
||||||
|
if tg := m["nfs-arch"]; tg.Kind != "network" || tg.IdentityReason != "network" {
|
||||||
|
t.Errorf("nfs: got kind=%q reason=%q", tg.Kind, tg.IdentityReason)
|
||||||
|
}
|
||||||
|
if tg := m["gone"]; tg.Identity != "" || len(tg.PhysicalDisks) != 0 {
|
||||||
|
t.Errorf("an unmounted dir must not be computed (never root's disk), got %+v", tg)
|
||||||
|
}
|
||||||
|
// Without a resolver the fields stay empty (the pre-0.157 shape).
|
||||||
|
plain, _ := NewObserver(api, host, nil, quietLogger()).Observe(context.Background())
|
||||||
|
if tg := byName(plain)["local"]; tg.Identity != "" || tg.Kind != "" {
|
||||||
|
t.Errorf("no resolver: want empty identity, got %+v", tg)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The data-bearing partition path binds the disk id + the part number + the fs-UUID (felhom.eu architecture/16 §6,
|
||||||
|
// agent v0.158.0). The wipe re-resolve (localapi antiRetargetResolveExpect) formats only when resolve(id) succeeds AND
|
||||||
|
// DeviceDurableID(that device) == id — the round trip these tests pin, in the three ways a target can move.
|
||||||
|
|
||||||
|
type partFixture struct{ base, root string }
|
||||||
|
|
||||||
|
func newPartFixture(t *testing.T) partFixture {
|
||||||
|
t.Helper()
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("symlink fixtures run on Linux")
|
||||||
|
}
|
||||||
|
f := partFixture{base: t.TempDir()}
|
||||||
|
f.root = filepath.Join(f.base, "disk")
|
||||||
|
for _, d := range []string{"sdb", "sdb1", "sdb2", "sdc2"} {
|
||||||
|
if err := os.WriteFile(filepath.Join(f.base, d), []byte("x"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
os.MkdirAll(filepath.Join(f.root, "by-id"), 0o755)
|
||||||
|
os.MkdirAll(filepath.Join(f.root, "by-uuid"), 0o755)
|
||||||
|
withDevDiskRoot(t, f.root)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f partFixture) link(t *testing.T, sub, name, dev string) {
|
||||||
|
t.Helper()
|
||||||
|
p := filepath.Join(f.root, sub, name)
|
||||||
|
os.Remove(p)
|
||||||
|
if err := os.Symlink(filepath.Join(f.base, dev), p); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f partFixture) dev(name string) string { return filepath.Join(f.base, name) }
|
||||||
|
|
||||||
|
func (f partFixture) standard(t *testing.T) {
|
||||||
|
f.link(t, "by-id", "wwn-0x5000aaaa", "sdb")
|
||||||
|
f.link(t, "by-id", "ata-TOSHIBA_MQ04_65NOP-part1", "sdb1")
|
||||||
|
f.link(t, "by-id", "wwn-0x5000aaaa-part1", "sdb1")
|
||||||
|
f.link(t, "by-id", "wwn-0x5000aaaa-part2", "sdb2")
|
||||||
|
f.link(t, "by-uuid", "1111-films", "sdb2")
|
||||||
|
}
|
||||||
|
|
||||||
|
func roundTrip(id string) (string, bool) {
|
||||||
|
dev, err := ResolveDurableDevice(id)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
again, err := DeviceDurableID(dev)
|
||||||
|
return dev, err == nil && again == id
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartitionWipeID_BindsDiskPartAndFilesystem(t *testing.T) {
|
||||||
|
f := newPartFixture(t)
|
||||||
|
f.standard(t)
|
||||||
|
id, err := DeviceDurableID(f.dev("sdb2"))
|
||||||
|
if err != nil || id != "byid:wwn-0x5000aaaa-part2+fsuuid:1111-films" {
|
||||||
|
t.Fatalf("a part with a filesystem: want disk id + part + fs-UUID, got %q %v", id, err)
|
||||||
|
}
|
||||||
|
if dev, ok := roundTrip(id); !ok || dev != f.dev("sdb2") {
|
||||||
|
t.Fatalf("the unchanged part must round-trip, got %q %v", dev, ok)
|
||||||
|
}
|
||||||
|
// A blank part (no filesystem) and a whole disk keep the plain by-id binding.
|
||||||
|
if id1, _ := DeviceDurableID(f.dev("sdb1")); id1 != "byid:wwn-0x5000aaaa-part1" {
|
||||||
|
t.Fatalf("a blank part: want the plain part link, got %q", id1)
|
||||||
|
}
|
||||||
|
if idDisk, _ := DeviceDurableID(f.dev("sdb")); idDisk != "byid:wwn-0x5000aaaa" {
|
||||||
|
t.Fatalf("a whole disk keeps today's id, got %q", idDisk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartitionWipeID_ReformattedPartRefuses(t *testing.T) {
|
||||||
|
f := newPartFixture(t)
|
||||||
|
f.standard(t)
|
||||||
|
id, _ := DeviceDurableID(f.dev("sdb2"))
|
||||||
|
os.Remove(filepath.Join(f.root, "by-uuid", "1111-films"))
|
||||||
|
f.link(t, "by-uuid", "2222-other", "sdb2") // someone reformatted the part since the household confirmed
|
||||||
|
if _, ok := roundTrip(id); ok {
|
||||||
|
t.Fatalf("a reformatted part must not round-trip (the wipe would hit a different filesystem)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartitionWipeID_LinkMovedToAnotherDiskRefuses(t *testing.T) {
|
||||||
|
f := newPartFixture(t)
|
||||||
|
f.standard(t)
|
||||||
|
id, _ := DeviceDurableID(f.dev("sdb2"))
|
||||||
|
f.link(t, "by-id", "wwn-0x5000aaaa-part2", "sdc2") // a different node took the part link; the fs is still sdb2
|
||||||
|
if _, err := ResolveDurableDevice(id); err == nil || !strings.Contains(err.Error(), "different devices") {
|
||||||
|
t.Fatalf("the part link and its filesystem on different devices must refuse, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartitionWipeID_RefusesAForgedCompound(t *testing.T) {
|
||||||
|
f := newPartFixture(t)
|
||||||
|
f.standard(t)
|
||||||
|
f.link(t, "by-uuid", "3333-whole", "sdb") // a filesystem straight on the whole disk: both halves name sdb
|
||||||
|
for _, id := range []string{"byid:wwn-0x5000aaaa+fsuuid:3333-whole", "byid:wwn-0x5000aaaa-part2+fsuuid:../x"} {
|
||||||
|
if _, err := ResolveDurableDevice(id); err == nil {
|
||||||
|
t.Fatalf("%q must be refused", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Partitions as places (agent v0.158.0) — felhom.eu architecture/16-storage-physical-identity.md §6.
|
||||||
|
//
|
||||||
|
// A disk that passed the WHOLE-disk unclaimed filter (classifyClaim: not the system disk, no LVM/ZFS/RAID/LUKS/swap
|
||||||
|
// member on any node, no foreign mount on any node) also lists its partitions, each with its own state:
|
||||||
|
//
|
||||||
|
// empty no filesystem, no children → „format this part" (self-serve: the agent's probe says blank)
|
||||||
|
// filesystem ext4/xfs (mountable) or another fs → „use this part" when mountable; „format this part" only through
|
||||||
|
// the customer-confirmed data-bearing path (the agent's own probe)
|
||||||
|
// in_use already Felhom's own drive → shown, never offered (it is a registered drive already)
|
||||||
|
//
|
||||||
|
// A partition with children (LVM, a mapper) is not listed. The design's separate „a filesystem with no files" state is
|
||||||
|
// folded into `filesystem` on purpose: telling the two apart needs a root read-only mount, and the data-bearing path's
|
||||||
|
// confirmation is the safer default for both (`16` §13). Creating, deleting or resizing a partition: never here (D5).
|
||||||
|
// The format binding is the partition's own by-id link (…-partN: the disk id + the partition number) via
|
||||||
|
// DeviceDurableID, re-resolved before mkfs (wipe_reresolve.go); the attach key is its fs-UUID.
|
||||||
|
type CandidatePartition struct {
|
||||||
|
Device string `json:"device"` // e.g. /dev/sdb2
|
||||||
|
Number int `json:"number,omitempty"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
FSType string `json:"fstype,omitempty"`
|
||||||
|
State string `json:"state"` // empty | filesystem | in_use
|
||||||
|
Mountable bool `json:"mountable"`
|
||||||
|
DurableID string `json:"durable_id,omitempty"` // uuid:<fs-uuid> — the attach key ("" when empty)
|
||||||
|
MountedAt string `json:"mounted_at,omitempty"` // in_use: Felhom's own mount
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
PartEmpty = "empty"
|
||||||
|
PartFilesystem = "filesystem"
|
||||||
|
PartInUse = "in_use"
|
||||||
|
)
|
||||||
|
|
||||||
|
// candidatePartitions is the pure lister: the whole disk's lsblk tree → its partitions. sizeOf/numberOf read /sys,
|
||||||
|
// uuidOf the by-uuid links, owned is the set of mountpoints corroborated as Felhom's own (felhomOwnedMounts).
|
||||||
|
func candidatePartitions(lsblkJSON []byte, wholeName string, sizeOf func(string) int64, numberOf func(string) int,
|
||||||
|
uuidOf func(string) (string, bool), owned map[string]bool) []CandidatePartition {
|
||||||
|
var doc struct {
|
||||||
|
BlockDevices []lsblkDev `json:"blockdevices"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(lsblkJSON, &doc) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []CandidatePartition
|
||||||
|
for _, d := range doc.BlockDevices {
|
||||||
|
if d.Name != wholeName {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, p := range d.Children {
|
||||||
|
if len(p.Children) > 0 || memberFSTypes[p.FSType] {
|
||||||
|
continue // LVM/mapper on it, or a member: not a place (the disk filter refuses these anyway)
|
||||||
|
}
|
||||||
|
c := CandidatePartition{Device: "/dev/" + p.Name, Number: numberOf(p.Name), SizeBytes: sizeOf(p.Name), FSType: p.FSType}
|
||||||
|
switch {
|
||||||
|
case p.MountPoint != "" && (underFelhomDrives(p.MountPoint) || owned[p.MountPoint]):
|
||||||
|
c.State, c.MountedAt = PartInUse, p.MountPoint
|
||||||
|
case p.MountPoint != "":
|
||||||
|
continue // mounted by something else: never offered (the disk filter refuses the disk anyway)
|
||||||
|
case p.FSType == "":
|
||||||
|
c.State = PartEmpty
|
||||||
|
default:
|
||||||
|
c.State = PartFilesystem
|
||||||
|
c.Mountable = mountableFSTypes[p.FSType]
|
||||||
|
if u, ok := uuidOf(c.Device); ok && u != "" {
|
||||||
|
c.DurableID = "uuid:" + u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// readSysPartNumber reads /sys/class/block/<part>/partition (0 when unreadable).
|
||||||
|
func readSysPartNumber(name string) int {
|
||||||
|
b, err := os.ReadFile(filepath.Join("/sys/class/block", name, "partition"))
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n, _ := strconv.Atoi(strings.TrimSpace(string(b)))
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// readSysPartSize reads a partition's size in bytes from /sys/class/block/<part>/size (512-byte sectors).
|
||||||
|
func readSysPartSize(name string) int64 {
|
||||||
|
b, err := os.ReadFile(filepath.Join("/sys/class/block", name, "size"))
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
s, err := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return s * 512
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Partitions as places (felhom.eu architecture/16 §6, agent v0.158.0): every partition of a free disk with its own
|
||||||
|
// state; Felhom's own drive shown but never offered; anything foreign, layered or a member never listed.
|
||||||
|
|
||||||
|
const partTree = `{"blockdevices":[
|
||||||
|
{"name":"sdb","fstype":null,"mountpoint":null,"children":[
|
||||||
|
{"name":"sdb1","fstype":"ext4","mountpoint":"/mnt/felhom-drives/hdd_1"},
|
||||||
|
{"name":"sdb2","fstype":null,"mountpoint":null},
|
||||||
|
{"name":"sdb3","fstype":"ntfs","mountpoint":null},
|
||||||
|
{"name":"sdb4","fstype":"xfs","mountpoint":null},
|
||||||
|
{"name":"sdb5","fstype":"ext4","mountpoint":"/mnt/films"},
|
||||||
|
{"name":"sdb6","fstype":"ext4","mountpoint":"/srv/data"},
|
||||||
|
{"name":"sdb7","fstype":"LVM2_member","mountpoint":null,"children":[{"name":"vg-lv","fstype":"ext4","mountpoint":null}]},
|
||||||
|
{"name":"sdb8","fstype":"crypto_LUKS","mountpoint":null}
|
||||||
|
]},
|
||||||
|
{"name":"sdc","fstype":null,"mountpoint":null,"children":[{"name":"sdc1","fstype":null,"mountpoint":null}]}
|
||||||
|
]}`
|
||||||
|
|
||||||
|
func listParts(owned map[string]bool) map[string]CandidatePartition {
|
||||||
|
got := candidatePartitions([]byte(partTree), "sdb",
|
||||||
|
func(n string) int64 { return int64(len(n)) << 30 },
|
||||||
|
func(n string) int { return int(n[len(n)-1] - '0') },
|
||||||
|
func(dev string) (string, bool) { return "uuid-of-" + strings.TrimPrefix(dev, "/dev/"), true },
|
||||||
|
owned)
|
||||||
|
m := map[string]CandidatePartition{}
|
||||||
|
for _, p := range got {
|
||||||
|
m[p.Device] = p
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartitions_StatesPerPartition(t *testing.T) {
|
||||||
|
m := listParts(map[string]bool{"/mnt/films": true})
|
||||||
|
want := map[string]string{"/dev/sdb1": PartInUse, "/dev/sdb2": PartEmpty, "/dev/sdb3": PartFilesystem,
|
||||||
|
"/dev/sdb4": PartFilesystem, "/dev/sdb5": PartInUse}
|
||||||
|
if len(m) != len(want) {
|
||||||
|
t.Fatalf("want exactly %d listed partitions, got %+v", len(want), m)
|
||||||
|
}
|
||||||
|
for dev, st := range want {
|
||||||
|
if m[dev].State != st {
|
||||||
|
t.Fatalf("%s: want %s, got %+v", dev, st, m[dev])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m["/dev/sdb2"].DurableID != "" || m["/dev/sdb2"].Mountable {
|
||||||
|
t.Fatalf("an empty part has no attach key: %+v", m["/dev/sdb2"])
|
||||||
|
}
|
||||||
|
if !m["/dev/sdb4"].Mountable || m["/dev/sdb4"].DurableID != "uuid:uuid-of-sdb4" || m["/dev/sdb4"].Number != 4 {
|
||||||
|
t.Fatalf("an xfs part is usable by its fs-UUID: %+v", m["/dev/sdb4"])
|
||||||
|
}
|
||||||
|
if m["/dev/sdb3"].Mountable {
|
||||||
|
t.Fatalf("ntfs is a filesystem the agent does not mount as-is: %+v", m["/dev/sdb3"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A raw /mnt/<name> mount counts as Felhom's own ONLY when corroborated (felhomOwnedMounts); else it is foreign and
|
||||||
|
// the part is not listed. Positive control: TestPartitions_StatesPerPartition (the same mount, corroborated).
|
||||||
|
func TestPartitions_UncorroboratedMountIsNotOffered(t *testing.T) {
|
||||||
|
m := listParts(nil)
|
||||||
|
if _, ok := m["/dev/sdb5"]; ok {
|
||||||
|
t.Fatalf("an uncorroborated /mnt/films mount must not be listed, got %+v", m["/dev/sdb5"])
|
||||||
|
}
|
||||||
|
for _, dev := range []string{"/dev/sdb6", "/dev/sdb7", "/dev/sdb8", "/dev/sdc1"} {
|
||||||
|
if _, ok := m[dev]; ok {
|
||||||
|
t.Fatalf("%s must never be listed (foreign mount, LVM, LUKS, another disk)", dev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user