7f11cfb36c
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.
88 lines
2.9 KiB
Go
88 lines
2.9 KiB
Go
package web
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"gitea.dooplex.hu/admin/felhom-hub/internal/monitor"
|
||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||
)
|
||
|
||
const gib = int64(1) << 30
|
||
|
||
// Not configured (no HETZNER_TOKEN) → honest "not configured", never an error or fake zeros.
|
||
// (Uses renderOffsite from offsite_test.go — the real handler via httptest.)
|
||
func TestOffsiteBoxPanel_NotConfigured(t *testing.T) {
|
||
s, _ := newRenderServer(t)
|
||
// s.offsiteBox left nil
|
||
body := renderOffsite(t, s)
|
||
if !strings.Contains(body, "Offsite pool metrics not configured") {
|
||
t.Fatalf("unconfigured panel must say 'not configured':\n%s", body)
|
||
}
|
||
if strings.Contains(body, "0.0 GB") {
|
||
t.Fatal("unconfigured panel must not render fake zeros")
|
||
}
|
||
}
|
||
|
||
// With data + per-customer rows: the panel renders capacity/fill/ratio; a shared customer with a report
|
||
// shows usage, a customer with NO report shows 'no usage reported yet' (never an asserted 0 GB).
|
||
func TestOffsiteBoxPanel_WithData(t *testing.T) {
|
||
s, st := newRenderServer(t)
|
||
s.SetOffsiteBox(func() (monitor.BoxSnapshot, bool) {
|
||
return monitor.BoxSnapshot{
|
||
CapacityBytes: 1 << 40, UsedBytes: 300 * gib, DataBytes: 300 * gib,
|
||
FillPercent: 27, FillBand: "ok", SumQuotaGB: 1200, Ratio: 1.17, OversubBand: "ok",
|
||
BoxType: "bx11", FetchedAt: time.Now().UTC(),
|
||
}, true
|
||
})
|
||
// "acme": shared, enabled, 500 GB, WITH a report (200 GiB used). "newbie": shared, enabled, no report.
|
||
saveCfg(t, st, "acme", `{"offsite":{"enabled":true,"type":"shared","quota_gb":500}}`)
|
||
saveCfg(t, st, "newbie", `{"offsite":{"enabled":true,"type":"shared","quota_gb":300}}`)
|
||
if err := st.SaveReport("acme", []byte(`{"customer_id":"acme","offsite":{"enabled":true,"repo_size_bytes":`+itoa(200*gib)+`}}`)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
body := renderOffsite(t, s)
|
||
for _, want := range []string{"Offsite pool box", "1.00 TB", "1.17×", "acme", "newbie", "no usage reported yet"} {
|
||
if !strings.Contains(body, want) {
|
||
t.Fatalf("panel missing %q:\n%s", want, body)
|
||
}
|
||
}
|
||
// The RESTIC panel is configured — its specific not-configured message must be absent. (The PBS DR
|
||
// panel legitimately shows its own "not configured" here since s.pbsdrBox is unset.)
|
||
if strings.Contains(body, "Offsite pool metrics not configured") {
|
||
t.Fatal("a configured restic panel must not show the not-configured message")
|
||
}
|
||
}
|
||
|
||
func saveCfg(t *testing.T, st *store.Store, id, cfgJSON string) {
|
||
t.Helper()
|
||
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: id, CustomerName: id, APIKey: "k-" + id, RetrievalPassword: "p", ConfigJSON: cfgJSON}); err != nil {
|
||
t.Fatalf("save config %s: %v", id, err)
|
||
}
|
||
}
|
||
|
||
// itoa avoids importing strconv just for the report body.
|
||
func itoa(v int64) string {
|
||
if v == 0 {
|
||
return "0"
|
||
}
|
||
neg := v < 0
|
||
if neg {
|
||
v = -v
|
||
}
|
||
var b [20]byte
|
||
i := len(b)
|
||
for v > 0 {
|
||
i--
|
||
b[i] = byte('0' + v%10)
|
||
v /= 10
|
||
}
|
||
if neg {
|
||
i--
|
||
b[i] = '-'
|
||
}
|
||
return string(b[i:])
|
||
}
|