Files
felhom-agent/internal/localapi/guestmemory.go
T
admin 88b3cf03dd gofmt: normalize internal/localapi (whitespace only)
Swept up by gofmt -w on the package while adding the guest-power observable. No
semantic change; 3 of 5 files are tests.
2026-07-28 11:15:45 +02:00

260 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package localapi
import (
"context"
"fmt"
"net/http"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
)
// Guest RAM resize (v0.90.0, R-24 controller-direct). The customer sees the guest's current memory
// on the controller's system-settings page and resizes it; the controller calls these self-scoped
// endpoints; the AGENT enforces every bound FRESH per request (the UI's numbers are decoration) and
// applies via the PVE API's SetConfig — a live cgroup apply, no reboot (Phase-0 proved it: maxmem
// moves with the guest running, /proc/meminfo ripples via lxcfs). Memory only; cores stay observation.
//
// Ruled bounds (Viktor, 2026-07-17):
// - min = minGuestMemoryMB (2048)
// - max = host_total hostReserveMB (2048 reserved for the host)
// - shrink floor = max(minGuestMemoryMB, current_usage + shrinkUsageMarginMB) — a customer wanting
// less must stop applications first (the refusal says so, in the controller's Hungarian).
//
// The floor gates SHRINK only; a grow is bounded by min/max alone.
const (
// minGuestMemoryMB is the ruled floor for any guest allocation (a Felhom box needs headroom).
minGuestMemoryMB int64 = 2048
// hostReserveMB is held back from the host total so a resize can never starve the hypervisor.
hostReserveMB int64 = 2048
// shrinkUsageMarginMB is the safety gap kept above live usage on a SHRINK (the "never below
// current usage" ruling, with headroom). One named constant — trivially re-ruled.
shrinkUsageMarginMB int64 = 512
// mib is one Proxmox "memory" unit (MiB) in bytes. PVE config `memory` is MiB; status/node
// memory fields are bytes — convert at this boundary (the §8 units trap).
mib int64 = 1 << 20
)
// MemoryOps is the guest-RAM-resize Proxmox surface (v0.90.0). Satisfied by *proxmox.Client. Kept
// separate from GuestAPI so adding it breaks no existing fake. Every method is invoked ONLY with the
// token-resolved VMID (never a caller-supplied id).
type MemoryOps interface {
GuestStatus(ctx context.Context, vmid int) (proxmox.Guest, error)
GuestConfig(ctx context.Context, vmid int) (proxmox.GuestConfig, error)
NodeStatus(ctx context.Context) (proxmox.NodeStatus, error)
SetConfig(ctx context.Context, vmid int, params map[string]string) (string, error)
WaitTask(ctx context.Context, upid string, opts proxmox.WaitOptions) (proxmox.TaskStatus, error)
}
// MemoryInfo is GET /guest/memory — every field in MB, computed agent-side (Scenario C). min/max/floor
// are the CURRENT enforced bounds, so a stale UI re-renders honestly on every read/refusal.
type MemoryInfo struct {
VMID int `json:"vmid"`
AllocatedMB int64 `json:"allocated_mb"`
UsageMB int64 `json:"usage_mb"`
HostTotalMB int64 `json:"host_total_mb"`
MinMB int64 `json:"min_mb"`
MaxMB int64 `json:"max_mb"`
FloorMB int64 `json:"floor_mb"`
Running bool `json:"running"`
}
// memoryResizeRequest is the POST /guest/memory body.
type memoryResizeRequest struct {
VMID int `json:"vmid,omitempty"` // optional; if set must equal the token's guest (self-scope)
MemoryMB int64 `json:"memory_mb"`
}
// memoryRefusal is the data field of a 412 refusal: the machine code the controller maps to Hungarian,
// plus the fresh bounds so the UI re-renders without a second round-trip.
type memoryRefusal struct {
Code string `json:"code"` // below_min | above_max | below_usage_floor
AllocatedMB int64 `json:"allocated_mb"`
UsageMB int64 `json:"usage_mb"`
HostTotalMB int64 `json:"host_total_mb"`
MinMB int64 `json:"min_mb"`
MaxMB int64 `json:"max_mb"`
FloorMB int64 `json:"floor_mb"`
}
// memoryResizeResult is the success data field.
type memoryResizeResult struct {
VMID int `json:"vmid"`
OldMB int64 `json:"old_mb"`
NewMB int64 `json:"new_mb"`
Unchanged bool `json:"unchanged"`
}
// memoryBounds is one fresh snapshot of everything the validation needs.
type memoryBounds struct {
allocatedMB int64
usageMB int64
hostTotalMB int64
minMB int64
maxMB int64
floorMB int64
running bool
}
func (b memoryBounds) refusal(code string) memoryRefusal {
return memoryRefusal{
Code: code, AllocatedMB: b.allocatedMB, UsageMB: b.usageMB, HostTotalMB: b.hostTotalMB,
MinMB: b.minMB, MaxMB: b.maxMB, FloorMB: b.floorMB,
}
}
func (b memoryBounds) info(vmid int) MemoryInfo {
return MemoryInfo{
VMID: vmid, AllocatedMB: b.allocatedMB, UsageMB: b.usageMB, HostTotalMB: b.hostTotalMB,
MinMB: b.minMB, MaxMB: b.maxMB, FloorMB: b.floorMB, Running: b.running,
}
}
// bytesToMBUp converts bytes → MB rounding UP (usage must never be under-reported for the floor).
func bytesToMBUp(b int64) int64 {
if b <= 0 {
return 0
}
return (b + mib - 1) / mib
}
// readMemoryBounds computes the enforced bounds from a FRESH read of guest config/status + host total.
// Called by BOTH the GET and the POST so a stale UI can never smuggle an old max/floor.
func (s *Server) readMemoryBounds(ctx context.Context, vmid int) (memoryBounds, error) {
cfg, err := s.mem.GuestConfig(ctx, vmid)
if err != nil {
return memoryBounds{}, fmt.Errorf("guest config: %w", err)
}
st, err := s.mem.GuestStatus(ctx, vmid)
if err != nil {
return memoryBounds{}, fmt.Errorf("guest status: %w", err)
}
node, err := s.mem.NodeStatus(ctx)
if err != nil {
return memoryBounds{}, fmt.Errorf("node status: %w", err)
}
b := memoryBounds{
allocatedMB: cfg.Memory, // PVE config memory is already MB
usageMB: bytesToMBUp(st.Mem), // bytes → MB, rounded up
hostTotalMB: node.Memory.Total / mib, // bytes → MB, floor (conservative for max)
minMB: minGuestMemoryMB,
running: st.Status == "running",
}
b.maxMB = b.hostTotalMB - hostReserveMB
b.floorMB = b.usageMB + shrinkUsageMarginMB
if b.floorMB < minGuestMemoryMB {
b.floorMB = minGuestMemoryMB
}
return b, nil
}
// handleGuestMemory serves GET /guest/memory — the current allocation, live usage, and the enforced
// bounds, all agent-computed (Scenario C).
func (s *Server) handleGuestMemory(w http.ResponseWriter, r *http.Request, vmid int) {
if s.mem == nil {
writeErr(w, http.StatusServiceUnavailable, "guest memory resize not configured on this host")
return
}
b, err := s.readMemoryBounds(r.Context(), vmid)
if err != nil {
s.logger.Error("local-api: guest-memory read", "vmid", vmid, "err", err)
writeErr(w, http.StatusBadGateway, "could not read guest memory: "+err.Error())
return
}
writeOK(w, b.info(vmid))
}
// handleGuestMemoryResize serves POST /guest/memory — validate FRESH against the ruled bounds (the UI's
// numbers are decoration), then apply via SetConfig (live cgroup apply) and verify the new maxmem before
// claiming success. Single-flight (one customer per host). SetConfig is NEVER called on a refusal path.
func (s *Server) handleGuestMemoryResize(w http.ResponseWriter, r *http.Request, vmid int) {
if s.mem == nil {
writeErr(w, http.StatusServiceUnavailable, "guest memory resize not configured on this host")
return
}
var req memoryResizeRequest
if !decodeBody(w, r, &req) {
return
}
if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
return
}
if req.MemoryMB <= 0 {
writeErr(w, http.StatusBadRequest, "memory_mb must be a positive integer")
return
}
// Single-flight: one customer per host; last-write-wins at PVE is not a UX we want.
s.memMu.Lock()
defer s.memMu.Unlock()
b, err := s.readMemoryBounds(r.Context(), vmid)
if err != nil {
s.logger.Error("local-api: guest-memory resize precheck", "vmid", vmid, "err", err)
writeErr(w, http.StatusBadGateway, "could not read guest memory: "+err.Error())
return
}
target := req.MemoryMB
// No-op: target == current allocation → success, no SetConfig call.
if target == b.allocatedMB {
s.logger.Info("local-api: guest-memory resize no-op (unchanged)", "vmid", vmid, "memory_mb", target)
writeOK(w, memoryResizeResult{VMID: vmid, OldMB: b.allocatedMB, NewMB: b.allocatedMB, Unchanged: true})
return
}
// Ruled refusals — SetConfig MUST NOT run on any of these.
switch {
case target < b.minMB:
s.logger.Warn("local-api: guest-memory resize refused (below_min)", "vmid", vmid, "target_mb", target, "min_mb", b.minMB)
writeStatus(w, http.StatusPreconditionFailed, false, b.refusal("below_min"),
fmt.Sprintf("requested %d MB is below the minimum %d MB", target, b.minMB))
return
case target > b.maxMB:
s.logger.Warn("local-api: guest-memory resize refused (above_max)", "vmid", vmid, "target_mb", target, "max_mb", b.maxMB)
writeStatus(w, http.StatusPreconditionFailed, false, b.refusal("above_max"),
fmt.Sprintf("requested %d MB exceeds the maximum %d MB (host reserve %d MB)", target, b.maxMB, hostReserveMB))
return
case target < b.allocatedMB && target < b.floorMB:
// SHRINK below the usage floor — the customer must stop applications first.
s.logger.Warn("local-api: guest-memory resize refused (below_usage_floor)",
"vmid", vmid, "target_mb", target, "usage_mb", b.usageMB, "floor_mb", b.floorMB)
writeStatus(w, http.StatusPreconditionFailed, false, b.refusal("below_usage_floor"),
fmt.Sprintf("requested %d MB is too close to current usage %d MB (floor %d MB) — stop applications first", target, b.usageMB, b.floorMB))
return
}
// Apply. SetConfig may be synchronous (empty UPID) or return a task to wait on.
upid, err := s.mem.SetConfig(r.Context(), vmid, map[string]string{"memory": fmt.Sprintf("%d", target)})
if err != nil {
s.logger.Error("local-api: guest-memory SetConfig", "vmid", vmid, "target_mb", target, "err", err)
writeErr(w, http.StatusBadGateway, "memory resize failed: "+err.Error())
return
}
if upid != "" {
if _, err := s.mem.WaitTask(r.Context(), upid, proxmox.WaitOptions{}); err != nil {
s.logger.Error("local-api: guest-memory WaitTask", "vmid", vmid, "upid", upid, "err", err)
writeErr(w, http.StatusBadGateway, "memory resize task failed: "+err.Error())
return
}
}
// Verify: re-read and confirm maxmem reflects the target before claiming success (never trust the
// POST 200 — the config could have been rejected at task execution, or applied as pending).
st, err := s.mem.GuestStatus(r.Context(), vmid)
if err != nil {
s.logger.Error("local-api: guest-memory post-apply status", "vmid", vmid, "err", err)
writeErr(w, http.StatusBadGateway, "resize applied but verification read failed: "+err.Error())
return
}
if st.MaxMem != target*mib {
s.logger.Error("local-api: guest-memory resize NOT reflected after apply",
"vmid", vmid, "target_mb", target, "observed_maxmem_bytes", st.MaxMem)
writeErr(w, http.StatusBadGateway,
fmt.Sprintf("resize did not take effect (guest still reports %d MB) — a reboot may be required", st.MaxMem/mib))
return
}
s.logger.Info("local-api: guest-memory resized", "vmid", vmid, "old_mb", b.allocatedMB, "new_mb", target)
writeOK(w, memoryResizeResult{VMID: vmid, OldMB: b.allocatedMB, NewMB: target})
}