From a43d0423d5781ce363bee8ff30c1845731eb3bfb Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 16 Oct 2026 03:57:49 +0200 Subject: [PATCH] v0.318.0: the internal data place, partitions, an honest whole-box backup offer (R-1084), sys_drive never offered (R-1082) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit felhom.eu architecture/16 §4-§6 (build B). MinAgent 0.157.0 — the internal place and the partitions appear only when agent >= 0.158.0 reports them. Tests red-proved; four parity fixtures refreshed (only the new lines). Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb --- CHANGELOG.md | 24 ++++ REUSE.md | 3 + controller/README.md | 20 ++++ controller/internal/agentapi/client.go | 32 +++++- controller/internal/i18n/locales/en.json | 6 + controller/internal/i18n/locales/hu.json | 6 + controller/internal/settings/settings.go | 8 ++ .../internal/stacks/d4_needs_drive_test.go | 35 ++++++ .../internal/web/agent_disk_handlers.go | 34 ++++++ controller/internal/web/attach_sources.go | 46 +++++++- .../internal/web/backup_target_offer.go | 92 +++++++++++++-- controller/internal/web/i18n_cases_c_test.go | 4 + controller/internal/web/intermediary.go | 1 + controller/internal/web/internal_place.go | 108 ++++++++++++++++++ .../internal/web/internal_place_test.go | 103 +++++++++++++++++ controller/internal/web/partitions_test.go | 99 ++++++++++++++++ .../web/r1082_sysdrive_hidden_test.go | 36 ++++++ .../internal/web/r1084_offer_identity_test.go | 90 +++++++++++++++ controller/internal/web/server.go | 5 + controller/internal/web/storage_handlers.go | 25 ++++ .../internal/web/templates/storage.html | 9 +- .../web/templates/storage_attach.html | 14 +++ .../internal/web/templates/storage_init.html | 22 +++- .../testdata/i18n_parity/storage_attach.html | 14 +++ .../testdata/i18n_parity/storage_empty.html | 2 + .../testdata/i18n_parity/storage_full.html | 73 +++++++++++- .../testdata/i18n_parity/storage_init.html | 22 +++- controller/scripts/i18n_go_keys.json | 6 +- 28 files changed, 913 insertions(+), 26 deletions(-) create mode 100644 controller/internal/web/internal_place.go create mode 100644 controller/internal/web/internal_place_test.go create mode 100644 controller/internal/web/partitions_test.go create mode 100644 controller/internal/web/r1082_sysdrive_hidden_test.go create mode 100644 controller/internal/web/r1084_offer_identity_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e9422a0..c4b8a92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,27 @@ +## 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 +(`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). + +- **The internal data place „Belső tárhely"** (`web/internal_place.go`): an agent row of kind `internal` is bound into the + guest on one drive-gate tick and registered on the next — kind `internal`, the default drive unless the household has one, + label in the box's language. Media and data-folder apps then install with no extra step and no sentence (R-1068 closes when this is seen + live). Eject, retire and format of it are refused in words; its card says it is on the machine's own disk and + outside the full system backup; it is never offered as the whole-box backup place. +- **R-1084 — the whole-box backup offer asks the agent**: proven different disk → today's promise; unproven (a VM, a USB + bridge) → offered with build A's words and no promise (`backup.target.offer_unproven`); same disk → not offered; older + 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. +- 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). + ## v0.317.0 — a second copy counts only on a proven different disk; the no-drive install speaks in words (2026-10-15) **MinAgent: 0.157.0** — the same-disk verdict needs agent 0.157.0 (`FeatureSameDisk`, `POST /disks/same-disk`). Below it the diff --git a/REUSE.md b/REUSE.md index f29ae37..5f9e55f 100644 --- a/REUSE.md +++ b/REUSE.md @@ -215,6 +215,9 @@ | `system.IsMountPoint` / `IsWritable` / `PathsOverlap` | controller/internal/system/mounts_linux.go | `(path) bool` | Mount checks | `_other.go` stubs return permissive values — Linux behavior is the real one | | `system.CheckBackupDestination` | controller/internal/system/mounts_linux.go | `(path) DestinationHealth` | Tier2/offbox target vetting | Detects same-physical-device (`SamePhysicalDevice`) | | `backup.Manager.diskRelation` | controller/internal/backup/backup.go | `(a, b) (DiskRelation, reason)` — DiskDifferent / DiskSame / DiskUnknown | EVERY Tier-2 or one-copy "same disk?" decision (v0.317.0, felhom.eu `16-storage-physical-identity.md` §4) | Asks the host agent (`SetDiskJudge`, FeatureSameDisk ≥ agent 0.157.0); an older agent or an `unmapped` path → the old `sameDevice`/st_dev rule. Never call `sameDevice` directly for a Tier-2 decision — st_dev calls two partitions of one disk "different". Pinned by diskrelation_test.go | +| `web.Server.ensureInternalPlace` / `refuseInternalLifecycle` | controller/internal/web/internal_place.go | drive-gate tick hook; `(w, r, where) bool` | the internal data place (v0.318.0, `16` §5): bind on one tick, register (kind internal, default when none) on the next; refuse eject/decommission/wipe | Only an agent row of kind `internal` is enrolled without a press — a household drive never is. Pinned by internal_place_test.go + stacks d4_needs_drive_test.go (a media app lands on it) | +| `web.Server.offerRelationFor` | controller/internal/web/backup_target_offer.go | `(ctx, drive) offerRelation` (sameDiskFn seam) | the whole-box backup offer's disk question (R-1084) | Older agent → today's offer; an error → no promise. Pinned by r1084_offer_identity_test.go | +| `web.withoutSystemVolume` / `deviceBacksRegistered` / `freePartitions` | controller/internal/web/attach_sources.go, agent_disk_handlers.go | pure filters | R-1082 (sys_drive never offered, by device) and partitions (`16` §6: free parts of an in-use disk stay, the whole disk never; server-side refusal) | Pinned by r1082_sysdrive_hidden_test.go + partitions_test.go | | `system.ProbeStoragePath` | controller/internal/system/mounts_linux.go | `(path) ProbeResult` | Disconnect detection | — | | `appexport.DiskFree` | controller/internal/appexport/estimate.go | `(path) int64` | Free bytes for space gates (df-based, 0 on any error) | Exported v0.128.0 for the browser-upload gate; test seam = `web.uploadDiskFree` package var | | `stacks.ExportDataMounts` | controller/internal/stacks/delete.go | `(composePath, hddPath) []string` | THE .fab-export mount discovery (v0.130.0 C6B-F1) | Unions `${HDD_PATH}` binds + the `${USERDATA_PATH}` ROOT (single `userdata` entry — basename must round-trip the import's `/` mapping; NEVER return per-bind userdata subpaths). Containment-deduped. Backup-side `stackAdapter` deliberately does NOT use it | diff --git a/controller/README.md b/controller/README.md index 8f5da68..5182255 100644 --- a/controller/README.md +++ b/controller/README.md @@ -3396,6 +3396,26 @@ no key into the household's account). Code: `internal/stacks/after_unclean_boot. - **Plex:** the claim code is an `after_start` field — asked on the Plex page once Plex runs (`POST /apps/plex/plex-claim`); the gate opens only when Plex says claimed, and „Kész" is refused before that. +### 8d. Storage that knows its physical disks — the internal place, partitions (v0.317.0 build A, v0.318.0 build B) + +Design: felhom.eu `architecture/16-storage-physical-identity.md`. The host agent decides which physical disk a place sits on; +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 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). +- **`/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. + ### 9. Central Hub Reporting #### Operator actions (`internal/report/opactions.go`, `09` §3 decision 185) diff --git a/controller/internal/agentapi/client.go b/controller/internal/agentapi/client.go index 966485c..2c8de61 100644 --- a/controller/internal/agentapi/client.go +++ b/controller/internal/agentapi/client.go @@ -357,12 +357,16 @@ type DiskInfo struct { Smart *SmartSummary `json:"smart,omitempty"` // Physical-disk identity (agent v0.157.0, `16` §3): kind, the keys of the physical disks under the place, // proven|unknown + reason. Empty from an older agent or for an absent drive. - Kind string `json:"kind,omitempty"` + Kind string `json:"kind,omitempty"` // system | internal | disk | partition | network (felhom.eu `16` §3) PhysicalDisks []string `json:"physical_disks,omitempty"` Identity string `json:"identity,omitempty"` IdentityReason string `json:"identity_reason,omitempty"` } +// PlaceKindInternal is the agent's kind for the one-disk box's internal data place („Belső tárhely", agent ≥ 0.158.0, +// felhom.eu architecture/16 §5): a host mount on the system disk, bound at /mnt/felhom-drives/belso. +const PlaceKindInternal = "internal" + // FSUUID returns the raw filesystem UUID from a "uuid:<…>" DurableID, or "" if this disk's identity // is not a filesystem UUID (network/lvm targets — not assignable as a host mount). func (d DiskInfo) FSUUID() string { @@ -477,8 +481,34 @@ type DiskCandidate struct { // mountpoint — sending it down the device-attach path would try to mount an in-guest path as if // it were a raw device. See web/attach_sources.go for why the agent's scan cannot supply these. AlreadyMounted bool `json:"already_mounted,omitempty"` + // Partitions (agent ≥ 0.158.0, felhom.eu architecture/16 §6): each partition of this disk as its own place — + // state empty („format this part"), filesystem („use this part" when mountable; format through the confirmed + // path), in_use (already a Felhom drive: shown, never offered). Absent from an older agent. + Partitions []CandidatePartition `json:"partitions,omitempty"` + // WholeDiskInUse is set by the CONTROLLER (never the agent) when a partition of this disk is a registered drive + // (R-542): the disk stays listed for its FREE parts only, and no whole-disk action is offered. + WholeDiskInUse bool `json:"whole_disk_in_use,omitempty"` } +// CandidatePartition mirrors the agent's partition entry (felhom-agent internal/storage/partitions.go). +type CandidatePartition struct { + Device string `json:"device"` + 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: — the attach key + MountedAt string `json:"mounted_at,omitempty"` +} + +// Partition states (agent ≥ 0.158.0). +const ( + PartEmpty = "empty" + PartFilesystem = "filesystem" + PartInUse = "in_use" +) + // CandidatesResult mirrors GET /disks/candidates: disks free to enroll, split into initialize (all // unclaimed) and attach (the mountable-FS subset). type CandidatesResult struct { diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index b6dfee5..16a4683 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -1362,6 +1362,12 @@ "err.stacks.ellenorzes_sikertelen_mentes": "the check failed (%s backup): %s", "err.stacks.field_required": "the %q (%s) field has to be filled in", "err.stacks.needs_drive": "This app needs a storage drive. You can add one on the Storage → Drives page.", + "storage_wizard.resz_n": "part {n}", + "err.web.meghajto_hasznalatban_formazas": "This disk holds a drive that is in use, so the whole disk cannot be formatted. You can format only its free part.", + "storage.belso_tarhely_magyarazat": "It is on the machine's own disk, but it is not part of the full system backup. A different disk or the off-site backup protects against a disk failure.", + "backup.target.offer_unproven": "You can choose this drive as the place for the system backup. Felhom cannot check that this drive is really a different disk (inside a virtual machine and behind some USB enclosures this is not visible), so we cannot promise that you can restore the system after a disk failure.", + "storage.belso_tarhely": "Internal storage", + "err.web.belso_tarhely_eletciklus": "The internal storage is on the disk of the machine itself, so it cannot be removed, retired or formatted. You can move its data to another drive.", "err.stacks.field_required_password": "the %q field has to be filled in — use the Generate button, or type a password", "err.stacks.forras_nevter_olvasasa_sikertelen": "reading the source folder failed: %s", "err.stacks.forras_torlese_sikertelen": "removing the source failed (%s): %s", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 1cba935..b92fbde 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -1355,6 +1355,12 @@ "err.stacks.ellenorzes_sikertelen_mentes": "ellenőrzés sikertelen (%s mentés): %s", "err.stacks.field_required": "a(z) %q (%s) mező kitöltése kötelező", "err.stacks.needs_drive": "Ehhez az alkalmazáshoz egy adattároló kell. A Meghajtók oldalon adhatsz hozzá egyet.", + "storage_wizard.resz_n": "{n}. rész", + "err.web.meghajto_hasznalatban_formazas": "Ezen a lemezen egy használatban lévő meghajtó van, ezért a teljes lemez nem formázható. Csak a szabad részét formázhatod.", + "storage.belso_tarhely_magyarazat": "A gép saját lemezén van, de nem része a teljes rendszermentésnek. Egy lemezhiba ellen egy másik lemez vagy a távoli mentés véd.", + "backup.target.offer_unproven": "Ezt a meghajtót kijelölheted a rendszermentés helyéül. A Felhom nem tudja ellenőrizni, hogy ez a meghajtó valóban másik lemez-e (virtuális gépben és egyes USB-házakban ez nem látszik), ezért nem ígérhetjük, hogy egy lemezhiba után is vissza tudod állítani a rendszert.", + "storage.belso_tarhely": "Belső tárhely", + "err.web.belso_tarhely_eletciklus": "A Belső tárhely a gép saját lemezén van, ezért nem lehet leválasztani, leszerelni vagy formázni. Az adatait áthelyezheted egy másik meghajtóra.", "err.stacks.field_required_password": "a(z) %q mező kitöltése kötelező — használd a Generálás gombot vagy írj be egy jelszót", "err.stacks.forras_nevter_olvasasa_sikertelen": "forrás névtér olvasása sikertelen: %s", "err.stacks.forras_torlese_sikertelen": "forrás törlése sikertelen (%s): %s", diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index bdfa5d3..10126f4 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -563,6 +563,11 @@ type CrossDriveBackup struct { const ( StorageKindDrive = "drive" StorageKindNetwork = "network" + // StorageKindInternal (v0.318.0, felhom.eu architecture/16 §5) is the one-disk box's internal data place + // („Belső tárhely"): a volume on the SYSTEM disk the agent mounts like a drive. Apps and their data live there; + // the drive lifecycle that would remove or format it (eject, decommission, wipe) does not apply — moving its data + // to another drive does. + StorageKindInternal = "internal" ) // NetworkMountRoot is the in-guest path under which the agent propagates NAS shares (mirrors the agent's @@ -635,6 +640,9 @@ type StoragePath struct { // lifecycle (eject/decommission/migrate/wipe/SMART) must NEVER be applied to a network path. func (p StoragePath) IsNetwork() bool { return p.Kind == StorageKindNetwork } +// IsInternal reports whether this is the internal data place (`16` §5). +func (p StoragePath) IsInternal() bool { return p.Kind == StorageKindInternal } + // NotificationPrefs holds customer notification preferences. type NotificationPrefs struct { Email string `json:"email,omitempty"` diff --git a/controller/internal/stacks/d4_needs_drive_test.go b/controller/internal/stacks/d4_needs_drive_test.go index c0b9734..e733049 100644 --- a/controller/internal/stacks/d4_needs_drive_test.go +++ b/controller/internal/stacks/d4_needs_drive_test.go @@ -2,8 +2,13 @@ package stacks import ( "errors" + "io" + "log" + "path/filepath" "strings" "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // D4 (felhom.eu architecture/16-storage-physical-identity.md §5, R-1068): a data-folder or media app on a @@ -45,3 +50,33 @@ func TestD4_FilledPathKeepsItsOwnRefusal(t *testing.T) { t.Fatalf("want ErrPathMissing for a filled, missing path, got %v", err) } } + +// `16` §5 (v0.318.0): on a one-disk box with the internal place registered, a media app installs with NO extra step +// and NO sentence — its HDD_PATH lands on the internal place. Companion of TestD4_NoDriveRefusalSpeaksInWords (the +// same app, no drive → the refusal). +func TestD4_InternalPlaceIsTheMediaAppsDrive(t *testing.T) { + m := r553Manager(t, "display_name: Jellyfin\nmedia_library:\n folder: films\ndeploy_fields:\n - env_var: HDD_PATH\n label: Médiatár útvonal\n type: path\n required: true\n") + sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0)) + if err != nil { + t.Fatal(err) + } + belso := filepath.Join(t.TempDir(), "belso") + if err := sett.AddStoragePath(settings.StoragePath{Path: belso, IsDefault: true, Schedulable: true, Kind: settings.StorageKindInternal}); err != nil { + t.Fatal(err) + } + m.settings = sett + m.isMountPoint = func(p string) bool { return filepath.Clean(p) == belso } + if got := m.defaultMediaDrive(); got != belso { + t.Fatalf("the default media drive: want the internal place %q, got %q", belso, got) + } + // The folder is deliberately NOT created: the deploy then stops at its path check, so no real `docker compose` + // can ever start on the machine running this test (tests that reach real docker act on DooPlex, R-650). The + // refusal naming the internal place proves HDD_PATH was filled with it — the step the household no longer does. + _, err = m.DeployStack(DeployRequest{StackName: "r553app", Values: map[string]string{}}) + if errors.Is(err, ErrNeedsDrive) { + t.Fatalf("a box with the internal place must not be told it needs a drive: %v", err) + } + if !errors.Is(err, ErrPathMissing) || !strings.Contains(err.Error(), belso) { + t.Fatalf("want the media app's HDD_PATH filled with the internal place %q, got %v", belso, err) + } +} diff --git a/controller/internal/web/agent_disk_handlers.go b/controller/internal/web/agent_disk_handlers.go index 6a1792c..3cc23ca 100644 --- a/controller/internal/web/agent_disk_handlers.go +++ b/controller/internal/web/agent_disk_handlers.go @@ -190,6 +190,12 @@ func dropRegisteredDrives(resp agentapi.CandidatesResult, mountsText string, reg 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 — + // the whole disk itself, never (WholeDiskInUse; handleStorageInit refuses it server-side too). + if free := freePartitions(c.Partitions, inUse); len(free) > 0 { + c.Partitions, c.WholeDiskInUse = free, true + out = append(out, c) + } continue } out = append(out, c) @@ -201,6 +207,34 @@ func dropRegisteredDrives(resp agentapi.CandidatesResult, mountsText string, reg return resp } +// deviceBacksRegistered reports whether formatting `device` would touch a registered drive: the device itself, or a +// whole disk one of whose parts is registered (the same join as dropRegisteredDrives). FAIL-SAFE: an unreadable mount +// table cannot prove the device free → true. +func deviceBacksRegistered(device, mountsText string, registered map[string]bool) bool { + if strings.TrimSpace(mountsText) == "" { + return true + } + inUse := map[string]bool{} + for _, row := range parseMountTable(mountsText) { + if registered[path.Clean(row[1])] && strings.HasPrefix(row[0], "/dev/") { + inUse[row[0]] = true + } + } + 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 { + var out []agentapi.CandidatePartition + for _, p := range parts { + if p.State == agentapi.PartInUse || inUse[p.Device] { + continue + } + out = append(out, p) + } + return out +} + // candidateBacksInUse reports whether a candidate's disk is a source in inUse: the whole disk itself, // its mountable partition, or any partition of it (/dev/sdb1, /dev/nvme0n1p1 under /dev/sdb, /dev/nvme0n1). func candidateBacksInUse(c agentapi.DiskCandidate, inUse map[string]bool) bool { diff --git a/controller/internal/web/attach_sources.go b/controller/internal/web/attach_sources.go index f91ac16..8cc9a6b 100644 --- a/controller/internal/web/attach_sources.go +++ b/controller/internal/web/attach_sources.go @@ -143,6 +143,14 @@ func readMountTable() string { return string(b) } +// mountTable is readMountTable through the mountsFn test seam (nil → the controller's real /proc/mounts). +func (s *Server) mountTable() string { + if s.mountsFn != nil { + return s.mountsFn() + } + return readMountTable() +} + // registeredStoragePaths is the set of paths already in the registry, for the exclusion above. func (s *Server) registeredStoragePaths() map[string]bool { out := map[string]bool{} @@ -159,5 +167,41 @@ func (s *Server) registeredStoragePaths() map[string]bool { // precondition read, so the picker and the sentence pointing at it cannot disagree — the same // single-derivation rule R-252 applied to HasRestoreDestination. func (s *Server) attachableStores() []mountedStore { - return mountedUnregisteredStores(readMountTable(), s.registeredStoragePaths()) + return s.attachableStoresFrom(readMountTable()) +} + +// attachableStoresFrom is attachableStores for a given mount table (the seam its tests use). +func (s *Server) attachableStoresFrom(mounts string) []mountedStore { + sdp := "" + if s.cfg != nil { + sdp = s.cfg.Paths.SystemDataPath + } + return withoutSystemVolume(mountedUnregisteredStores(mounts, s.registeredStoragePaths()), mounts, sdp) +} + +// withoutSystemVolume drops every offered store that lives on the guest's OWN data volume (R-1082, operator ruling +// 2026-10-16 „hide it now"; felhom.eu architecture/16 §11): /mnt/sys_drive is a folder of that volume, which rides in +// every whole-box copy, local and off-site — films put there would fill them. Excluded by DEVICE, like the rootfs +// guard above, so a second bind of the same volume under another /mnt/ is hidden too. A box that already +// REGISTERED sys_drive keeps it: this filters only what is OFFERED. systemDataPath "" falls back to the config +// default /mnt/sys_drive (fail toward hiding). Pinned by r1082_sysdrive_hidden_test.go. +func withoutSystemVolume(stores []mountedStore, mountsText, systemDataPath string) []mountedStore { + if systemDataPath == "" { + systemDataPath = "/mnt/sys_drive" + } + volumePaths := map[string]bool{path.Clean(systemDataPath): true, "/var/lib/felhom": true, "/var/lib/docker": true} + volumeDevices := map[string]bool{} + for _, row := range parseMountTable(mountsText) { + if volumePaths[path.Clean(row[1])] { + volumeDevices[row[0]] = true + } + } + out := stores[:0:0] + for _, m := range stores { + if volumeDevices[m.Device] || volumePaths[path.Clean(m.Path)] { + continue + } + out = append(out, m) + } + return out } diff --git a/controller/internal/web/backup_target_offer.go b/controller/internal/web/backup_target_offer.go index 2db02bd..5ab7fb3 100644 --- a/controller/internal/web/backup_target_offer.go +++ b/controller/internal/web/backup_target_offer.go @@ -37,6 +37,9 @@ type BackupTargetState struct { // was told the backup was on the system disk (false) and offered the drive that just disappeared // (felhom.eu audits/E2D-fresh-vm-2026-07-29.md §5.3). TargetAbsent bool + // OfferUnproven (R-1084) marks an offered drive the agent cannot prove is another disk than the system data: + // the page then makes no disk-failure promise. + OfferUnproven bool // TargetID is the agent's primary tier storage id (e.g. "felhom-backup" / "local"). TargetID string // Label is the customer-facing drive name when the target is a real drive. @@ -95,7 +98,7 @@ func (s *Server) resolveBackupTargetState(ctx context.Context) BackupTargetState // this state must be correct on its own. Belt here, braces there. return st } - st.OfferPath, st.OfferLabel = s.firstOfferableDrive(disks.Disks) + st.OfferPath, st.OfferLabel, st.OfferUnproven = s.firstOfferableDrive(ctx, disks.Disks) return st } @@ -127,20 +130,81 @@ func targetIsConfiguredDrive(targetID string) bool { // firstOfferableDrive picks a registered, connected, non-network drive that could hold the backup. // It is a SUGGESTION for the offer — the customer still has to choose (E-2 §3). Nothing here assigns // anything. -func (s *Server) firstOfferableDrive(disks []agentapi.DiskInfo) (path, label string) { +// +// R-1084 (v0.318.0, felhom.eu architecture/16 §4): the offer promises survival of a DISK failure, so it now asks the +// agent whether the drive is really another disk than the system data. A proven different disk wins; a drive the +// agent cannot prove (a VM, a USB bridge that hides the serial) is still offered, but unproven=true makes the page say +// so in build A's words instead of the promise; a drive on the SAME disk is never offered (a copy there dies with the +// disk); an agent older than 0.157.0 keeps today's offer. Pinned by r1084_offer_identity_test.go. +func (s *Server) firstOfferableDrive(ctx context.Context, disks []agentapi.DiskInfo) (path, label string, unproven bool) { + fallback, fallbackLabel, fallbackUnproven := "", "", false for _, sp := range s.settings.GetStoragePaths() { - if sp.IsNetwork() || sp.Decommissioned || sp.Disconnected { + if sp.IsNetwork() || sp.IsInternal() || sp.Decommissioned || sp.Disconnected { continue } for _, d := range disks { // Only a drive the agent classifies as user-data with its OWN mountpoint can be a target; // the agent refuses anything else anyway, so offering it would be a dead end. - if d.GuestPath == sp.Path && d.MountPath != "" && d.Role == "user-data" { - return sp.Path, s.storageLabelFor(sp.Path) + if d.GuestPath != sp.Path || d.MountPath == "" || d.Role != "user-data" { + continue + } + switch s.offerRelationFor(ctx, sp.Path) { + case offerDifferent: + return sp.Path, s.storageLabelFor(sp.Path), false + case offerSame: + continue + case offerUnknown: + if fallback == "" { + fallback, fallbackLabel, fallbackUnproven = sp.Path, s.storageLabelFor(sp.Path), true + } + default: // offerLegacy: today's behaviour + if fallback == "" { + fallback, fallbackLabel, fallbackUnproven = sp.Path, s.storageLabelFor(sp.Path), false + } } } } - return "", "" + return fallback, fallbackLabel, fallbackUnproven +} + +// offerRelation is the agent's verdict on a candidate backup drive against the system data (R-1084). +type offerRelation int + +const ( + offerLegacy offerRelation = iota // the agent cannot judge (older than 0.157.0): today's offer, today's words + offerDifferent // proven different disks: the disk-failure promise holds + offerUnknown // not proven (a VM, a hiding USB bridge, or the question failed): no promise + offerSame // the same physical disk: never offered +) + +// offerRelationFor asks the agent (POST /disks/same-disk) through the sameDiskFn seam (nil → the real client). +func (s *Server) offerRelationFor(ctx context.Context, drive string) offerRelation { + sdp := "/mnt/sys_drive" + if s.cfg != nil && s.cfg.Paths.SystemDataPath != "" { + sdp = s.cfg.Paths.SystemDataPath + } + if s.sameDiskFn != nil { + return s.sameDiskFn(ctx, drive, sdp) + } + client, err := s.agentClient() + if err != nil { + return offerLegacy + } + if client.Supports(ctx, agentapi.FeatureSameDisk) == agentapi.SupportNo { + return offerLegacy + } + r, err := client.SameDisk(ctx, drive, sdp) + switch { + case err != nil: + s.logger.Printf("[WARN] [backup-target] same-disk question for %s failed: %v — the offer makes no promise", drive, err) + return offerUnknown + case r.Proven && r.Different: + return offerDifferent + case r.Proven: + return offerSame + default: + return offerUnknown + } } // baseName is path.Base without importing path into this file's surface. @@ -171,6 +235,9 @@ func baseName(p string) string { const ( backupTargetDegradedKey = "backup.target.degraded" backupTargetOfferKey = "backup.target.offer" + // backupTargetOfferUnprovenKey (R-1084, v0.318.0): the offer for a drive the agent cannot prove is another disk — + // build A's words, no disk-failure promise. + backupTargetOfferUnprovenKey = "backup.target.offer_unproven" // backupTargetAbsentText (R-114) is the CONFIGURED-BUT-GONE state. It is VERBATIM the hub's // customerMessages["backup_target_absent"] (felhom.eu hub/internal/notify/templates.go:93) so the // banner a customer reads on the page and the email they receive say exactly the same thing — a @@ -245,7 +312,11 @@ func (s *Server) backupTargetView(ctx context.Context, lang string) *BackupTarge } v := &BackupTargetView{Message: s.msgLang(lang, key)} if st.OfferPath != "" { - v.OfferPath, v.OfferLabel, v.OfferText = st.OfferPath, st.OfferLabel, s.msgLang(lang, backupTargetOfferKey) + offerKey := backupTargetOfferKey + if st.OfferUnproven { + offerKey = backupTargetOfferUnprovenKey // R-1084: no disk-failure promise for an unproven disk + } + v.OfferPath, v.OfferLabel, v.OfferText = st.OfferPath, st.OfferLabel, s.msgLang(lang, offerKey) } return v } @@ -264,8 +335,13 @@ func (s *Server) handleBackupTargetState(w http.ResponseWriter, r *http.Request) out["message"] = s.msg(r, degradedMessageFor(st)) if st.OfferPath != "" { out["offer_path"] = st.OfferPath + out["offer_unproven"] = st.OfferUnproven // R-1084 out["offer_label"] = st.OfferLabel - out["offer_message"] = s.msg(r, backupTargetOfferKey) + offerKey := backupTargetOfferKey + if st.OfferUnproven { + offerKey = backupTargetOfferUnprovenKey // R-1084 + } + out["offer_message"] = s.msg(r, offerKey) } } } diff --git a/controller/internal/web/i18n_cases_c_test.go b/controller/internal/web/i18n_cases_c_test.go index b3c8a95..e47f542 100644 --- a/controller/internal/web/i18n_cases_c_test.go +++ b/controller/internal/web/i18n_cases_c_test.go @@ -17,6 +17,10 @@ func i18nStoragePaths() []m { "AppCount": 0, "IsEnrolled": false}, {"Path": "/mnt/hdd3", "Label": "HDD három", "IsDefault": false, "Schedulable": true, "IsMounted": true, "DiskInfo": m{"UsedHuman": "10 GB", "TotalHuman": "1 TB", "UsedPercent": 1.0}, "AppCount": 0, "IsEnrolled": true}, + // v0.318.0 (`16` §5): the internal data place — its one line, and no safe-disconnect or retire button even + // where the card would otherwise carry one (IsUSB). + {"Path": "/mnt/felhom-drives/belso", "Label": "Belső tárhely", "IsDefault": false, "Schedulable": true, "IsMounted": true, + "IsInternal": true, "IsUSB": true, "AppCount": 0, "IsEnrolled": true}, } } diff --git a/controller/internal/web/intermediary.go b/controller/internal/web/intermediary.go index b76e5a2..bf381ef 100644 --- a/controller/internal/web/intermediary.go +++ b/controller/internal/web/intermediary.go @@ -313,6 +313,7 @@ func (s *Server) ReconcileDriveGates() { return } s.reconcileEjectedDrives(ctx, agent, resp.Disks) + s.ensureInternalPlace(ctx, agent, resp.Disks) // v0.318.0 `16` §5: the one-disk box's data place, no press needed for _, a := range planDriveGates(s.settings.GetStoragePaths(), resp.Disks) { switch { case a.Stop: diff --git a/controller/internal/web/internal_place.go b/controller/internal/web/internal_place.go new file mode 100644 index 0000000..3362b60 --- /dev/null +++ b/controller/internal/web/internal_place.go @@ -0,0 +1,108 @@ +package web + +import ( + "context" + "net/http" + "path" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// The internal data place („Belső tárhely", v0.318.0) — felhom.eu architecture/16-storage-physical-identity.md §5. +// +// On a one-disk box the installer (or an operator's signed job) makes a second volume on the system disk, and the +// agent (≥ 0.158.0) mounts it like a drive and reports it in GET /disks with kind „internal". The controller then, +// with no household press: +// 1. asks the agent to bind it into this guest (POST /disks/guest-attach — scoped to this guest's own token), and +// 2. on the next drive-gate tick, once the bind is live, registers it as a drive of kind internal — the DEFAULT +// drive when no other drive is default — so media and data-folder apps install with no extra step and no +// sentence (R-1068; the D4 refusal stays for a box with no drive at all). +// +// It is never offered for the drive lifecycle that would remove or format it (eject, decommission, wipe — +// refuseInternalLifecycle); the agent refuses those too (its device is on the system disk). Moving its data to +// another drive stays allowed. Its physical disk is the system disk, so build A never counts a copy between it and +// sys_drive as a second copy. Pinned by internal_place_test.go. + +// internalPlaceAgent is the one agent call this needs (a seam for tests). +type internalPlaceAgent interface { + GuestAttach(ctx context.Context, where string) error +} + +// ensureInternalPlace runs on every drive-gate tick (ReconcileDriveGates) with the agent's current /disks rows. +func (s *Server) ensureInternalPlace(ctx context.Context, agent internalPlaceAgent, disks []agentapi.DiskInfo) { + if s.settings == nil { + return + } + for _, d := range disks { + if d.Kind != agentapi.PlaceKindInternal || d.MountPath == "" || d.GuestPath == "" { + continue + } + gp := path.Clean(d.GuestPath) + if !d.BoundUnderParent { + // Bind it now; register on a later tick, once the bind is live (the skeleton is made in-guest). + if err := agent.GuestAttach(ctx, d.MountPath); err != nil { + s.logger.Printf("[WARN] [internal-place] guest bind of %s failed: %v", d.MountPath, err) + return + } + s.logger.Printf("[INFO] [internal-place] bound %s into the guest at %s", d.MountPath, gp) + return + } + for _, sp := range s.settings.GetStoragePaths() { + if path.Clean(sp.Path) == gp { + return // registered already (by us, or by the household on an older controller) + } + } + if err := s.registerInternalPlace(gp); err != nil { + s.logger.Printf("[WARN] [internal-place] register %s failed: %v", gp, err) + return + } + return + } +} + +// registerInternalPlace records the internal place as a drive of kind internal — the default drive unless the +// household already has one. +func (s *Server) registerInternalPlace(where string) error { + setDefault := true + for _, sp := range s.settings.GetStoragePaths() { + if sp.IsDefault && !sp.IsNetwork() && !sp.Decommissioned { + setDefault = false + } + } + if err := s.ensureUserdataSkeleton(where); err != nil { + s.logger.Printf("[WARN] [internal-place] userdata skeleton on %s: %v", where, err) + } + sp := settings.StoragePath{ + Path: where, + Label: s.msgLang(s.settings.GetLanguage(), "storage.belso_tarhely"), + IsDefault: setDefault, + Schedulable: true, + AddedAt: time.Now().UTC().Format(time.RFC3339), + Kind: settings.StorageKindInternal, + } + if err := s.settings.AddStoragePath(sp); err != nil { + return err + } + s.logger.Printf("[INFO] [internal-place] registered %s (default=%v) — media and data-folder apps can use it", where, setDefault) + go s.SyncFileBrowserMounts() + s.mediaDriveEvent(where, "added") + return nil +} + +// refuseInternalLifecycle blocks eject, decommission and wipe of the internal place (it is part of the system disk). +// Returns true (and writes the refusal) when the path is the internal place; the caller must then return. +func (s *Server) refuseInternalLifecycle(w http.ResponseWriter, r *http.Request, where string) bool { + if s.settings == nil { + return false + } + p := path.Clean(where) + for _, sp := range s.settings.GetStoragePaths() { + if sp.IsInternal() && path.Clean(sp.Path) == p { + writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "err.web.belso_tarhely_eletciklus"), nil) + return true + } + } + return false +} diff --git a/controller/internal/web/internal_place_test.go b/controller/internal/web/internal_place_test.go new file mode 100644 index 0000000..5e9e5d7 --- /dev/null +++ b/controller/internal/web/internal_place_test.go @@ -0,0 +1,103 @@ +package web + +import ( + "context" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// The internal data place (felhom.eu architecture/16 §5, v0.318.0): bound and registered with no press, the default +// drive when there is no other, never ejected, retired or formatted. + +func internalRow(gp string, bound bool) agentapi.DiskInfo { + return agentapi.DiskInfo{Name: "belso", Kind: agentapi.PlaceKindInternal, MountPath: "/mnt/belso", GuestPath: gp, + BoundUnderParent: bound, Role: "user-data"} +} + +func TestInternalPlace_UnboundIsBoundNotYetRegistered(t *testing.T) { + s := testServer(t) + gp := filepath.Join(t.TempDir(), "belso") + ag := &mockAgent{} + s.ensureInternalPlace(context.Background(), ag, []agentapi.DiskInfo{internalRow(gp, false)}) + if len(ag.guestAttachCalls) != 1 || ag.guestAttachCalls[0] != "/mnt/belso" { + t.Fatalf("want one guest bind of /mnt/belso, got %v", ag.guestAttachCalls) + } + if n := len(s.settings.GetStoragePaths()); n != 0 { + t.Fatalf("an unbound place must not be registered yet, got %d path(s)", n) + } +} + +func TestInternalPlace_BoundIsRegisteredAsTheDefaultDrive(t *testing.T) { + s := testServer(t) + gp := filepath.Join(t.TempDir(), "belso") + ag := &mockAgent{} + s.ensureInternalPlace(context.Background(), ag, []agentapi.DiskInfo{internalRow(gp, true)}) + paths := s.settings.GetStoragePaths() + if len(paths) != 1 { + t.Fatalf("want the internal place registered once, got %+v", paths) + } + sp := paths[0] + if sp.Path != gp || !sp.IsInternal() || !sp.IsDefault || !sp.Schedulable || sp.Label == "" || strings.Contains(sp.Label, ".") { + t.Fatalf("registered as: %+v", sp) + } + if len(ag.guestAttachCalls) != 0 { + t.Fatalf("a bound place needs no new bind, got %v", ag.guestAttachCalls) + } + // Idempotent: the next tick changes nothing. + s.ensureInternalPlace(context.Background(), ag, []agentapi.DiskInfo{internalRow(gp, true)}) + if n := len(s.settings.GetStoragePaths()); n != 1 { + t.Fatalf("a second tick must not register it again, got %d", n) + } +} + +func TestInternalPlace_HouseholdDefaultStaysDefault(t *testing.T) { + s := testServer(t) + usb := filepath.Join(t.TempDir(), "hdd_1") + if err := s.settings.AddStoragePath(settings.StoragePath{Path: usb, IsDefault: true, Schedulable: true}); err != nil { + t.Fatal(err) + } + gp := filepath.Join(t.TempDir(), "belso") + s.ensureInternalPlace(context.Background(), &mockAgent{}, []agentapi.DiskInfo{internalRow(gp, true)}) + for _, sp := range s.settings.GetStoragePaths() { + if sp.Path == gp && sp.IsDefault { + t.Fatalf("the household's own default drive must stay the default") + } + if sp.Path == usb && !sp.IsDefault { + t.Fatalf("the USB default was taken away") + } + } +} + +func TestInternalPlace_OtherKindsAreNotTouched(t *testing.T) { + s := testServer(t) + ag := &mockAgent{} + usb := agentapi.DiskInfo{Name: "hdd_1", Kind: "disk", MountPath: "/mnt/hdd_1", GuestPath: filepath.Join(t.TempDir(), "hdd_1"), BoundUnderParent: false} + s.ensureInternalPlace(context.Background(), ag, []agentapi.DiskInfo{usb}) + if len(ag.guestAttachCalls) != 0 || len(s.settings.GetStoragePaths()) != 0 { + t.Fatalf("a household drive is enrolled only by the household: attach=%v paths=%v", ag.guestAttachCalls, s.settings.GetStoragePaths()) + } +} + +// Eject, decommission and wipe of the internal place are refused in words. Positive control: a normal drive path is +// not refused by this gate. +func TestInternalPlace_LifecycleRefused(t *testing.T) { + s := testServer(t) + gp := filepath.Join(t.TempDir(), "belso") + s.ensureInternalPlace(context.Background(), &mockAgent{}, []agentapi.DiskInfo{internalRow(gp, true)}) + r := httptest.NewRequest("POST", "/api/storage/eject", nil) + w := httptest.NewRecorder() + if !s.refuseInternalLifecycle(w, r, gp+"/") { + t.Fatalf("the internal place must be refused") + } + if w.Code != 400 || !strings.Contains(w.Body.String(), "lemez") { + t.Fatalf("refusal: %d %s", w.Code, w.Body.String()) + } + if s.refuseInternalLifecycle(httptest.NewRecorder(), r, "/mnt/felhom-drives/hdd_1") { + t.Fatalf("positive control: a household drive is not refused by this gate") + } +} diff --git a/controller/internal/web/partitions_test.go b/controller/internal/web/partitions_test.go new file mode 100644 index 0000000..cda41dd --- /dev/null +++ b/controller/internal/web/partitions_test.go @@ -0,0 +1,99 @@ +package web + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// Partitions as places (felhom.eu architecture/16 §6, v0.318.0): the free part of a disk whose other part is a +// registered drive stays on offer; the whole disk never does — in the list AND on the server. + +const partMounts = `/dev/mapper/pve-root /mnt/felhom-drives ext4 rw,relatime 0 0 +/dev/sdb1 /mnt/felhom-drives/hdd_1 ext4 rw,relatime 0 0 +/dev/mapper/pve-vm--9201--disk--1 /mnt/sys_drive ext4 rw,relatime 0 0 +` + +func diskWithParts() agentapi.DiskCandidate { + return agentapi.DiskCandidate{Device: "/dev/sdb", MountSource: "/dev/sdb1", FSType: "ext4", DataBearing: true, + Partitions: []agentapi.CandidatePartition{ + {Device: "/dev/sdb1", Number: 1, FSType: "ext4", State: agentapi.PartInUse, MountedAt: "/mnt/felhom-drives/hdd_1"}, + {Device: "/dev/sdb2", Number: 2, State: agentapi.PartEmpty}, + {Device: "/dev/sdb3", Number: 3, FSType: "ext4", State: agentapi.PartFilesystem, Mountable: true, DurableID: "uuid:3333"}, + }} +} + +func TestPartitions_FreePartsOfAnInUseDiskStayOffered(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) + } + 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(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) + } +} + +// R-542 unchanged: a disk with nothing free is still dropped. +func TestPartitions_InUseDiskWithNoFreePartIsDropped(t *testing.T) { + d := diskWithParts() + d.Partitions = d.Partitions[:1] + got := dropRegisteredDrives(agentapi.CandidatesResult{Initialize: []agentapi.DiskCandidate{d}}, partMounts, map[string]bool{"/mnt/felhom-drives/hdd_1": true}) + if len(got.Initialize) != 0 { + t.Fatalf("a disk with no free part must not be offered, got %+v", got.Initialize) + } +} + +func TestPartitions_ServerRefusesTheWholeDiskButNotTheFreePart(t *testing.T) { + reg := map[string]bool{"/mnt/felhom-drives/hdd_1": true} + for dev, want := range map[string]bool{"/dev/sdb": true, "/dev/sdb1": true, "/dev/sdb2": false, "/dev/sdc": false} { + if got := deviceBacksRegistered(dev, partMounts, reg); got != want { + t.Errorf("%s: backs a registered drive = %v, want %v", dev, got, want) + } + } + if !deviceBacksRegistered("/dev/sdc", "", reg) { + t.Errorf("an unreadable mount table must refuse (fail-safe)") + } +} + +func TestPartitions_AFormattedPartsUUIDIsFound(t *testing.T) { + ag := &mockAgent{candidates: agentapi.CandidatesResult{Initialize: []agentapi.DiskCandidate{diskWithParts()}}} + if got := resolveEnrollUUID(context.Background(), ag, "/dev/sdb3"); got != "3333" { + t.Fatalf("the part's new filesystem must be found for the mount, got %q", got) + } +} + +// The handler asks before it starts anything: the whole disk under a registered part → 409, never a format job. +// Positive control: the free part passes the guard and reaches the agent step (503 here: no agent configured). +func TestPartitions_InitHandlerRefusesTheWholeDisk(t *testing.T) { + s := testServer(t) + s.mountsFn = func() string { return partMounts } + if err := s.settings.AddStoragePath(settingsPath("/mnt/felhom-drives/hdd_1")); err != nil { + t.Fatal(err) + } + post := func(dev string) int { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/api/storage/init", strings.NewReader(`{"device":"`+dev+`","fstype":"ext4","mount_name":"films"}`)) + s.handleStorageInit(w, r) + return w.Code + } + if c := post("/dev/sdb"); c != http.StatusConflict { + t.Fatalf("the whole disk under a registered part: want 409, got %d", c) + } + if c := post("/dev/sdb2"); c == http.StatusConflict { + t.Fatalf("positive control: the free part must pass the guard, got %d", c) + } +} + +func settingsPath(p string) settings.StoragePath { + return settings.StoragePath{Path: p, Schedulable: true} +} diff --git a/controller/internal/web/r1082_sysdrive_hidden_test.go b/controller/internal/web/r1082_sysdrive_hidden_test.go new file mode 100644 index 0000000..3b61f21 --- /dev/null +++ b/controller/internal/web/r1082_sysdrive_hidden_test.go @@ -0,0 +1,36 @@ +package web + +import "testing" + +// R-1082 (operator ruling 2026-10-16 „hide it now"; felhom.eu architecture/16 §11): the drives page never offers the +// guest's own data volume to attach — /mnt/sys_drive rides in every whole-box copy. The raw filter +// (mountedUnregisteredStores) still sees it; what the page offers (attachableStores) does not. + +const r1082Mounts = demoHPMounts + + "/dev/mapper/pve-vm--9201--disk--1 /var/lib/felhom ext4 rw,relatime 0 0\n" + + "/dev/mapper/pve-vm--9201--disk--1 /mnt/films ext4 rw,relatime 0 0\n" + // a second bind of the same volume + "/dev/sdd1 /mnt/usb_films ext4 rw,relatime 0 0\n" // a real USB drive with a filesystem + +func TestR1082_SysDriveAndItsAliasesAreNotOffered(t *testing.T) { + got := withoutSystemVolume(mountedUnregisteredStores(r1082Mounts, map[string]bool{}), r1082Mounts, "/mnt/sys_drive") + if len(got) != 1 || got[0].Path != "/mnt/usb_films" { + t.Fatalf("want only the USB drive offered (positive control), got %+v", got) + } +} + +func TestR1082_EmptyConfigFallsBackToHiding(t *testing.T) { + got := withoutSystemVolume(mountedUnregisteredStores(demoHPMounts, map[string]bool{}), demoHPMounts, "") + if len(got) != 0 { + t.Fatalf("with no configured system data path, sys_drive must still be hidden, got %+v", got) + } +} + +// The page's own list (the derivation the candidates endpoint and the restore page share) hides it. +func TestR1082_AttachableStoresHidesSysDrive(t *testing.T) { + s := testServer(t) + s.cfg.Paths.SystemDataPath = "/mnt/sys_drive" + got := s.attachableStoresFrom(r1082Mounts) + if len(got) != 1 || got[0].Path != "/mnt/usb_films" { + t.Fatalf("the page must offer only the USB drive, got %+v", got) + } +} diff --git a/controller/internal/web/r1084_offer_identity_test.go b/controller/internal/web/r1084_offer_identity_test.go new file mode 100644 index 0000000..85757c7 --- /dev/null +++ b/controller/internal/web/r1084_offer_identity_test.go @@ -0,0 +1,90 @@ +package web + +import ( + "context" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// R-1084 (v0.318.0, felhom.eu architecture/16 §4): the whole-box backup offer promises survival of a DISK failure only +// for a drive the agent proves is another disk than the system data. Seen on Tester 1 (a VM): the promise stood next to +// build A's „cannot check" notice. + +func offerHarness(t *testing.T, rel map[string]offerRelation, paths ...string) *Server { + t.Helper() + var disks []agentapi.DiskInfo + for _, p := range paths { + disks = append(disks, agentapi.DiskInfo{Name: p, GuestPath: p, MountPath: "/mnt/" + strings.TrimPrefix(p, "/mnt/felhom-drives/"), Role: "user-data"}) + } + s := absentHarness(t, "local", disks, "") + for _, p := range paths { + if err := s.settings.AddStoragePath(settings.StoragePath{Path: p, Label: "Meghajtó " + p}); err != nil { + t.Fatal(err) + } + } + s.sameDiskFn = func(_ context.Context, drive, sysData string) offerRelation { + if sysData != "/mnt/sys_drive" { + t.Errorf("the offer must compare against the system data path, got %q", sysData) + } + return rel[drive] + } + return s +} + +const drvA, drvB = "/mnt/felhom-drives/hdd_1", "/mnt/felhom-drives/hdd_2" + +func TestR1084_ProvenDifferentDiskKeepsThePromise(t *testing.T) { + s := offerHarness(t, map[string]offerRelation{drvA: offerDifferent}, drvA) + v := s.backupTargetView(context.Background(), "hu") + if v == nil || v.OfferPath != drvA || v.OfferText != huText(t, backupTargetOfferKey) { + t.Fatalf("a proven different disk: want today's offer, got %+v", v) + } +} + +func TestR1084_UnprovenDiskIsOfferedWithoutThePromise(t *testing.T) { + s := offerHarness(t, map[string]offerRelation{drvA: offerUnknown}, drvA) + v := s.backupTargetView(context.Background(), "hu") + if v == nil || v.OfferPath != drvA { + t.Fatalf("an unproven disk is still offered, got %+v", v) + } + if strings.Contains(v.OfferText, "így egy lemezhiba után is vissza tudod") || !strings.Contains(v.OfferText, "nem tudja ellenőrizni") { + t.Fatalf("an unproven disk must not carry the disk-failure promise; got %q", v.OfferText) + } +} + +func TestR1084_SameDiskIsNeverOffered(t *testing.T) { + s := offerHarness(t, map[string]offerRelation{drvA: offerSame}, drvA) + if v := s.backupTargetView(context.Background(), "hu"); v == nil || v.OfferPath != "" { + t.Fatalf("a drive on the system disk must not be offered as the backup place, got %+v", v) + } +} + +func TestR1084_AProvenDiskWinsOverAnUnprovenOne(t *testing.T) { + s := offerHarness(t, map[string]offerRelation{drvA: offerUnknown, drvB: offerDifferent}, drvA, drvB) + if v := s.backupTargetView(context.Background(), "hu"); v == nil || v.OfferPath != drvB || v.OfferText != huText(t, backupTargetOfferKey) { + t.Fatalf("the proven different disk must be offered first, got %+v", v) + } +} + +// An agent older than 0.157.0 cannot judge: today's offer stays (build A's rule for an older agent). +func TestR1084_OlderAgentKeepsTodaysOffer(t *testing.T) { + s := offerHarness(t, map[string]offerRelation{drvA: offerLegacy}, drvA) + if v := s.backupTargetView(context.Background(), "hu"); v == nil || v.OfferPath != drvA || v.OfferText != huText(t, backupTargetOfferKey) { + t.Fatalf("an older agent: want today's offer, got %+v", v) + } +} + +// The internal place is never the whole-box backup place (it is the system disk). +func TestR1084_InternalPlaceIsNeverOffered(t *testing.T) { + s := absentHarness(t, "local", []agentapi.DiskInfo{{Name: "belso", GuestPath: "/mnt/felhom-drives/belso", MountPath: "/mnt/belso", Role: "user-data", Kind: agentapi.PlaceKindInternal}}, "") + if err := s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/felhom-drives/belso", Kind: settings.StorageKindInternal}); err != nil { + t.Fatal(err) + } + s.sameDiskFn = func(context.Context, string, string) offerRelation { return offerLegacy } // even an old judge + if v := s.backupTargetView(context.Background(), "hu"); v == nil || v.OfferPath != "" { + t.Fatalf("the internal place must not be offered, got %+v", v) + } +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 8a35b82..f408059 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -251,6 +251,11 @@ type Server struct { // pure copy helper. Without it the resolver's own branching had no test at all, which is how the // configured-but-absent case reached production saying the wrong thing. tiersFn func(context.Context) (agentapi.TiersResponse, error) + // sameDiskFn (R-1084, v0.318.0) is the test seam for the backup-target offer's same-disk question + // (nil → the real agent client, POST /disks/same-disk). + sameDiskFn func(ctx context.Context, drive, systemData string) offerRelation + // mountsFn (v0.318.0) is the test seam for the guest's mount table (nil → /proc/mounts). + mountsFn func() string // App-email SMTP shim lifecycle (optional — nil when no hub is configured or the kill-switch is // off). The global app-email settings toggle calls Apply() so the shim starts/stops at runtime. diff --git a/controller/internal/web/storage_handlers.go b/controller/internal/web/storage_handlers.go index ad698fc..db7d213 100644 --- a/controller/internal/web/storage_handlers.go +++ b/controller/internal/web/storage_handlers.go @@ -86,6 +86,14 @@ func resolveEnrollUUID(ctx context.Context, agent diskAgent, device string) stri return rest } } + // `16` §6 (agent ≥ 0.158.0): a formatted PART reports its new filesystem under the disk's partitions. + for _, p := range c.Partitions { + if p.Device == device { + if rest, ok := strings.CutPrefix(p.DurableID, "uuid:"); ok { + return rest + } + } + } } return "" } @@ -503,6 +511,9 @@ func (s *Server) handleStorageDecommission(w http.ResponseWriter, r *http.Reques if s.refuseNetworkLifecycle(w, req.Where) { return } + if s.refuseInternalLifecycle(w, r, req.Where) { // v0.318.0 `16` §5 + return + } switch req.Mode { case "migrate": @@ -651,6 +662,14 @@ func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) { writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.device_required"), nil) return } + // `16` §6 (v0.318.0): never format a device that backs a REGISTERED drive — the whole disk under a registered part + // included. The list (dropRegisteredDrives) already hides it; this is the server-side half, so a stale page or a + // direct call cannot reach it. A FREE part next to the registered one is allowed (the agent and its root wrapper + // re-check, as root). + if deviceBacksRegistered(req.Device, s.mountTable(), s.registeredStoragePaths()) { + writeDiskJSON(w, http.StatusConflict, false, s.msg(r, "err.web.meghajto_hasznalatban_formazas"), nil) + return + } agent, err := s.agentClient() if err != nil { writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil) @@ -764,6 +783,9 @@ func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) { if s.refuseNetworkLifecycle(w, req.Where) { return } + if s.refuseInternalLifecycle(w, r, req.Where) { // v0.318.0 `16` §5 + return + } // Server-side type-to-confirm: the typed name must match the mount's basename exactly. if strings.TrimSpace(req.MountName) != path.Base(req.Where) { writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.name_mismatch"), nil) @@ -982,6 +1004,9 @@ func (s *Server) handleStorageEject(w http.ResponseWriter, r *http.Request) { if s.refuseNetworkLifecycle(w, req.Where) { return } + if s.refuseInternalLifecycle(w, r, req.Where) { // v0.318.0 `16` §5 + return + } agent, err := s.agentClient() if err != nil { writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil) diff --git a/controller/internal/web/templates/storage.html b/controller/internal/web/templates/storage.html index 5b92954..f24e54f 100644 --- a/controller/internal/web/templates/storage.html +++ b/controller/internal/web/templates/storage.html @@ -82,6 +82,9 @@ {{end}} + {{if .IsInternal}} +
{{T "storage.belso_tarhely_magyarazat"}}
+ {{end}} {{if .FSInfo}}
{{.FSInfo.FSType}} · {{.FSInfo.Device}}{{if .FSInfo.Model}} · {{.FSInfo.Model}}{{end}} @@ -138,7 +141,7 @@ {{end}} - {{if .IsUSB}} + {{if and .IsUSB (not .IsInternal)}} {{end}} {{/* List-removal is the undo of a MANUAL add only. Wizard-enrolled drives @@ -163,7 +166,9 @@ {{end}} + {{if not .IsInternal}} + {{end}}
{{end}} @@ -303,6 +308,8 @@ window.__registeredPaths=[{{range .StoragePaths}}{{if .Path}}"{{.Path}}",{{end}} function actions(d, registered, hasSafeDisconnect){ // Destructive controls ONLY for user-data drives that are mounted under /mnt. System/backup get none. if(d.role!=='user-data' || !d.mount_path || d.mount_path.indexOf('/mnt/')!==0) return ''; + // The internal data place (`16` §5) is part of the system disk: no eject, no format (the agent refuses both too). + if(d.kind==='internal') return ''; // A user-data drive that ALSO backs the whole-system backup (agent `backup_target`): the agent refuses its eject // (403) and a data-bearing format needs a signed job, so neither button is offered (R-298 design, 2026-10-08). if(d.backup_target){ diff --git a/controller/internal/web/templates/storage_attach.html b/controller/internal/web/templates/storage_attach.html index 67c18b2..02a5188 100644 --- a/controller/internal/web/templates/storage_attach.html +++ b/controller/internal/web/templates/storage_attach.html @@ -71,6 +71,20 @@ async function loadDisks(){ var cands = (j.data&&j.data.attach)||[]; if(cands.length===0){ document.getElementById('disk-list').innerHTML='

{{T "storage_attach.nincs_csatolhato_meghajto_a_csatolashoz"}}

'; return; } var html='
'; + // `16` §6 (agent ≥ 0.158.0): a disk that lists its parts offers each usable part (an ext4/xfs filesystem not yet + // a drive) as its own card, instead of the disk's first filesystem. + var flat=[]; + cands.forEach(function(d){ + var usable=(d.partitions||[]).filter(function(p){ return p.state==='filesystem' && p.mountable; }); + if(!d.already_mounted && d.partitions && d.partitions.length){ + usable.forEach(function(p){ flat.push({device:p.device, mount_source:p.device, fstype:p.fstype, size_bytes:p.size_bytes, + model:(d.model?d.model:d.device)+' — '+'{{T "storage_wizard.resz_n"}}'.replace('{n}', p.number||'?')}); }); + return; + } + flat.push(d); + }); + cands=flat; + if(cands.length===0){ document.getElementById('disk-list').innerHTML='

{{T "storage_attach.nincs_csatolhato_meghajto_a_csatolashoz"}}

'; return; } cands.forEach(function(d,i){ var title = d.model ? esc(d.model) : esc(d.device); var parts = [esc(d.device)]; diff --git a/controller/internal/web/templates/storage_init.html b/controller/internal/web/templates/storage_init.html index f06b48c..34d1bf1 100644 --- a/controller/internal/web/templates/storage_init.html +++ b/controller/internal/web/templates/storage_init.html @@ -82,16 +82,26 @@ async function loadDisks(){ var cands = (j.data&&j.data.initialize)||[]; if(cands.length===0){ document.getElementById('disk-list').innerHTML='

{{T "storage_init.nincs_elerheto_meghajto_az_inicializalas"}}

'; return; } var html='
'; - cands.forEach(function(d,i){ + var n=0; + function card(dev, title, sub, db){ + var i=n++; + return ''; + } + cands.forEach(function(d){ var title = d.model ? esc(d.model) : esc(d.device); var parts = [esc(d.device)]; if(d.size_bytes) parts.push(fmtSize(d.size_bytes)); if(d.fstype) parts.push('{{T "storage_init.jelenlegi"}} '+esc(d.fstype)); - var sub = parts.join(' · '); - html+=''; + // `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; + 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'); + }); }); html+='
'; document.getElementById('disk-list').innerHTML=html; diff --git a/controller/internal/web/testdata/i18n_parity/storage_attach.html b/controller/internal/web/testdata/i18n_parity/storage_attach.html index 7d3c565..2a2a090 100644 --- a/controller/internal/web/testdata/i18n_parity/storage_attach.html +++ b/controller/internal/web/testdata/i18n_parity/storage_attach.html @@ -243,6 +243,20 @@ async function loadDisks(){ var cands = (j.data&&j.data.attach)||[]; if(cands.length===0){ document.getElementById('disk-list').innerHTML='

Nincs csatolható meghajtó — a csatoláshoz olyan (még nem használt) meghajtó kell, amelyen már van fájlrendszer. Új, üres meghajtóhoz használd az „Inicializálás” lehetőséget.

'; return; } var html='
'; + + + var flat=[]; + cands.forEach(function(d){ + var usable=(d.partitions||[]).filter(function(p){ return p.state==='filesystem' && p.mountable; }); + if(!d.already_mounted && d.partitions && d.partitions.length){ + usable.forEach(function(p){ flat.push({device:p.device, mount_source:p.device, fstype:p.fstype, size_bytes:p.size_bytes, + model:(d.model?d.model:d.device)+' — '+'{n}. rész'.replace('{n}', p.number||'?')}); }); + return; + } + flat.push(d); + }); + cands=flat; + if(cands.length===0){ document.getElementById('disk-list').innerHTML='

Nincs csatolható meghajtó — a csatoláshoz olyan (még nem használt) meghajtó kell, amelyen már van fájlrendszer. Új, üres meghajtóhoz használd az „Inicializálás” lehetőséget.

'; return; } cands.forEach(function(d,i){ var title = d.model ? esc(d.model) : esc(d.device); var parts = [esc(d.device)]; diff --git a/controller/internal/web/testdata/i18n_parity/storage_empty.html b/controller/internal/web/testdata/i18n_parity/storage_empty.html index 653a787..f48cdf9 100644 --- a/controller/internal/web/testdata/i18n_parity/storage_empty.html +++ b/controller/internal/web/testdata/i18n_parity/storage_empty.html @@ -388,6 +388,8 @@ window.__registeredPaths=[]; if(d.role!=='user-data' || !d.mount_path || d.mount_path.indexOf('/mnt/')!==0) return ''; + if(d.kind==='internal') return ''; + if(d.backup_target){ return registered[regKey(d)] ? '' : '
'; diff --git a/controller/internal/web/testdata/i18n_parity/storage_full.html b/controller/internal/web/testdata/i18n_parity/storage_full.html index 85d64ac..f44c2e5 100644 --- a/controller/internal/web/testdata/i18n_parity/storage_full.html +++ b/controller/internal/web/testdata/i18n_parity/storage_full.html @@ -353,6 +353,7 @@
+
ext4 · /dev/sdb1 · WD Red
@@ -403,12 +404,14 @@ + +
@@ -446,6 +449,7 @@ +
ext4 · /dev/sdc1
@@ -485,7 +489,9 @@ + + @@ -524,6 +530,7 @@ +
Nincs alkalmazás ezen a tárolón @@ -551,7 +558,67 @@ + + +
+ + + +
+
+
+
+ Belső tárhely + +
+ /mnt/felhom-drives/belso +
+
+ + + Aktív + USB + + +
+
+ +
+ + +
A gép saját lemezén van, de nem része a teljes rendszermentésnek. Egy lemezhiba ellen egy másik lemez vagy a távoli mentés véd.
+ + + +
+ + Nincs alkalmazás ezen a tárolón + +
+
+
+
+ +
+ + + +
+ + +
+ + + + +
+ + + + + +
@@ -629,7 +696,7 @@