v0.109.0: soft-quota gate + usage bar + offsite report status (SLICE 4)
QuotaGB rides the descriptor into OffboxTarget; RepoSizeBytes persisted from restic stats. Pre-run gate: >=100% refuses NEW backups (Hungarian notice + operator alert) but prune STILL runs (red-proofed) and restore is never gated; >=80% warns. /backups usage bar (quota>0 only). The hub report gains the non-secret offsite status object for the OffsiteChecker. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -243,8 +243,8 @@ func main() {
|
||||
KeyGen: offsiteapply.ED25519KeyGen{},
|
||||
Installer: offsiteapply.SSHCopyIDInstaller{},
|
||||
Prober: offsiteapply.SFTPKeyAuthProber{KeyPath: filepath.Join(cfg.Paths.DataDir, "offbox", "ssh_key")},
|
||||
Enabler: offsiteapply.EnablerFunc(func(ctx context.Context, host, user string, port int, repoPath, priv, kh string) error {
|
||||
tgt := &settings.OffboxTarget{Enabled: true, Host: host, User: user, Port: port, RepoPath: repoPath, Schedule: "daily"}
|
||||
Enabler: offsiteapply.EnablerFunc(func(ctx context.Context, host, user string, port int, repoPath, priv, kh string, quotaGB int) error {
|
||||
tgt := &settings.OffboxTarget{Enabled: true, Host: host, User: user, Port: port, RepoPath: repoPath, Schedule: "daily", QuotaGB: quotaGB}
|
||||
stage := func(ctx context.Context, pw string) error {
|
||||
ac, err := agentapi.New(cfg.LocalAPI.Endpoint, cfg.LocalAPI.Token, cfg.LocalAPI.Fingerprint)
|
||||
if err != nil {
|
||||
|
||||
@@ -346,7 +346,21 @@ func (m *Manager) RunOffboxBackup(ctx context.Context) error {
|
||||
start := time.Now()
|
||||
_ = m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.LastStatus = "running"; o.LastError = "" })
|
||||
|
||||
backedUp, missing, runErr := m.runOffboxInternal(ctx, apps, base, env)
|
||||
var backedUp int
|
||||
var missing []string
|
||||
var runErr error
|
||||
if usedGB, quota, over := offboxQuotaState(t); over {
|
||||
// SLICE 4 soft-quota gate (pre-run): NEW backups are refused at ≥100% of the shared-model quota —
|
||||
// but the retention/prune step STILL RUNS (pruning is the customer's only way back under quota;
|
||||
// gating it too would deadlock them over-quota) and restore paths are untouched. The CURRENT run's
|
||||
// gate uses the last-known repo size; a run that crosses 100% mid-flight finishes and the NEXT
|
||||
// run refuses.
|
||||
m.offboxPruneOnly(ctx, base, env)
|
||||
m.offboxRecordStats(ctx, base, env) // the prune may have brought the size back down — refresh
|
||||
runErr = fmt.Errorf("A NAS-mentés túllépte a tárhelykeretet (%d/%d GB) — törölj régi mentéseket vagy kérj nagyobb keretet.", usedGB, quota)
|
||||
} else {
|
||||
backedUp, missing, runErr = m.runOffboxInternal(ctx, apps, base, env)
|
||||
}
|
||||
|
||||
// No-silent-success: apps were toggled but NOTHING was captured (every unit missing) → promote to a
|
||||
// hard error so the run reports "error" and the operator is alerted, instead of a misleading ok/0.
|
||||
@@ -371,12 +385,16 @@ func (m *Manager) RunOffboxBackup(ctx context.Context) error {
|
||||
o.LastStatus = "ok"
|
||||
o.LastError = ""
|
||||
o.SnapshotCount = snapshots
|
||||
if len(missing) == 0 {
|
||||
o.LastWarning = ""
|
||||
} else {
|
||||
o.LastWarning = fmt.Sprintf("Figyelmeztetés: %d alkalmazásnak nincs elérhető mentése, ezek kimaradtak: %s",
|
||||
len(missing), strings.Join(missing, ", "))
|
||||
var warns []string
|
||||
if len(missing) > 0 {
|
||||
warns = append(warns, fmt.Sprintf("Figyelmeztetés: %d alkalmazásnak nincs elérhető mentése, ezek kimaradtak: %s",
|
||||
len(missing), strings.Join(missing, ", ")))
|
||||
}
|
||||
// SLICE 4: approaching the soft quota (≥80%, <100%) — warn on an otherwise-OK run.
|
||||
if qw := offboxQuotaWarning(o); qw != "" {
|
||||
warns = append(warns, qw)
|
||||
}
|
||||
o.LastWarning = strings.Join(warns, " ")
|
||||
}
|
||||
})
|
||||
if m.offboxNotify != nil {
|
||||
@@ -515,6 +533,88 @@ func (m *Manager) runOffboxInternal(ctx context.Context, apps, base, env []strin
|
||||
return backedUp, missing, nil
|
||||
}
|
||||
|
||||
// offboxGiB is the soft-quota unit: QuotaGB counts binary gigabytes (GiB) of restic restore-size.
|
||||
const offboxGiB = int64(1) << 30
|
||||
|
||||
// OffboxReportStatus is the NON-SECRET offsite summary carried on the hub report (SLICE 4) — the input
|
||||
// to the hub's OffsiteChecker (fill + staleness alerts). nil when no offbox target is configured.
|
||||
type OffboxReportStatus struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
EscrowState string `json:"escrow_state"`
|
||||
LastRun string `json:"last_run,omitempty"` // RFC3339
|
||||
LastStatus string `json:"last_status,omitempty"` // "ok" | "error" | "running"
|
||||
SnapshotCount int `json:"snapshot_count"`
|
||||
RepoSizeBytes int64 `json:"repo_size_bytes"`
|
||||
QuotaGB int `json:"quota_gb"`
|
||||
}
|
||||
|
||||
// OffboxReportStatus returns the offsite summary for the hub report (nil = not configured; the hub's
|
||||
// checker treats absence as "nothing to watch" — pre-v0.109 reports look the same).
|
||||
func (m *Manager) OffboxReportStatus() *OffboxReportStatus {
|
||||
t := m.settings.GetOffboxTarget()
|
||||
if t == nil || !t.Enabled {
|
||||
return nil
|
||||
}
|
||||
return &OffboxReportStatus{
|
||||
Enabled: true, EscrowState: t.EscrowState, LastRun: t.LastRun, LastStatus: t.LastStatus,
|
||||
SnapshotCount: t.SnapshotCount, RepoSizeBytes: t.RepoSizeBytes, QuotaGB: t.QuotaGB,
|
||||
}
|
||||
}
|
||||
|
||||
// offboxQuotaState evaluates the SLICE 4 soft-quota gate against the LAST-KNOWN repo size (a failed stats
|
||||
// call keeps the previous value — stale-but-safe). quota<=0 = no soft limit (dedicated/manual targets).
|
||||
func offboxQuotaState(t *settings.OffboxTarget) (usedGB, quotaGB int, over bool) {
|
||||
if t == nil || t.QuotaGB <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
usedGB = int(t.RepoSizeBytes / offboxGiB)
|
||||
return usedGB, t.QuotaGB, t.RepoSizeBytes >= int64(t.QuotaGB)*offboxGiB
|
||||
}
|
||||
|
||||
// offboxQuotaWarning returns the Hungarian ≥80% (<100%) usage notice, or "" (quota unset / usage fine /
|
||||
// already over — over-quota is the run-refusal error, not a warning).
|
||||
func offboxQuotaWarning(t *settings.OffboxTarget) string {
|
||||
if t == nil || t.QuotaGB <= 0 || t.RepoSizeBytes <= 0 {
|
||||
return ""
|
||||
}
|
||||
limit := int64(t.QuotaGB) * offboxGiB
|
||||
pct := t.RepoSizeBytes * 100 / limit
|
||||
if pct < 80 || t.RepoSizeBytes >= limit {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("A NAS-mentés a keret %d%%-át használja (%d/%d GB).", pct, t.RepoSizeBytes/offboxGiB, t.QuotaGB)
|
||||
}
|
||||
|
||||
// OffboxQuotaPercent returns the usage percentage for the /backups usage bar (0 when no quota/size).
|
||||
func OffboxQuotaPercent(t *settings.OffboxTarget) int {
|
||||
if t == nil || t.QuotaGB <= 0 || t.RepoSizeBytes <= 0 {
|
||||
return 0
|
||||
}
|
||||
pct := int(t.RepoSizeBytes * 100 / (int64(t.QuotaGB) * offboxGiB))
|
||||
if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
return pct
|
||||
}
|
||||
|
||||
// offboxPruneOnly runs ONLY the retention/prune step (the over-quota path: new backups are refused but
|
||||
// pruning must stay available — it is the only way back under the quota). Repo-ensure first so a fresh
|
||||
// target still fails loudly; errors are non-fatal (same as the regular run's prune).
|
||||
func (m *Manager) offboxPruneOnly(ctx context.Context, base, env []string) {
|
||||
if rerr := m.ensureOffboxRepo(ctx, base, env); rerr != nil {
|
||||
m.logger.Printf("[WARN] [offbox] over-quota prune: repo unreachable: %v", rerr)
|
||||
return
|
||||
}
|
||||
fctx, cancel := context.WithTimeout(ctx, offboxBackupTimeout)
|
||||
defer cancel()
|
||||
fargs := append(append([]string{}, base...), "forget", "--keep-daily", "7", "--keep-weekly", "4", "--keep-monthly", "6", "--prune")
|
||||
if out, ferr := m.runner()(fctx, env, fargs...); ferr != nil {
|
||||
m.logger.Printf("[WARN] [offbox] over-quota prune failed: %v: %s", ferr, truncate(out))
|
||||
} else {
|
||||
m.logger.Printf("[INFO] [offbox] over-quota: prune executed (new backups refused until under quota)")
|
||||
}
|
||||
}
|
||||
|
||||
// offboxRecordStats reads the snapshot count (best-effort) for the UI; also fills repo size when stats works.
|
||||
func (m *Manager) offboxRecordStats(ctx context.Context, base, env []string) int {
|
||||
sctx, cancel := context.WithTimeout(ctx, offboxProbeTimeout)
|
||||
@@ -529,14 +629,18 @@ func (m *Manager) offboxRecordStats(ctx context.Context, base, env []string) int
|
||||
if json.Unmarshal(out, &snaps) != nil {
|
||||
return 0
|
||||
}
|
||||
// Repo size (best-effort, restore-size).
|
||||
// Repo size (best-effort, restore-size). Bytes feed the soft-quota gate (SLICE 4); a failed stats
|
||||
// call keeps the last-known value (stale-but-safe).
|
||||
if so, serr := m.runner()(sctx, env, append(append([]string{}, base...), "stats", "--json")...); serr == nil {
|
||||
var st struct {
|
||||
TotalSize int64 `json:"total_size"`
|
||||
}
|
||||
if json.Unmarshal(so, &st) == nil && st.TotalSize > 0 {
|
||||
human := humanizeBytes(st.TotalSize)
|
||||
_ = m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.RepoSizeHuman = human })
|
||||
_ = m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||
o.RepoSizeHuman = human
|
||||
o.RepoSizeBytes = st.TotalSize
|
||||
})
|
||||
}
|
||||
}
|
||||
return len(snaps)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -272,6 +273,147 @@ func TestOffbox_RestoreRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario A (SLICE 4) — over the soft quota: the BACKUP step is refused (Hungarian error + status),
|
||||
// but the retention/prune step STILL RUNS (pruning is the customer's only way back under quota) and
|
||||
// restore is untouched. The prune-still-runs assert is the red-proofed core.
|
||||
func TestOffbox_QuotaRefusesBackupButPrunes(t *testing.T) {
|
||||
m, sett := newOffboxManager(t)
|
||||
_ = sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||
o.QuotaGB = 50
|
||||
o.RepoSizeBytes = 51 << 30 // 51 GiB — over the 50 GiB soft quota
|
||||
})
|
||||
var backups, forgets, restores int
|
||||
m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
|
||||
switch {
|
||||
case contains(args, "cat") && contains(args, "config"):
|
||||
return []byte(`{}`), nil
|
||||
case contains(args, "backup"):
|
||||
backups++
|
||||
case contains(args, "forget"):
|
||||
forgets++
|
||||
case contains(args, "restore"):
|
||||
restores++
|
||||
case contains(args, "stats"):
|
||||
return []byte(`{"total_size":123}`), nil
|
||||
case contains(args, "snapshots"):
|
||||
return []byte(`[]`), nil
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
if err := m.RunOffboxBackup(context.Background()); err == nil {
|
||||
t.Fatal("an over-quota run must report the refusal as an error")
|
||||
}
|
||||
if backups != 0 {
|
||||
t.Fatalf("NEW backups must be refused over quota, got %d backup call(s)", backups)
|
||||
}
|
||||
if forgets != 1 {
|
||||
t.Fatalf("prune MUST still run over quota (the only way back down — gating it deadlocks the customer), got %d", forgets)
|
||||
}
|
||||
tgt := sett.GetOffboxTarget()
|
||||
if tgt.LastStatus != "error" || !strings.Contains(tgt.LastError, "túllépte a tárhelykeretet") ||
|
||||
!strings.Contains(tgt.LastError, "51/50") {
|
||||
t.Fatalf("Hungarian over-quota status wrong: status=%q err=%q", tgt.LastStatus, tgt.LastError)
|
||||
}
|
||||
// restore is NEVER quota-gated: it must reach the runner even over quota
|
||||
_ = m.RestoreOffbox(context.Background(), "rallly", t.TempDir())
|
||||
if restores != 1 {
|
||||
t.Fatal("restore must NOT be quota-gated")
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario B (SLICE 4) — approaching the quota (≥80%, <100%): the run proceeds OK and the Hungarian
|
||||
// usage warning is set (visible on /backups).
|
||||
func TestOffbox_QuotaWarnsAt80Percent(t *testing.T) {
|
||||
m, sett := newOffboxManager(t)
|
||||
_ = sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.QuotaGB = 50 })
|
||||
var backups int
|
||||
m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
|
||||
switch {
|
||||
case contains(args, "cat") && contains(args, "config"):
|
||||
return []byte(`{}`), nil
|
||||
case contains(args, "backup"):
|
||||
backups++
|
||||
case contains(args, "snapshots"):
|
||||
return []byte(`[{"id":"s1"}]`), nil
|
||||
case contains(args, "stats"):
|
||||
return []byte(fmt.Sprintf(`{"total_size":%d}`, int64(42)<<30)), nil // 42 GiB of 50 = 84%
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
if err := m.RunOffboxBackup(context.Background()); err != nil {
|
||||
t.Fatalf("an 84%% run must proceed, got %v", err)
|
||||
}
|
||||
tgt := sett.GetOffboxTarget()
|
||||
if tgt.LastStatus != "ok" {
|
||||
t.Fatalf("status must be ok at 84%%, got %q (%q)", tgt.LastStatus, tgt.LastError)
|
||||
}
|
||||
if !strings.Contains(tgt.LastWarning, "84%-át használja") || !strings.Contains(tgt.LastWarning, "42/50") {
|
||||
t.Fatalf("the 80%%+ usage warning must be set, got %q", tgt.LastWarning)
|
||||
}
|
||||
if tgt.RepoSizeBytes != int64(42)<<30 {
|
||||
t.Fatalf("RepoSizeBytes must persist from stats, got %d", tgt.RepoSizeBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario C (SLICE 4) — quota 0 (dedicated/unset): no soft gate regardless of size.
|
||||
func TestOffbox_QuotaZeroMeansNoGate(t *testing.T) {
|
||||
m, sett := newOffboxManager(t)
|
||||
_ = sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||
o.QuotaGB = 0
|
||||
o.RepoSizeBytes = 900 << 30 // enormous — must not matter
|
||||
})
|
||||
var backups int
|
||||
m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
|
||||
switch {
|
||||
case contains(args, "cat") && contains(args, "config"):
|
||||
return []byte(`{}`), nil
|
||||
case contains(args, "backup"):
|
||||
backups++
|
||||
case contains(args, "snapshots"):
|
||||
return []byte(`[]`), nil
|
||||
case contains(args, "stats"):
|
||||
return []byte(`{"total_size":123}`), nil
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
if err := m.RunOffboxBackup(context.Background()); err != nil {
|
||||
t.Fatalf("quota-0 run must proceed, got %v", err)
|
||||
}
|
||||
if tgt := sett.GetOffboxTarget(); strings.Contains(tgt.LastWarning, "keret") || strings.Contains(tgt.LastError, "keret") {
|
||||
t.Fatalf("quota 0 must produce no quota warning/refusal: warn=%q err=%q", tgt.LastWarning, tgt.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
// SLICE 4 — the report carries the non-secret offsite object when enabled; nil when unconfigured
|
||||
// (absent on the wire via omitempty → the hub checker skips, no false staleness on old/plain boxes).
|
||||
func TestOffboxReportStatus(t *testing.T) {
|
||||
m, sett := newOffboxManager(t)
|
||||
_ = sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||
o.QuotaGB = 50
|
||||
o.RepoSizeBytes = 45 << 30
|
||||
o.LastStatus = "ok"
|
||||
o.LastRun = "2026-07-09T20:00:00Z"
|
||||
o.SnapshotCount = 7
|
||||
})
|
||||
st := m.OffboxReportStatus()
|
||||
if st == nil || !st.Enabled || st.EscrowState != "escrowed" || st.QuotaGB != 50 ||
|
||||
st.RepoSizeBytes != int64(45)<<30 || st.LastStatus != "ok" || st.SnapshotCount != 7 {
|
||||
t.Fatalf("offsite report status wrong: %+v", st)
|
||||
}
|
||||
// unconfigured manager → nil
|
||||
logger := log.New(os.Stderr, "", 0)
|
||||
dataDir := t.TempDir()
|
||||
sett2, err := settings.Load(filepath.Join(dataDir, "settings.json"), logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.DataDir = dataDir
|
||||
if got := NewManager(cfg, sett2, logger).OffboxReportStatus(); got != nil {
|
||||
t.Fatalf("unconfigured offbox must report nil (absent on the wire), got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOffbox_SingleFlight: an off-box run while another backup holds m.running skips (no runner call).
|
||||
func TestOffbox_SingleFlight(t *testing.T) {
|
||||
m, _ := newOffboxManager(t)
|
||||
|
||||
@@ -38,10 +38,10 @@ type (
|
||||
KeyInstaller interface {
|
||||
Install(ctx context.Context, host, user string, port int, password, privPEM, pubAuthorized, knownHosts string) error
|
||||
}
|
||||
// OffboxEnabler configures the offbox target (key + known_hosts + target) and goes EscrowState="pending"
|
||||
// (the fork-4 enable path).
|
||||
// OffboxEnabler configures the offbox target (key + known_hosts + target + soft quota) and goes
|
||||
// EscrowState="pending" (the fork-4 enable path). quotaGB=0 = no soft limit (dedicated boxes).
|
||||
OffboxEnabler interface {
|
||||
ConfigureOffbox(ctx context.Context, host, user string, port int, repoPath, privPEM, knownHosts string) error
|
||||
ConfigureOffbox(ctx context.Context, host, user string, port int, repoPath, privPEM, knownHosts string, quotaGB int) error
|
||||
}
|
||||
// KeyAuthProber checks whether an ALREADY-INSTALLED key authenticates to the target (pinned to the
|
||||
// freshly-scanned knownHosts). ok=true returns that key's PEM so the descriptor change is applied by
|
||||
@@ -136,7 +136,7 @@ func (b *Bridge) Reconcile(ctx context.Context) error {
|
||||
// on an already-provisioned guest no longer loops on consume-404.
|
||||
if b.Prober != nil {
|
||||
if privPEM, ok := b.Prober.Probe(ctx, o.Host, o.User, port, knownHostsLine); ok {
|
||||
if err := b.Enabler.ConfigureOffbox(ctx, o.Host, o.User, port, o.RepoPath, privPEM, knownHostsLine); err != nil {
|
||||
if err := b.Enabler.ConfigureOffbox(ctx, o.Host, o.User, port, o.RepoPath, privPEM, knownHostsLine, o.QuotaGB); err != nil {
|
||||
return fmt.Errorf("offsite-apply: reconfigure (key-auth-first): %w", err)
|
||||
}
|
||||
if err := b.writeMarker(h); err != nil {
|
||||
@@ -171,7 +171,7 @@ func (b *Bridge) Reconcile(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// 5) Configure the offbox target + go EscrowState="pending" (fork-4 enable path).
|
||||
if err := b.Enabler.ConfigureOffbox(ctx, o.Host, o.User, port, o.RepoPath, privPEM, knownHostsLine); err != nil {
|
||||
if err := b.Enabler.ConfigureOffbox(ctx, o.Host, o.User, port, o.RepoPath, privPEM, knownHostsLine, o.QuotaGB); err != nil {
|
||||
return fmt.Errorf("offsite-apply: configure offbox: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -81,11 +81,12 @@ type fakeEnabler struct {
|
||||
gotHost string
|
||||
gotKnownHost string
|
||||
gotPriv string
|
||||
gotQuotaGB int
|
||||
}
|
||||
|
||||
func (f *fakeEnabler) ConfigureOffbox(_ context.Context, host, _ string, _ int, _, privPEM, knownHosts string) error {
|
||||
func (f *fakeEnabler) ConfigureOffbox(_ context.Context, host, _ string, _ int, _, privPEM, knownHosts string, quotaGB int) error {
|
||||
f.calls++
|
||||
f.gotHost, f.gotKnownHost, f.gotPriv = host, knownHosts, privPEM
|
||||
f.gotHost, f.gotKnownHost, f.gotPriv, f.gotQuotaGB = host, knownHosts, privPEM, quotaGB
|
||||
return f.err
|
||||
}
|
||||
|
||||
@@ -111,7 +112,7 @@ func newBridge(t *testing.T, o config.OffsiteConfig) (*Bridge, *fakeConsumer, *f
|
||||
}
|
||||
|
||||
func goodOffsite() config.OffsiteConfig {
|
||||
return config.OffsiteConfig{Enabled: true, Type: "shared", Host: "h", User: "u", Port: 23, RepoPath: "/home/felhom-repo", HostFingerprint: "SHA256:goodfp"}
|
||||
return config.OffsiteConfig{Enabled: true, Type: "shared", Host: "h", User: "u", Port: 23, RepoPath: "/home/felhom-repo", QuotaGB: 50, HostFingerprint: "SHA256:goodfp"}
|
||||
}
|
||||
|
||||
// Scenario A — full apply: consume → verify-pin → install → configure offbox → marker persisted; pw not logged.
|
||||
@@ -132,6 +133,9 @@ func TestBridge_AppliesEndToEnd(t *testing.T) {
|
||||
if en.calls != 1 || en.gotHost != "h" || en.gotKnownHost != "[h]:23 ssh-ed25519 AAAAKEY" || en.gotPriv != "PRIVPEM" {
|
||||
t.Fatalf("enabler not called with the pinned known_hosts + key: %+v", en)
|
||||
}
|
||||
if en.gotQuotaGB != 50 {
|
||||
t.Fatalf("the bridge must map the descriptor's quota_gb into the target (SLICE 4), got %d", en.gotQuotaGB)
|
||||
}
|
||||
if b.readMarker() != descriptorHash(b.Cfg.Offsite) {
|
||||
t.Fatal("marker not persisted after a successful apply")
|
||||
}
|
||||
|
||||
@@ -27,10 +27,10 @@ type ConsumerFunc func(ctx context.Context) (string, error)
|
||||
|
||||
func (f ConsumerFunc) Consume(ctx context.Context) (string, error) { return f(ctx) }
|
||||
|
||||
type EnablerFunc func(ctx context.Context, host, user string, port int, repoPath, privPEM, knownHosts string) error
|
||||
type EnablerFunc func(ctx context.Context, host, user string, port int, repoPath, privPEM, knownHosts string, quotaGB int) error
|
||||
|
||||
func (f EnablerFunc) ConfigureOffbox(ctx context.Context, host, user string, port int, repoPath, privPEM, knownHosts string) error {
|
||||
return f(ctx, host, user, port, repoPath, privPEM, knownHosts)
|
||||
func (f EnablerFunc) ConfigureOffbox(ctx context.Context, host, user string, port int, repoPath, privPEM, knownHosts string, quotaGB int) error {
|
||||
return f(ctx, host, user, port, repoPath, privPEM, knownHosts, quotaGB)
|
||||
}
|
||||
|
||||
// --- HTTPConsumer: POST the hub consume-password endpoint with the per-customer API key ---
|
||||
|
||||
@@ -170,6 +170,9 @@ func BuildReport(
|
||||
if host, user, port, repoPath, ok := backupMgr.OffboxCoord(); ok {
|
||||
r.DRRecipe.OffsiteRestic = &DRResticCoord{Host: host, User: user, Port: port, RepoPath: repoPath}
|
||||
}
|
||||
// SLICE 4: the non-secret offsite status object — the hub's OffsiteChecker input (fill +
|
||||
// staleness). nil (absent on the wire) when no offbox target is enabled.
|
||||
r.Offsite = backupMgr.OffboxReportStatus()
|
||||
}
|
||||
|
||||
if debug && logger != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package report
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
|
||||
)
|
||||
|
||||
@@ -28,6 +29,11 @@ type Report struct {
|
||||
// DR recipe — the controller (customer + apps) half of the secret-free reconstruction recipe
|
||||
// (SPIKE-dr-recipe-2026-06-16). The hub assembles it with the agent's storage/guest/PBS half.
|
||||
DRRecipe *DRRecipeAppHalf `json:"dr_recipe,omitempty"`
|
||||
|
||||
// Offsite (SLICE 4) — the non-secret offbox status for the hub's OffsiteChecker (fill + staleness
|
||||
// alerts). Absent when no offbox target is enabled (and on pre-v0.109 controllers — the checker is
|
||||
// nil-safe on both).
|
||||
Offsite *backup.OffboxReportStatus `json:"offsite,omitempty"`
|
||||
}
|
||||
|
||||
// SystemReport holds host-level system info.
|
||||
|
||||
@@ -118,6 +118,10 @@ type OffboxTarget struct {
|
||||
User string `json:"user"`
|
||||
RepoPath string `json:"repo_path"` // absolute path on the NAS, e.g. /volume1/felhom-backup/repo
|
||||
Schedule string `json:"schedule"` // "daily" | "manual"
|
||||
// QuotaGB is the shared-model SOFT quota (SLICE 4), mapped from the hub descriptor by the
|
||||
// apply-bridge. 0 = no soft limit (dedicated boxes are Hetzner-enforced; manual targets unset).
|
||||
// Felhom-enforced: at ≥100% NEW backup runs are refused (prune/restore never are); ≥80% warns.
|
||||
QuotaGB int `json:"quota_gb,omitempty"`
|
||||
|
||||
// Runtime status (written by the off-box runner; never holds a secret).
|
||||
LastRun string `json:"last_run,omitempty"` // RFC3339
|
||||
@@ -125,7 +129,10 @@ type OffboxTarget struct {
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastDuration string `json:"last_duration,omitempty"`
|
||||
RepoSizeHuman string `json:"repo_size_human,omitempty"`
|
||||
SnapshotCount int `json:"snapshot_count,omitempty"`
|
||||
// RepoSizeBytes (SLICE 4) is the machine-readable repo size from `restic stats` — the soft-quota
|
||||
// gate's input (last-known value; a failed stats call keeps the previous one — stale-but-safe).
|
||||
RepoSizeBytes int64 `json:"repo_size_bytes,omitempty"`
|
||||
SnapshotCount int `json:"snapshot_count,omitempty"`
|
||||
// LastWarning is a customer-visible notice set on an otherwise-OK run when SOME toggled apps had
|
||||
// no discoverable recovery unit (partial run). Empty on a fully-successful or failed run.
|
||||
LastWarning string `json:"last_warning,omitempty"`
|
||||
|
||||
@@ -638,9 +638,12 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data["DBDumpTotalBytes"] = dbDumpTotalBytes
|
||||
|
||||
// Off-box (NAS) restic-SFTP backup (Part B): the target status + per-app off-box toggles.
|
||||
data["Offbox"] = s.settings.GetOffboxTarget()
|
||||
offboxTgt := s.settings.GetOffboxTarget()
|
||||
data["Offbox"] = offboxTgt
|
||||
data["OffboxConfigured"] = s.backupMgr.OffboxConfigured()
|
||||
data["OffboxApps"] = s.buildOffboxApps()
|
||||
// SLICE 4 soft-quota usage bar (rendered only when a quota is set — shared model).
|
||||
data["OffboxQuotaPct"] = backup.OffboxQuotaPercent(offboxTgt)
|
||||
} else {
|
||||
data["Backup"] = nil
|
||||
}
|
||||
|
||||
@@ -140,6 +140,15 @@
|
||||
<div class="stat-label">{{if .Offbox.Enabled}}{{.Offbox.RepoPath}}{{else}}A NAS-mentés ki van kapcsolva{{end}}</div>
|
||||
</div>
|
||||
</div>
|
||||
{{if and .Offbox.Enabled (gt .Offbox.QuotaGB 0)}}
|
||||
<!-- SLICE 4: soft-quota usage bar (shared model; quota_gb from the hub descriptor). -->
|
||||
<div id="offbox-quota-bar" style="max-width:560px;margin:.5rem 0">
|
||||
<div class="stat-label" style="margin-bottom:.25rem">Tárhelykeret: {{if .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}0{{end}} / {{.Offbox.QuotaGB}} GB ({{.OffboxQuotaPct}}%)</div>
|
||||
<div style="background:var(--border,#334);border-radius:4px;height:8px;overflow:hidden">
|
||||
<div style="height:8px;border-radius:4px;width:{{.OffboxQuotaPct}}%;background:{{if ge .OffboxQuotaPct 100}}var(--crit,#e5484d){{else if ge .OffboxQuotaPct 80}}var(--warn,#f5a524){{else}}var(--ok,#30a46c){{end}}"></div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Offbox.LastError}}<p class="form-hint" style="color:var(--crit)">Utolsó hiba: {{.Offbox.LastError}}</p>{{end}}
|
||||
{{if .Offbox.LastWarning}}<p class="form-hint" style="color:var(--warn)">{{.Offbox.LastWarning}}</p>{{end}}
|
||||
{{if and .OffboxConfigured (ne .Offbox.EscrowState "escrowed")}}
|
||||
|
||||
Reference in New Issue
Block a user