v0.318.0: the internal data place, partitions, an honest whole-box backup offer (R-1084), sys_drive never offered (R-1082)
gates / gates (push) Successful in 46s

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
This commit is contained in:
2026-10-16 03:57:49 +02:00
parent 123d2d553f
commit a43d0423d5
28 changed files with 913 additions and 26 deletions
+24
View File
@@ -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
+3
View File
@@ -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 `<HDD_PATH>/<subdir>` mapping; NEVER return per-bind userdata subpaths). Containment-deduped. Backup-side `stackAdapter` deliberately does NOT use it |
+20
View File
@@ -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)
+31 -1
View File
@@ -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:<fs-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 {
+6
View File
@@ -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",
+6
View File
@@ -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",
+8
View File
@@ -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"`
@@ -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)
}
}
@@ -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 {
+45 -1
View File
@@ -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/<name> 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
}
+84 -8
View File
@@ -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)
}
}
}
@@ -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},
}
}
+1
View File
@@ -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:
+108
View File
@@ -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
}
@@ -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")
}
}
@@ -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}
}
@@ -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)
}
}
@@ -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)
}
}
+5
View File
@@ -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.
@@ -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)
@@ -82,6 +82,9 @@
</div>
</div>
{{end}}
{{if .IsInternal}}
<div class="form-hint">{{T "storage.belso_tarhely_magyarazat"}}</div>
{{end}}
{{if .FSInfo}}
<div class="storage-path-fsinfo mono form-hint">
{{.FSInfo.FSType}} · {{.FSInfo.Device}}{{if .FSInfo.Model}} · {{.FSInfo.Model}}{{end}}
@@ -138,7 +141,7 @@
<button type="submit" class="btn btn-xs btn-outline" title="{{T "storage.a_meghajto_ujra_valaszthato_az"}}">{{T "storage.uj_telepitesek_engedelyezese"}}</button>
</form>
{{end}}
{{if .IsUSB}}
{{if and .IsUSB (not .IsInternal)}}
<button class="btn btn-xs btn-danger-outline" onclick="storageDisconnect('{{.Path}}', '{{.Label}}', {{.AppCount}})" title="{{T "storage.az_itt_tarolt_alkalmazasok_leallnak"}}">{{T "storage.biztonsagos_levalasztas"}}</button>
{{end}}
{{/* List-removal is the undo of a MANUAL add only. Wizard-enrolled drives
@@ -163,7 +166,9 @@
<button class="btn btn-xs btn-outline" onclick="storageMigrateAll('{{.Path}}','{{.Label}}')">{{T "storage.osszes_adat_athelyezese"}}</button>
</span>
{{end}}
{{if not .IsInternal}}
<button class="btn btn-xs btn-danger-outline" onclick="storageDecommission('{{.Path}}','{{.Label}}',{{.AppCount}})" title="{{T "storage.a_meghajto_vegleges_kivonasa_a"}}">{{T "storage.vegleges_leszereles"}}</button>
{{end}}
</div>
{{end}}
</div>
@@ -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){
@@ -71,6 +71,20 @@ async function loadDisks(){
var cands = (j.data&&j.data.attach)||[];
if(cands.length===0){ document.getElementById('disk-list').innerHTML='<p class="form-hint">{{T "storage_attach.nincs_csatolhato_meghajto_a_csatolashoz"}}</p>'; return; }
var html='<div class="drive-list">';
// `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='<p class="form-hint">{{T "storage_attach.nincs_csatolhato_meghajto_a_csatolashoz"}}</p>'; return; }
cands.forEach(function(d,i){
var title = d.model ? esc(d.model) : esc(d.device);
var parts = [esc(d.device)];
@@ -82,16 +82,26 @@ async function loadDisks(){
var cands = (j.data&&j.data.initialize)||[];
if(cands.length===0){ document.getElementById('disk-list').innerHTML='<p class="form-hint">{{T "storage_init.nincs_elerheto_meghajto_az_inicializalas"}}</p>'; return; }
var html='<div class="drive-list">';
cands.forEach(function(d,i){
var n=0;
function card(dev, title, sub, db){
var i=n++;
return '<label class="drive-card role-user-data is-selectable" id="dc-'+i+'">'
+'<div class="drive-card-top"><div class="drive-select"><input type="radio" name="disk" value="'+esc(dev)+'" data-db="'+(db?'1':'0')+'" data-i="'+i+'" onchange="pickDisk(this)">'
+'<div class="drive-id"><span class="drive-name">'+title+'</span><span class="drive-sub">'+sub+'</span></div></div>'
+'<div class="drive-badges">'+dataBadge({data_bearing:db})+'</div></div></label>';
}
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+='<label class="drive-card role-user-data is-selectable" id="dc-'+i+'">'
+'<div class="drive-card-top"><div class="drive-select"><input type="radio" name="disk" value="'+esc(d.device)+'" data-db="'+(d.data_bearing?'1':'0')+'" data-i="'+i+'" onchange="pickDisk(this)">'
+'<div class="drive-id"><span class="drive-name">'+title+'</span><span class="drive-sub">'+sub+'</span></div></div>'
+'<div class="drive-badges">'+dataBadge(d)+'</div></div></label>';
// `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+='</div>';
document.getElementById('disk-list').innerHTML=html;
@@ -243,6 +243,20 @@ async function loadDisks(){
var cands = (j.data&&j.data.attach)||[];
if(cands.length===0){ document.getElementById('disk-list').innerHTML='<p class="form-hint">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.</p>'; return; }
var html='<div class="drive-list">';
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='<p class="form-hint">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.</p>'; return; }
cands.forEach(function(d,i){
var title = d.model ? esc(d.model) : esc(d.device);
var parts = [esc(d.device)];
@@ -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)] ? '' : '<div class="drive-actions"><button class="btn btn-xs btn-primary" onclick="registerDrive(\''+esc(d.mount_path)+'\')">Regisztrálás</button></div>';
@@ -353,6 +353,7 @@
</div>
<div class="storage-path-fsinfo mono form-hint">
ext4 · /dev/sdb1 · WD Red
</div>
@@ -403,12 +404,14 @@
<span class="migrate-inline" style="display:inline-flex;gap:.35rem;align-items:center">
<select id="migrate-target-/mnt/hdd1" class="btn btn-xs btn-outline">
<option value="">Áthelyezés ide…</option>
<option value="/mnt/hdd3">HDD három (/mnt/hdd3)</option>
<option value="/mnt/hdd3">HDD három (/mnt/hdd3)</option><option value="/mnt/felhom-drives/belso">Belső tárhely (/mnt/felhom-drives/belso)</option>
</select>
<button class="btn btn-xs btn-outline" onclick="storageMigrateAll('\/mnt\/hdd1','HDD egy')">Összes adat áthelyezése</button>
</span>
<button class="btn btn-xs btn-danger-outline" onclick="storageDecommission('\/mnt\/hdd1','HDD egy', 1 )" title="A meghajtó végleges kivonása a rendszerből. Ha céltárolót választ, előbb minden adat átkerül oda; áthelyezés nélkül az adatok a meghajtón maradnak.">Végleges leszerelés</button>
</div>
</div>
@@ -446,6 +449,7 @@
</div>
<div class="storage-path-fsinfo mono form-hint">
ext4 · /dev/sdc1
</div>
@@ -485,7 +489,9 @@
</form>
<button class="btn btn-xs btn-danger-outline" onclick="storageDecommission('\/mnt\/hdd2','HDD kettő', 0 )" title="A meghajtó végleges kivonása a rendszerből. Ha céltárolót választ, előbb minden adat átkerül oda; áthelyezés nélkül az adatok a meghajtón maradnak.">Végleges leszerelés</button>
</div>
</div>
@@ -524,6 +530,7 @@
<div class="storage-path-meta">
<span class="form-hint">Nincs alkalmazás ezen a tárolón</span>
@@ -551,7 +558,67 @@
<button class="btn btn-xs btn-danger-outline" onclick="storageDecommission('\/mnt\/hdd3','HDD három', 0 )" title="A meghajtó végleges kivonása a rendszerből. Ha céltárolót választ, előbb minden adat átkerül oda; áthelyezés nélkül az adatok a meghajtón maradnak.">Végleges leszerelés</button>
</div>
</div>
<div class="storage-path-item">
<div class="storage-path-header">
<div class="storage-path-info">
<div class="storage-path-label-wrap" id="label-wrap-/mnt/felhom-drives/belso">
<span class="storage-path-label" id="label-display-/mnt/felhom-drives/belso">Belső tárhely</span>
<button class="btn btn-xs btn-ghost" onclick="editStorageLabel('\/mnt\/felhom-drives\/belso', 'Belső tárhely')" title="Átnevezés"><svg class="ico ico-sm"><use href="#i-pencil"/></svg></button>
</div>
<span class="storage-path-path mono">/mnt/felhom-drives/belso</span>
</div>
<div class="storage-path-badges">
<span class="tag tag-run"><span class="dot"></span>Aktív</span>
<span class="badge" style="background:rgba(255,165,0,0.15);color:var(--warn)">USB</span>
</div>
</div>
<div class="storage-path-details">
<div class="form-hint">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.</div>
<div class="storage-path-meta">
<span class="form-hint">Nincs alkalmazás ezen a tárolón</span>
</div>
<div class="drive-agent-extra" id="agent-extra-/mnt/felhom-drives/belso"></div>
</div>
<div class="storage-path-actions">
<form method="POST" action="/settings/storage/default" style="display:inline">
<input type="hidden" name="storage_path" value="/mnt/felhom-drives/belso">
<button type="submit" class="btn btn-xs btn-outline">Legyen alapértelmezett</button>
</form>
<form method="POST" action="/settings/storage/schedulable" style="display:inline">
<input type="hidden" name="storage_path" value="/mnt/felhom-drives/belso">
<input type="hidden" name="schedulable" value="false">
<button type="submit" class="btn btn-xs btn-outline" title="A meghajtó kimarad az új alkalmazás-telepítésekből. A rajta lévő alkalmazások változatlanul futnak.">Új telepítések letiltása</button>
</form>
</div>
</div>
@@ -629,7 +696,7 @@
<div id="confirm-root"></div>
<div id="dialog-root"></div>
<script>
window.__registeredPaths=["\/mnt\/usb1","\/mnt\/old","\/mnt\/hdd1","\/mnt\/hdd2","\/mnt\/hdd3",];
window.__registeredPaths=["\/mnt\/usb1","\/mnt\/old","\/mnt\/hdd1","\/mnt\/hdd2","\/mnt\/hdd3","\/mnt\/felhom-drives\/belso",];
(function(){
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];}); }
function hum(b){ if(!b||b<=0) return ''; var u=['B','KB','MB','GB','TB'],i=0,v=b; while(v>=1024&&i<u.length-1){v/=1024;i++;} return (v>=10||i===0?Math.round(v):fhNum(v, 1))+' '+u[i]; }
@@ -688,6 +755,8 @@ window.__registeredPaths=["\/mnt\/usb1","\/mnt\/old","\/mnt\/hdd1","\/mnt\/hdd2"
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)] ? '' : '<div class="drive-actions"><button class="btn btn-xs btn-primary" onclick="registerDrive(\''+esc(d.mount_path)+'\')">Regisztrálás</button></div>';
@@ -256,16 +256,26 @@ async function loadDisks(){
var cands = (j.data&&j.data.initialize)||[];
if(cands.length===0){ document.getElementById('disk-list').innerHTML='<p class="form-hint">Nincs elérhető meghajtó az inicializáláshoz. Csatlakoztass egy új adathordozót — a rendszer-, a biztonsági mentés- és a már használatban lévő meghajtók itt nem jelennek meg.</p>'; return; }
var html='<div class="drive-list">';
cands.forEach(function(d,i){
var n=0;
function card(dev, title, sub, db){
var i=n++;
return '<label class="drive-card role-user-data is-selectable" id="dc-'+i+'">'
+'<div class="drive-card-top"><div class="drive-select"><input type="radio" name="disk" value="'+esc(dev)+'" data-db="'+(db?'1':'0')+'" data-i="'+i+'" onchange="pickDisk(this)">'
+'<div class="drive-id"><span class="drive-name">'+title+'</span><span class="drive-sub">'+sub+'</span></div></div>'
+'<div class="drive-badges">'+dataBadge({data_bearing:db})+'</div></div></label>';
}
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('jelenlegi: '+esc(d.fstype));
var sub = parts.join(' · ');
html+='<label class="drive-card role-user-data is-selectable" id="dc-'+i+'">'
+'<div class="drive-card-top"><div class="drive-select"><input type="radio" name="disk" value="'+esc(d.device)+'" data-db="'+(d.data_bearing?'1':'0')+'" data-i="'+i+'" onchange="pickDisk(this)">'
+'<div class="drive-id"><span class="drive-name">'+title+'</span><span class="drive-sub">'+sub+'</span></div></div>'
+'<div class="drive-badges">'+dataBadge(d)+'</div></div></label>';
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('jelenlegi: '+esc(p.fstype));
html+=card(p.device, title+' — '+'{n}. rész'.replace('{n}', p.number||'?'), ps.join(' · '), p.state==='filesystem');
});
});
html+='</div>';
document.getElementById('disk-list').innerHTML=html;
+5 -1
View File
@@ -254,7 +254,11 @@
"note.backup.single_copy": "BORN AS A KEY (v0.317.0, 2026-10-15) -- the one-copy notice on the backup page (felhom.eu architecture/16-storage-physical-identity.md §4). It replaces the Go constant singleCopyNotice, removed in the same commit; the wording changed ON PURPOSE (the informal voice, R-1072; the honest same-disk sentence, `16` D1).",
"note.backup.single_copy_unproven": "BORN AS A KEY (v0.317.0, 2026-10-15) -- the notice when a second drive exists whose disk the host agent cannot prove (a VM, a USB bridge that hides the serial; `16` D2). New.",
"backup.tier2.unproven": "BORN AS A KEY (v0.317.0, 2026-10-15) -- the app row's line under a Tier-2 copy on a drive the agent cannot prove is another disk (`16` D2). New.",
"err.stacks.needs_drive": "BORN AS A KEY (v0.317.0, 2026-10-15) -- the install refusal on a box with no drive (`16` §5, D4; R-1068). New; it never names the field."
"err.stacks.needs_drive": "BORN AS A KEY (v0.317.0, 2026-10-15) -- the install refusal on a box with no drive (`16` §5, D4; R-1068). New; it never names the field.",
"err.web.meghajto_hasznalatban_formazas": "BORN AS A KEY (v0.318.0, 2026-10-16) -- the server-side refusal to format a whole disk under a registered drive (`16` §6). New.",
"backup.target.offer_unproven": "BORN AS A KEY (v0.318.0, 2026-10-16) -- the whole-box backup offer for a drive the agent cannot prove is another disk (R-1084, `16` §4); build A's words, no disk-failure promise. New.",
"storage.belso_tarhely": "BORN AS A KEY (v0.318.0, 2026-10-16) -- the label of the one-disk box's internal data place, registered in the box's language (`16` §5). New.",
"err.web.belso_tarhely_eletciklus": "BORN AS A KEY (v0.318.0, 2026-10-16) -- the refusal of eject/decommission/wipe on the internal data place (`16` §5). New."
},
"flash.share.already_on": "A megosztás már be van kapcsolva.",
"flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.",