v0.123.0: a tier the box cannot READ now says so (R-185)
gates / gates (push) Successful in 7s

The missing grant is one command; the silence was the defect. On demo-felhom the
agent's token has FelhomAgentStore on local, local-lvm and felhom-pbs — and not
on felhom-backup, the storage the same installer configured as
local_backup_target. That storage answers {"data":[]} through the token while
root sees three archives.

An empty listing is what a FORBIDDEN tier and a NEWBORN tier both return, so
pickForThisRun skipped it as "no settled archive yet" and the tier was never
restore-testable on that box. The permission question, unlike the listing, has a
definite answer, so it is asked directly: Client.Permissions reads
/access/permissions as the agent's OWN token, and one capability.Status per
configured tier reports it — composed around the sudo prober, the way the
pool-read check already is.

Measured first, because the obvious reading is wrong: an ungranted path answers
neither empty nor 403, but with the privileges inherited from the box-wide grant
(Sys.Audit, SDN.Use, Datastore.Audit). Checking for Datastore.Audit would report
a blinded storage healthy — red-proved. The probe tests for
Datastore.AllocateSpace.

The probed set comes from the box's own config, never a fixed list: a hardcoded
probe list is the defect reproduced inside the fix. Critical, because the hub
alerts only on critical — except the "local" fallback target, which is reported
but does not page. It never looks at content, so it cannot alarm on a newborn
tier; it never reports ok when it could not ask. Status wire shape unchanged, so
no hub change.
This commit is contained in:
2026-08-03 18:53:47 +02:00
parent 0b28eae7bb
commit fe14bc62c0
4 changed files with 351 additions and 1 deletions
+29
View File
@@ -45,6 +45,35 @@ func (c *Client) Pool(ctx context.Context, name string) (PoolInfo, error) {
return p, c.get(ctx, "/pools/"+url.PathEscape(name), &p)
}
// Permissions returns the privileges this API TOKEN holds at an ACL path, as
// GET /access/permissions?path=<path> answers it: privilege name → 1.
//
// R-185. It asks about the CALLER — the agent's own token — which is the only useful form of the
// question. Asking as root answers a different question and always says yes.
//
// MEASURED SHAPE (demo-felhom, 2026-08-03), because the whole value of this call is reading the
// answer correctly and the obvious reading is wrong:
//
// /storage/felhom-pbs → {"Datastore.Allocate":1,"Datastore.AllocateSpace":1}
// /storage/felhom-backup → {"Sys.Audit":1,"SDN.Use":1,"Datastore.Audit":1}
//
// The ungranted path does NOT answer empty, and does NOT 403. It answers with the privileges
// INHERITED from the box-wide `/` grant — so "is this path present in the response" reports OK for a
// storage the agent demonstrably cannot list. The caller must test for the SPECIFIC privilege.
//
// The response is keyed by path; an absent path yields no privileges, which is the same answer as
// "none" and is treated as such by the caller.
func (c *Client) Permissions(ctx context.Context, aclPath string) (map[string]int, error) {
var raw map[string]map[string]int
if err := c.get(ctx, "/access/permissions?path="+url.QueryEscape(aclPath), &raw); err != nil {
return nil, err
}
if p, ok := raw[aclPath]; ok {
return p, nil
}
return map[string]int{}, nil
}
// GuestStatus returns GET /nodes/{node}/lxc/{vmid}/status/current. The API body
// has no vmid field (it is in the path), so it is set from the argument.
func (c *Client) GuestStatus(ctx context.Context, vmid int) (Guest, error) {