Files
felhom-agent/internal/pvegate/pvegate.go
T
admin ce1a4b4758 R-812 option A: the Proxmox package lane (layer pve) + the /etc/pve write gate
The wrapper gains layer "pve" (slow lane): the host's Proxmox userspace
packages only — origin "Proxmox Debian Repository", never a kernel / boot /
firmware / microcode name (R14), no removal, no undo, a new package only from
an allow-list; authority = a signed os_pve_step or the root-owned ring-0 mark.
The night leg runs it in ring 0 after a healthy host step; ring 1 only by a
signed job (PVEStepExecutor). While it runs, the agent's own /etc/pve writes
(every non-GET API call, pct config verbs, pvesm, pveum, felhom-pbs-apply)
wait on internal/pvegate. Health = the host rule + unchanged container ids +
pveversion reads the installed pve-manager.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-07 10:19:38 +02:00

105 lines
3.0 KiB
Go

// Package pvegate keeps the agent's own writes to /etc/pve out of the way of a Proxmox package step (R-812 option A,
// `09` §3 decision 163, `11` §5.10).
//
// WHY. A `pve` step upgrades pve-cluster / pve-manager / qemu-server / pve-container; their postinst scripts restart
// pmxcfs (the FUSE filesystem behind /etc/pve) and the API daemons. A write that lands while pmxcfs restarts fails or,
// worse, half-lands (design-R-812 §3 A, "can go wrong"). Backups and restore-tests are already kept out by the
// host-wide heavy-op gate; this gate covers everything else the agent writes: every non-GET Proxmox API call
// (proxmox.Client.doBody) and every root CLI that writes /etc/pve (proxmox.ExecRunner — `pct set|create|…`, `pvesm`,
// `pveum`, `felhom-pbs-apply create|reconcile`).
//
// THE RULE. Write waits while a step runs (bounded by its own context). Step marks the step and then waits until every
// write already in flight has finished; it never waits forever (its context bounds it, and the caller gives up and
// does not run the step). One step at a time. Pinned by pvegate_test.go and, at the two chokepoints, by
// proxmox TestPVEGate_*.
package pvegate
import (
"context"
"errors"
"sync"
"time"
)
var (
mu sync.Mutex
inFlight int
stepping bool
stepDone chan struct{}
)
// ErrStepRunning is returned by Step when another step already holds the gate.
var ErrStepRunning = errors.New("pvegate: a Proxmox package step is already running")
// Write marks one /etc/pve write in flight, first waiting while a Proxmox package step runs. The returned release must
// be called when the write has finished. waited reports how long the write was held back.
func Write(ctx context.Context) (release func(), waited time.Duration, err error) {
start := time.Now()
for {
mu.Lock()
if !stepping {
inFlight++
mu.Unlock()
var once sync.Once
return func() {
once.Do(func() {
mu.Lock()
inFlight--
mu.Unlock()
})
}, time.Since(start), nil
}
ch := stepDone
mu.Unlock()
select {
case <-ch:
case <-ctx.Done():
return nil, time.Since(start), ctx.Err()
}
}
}
// Step marks a Proxmox package step and waits until every /etc/pve write already in flight has finished. On error the
// gate is released again and the step must not run. end releases the gate and lets the held-back writes go.
func Step(ctx context.Context) (end func(), err error) {
mu.Lock()
if stepping {
mu.Unlock()
return nil, ErrStepRunning
}
stepping = true
done := make(chan struct{})
stepDone = done
mu.Unlock()
var once sync.Once
end = func() {
once.Do(func() {
mu.Lock()
stepping = false
close(done)
mu.Unlock()
})
}
for {
mu.Lock()
n := inFlight
mu.Unlock()
if n == 0 {
return end, nil
}
select {
case <-ctx.Done():
end()
return nil, ctx.Err()
case <-time.After(50 * time.Millisecond):
}
}
}
// Stepping reports whether a Proxmox package step holds the gate (for logs).
func Stepping() bool {
mu.Lock()
defer mu.Unlock()
return stepping
}