Files
mrleo1nidandClaude Opus 5.5 1d44d067e9 backup-master: сервер бэкапов, агент и восстановление VPS
Один бинарник backupctl:
- server: API для агентов и restore, веб-морда (один админ), SQLite,
  ретеншн GFS, heavy-части, уведомления ntfy, раздача agent.sh/restore.sh;
- backup/plan: сбор по каталогу категорий (files/systemd/xui/compose),
  горячие дампы Postgres/Mongo/MySQL, снимок x-ui.db, docker-тома,
  digest образов, проверка crontab, потоковая отправка tar.zst;
- restore: провижининг на основе vps_installer с теми же версиями пакетов,
  recipes 3x-ui/nebula/sing-box нужных версий, восстановление compose
  с заливкой дампов и закреплением образов по digest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:52:40 +03:00

113 lines
3.1 KiB
Go

package server
import (
"fmt"
"log"
"os"
"path/filepath"
"time"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
)
// SelectGFS возвращает индексы бэкапов, которые нужно сохранить.
// times отсортированы от новых к старым. Последний бэкап сохраняется всегда,
// дальше — самый новый в каждом из daily последних дней, weekly недель (ISO), monthly месяцев.
func SelectGFS(times []time.Time, daily, weekly, monthly int) map[int]bool {
keep := map[int]bool{}
if len(times) == 0 {
return keep
}
keep[0] = true
bucket := func(n int, key func(time.Time) string) {
seen := map[string]bool{}
for i, t := range times {
if len(seen) >= n {
return
}
k := key(t)
if !seen[k] {
seen[k] = true
keep[i] = true
}
}
}
bucket(daily, func(t time.Time) string { return t.Format("2006-01-02") })
bucket(weekly, func(t time.Time) string {
y, w := t.ISOWeek()
return fmt.Sprintf("%d-W%02d", y, w)
})
bucket(monthly, func(t time.Time) string { return t.Format("2006-01") })
return keep
}
// applyRetention удаляет лишние бэкапы клиента и старые heavy-части.
func (s *Server) applyRetention(c *Client) error {
backups, err := s.st.ListBackups(c.ID, StatusComplete)
if err != nil {
return err
}
times := make([]time.Time, len(backups))
for i, b := range backups {
times[i] = time.Unix(b.CreatedAt, 0)
}
keep := SelectGFS(times, c.KeepDaily, c.KeepWeekly, c.KeepMonthly)
// heavy-части: по каждому имени — только heavy_keep последних.
// Бэкап с сохраняемой heavy-частью не удаляется целиком, даже если GFS его не выбрал.
type excess struct {
b *Backup
name string
}
var drop []excess
heavySeen := map[string]int{}
for i, b := range backups {
for _, p := range b.Parts {
if p.Name == manifest.MainPart || p.Status != PartComplete {
continue
}
heavySeen[p.Name]++
if heavySeen[p.Name] <= max(c.HeavyKeep, 0) {
keep[i] = true
} else {
drop = append(drop, excess{b, p.Name})
}
}
}
for _, d := range drop {
if err := s.deletePart(d.b, d.name); err != nil {
return err
}
}
for i, b := range backups {
if keep[i] {
continue
}
if err := s.deleteBackup(b); err != nil {
return err
}
log.Printf("ретеншн: %s удалён бэкап #%d от %s", c.Domain, b.ID, time.Unix(b.CreatedAt, 0).Format(time.DateTime))
}
return nil
}
func (s *Server) deletePart(b *Backup, name string) error {
if err := os.Remove(s.partPath(b.Dir, name)); err != nil && !os.IsNotExist(err) {
return err
}
if err := s.st.SetPartStatus(b.ID, name, PartDeleted); err != nil {
return err
}
return s.st.RecalcTotal(b.ID)
}
func (s *Server) deleteBackup(b *Backup) error {
if b.Dir != "" {
if err := os.RemoveAll(filepath.Join(s.backupsDir(), b.Dir)); err != nil {
return err
}
}
return s.st.DeleteBackup(b.ID)
}