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
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package pvegate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A write that starts while a step runs waits until the step ends.
|
||||
//
|
||||
// COMPANION RED-PROOF (observed): make Write ignore `stepping` → this fails with "the write went through while the
|
||||
// Proxmox step held the gate". Restored. (audits/day-2026-10-07/B/red-pvegate.txt)
|
||||
func TestWrite_WaitsWhileAStepRuns(t *testing.T) {
|
||||
end, err := Step(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := make(chan time.Time, 1)
|
||||
go func() {
|
||||
rel, _, err := Write(context.Background())
|
||||
if err == nil {
|
||||
rel()
|
||||
}
|
||||
got <- time.Now()
|
||||
}()
|
||||
select {
|
||||
case <-got:
|
||||
end()
|
||||
t.Fatal("the write went through while the Proxmox step held the gate")
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
}
|
||||
ended := time.Now()
|
||||
end()
|
||||
select {
|
||||
case at := <-got:
|
||||
if at.Before(ended) {
|
||||
t.Fatal("the write finished before the step ended")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("the write never went through after the step ended")
|
||||
}
|
||||
}
|
||||
|
||||
// A step waits for a write already in flight before it starts.
|
||||
func TestStep_WaitsForAWriteInFlight(t *testing.T) {
|
||||
rel, _, err := Write(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
started := make(chan struct{})
|
||||
go func() {
|
||||
end, err := Step(context.Background())
|
||||
if err == nil {
|
||||
close(started)
|
||||
end()
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-started:
|
||||
rel()
|
||||
t.Fatal("the step started while a write was in flight")
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
}
|
||||
rel()
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("the step never started after the write finished")
|
||||
}
|
||||
}
|
||||
|
||||
// A step that cannot drain the writes in time gives up and releases the gate (it never waits forever).
|
||||
func TestStep_GivesUpAndReleases(t *testing.T) {
|
||||
rel, _, _ := Write(context.Background())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, err := Step(ctx); err == nil {
|
||||
t.Fatal("the step must give up while a write is in flight past its deadline")
|
||||
}
|
||||
if Stepping() {
|
||||
t.Fatal("a step that gave up must release the gate")
|
||||
}
|
||||
rel()
|
||||
}
|
||||
|
||||
// A write held back past its own deadline returns the context's error.
|
||||
func TestWrite_HonoursItsContext(t *testing.T) {
|
||||
end, _ := Step(context.Background())
|
||||
defer end()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, _, err := Write(ctx); err == nil {
|
||||
t.Fatal("a write held back past its deadline must fail")
|
||||
}
|
||||
}
|
||||
|
||||
// One step at a time.
|
||||
func TestStep_OneAtATime(t *testing.T) {
|
||||
end, _ := Step(context.Background())
|
||||
defer end()
|
||||
if _, err := Step(context.Background()); err != ErrStepRunning {
|
||||
t.Fatalf("a second step must be refused, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user