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:
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user