v0.143.0 — guest RAM resize UI (R-24) — MinAgent: 0.90.0
The customer sees the guest's current memory + allowed range on the Rendszer settings page and resizes it. The controller proxies + maps the agent's machine code to Hungarian; the agent (felhom-agent v0.90.0) enforces every bound and applies live (no reboot). agentapi: GuestMemory + ResizeMemory; ruled 412 -> *MemoryRefusedError (code+bounds); pre-0.90 agent 404 -> typed *StatusError. Capability: FeatureGuestMemoryResize + featureMinAgent 0.90.0 + a featureProbes row (type-asserts GuestMemory so the shared SupportProber/netAgent are untouched). UI (internal/web/system_memory_handlers.go, settings_system.html): "Szerver memoria (RAM)" card + number input; POST /api/system/memory/resize -> capability gate -> agent -> flash. JS confirm only on shrink. Code->Hungarian map; agent-outdated hides the control; agent-unreachable falls back to the guest's /proc/meminfo. Agent English never shown raw. Tests: agentapi (decode, 404, refusal-code, capability table) + web handler (success/ below_usage_floor/agent_outdated). Gates + go build/vet/test all pass.
This commit is contained in:
@@ -454,6 +454,101 @@ func (c *Client) GuestReboot(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- v0.143.0: guest RAM resize (R-24, agent ≥ 0.90.0) ------------------------------------
|
||||
|
||||
// GuestMemoryInfo mirrors the agent's GET /guest/memory (every field MB, agent-computed). The
|
||||
// min/max/floor are the CURRENTLY-enforced bounds — the UI renders them but the agent re-checks fresh.
|
||||
type GuestMemoryInfo 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"`
|
||||
}
|
||||
|
||||
// MemoryResizeResult mirrors the agent's POST /guest/memory success body.
|
||||
type MemoryResizeResult struct {
|
||||
VMID int `json:"vmid"`
|
||||
OldMB int64 `json:"old_mb"`
|
||||
NewMB int64 `json:"new_mb"`
|
||||
Unchanged bool `json:"unchanged"`
|
||||
}
|
||||
|
||||
// MemoryRefusedError carries the agent's machine refusal code (below_min | above_max |
|
||||
// below_usage_floor) plus the fresh bounds, so the web layer maps it to a Hungarian message and
|
||||
// re-renders the range honestly — the agent's English message is never shown raw.
|
||||
type MemoryRefusedError struct {
|
||||
Code string
|
||||
Bounds GuestMemoryInfo
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (e *MemoryRefusedError) Error() string {
|
||||
return "agentapi: memory resize refused (" + e.Code + "): " + e.Msg
|
||||
}
|
||||
|
||||
// GuestMemory reads the guest's current allocation, live usage, and the enforced bounds. A pre-0.90
|
||||
// agent has no such route → the get helper returns *StatusError{404} (the capability probe signal
|
||||
// and the UI's "needs an update" path).
|
||||
func (c *Client) GuestMemory(ctx context.Context) (GuestMemoryInfo, error) {
|
||||
var out GuestMemoryInfo
|
||||
data, err := c.get(ctx, "/guest/memory")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return out, fmt.Errorf("agentapi: decode /guest/memory: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ResizeMemory requests a bounded resize. The agent enforces every bound; a ruled refusal (412)
|
||||
// returns *MemoryRefusedError carrying the code + fresh bounds; a non-coded failure (e.g. the 502
|
||||
// verify-after-apply) returns a plain error; success returns old→new.
|
||||
func (c *Client) ResizeMemory(ctx context.Context, memoryMB int64) (MemoryResizeResult, error) {
|
||||
var out MemoryResizeResult
|
||||
env, status, err := c.postWithStatus(ctx, "/guest/memory", map[string]int64{"memory_mb": memoryMB})
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if status == http.StatusOK && env.OK {
|
||||
if len(env.Data) > 0 {
|
||||
_ = json.Unmarshal(env.Data, &out)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
// Refusal — the data carries {code, ...fresh bounds}.
|
||||
var ref struct {
|
||||
Code string `json:"code"`
|
||||
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"`
|
||||
}
|
||||
if len(env.Data) > 0 {
|
||||
_ = json.Unmarshal(env.Data, &ref)
|
||||
}
|
||||
if ref.Code != "" {
|
||||
return out, &MemoryRefusedError{
|
||||
Code: ref.Code,
|
||||
Bounds: GuestMemoryInfo{
|
||||
AllocatedMB: ref.AllocatedMB, UsageMB: ref.UsageMB, HostTotalMB: ref.HostTotalMB,
|
||||
MinMB: ref.MinMB, MaxMB: ref.MaxMB, FloorMB: ref.FloorMB,
|
||||
},
|
||||
Msg: truncateErr(env.Error, 300),
|
||||
}
|
||||
}
|
||||
if rerr := refusalError("/guest/memory", status, env); rerr != nil {
|
||||
return out, rerr
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SwapResult mirrors the agent's 202 from POST /controller/swap (agentic controller update, Phase 1).
|
||||
type SwapResult struct {
|
||||
Status string `json:"status"` // "swapping"
|
||||
|
||||
@@ -25,6 +25,10 @@ type Feature string
|
||||
// capability signal.
|
||||
const FeatureNetstorageVerify Feature = "netstorage_verify"
|
||||
|
||||
// FeatureGuestMemoryResize is the guest RAM resize (agent v0.90.0, R-24): the resize endpoints
|
||||
// (GET/POST /guest/memory) shipped together, so GET /guest/memory IS the capability signal.
|
||||
const FeatureGuestMemoryResize Feature = "guest_memory_resize"
|
||||
|
||||
// SupportState is a probe verdict. The zero value is SupportUnknown (fail-open: unknown never
|
||||
// refuses — the existing agent-error paths speak honestly when the agent is down).
|
||||
type SupportState int
|
||||
@@ -67,14 +71,34 @@ var featureProbes = map[Feature]func(ctx context.Context, p SupportProber) error
|
||||
_, err := p.NetVerifyStatus(ctx)
|
||||
return err
|
||||
},
|
||||
// The memory-resize prober needs GET /guest/memory, not NetVerifyStatus. Rather than couple the
|
||||
// shared SupportProber (and every unrelated prober/fake) to the memory surface, the probe
|
||||
// type-asserts the ONE method it needs — the memory feature is only ever probed with a
|
||||
// GuestMemory-capable prober (the web memAgent seam / *Client). A prober without it → a non-404
|
||||
// error → SupportUnknown (fail-open), never a false "supported".
|
||||
FeatureGuestMemoryResize: func(ctx context.Context, p SupportProber) error {
|
||||
gm, ok := p.(interface {
|
||||
GuestMemory(ctx context.Context) (GuestMemoryInfo, error)
|
||||
})
|
||||
if !ok {
|
||||
return errNoMemoryProbe
|
||||
}
|
||||
_, err := gm.GuestMemory(ctx)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
// errNoMemoryProbe classifies to SupportUnknown (not a *StatusError 404), so a prober that cannot be
|
||||
// asked never reads as "unsupported".
|
||||
var errNoMemoryProbe = errors.New("agentapi: prober does not support the guest-memory probe")
|
||||
|
||||
// featureMinAgent maps each coupled feature to the MINIMUM agent version that carries its coupled
|
||||
// semantics (the CHANGELOG `MinAgent:` header value). Used by Supports when the agent's version is
|
||||
// KNOWN (the v0.82.0 X-Felhom-Agent-Version channel) — a direct comparison, no probe traffic. A
|
||||
// feature missing here (or an unparseable table value) falls back to the probe.
|
||||
var featureMinAgent = map[Feature]string{
|
||||
FeatureNetstorageVerify: "0.81.0",
|
||||
FeatureNetstorageVerify: "0.81.0",
|
||||
FeatureGuestMemoryResize: "0.90.0",
|
||||
}
|
||||
|
||||
// AgentVersionReporter is optionally implemented by a SupportProber (*Client is one): it reports
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// memProber satisfies the SupportProber contract (NetVerifyStatus) PLUS the memory probe's
|
||||
// type-asserted GuestMemory + the AgentVersion fast-path — the shape the web memAgent seam has.
|
||||
type memProber struct {
|
||||
memErr error
|
||||
ver string
|
||||
}
|
||||
|
||||
func (p *memProber) NetVerifyStatus(context.Context) (NetVerifyStatus, error) {
|
||||
return NetVerifyStatus{Phase: "none"}, nil
|
||||
}
|
||||
func (p *memProber) GuestMemory(context.Context) (GuestMemoryInfo, error) {
|
||||
return GuestMemoryInfo{AllocatedMB: 8192}, p.memErr
|
||||
}
|
||||
func (p *memProber) AgentVersion() string { return p.ver }
|
||||
|
||||
// The capability table: v0.90.0 → Yes, v0.89.0 → No via the version fast-path; the probe (no
|
||||
// version) classifies 404 → No, nil → Yes.
|
||||
func TestGuestMemory_Capability(t *testing.T) {
|
||||
t.Run("version >= 0.90 → Yes", func(t *testing.T) {
|
||||
var sc SupportCache
|
||||
if got := sc.Supports(context.Background(), &memProber{ver: "0.90.0"}, FeatureGuestMemoryResize); got != SupportYes {
|
||||
t.Errorf("v0.90.0 = %v, want SupportYes", got)
|
||||
}
|
||||
})
|
||||
t.Run("version < 0.90 → No", func(t *testing.T) {
|
||||
var sc SupportCache
|
||||
if got := sc.Supports(context.Background(), &memProber{ver: "0.89.0"}, FeatureGuestMemoryResize); got != SupportNo {
|
||||
t.Errorf("v0.89.0 = %v, want SupportNo", got)
|
||||
}
|
||||
})
|
||||
t.Run("no version, probe 404 → No", func(t *testing.T) {
|
||||
var sc SupportCache
|
||||
p := &memProber{memErr: &StatusError{Path: "/guest/memory", Code: http.StatusNotFound}}
|
||||
if got := sc.Supports(context.Background(), p, FeatureGuestMemoryResize); got != SupportNo {
|
||||
t.Errorf("probe 404 = %v, want SupportNo", got)
|
||||
}
|
||||
})
|
||||
t.Run("no version, probe ok → Yes", func(t *testing.T) {
|
||||
var sc SupportCache
|
||||
if got := sc.Supports(context.Background(), &memProber{}, FeatureGuestMemoryResize); got != SupportYes {
|
||||
t.Errorf("probe ok = %v, want SupportYes", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// GuestMemory decodes the agent's payload; a 404 (pre-0.90) is the typed *StatusError.
|
||||
func TestClient_GuestMemory(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/guest/memory", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(405)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"allocated_mb":8192,"usage_mb":3000,"host_total_mb":16384,"min_mb":2048,"max_mb":14336,"floor_mb":3512,"running":true}}`))
|
||||
})
|
||||
c := newMemTestClient(t, mux)
|
||||
info, err := c.GuestMemory(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GuestMemory: %v", err)
|
||||
}
|
||||
if info.AllocatedMB != 8192 || info.UsageMB != 3000 || info.MaxMB != 14336 || info.FloorMB != 3512 || !info.Running {
|
||||
t.Errorf("decoded wrong: %+v", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GuestMemory_404(t *testing.T) {
|
||||
c := newMemTestClient(t, http.NewServeMux()) // no route → 404
|
||||
_, err := c.GuestMemory(context.Background())
|
||||
var se *StatusError
|
||||
if !errors.As(err, &se) || se.Code != http.StatusNotFound {
|
||||
t.Fatalf("pre-0.90 agent must 404 as *StatusError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ResizeMemory: success returns old→new; a 412 refusal surfaces *MemoryRefusedError with the code.
|
||||
func TestClient_ResizeMemory(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/guest/memory", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"old_mb":8192,"new_mb":12288,"unchanged":false}}`))
|
||||
})
|
||||
c := newMemTestClient(t, mux)
|
||||
res, err := c.ResizeMemory(context.Background(), 12288)
|
||||
if err != nil {
|
||||
t.Fatalf("ResizeMemory: %v", err)
|
||||
}
|
||||
if res.OldMB != 8192 || res.NewMB != 12288 {
|
||||
t.Errorf("result = %+v", res)
|
||||
}
|
||||
})
|
||||
t.Run("refusal carries the code", func(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/guest/memory", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusPreconditionFailed)
|
||||
_, _ = w.Write([]byte(`{"ok":false,"error":"requested 3300 MB is too close to current usage 3000 MB (floor 3512 MB)","data":{"code":"below_usage_floor","usage_mb":3000,"floor_mb":3512,"min_mb":2048,"max_mb":14336}}`))
|
||||
})
|
||||
c := newMemTestClient(t, mux)
|
||||
_, err := c.ResizeMemory(context.Background(), 3300)
|
||||
var refused *MemoryRefusedError
|
||||
if !errors.As(err, &refused) {
|
||||
t.Fatalf("want *MemoryRefusedError, got %T: %v", err, err)
|
||||
}
|
||||
if refused.Code != "below_usage_floor" || refused.Bounds.UsageMB != 3000 || refused.Bounds.FloorMB != 3512 {
|
||||
t.Errorf("refusal = %+v", refused)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func newMemTestClient(t *testing.T, mux *http.ServeMux) *Client {
|
||||
t.Helper()
|
||||
srv := httptest.NewTLSServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
fp := sha256.Sum256(srv.Certificate().Raw)
|
||||
c, err := New(strings.TrimPrefix(srv.URL, "https://"), "test-token", hex.EncodeToString(fp[:]))
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
Reference in New Issue
Block a user