Files
backup-master/internal/server/store.go
T
mrleo1nidandClaude Opus 5.5 488abc2d34
CI / test (push) Successful in 1m29s
Агенты подхватывают изменения с сервера: каталог и самообновление
- сервер отдаёт агенту итоговый каталог (встроенный каталог сервера +
  переопределения клиента); если каталог не подходит версии агента —
  встроенный каталог агента и предупреждение
- самообновление перед бэкапом: версия сервера новее → скачивание с того же
  сервера (только /dl/...), проверка sha256 и запуска новой версии, подмена
  с откатом, перезапуск через exec; при ошибке бэкап идёт текущей версией
- галочка «Автообновление» у клиента (по умолчанию включена), версия агента
  в списке и карточке, отставшие подсвечены; версия сервера в шапке
- миграция существующей базы: колонки auto_update и agent_version
- backupctl backup --no-update

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:27:31 +03:00

502 lines
15 KiB
Go

package server
import (
"database/sql"
"errors"
"fmt"
"time"
_ "modernc.org/sqlite"
)
const schema = `
CREATE TABLE IF NOT EXISTS clients (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
domain TEXT NOT NULL UNIQUE,
token_hash TEXT NOT NULL UNIQUE,
config_yaml TEXT NOT NULL DEFAULT '',
keep_daily INTEGER NOT NULL DEFAULT 7,
keep_weekly INTEGER NOT NULL DEFAULT 4,
keep_monthly INTEGER NOT NULL DEFAULT 6,
heavy_keep INTEGER NOT NULL DEFAULT 1,
heavy_every_days INTEGER NOT NULL DEFAULT 7,
stale_hours INTEGER NOT NULL DEFAULT 26,
created_at INTEGER NOT NULL,
last_backup_at INTEGER NOT NULL DEFAULT 0,
last_heavy_at INTEGER NOT NULL DEFAULT 0,
last_seen_at INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
last_error_at INTEGER NOT NULL DEFAULT 0,
stale_notified INTEGER NOT NULL DEFAULT 0,
disabled INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS backups (
id INTEGER PRIMARY KEY,
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
status TEXT NOT NULL,
dir TEXT NOT NULL DEFAULT '',
manifest TEXT NOT NULL,
total_size INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS backups_client ON backups(client_id, created_at);
CREATE TABLE IF NOT EXISTS parts (
backup_id INTEGER NOT NULL REFERENCES backups(id) ON DELETE CASCADE,
name TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
sha256 TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL,
PRIMARY KEY (backup_id, name)
);
CREATE TABLE IF NOT EXISTS restore_tokens (
token_hash TEXT PRIMARY KEY,
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
csrf TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`
const (
StatusUploading = "uploading"
StatusComplete = "complete"
StatusFailed = "failed"
PartUploaded = "uploaded"
PartComplete = "complete"
PartDeleted = "deleted" // heavy-часть удалена ротацией
)
var ErrNotFound = errors.New("не найдено")
type Store struct {
db *sql.DB
}
func OpenStore(path string) (*Store, error) {
db, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)")
if err != nil {
return nil, err
}
// одно соединение: исключает SQLITE_BUSY, нагрузка у сервиса мизерная
db.SetMaxOpenConns(1)
if _, err := db.Exec(schema); err != nil {
db.Close()
return nil, fmt.Errorf("миграция: %w", err)
}
st := &Store{db: db}
// колонки, добавленные после первых релизов: у существующих баз их нет
for _, col := range []struct{ table, name, def string }{
{"clients", "auto_update", "INTEGER NOT NULL DEFAULT 1"},
{"clients", "agent_version", "TEXT NOT NULL DEFAULT ''"},
} {
if err := st.ensureColumn(col.table, col.name, col.def); err != nil {
db.Close()
return nil, fmt.Errorf("миграция %s.%s: %w", col.table, col.name, err)
}
}
return st, nil
}
func (s *Store) ensureColumn(table, name, def string) error {
rows, err := s.db.Query(`SELECT name FROM pragma_table_info(?)`, table)
if err != nil {
return err
}
found := false
for rows.Next() {
var n string
if rows.Scan(&n) == nil && n == name {
found = true
}
}
rows.Close()
if found {
return nil
}
_, err = s.db.Exec(fmt.Sprintf(`ALTER TABLE %s ADD COLUMN %s %s`, table, name, def))
return err
}
func (s *Store) Close() error { return s.db.Close() }
type Client struct {
ID int64
Name string
Domain string
TokenHash string
ConfigYAML string
KeepDaily int
KeepWeekly int
KeepMonthly int
HeavyKeep int
HeavyEveryDays int
StaleHours int
CreatedAt int64
LastBackupAt int64
LastHeavyAt int64
LastSeenAt int64
LastError string
LastErrorAt int64
StaleNotified bool
Disabled bool
AutoUpdate bool // агент сам обновляется до версии сервера
AgentVersion string // версия агента при последнем обращении
}
const clientCols = `id, name, domain, token_hash, config_yaml, keep_daily, keep_weekly, keep_monthly,
heavy_keep, heavy_every_days, stale_hours, created_at, last_backup_at, last_heavy_at, last_seen_at,
last_error, last_error_at, stale_notified, disabled, auto_update, agent_version`
func scanClient(row interface{ Scan(...any) error }) (*Client, error) {
var c Client
err := row.Scan(&c.ID, &c.Name, &c.Domain, &c.TokenHash, &c.ConfigYAML, &c.KeepDaily, &c.KeepWeekly,
&c.KeepMonthly, &c.HeavyKeep, &c.HeavyEveryDays, &c.StaleHours, &c.CreatedAt, &c.LastBackupAt,
&c.LastHeavyAt, &c.LastSeenAt, &c.LastError, &c.LastErrorAt, &c.StaleNotified, &c.Disabled,
&c.AutoUpdate, &c.AgentVersion)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return &c, err
}
func (s *Store) CreateClient(name, domain, tokenHash string) (int64, error) {
res, err := s.db.Exec(`INSERT INTO clients (name, domain, token_hash, created_at) VALUES (?, ?, ?, ?)`,
name, domain, tokenHash, time.Now().Unix())
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) GetClient(id int64) (*Client, error) {
return scanClient(s.db.QueryRow(`SELECT `+clientCols+` FROM clients WHERE id = ?`, id))
}
func (s *Store) ClientByToken(hash string) (*Client, error) {
return scanClient(s.db.QueryRow(`SELECT `+clientCols+` FROM clients WHERE token_hash = ?`, hash))
}
func (s *Store) ListClients() ([]*Client, error) {
rows, err := s.db.Query(`SELECT ` + clientCols + ` FROM clients ORDER BY domain`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Client
for rows.Next() {
c, err := scanClient(rows)
if err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
func (s *Store) UpdateClientSettings(c *Client) error {
_, err := s.db.Exec(`UPDATE clients SET name = ?, config_yaml = ?, keep_daily = ?, keep_weekly = ?,
keep_monthly = ?, heavy_keep = ?, heavy_every_days = ?, stale_hours = ?, disabled = ?, auto_update = ?
WHERE id = ?`,
c.Name, c.ConfigYAML, c.KeepDaily, c.KeepWeekly, c.KeepMonthly, c.HeavyKeep, c.HeavyEveryDays,
c.StaleHours, c.Disabled, c.AutoUpdate, c.ID)
return err
}
func (s *Store) SetClientToken(id int64, hash string) error {
_, err := s.db.Exec(`UPDATE clients SET token_hash = ? WHERE id = ?`, hash, id)
return err
}
func (s *Store) DeleteClient(id int64) error {
_, err := s.db.Exec(`DELETE FROM clients WHERE id = ?`, id)
return err
}
func (s *Store) TouchClient(id int64) error {
_, err := s.db.Exec(`UPDATE clients SET last_seen_at = ? WHERE id = ?`, time.Now().Unix(), id)
return err
}
func (s *Store) SetAgentVersion(id int64, version string) error {
_, err := s.db.Exec(`UPDATE clients SET agent_version = ? WHERE id = ?`, version, id)
return err
}
func (s *Store) MarkClientSuccess(id int64, at int64, heavy bool) error {
q := `UPDATE clients SET last_backup_at = ?1, last_error = '', last_error_at = 0, stale_notified = 0`
if heavy {
q += `, last_heavy_at = ?1`
}
_, err := s.db.Exec(q+` WHERE id = ?2`, at, id)
return err
}
func (s *Store) MarkClientError(id int64, msg string) error {
_, err := s.db.Exec(`UPDATE clients SET last_error = ?, last_error_at = ? WHERE id = ?`, msg, time.Now().Unix(), id)
return err
}
func (s *Store) SetStaleNotified(id int64) error {
_, err := s.db.Exec(`UPDATE clients SET stale_notified = 1 WHERE id = ?`, id)
return err
}
type Backup struct {
ID int64
ClientID int64
CreatedAt int64
Status string
Dir string
Manifest string
TotalSize int64
Error string
Parts []Part
}
type Part struct {
BackupID int64
Name string
Size int64
SHA256 string
Status string
}
func (s *Store) CreateBackup(clientID int64, createdAt int64, manifest string) (int64, error) {
res, err := s.db.Exec(`INSERT INTO backups (client_id, created_at, status, manifest) VALUES (?, ?, ?, ?)`,
clientID, createdAt, StatusUploading, manifest)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) SetBackupDir(id int64, dir string) error {
_, err := s.db.Exec(`UPDATE backups SET dir = ? WHERE id = ?`, dir, id)
return err
}
func (s *Store) SetBackupStatus(id int64, status, errMsg string, totalSize int64) error {
_, err := s.db.Exec(`UPDATE backups SET status = ?, error = ?, total_size = ? WHERE id = ?`, status, errMsg, totalSize, id)
return err
}
func (s *Store) SetBackupManifest(id int64, manifest string) error {
_, err := s.db.Exec(`UPDATE backups SET manifest = ? WHERE id = ?`, manifest, id)
return err
}
func (s *Store) DeleteBackup(id int64) error {
_, err := s.db.Exec(`DELETE FROM backups WHERE id = ?`, id)
return err
}
func (s *Store) GetBackup(id int64) (*Backup, error) {
var b Backup
err := s.db.QueryRow(`SELECT id, client_id, created_at, status, dir, manifest, total_size, error FROM backups WHERE id = ?`, id).
Scan(&b.ID, &b.ClientID, &b.CreatedAt, &b.Status, &b.Dir, &b.Manifest, &b.TotalSize, &b.Error)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
b.Parts, err = s.parts(id)
return &b, err
}
func (s *Store) parts(backupID int64) ([]Part, error) {
rows, err := s.db.Query(`SELECT backup_id, name, size, sha256, status FROM parts WHERE backup_id = ? ORDER BY name = 'main' DESC, name`, backupID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Part
for rows.Next() {
var p Part
if err := rows.Scan(&p.BackupID, &p.Name, &p.Size, &p.SHA256, &p.Status); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// ListBackups возвращает бэкапы клиента от новых к старым (без манифеста), с частями.
func (s *Store) ListBackups(clientID int64, status string) ([]*Backup, error) {
q := `SELECT id, client_id, created_at, status, dir, total_size, error FROM backups WHERE client_id = ?`
args := []any{clientID}
if status != "" {
q += ` AND status = ?`
args = append(args, status)
}
rows, err := s.db.Query(q+` ORDER BY created_at DESC, id DESC`, args...)
if err != nil {
return nil, err
}
var out []*Backup
for rows.Next() {
var b Backup
if err := rows.Scan(&b.ID, &b.ClientID, &b.CreatedAt, &b.Status, &b.Dir, &b.TotalSize, &b.Error); err != nil {
rows.Close()
return nil, err
}
out = append(out, &b)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
for _, b := range out {
if b.Parts, err = s.parts(b.ID); err != nil {
return nil, err
}
}
return out, nil
}
// LastSizes возвращает размер последнего успешного бэкапа по каждому клиенту одним запросом.
func (s *Store) LastSizes() (map[int64]int64, error) {
rows, err := s.db.Query(`SELECT client_id, total_size FROM backups b WHERE status = ?1 AND id = (
SELECT id FROM backups WHERE client_id = b.client_id AND status = ?1 ORDER BY created_at DESC, id DESC LIMIT 1)`, StatusComplete)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int64]int64{}
for rows.Next() {
var id, size int64
if err := rows.Scan(&id, &size); err != nil {
return nil, err
}
out[id] = size
}
return out, rows.Err()
}
// LatestComplete возвращает последний успешный бэкап клиента.
func (s *Store) LatestComplete(clientID int64) (*Backup, error) {
var id int64
err := s.db.QueryRow(`SELECT id FROM backups WHERE client_id = ? AND status = ? ORDER BY created_at DESC, id DESC LIMIT 1`,
clientID, StatusComplete).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return s.GetBackup(id)
}
// UploadingBefore возвращает незавершённые загрузки старше ts (по всем клиентам или одному).
func (s *Store) UploadingBefore(ts int64, clientID int64) ([]*Backup, error) {
q := `SELECT id, client_id, dir FROM backups WHERE status = ? AND created_at < ?`
args := []any{StatusUploading, ts}
if clientID != 0 {
q += ` AND client_id = ?`
args = append(args, clientID)
}
rows, err := s.db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Backup
for rows.Next() {
var b Backup
if err := rows.Scan(&b.ID, &b.ClientID, &b.Dir); err != nil {
return nil, err
}
out = append(out, &b)
}
return out, rows.Err()
}
func (s *Store) UpsertPart(p Part) error {
_, err := s.db.Exec(`INSERT INTO parts (backup_id, name, size, sha256, status) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (backup_id, name) DO UPDATE SET size = excluded.size, sha256 = excluded.sha256, status = excluded.status`,
p.BackupID, p.Name, p.Size, p.SHA256, p.Status)
return err
}
func (s *Store) SetPartStatus(backupID int64, name, status string) error {
_, err := s.db.Exec(`UPDATE parts SET status = ? WHERE backup_id = ? AND name = ?`, status, backupID, name)
return err
}
func (s *Store) RecalcTotal(backupID int64) error {
_, err := s.db.Exec(`UPDATE backups SET total_size = (SELECT COALESCE(SUM(size), 0) FROM parts
WHERE backup_id = ?1 AND status = ?2) WHERE id = ?1`, backupID, PartComplete)
return err
}
func (s *Store) CreateRestoreToken(hash string, clientID int64, ttl time.Duration) error {
now := time.Now()
_, err := s.db.Exec(`INSERT INTO restore_tokens (token_hash, client_id, created_at, expires_at) VALUES (?, ?, ?, ?)`,
hash, clientID, now.Unix(), now.Add(ttl).Unix())
return err
}
func (s *Store) RestoreTokenClient(hash string) (int64, error) {
var id int64
err := s.db.QueryRow(`SELECT client_id FROM restore_tokens WHERE token_hash = ? AND expires_at > ?`, hash, time.Now().Unix()).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return 0, ErrNotFound
}
return id, err
}
func (s *Store) CreateSession(token, csrf string, ttl time.Duration) error {
_, err := s.db.Exec(`INSERT INTO sessions (token, csrf, expires_at) VALUES (?, ?, ?)`, token, csrf, time.Now().Add(ttl).Unix())
return err
}
func (s *Store) SessionCSRF(token string) (string, error) {
var csrf string
err := s.db.QueryRow(`SELECT csrf FROM sessions WHERE token = ? AND expires_at > ?`, token, time.Now().Unix()).Scan(&csrf)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return csrf, err
}
func (s *Store) DeleteSession(token string) error {
_, err := s.db.Exec(`DELETE FROM sessions WHERE token = ?`, token)
return err
}
func (s *Store) DeleteAllSessions() error {
_, err := s.db.Exec(`DELETE FROM sessions`)
return err
}
func (s *Store) Cleanup() error {
now := time.Now().Unix()
if _, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at <= ?`, now); err != nil {
return err
}
_, err := s.db.Exec(`DELETE FROM restore_tokens WHERE expires_at <= ?`, now)
return err
}
func (s *Store) Setting(key string) string {
var v string
_ = s.db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
return v
}
func (s *Store) SetSetting(key, value string) error {
_, err := s.db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value`, key, value)
return err
}