v0.90.0 — guest RAM resize (R-24) + fast-tick-until-convergence (R-28)

MinAgent coupling: felhom-controller v0.143.0 gates its guest-memory-resize UI on
this agent (FeatureGuestMemoryResize, MinAgent 0.90.0).

R-24 guest RAM resize (internal/localapi/guestmemory.go): self-scoped GET/POST
/guest/memory. Agent enforces every bound FRESH per request (min 2048, max
host_total-2048, shrink floor max(2048, usage+512)); applies via PVE SetConfig —
live cgroup apply, no reboot (Phase-0 proven on the nested demo box). Verify-after-apply
re-reads maxmem before claiming success. New narrow MemoryOps seam (GuestAPI untouched);
Options.Memory nil -> 503. Memory only.

R-28 fast-tick (internal/fasttick): while any desired-state item is unapplied -
including the pre-tunnel window a hub poke can't reach - pulse the shared out-of-band
trigger every 30s, self-disarm on convergence. Four cached sources (desired-gen==0,
reconcile Planned-Pending>0, pbsdr waiting_secret only, wgtunnel desired-not-operational);
LOUD pbsdr states + pending_signature excluded. Seams: reconcile.Engine.LastResult() +
wgtunnel.Manager.TunnelConvergence() (cached, no per-tick exec).

Guests-0/0: hypothesis REFUTED live (9201 IS a pool member; 0/0 was the pre-provision
window; PoolAddVMID re-assert already covers restore-over-existing). No code change; the
fast-tick mitigates the window.

Tests + red-proofs (i floor guard, ii max guard, iii always-pulse) all restored green.
This commit is contained in:
2026-07-17 19:09:40 +02:00
parent 9127f547f9
commit ac112c956e
10 changed files with 958 additions and 1 deletions
+42 -1
View File
@@ -33,6 +33,7 @@ import (
"gitea.dooplex.hu/admin/felhom-agent/internal/desired"
"gitea.dooplex.hu/admin/felhom-agent/internal/dr"
"gitea.dooplex.hu/admin/felhom-agent/internal/escrow"
"gitea.dooplex.hu/admin/felhom-agent/internal/fasttick"
"gitea.dooplex.hu/admin/felhom-agent/internal/felhomsshd"
"gitea.dooplex.hu/admin/felhom-agent/internal/guesthook"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
@@ -787,6 +788,7 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int
// registration, no report stanza.
wgServers := 0
var wgLoop *wgtunnel.Loop
var wgMgr *wgtunnel.Manager // hoisted for the fast-tick convergence source (nil when tunnel disabled)
var pokeListener *poke.Listener
{
wt := cfg.WGTunnel.WithDefaults()
@@ -796,7 +798,7 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int
wtMode = proxmox.RunnerSudo
}
wtRunner := &proxmox.ExecRunner{Mode: wtMode, SudoPath: cfg.Privileged.SudoPath}
wgMgr := wgtunnel.NewManager(wtRunner, client, wt.StateDir, logger)
wgMgr = wgtunnel.NewManager(wtRunner, client, wt.StateDir, logger)
wgMgr.SetStaleAfter(time.Duration(wt.StaleAfterSeconds) * time.Second)
wgLoop = wgtunnel.NewLoop(wgMgr, time.Duration(wt.IntervalSeconds)*time.Second, logger)
desiredSyncer.AddConsumer(wgLoop) // raw desired-state → the wireguard block
@@ -883,6 +885,44 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int
if pokeListener != nil {
go func() { errc <- pokeListener.Run(ctx) }() // agent-plane immediate-sync listener (v0.89.0)
}
// Fast-tick (v0.90.0, R-28): the agent-plane immediacy SECONDARY. While ANY desired-state item is
// unapplied — most importantly the pre-tunnel window a hub poke cannot reach — pulse the SAME
// out-of-band trigger the watchdog/poke use, every 30 s, and self-disarm the instant everything
// converges. Every source is a cached read (no exec/network per tick). The LOUD pbsdr states
// (consumed_failed/verify_failed) and the destructive pending_signature drift are deliberately
// EXCLUDED (§8) so a stuck-loud box never hammers.
fastTick := fasttick.New(storageTrigger, fasttick.DefaultInterval, logger,
fasttick.SourceFunc(func() (bool, string) {
if desiredProvider.Generation() == 0 {
return true, "desired-state not yet fetched"
}
return false, ""
}),
fasttick.SourceFunc(func() (bool, string) {
if res, ok := engine.LastResult(); ok && res.Planned-res.Pending > 0 {
return true, "reconcile drift not yet applied"
}
return false, ""
}),
fasttick.SourceFunc(func() (bool, string) {
if pbsdrLoop != nil {
if st := pbsdrLoop.PBSDRStatus(ctx); st != nil && st.State == "waiting_secret" {
return true, "pbsdr waiting for its consume-once secret"
}
}
return false, ""
}),
fasttick.SourceFunc(func() (bool, string) {
if wgMgr != nil {
if desired, operational := wgMgr.TunnelConvergence(); desired && !operational {
return true, "wireguard tunnel not yet operational"
}
}
return false, ""
}),
)
go func() { errc <- fastTick.Run(ctx) }()
if localSrv != nil {
localServers = 1
// Host-reboot remount fix: BEFORE binding into the guest, re-assert enrolled drive MOUNTS on the
@@ -1178,6 +1218,7 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St
DiskGate: storageGateAdapter{gate: gate, hostID: cfg.Hub.HostID},
Guests2: px,
GuestAttach: guestBinder, // slice 10 P2: bind enrolled data drives into the guest
Memory: px, // v0.90.0 R-24: guest RAM resize (SetConfig live cgroup apply)
// Network storage (NAS) — Part A1: the privileged host network-mount surface (NFS/SMB automount).
NetStorage: hostOps,
SmbCredsDir: cfg.Privileged.SmbCredsDir,