agent v0.25.0: slice 10 P2 — bind enrolled user-data drives into the guest
POST /disks/guest-attach binds an enrolled drive's felhom-data namespace into the guest (Model A: felhom-data is the bind source mounted at /mnt/<name>, so only Felhom's namespace crosses in). GuestBinder does mkdir+chown(100000)+pct set (RW bind) via the fenced runner. Idempotent, free-slot selection, path-validated. Spike-proven on 9201. Pairs with controller P2C + golden /mnt:rslave (P2B). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,30 @@
|
|||||||
All notable changes to **felhom-agent** are recorded here. Update on every code
|
All notable changes to **felhom-agent** are recorded here. Update on every code
|
||||||
change that gets pushed.
|
change that gets pushed.
|
||||||
|
|
||||||
|
## v0.25.0 — slice 10 P2: bind enrolled user-data drives into the guest (passthrough) (2026-06-12)
|
||||||
|
|
||||||
|
External user-data drives are mounted on the HOST but were never passed INTO the guest (diagnosed
|
||||||
|
Branch A), so apps silently wrote to the rootfs and the controller couldn't see them. This adds the
|
||||||
|
guest passthrough. Spike-proven on 9201 first (see REPORT / the usb-passthrough-spike findings):
|
||||||
|
`pct set` **bind form** (host path, never `storage:size`), `chown` to the guest base (idmap not clean
|
||||||
|
for mixed-ownership data), `shared:49` propagation host↔guest automatic.
|
||||||
|
|
||||||
|
- **`POST /disks/guest-attach` (`internal/localapi`)** — self-scoped (vmid from token). Binds an
|
||||||
|
enrolled drive's **felhom-data namespace** into the guest at `/mnt/<name>` (**Model A**: the
|
||||||
|
felhom-data dir is the bind source mounted AT `/mnt/<name>`, so only Felhom's namespace crosses into
|
||||||
|
the guest — the customer's other data on the drive never does). Idempotent (returns the existing slot
|
||||||
|
if already bound); picks the lowest free `mpN`; validates `where` is `/mnt/<name>` (no traversal).
|
||||||
|
- **`GuestBinder` (`internal/localapi/guestbind.go`)** — the host-root steps over the fenced
|
||||||
|
`proxmox.Runner` (same pattern as the provision back-half's bind): `mkdir -p <drive>/felhom-data` →
|
||||||
|
`chown 100000:100000` the namespace ROOT (not -R; per-app subdirs are chowned at deploy) → `pct set
|
||||||
|
<vmid> -mpN <drive>/felhom-data,mp=/mnt/<name>` (RW bind). The namespace is created fresh + uniformly
|
||||||
|
owned, which sidesteps the drive's pre-existing mixed-ownership data entirely.
|
||||||
|
- **Tests** — `TestGuestAttach_*`: free-slot selection (mp0 when mp9 taken), idempotency (no re-bind +
|
||||||
|
`already:true`), bad-path rejection (traversal/non-/mnt/multi-component), not-configured 503.
|
||||||
|
|
||||||
|
Pairs with felhom-controller P2C (enroll triggers attach) + the golden's `/mnt:rslave` controller bind
|
||||||
|
(P2B). Self-heal reconcile (P3) and dual-role (P4) follow.
|
||||||
|
|
||||||
## v0.24.0 — role-gate the eject path (system/backup mounts are unmount-protected at the agent) (2026-06-12)
|
## v0.24.0 — role-gate the eject path (system/backup mounts are unmount-protected at the agent) (2026-06-12)
|
||||||
|
|
||||||
Closes the eject gap in the storage-authorization redesign: `POST /disks/eject` now **refuses to
|
Closes the eject gap in the storage-authorization redesign: `POST /disks/eject` now **refuses to
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import (
|
|||||||
|
|
||||||
// version is the agent version. Overridable at build time with
|
// version is the agent version. Overridable at build time with
|
||||||
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version.
|
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version.
|
||||||
var version = "0.24.0"
|
var version = "0.25.0"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var (
|
var (
|
||||||
@@ -554,6 +554,13 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St
|
|||||||
}
|
}
|
||||||
logger.Info("local-api leaf ready", "fingerprint_sha256", fp, "cert", cfg.LocalAPI.CertPath())
|
logger.Info("local-api leaf ready", "fingerprint_sha256", fp, "cert", cfg.LocalAPI.CertPath())
|
||||||
runner := backup.NewBackupRunner(px, cfg.Backup.LocalBackupTarget, "", "felhom local-api", logger)
|
runner := backup.NewBackupRunner(px, cfg.Backup.LocalBackupTarget, "", "felhom local-api", logger)
|
||||||
|
// Guest data-drive passthrough (slice 10 P2): a root-CLI runner for the `pct set` bind + chown
|
||||||
|
// (same fenced ExecRunner the host-storage + provision back-half use).
|
||||||
|
gaMode := proxmox.RunnerMode(cfg.Privileged.Mode)
|
||||||
|
if gaMode == "" {
|
||||||
|
gaMode = proxmox.RunnerSudo
|
||||||
|
}
|
||||||
|
guestBinder := localapi.NewGuestBinder(&proxmox.ExecRunner{Mode: gaMode, SudoPath: cfg.Privileged.SudoPath}, logger)
|
||||||
srv, err := localapi.NewServer(localapi.Options{
|
srv, err := localapi.NewServer(localapi.Options{
|
||||||
ListenAddr: cfg.LocalAPI.ListenAddr,
|
ListenAddr: cfg.LocalAPI.ListenAddr,
|
||||||
Cert: cert,
|
Cert: cert,
|
||||||
@@ -564,9 +571,11 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St
|
|||||||
Tokens: tokens,
|
Tokens: tokens,
|
||||||
BackupCadence: cfg.Backup.BackupCadence(),
|
BackupCadence: cfg.Backup.BackupCadence(),
|
||||||
// Disk management (slice 8C): the privileged host surface + the data-bearing wipe gate.
|
// Disk management (slice 8C): the privileged host surface + the data-bearing wipe gate.
|
||||||
Disks: hostOps,
|
Disks: hostOps,
|
||||||
DiskGate: storageGateAdapter{gate: gate, hostID: cfg.Hub.HostID},
|
DiskGate: storageGateAdapter{gate: gate, hostID: cfg.Hub.HostID},
|
||||||
Guests2: px,
|
Guests2: px,
|
||||||
|
GuestAttach: guestBinder, // slice 10 P2: bind enrolled data drives into the guest
|
||||||
|
|
||||||
// Host metrics (slice 9): the shared collector serves GET /host/metrics — a fresh host +
|
// Host metrics (slice 9): the shared collector serves GET /host/metrics — a fresh host +
|
||||||
// per-storage view to the customer's monitoring page (reuses the slice-4 collector).
|
// per-storage view to the customer's monitoring page (reuses the slice-4 collector).
|
||||||
HostMetrics: collector,
|
HostMetrics: collector,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package localapi
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
||||||
@@ -60,6 +61,12 @@ type GuestLister interface {
|
|||||||
ListLXC(ctx context.Context) ([]proxmox.Guest, error)
|
ListLXC(ctx context.Context) ([]proxmox.Guest, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GuestAttacher binds an enrolled user-data drive's felhom-data namespace into a guest as an RW bind
|
||||||
|
// mount (slice 10 P2, Model A). Satisfied by *GuestBinder. The handler picks the slot + dedups.
|
||||||
|
type GuestAttacher interface {
|
||||||
|
AttachBind(ctx context.Context, vmid int, mountKey, where string) error
|
||||||
|
}
|
||||||
|
|
||||||
// ---- handlers ---------------------------------------------------------------------------
|
// ---- handlers ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// DiskInfo is one host drive with its data-bearing flag (for the UI).
|
// DiskInfo is one host drive with its data-bearing flag (for the UI).
|
||||||
@@ -203,6 +210,93 @@ func (s *Server) handleDiskEject(w http.ResponseWriter, r *http.Request, vmid in
|
|||||||
writeOK(w, map[string]any{"vmid": vmid, "ejected": req.Where, "dependent_guests": dependents})
|
writeOK(w, map[string]any{"vmid": vmid, "ejected": req.Where, "dependent_guests": dependents})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type guestAttachRequest struct {
|
||||||
|
VMID int `json:"vmid"`
|
||||||
|
Where string `json:"where"` // the host mount path of the enrolled drive (e.g. /mnt/felhom-usb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDiskGuestAttach binds an enrolled user-data drive's felhom-data namespace into THIS guest as
|
||||||
|
// an RW bind mount (slice 10 P2, Model A). Self-scoped (the vmid is the token's). Idempotent: if a
|
||||||
|
// mountpoint already binds `where`, it returns the existing slot without re-attaching. The drive must
|
||||||
|
// already be mounted on the host at `where` (the enroll flow's assign did that) — this only adds the
|
||||||
|
// guest passthrough. The customer's non-felhom data on the drive is NOT exposed (only felhom-data).
|
||||||
|
func (s *Server) handleDiskGuestAttach(w http.ResponseWriter, r *http.Request, vmid int) {
|
||||||
|
if s.guestAttach == nil {
|
||||||
|
writeErr(w, http.StatusServiceUnavailable, "guest passthrough not configured on this host")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req guestAttachRequest
|
||||||
|
if !decodeBody(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
where := strings.TrimSpace(req.Where)
|
||||||
|
if !validGuestMountPath(where) {
|
||||||
|
writeErr(w, http.StatusBadRequest, "where must be an absolute /mnt/<name> path (no traversal)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Read the guest config for idempotency + free-slot selection.
|
||||||
|
cfg, err := s.guests.GuestConfig(r.Context(), vmid)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("local-api: guest-attach guest config", "vmid", vmid, "err", err)
|
||||||
|
writeErr(w, http.StatusBadGateway, "could not read guest config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mounts := cfg.MountPoints()
|
||||||
|
// Idempotency: already bound at `where`? (a bind's mp= equals the guest path).
|
||||||
|
for key, spec := range mounts {
|
||||||
|
if _, mp, _ := parseMount(spec); mp == where {
|
||||||
|
s.logger.Info("local-api: guest-attach idempotent (already bound)", "vmid", vmid, "where", where, "slot", key)
|
||||||
|
writeOK(w, map[string]any{"vmid": vmid, "attached": where, "slot": key, "already": true})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slot, ok := freeMountSlot(mounts)
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusConflict, "no free mountpoint slot on the guest")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.guestAttach.AttachBind(r.Context(), vmid, slot, where); err != nil {
|
||||||
|
s.logger.Error("local-api: guest-attach", "vmid", vmid, "where", where, "slot", slot, "err", err)
|
||||||
|
writeErr(w, http.StatusBadGateway, "guest-attach failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(w, map[string]any{"vmid": vmid, "attached": where, "slot": slot})
|
||||||
|
}
|
||||||
|
|
||||||
|
// validGuestMountPath accepts an absolute /mnt/<name> path with no traversal (the enroll convention
|
||||||
|
// root). Mirrors the controller's mount-name discipline so a hostile `where` can't escape /mnt.
|
||||||
|
func validGuestMountPath(p string) bool {
|
||||||
|
if !strings.HasPrefix(p, "/mnt/") || strings.Contains(p, "..") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rest := strings.TrimPrefix(p, "/mnt/")
|
||||||
|
if rest == "" || strings.ContainsAny(rest, "/ \t") {
|
||||||
|
return false // exactly one path component under /mnt
|
||||||
|
}
|
||||||
|
for _, c := range rest {
|
||||||
|
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// freeMountSlot returns the lowest mpN (0..255) not present in the guest's current mountpoints. The
|
||||||
|
// bootstrap mount (mp9) and any existing data mounts are already in `mounts`, so they're skipped.
|
||||||
|
func freeMountSlot(mounts map[string]string) (string, bool) {
|
||||||
|
for i := 0; i <= 255; i++ {
|
||||||
|
key := "mp" + strconv.Itoa(i)
|
||||||
|
if _, used := mounts[key]; !used {
|
||||||
|
return key, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
type formatRequest struct {
|
type formatRequest struct {
|
||||||
VMID int `json:"vmid"`
|
VMID int `json:"vmid"`
|
||||||
Device string `json:"device"`
|
Device string `json:"device"`
|
||||||
|
|||||||
@@ -372,6 +372,96 @@ func TestEject_RoleGated(t *testing.T) {
|
|||||||
d4.mu.Unlock()
|
d4.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- guest data-drive passthrough (slice 10 P2) -----------------------------------------
|
||||||
|
|
||||||
|
type fakeGuestAttacher struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
calls []struct {
|
||||||
|
vmid int
|
||||||
|
slot, where string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeGuestAttacher) AttachBind(_ context.Context, vmid int, mountKey, where string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.calls = append(f.calls, struct {
|
||||||
|
vmid int
|
||||||
|
slot, where string
|
||||||
|
}{vmid, mountKey, where})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeGuestAttacher) count() int { f.mu.Lock(); defer f.mu.Unlock(); return len(f.calls) }
|
||||||
|
|
||||||
|
func newAttachServer(t *testing.T, ga GuestAttacher, mounts map[int]map[string]string) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
srv, err := NewServer(Options{
|
||||||
|
ListenAddr: "127.0.0.1:0", Guests: &fakeGuestsCfg{mounts: mounts}, Backups: &fakeBackups{},
|
||||||
|
Store: &fakeStore{}, Storage: fakeStorage{}, Tokens: staticTokens{"A": 8200, "B": 9300},
|
||||||
|
GuestAttach: ga, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv.baseCtx = context.Background()
|
||||||
|
return srv.Handler()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A first attach picks the lowest free slot (mp0; mp9 bootstrap is taken) and calls the binder.
|
||||||
|
func TestGuestAttach_PicksFreeSlotAndBinds(t *testing.T) {
|
||||||
|
ga := &fakeGuestAttacher{}
|
||||||
|
h := newAttachServer(t, ga, map[int]map[string]string{
|
||||||
|
8200: {"mp9": "/var/lib/.../bootstrap,mp=/etc/felhom-bootstrap,ro=1"},
|
||||||
|
})
|
||||||
|
w := do(t, h, "POST", "/disks/guest-attach", "A", `{"where":"/mnt/felhom-usb"}`)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("attach: got %d want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if ga.count() != 1 || ga.calls[0].slot != "mp0" || ga.calls[0].where != "/mnt/felhom-usb" || ga.calls[0].vmid != 8200 {
|
||||||
|
t.Fatalf("AttachBind not called with mp0/where/vmid: %+v", ga.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An already-bound drive is idempotent: returns the existing slot, binder NOT called again.
|
||||||
|
func TestGuestAttach_Idempotent(t *testing.T) {
|
||||||
|
ga := &fakeGuestAttacher{}
|
||||||
|
h := newAttachServer(t, ga, map[int]map[string]string{
|
||||||
|
8200: {"mp0": "/mnt/felhom-usb/felhom-data,mp=/mnt/felhom-usb"},
|
||||||
|
})
|
||||||
|
w := do(t, h, "POST", "/disks/guest-attach", "A", `{"where":"/mnt/felhom-usb"}`)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("idempotent attach: got %d want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), `"already":true`) {
|
||||||
|
t.Fatalf("expected already:true: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
if ga.count() != 0 {
|
||||||
|
t.Fatalf("AttachBind must NOT be called for an already-bound drive: %+v", ga.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A hostile/invalid where is refused with no binder call.
|
||||||
|
func TestGuestAttach_RejectsBadPath(t *testing.T) {
|
||||||
|
ga := &fakeGuestAttacher{}
|
||||||
|
h := newAttachServer(t, ga, map[int]map[string]string{8200: {}})
|
||||||
|
for _, bad := range []string{`{"where":"/mnt/../etc"}`, `{"where":"/etc/passwd"}`, `{"where":"/mnt/a/b"}`, `{"where":""}`} {
|
||||||
|
if w := do(t, h, "POST", "/disks/guest-attach", "A", bad); w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("bad where %s: got %d want 400", bad, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ga.count() != 0 {
|
||||||
|
t.Fatal("binder called for an invalid path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not configured (no GuestAttach dep) → 503.
|
||||||
|
func TestGuestAttach_NotConfigured(t *testing.T) {
|
||||||
|
h := newAttachServer(t, nil, map[int]map[string]string{8200: {}})
|
||||||
|
if w := do(t, h, "POST", "/disks/guest-attach", "A", `{"where":"/mnt/felhom-usb"}`); w.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("unconfigured: got %d want 503", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- auth / config ----------------------------------------------------------------------
|
// ---- auth / config ----------------------------------------------------------------------
|
||||||
|
|
||||||
func TestDisks_CrossGuest403(t *testing.T) {
|
func TestDisks_CrossGuest403(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package localapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Guest data-drive passthrough (slice 10 P2, Model A). An enrolled external user-data drive is
|
||||||
|
// mounted on the HOST at /mnt/<name>; this binds its felhom-data NAMESPACE into the guest so the
|
||||||
|
// in-guest controller + apps can use it. Confinement is the inner (host→guest) bind: only
|
||||||
|
// <drive>/felhom-data crosses into the guest — the customer's other data on the drive never does.
|
||||||
|
//
|
||||||
|
// Model A: the felhom-data dir is bound AT the guest's /mnt/<name> (so the guest's /mnt/<name> IS the
|
||||||
|
// felhom-data namespace; `findmnt` shows /dev/sdXN[/felhom-data], which the controller's mount strip
|
||||||
|
// already handles). The bind is RW (NOT ro=1 like the bootstrap mount). The namespace is chowned to
|
||||||
|
// the unprivileged-LXC base so the guest reads it as root-owned (per-app subdirs are chowned to the
|
||||||
|
// app's mapped UID at deploy — NOT here). Spike-proven on 9201 (see usb-passthrough-spike memory).
|
||||||
|
|
||||||
|
// guestMappedRoot is the unprivileged-LXC idmap base — guest root (UID 0) == host UID 100000. chowning
|
||||||
|
// the namespace to this makes the guest see it as root:root, writable by the in-guest controller.
|
||||||
|
const guestMappedRoot = "100000:100000"
|
||||||
|
|
||||||
|
// felhomDataNS is the Felhom-managed namespace directory created on every external data drive. Only
|
||||||
|
// this subtree is exposed to the guest (matches the controller's appbackup.FelhomDataDir).
|
||||||
|
const felhomDataNS = "felhom-data"
|
||||||
|
|
||||||
|
// GuestBinder attaches a host data-drive's felhom-data namespace into a guest as an RW bind mount via
|
||||||
|
// `pct set` (a root@pam op — same fenced Runner the provision back-half uses for its bind). It does
|
||||||
|
// NOT make HTTP calls; the slot selection + idempotency live in the handler (which has the guest
|
||||||
|
// config). Satisfies localapi.GuestAttacher.
|
||||||
|
type GuestBinder struct {
|
||||||
|
runner proxmox.Runner
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGuestBinder builds a binder over the given root-CLI runner.
|
||||||
|
func NewGuestBinder(r proxmox.Runner, logger *slog.Logger) *GuestBinder {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
return &GuestBinder{runner: r, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttachBind creates + chowns <where>/felhom-data on the host and binds it into the guest at <where>
|
||||||
|
// (Model A). mountKey is the chosen guest slot ("mp3"). Idempotency + slot choice are the caller's
|
||||||
|
// (it reads the guest config); this performs the host-root steps only.
|
||||||
|
func (b *GuestBinder) AttachBind(ctx context.Context, vmid int, mountKey, where string) error {
|
||||||
|
src := where + "/" + felhomDataNS // host source = the felhom-data namespace on the drive
|
||||||
|
// 1. Ensure the namespace dir exists (idempotent; created fresh + uniformly owned, so the drive's
|
||||||
|
// pre-existing mixed-ownership customer data is never touched).
|
||||||
|
if err := b.run(ctx, "mkdir", "-p", src); err != nil {
|
||||||
|
return fmt.Errorf("guest-attach: create namespace %s: %w", src, err)
|
||||||
|
}
|
||||||
|
// 2. chown the namespace ROOT to the guest base (NOT -R: per-app subdirs are chowned at deploy).
|
||||||
|
if err := b.run(ctx, "chown", guestMappedRoot, src); err != nil {
|
||||||
|
return fmt.Errorf("guest-attach: chown namespace %s: %w", src, err)
|
||||||
|
}
|
||||||
|
// 3. Bind it into the guest at `where`, RW. Bind form (host path), NEVER storage:size (that volume
|
||||||
|
// form would create a fresh empty disk and lose the existing data).
|
||||||
|
spec := fmt.Sprintf("%s,mp=%s", src, where)
|
||||||
|
if err := b.run(ctx, "pct", "set", strconv.Itoa(vmid), "-"+mountKey, spec); err != nil {
|
||||||
|
return fmt.Errorf("guest-attach: pct set %s: %w", spec, err)
|
||||||
|
}
|
||||||
|
b.logger.Info("guest-attach: data drive bound into guest",
|
||||||
|
"vmid", vmid, "slot", mountKey, "source", src, "guest_path", where)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *GuestBinder) run(ctx context.Context, name string, args ...string) error {
|
||||||
|
_, stderr, err := b.runner.Run(ctx, name, args...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w: %s", name, err, string(stderr))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -81,6 +81,9 @@ type Options struct {
|
|||||||
Disks DiskOps
|
Disks DiskOps
|
||||||
DiskGate StorageGate
|
DiskGate StorageGate
|
||||||
Guests2 GuestLister
|
Guests2 GuestLister
|
||||||
|
// GuestAttach binds an enrolled user-data drive's felhom-data namespace into the guest (slice 10
|
||||||
|
// P2, Model A). OPTIONAL — when nil, POST /disks/guest-attach reports "not configured".
|
||||||
|
GuestAttach GuestAttacher
|
||||||
// HostReader is the root-free host topology reader used to classify a device/mount's protection
|
// HostReader is the root-free host topology reader used to classify a device/mount's protection
|
||||||
// ROLE (it backs SystemDisks for the eject role-gate + the /disks role hints). OPTIONAL — when nil
|
// ROLE (it backs SystemDisks for the eject role-gate + the /disks role hints). OPTIONAL — when nil
|
||||||
// it defaults to the production *storage.ProcHostReader. Injectable so the role-gate is testable.
|
// it defaults to the production *storage.ProcHostReader. Injectable so the role-gate is testable.
|
||||||
@@ -132,10 +135,11 @@ type Server struct {
|
|||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
|
|
||||||
disks DiskOps // slice 8C (optional)
|
disks DiskOps // slice 8C (optional)
|
||||||
diskGate StorageGate // slice 8C (optional)
|
diskGate StorageGate // slice 8C (optional)
|
||||||
guestList GuestLister // slice 8C (optional)
|
guestList GuestLister // slice 8C (optional)
|
||||||
host storage.HostReader // role classification source (optional; defaults to ProcHostReader)
|
guestAttach GuestAttacher // slice 10 P2 (optional)
|
||||||
|
host storage.HostReader // role classification source (optional; defaults to ProcHostReader)
|
||||||
|
|
||||||
hostMetrics HostMetricsProvider // slice 9 (optional)
|
hostMetrics HostMetricsProvider // slice 9 (optional)
|
||||||
hostID string // slice 10B: for the data-bearing-format pending-op hint
|
hostID string // slice 10B: for the data-bearing-format pending-op hint
|
||||||
@@ -175,6 +179,7 @@ func NewServer(o Options) (*Server, error) {
|
|||||||
disks: o.Disks,
|
disks: o.Disks,
|
||||||
diskGate: o.DiskGate,
|
diskGate: o.DiskGate,
|
||||||
guestList: o.Guests2,
|
guestList: o.Guests2,
|
||||||
|
guestAttach: o.GuestAttach,
|
||||||
host: o.HostReader,
|
host: o.HostReader,
|
||||||
hostMetrics: o.HostMetrics,
|
hostMetrics: o.HostMetrics,
|
||||||
hostID: o.HostID,
|
hostID: o.HostID,
|
||||||
@@ -200,6 +205,8 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("POST /disks/assign", s.withGuest(s.handleDiskAssign))
|
mux.HandleFunc("POST /disks/assign", s.withGuest(s.handleDiskAssign))
|
||||||
mux.HandleFunc("POST /disks/eject", s.withGuest(s.handleDiskEject))
|
mux.HandleFunc("POST /disks/eject", s.withGuest(s.handleDiskEject))
|
||||||
mux.HandleFunc("POST /disks/format", s.withGuest(s.handleDiskFormat))
|
mux.HandleFunc("POST /disks/format", s.withGuest(s.handleDiskFormat))
|
||||||
|
// Guest data-drive passthrough (slice 10 P2): bind an enrolled drive's felhom-data namespace in.
|
||||||
|
mux.HandleFunc("POST /disks/guest-attach", s.withGuest(s.handleDiskGuestAttach))
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user