From bb1692cbfb0f9b55443e65d9d257d2635fcc60de Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 12 Jun 2026 17:19:12 +0200 Subject: [PATCH] =?UTF-8?q?agent=20v0.26.0:=20slice=2010=20P2=20activation?= =?UTF-8?q?=20=E2=80=94=20POST=20/guest/reboot=20(user-triggered)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 13 ++++++++++ cmd/felhom-agent/main.go | 2 +- internal/localapi/disks.go | 44 ++++++++++++++++++++++++++++++++- internal/localapi/disks_test.go | 40 ++++++++++++++++++++++++++++++ internal/localapi/guestbind.go | 13 ++++++++++ internal/localapi/server.go | 2 ++ 6 files changed, 112 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d0b844..295ebdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to **felhom-agent** are recorded here. Update on every code 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//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 + ` **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) External user-data drives are mounted on the HOST but were never passed INTO the guest (diagnosed diff --git a/cmd/felhom-agent/main.go b/cmd/felhom-agent/main.go index 80c34d7..9dff6c9 100644 --- a/cmd/felhom-agent/main.go +++ b/cmd/felhom-agent/main.go @@ -43,7 +43,7 @@ import ( // version is the agent version. Overridable at build time with // -ldflags "-X main.version="; defaults to the in-repo CHANGELOG version. -var version = "0.25.0" +var version = "0.26.0" func main() { var ( diff --git a/internal/localapi/disks.go b/internal/localapi/disks.go index c898aa5..9ebb44c 100644 --- a/internal/localapi/disks.go +++ b/internal/localapi/disks.go @@ -5,6 +5,7 @@ import ( "net/http" "strconv" "strings" + "time" "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" "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 -// 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 { AttachBind(ctx context.Context, vmid int, mountKey, where string) error + RebootGuest(ctx context.Context, vmid int) error } // ---- 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}) } +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/ 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 { diff --git a/internal/localapi/disks_test.go b/internal/localapi/disks_test.go index 889817c..dd3be38 100644 --- a/internal/localapi/disks_test.go +++ b/internal/localapi/disks_test.go @@ -9,6 +9,7 @@ import ( "strings" "sync" "testing" + "time" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" @@ -380,6 +381,7 @@ type fakeGuestAttacher struct { vmid int slot, where string } + reboots []int } 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) 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 { t.Helper() 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 ---------------------------------------------------------------------- func TestDisks_CrossGuest403(t *testing.T) { diff --git a/internal/localapi/guestbind.go b/internal/localapi/guestbind.go index 411bb43..f0e8f63 100644 --- a/internal/localapi/guestbind.go +++ b/internal/localapi/guestbind.go @@ -70,6 +70,19 @@ func (b *GuestBinder) AttachBind(ctx context.Context, vmid int, mountKey, where 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 { _, stderr, err := b.runner.Run(ctx, name, args...) if err != nil { diff --git a/internal/localapi/server.go b/internal/localapi/server.go index 421a698..2cc5406 100644 --- a/internal/localapi/server.go +++ b/internal/localapi/server.go @@ -207,6 +207,8 @@ func (s *Server) Handler() http.Handler { 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)) + // 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 }