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