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
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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user