hub v0.65.0 — PBS DR storage visibility (ep0 usage op) + Offsite tab split + dual dashboard gauges (R-5)

Makes PBS DR storage visible like the restic pool box (v0.64.0), differentiated. Scoping
correction: restic = subaccounts on the shared Hetzner Storage Box (Hetzner API); PBS DR =
the felhom-offsite PBS datastore on the ep0 endpoint VM (NO Hetzner API). Option A
(Viktor-ruled): a read-only `usage` op on the felhom-tenantsync ep0 forced command (twin of
fingerprint), polled by a new hub checker on the 15-min throttle. READ-ONLY throughout.

Phase-0 (gate PASSED): on ep0 (PBS 4.2.3), df -B1 --output=size,used,avail <datastore path>
yields bytes (39990112256/7627939840/... ~19%), read-only, existing sudo context, no admin token.

- scripts/felhom-tenantsync.sh -> v1.2.0: read-only `usage` short-circuit (df on the datastore
  path), no customer_id, no admin token, NO mutation. + a bash harness proving zero mutation.
- tenantsync.Client.Usage() + BoxUsage; unknown-op -> typed ErrUsageUnsupported (graceful).
- monitor.PBSDRBoxChecker: OffsiteBoxChecker clone over a usageReader seam; 15-min throttle,
  cached PBSBoxSnapshot, escalation-only pbsdr_box_fill on the "pbsdr-box" scope (operator only,
  no SaveEvent), recovery re-arm. Fill only. THREE states: ok / unavailable (ep0 <=v1.1.0,
  neutral no-alert) / degraded (exec failed, keep last).
- config: Alerting.PBSDRBoxFill{Warn,Crit}Percent (80/90); built with the tenantsync client,
  60s sweep, SetPBSDRBox. Hub deploy INDEPENDENT of the ep0 update (graceful degradation).
- web: /offsite splits into Restic + PBS DR hash tabs (endpoint cards under PBS DR); PBS panel;
  the single dashboard tile becomes two gauges (RESTIC pct.ratio, PBS DR pct / n/a).
- runbook offsite-endpoint.md 10: v1.2.0 update steps (no sudoers/authorized_keys change).

Tests: 10 Go + the harness; 3 red-proofs (usage mutation, escalation-only, unavailable-drives-band)
confirmed red then restored. go build/vet/test + bash -n + hub confirm gate all pass.
This commit is contained in:
2026-07-17 21:13:30 +02:00
parent 3588a31b78
commit 7f11cfb36c
19 changed files with 856 additions and 22 deletions
+39
View File
@@ -28,6 +28,14 @@ import (
// treats it as a state mismatch (a descriptor should exist; re-issue is the explicit recovery).
var ErrTokenExists = errors.New("tenantsync: token already exists on the endpoint (re-issue is the explicit path)")
// ErrUsageUnsupported is the typed "endpoint script predates the usage op" outcome (v0.65.0): an
// endpoint STILL on tenantsync ≤ v1.1.0 answers `{"op":"usage"}` with its generic `bad_request
// "unknown op"`. It is the GRACEFUL-DEGRADATION signal — the PBS checker maps it to an "unavailable"
// snapshot state (an expected pre-update condition), NOT an error/degraded state, so no alert fires
// and the gauge honestly says "usage not available (endpoint update pending)". The moment ep0 gets
// v1.2.0, the next poll succeeds — no hub redeploy.
var ErrUsageUnsupported = errors.New("tenantsync: endpoint does not support the usage op (script update pending)")
// customerIDRe mirrors the script's validation — refuse client-side before a wasted SSH round-trip.
var customerIDRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,30}$`)
@@ -137,6 +145,28 @@ func (c *Client) Fingerprint(ctx context.Context) (string, error) {
return resp.Fingerprint, nil
}
// BoxUsage is the endpoint PBS datastore's raw fill (bytes), from the read-only usage op (v1.2.0).
type BoxUsage struct {
Total int64
Used int64
Avail int64
}
// Usage returns the felhom-offsite datastore's total/used/avail from the endpoint — a read-only op
// (no token, no namespace, no mutation), the structural twin of Fingerprint. Against an endpoint
// still on tenantsync ≤ v1.1.0 the op is unknown → ErrUsageUnsupported (graceful degradation).
func (c *Client) Usage(ctx context.Context) (BoxUsage, error) {
stdout, stderr, runErr := c.exec(ctx, []byte(`{"op":"usage"}`))
resp, err := parseResponse(stdout, stderr, runErr)
if err != nil {
return BoxUsage{}, err
}
if resp.Total <= 0 {
return BoxUsage{}, fmt.Errorf("tenantsync: usage op returned a non-positive total")
}
return BoxUsage{Total: resp.Total, Used: resp.Used, Avail: resp.Avail}, nil
}
func (c *Client) tenancyOp(ctx context.Context, op, customerID string) (*Result, error) {
if !customerIDRe.MatchString(customerID) {
return nil, fmt.Errorf("tenantsync: invalid customer_id %q", customerID)
@@ -165,6 +195,10 @@ type response struct {
Code string `json:"code"`
Error string `json:"error"`
Deleted bool `json:"deleted"` // deprovision op: whether the namespace existed (was destroyed)
// usage op (v1.2.0): the felhom-offsite datastore's raw fill, in BYTES.
Total int64 `json:"total"`
Used int64 `json:"used"`
Avail int64 `json:"avail"`
Result
}
@@ -178,6 +212,11 @@ func parseResponse(stdout, stderr []byte, runErr error) (*response, error) {
if resp.Code == "token_exists" {
return nil, ErrTokenExists
}
// An endpoint ≤ v1.1.0 has no usage op → its generic `bad_request "unknown op"`. Map it to the
// typed graceful-degradation signal (the exact err_json string is the script's fixed contract).
if resp.Code == "bad_request" && strings.Contains(resp.Error, "unknown op") {
return nil, ErrUsageUnsupported
}
return nil, fmt.Errorf("tenantsync: endpoint refused: %s (code %s)", resp.Error, resp.Code)
}
if runErr != nil {
+32
View File
@@ -208,6 +208,38 @@ func TestFingerprint_Op(t *testing.T) {
}
}
// TestUsage_Op (v0.65.0): the read-only usage op parses total/used/avail (bytes).
func TestUsage_Op(t *testing.T) {
clientPEM, clientSigner := testKeys(t)
_, hostSigner := testKeys(t)
srv := startTestServer(t, hostSigner, clientSigner,
`{"status":"ok","total":39990112256,"used":7628091392,"avail":30686175232}`, "", 0)
c := newTestClient(t, srv.addr, hostKeyLine(t, hostSigner), clientPEM)
u, err := c.Usage(context.Background())
if err != nil {
t.Fatalf("Usage: %v", err)
}
if u.Total != 39990112256 || u.Used != 7628091392 || u.Avail != 30686175232 {
t.Errorf("usage = %+v, want total/used/avail 39990112256/7628091392/30686175232", u)
}
}
// TestUsage_UnknownOpTypedUnsupported (v0.65.0, graceful degradation): an ep0 still on tenantsync
// ≤ v1.1.0 answers `{"op":"usage"}` with its generic `bad_request "unknown op"` (exit 1). That must
// map to the typed ErrUsageUnsupported so the PBS checker records "unavailable", not an error.
func TestUsage_UnknownOpTypedUnsupported(t *testing.T) {
clientPEM, clientSigner := testKeys(t)
_, hostSigner := testKeys(t)
srv := startTestServer(t, hostSigner, clientSigner,
`{"status":"error","code":"bad_request","error":"unknown op"}`, "", 1)
c := newTestClient(t, srv.addr, hostKeyLine(t, hostSigner), clientPEM)
if _, err := c.Usage(context.Background()); !errors.Is(err, ErrUsageUnsupported) {
t.Fatalf("an ep0 without the usage op must map to ErrUsageUnsupported, got %v", err)
}
}
// TestErrors_NeverEmbedStdout is the secret-hygiene contract: stdout may carry the token secret,
// so NO error path may quote it. A malformed-but-secret-bearing stdout must yield an error that
// does not contain the marker bytes. (wgsync quotes stdout in its malformed error — this package