agent v0.26.0: slice 10 P2 activation — POST /guest/reboot (user-triggered)

Self-scoped guest reboot (pct reboot, detached, 202) so an enrolled-into-running-
guest drive's persisted bind activates at next boot. Tests: accepted + cross-guest
403. Pairs with controller v0.49.0 pending-drive detection + "Újraindítás most".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 17:19:12 +02:00
parent 7336a87514
commit bb1692cbfb
6 changed files with 112 additions and 2 deletions
+13
View File
@@ -3,6 +3,19 @@
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.26.0 — slice 10 P2 activation: guest-reboot endpoint (user-triggered drive activation) (2026-06-12)
A drive enrolled into a RUNNING unprivileged guest can't be live-activated (proven: `pct set` won't
hot-apply; `/proc/<pid>/root` bind → mount-locking refusal; `nsenter -m` loses the host source). So the
bind activates at the next guest boot. This adds the user-triggered restart path.
- **`POST /guest/reboot` (`internal/localapi`)** — self-scoped (vmid from token). Runs `pct reboot
<vmid>` **detached** (it blocks ~30s until the guest is back) and returns **202** immediately, so the
calling controller gets a clean response before the reboot takes it down (the agent is host-side and
survives). `GuestBinder.RebootGuest` over the fenced runner. Tests: `TestGuestReboot_Accepted`
(202 + RebootGuest invoked for the token's vmid), `TestGuestReboot_CrossGuest403` (body vmid mismatch
refused, no reboot). Pairs with controller v0.49.0 (pending-activation detection + "Újraindítás most").
## v0.25.0 — slice 10 P2: bind enrolled user-data drives into the guest (passthrough) (2026-06-12) ## 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 External user-data drives are mounted on the HOST but were never passed INTO the guest (diagnosed
+1 -1
View File
@@ -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.25.0" var version = "0.26.0"
func main() { func main() {
var ( var (
+43 -1
View File
@@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
"gitea.dooplex.hu/admin/felhom-agent/internal/storage" "gitea.dooplex.hu/admin/felhom-agent/internal/storage"
@@ -62,9 +63,11 @@ type GuestLister interface {
} }
// GuestAttacher binds an enrolled user-data drive's felhom-data namespace into a guest as an RW bind // 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. // mount (slice 10 P2, Model A) and reboots the guest to activate persisted-but-inactive binds (the
// host-side live inject is blocked on unprivileged guests). Satisfied by *GuestBinder.
type GuestAttacher interface { type GuestAttacher interface {
AttachBind(ctx context.Context, vmid int, mountKey, where string) error AttachBind(ctx context.Context, vmid int, mountKey, where string) error
RebootGuest(ctx context.Context, vmid int) error
} }
// ---- handlers --------------------------------------------------------------------------- // ---- handlers ---------------------------------------------------------------------------
@@ -266,6 +269,45 @@ func (s *Server) handleDiskGuestAttach(w http.ResponseWriter, r *http.Request, v
writeOK(w, map[string]any{"vmid": vmid, "attached": where, "slot": slot}) writeOK(w, map[string]any{"vmid": vmid, "attached": where, "slot": slot})
} }
type guestRebootRequest struct {
VMID int `json:"vmid"`
}
// handleGuestReboot reboots THIS guest (self-scoped) to activate persisted-but-inactive mountpoint
// binds (slice 10 P2 activation). It runs the reboot DETACHED and returns 202 immediately, so the
// calling controller gets a clean response before the reboot takes it (and the agent — host-side —
// survives the guest reboot). User-triggered ("Újraindítás most"); the controller batches all pending
// drives into one restart.
func (s *Server) handleGuestReboot(w http.ResponseWriter, r *http.Request, vmid int) {
if s.guestAttach == nil {
writeErr(w, http.StatusServiceUnavailable, "guest passthrough not configured on this host")
return
}
if r.ContentLength != 0 {
var req guestRebootRequest
if !decodeBody(w, r, &req) {
return
}
if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
return
}
}
base := s.baseCtx
if base == nil {
base = context.Background()
}
go func() {
// Detached: pct reboot blocks ~30s until the guest is back; don't tie it to the request ctx.
rebootCtx, cancel := context.WithTimeout(base, 5*time.Minute)
defer cancel()
if err := s.guestAttach.RebootGuest(rebootCtx, vmid); err != nil {
s.logger.Error("local-api: guest-reboot failed", "vmid", vmid, "err", err)
}
}()
s.logger.Warn("local-api: guest reboot requested (activating pending drive binds)", "vmid", vmid)
writeStatus(w, http.StatusAccepted, true, map[string]any{"vmid": vmid, "rebooting": true}, "")
}
// validGuestMountPath accepts an absolute /mnt/<name> path with no traversal (the enroll convention // 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. // root). Mirrors the controller's mount-name discipline so a hostile `where` can't escape /mnt.
func validGuestMountPath(p string) bool { func validGuestMountPath(p string) bool {
+40
View File
@@ -9,6 +9,7 @@ import (
"strings" "strings"
"sync" "sync"
"testing" "testing"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub" "gitea.dooplex.hu/admin/felhom-agent/internal/hub"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
@@ -380,6 +381,7 @@ type fakeGuestAttacher struct {
vmid int vmid int
slot, where string slot, where string
} }
reboots []int
} }
func (f *fakeGuestAttacher) AttachBind(_ context.Context, vmid int, mountKey, where string) error { func (f *fakeGuestAttacher) AttachBind(_ context.Context, vmid int, mountKey, where string) error {
@@ -393,6 +395,14 @@ func (f *fakeGuestAttacher) AttachBind(_ context.Context, vmid int, mountKey, wh
} }
func (f *fakeGuestAttacher) count() int { f.mu.Lock(); defer f.mu.Unlock(); return len(f.calls) } func (f *fakeGuestAttacher) count() int { f.mu.Lock(); defer f.mu.Unlock(); return len(f.calls) }
func (f *fakeGuestAttacher) RebootGuest(_ context.Context, vmid int) error {
f.mu.Lock()
defer f.mu.Unlock()
f.reboots = append(f.reboots, vmid)
return nil
}
func (f *fakeGuestAttacher) rebootCount() int { f.mu.Lock(); defer f.mu.Unlock(); return len(f.reboots) }
func newAttachServer(t *testing.T, ga GuestAttacher, mounts map[int]map[string]string) http.Handler { func newAttachServer(t *testing.T, ga GuestAttacher, mounts map[int]map[string]string) http.Handler {
t.Helper() t.Helper()
srv, err := NewServer(Options{ srv, err := NewServer(Options{
@@ -462,6 +472,36 @@ func TestGuestAttach_NotConfigured(t *testing.T) {
} }
} }
// Guest reboot (activation) returns 202 and triggers RebootGuest for the token's vmid (detached).
func TestGuestReboot_Accepted(t *testing.T) {
ga := &fakeGuestAttacher{}
h := newAttachServer(t, ga, map[int]map[string]string{8200: {}})
w := do(t, h, "POST", "/guest/reboot", "A", "")
if w.Code != http.StatusAccepted {
t.Fatalf("reboot: got %d want 202 (%s)", w.Code, w.Body.String())
}
// The reboot runs in a goroutine; give it a moment to record the call.
for i := 0; i < 100 && ga.rebootCount() == 0; i++ {
time.Sleep(time.Millisecond)
}
if ga.rebootCount() != 1 || ga.reboots[0] != 8200 {
t.Fatalf("RebootGuest not invoked for vmid 8200: %+v", ga.reboots)
}
}
// A cross-guest reboot (body vmid != token's) is refused 403, no reboot.
func TestGuestReboot_CrossGuest403(t *testing.T) {
ga := &fakeGuestAttacher{}
h := newAttachServer(t, ga, map[int]map[string]string{8200: {}})
if w := do(t, h, "POST", "/guest/reboot", "A", `{"vmid":9300}`); w.Code != http.StatusForbidden {
t.Fatalf("cross-guest reboot: got %d want 403", w.Code)
}
time.Sleep(5 * time.Millisecond)
if ga.rebootCount() != 0 {
t.Fatal("reboot triggered for a cross-guest request")
}
}
// ---- auth / config ---------------------------------------------------------------------- // ---- auth / config ----------------------------------------------------------------------
func TestDisks_CrossGuest403(t *testing.T) { func TestDisks_CrossGuest403(t *testing.T) {
+13
View File
@@ -70,6 +70,19 @@ func (b *GuestBinder) AttachBind(ctx context.Context, vmid int, mountKey, where
return nil return nil
} }
// RebootGuest reboots the guest (graceful shutdown + start) so persisted-but-inactive mountpoint
// binds activate (slice 10 P2: the host-side live inject is blocked on an unprivileged guest, so a
// drive enrolled into a RUNNING guest activates only at the next boot — this is the user-triggered
// "Újraindítás most" path). `pct reboot` blocks until the guest is back, so callers run it detached.
func (b *GuestBinder) RebootGuest(ctx context.Context, vmid int) error {
b.logger.Warn("guest-reboot: rebooting guest to activate pending mountpoint binds", "vmid", vmid)
if err := b.run(ctx, "pct", "reboot", strconv.Itoa(vmid)); err != nil {
return fmt.Errorf("guest-reboot: pct reboot %d: %w", vmid, err)
}
b.logger.Info("guest-reboot: guest back up", "vmid", vmid)
return nil
}
func (b *GuestBinder) run(ctx context.Context, name string, args ...string) error { func (b *GuestBinder) run(ctx context.Context, name string, args ...string) error {
_, stderr, err := b.runner.Run(ctx, name, args...) _, stderr, err := b.runner.Run(ctx, name, args...)
if err != nil { if err != nil {
+2
View File
@@ -207,6 +207,8 @@ func (s *Server) Handler() http.Handler {
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. // 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)) mux.HandleFunc("POST /disks/guest-attach", s.withGuest(s.handleDiskGuestAttach))
// Guest reboot (slice 10 P2 activation): user-triggered restart to activate pending drive binds.
mux.HandleFunc("POST /guest/reboot", s.withGuest(s.handleGuestReboot))
return mux return mux
} }