Один бинарник 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>
113 lines
3.1 KiB
Go
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)
|
|
}
|