agent (unreleased): fixes after an independent review — one lock for the writers of agent.json, the trust run serialised and bounded, the CA copy keeps its previous root, the profile stanza from the wrapper's answer, a stale give-up ends, both boot tokens from one read
gates / gates (push) Successful in 48s

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
admin committed 2026-11-14 19:09:21 +01:00
1 parent 61feb95c06
commit ec7e699fd7
18 files changed
+333 -59

No files matched your search

+3
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"gitea.dooplex.hu/admin/felhom-agent/internal/config"
"net/http"
"os"
"path/filepath"
@@ -148,6 +149,8 @@ func (s *Server) validateBackupTargetMount(ctx context.Context, where string) er
// agent-owned 0600, so the non-root agent cannot rename into that directory. A recovery copy is
// parked first, so a torn write is recoverable.
func (s *Server) setConfiguredBackupTarget(id string) error {
config.FileMu.Lock() // one writer of agent.json at a time in this process (config.FileMu)
defer config.FileMu.Unlock()
path := s.configPath
if path == "" {
return fmt.Errorf("no config path known to this agent (env-only config)")
+3 -2
View File
@@ -90,6 +90,8 @@ type GuestAttacher interface {
// GuestBootKey is the same signal built from the kernel's boot_id, which a step of the host's clock cannot
// move (R-1395). "" = not readable; the controller then reads GuestBootID.
GuestBootKey(ctx context.Context, vmid int) string
// GuestBootTokens returns (GuestBootID, GuestBootKey) from one read — what /disks sends.
GuestBootTokens(ctx context.Context, vmid int) (id, key string)
// AttachBind is the LEGACY per-drive `pct set -mpN` bind (pre-intermediary). Retained for the
// transition; new attaches use AttachDrive.
AttachBind(ctx context.Context, vmid int, mountKey, where string) error
@@ -437,8 +439,7 @@ func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) {
// R-1395: guest_boot_key is the clock-proof form (kernel boot_id); guest_boot_id stays for older controllers.
bootID, bootKey := "", ""
if s.guestAttach != nil {
bootID = s.guestAttach.GuestBootID(r.Context(), vmid)
bootKey = s.guestAttach.GuestBootKey(r.Context(), vmid)
bootID, bootKey = s.guestAttach.GuestBootTokens(r.Context(), vmid)
}
writeOK(w, map[string]any{"vmid": vmid, "disks": out, "guest_boot_id": bootID, "guest_boot_key": bootKey})
}
+3
View File
@@ -467,6 +467,9 @@ func (f *fakeGuestAttacher) AttachDrive(_ context.Context, _ int, where string)
func (f *fakeGuestAttacher) GuestSeesMount(_ context.Context, _ int, _ string) bool { return true }
func (f *fakeGuestAttacher) GuestBootID(_ context.Context, _ int) string { return "boot-1" }
func (f *fakeGuestAttacher) GuestBootKey(_ context.Context, _ int) string { return "key-1" }
func (f *fakeGuestAttacher) GuestBootTokens(_ context.Context, _ int) (string, string) {
return "boot-1", "key-1"
}
func (f *fakeGuestAttacher) attachDriveCount() int {
f.mu.Lock()
defer f.mu.Unlock()
+7
View File
@@ -76,6 +76,13 @@ func TestGuestBootKey_AClockStepDoesNotMoveIt(t *testing.T) {
t.Errorf("the old token must still be sent when the boot_id is unreadable")
}
}
// what /disks sends: both tokens from ONE read of the guest's init — the init is asked for once, not twice
bootID, start = "5b1f6c1e-8f0a-4c56-9d1c-0a8c3b7d2e11", "4242"
reads := 0
procStarttimeFn = func(string) string { reads++; return start }
if id, key := b.GuestBootTokens(ctx, 9201); id != "1794112550-4242" || key != "5b1f6c1e-8f0a-4c56-9d1c-0a8c3b7d2e11-4242" || reads != 1 {
t.Errorf("GuestBootTokens = %q, %q after %d read(s) of the init — want both tokens from ONE read", id, key, reads)
}
bootID, start = "5b1f6c1e-8f0a-4c56-9d1c-0a8c3b7d2e11", "" // the guest does not run
if k := b.GuestBootKey(ctx, 9201); k != "" {
t.Errorf("a guest that does not run gave the key %q", k)
+15
View File
@@ -152,6 +152,19 @@ func (s *Server) GuestPowerTick(ctx context.Context) {
s.logger.Warn("guest-power: guest list unavailable — skipping sweep (ownership unproven)", "err", err)
return
}
// A guest that left the list (destroyed, or re-made under another number) takes its attempt record with it — a
// give-up for a guest that no longer exists would ride every host report until the agent restarts.
present := make(map[int]bool, len(guests))
for _, g := range guests {
present[g.VMID] = true
}
guestPowerMu.Lock()
for vmid := range s.guestPower {
if !present[vmid] {
delete(s.guestPower, vmid)
}
}
guestPowerMu.Unlock()
var stopped int
for _, g := range guests {
if ctx.Err() != nil {
@@ -202,6 +215,7 @@ func (s *Server) recoverOneStoppedGuest(ctx context.Context, g proxmox.Guest) {
// SCENARIO B — a deliberately stopped guest is left alone, forever. onboot:0 means the operator
// (or the golden-image provisioning) does not want this guest running.
if !onboot {
s.forgetGuestPower(g.VMID) // not ours to start any more: a give-up for it must not be reported for ever
return
}
@@ -211,6 +225,7 @@ func (s *Server) recoverOneStoppedGuest(ctx context.Context, g proxmox.Guest) {
if lock != "" {
s.logger.Info("guest-power: guest is stopped but LOCKED — leaving it to the stale-lock path",
"vmid", g.VMID, "lock", lock)
s.forgetGuestPower(g.VMID) // another operation owns it now; if it ends stopped, the three tries start again
return
}
+35
View File
@@ -281,3 +281,38 @@ func TestGuestPower_GiveUpIsReportedUntilTheGuestRuns(t *testing.T) {
t.Fatalf("the guest runs again and the stanza is still %#v", st)
}
}
// Found by review, 2026-11-14: the give-up was forgotten only when the guest was seen RUNNING. A guest the operator
// then switches to onboot:0, a guest another operation locks, and a guest that is destroyed would be reported as
// „given up” for ever (the hub's alarm could never clear). Each of the three ends the report.
func TestGuestPower_GiveUpEndsWhenTheGuestIsNoLongerOurs(t *testing.T) {
for _, c := range []struct {
name string
change func(ctl *fakeGuestPowerCtl)
}{
{"onboot switched off", func(ctl *fakeGuestPowerCtl) { ctl.onboot = map[int]bool{9201: false} }},
{"locked by another operation", func(ctl *fakeGuestPowerCtl) { ctl.locks = map[int]string{9201: "backup"} }},
{"destroyed", func(ctl *fakeGuestPowerCtl) { ctl.guests = nil }},
} {
ctl := &fakeGuestPowerCtl{
guests: []proxmox.Guest{{VMID: 9201, Status: "stopped"}},
locks: map[int]string{9201: ""},
onboot: map[int]bool{9201: true},
startErr: errors.New("cannot start"),
}
now := time.Date(2026, 11, 14, 2, 0, 0, 0, time.UTC)
s := gpServer(t, ctl, func() time.Time { return now })
for i := 0; i < guestPowerMaxAttempts+1; i++ {
s.GuestPowerTick(context.Background())
now = now.Add(10 * time.Minute)
}
if len(s.GuestPowerGaveUp()) != 1 {
t.Fatalf("%s: setup — no give-up recorded", c.name)
}
c.change(ctl)
s.GuestPowerTick(context.Background())
if g := s.GuestPowerGaveUp(); len(g) != 0 {
t.Errorf("%s: the give-up is still reported: %+v", c.name, g)
}
}
}
+15
View File
@@ -324,6 +324,21 @@ func (b *GuestBinder) GuestBootKey(ctx context.Context, vmid int) string {
return guestBootKey(hostBootIDFn(), start)
}
// GuestBootTokens returns both tokens from ONE read of the guest's init: the old token and the key always describe
// the same moment. Read separately, one could succeed and the other fail (the guest stops between the two) — the
// controller would then get an old token with no key, read „an older agent”, and recreate every drive-backed app.
func (b *GuestBinder) GuestBootTokens(ctx context.Context, vmid int) (id, key string) {
start := b.guestInitStart(ctx, vmid)
if start == "" {
return "", ""
}
bt := hostBtimeFn()
if bt == "" {
bt = "0"
}
return bt + "-" + start, guestBootKey(hostBootIDFn(), start)
}
// guestBootKey joins the two halves; "" when either is missing or the boot_id is not the kernel's shape.
func guestBootKey(bootID, start string) string {
if start == "" || !kernelBootIDRe.MatchString(bootID) {