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:
@@ -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/<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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user