v0.89.0: pbsdr self-grant (R-22) + escrow config live-reload + agent-plane poke listener (Direction-2a)

- pbsdr: on a 403 pre-check (non-default storage id, no ACL yet) self-grant via the root wrapper then re-read, instead of aborting before the grant — closes F4/R-22. Red-proof TestSelfGrant_PreCheck403DoesNotAbortBeforeGrant.
- escrow preflight: late-bound CurrentPBSStorageID re-reads agent.json so a pbsdr-seeded pbs_storage_id flips the row green in-process (no restart). Red-proof TestEscrowPreflight_PBSStorageIDLiveReload.
- internal/poke: contentless UDP poke listener bound exclusively to the box WG /32 (port 51822), leading-edge debounced, fires the hub-loop out-of-band trigger for an immediate desired-state cycle. First slice of R-13. Red-proofs TestBindConfinement + TestDebounceCoalescesBurst.
This commit is contained in:
2026-07-16 22:47:22 +02:00
parent c040c180e9
commit a659e5dc09
11 changed files with 643 additions and 15 deletions
+31 -2
View File
@@ -239,8 +239,37 @@ func (m *Manager) Apply(ctx context.Context, fetched bool, block *hub.WirePBSDR)
entry, found, err := m.px.StorageEntry(ctx, block.StorageID)
if err != nil {
m.logger.Warn("pbsdr: storage-entry read failed (transient; retrying next tick)", "err", err)
return
// R-22 self-grant (F4, tests/VALIDATION-n100-baremetal): on a NON-DEFAULT storage id the
// agent token holds no ACL on /storage/<id> yet, so this token-auth pre-check
// (GET /storage/<id>) 403s. Aborting here would deadlock permanently — the root-run wrapper
// `grant` that CREATES that very ACL is only reached further down (adoption / create paths).
// So on a 403 ONLY, run the grant now (root, no secret, no pre-existing entry required —
// `pveum acl modify` on a path is unconditional) and re-read once; the retry then flows the
// normal adoption/create path. Every OTHER error stays transient (retry next tick). The
// pre-check itself is KEPT: once the ACL exists the read succeeds and short-circuits the
// happy path cheaply — we only stop the 403 from being a first-contact dead-end.
var ae *proxmox.APIError
if !errors.As(err, &ae) || !ae.IsForbidden() {
m.logger.Warn("pbsdr: storage-entry read failed (transient; retrying next tick)", "err", err)
return
}
m.logger.Info("pbsdr: pre-check 403 (token has no ACL on this storage id yet) — self-granting via the root wrapper, then re-reading (R-22)",
"storage_id", block.StorageID)
if _, errOut, gerr := m.runner.Run(ctx, WrapperPath, "grant", block.StorageID); gerr != nil {
m.logger.Warn("pbsdr: self-grant failed (retrying next tick)", "err", gerr, "stderr", tail(errOut))
m.setStatus(&hub.PBSDRStatus{State: "verify_failed", StorageID: block.StorageID, Namespace: block.Namespace,
Message: "pre-check 403 and self-grant failed: " + gerr.Error()})
return
}
entry, found, err = m.px.StorageEntry(ctx, block.StorageID)
if err != nil {
// Grant succeeded but the read STILL fails → not the ACL bootstrap after all; surface it
// loudly rather than looping silently.
m.logger.Warn("pbsdr: storage-entry read still failing after self-grant (retrying next tick)", "err", err)
m.setStatus(&hub.PBSDRStatus{State: "verify_failed", StorageID: block.StorageID, Namespace: block.Namespace,
Message: "storage read failed even after self-grant: " + err.Error()})
return
}
}
if found && entry.Type != "pbs" {
msg := fmt.Sprintf("storage id %s exists with type %q (not pbs) — refusing to touch it", block.StorageID, entry.Type)
+62
View File
@@ -67,9 +67,19 @@ type fakeStorage struct {
found bool
active []bool // consumed per StorageActive call; last value repeats
calls int
// entryErrs is consumed per StorageEntry call (nil = the normal (entry,found,nil) answer);
// after the slice is exhausted every call answers normally. Lets a test model the R-22
// pre-check 403 that self-grant must recover from without aborting.
entryErrs []error
entryCalls int
}
func (f *fakeStorage) StorageEntry(context.Context, string) (*proxmox.StorageEntryConfig, bool, error) {
i := f.entryCalls
f.entryCalls++
if i < len(f.entryErrs) && f.entryErrs[i] != nil {
return nil, false, f.entryErrs[i]
}
return f.entry, f.found, nil
}
@@ -182,6 +192,58 @@ func TestFreshPath_SecretOnStdinNeverArgv(t *testing.T) {
}
}
// TestSelfGrant_PreCheck403DoesNotAbortBeforeGrant pins R-22 (F4 from tests/VALIDATION-n100):
// on a non-default storage id whose ACL the token lacks, the token-auth pre-check GET /storage/<id>
// 403s. The fix must NOT abort — it must run the root-run `grant` (which creates that very ACL),
// re-read, and converge. RED-PROOF: the pre-fix code returns on the StorageEntry error before any
// runner call, so NO grant runs and the box never converges — this test then fails on
// "self-grant never ran". No secret may be consumed on this (adoption) path.
func TestSelfGrant_PreCheck403DoesNotAbortBeforeGrant(t *testing.T) {
r := &fakeRunner{}
forbidden := &proxmox.APIError{StatusCode: 403, Method: "GET", Path: "/storage/felhom-offsite",
Body: "Permission check failed (/storage/felhom-offsite, Datastore.Audit)"}
st := &fakeStorage{
// First read 403s (no ACL); after the self-grant the entry reads healthy → adoption.
entryErrs: []error{forbidden},
entry: &proxmox.StorageEntryConfig{Storage: "felhom-offsite", Type: "pbs",
Namespace: "peti", Fingerprint: testFP},
found: true,
active: []bool{true},
}
c := &fakeConsumer{secret: "MUST-NOT-BURN"}
m, _ := newTestManager(t, r, st, c)
block := testBlock()
block.StorageID = "felhom-offsite"
block.Namespace = "peti"
m.Apply(context.Background(), true, block)
// A 403 on IsForbidden must be recognised as such (regression guard on the type assertion).
if !forbidden.IsForbidden() {
t.Fatal("precondition: crafted APIError is not IsForbidden")
}
calls := r.recorded()
grants := 0
for _, call := range calls {
if len(call.Args) > 0 && call.Args[0] == "grant" {
grants++
}
}
if grants == 0 {
t.Fatalf("self-grant never ran — the pre-check 403 aborted before the root grant (R-22 regression); calls=%+v", calls)
}
if c.calls != 0 {
t.Fatalf("a secret was consumed on the self-grant/adoption path (%d calls) — the no-consume law", c.calls)
}
if s := m.Status(); s == nil || (s.State != "adopted" && s.State != "applied") {
t.Fatalf("status = %+v, want converged (adopted/applied) after self-grant", s)
}
// The pre-check was re-read (not dropped): 2 StorageEntry calls — the 403, then the post-grant read.
if st.entryCalls < 2 {
t.Fatalf("StorageEntry called %d times — the post-grant re-read is missing", st.entryCalls)
}
}
func TestVerifyPinBeforeConsume(t *testing.T) {
r := &fakeRunner{}
st := &fakeStorage{found: false}