Files
felhom-controller/controller/internal/web/internal_place.go
T
admin a43d0423d5
gates / gates (push) Successful in 46s
v0.318.0: the internal data place, partitions, an honest whole-box backup offer (R-1084), sys_drive never offered (R-1082)
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
2026-10-16 03:57:49 +02:00

109 lines
4.3 KiB
Go

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
}