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'); });