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
gates / gates (push) Successful in 48s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
1 parent
61feb95c06
commit
ec7e699fd7
18 files changed
+333
-59
No files matched your search
@@ -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)")
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user