From 36da4089cac4ad37d0025aa29fc46df3304d8ec4 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 16 Oct 2026 04:05:09 +0200 Subject: [PATCH] v0.318.0: a part is offered for formatting only while nothing on its disk is mounted (agent 0.158.1 formattable) The agent's root mkfs wrapper (security review of agent 0.158.0) refuses any mounted node and trusts no sibling claim; the wizard now matches it. A free filesystem part of an in-use disk stays offered for use. Red-proved. Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb --- CHANGELOG.md | 12 ++++++---- controller/README.md | 11 +++++---- controller/internal/agentapi/client.go | 3 +++ .../internal/web/agent_disk_handlers.go | 19 ++++++++------- controller/internal/web/partitions_test.go | 23 +++++++++++-------- .../internal/web/templates/storage_init.html | 3 ++- .../testdata/i18n_parity/storage_init.html | 3 ++- 7 files changed, 45 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4b8a92..4c37cae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## v0.318.0 — the internal data place, partitions, an honest whole-box backup offer, sys_drive never offered (2026-10-16) **MinAgent: 0.157.0** — nothing here calls a new agent endpoint. The internal place and the partitions appear only when the -agent (≥ 0.158.0) reports them; below that the pages are exactly as before. The backup offer uses the 0.157.0 same-disk call +agent (≥ 0.158.1; 0.158.0 is superseded) reports them; below that the pages are exactly as before. The backup offer uses the 0.157.0 same-disk call (`FeatureSameDisk`), and an older agent keeps today's offer. No golden (`09` §3 decision 250). Design: felhom.eu `architecture/16-storage-physical-identity.md` §4–§6 (build B; operator rulings D3–D5, sizes 2026-10-16). @@ -15,10 +15,12 @@ agent (≥ 0.158.0) reports them; below that the pages are exactly as before. Th agent → today's offer. - **R-1082 — `/mnt/sys_drive` is never offered to attach** (operator ruling 2026-10-16 „hide it now"): excluded by device, so no second bind of the data volume can bring it back. A box that already registered it keeps it. -- **Partitions** (`16` §6): the format and attach wizards list each part of a free disk („{n}. rész"); a disk whose other - part is a registered drive offers only its free parts, and formatting that whole disk is refused on the server too - (`err.web.meghajto_hasznalatban_formazas`). A formatted part's filesystem is found for the mount. Nothing creates, deletes - or resizes a part. +- **Partitions** (`16` §6, agent ≥ 0.158.1): the format and attach wizards list each part of a free disk („{n}. rész"). + A part is offered for FORMATTING only while nothing on its disk is mounted (the agent's `formattable`: its root mkfs + wrapper refuses any mounted node and trusts no „it is Felhom's own" claim — security review of agent 0.158.0). A disk + whose other part is a registered drive offers only its free filesystem parts, for USE; formatting that whole disk is + refused on the server too (`err.web.meghajto_hasznalatban_formazas`). A formatted part's filesystem is found for the + mount. Nothing creates, deletes or resizes a part. - Tests red-proved: internal_place_test.go, d4 (a media app lands on the internal place), r1084_offer_identity_test.go, r1082_sysdrive_hidden_test.go, partitions_test.go. Four parity fixtures refreshed (diff = only the new lines). diff --git a/controller/README.md b/controller/README.md index 5182255..ee768e3 100644 --- a/controller/README.md +++ b/controller/README.md @@ -3404,15 +3404,16 @@ the controller only asks. - **Second copies** (v0.317.0): Tier-2 and the one-copy notice ask the agent (`POST /disks/same-disk`); a copy counts only on a proven different disk. **The whole-box backup offer** (v0.318.0, R-1084) does the same: proven different → the disk-failure promise; unproven (a VM) → offered without the promise (`backup.target.offer_unproven`); same disk → not offered. -- **The internal data place „Belső tárhely"** (v0.318.0, agent ≥ 0.158.0): a volume on the system disk that the installer (or +- **The internal data place „Belső tárhely"** (v0.318.0, agent ≥ 0.158.1): a volume on the system disk that the installer (or the operator's signed `add_internal_place`) makes on a one-disk box. When the agent reports it (`GET /disks`, kind `internal`), the drive-gate tick binds it into the guest and, on the next tick, registers it as a drive of kind `internal` — the default drive unless the household has one. Media and data-folder apps then install with no extra step. Eject, retire and format are refused (`err.web.belso_tarhely_eletciklus`); moving its data to another drive is allowed. -- **Partitions** (v0.318.0, agent ≥ 0.158.0): the format and attach wizards list each part of a free disk — „format this - part" (empty, or with data through the typed confirmation) and „use this part" (an ext4/xfs filesystem). A disk whose other - part is already a drive offers only its free parts; formatting that whole disk is refused on the server too. No part is - ever created, deleted or resized from the dashboard (`16` D5). +- **Partitions** (v0.318.0, agent ≥ 0.158.1): the format and attach wizards list each part of a free disk — „format this + part" (empty, or with data through the typed confirmation), only while nothing on the disk is mounted; „use this part" + (an ext4/xfs filesystem) always. A disk whose other part is already a drive offers only its free filesystem parts, for + use; formatting that whole disk is refused on the server too. No part is ever created, deleted or resized from the + dashboard (`16` D5). - **`/mnt/sys_drive` is never offered to attach** (v0.318.0, R-1082): it rides in every whole-box copy. A box that already registered it keeps it. diff --git a/controller/internal/agentapi/client.go b/controller/internal/agentapi/client.go index 2c8de61..9e27892 100644 --- a/controller/internal/agentapi/client.go +++ b/controller/internal/agentapi/client.go @@ -500,6 +500,9 @@ type CandidatePartition struct { Mountable bool `json:"mountable"` DurableID string `json:"durable_id,omitempty"` // uuid: — the attach key MountedAt string `json:"mounted_at,omitempty"` + // Formattable (agent ≥ 0.158.1): false for every part of a disk with a mounted node — the root mkfs wrapper refuses + // those and trusts no „it is Felhom's own" claim. Only formattable parts are offered for formatting. + Formattable bool `json:"formattable"` } // Partition states (agent ≥ 0.158.0). diff --git a/controller/internal/web/agent_disk_handlers.go b/controller/internal/web/agent_disk_handlers.go index 3cc23ca..c56804a 100644 --- a/controller/internal/web/agent_disk_handlers.go +++ b/controller/internal/web/agent_disk_handlers.go @@ -186,13 +186,13 @@ func dropRegisteredDrives(resp agentapi.CandidatesResult, mountsText string, reg if len(inUse) == 0 { return resp } - keep := func(in []agentapi.DiskCandidate) []agentapi.DiskCandidate { + keep := func(in []agentapi.DiskCandidate, usable func(agentapi.CandidatePartition) bool) []agentapi.DiskCandidate { out := make([]agentapi.DiskCandidate, 0, len(in)) for _, c := range in { if candidateBacksInUse(c, inUse) { - // `16` §6 (v0.318.0): a disk whose ONE part is a registered drive keeps its FREE parts on offer — + // `16` §6 (v0.318.0): a disk whose ONE part is a registered drive keeps its usable FREE parts on offer — // the whole disk itself, never (WholeDiskInUse; handleStorageInit refuses it server-side too). - if free := freePartitions(c.Partitions, inUse); len(free) > 0 { + if free := freePartitions(c.Partitions, inUse, usable); len(free) > 0 { c.Partitions, c.WholeDiskInUse = free, true out = append(out, c) } @@ -202,8 +202,10 @@ func dropRegisteredDrives(resp agentapi.CandidatesResult, mountsText string, reg } return out } - resp.Initialize = keep(resp.Initialize) - resp.Attach = keep(resp.Attach) + // Formatting a part needs a disk with nothing mounted (the agent's Formattable — its root wrapper refuses otherwise); + // USING a part with a filesystem erases nothing and stays offered. + resp.Initialize = keep(resp.Initialize, func(p agentapi.CandidatePartition) bool { return p.Formattable }) + resp.Attach = keep(resp.Attach, func(p agentapi.CandidatePartition) bool { return p.State == agentapi.PartFilesystem && p.Mountable }) return resp } @@ -223,11 +225,12 @@ func deviceBacksRegistered(device, mountsText string, registered map[string]bool return candidateBacksInUse(agentapi.DiskCandidate{Device: device}, inUse) } -// freePartitions keeps the parts that are not a registered drive and not already Felhom's (state in_use). -func freePartitions(parts []agentapi.CandidatePartition, inUse map[string]bool) []agentapi.CandidatePartition { +// freePartitions keeps the parts that are not a registered drive, not already Felhom's (state in_use), and usable for +// the list in question. +func freePartitions(parts []agentapi.CandidatePartition, inUse map[string]bool, usable func(agentapi.CandidatePartition) bool) []agentapi.CandidatePartition { var out []agentapi.CandidatePartition for _, p := range parts { - if p.State == agentapi.PartInUse || inUse[p.Device] { + if p.State == agentapi.PartInUse || inUse[p.Device] || !usable(p) { continue } out = append(out, p) diff --git a/controller/internal/web/partitions_test.go b/controller/internal/web/partitions_test.go index cda41dd..bc0596e 100644 --- a/controller/internal/web/partitions_test.go +++ b/controller/internal/web/partitions_test.go @@ -28,18 +28,23 @@ func diskWithParts() agentapi.DiskCandidate { }} } -func TestPartitions_FreePartsOfAnInUseDiskStayOffered(t *testing.T) { +// A disk whose one part is a registered drive: its free FILESYSTEM part stays offered for USE (attach erases nothing); +// nothing of it is offered for formatting — the agent marks its parts not Formattable (a node is mounted) and its root +// wrapper would refuse (agent 0.158.1, security review). Positive control: an unmounted disk's formattable part. +func TestPartitions_InUseDiskOffersItsFreePartsForUseOnly(t *testing.T) { reg := map[string]bool{"/mnt/felhom-drives/hdd_1": true} - got := dropRegisteredDrives(agentapi.CandidatesResult{Initialize: []agentapi.DiskCandidate{diskWithParts()}}, partMounts, reg) - if len(got.Initialize) != 1 { - t.Fatalf("the disk must stay listed for its free parts, got %+v", got.Initialize) + d := diskWithParts() + got := dropRegisteredDrives(agentapi.CandidatesResult{Initialize: []agentapi.DiskCandidate{d}, Attach: []agentapi.DiskCandidate{d}}, partMounts, reg) + if len(got.Initialize) != 0 { + t.Fatalf("nothing of a disk with a mounted part may be offered for formatting, got %+v", got.Initialize) } - c := got.Initialize[0] - if !c.WholeDiskInUse { - t.Fatalf("the whole disk must be marked in use (no whole-disk action): %+v", c) + if len(got.Attach) != 1 || !got.Attach[0].WholeDiskInUse || len(got.Attach[0].Partitions) != 1 || got.Attach[0].Partitions[0].Device != "/dev/sdb3" { + t.Fatalf("the free filesystem part must stay offered for use (and only it), got %+v", got.Attach) } - if len(c.Partitions) != 2 || c.Partitions[0].Device != "/dev/sdb2" || c.Partitions[1].Device != "/dev/sdb3" { - t.Fatalf("only the free parts may be offered, got %+v", c.Partitions) + free := agentapi.DiskCandidate{Device: "/dev/sdc", Partitions: []agentapi.CandidatePartition{{Device: "/dev/sdc1", State: agentapi.PartEmpty, Formattable: true}}} + got = dropRegisteredDrives(agentapi.CandidatesResult{Initialize: []agentapi.DiskCandidate{free}}, partMounts, reg) + if len(got.Initialize) != 1 || len(got.Initialize[0].Partitions) != 1 { + t.Fatalf("positive control: an unmounted disk's formattable part stays offered, got %+v", got.Initialize) } } diff --git a/controller/internal/web/templates/storage_init.html b/controller/internal/web/templates/storage_init.html index 34d1bf1..f598e84 100644 --- a/controller/internal/web/templates/storage_init.html +++ b/controller/internal/web/templates/storage_init.html @@ -98,7 +98,8 @@ async function loadDisks(){ // `16` §6: a disk whose other part is already a drive offers only its free parts, never the whole disk. if(!d.whole_disk_in_use) html+=card(d.device, title, parts.join(' · '), d.data_bearing); (d.partitions||[]).forEach(function(p){ - if(p.state!=='empty' && p.state!=='filesystem') return; + // only a part the agent says can be formatted (nothing else on the disk is mounted — its root wrapper refuses otherwise) + if((p.state!=='empty' && p.state!=='filesystem') || !p.formattable) return; var ps=[esc(p.device)]; if(p.size_bytes) ps.push(fmtSize(p.size_bytes)); if(p.fstype) ps.push('{{T "storage_init.jelenlegi"}} '+esc(p.fstype)); html+=card(p.device, title+' — '+'{{T "storage_wizard.resz_n"}}'.replace('{n}', p.number||'?'), ps.join(' · '), p.state==='filesystem'); }); diff --git a/controller/internal/web/testdata/i18n_parity/storage_init.html b/controller/internal/web/testdata/i18n_parity/storage_init.html index 2a1b2db..6f2fe9d 100644 --- a/controller/internal/web/testdata/i18n_parity/storage_init.html +++ b/controller/internal/web/testdata/i18n_parity/storage_init.html @@ -272,7 +272,8 @@ async function loadDisks(){ if(!d.whole_disk_in_use) html+=card(d.device, title, parts.join(' · '), d.data_bearing); (d.partitions||[]).forEach(function(p){ - if(p.state!=='empty' && p.state!=='filesystem') return; + + if((p.state!=='empty' && p.state!=='filesystem') || !p.formattable) return; var ps=[esc(p.device)]; if(p.size_bytes) ps.push(fmtSize(p.size_bytes)); if(p.fstype) ps.push('jelenlegi: '+esc(p.fstype)); html+=card(p.device, title+' — '+'{n}. rész'.replace('{n}', p.number||'?'), ps.join(' · '), p.state==='filesystem'); });