Files
felhom.eu/hub/internal/store/offsite_keys.go
T
2026-10-04 08:56:56 +02:00

327 lines
12 KiB
Go

package store
import (
"database/sql"
"fmt"
"strconv"
"strings"
"time"
)
// OffsiteKey is the registrar's record of the box key the hub installed pinned (decision 69).
type OffsiteKey struct {
CustomerID string
Fingerprint string
InstalledAt time.Time
ConfirmedAt time.Time // zero = the box has not confirmed it yet
}
// RecordOffsiteKeyInstalled records (last-write-wins) the key the hub just installed; confirmation resets.
func (s *Store) RecordOffsiteKeyInstalled(customerID, fp string) error {
_, err := s.db.Exec(`
INSERT INTO offsite_keys (customer_id, fingerprint, installed_at, confirmed_at) VALUES (?, ?, datetime('now'), NULL)
ON CONFLICT(customer_id) DO UPDATE SET fingerprint = excluded.fingerprint, installed_at = datetime('now'), confirmed_at = NULL`,
customerID, fp)
return err
}
// RecordOffsiteKeyConfirmed marks the installed key confirmed by the box; false when fp is not the key on record.
func (s *Store) RecordOffsiteKeyConfirmed(customerID, fp string) (bool, error) {
res, err := s.db.Exec(`UPDATE offsite_keys SET confirmed_at = datetime('now') WHERE customer_id = ? AND fingerprint = ?`, customerID, fp)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
// GetOffsiteKey returns the record, or (nil, nil) when none exists.
func (s *Store) GetOffsiteKey(customerID string) (*OffsiteKey, error) {
var k OffsiteKey
var inst string
var conf sql.NullString
err := s.db.QueryRow(`SELECT customer_id, fingerprint, installed_at, confirmed_at FROM offsite_keys WHERE customer_id = ?`, customerID).
Scan(&k.CustomerID, &k.Fingerprint, &inst, &conf)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
k.InstalledAt = parseSQLiteTime(inst)
if conf.Valid {
k.ConfirmedAt = parseSQLiteTime(conf.String)
}
return &k, nil
}
// OffsiteWindowOpen reports whether a clean-up window is open for the customer right now (decision 68):
// a window row not closed and not past its closes_by. Errors read as "closed" — the key check then
// alarms on a window line, which is the safe side.
func (s *Store) OffsiteWindowOpen(customerID string) bool {
var n int
err := s.db.QueryRow(`SELECT COUNT(*) FROM offsite_windows WHERE customer_id = ? AND closed_at IS NULL AND closes_by > datetime('now')`, customerID).Scan(&n)
return err == nil && n > 0
}
// OffsiteWindow is one clean-up window (decision 68).
type OffsiteWindow struct {
ID int64
CustomerID string
OpenedAt time.Time
ClosesBy time.Time
ClosedAt time.Time
CountBefore int
CountAfter int
BoxResult string
CloseReason string
// MaxRemove is the cap this window was opened with (R-833: an operator grant may raise it for one
// window). 0 on rows written before v0.129.0 — readers fall back to the default cap then.
MaxRemove int
}
// OpenOffsiteWindowRow records a window the hub just opened, with the cap it was opened under.
func (s *Store) OpenOffsiteWindowRow(customerID string, closesBy time.Time, countBefore, maxRemove int) (int64, error) {
res, err := s.db.Exec(`INSERT INTO offsite_windows (customer_id, opened_at, closes_by, count_before, max_remove) VALUES (?, datetime('now'), ?, ?, ?)`,
customerID, closesBy.UTC().Format("2006-01-02 15:04:05"), countBefore, maxRemove)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// CloseOffsiteWindowRow closes a window (idempotent: an already-closed row is not touched).
func (s *Store) CloseOffsiteWindowRow(id int64, countAfter int, boxResult, reason string) (bool, error) {
res, err := s.db.Exec(`UPDATE offsite_windows SET closed_at = datetime('now'), count_after = ?, box_result = ?, close_reason = ? WHERE id = ? AND closed_at IS NULL`,
countAfter, boxResult, reason, id)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
// GetOffsiteWindow returns one window row, or (nil, nil).
func (s *Store) GetOffsiteWindow(id int64) (*OffsiteWindow, error) {
var w OffsiteWindow
var opened, closesBy string
var closed, boxRes, reason sql.NullString
var before, after, maxRm sql.NullInt64
err := s.db.QueryRow(`SELECT id, customer_id, opened_at, closes_by, closed_at, count_before, count_after, box_result, close_reason, max_remove FROM offsite_windows WHERE id = ?`, id).
Scan(&w.ID, &w.CustomerID, &opened, &closesBy, &closed, &before, &after, &boxRes, &reason, &maxRm)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
w.OpenedAt, w.ClosesBy = parseSQLiteTime(opened), parseSQLiteTime(closesBy)
if closed.Valid {
w.ClosedAt = parseSQLiteTime(closed.String)
}
w.CountBefore, w.CountAfter = int(before.Int64), int(after.Int64)
w.BoxResult, w.CloseReason = boxRes.String, reason.String
w.MaxRemove = int(maxRm.Int64)
return &w, nil
}
// LastOffsiteWindowOpened returns when the customer's most recent window was opened (zero = never).
func (s *Store) LastOffsiteWindowOpened(customerID string) time.Time {
var v sql.NullString
if err := s.db.QueryRow(`SELECT MAX(opened_at) FROM offsite_windows WHERE customer_id = ?`, customerID).Scan(&v); err != nil || !v.Valid {
return time.Time{}
}
return parseSQLiteTime(v.String)
}
// ExpiredOffsiteWindows lists windows still open past their closes_by.
func (s *Store) ExpiredOffsiteWindows() ([]OffsiteWindow, error) {
rows, err := s.db.Query(`SELECT id, customer_id FROM offsite_windows WHERE closed_at IS NULL AND closes_by <= datetime('now')`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []OffsiteWindow
for rows.Next() {
var w OffsiteWindow
if err := rows.Scan(&w.ID, &w.CustomerID); err != nil {
return nil, err
}
out = append(out, w)
}
return out, rows.Err()
}
const offsiteWindowsEnabledKey = "offsite_prune_windows_enabled"
// OffsiteWindowsEnabled — the operator switch for WEEKLY windows (decision 68). Off by default: the
// interim is "nothing prunes" until the operator turns the weekly window on.
func (s *Store) OffsiteWindowsEnabled() bool { return s.getSetting(offsiteWindowsEnabledKey) == "on" }
// SetOffsiteWindowsEnabled flips the weekly switch.
func (s *Store) SetOffsiteWindowsEnabled(on bool) error {
v := ""
if on {
v = "on"
}
return s.setSetting(offsiteWindowsEnabledKey, v)
}
// GrantOffsiteWindowOnce lets the customer's NEXT window request through regardless of the weekly
// cadence (operator one-shot).
func (s *Store) GrantOffsiteWindowOnce(customerID string) error {
return s.setSetting("offsite_window_grant:"+customerID, "1")
}
// GrantOffsiteWindowOnceMax is the operator's one-shot grant that ALSO raises the removal cap for that
// one window (R-833: after a long gap the honest backlog exceeds half the snapshots and the default cap
// refuses every window). The raised cap is consumed with the grant; the next window has the default.
func (s *Store) GrantOffsiteWindowOnceMax(customerID string, maxRemove int) error {
if maxRemove <= 0 {
return fmt.Errorf("max_remove must be positive, got %d", maxRemove)
}
return s.setSetting("offsite_window_grant:"+customerID, "max:"+strconv.Itoa(maxRemove))
}
// TakeOffsiteWindowGrant consumes a one-shot grant: granted is true when one was present, and
// maxRemove is the operator's raised cap for that window (0 = none, use the default).
func (s *Store) TakeOffsiteWindowGrant(customerID string) (granted bool, maxRemove int) {
k := "offsite_window_grant:" + customerID
v := s.getSetting(k)
switch {
case v == "1":
case strings.HasPrefix(v, "max:"):
n, err := strconv.Atoi(strings.TrimPrefix(v, "max:"))
if err != nil || n <= 0 {
n = 0 // a malformed value still grants the window, at the default cap
}
maxRemove = n
default:
return false, 0
}
_ = s.setSetting(k, "")
return true, maxRemove
}
// ForceOffsiteWindowDueForTest back-dates a window's closes_by. TEST-ONLY.
func (s *Store) ForceOffsiteWindowDueForTest(id int64) error {
_, err := s.db.Exec(`UPDATE offsite_windows SET closes_by = datetime('now', '-1 minute') WHERE id = ?`, id)
return err
}
// OffsiteAbandon is one household request to delete a set-aside off-site copy (decision 74, R-823). The
// hub deletes it only after DueAt, and only if nobody cancelled it.
type OffsiteAbandon struct {
ID int64
CustomerID string
Path string
RequestedAt time.Time
DueAt time.Time
CancelledAt time.Time
CancelledBy string
DeletedAt time.Time
LastError string
}
func (a *OffsiteAbandon) State() string {
switch {
case a == nil:
return "none"
case !a.DeletedAt.IsZero():
return "deleted"
case !a.CancelledAt.IsZero():
return "cancelled"
}
return "pending"
}
const abandonCols = `id, customer_id, path, requested_at, due_at, cancelled_at, cancelled_by, deleted_at, last_error`
func scanAbandon(sc interface{ Scan(...any) error }) (*OffsiteAbandon, error) {
var a OffsiteAbandon
var req, due string
var canc, by, del, lerr sql.NullString
if err := sc.Scan(&a.ID, &a.CustomerID, &a.Path, &req, &due, &canc, &by, &del, &lerr); err != nil {
return nil, err
}
a.RequestedAt, a.DueAt = parseSQLiteTime(req), parseSQLiteTime(due)
if canc.Valid {
a.CancelledAt = parseSQLiteTime(canc.String)
}
if del.Valid {
a.DeletedAt = parseSQLiteTime(del.String)
}
a.CancelledBy, a.LastError = by.String, lerr.String
return &a, nil
}
// LatestOffsiteAbandon returns the customer's most recent request for path ("" = any), or (nil, nil).
func (s *Store) LatestOffsiteAbandon(customerID, path string) (*OffsiteAbandon, error) {
q := `SELECT ` + abandonCols + ` FROM offsite_abandon_requests WHERE customer_id = ?`
args := []any{customerID}
if path != "" {
q += ` AND path = ?`
args = append(args, path)
}
a, err := scanAbandon(s.db.QueryRow(q+` ORDER BY id DESC LIMIT 1`, args...))
if err == sql.ErrNoRows {
return nil, nil
}
return a, err
}
// CreateOffsiteAbandon records a request due at dueAt.
func (s *Store) CreateOffsiteAbandon(customerID, path string, dueAt time.Time) (int64, error) {
res, err := s.db.Exec(`INSERT INTO offsite_abandon_requests (customer_id, path, requested_at, due_at) VALUES (?, ?, datetime('now'), ?)`,
customerID, path, dueAt.UTC().Format("2006-01-02 15:04:05"))
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// CancelOffsiteAbandon cancels every PENDING request of the customer; returns how many.
func (s *Store) CancelOffsiteAbandon(customerID, by string) (int, error) {
res, err := s.db.Exec(`UPDATE offsite_abandon_requests SET cancelled_at = datetime('now'), cancelled_by = ? WHERE customer_id = ? AND cancelled_at IS NULL AND deleted_at IS NULL`, by, customerID)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return int(n), nil
}
// DueOffsiteAbandons lists pending requests whose due time has passed.
func (s *Store) DueOffsiteAbandons() ([]*OffsiteAbandon, error) {
rows, err := s.db.Query(`SELECT ` + abandonCols + ` FROM offsite_abandon_requests WHERE cancelled_at IS NULL AND deleted_at IS NULL AND due_at <= datetime('now')`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*OffsiteAbandon
for rows.Next() {
a, err := scanAbandon(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
// MarkOffsiteAbandonDeleted / MarkOffsiteAbandonError record the sweep's outcome.
func (s *Store) MarkOffsiteAbandonDeleted(id int64) error {
_, err := s.db.Exec(`UPDATE offsite_abandon_requests SET deleted_at = datetime('now'), last_error = NULL WHERE id = ?`, id)
return err
}
func (s *Store) MarkOffsiteAbandonError(id int64, msg string) error {
_, err := s.db.Exec(`UPDATE offsite_abandon_requests SET last_error = ? WHERE id = ?`, msg, id)
return err
}
// ForceOffsiteAbandonDueForTest back-dates a request. TEST-ONLY.
func (s *Store) ForceOffsiteAbandonDueForTest(id int64) error {
_, err := s.db.Exec(`UPDATE offsite_abandon_requests SET due_at = datetime('now', '-1 minute') WHERE id = ?`, id)
return err
}