diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a87d7b..618ead8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ ## Changelog +### v0.109.0 — SLICE 4: soft-quota gate + usage bar + offsite report status (2026-07-09) + +The shared-model soft quota (`quota_gb`) enforced controller-side (pairs with hub v0.41.0's +OffsiteChecker + freeze lever). No secrets anywhere in this slice — sizes/timestamps only. + +- **`internal/settings`:** `OffboxTarget.QuotaGB` (mapped from the hub descriptor by the apply-bridge — + `OffboxEnabler` seam gains `quotaGB`; 0 = no soft limit, dedicated boxes are Hetzner-enforced) + + `RepoSizeBytes` (machine-readable size persisted from `restic stats` alongside the human string; a failed + stats call keeps the last-known value — stale-but-safe). +- **Pre-run soft-quota gate (`RunOffboxBackup`):** at **≥100%** NEW backup runs are refused — + `LastStatus="error"` with the Hungarian notice ("A NAS-mentés túllépte a tárhelykeretet (X/Y GB) — törölj + régi mentéseket vagy kérj nagyobb keretet."), operator alert via the existing `offboxNotify` path — but + the **retention/prune step STILL RUNS** (`offboxPruneOnly`; pruning is the customer's only way back under + quota — gating it would deadlock them) and **restore is never gated**. **Companion red-proof:** gated the + prune too → "prune MUST still run over quota, got 0" → test FAILED. Reverted. The gate is pre-run: a run + crossing 100% mid-flight finishes; the next refuses. At **≥80%** (<100%) an OK run sets the Hungarian + usage `LastWarning` ("A NAS-mentés a keret X%-át használja (A/B GB)."). +- **UI:** `/backups` gains a soft-quota usage bar (used/quota + %, green/amber/red) — rendered only when + `QuotaGB > 0`. Template gates green. +- **`internal/report`:** the hub report gains `offsite:{enabled, escrow_state, last_run, last_status, + snapshot_count, repo_size_bytes, quota_gb}` (`backup.OffboxReportStatus`; absent when no offbox target — + the hub checker is nil-safe on old controllers). +- Tests: over-quota refusal (backup 0 calls, prune 1 call, Hungarian status, restore ungated); 84% warn + + bytes persisted; quota-0 no gate; report object + nil when unconfigured; bridge quota mapping. + ### v0.108.0 — SLICE 3: hub-verified escrow auto-confirm (current-password hash match) (2026-07-09) Replaces operator trust with a verified fact (pairs with agent v0.79.0 + hub v0.40.0): the report ACK now diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 31cac74..172ae08 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -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 { diff --git a/controller/internal/backup/offbox.go b/controller/internal/backup/offbox.go index 4a1eb49..2068ba6 100644 --- a/controller/internal/backup/offbox.go +++ b/controller/internal/backup/offbox.go @@ -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) diff --git a/controller/internal/backup/offbox_test.go b/controller/internal/backup/offbox_test.go index 2b874da..da26cae 100644 --- a/controller/internal/backup/offbox_test.go +++ b/controller/internal/backup/offbox_test.go @@ -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) diff --git a/controller/internal/offsiteapply/offsiteapply.go b/controller/internal/offsiteapply/offsiteapply.go index fdfe251..a8e35f1 100644 --- a/controller/internal/offsiteapply/offsiteapply.go +++ b/controller/internal/offsiteapply/offsiteapply.go @@ -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) } diff --git a/controller/internal/offsiteapply/offsiteapply_test.go b/controller/internal/offsiteapply/offsiteapply_test.go index 1fa5dd2..c4a98a0 100644 --- a/controller/internal/offsiteapply/offsiteapply_test.go +++ b/controller/internal/offsiteapply/offsiteapply_test.go @@ -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") } diff --git a/controller/internal/offsiteapply/seams.go b/controller/internal/offsiteapply/seams.go index bb143d4..91c0e3f 100644 --- a/controller/internal/offsiteapply/seams.go +++ b/controller/internal/offsiteapply/seams.go @@ -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 --- diff --git a/controller/internal/report/builder.go b/controller/internal/report/builder.go index c6ff478..f0cf5e3 100644 --- a/controller/internal/report/builder.go +++ b/controller/internal/report/builder.go @@ -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 { diff --git a/controller/internal/report/types.go b/controller/internal/report/types.go index b9072ef..56a8e88 100644 --- a/controller/internal/report/types.go +++ b/controller/internal/report/types.go @@ -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. diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index e3a970e..695f506 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -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"` diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index a5e5892..37fb856 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -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 } diff --git a/controller/internal/web/templates/backups.html b/controller/internal/web/templates/backups.html index f14af5c..e3638e2 100644 --- a/controller/internal/web/templates/backups.html +++ b/controller/internal/web/templates/backups.html @@ -140,6 +140,15 @@
{{if .Offbox.Enabled}}{{.Offbox.RepoPath}}{{else}}A NAS-mentés ki van kapcsolva{{end}}
+ {{if and .Offbox.Enabled (gt .Offbox.QuotaGB 0)}} + +
+
Tárhelykeret: {{if .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}0{{end}} / {{.Offbox.QuotaGB}} GB ({{.OffboxQuotaPct}}%)
+
+
+
+
+ {{end}} {{if .Offbox.LastError}}

Utolsó hiba: {{.Offbox.LastError}}

{{end}} {{if .Offbox.LastWarning}}

{{.Offbox.LastWarning}}

{{end}} {{if and .OffboxConfigured (ne .Offbox.EscrowState "escrowed")}}