The internal data place, partitions as places, add_internal_place (felhom.eu architecture/16 §5-§7, §9; build B)
gates / gates (push) Successful in 27s
gates / gates (push) Successful in 27s
- internal place: the record /var/lib/felhom-agent/internal-place.json -> mounted at /mnt/belso by fs-UUID like an enrolled drive (startup + 20 s tick), kind "internal" in GET /disks; never takes a household drive's /mnt/belso; eject/decommission/format refused (dm on the system disk). - configs/felhom-internal-place: the ruled size rule and the only lvcreate/mkfs of a system-disk volume (installer + the new signed op add_internal_place, operator key, no parameters). - partitions listed per free disk (empty / filesystem / in_use); a data-bearing part's wipe id binds disk id + part number + fs-UUID. - felhom-mkfs-guarded: Felhom's own sibling drive no longer blocks formatting the free part; an unlistable disk refuses. Tests red-proved (script mutations, Go mutations, the old wrapper measured against the new tests). Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
1 parent
e569ec6c5f
commit
912f7c127e
24 files changed
+1591
-69
No files matched your search
@@ -0,0 +1,207 @@
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
|
||||
)
|
||||
|
||||
// The internal data place („Belső tárhely", agent v0.158.0) — felhom.eu architecture/16-storage-physical-identity.md
|
||||
// §5, §7. On a one-disk box the root helper configs/felhom-internal-place makes a second thin volume in local-lvm
|
||||
// (<vg>/felhom-belso, ext4, label felhom-belso). The installer (a new box) or the signed add_internal_place job (an old
|
||||
// box) then writes InternalPlaceRecord to the agent's state dir. From then on the agent treats the volume exactly like
|
||||
// an enrolled drive: a Felhom mount unit by fs-UUID at /mnt/belso (EnsureMount), the `enrolled` intent, and — when the
|
||||
// controller asks with its own token (POST /disks/guest-attach) — the bind at /mnt/felhom-drives/belso. So it is a
|
||||
// HOST mount under the guest's mp8 parent: not in the guest config, not in vzdump, not in PBS (`16` §5).
|
||||
//
|
||||
// What it is NOT: a drive the household can format, wipe, eject or decommission. Its device is a device-mapper volume
|
||||
// on the system disk, so the role gate (roleForMountPath → system) and the format path (ValidateBlockDevice refuses
|
||||
// dm) refuse all four — pinned by internalplace_test.go. Its identity is the system disk's (build A), so a Tier-2 copy
|
||||
// between it and sys_drive is never a second copy.
|
||||
const (
|
||||
InternalPlaceName = "belso"
|
||||
InternalPlaceWhere = "/mnt/belso"
|
||||
// InternalPlaceFile is the record's name inside the agent's state dir.
|
||||
InternalPlaceFile = "internal-place.json"
|
||||
)
|
||||
|
||||
var reFSUUID = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
|
||||
|
||||
// InternalPlaceRecord is the state file the maker writes once the volume exists and is formatted.
|
||||
type InternalPlaceRecord struct {
|
||||
FSUUID string `json:"fs_uuid"`
|
||||
GiB int `json:"gib,omitempty"`
|
||||
MadeBy string `json:"made_by,omitempty"` // "installer" | "signed-job"
|
||||
MadeAt string `json:"made_at,omitempty"`
|
||||
}
|
||||
|
||||
// ReadInternalPlace reads the record; ok=false when there is none (the common case: a box with no internal place).
|
||||
func ReadInternalPlace(file string) (InternalPlaceRecord, bool, error) {
|
||||
var rec InternalPlaceRecord
|
||||
b, err := os.ReadFile(file)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return rec, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return rec, false, err
|
||||
}
|
||||
if err := json.Unmarshal(b, &rec); err != nil {
|
||||
return rec, false, fmt.Errorf("internal place record %s: %w", file, err)
|
||||
}
|
||||
if !reFSUUID.MatchString(rec.FSUUID) {
|
||||
return rec, false, fmt.Errorf("internal place record %s: fs_uuid %q is not a filesystem UUID", file, rec.FSUUID)
|
||||
}
|
||||
return rec, true, nil
|
||||
}
|
||||
|
||||
// WriteInternalPlace writes the record atomically (0600).
|
||||
func WriteInternalPlace(file string, rec InternalPlaceRecord) error {
|
||||
if !reFSUUID.MatchString(rec.FSUUID) {
|
||||
return fmt.Errorf("internal place: fs_uuid %q is not a filesystem UUID", rec.FSUUID)
|
||||
}
|
||||
b, err := json.MarshalIndent(rec, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := file + ".tmp"
|
||||
if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, file)
|
||||
}
|
||||
|
||||
// internalPlaceState memoises what EnsureInternalPlace last saw, so the 20-second tick logs a state CHANGE once and
|
||||
// does no work on the steady state.
|
||||
type internalPlaceState struct {
|
||||
mu sync.Mutex
|
||||
done string // the fs-UUID that is mounted and recorded enrolled
|
||||
lastWhy string // the last reason it could not be ensured (logged once per change)
|
||||
}
|
||||
|
||||
// internalPlaceUUID returns the configured internal place's fs-UUID, or "" when this box has none (or the record is
|
||||
// unreadable — logged by EnsureInternalPlace).
|
||||
func (s *Server) internalPlaceUUID() string {
|
||||
if s.internalPlaceFile == "" {
|
||||
return ""
|
||||
}
|
||||
rec, ok, err := ReadInternalPlace(s.internalPlaceFile)
|
||||
if err != nil || !ok {
|
||||
return ""
|
||||
}
|
||||
return rec.FSUUID
|
||||
}
|
||||
|
||||
// isInternalPlace reports whether a host path is this box's internal place: its raw mount (/mnt/belso) or its bind
|
||||
// under the guests' shared parent (/mnt/felhom-drives/belso — the path POST /disks/same-disk judges).
|
||||
func (s *Server) isInternalPlace(hostPath string) bool {
|
||||
p := path.Clean(hostPath)
|
||||
return (p == InternalPlaceWhere || p == StableParentDir+"/"+InternalPlaceName) && s.internalPlaceUUID() != ""
|
||||
}
|
||||
|
||||
// EnsureInternalPlace mounts the internal place on the host at /mnt/belso by fs-UUID and records it `enrolled`,
|
||||
// when this box has one. Idempotent and cheap on the steady state; safe on the 20-second tick. It never formats,
|
||||
// never unmounts, and never mounts anything at /mnt/belso that is not the recorded UUID: a household drive that
|
||||
// happens to be named „belso" keeps its mount, and the internal place waits (logged once).
|
||||
func (s *Server) EnsureInternalPlace(ctx context.Context) {
|
||||
if s.internalPlaceFile == "" {
|
||||
return
|
||||
}
|
||||
rec, ok, err := ReadInternalPlace(s.internalPlaceFile)
|
||||
if err != nil {
|
||||
s.internalPlaceNote("record unreadable: " + err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
uuid := rec.FSUUID
|
||||
st := &s.ipState
|
||||
st.mu.Lock()
|
||||
done := st.done == uuid
|
||||
st.mu.Unlock()
|
||||
|
||||
// What is at /mnt/belso now, if anything?
|
||||
mountedUUID, mounted := "", false
|
||||
if mounts, merr := s.hostReader().Mounts(); merr == nil {
|
||||
for _, m := range mounts {
|
||||
if path.Clean(m.MountPoint) == InternalPlaceWhere {
|
||||
mounted = true
|
||||
if u, okU := s.hostReader().ResolveUUID(m.Device); okU {
|
||||
mountedUUID = u
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if mounted && mountedUUID != "" && mountedUUID != uuid {
|
||||
s.internalPlaceNote("another filesystem (uuid " + mountedUUID + ") is mounted at " + InternalPlaceWhere + " — the internal place waits")
|
||||
return
|
||||
}
|
||||
// A Felhom unit for /mnt/belso that names another UUID (a household drive called „belso", unplugged): never
|
||||
// overwrite it — that would silently take the drive's name and mount point.
|
||||
if s.driveTargets != nil {
|
||||
if drives, derr := s.driveTargets.Known(ctx); derr == nil {
|
||||
for _, d := range drives {
|
||||
if path.Clean(d.MountPath) == InternalPlaceWhere && d.UUID != "" && d.UUID != uuid {
|
||||
s.internalPlaceNote("a drive unit for " + InternalPlaceWhere + " names another uuid (" + d.UUID + ") — the internal place waits")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if mounted && mountedUUID == uuid {
|
||||
if !done {
|
||||
s.recordIntent(ctx, InternalPlaceWhere, "enrolled")
|
||||
st.mu.Lock()
|
||||
st.done, st.lastWhy = uuid, ""
|
||||
st.mu.Unlock()
|
||||
s.logger.Info("internal place: mounted and enrolled", "where", InternalPlaceWhere, "fs_uuid", uuid, "gib", rec.GiB)
|
||||
}
|
||||
return
|
||||
}
|
||||
if s.disks == nil {
|
||||
s.internalPlaceNote("disk management is not configured on this host")
|
||||
return
|
||||
}
|
||||
if s.resolveStorageDevice != nil {
|
||||
if _, rerr := s.resolveStorageDevice("uuid:" + uuid); rerr != nil {
|
||||
s.internalPlaceNote("its volume is not present (uuid " + uuid + ")")
|
||||
return
|
||||
}
|
||||
}
|
||||
start := time.Now()
|
||||
if err := s.disks.EnsureMount(ctx, storage.MountSpec{Name: InternalPlaceName, UUID: uuid, Where: InternalPlaceWhere, FSType: "ext4"}); err != nil {
|
||||
s.internalPlaceNote("mount failed: " + err.Error())
|
||||
return
|
||||
}
|
||||
s.recordIntent(ctx, InternalPlaceWhere, "enrolled")
|
||||
st.mu.Lock()
|
||||
st.done, st.lastWhy = uuid, ""
|
||||
st.mu.Unlock()
|
||||
s.logger.Info("internal place: mounted on the host", "where", InternalPlaceWhere, "fs_uuid", uuid, "gib", rec.GiB,
|
||||
"duration_ms", time.Since(start).Milliseconds())
|
||||
}
|
||||
|
||||
func (s *Server) internalPlaceNote(why string) {
|
||||
st := &s.ipState
|
||||
st.mu.Lock()
|
||||
changed := st.lastWhy != why
|
||||
st.lastWhy, st.done = why, ""
|
||||
st.mu.Unlock()
|
||||
if changed {
|
||||
s.logger.Warn("internal place: not ensured", "why", why)
|
||||
} else {
|
||||
s.logger.Debug("internal place: still not ensured", "why", why)
|
||||
}
|
||||
}
|
||||
|
||||
// InternalPlaceFileIn is the record's path inside a state dir.
|
||||
func InternalPlaceFileIn(stateDir string) string { return filepath.Join(stateDir, InternalPlaceFile) }
|
||||
@@ -0,0 +1,249 @@
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
|
||||
)
|
||||
|
||||
// The internal data place (felhom.eu architecture/16 §5, agent v0.158.0): mounted like an enrolled drive, but never
|
||||
// at the cost of a household drive, never formatted, ejected or decommissioned, and the same disk as sys_drive.
|
||||
|
||||
const ipUUID = "0b6e4c5a-1d2e-4f3a-9b8c-7d6e5f4a3b2c"
|
||||
const ipDev = "/dev/mapper/pve-felhom--belso"
|
||||
|
||||
// ipHost is a HostReader whose mount table the test changes, and whose by-uuid map it controls.
|
||||
type ipHost struct {
|
||||
mu sync.Mutex
|
||||
mounts []storage.Mount
|
||||
uuids map[string]string // device → fs-UUID
|
||||
}
|
||||
|
||||
func (h *ipHost) Mounts() ([]storage.Mount, error) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return append([]storage.Mount(nil), h.mounts...), nil
|
||||
}
|
||||
func (h *ipHost) ResolveUUID(dev string) (string, bool) { u, ok := h.uuids[dev]; return u, ok }
|
||||
func (h *ipHost) DeviceExists(string) bool { return true }
|
||||
func (h *ipHost) Rotational(string) (bool, bool) { return false, false }
|
||||
func (h *ipHost) Removable(string) (bool, bool) { return false, false }
|
||||
func (h *ipHost) BlockSlaves(string) ([]string, bool) { return nil, false }
|
||||
|
||||
func newIPHost(extra ...storage.Mount) *ipHost {
|
||||
return &ipHost{
|
||||
// root on a whole-disk partition (as sysOnSDA): the system disk resolves, so a USB drive is user-data.
|
||||
mounts: append([]storage.Mount{{Device: "/dev/sda1", MountPoint: "/", FSType: "ext4"}}, extra...),
|
||||
uuids: map[string]string{ipDev: ipUUID, "/dev/sdb1": "99999999-aaaa-bbbb-cccc-dddddddddddd"},
|
||||
}
|
||||
}
|
||||
|
||||
type ipRig struct {
|
||||
srv *Server
|
||||
disks *fakeDiskOps
|
||||
intent *fakeIntent
|
||||
}
|
||||
|
||||
func newIPRig(t *testing.T, host *ipHost, known []storage.KnownTarget, record bool) ipRig {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, InternalPlaceFile)
|
||||
if record {
|
||||
if err := WriteInternalPlace(file, InternalPlaceRecord{FSUUID: ipUUID, GiB: 183, MadeBy: "installer"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
d, in := &fakeDiskOps{}, newFakeIntent()
|
||||
srv, err := NewServer(Options{
|
||||
ListenAddr: "127.0.0.1:0", Guests: &fakeGuests{}, Backups: &fakeBackups{}, Store: &fakeStore{},
|
||||
Storage: fakeStorage{}, Tokens: staticTokens{"A": 8200}, Disks: d, DiskGate: &fakeGate{},
|
||||
HostReader: host, Intent: in, DriveTargets: fakeKnownTargets{drives: known},
|
||||
InternalPlaceFile: file, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.baseCtx = context.Background()
|
||||
srv.resolveStorageDevice = func(id string) (string, error) {
|
||||
if id == "uuid:"+ipUUID {
|
||||
return "/dev/dm-7", nil
|
||||
}
|
||||
return "", errors.New("absent")
|
||||
}
|
||||
return ipRig{srv, d, in}
|
||||
}
|
||||
|
||||
func TestInternalPlace_NoRecord_DoesNothing(t *testing.T) {
|
||||
r := newIPRig(t, newIPHost(), nil, false)
|
||||
r.srv.EnsureInternalPlace(context.Background())
|
||||
if len(r.disks.mountCalls) != 0 {
|
||||
t.Fatalf("no record: no mount may be made, got %+v", r.disks.mountCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalPlace_Record_MountsByUUIDAtMntBelso(t *testing.T) {
|
||||
r := newIPRig(t, newIPHost(), nil, true)
|
||||
r.srv.EnsureInternalPlace(context.Background())
|
||||
if len(r.disks.mountCalls) != 1 {
|
||||
t.Fatalf("want exactly one EnsureMount, got %+v", r.disks.mountCalls)
|
||||
}
|
||||
got := r.disks.mountCalls[0]
|
||||
if got.UUID != ipUUID || got.Where != InternalPlaceWhere || got.FSType != "ext4" || got.Name != InternalPlaceName {
|
||||
t.Fatalf("EnsureMount spec: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalPlace_AlreadyMounted_EnrolledOnceNoRemount(t *testing.T) {
|
||||
host := newIPHost(storage.Mount{Device: ipDev, MountPoint: InternalPlaceWhere, FSType: "ext4"})
|
||||
r := newIPRig(t, host, nil, true)
|
||||
r.srv.EnsureInternalPlace(context.Background())
|
||||
r.srv.EnsureInternalPlace(context.Background())
|
||||
if len(r.disks.mountCalls) != 0 {
|
||||
t.Fatalf("already mounted: no EnsureMount, got %+v", r.disks.mountCalls)
|
||||
}
|
||||
if r.intent.Get("uuid:"+ipUUID) != storage.IntentEnrolled {
|
||||
t.Fatalf("the internal place must be recorded enrolled")
|
||||
}
|
||||
}
|
||||
|
||||
// A household drive called „belso" keeps its mount point: the internal place never takes it.
|
||||
// Positive control: TestInternalPlace_Record_MountsByUUIDAtMntBelso (the same record, /mnt/belso free).
|
||||
func TestInternalPlace_NeverTakesAHouseholdDriveNamedBelso(t *testing.T) {
|
||||
mounted := newIPHost(storage.Mount{Device: "/dev/sdb1", MountPoint: InternalPlaceWhere, FSType: "ext4"})
|
||||
r := newIPRig(t, mounted, nil, true)
|
||||
r.srv.EnsureInternalPlace(context.Background())
|
||||
if len(r.disks.mountCalls) != 0 || r.intent.Get("uuid:"+ipUUID) != "" {
|
||||
t.Fatalf("a foreign filesystem at /mnt/belso: nothing may be mounted or recorded, got %+v", r.disks.mountCalls)
|
||||
}
|
||||
unplugged := []storage.KnownTarget{{Name: "belso", UUID: "99999999-aaaa-bbbb-cccc-dddddddddddd", MountPath: InternalPlaceWhere, MountBacked: true}}
|
||||
r2 := newIPRig(t, newIPHost(), unplugged, true)
|
||||
r2.srv.EnsureInternalPlace(context.Background())
|
||||
if len(r2.disks.mountCalls) != 0 {
|
||||
t.Fatalf("an unplugged household drive's unit for /mnt/belso must not be overwritten, got %+v", r2.disks.mountCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalPlace_VolumeAbsent_NoMount(t *testing.T) {
|
||||
r := newIPRig(t, newIPHost(), nil, true)
|
||||
r.srv.resolveStorageDevice = func(string) (string, error) { return "", errors.New("absent") }
|
||||
r.srv.EnsureInternalPlace(context.Background())
|
||||
if len(r.disks.mountCalls) != 0 {
|
||||
t.Fatalf("absent volume: no mount, got %+v", r.disks.mountCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// The household cannot eject or decommission it: its device is a device-mapper volume on the system disk.
|
||||
// Positive control: the same calls for a whole-disk USB drive (/dev/sdb1) are not refused by role.
|
||||
func TestInternalPlace_EjectAndDecommissionRefused(t *testing.T) {
|
||||
host := newIPHost(
|
||||
storage.Mount{Device: ipDev, MountPoint: InternalPlaceWhere, FSType: "ext4"},
|
||||
storage.Mount{Device: "/dev/sdb1", MountPoint: "/mnt/hdd_1", FSType: "ext4"},
|
||||
)
|
||||
r := newIPRig(t, host, nil, true)
|
||||
h := r.srv.Handler()
|
||||
for _, route := range []string{"/disks/eject", "/disks/decommission"} {
|
||||
if w := do(t, h, "POST", route, "A", `{"where":"/mnt/belso"}`); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s /mnt/belso: want 403, got %d %s", route, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
if w := do(t, h, "POST", "/disks/eject", "A", `{"where":"/mnt/hdd_1"}`); w.Code == http.StatusForbidden {
|
||||
t.Fatalf("positive control: a USB drive's eject must not be refused by role, got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
for _, u := range r.disks.unmountCalls {
|
||||
if u == InternalPlaceWhere {
|
||||
t.Fatalf("the internal place must never be unmounted, got %+v", r.disks.unmountCalls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The consequence `16` §5 needs: a copy between the internal place and sys_drive is the SAME disk (build A then never
|
||||
// counts it), and the place is reported as kind internal.
|
||||
type ipIdentHost struct{ fakeIdentHost }
|
||||
|
||||
func (h *ipIdentHost) KernelName(dev string) (string, bool) {
|
||||
if dev == ipDev {
|
||||
return "dm-7", true
|
||||
}
|
||||
return h.fakeIdentHost.KernelName(dev)
|
||||
}
|
||||
func (h *ipIdentHost) Slaves(n string) []string {
|
||||
if n == "dm-7" {
|
||||
return []string{"dm-3"} // felhom-belso → the thin pool
|
||||
}
|
||||
if n == "dm-3" {
|
||||
return []string{"sda3"} // the pool → the system disk's LVM partition
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestInternalPlace_IsTheSystemDiskAndKindInternal(t *testing.T) {
|
||||
ih := &ipIdentHost{fakeIdentHost{
|
||||
virt: "none",
|
||||
parent: map[string]string{"sda3": "sda"},
|
||||
props: map[string]map[string]string{"sda": {"ID_BUS": "ata", "ID_MODEL": "SanDisk", "ID_SERIAL_SHORT": "1"}},
|
||||
mounts: []storage.Mount{
|
||||
{Device: "/dev/mapper/pve-root", MountPoint: "/", FSType: "ext4"},
|
||||
{Device: "/dev/mapper/pve-root", MountPoint: "/mnt/felhom-drives", FSType: "ext4"},
|
||||
{Device: ipDev, MountPoint: InternalPlaceWhere, FSType: "ext4"},
|
||||
{Device: ipDev, MountPoint: "/mnt/felhom-drives/belso", FSType: "ext4"},
|
||||
},
|
||||
}}
|
||||
fg := &fakeGuestsCfg{mounts: map[int]map[string]string{8200: {
|
||||
"mp0": "local-lvm:vm-8200-disk-1,mp=/var/lib/felhom,backup=1,size=100G",
|
||||
"mp8": "/mnt/felhom-drives,mp=/mnt/felhom-drives",
|
||||
}}}
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, InternalPlaceFile)
|
||||
if err := WriteInternalPlace(file, InternalPlaceRecord{FSUUID: ipUUID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv, err := NewServer(Options{
|
||||
ListenAddr: "127.0.0.1:0", Guests: fg, Backups: &fakeBackups{}, Store: &fakeStore{},
|
||||
Storage: fakeStorage{targets: []hub.StorageTarget{provenSSD}}, Tokens: staticTokens{"A": 8200},
|
||||
HostReader: fakeHostReader{mounts: ih.mounts}, Identity: &storage.IdentityResolver{Host: ih},
|
||||
InternalPlaceFile: file, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.baseCtx = context.Background()
|
||||
got, code := sameDisk(t, srv.Handler(), "/mnt/felhom-drives/belso/felhom-data/jellyfin", "/mnt/sys_drive")
|
||||
if code != http.StatusOK || got.Different || !got.Proven || got.Reason != "same_disk" {
|
||||
t.Fatalf("internal place vs sys_drive: want same disk, proven; got %d %+v", code, got)
|
||||
}
|
||||
if got.A.Kind != storage.PlaceKindInternal {
|
||||
t.Fatalf("kind: want %q, got %q", storage.PlaceKindInternal, got.A.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalPlaceRecord_RefusesANonUUID(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), InternalPlaceFile)
|
||||
if err := WriteInternalPlace(file, InternalPlaceRecord{FSUUID: "../../etc"}); err == nil {
|
||||
t.Fatal("a non-UUID must not be written")
|
||||
}
|
||||
}
|
||||
|
||||
// The root helper's own tests (configs/test_felhom_internal_place.py — the ruled size table, never formatting what
|
||||
// holds anything) run with the Go suite.
|
||||
func TestInternalPlaceHelperPythonSuite(t *testing.T) {
|
||||
py, err := exec.LookPath("python3")
|
||||
if err != nil {
|
||||
t.Skip("python3 not found")
|
||||
}
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
script := filepath.Join(filepath.Dir(file), "..", "..", "configs", "test_felhom_internal_place.py")
|
||||
out, err := exec.Command(py, script).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("helper suite failed: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,8 @@ func (s *Server) ownMountIdentity(hostPath string) (storage.PlaceIdentity, strin
|
||||
}
|
||||
kind := storage.PlaceKindDisk
|
||||
switch {
|
||||
case s.isInternalPlace(hostPath): // v0.158.0 `16` §5: the one-disk box's data place, on the system disk
|
||||
kind = storage.PlaceKindInternal
|
||||
case id.Reason == storage.ReasonNetwork:
|
||||
kind = storage.PlaceKindNetwork
|
||||
case strings.HasPrefix(m.Device, "/dev/") && !strings.HasPrefix(m.Device, "/dev/mapper/") && isPartitionPath(m.Device):
|
||||
|
||||
+49
-43
@@ -208,6 +208,9 @@ type Options struct {
|
||||
// Intent records drive enroll/eject intent for the self-heal watchdog (slice 10 P3). OPTIONAL —
|
||||
// when nil, no intent is recorded (self-heal runs ungated).
|
||||
Intent IntentRecorder
|
||||
// InternalPlaceFile (v0.158.0, felhom.eu `16` §5) is the path of the internal data place record
|
||||
// (InternalPlaceRecord). OPTIONAL — "" means this host has no internal place and EnsureInternalPlace is a no-op.
|
||||
InternalPlaceFile string
|
||||
// GuestBinds persists which user-data drives (by durable-id) are enrolled into each guest, so the
|
||||
// startup re-assert (ReassertGuestBinds) can restore a bind that a re-provision dropped (F9).
|
||||
// OPTIONAL — when nil, guest binds are not recorded and the startup re-assert is a no-op.
|
||||
@@ -316,12 +319,14 @@ type Server struct {
|
||||
// identity blob from the hub, unseal it with the customer's recovery code, return ONLY the
|
||||
// offsite repository password. OPTIONAL — nil (no hub client configured) makes
|
||||
// POST /escrow/recover-offsite-password answer 503 rather than pretending.
|
||||
escrowRecovery EscrowRecoverer
|
||||
intent IntentRecorder // slice 10 P3 (optional)
|
||||
guestBinds *GuestBindStore // F9 startup bind re-assert record (optional)
|
||||
formatJobs *FormatJobStore // F20-BUG3 detached-format job record (optional)
|
||||
staleLock StaleLockController // F2-b startup stale-lock recovery (optional)
|
||||
partialSweep func(ctx context.Context, vmid int) // R-1006 start-up sweep of dead vzdump leftovers (optional)
|
||||
escrowRecovery EscrowRecoverer
|
||||
intent IntentRecorder // slice 10 P3 (optional)
|
||||
internalPlaceFile string // v0.158.0 `16` §5 (optional)
|
||||
ipState internalPlaceState // EnsureInternalPlace's memo
|
||||
guestBinds *GuestBindStore // F9 startup bind re-assert record (optional)
|
||||
formatJobs *FormatJobStore // F20-BUG3 detached-format job record (optional)
|
||||
staleLock StaleLockController // F2-b startup stale-lock recovery (optional)
|
||||
partialSweep func(ctx context.Context, vmid int) // R-1006 start-up sweep of dead vzdump leftovers (optional)
|
||||
// guestPower (F-REBOOT) is per-guest start-attempt state for the guest-power watchdog.
|
||||
// Guarded by guestPowerMu in guestpower.go; in-memory on purpose (see guestPowerState).
|
||||
guestPower map[int]guestPowerState
|
||||
@@ -455,43 +460,44 @@ func NewServer(o Options) (*Server, error) {
|
||||
cadence = defaultBackupCadence
|
||||
}
|
||||
s := &Server{
|
||||
addr: o.ListenAddr,
|
||||
cert: o.Cert,
|
||||
guests: o.Guests,
|
||||
backups: o.Backups,
|
||||
store: o.Store,
|
||||
storage: o.Storage,
|
||||
driveTargets: o.DriveTargets,
|
||||
smart: o.Smart,
|
||||
tokens: o.Tokens,
|
||||
cadence: cadence,
|
||||
logger: o.Logger,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
disks: o.Disks,
|
||||
diskGate: o.DiskGate,
|
||||
guestList: o.Guests2,
|
||||
guestAttach: o.GuestAttach,
|
||||
mem: o.Memory,
|
||||
netStorage: o.NetStorage,
|
||||
privileged: o.Privileged,
|
||||
configPath: o.ConfigPath,
|
||||
stateDir: o.StateDir,
|
||||
netMountRoot: storage.NetworkMountRoot,
|
||||
smbCredsDir: o.SmbCredsDir,
|
||||
escrowStagePath: o.EscrowStagePath,
|
||||
escrowRecovery: o.EscrowRecovery,
|
||||
intent: o.Intent,
|
||||
guestBinds: o.GuestBinds,
|
||||
formatJobs: o.FormatJobs,
|
||||
staleLock: o.StaleLock,
|
||||
host: o.HostReader,
|
||||
ident: o.Identity,
|
||||
hostMetrics: o.HostMetrics,
|
||||
hostID: o.HostID,
|
||||
agentVersion: o.AgentVersion,
|
||||
logRing: o.LogRing,
|
||||
jobs: map[backupJobKey]*backupJob{},
|
||||
swapInFlight: map[int]bool{},
|
||||
addr: o.ListenAddr,
|
||||
cert: o.Cert,
|
||||
guests: o.Guests,
|
||||
backups: o.Backups,
|
||||
store: o.Store,
|
||||
storage: o.Storage,
|
||||
driveTargets: o.DriveTargets,
|
||||
smart: o.Smart,
|
||||
tokens: o.Tokens,
|
||||
cadence: cadence,
|
||||
logger: o.Logger,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
disks: o.Disks,
|
||||
diskGate: o.DiskGate,
|
||||
guestList: o.Guests2,
|
||||
guestAttach: o.GuestAttach,
|
||||
mem: o.Memory,
|
||||
netStorage: o.NetStorage,
|
||||
privileged: o.Privileged,
|
||||
configPath: o.ConfigPath,
|
||||
stateDir: o.StateDir,
|
||||
netMountRoot: storage.NetworkMountRoot,
|
||||
smbCredsDir: o.SmbCredsDir,
|
||||
escrowStagePath: o.EscrowStagePath,
|
||||
escrowRecovery: o.EscrowRecovery,
|
||||
intent: o.Intent,
|
||||
internalPlaceFile: o.InternalPlaceFile,
|
||||
guestBinds: o.GuestBinds,
|
||||
formatJobs: o.FormatJobs,
|
||||
staleLock: o.StaleLock,
|
||||
host: o.HostReader,
|
||||
ident: o.Identity,
|
||||
hostMetrics: o.HostMetrics,
|
||||
hostID: o.HostID,
|
||||
agentVersion: o.AgentVersion,
|
||||
logRing: o.LogRing,
|
||||
jobs: map[backupJobKey]*backupJob{},
|
||||
swapInFlight: map[int]bool{},
|
||||
}
|
||||
// R-82 tier resolution. Options.BackupTiers is authoritative when supplied; otherwise ONE tier
|
||||
// is synthesized from Backups + BackupCadence — the pre-R-82 shape, so every existing caller
|
||||
|
||||
Reference in new issue
Block a user