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,7 @@ package localapi
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
||||
@@ -60,6 +61,12 @@ type GuestLister interface {
|
||||
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 ---------------------------------------------------------------------------
|
||||
|
||||
// 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})
|
||||
}
|
||||
|
||||
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 {
|
||||
VMID int `json:"vmid"`
|
||||
Device string `json:"device"`
|
||||
|
||||
Reference in New Issue
Block a user