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>
This commit is contained in:
commit
1d44d067e9
53 files changed
+7278
No files matched your search
@@ -0,0 +1,271 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hsrv.site/mrleo1nid/backup-master/internal/archive"
|
||||
"gitea.hsrv.site/mrleo1nid/backup-master/internal/catalog"
|
||||
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
|
||||
"gitea.hsrv.site/mrleo1nid/backup-master/internal/sysx"
|
||||
)
|
||||
|
||||
// item — что положить в архив: обход Src с именами Name(path) и исключениями Skip.
|
||||
type item struct {
|
||||
Src string
|
||||
Name func(p string) string
|
||||
Skip *archive.Matcher
|
||||
}
|
||||
|
||||
// Plan — результат сбора: манифест, содержимое частей и временные файлы дампов.
|
||||
type Plan struct {
|
||||
Manifest manifest.Manifest
|
||||
Parts map[string][]item // имя части → элементы; порядок частей — Manifest.Parts
|
||||
}
|
||||
|
||||
type collector struct {
|
||||
ctx context.Context
|
||||
cat *catalog.Config
|
||||
spool string
|
||||
heavyDue bool
|
||||
global *archive.Matcher
|
||||
plan *Plan
|
||||
}
|
||||
|
||||
func (c *collector) warn(format string, a ...any) {
|
||||
msg := fmt.Sprintf(format, a...)
|
||||
c.plan.Manifest.Warnings = append(c.plan.Manifest.Warnings, msg)
|
||||
}
|
||||
|
||||
func (c *collector) add(part string, it item) {
|
||||
if _, ok := c.plan.Parts[part]; !ok {
|
||||
c.plan.Manifest.Parts = append(c.plan.Manifest.Parts, part)
|
||||
}
|
||||
c.plan.Parts[part] = append(c.plan.Parts[part], it)
|
||||
}
|
||||
|
||||
func filesItem(cat, src string, skip *archive.Matcher) item {
|
||||
prefix := manifest.FilesPrefix(cat)
|
||||
return item{Src: src, Skip: skip, Name: func(p string) string {
|
||||
return prefix + strings.TrimPrefix(filepath.ToSlash(p), "/")
|
||||
}}
|
||||
}
|
||||
|
||||
func binItem(cat, src string) item {
|
||||
prefix := manifest.BinPrefix(cat)
|
||||
return item{Src: src, Name: func(p string) string {
|
||||
return prefix + strings.TrimPrefix(filepath.ToSlash(p), "/")
|
||||
}}
|
||||
}
|
||||
|
||||
func dumpItem(cat, src, name string) item {
|
||||
n := manifest.DumpsPrefix(cat) + name
|
||||
return item{Src: src, Name: func(string) string { return n }}
|
||||
}
|
||||
|
||||
func treeItem(src, prefix string, skip *archive.Matcher) item {
|
||||
return item{Src: src, Skip: skip, Name: func(p string) string {
|
||||
rel, err := filepath.Rel(src, p)
|
||||
if err != nil || rel == "." {
|
||||
return strings.TrimSuffix(prefix, "/")
|
||||
}
|
||||
return prefix + filepath.ToSlash(rel)
|
||||
}}
|
||||
}
|
||||
|
||||
// Collect собирает план бэкапа по каталогу: выполняет дампы в spool, определяет версии.
|
||||
func Collect(ctx context.Context, cat *catalog.Config, spool string, heavyDue bool) (*Plan, error) {
|
||||
c := &collector{
|
||||
ctx: ctx,
|
||||
cat: cat,
|
||||
spool: spool,
|
||||
heavyDue: heavyDue,
|
||||
global: archive.NewMatcher(cat.Exclude),
|
||||
plan: &Plan{Parts: map[string][]item{}},
|
||||
}
|
||||
c.plan.Parts[manifest.MainPart] = nil
|
||||
c.plan.Manifest.Parts = []string{manifest.MainPart}
|
||||
c.fillSystem()
|
||||
|
||||
var composeSpecs []catalog.Spec
|
||||
for _, s := range cat.Categories {
|
||||
if s.Disabled {
|
||||
continue
|
||||
}
|
||||
if s.Type == catalog.TypeCompose {
|
||||
composeSpecs = append(composeSpecs, s)
|
||||
continue
|
||||
}
|
||||
mc, err := c.collectSpec(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("категория %s: %w", s.Name, err)
|
||||
}
|
||||
if mc != nil {
|
||||
c.plan.Manifest.Categories = append(c.plan.Manifest.Categories, *mc)
|
||||
}
|
||||
}
|
||||
if err := c.collectCompose(composeSpecs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.checkCron()
|
||||
return c.plan, nil
|
||||
}
|
||||
|
||||
func (c *collector) fillSystem() {
|
||||
m := &c.plan.Manifest
|
||||
m.FormatVersion = manifest.FormatVersion
|
||||
m.CreatedAt = time.Now()
|
||||
if h, err := sysx.Output(c.ctx, "hostnamectl", "hostname"); err == nil && h != "" {
|
||||
m.Hostname = h
|
||||
} else {
|
||||
m.Hostname, _ = os.Hostname()
|
||||
}
|
||||
osr := sysx.OSRelease()
|
||||
m.OS = manifest.OSInfo{ID: osr["ID"], VersionID: osr["VERSION_ID"], PrettyName: osr["PRETTY_NAME"], Arch: sysx.DpkgArch(c.ctx)}
|
||||
m.Packages = map[string]string{}
|
||||
for _, p := range []string{"caddy", "telegraf", "docker-ce", "docker-ce-cli", "containerd.io", "docker-compose-plugin", "docker-buildx-plugin"} {
|
||||
if v := sysx.DpkgVersion(c.ctx, p); v != "" {
|
||||
m.Packages[p] = v
|
||||
}
|
||||
}
|
||||
m.Features = manifest.Features{
|
||||
Blocklist: sysx.Exists("/etc/cron.d/ban-blacklist"),
|
||||
SSHHardened: sysx.Exists("/etc/ssh/sshd_config.d/00-hardening.conf"),
|
||||
}
|
||||
m.SudoUsers = sysx.GroupMembers("sudo")
|
||||
}
|
||||
|
||||
// collectSpec обрабатывает категории files/systemd/xui. nil — категория неактивна.
|
||||
func (c *collector) collectSpec(s catalog.Spec) (*manifest.Category, error) {
|
||||
if len(s.Detect) > 0 && !anyExists(s.Detect) {
|
||||
return nil, nil
|
||||
}
|
||||
mc := &manifest.Category{
|
||||
Name: s.Name,
|
||||
Type: s.Type,
|
||||
Install: s.Install,
|
||||
EnsureDirs: s.EnsureDirs,
|
||||
PostRestore: s.PostRestore,
|
||||
RestoreAfter: s.RestoreAfter,
|
||||
}
|
||||
skip := archive.NewMatcher(c.cat.Exclude, s.Exclude)
|
||||
for _, p := range s.Paths {
|
||||
if !sysx.Exists(p) {
|
||||
continue
|
||||
}
|
||||
mc.Paths = append(mc.Paths, p)
|
||||
c.add(manifest.MainPart, filesItem(s.Name, p, skip))
|
||||
}
|
||||
|
||||
if s.Type == catalog.TypeXUI {
|
||||
if err := c.snapshotXUI(mc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, d := range s.Dumps {
|
||||
if err := c.cmdDump(s.Name, d, mc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(mc.Paths) == 0 && len(mc.Dumps) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
for _, b := range s.KeepBinaries {
|
||||
if sysx.Exists(b) {
|
||||
mc.Binaries = append(mc.Binaries, b)
|
||||
c.add(manifest.MainPart, binItem(s.Name, b))
|
||||
}
|
||||
}
|
||||
if len(s.VersionCmd) > 0 && sysx.Exists(s.VersionCmd[0]) {
|
||||
mc.Version = c.version(s)
|
||||
}
|
||||
if s.Service != "" && sysx.UnitLoaded(c.ctx, s.Service) {
|
||||
en, ac := sysx.UnitState(c.ctx, s.Service)
|
||||
mc.Service = &manifest.ServiceState{Unit: s.Service, Enabled: en, Active: ac}
|
||||
}
|
||||
return mc, nil
|
||||
}
|
||||
|
||||
var defaultVersionRe = regexp.MustCompile(`v?(\d+\.\d+(?:\.\d+)?[\w.+-]*)`)
|
||||
|
||||
func (c *collector) version(s catalog.Spec) string {
|
||||
ctx, cancel := context.WithTimeout(c.ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
out, err := sysx.Output(ctx, s.VersionCmd[0], s.VersionCmd[1:]...)
|
||||
if err != nil {
|
||||
c.warn("%s: не удалось определить версию: %v", s.Name, err)
|
||||
return ""
|
||||
}
|
||||
re := defaultVersionRe
|
||||
if s.VersionRegex != "" {
|
||||
if r, err := regexp.Compile(s.VersionRegex); err == nil {
|
||||
re = r
|
||||
}
|
||||
}
|
||||
if m := re.FindStringSubmatch(out); len(m) > 1 {
|
||||
return m[1]
|
||||
}
|
||||
c.warn("%s: версия не распознана в выводе %q", s.Name, out)
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *collector) spoolDir(cat string) (string, error) {
|
||||
d := filepath.Join(c.spool, cat)
|
||||
return d, os.MkdirAll(d, 0o700)
|
||||
}
|
||||
|
||||
func (c *collector) cmdDump(cat string, d catalog.CmdDump, mc *manifest.Category) error {
|
||||
dir, err := c.spoolDir(cat)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := filepath.Join(dir, d.Name)
|
||||
if err := sysx.ToFile(c.ctx, out, "bash", "-o", "pipefail", "-c", d.Cmd); err != nil {
|
||||
c.warn("%s: дамп %s не удался: %v", cat, d.Name, err)
|
||||
return nil
|
||||
}
|
||||
mc.Dumps = append(mc.Dumps, manifest.Dump{Name: d.Name, Kind: "cmd", RestoreCmd: d.RestoreCmd})
|
||||
c.add(manifest.MainPart, dumpItem(cat, out, d.Name))
|
||||
return nil
|
||||
}
|
||||
|
||||
func anyExists(paths []string) bool {
|
||||
for _, p := range paths {
|
||||
if sysx.Exists(p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// treeSize считает размер дерева с учётом исключений (для plan).
|
||||
func treeSize(src string, skip *archive.Matcher) (int64, int) {
|
||||
var size int64
|
||||
var n int
|
||||
_ = filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if skip.Match(filepath.ToSlash(p)) {
|
||||
if d.IsDir() {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if d.Type().IsRegular() {
|
||||
if fi, err := d.Info(); err == nil {
|
||||
size += fi.Size()
|
||||
n++
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return size, n
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gitea.hsrv.site/mrleo1nid/backup-master/internal/archive"
|
||||
"gitea.hsrv.site/mrleo1nid/backup-master/internal/catalog"
|
||||
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
|
||||
"gitea.hsrv.site/mrleo1nid/backup-master/internal/sysx"
|
||||
)
|
||||
|
||||
type lsEntry struct {
|
||||
Name string
|
||||
Status string
|
||||
ConfigFiles string
|
||||
}
|
||||
|
||||
func (e lsEntry) files() []string {
|
||||
var out []string
|
||||
for _, f := range strings.Split(e.ConfigFiles, ",") {
|
||||
if f = strings.TrimSpace(f); f != "" {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (e lsEntry) dir() string {
|
||||
if fs := e.files(); len(fs) > 0 {
|
||||
return filepath.Dir(fs[0])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type composeConfig struct {
|
||||
Name string `json:"name"`
|
||||
Services map[string]composeService `json:"services"`
|
||||
Volumes map[string]struct {
|
||||
Name string `json:"name"`
|
||||
External bool `json:"external"`
|
||||
} `json:"volumes"`
|
||||
Networks map[string]struct {
|
||||
Name string `json:"name"`
|
||||
External bool `json:"external"`
|
||||
} `json:"networks"`
|
||||
}
|
||||
|
||||
type composeService struct {
|
||||
Image string `json:"image"`
|
||||
Build json.RawMessage `json:"build"`
|
||||
Volumes []struct {
|
||||
Type string `json:"type"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
} `json:"volumes"`
|
||||
}
|
||||
|
||||
type container struct {
|
||||
ID string
|
||||
Service string
|
||||
Running bool
|
||||
}
|
||||
|
||||
// Пути, которые не имеет смысла бэкапить как bind-mount.
|
||||
var systemMounts = []string{"/run", "/var/run", "/proc", "/sys", "/dev", "/etc/localtime", "/etc/timezone",
|
||||
"/var/lib/docker", "/usr", "/lib", "/bin", "/sbin", "/tmp"}
|
||||
|
||||
var composeFileNames = []string{"compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"}
|
||||
var overrideFileNames = []string{"compose.override.yaml", "compose.override.yml", "docker-compose.override.yaml", "docker-compose.override.yml"}
|
||||
|
||||
func findComposeFiles(dir string) []string {
|
||||
var out []string
|
||||
for _, group := range [][]string{composeFileNames, overrideFileNames} {
|
||||
for _, n := range group {
|
||||
if p := filepath.Join(dir, n); sysx.Exists(p) {
|
||||
out = append(out, p)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) > 0 && !slices.Contains(composeFileNames, filepath.Base(out[0])) {
|
||||
return nil // только override без основного файла
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *collector) composeLS() []lsEntry {
|
||||
if !sysx.HasCommand("docker") {
|
||||
return nil
|
||||
}
|
||||
out, err := sysx.Output(c.ctx, "docker", "compose", "ls", "-a", "--format", "json")
|
||||
if err != nil {
|
||||
c.warn("docker compose ls: %v", err)
|
||||
return nil
|
||||
}
|
||||
var entries []lsEntry
|
||||
if err := json.Unmarshal([]byte(out), &entries); err != nil {
|
||||
c.warn("docker compose ls: %v", err)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
var nonNameRe = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
|
||||
|
||||
// collectCompose обрабатывает compose-категории каталога и автонайденные проекты.
|
||||
func (c *collector) collectCompose(specs []catalog.Spec) error {
|
||||
entries := c.composeLS()
|
||||
used := map[string]bool{}
|
||||
taken := map[string]bool{}
|
||||
for _, mc := range c.plan.Manifest.Categories {
|
||||
taken[mc.Name] = true
|
||||
}
|
||||
// отключённые категории тоже «занимают» свои проекты, чтобы не попасть в авто
|
||||
for _, s := range c.cat.Categories {
|
||||
if s.Type != catalog.TypeCompose || !s.Disabled {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Name == s.Project || (s.Dir != "" && e.dir() == filepath.Clean(s.Dir)) {
|
||||
used[e.Name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type job struct {
|
||||
spec catalog.Spec
|
||||
entry *lsEntry
|
||||
auto bool
|
||||
}
|
||||
var jobs []job
|
||||
for _, s := range specs {
|
||||
var match *lsEntry
|
||||
for i, e := range entries {
|
||||
if (s.Project != "" && e.Name == s.Project) || (s.Dir != "" && e.dir() == filepath.Clean(s.Dir)) {
|
||||
match = &entries[i]
|
||||
used[e.Name] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if match == nil && (s.Dir == "" || len(findComposeFiles(s.Dir)) == 0) {
|
||||
continue // проекта на этом сервере нет
|
||||
}
|
||||
jobs = append(jobs, job{spec: s, entry: match})
|
||||
}
|
||||
if c.cat.AutoComposeEnabled() {
|
||||
for i, e := range entries {
|
||||
if used[e.Name] || e.dir() == "" {
|
||||
continue
|
||||
}
|
||||
name := nonNameRe.ReplaceAllString(e.Name, "-")
|
||||
if taken[name] {
|
||||
name = "compose-" + name
|
||||
}
|
||||
jobs = append(jobs, job{spec: catalog.Spec{Name: name, Type: catalog.TypeCompose, Dir: e.dir(), Project: e.Name}, entry: &entries[i], auto: true})
|
||||
}
|
||||
}
|
||||
for _, j := range jobs {
|
||||
mc, err := c.composeProject(j.spec, j.entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compose %s: %w", j.spec.Name, err)
|
||||
}
|
||||
mc.Auto = j.auto
|
||||
c.plan.Manifest.Categories = append(c.plan.Manifest.Categories, *mc)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *collector) composeProject(s catalog.Spec, e *lsEntry) (*manifest.Category, error) {
|
||||
mc := &manifest.Category{Name: s.Name, Type: catalog.TypeCompose, PostRestore: s.PostRestore, RestoreAfter: s.RestoreAfter}
|
||||
info := &manifest.Compose{Dir: filepath.Clean(s.Dir), Project: s.Project}
|
||||
if e != nil {
|
||||
info.Project = e.Name
|
||||
info.ConfigFiles = e.files()
|
||||
if info.Dir == "." || info.Dir == "" {
|
||||
info.Dir = e.dir()
|
||||
}
|
||||
} else {
|
||||
info.ConfigFiles = findComposeFiles(info.Dir)
|
||||
}
|
||||
mc.Compose = info
|
||||
dir := info.Dir
|
||||
dirSlash := filepath.ToSlash(dir)
|
||||
|
||||
skip := archive.NewMatcher(c.cat.Exclude)
|
||||
for _, p := range s.Exclude {
|
||||
skip.Add(absUnder(dirSlash, p))
|
||||
}
|
||||
var heavy []string
|
||||
for _, p := range s.HeavyPaths {
|
||||
h := absUnder(dirSlash, p)
|
||||
skip.Add(h) // heavy никогда не попадает в main
|
||||
if sysx.Exists(h) {
|
||||
heavy = append(heavy, h)
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := c.composeConfig(info)
|
||||
if err != nil {
|
||||
c.warn("%s: docker compose config не удался, бэкаплю только каталог: %v", s.Name, err)
|
||||
}
|
||||
var containers map[string]container
|
||||
if cfg != nil {
|
||||
if info.Project == "" {
|
||||
info.Project = cfg.Name
|
||||
}
|
||||
containers = c.composeContainers(info.Project)
|
||||
for _, ct := range containers {
|
||||
if ct.Running {
|
||||
info.Running = true
|
||||
}
|
||||
}
|
||||
for _, n := range cfg.Networks {
|
||||
if n.External && n.Name != "" {
|
||||
info.ExternalNetworks = append(info.ExternalNetworks, n.Name)
|
||||
}
|
||||
}
|
||||
slices.Sort(info.ExternalNetworks)
|
||||
info.Images = map[string]manifest.Image{}
|
||||
|
||||
names := make([]string, 0, len(cfg.Services))
|
||||
for name := range cfg.Services {
|
||||
names = append(names, name)
|
||||
}
|
||||
slices.Sort(names)
|
||||
var extraBinds []string
|
||||
for _, name := range names {
|
||||
svc := cfg.Services[name]
|
||||
ct, hasCt := containers[name]
|
||||
img := manifest.Image{Ref: svc.Image, Built: len(svc.Build) > 0 && string(svc.Build) != "null"}
|
||||
if hasCt && !img.Built {
|
||||
img.Digest = c.imageDigest(ct.ID)
|
||||
}
|
||||
info.Images[name] = img
|
||||
|
||||
kind := dbKind(svc.Image)
|
||||
skipData := slices.Contains(s.SkipServices, name) || imageName(svc.Image) == "rabbitmq"
|
||||
dumped := false
|
||||
if kind != "" {
|
||||
if hasCt && ct.Running {
|
||||
if err := c.dbDump(mc, name, kind, ct.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dumped = true
|
||||
} else {
|
||||
c.warn("%s: контейнер БД %s не запущен — каталог данных копируется как есть", s.Name, name)
|
||||
}
|
||||
}
|
||||
for _, v := range svc.Volumes {
|
||||
switch v.Type {
|
||||
case "bind":
|
||||
src := filepath.ToSlash(v.Source)
|
||||
if dumped || skipData {
|
||||
skip.Add(src)
|
||||
continue
|
||||
}
|
||||
if !archive.Covers([]string{dirSlash}, src) && !archive.Covers(systemMounts, src) && !strings.HasSuffix(src, "docker.sock") {
|
||||
extraBinds = append(extraBinds, v.Source)
|
||||
}
|
||||
case "volume":
|
||||
if dumped || skipData || v.Source == "" {
|
||||
continue
|
||||
}
|
||||
vol := cfg.Volumes[v.Source]
|
||||
real := vol.Name
|
||||
if real == "" {
|
||||
real = info.Project + "_" + v.Source
|
||||
}
|
||||
key := v.Source
|
||||
if vol.External {
|
||||
key = ""
|
||||
}
|
||||
c.addVolume(mc, real, key, skip)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, b := range extraBinds {
|
||||
if sysx.Exists(b) && !archive.Covers(mc.Paths, filepath.ToSlash(b)) {
|
||||
mc.Paths = append(mc.Paths, b)
|
||||
c.add(manifest.MainPart, filesItem(s.Name, b, skip))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mc.Paths = append([]string{dir}, mc.Paths...)
|
||||
c.add(manifest.MainPart, filesItem(s.Name, dir, skip))
|
||||
|
||||
if len(heavy) > 0 {
|
||||
mc.Heavy = heavy
|
||||
if c.heavyDue {
|
||||
for _, h := range heavy {
|
||||
c.add(s.Name, filesItem(s.Name, h, archive.NewMatcher(c.cat.Exclude)))
|
||||
}
|
||||
} else {
|
||||
mc.HeavySkipped = true
|
||||
}
|
||||
}
|
||||
info.Git = c.gitInfo(dir)
|
||||
return mc, nil
|
||||
}
|
||||
|
||||
func absUnder(dir, p string) string {
|
||||
p = filepath.ToSlash(p)
|
||||
if strings.HasPrefix(p, "/") {
|
||||
return p
|
||||
}
|
||||
return path.Join(dir, p)
|
||||
}
|
||||
|
||||
func (c *collector) composeArgs(info *manifest.Compose) []string {
|
||||
args := []string{"compose", "--project-directory", info.Dir}
|
||||
if info.Project != "" {
|
||||
args = append(args, "-p", info.Project)
|
||||
}
|
||||
for _, f := range info.ConfigFiles {
|
||||
args = append(args, "-f", f)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func (c *collector) composeConfig(info *manifest.Compose) (*composeConfig, error) {
|
||||
if !sysx.HasCommand("docker") {
|
||||
return nil, fmt.Errorf("docker не установлен")
|
||||
}
|
||||
if len(info.ConfigFiles) == 0 {
|
||||
return nil, fmt.Errorf("в %s нет compose-файла", info.Dir)
|
||||
}
|
||||
out, err := sysx.Output(c.ctx, "docker", append(c.composeArgs(info), "config", "--format", "json")...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cfg composeConfig
|
||||
if err := json.Unmarshal([]byte(out), &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (c *collector) composeContainers(project string) map[string]container {
|
||||
out, err := sysx.Output(c.ctx, "docker", "ps", "-a", "--filter", "label=com.docker.compose.project="+project,
|
||||
"--format", `{{.ID}} {{.Label "com.docker.compose.service"}} {{.State}}`)
|
||||
res := map[string]container{}
|
||||
if err != nil {
|
||||
c.warn("docker ps %s: %v", project, err)
|
||||
return res
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
f := strings.Split(line, "\t")
|
||||
if len(f) != 3 || f[1] == "" {
|
||||
continue
|
||||
}
|
||||
ct := container{ID: f[0], Service: f[1], Running: f[2] == "running"}
|
||||
// при нескольких репликах важнее запущенная
|
||||
if prev, ok := res[ct.Service]; !ok || (!prev.Running && ct.Running) {
|
||||
res[ct.Service] = ct
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (c *collector) imageDigest(containerID string) string {
|
||||
imgID, err := sysx.Output(c.ctx, "docker", "inspect", "-f", "{{.Image}}", containerID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
out, err := sysx.Output(c.ctx, "docker", "image", "inspect", "-f", "{{json .RepoDigests}}", imgID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var digests []string
|
||||
if json.Unmarshal([]byte(out), &digests) != nil || len(digests) == 0 {
|
||||
return ""
|
||||
}
|
||||
return digests[0]
|
||||
}
|
||||
|
||||
func (c *collector) addVolume(mc *manifest.Category, name, key string, skip *archive.Matcher) {
|
||||
for _, v := range mc.Volumes {
|
||||
if v.Name == name {
|
||||
return
|
||||
}
|
||||
}
|
||||
mp, err := sysx.Output(c.ctx, "docker", "volume", "inspect", "-f", "{{.Mountpoint}}", name)
|
||||
if err != nil || mp == "" {
|
||||
c.warn("%s: том %s не найден", mc.Name, name)
|
||||
return
|
||||
}
|
||||
mc.Volumes = append(mc.Volumes, manifest.Volume{Name: name, Key: key})
|
||||
c.add(manifest.MainPart, treeItem(mp, manifest.VolumesPrefix(mc.Name)+name+"/", skip))
|
||||
}
|
||||
|
||||
// imageName — последний компонент имени образа без тега и digest: docker.io/library/mongo:7 → mongo.
|
||||
func imageName(image string) string {
|
||||
image, _, _ = strings.Cut(image, "@")
|
||||
if i := strings.LastIndex(image, "/"); i >= 0 {
|
||||
image = image[i+1:]
|
||||
}
|
||||
image, _, _ = strings.Cut(image, ":")
|
||||
return strings.ToLower(image)
|
||||
}
|
||||
|
||||
func dbKind(image string) string {
|
||||
switch imageName(image) {
|
||||
case "postgres", "postgresql", "postgis":
|
||||
return "postgres"
|
||||
case "mongo", "mongodb":
|
||||
return "mongo"
|
||||
case "mysql":
|
||||
return "mysql"
|
||||
case "mariadb":
|
||||
return "mariadb"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Команды дампа выполняются внутри контейнера: учётные данные берутся из его окружения.
|
||||
var dumpCmds = map[string]struct{ ext, cmd string }{
|
||||
"postgres": {"pgdump", `exec pg_dump -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_DB:-${POSTGRES_USER:-postgres}}" -Fc`},
|
||||
"mongo": {"mongo.archive.gz", `if [ -n "$MONGO_INITDB_ROOT_USERNAME" ]; then exec mongodump --quiet --archive --gzip -u "$MONGO_INITDB_ROOT_USERNAME" -p "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin; else exec mongodump --quiet --archive --gzip; fi`},
|
||||
"mysql": {"sql", `exec mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --all-databases --single-transaction --routines --events --triggers`},
|
||||
"mariadb": {"sql", `D=$(command -v mariadb-dump || command -v mysqldump); exec "$D" -uroot -p"${MARIADB_ROOT_PASSWORD:-$MYSQL_ROOT_PASSWORD}" --all-databases --single-transaction --routines --events --triggers`},
|
||||
}
|
||||
|
||||
func (c *collector) dbDump(mc *manifest.Category, service, kind, containerID string) error {
|
||||
dc := dumpCmds[kind]
|
||||
dir, err := c.spoolDir(mc.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := service + "." + dc.ext
|
||||
out := filepath.Join(dir, name)
|
||||
if err := sysx.ToFile(c.ctx, out, "docker", "exec", containerID, "sh", "-c", dc.cmd); err != nil {
|
||||
return fmt.Errorf("дамп %s (%s): %w", service, kind, err)
|
||||
}
|
||||
if fi, err := os.Stat(out); err != nil || fi.Size() == 0 {
|
||||
return fmt.Errorf("дамп %s (%s) пустой", service, kind)
|
||||
}
|
||||
mc.Dumps = append(mc.Dumps, manifest.Dump{Name: name, Kind: kind, Service: service})
|
||||
c.add(manifest.MainPart, dumpItem(mc.Name, out, name))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *collector) gitInfo(dir string) *manifest.GitInfo {
|
||||
if !sysx.Exists(filepath.Join(dir, ".git")) || !sysx.HasCommand("git") {
|
||||
return nil
|
||||
}
|
||||
git := func(args ...string) string {
|
||||
out, _ := sysx.Output(c.ctx, "git", append([]string{"-c", "safe.directory=*", "-C", dir}, args...)...)
|
||||
return out
|
||||
}
|
||||
commit := git("rev-parse", "HEAD")
|
||||
if commit == "" {
|
||||
return nil
|
||||
}
|
||||
return &manifest.GitInfo{Remote: git("remote", "get-url", "origin"), Commit: commit, Dirty: git("status", "--porcelain") != ""}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Package agent собирает бэкап на клиенте и отправляет его на сервер.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const DefaultConfigPath = "/etc/backup-agent/agent.yaml"
|
||||
|
||||
type Config struct {
|
||||
Server string `yaml:"server"`
|
||||
Token string `yaml:"token"`
|
||||
SpoolDir string `yaml:"spool_dir"`
|
||||
}
|
||||
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var c Config
|
||||
if err := yaml.Unmarshal(data, &c); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
if c.Server == "" || c.Token == "" {
|
||||
return nil, fmt.Errorf("%s: нужны server и token", path)
|
||||
}
|
||||
if c.SpoolDir == "" {
|
||||
c.SpoolDir = "/var/tmp/backup-agent"
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.hsrv.site/mrleo1nid/backup-master/internal/archive"
|
||||
)
|
||||
|
||||
// Каталоги, скрипты из которых ставятся пакетами и в бэкапе не нужны.
|
||||
var systemBinDirs = []string{"/usr/bin", "/usr/sbin", "/bin", "/sbin", "/usr/lib", "/usr/share", "/lib", "/etc/cron.daily",
|
||||
"/etc/cron.hourly", "/etc/cron.weekly", "/etc/cron.monthly", "/usr/libexec"}
|
||||
|
||||
// checkCron ищет в crontab упоминания скриптов, не покрытых ни одной категорией.
|
||||
func (c *collector) checkCron() {
|
||||
var files []string
|
||||
for _, pattern := range []string{"/var/spool/cron/crontabs/*", "/etc/cron.d/*", "/etc/crontab"} {
|
||||
m, _ := filepath.Glob(pattern)
|
||||
files = append(files, m...)
|
||||
}
|
||||
var covered []string
|
||||
for _, mc := range c.plan.Manifest.Categories {
|
||||
for _, p := range mc.Paths {
|
||||
covered = append(covered, filepath.ToSlash(p))
|
||||
}
|
||||
}
|
||||
reported := map[string]bool{}
|
||||
for _, f := range files {
|
||||
data, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, p := range cronPaths(string(data)) {
|
||||
if reported[p] || archive.Covers(covered, p) || archive.Covers(systemBinDirs, p) {
|
||||
continue
|
||||
}
|
||||
fi, err := os.Stat(p)
|
||||
if err != nil || !fi.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
reported[p] = true
|
||||
c.warn("cron (%s) запускает %s, но он не попадает в бэкап — добавьте путь в категорию", f, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cronPaths извлекает абсолютные пути из команд crontab, кроме целей перенаправления вывода.
|
||||
func cronPaths(text string) []string {
|
||||
var out []string
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) > 0 && strings.Contains(fields[0], "=") && !strings.HasPrefix(fields[0], "/") {
|
||||
// присваивание переменной (SHELL=/bin/bash) в отдельной строке
|
||||
if len(fields) == 1 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
redirect := false
|
||||
for _, tok := range fields {
|
||||
if strings.HasSuffix(tok, ">") || strings.HasSuffix(tok, "<") {
|
||||
redirect = true
|
||||
continue
|
||||
}
|
||||
if redirect {
|
||||
redirect = false
|
||||
continue
|
||||
}
|
||||
// "2>/dev/null", ">>file" — перенаправление слитно с целью
|
||||
if i := strings.IndexAny(tok, "<>"); i >= 0 {
|
||||
continue
|
||||
}
|
||||
if _, v, ok := strings.Cut(tok, "="); ok {
|
||||
tok = v
|
||||
}
|
||||
tok = strings.Trim(tok, `"';&|()`)
|
||||
if strings.HasPrefix(tok, "/") && !strings.ContainsAny(tok, "*$") {
|
||||
out = append(out, tok)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCronPaths(t *testing.T) {
|
||||
text := `# m h dom mon dow command
|
||||
SHELL=/bin/bash
|
||||
17 4 * * * RESTART_CMD='x-ui restart' /usr/local/bin/update-geosite.sh >> /var/log/ipcheck-update.log 2>&1
|
||||
0 5 * * 1 /var/scripts/update-geodata.sh >> /var/log/update-geodata.log 2>&1
|
||||
@reboot sleep 60 && bash /usr/local/sbin/ban.sh
|
||||
0 */6 * * * bash /usr/local/sbin/ban.sh >/dev/null 2>&1
|
||||
`
|
||||
got := cronPaths(text)
|
||||
for _, want := range []string{"/usr/local/bin/update-geosite.sh", "/var/scripts/update-geodata.sh", "/usr/local/sbin/ban.sh"} {
|
||||
if !slices.Contains(got, want) {
|
||||
t.Errorf("нет %s в %v", want, got)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"/var/log/ipcheck-update.log", "/var/log/update-geodata.log", "/dev/null", "/bin/bash"} {
|
||||
if slices.Contains(got, bad) {
|
||||
t.Errorf("лишний путь %s в %v", bad, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBKind(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"postgres:17-alpine": "postgres",
|
||||
"docker.io/mongo": "mongo",
|
||||
"docker.io/library/mongo@sha256:abc": "mongo",
|
||||
"mariadb:11": "mariadb",
|
||||
"mongo-express": "",
|
||||
"ghcr.io/stoatchat/api:v0.15.5": "",
|
||||
"valkey/valkey:9-alpine": "",
|
||||
}
|
||||
for img, want := range cases {
|
||||
if got := dbKind(img); got != want {
|
||||
t.Errorf("dbKind(%q) = %q, want %q", img, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//go:build linux
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// lock не даёт запустить два бэкапа одновременно (таймер + ручной запуск).
|
||||
func lock(path string) (func(), error) {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
||||
f.Close()
|
||||
return nil, fmt.Errorf("бэкап уже выполняется (%s)", path)
|
||||
}
|
||||
return func() { f.Close() }, nil
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build !linux
|
||||
|
||||
package agent
|
||||
|
||||
func lock(string) (func(), error) { return func() {}, nil }
|
||||
@@ -0,0 +1,356 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hsrv.site/mrleo1nid/backup-master/internal/api"
|
||||
"gitea.hsrv.site/mrleo1nid/backup-master/internal/archive"
|
||||
"gitea.hsrv.site/mrleo1nid/backup-master/internal/catalog"
|
||||
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
ConfigPath string
|
||||
AgentVersion string
|
||||
// Output — записать части локально в каталог вместо отправки (для отладки).
|
||||
Output string
|
||||
// ForceHeavy — включить heavy-части независимо от расписания.
|
||||
ForceHeavy bool
|
||||
}
|
||||
|
||||
// prepare загружает конфиг, получает переопределения с сервера и собирает план.
|
||||
func prepare(ctx context.Context, opts Options, spool string) (*Plan, *api.Client, error) {
|
||||
cfg, err := LoadConfig(opts.ConfigPath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
client := api.NewClient(cfg.Server, cfg.Token)
|
||||
remote, err := client.AgentConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("конфиг с сервера: %w", err)
|
||||
}
|
||||
cat, err := catalog.Default()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if strings.TrimSpace(remote.ConfigYAML) != "" {
|
||||
over, err := catalog.Parse([]byte(remote.ConfigYAML))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("конфиг клиента с сервера: %w", err)
|
||||
}
|
||||
cat = catalog.Merge(cat, over)
|
||||
}
|
||||
if err := cat.Validate(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if spool == "" {
|
||||
spool = cfg.SpoolDir
|
||||
}
|
||||
plan, err := Collect(ctx, cat, spool, remote.HeavyDue || opts.ForceHeavy)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
plan.Manifest.Domain = remote.Domain
|
||||
plan.Manifest.AgentVersion = opts.AgentVersion
|
||||
return plan, client, nil
|
||||
}
|
||||
|
||||
func newSpool(base string) (string, error) {
|
||||
dir := filepath.Join(base, time.Now().Format("20060102-150405"))
|
||||
return dir, os.MkdirAll(dir, 0o700)
|
||||
}
|
||||
|
||||
// PrintPlan выводит, что попадёт в бэкап. Дампы БД при этом реально выполняются (во временный каталог).
|
||||
func PrintPlan(ctx context.Context, opts Options, w io.Writer) error {
|
||||
cfg, err := LoadConfig(opts.ConfigPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spool, err := newSpool(cfg.SpoolDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(spool)
|
||||
plan, _, err := prepare(ctx, opts, spool)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m := &plan.Manifest
|
||||
fmt.Fprintf(w, "Домен: %s, hostname: %s, %s (%s)\n", m.Domain, m.Hostname, m.OS.PrettyName, m.OS.Arch)
|
||||
if len(m.Packages) > 0 {
|
||||
fmt.Fprintf(w, "Пакеты:")
|
||||
for k, v := range m.Packages {
|
||||
fmt.Fprintf(w, " %s=%s", k, v)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
for _, c := range m.Categories {
|
||||
line := fmt.Sprintf("\n[%s] %s", c.Name, c.Type)
|
||||
if c.Auto {
|
||||
line += " (найден автоматически)"
|
||||
}
|
||||
if c.Version != "" {
|
||||
line += ", версия " + c.Version
|
||||
}
|
||||
if c.Service != nil {
|
||||
line += fmt.Sprintf(", сервис %s enabled=%v active=%v", c.Service.Unit, c.Service.Enabled, c.Service.Active)
|
||||
}
|
||||
fmt.Fprintln(w, line)
|
||||
if c.Compose != nil {
|
||||
fmt.Fprintf(w, " проект %s, запущен=%v\n", c.Compose.Project, c.Compose.Running)
|
||||
for svc, img := range c.Compose.Images {
|
||||
ref := img.Digest
|
||||
if img.Built {
|
||||
ref = "собирается из исходников"
|
||||
} else if ref == "" {
|
||||
ref = img.Ref + " (digest не определён)"
|
||||
}
|
||||
fmt.Fprintf(w, " образ %s: %s\n", svc, ref)
|
||||
}
|
||||
}
|
||||
for _, d := range c.Dumps {
|
||||
fmt.Fprintf(w, " дамп %s %s\n", d.Kind, d.Name)
|
||||
}
|
||||
for _, v := range c.Volumes {
|
||||
fmt.Fprintf(w, " том %s\n", v.Name)
|
||||
}
|
||||
for _, h := range c.Heavy {
|
||||
note := "в этот раз"
|
||||
if c.HeavySkipped {
|
||||
note = "по расписанию, не в этот раз"
|
||||
}
|
||||
fmt.Fprintf(w, " heavy %s (%s)\n", h, note)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w, "\nСодержимое частей:")
|
||||
for _, part := range m.Parts {
|
||||
var total int64
|
||||
var files int
|
||||
for _, it := range plan.Parts[part] {
|
||||
size, n := treeSize(it.Src, it.Skip)
|
||||
total += size
|
||||
files += n
|
||||
}
|
||||
fmt.Fprintf(w, " %s: %d файлов, %s до сжатия\n", part, files, human(total))
|
||||
}
|
||||
if len(m.Warnings) > 0 {
|
||||
fmt.Fprintln(w, "\nПредупреждения:")
|
||||
for _, wr := range m.Warnings {
|
||||
fmt.Fprintln(w, " ! "+wr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Backup выполняет полный цикл: сбор, отправка частей, подтверждение.
|
||||
func Backup(ctx context.Context, opts Options) (err error) {
|
||||
unlock, err := lock("/run/backup-agent.lock")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
cfg, err := LoadConfig(opts.ConfigPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spool, err := newSpool(cfg.SpoolDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(spool)
|
||||
|
||||
start := time.Now()
|
||||
plan, client, err := prepare(ctx, opts, spool)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m := &plan.Manifest
|
||||
log.Printf("собрано: %d категорий, части %v, предупреждений %d", len(m.Categories), m.Parts, len(m.Warnings))
|
||||
for _, w := range m.Warnings {
|
||||
log.Printf("! %s", w)
|
||||
}
|
||||
if len(m.Categories) == 0 {
|
||||
// пустой «успешный» бэкап хуже ошибки: сервер решит, что всё в порядке
|
||||
return errors.New("ни одна категория не найдена на сервере — проверьте каталог (backupctl plan)")
|
||||
}
|
||||
|
||||
if opts.Output != "" {
|
||||
return writeLocal(plan, opts.Output)
|
||||
}
|
||||
|
||||
id, err := client.CreateBackup(ctx, m)
|
||||
if err != nil {
|
||||
return fmt.Errorf("создание бэкапа на сервере: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
fctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if ferr := client.Fail(fctx, id, err.Error()); ferr != nil {
|
||||
log.Printf("не удалось сообщить серверу об ошибке: %v", ferr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
var done api.CompleteRequest
|
||||
for _, part := range m.Parts {
|
||||
info, warns, err := uploadWithRetry(ctx, client, id, plan, part)
|
||||
if err != nil {
|
||||
return fmt.Errorf("часть %s: %w", part, err)
|
||||
}
|
||||
done.Parts = append(done.Parts, *info)
|
||||
done.Warnings = append(done.Warnings, warns...)
|
||||
log.Printf("часть %s отправлена: %s", part, human(info.Size))
|
||||
}
|
||||
for _, w := range done.Warnings {
|
||||
log.Printf("! %s", w)
|
||||
}
|
||||
if err := client.Complete(ctx, id, done); err != nil {
|
||||
return fmt.Errorf("подтверждение: %w", err)
|
||||
}
|
||||
log.Printf("бэкап #%d готов за %s", id, time.Since(start).Round(time.Second))
|
||||
return nil
|
||||
}
|
||||
|
||||
func uploadWithRetry(ctx context.Context, client *api.Client, id int64, plan *Plan, part string) (*api.PartInfo, []string, error) {
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
if attempt > 1 {
|
||||
wait := time.Duration(attempt*attempt) * 20 * time.Second
|
||||
log.Printf("часть %s: попытка %d через %s (%v)", part, attempt, wait, lastErr)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, nil, ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
info, warns, err := uploadPart(ctx, client, id, plan, part)
|
||||
if err == nil {
|
||||
return info, warns, nil
|
||||
}
|
||||
lastErr = err
|
||||
var apiErr *api.Error
|
||||
if errors.As(err, &apiErr) && apiErr.Status >= 400 && apiErr.Status < 500 && apiErr.Status != 408 && apiErr.Status != 429 {
|
||||
break // повтор не поможет
|
||||
}
|
||||
}
|
||||
return nil, nil, lastErr
|
||||
}
|
||||
|
||||
// uploadPart архивирует часть прямо в тело PUT-запроса, параллельно считая sha256.
|
||||
func uploadPart(ctx context.Context, client *api.Client, id int64, plan *Plan, part string) (*api.PartInfo, []string, error) {
|
||||
pr, pw := io.Pipe()
|
||||
h := sha256.New()
|
||||
cw := &countWriter{w: io.MultiWriter(pw, h)}
|
||||
type result struct {
|
||||
warns []string
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
warns, err := writePart(cw, plan, part)
|
||||
pw.CloseWithError(err)
|
||||
done <- result{warns, err}
|
||||
}()
|
||||
info, err := client.UploadPart(ctx, id, part, pr)
|
||||
pr.CloseWithError(errors.New("загрузка прервана")) // освобождаем писателя, если сервер ответил раньше
|
||||
res := <-done
|
||||
if err != nil {
|
||||
if res.err != nil && !errors.Is(res.err, io.ErrClosedPipe) && !strings.Contains(res.err.Error(), "загрузка прервана") {
|
||||
return nil, nil, fmt.Errorf("архивация: %w", res.err)
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
if res.err != nil {
|
||||
return nil, nil, fmt.Errorf("архивация: %w", res.err)
|
||||
}
|
||||
warns := res.warns
|
||||
sum := hex.EncodeToString(h.Sum(nil))
|
||||
if info.SHA256 != sum || info.Size != cw.n {
|
||||
return nil, nil, fmt.Errorf("сервер получил %d байт (%s), отправлено %d (%s)", info.Size, info.SHA256, cw.n, sum)
|
||||
}
|
||||
return info, warns, nil
|
||||
}
|
||||
|
||||
func writePart(w io.Writer, plan *Plan, part string) ([]string, error) {
|
||||
aw, err := archive.NewWriter(w)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if part == manifest.MainPart {
|
||||
data, err := json.MarshalIndent(plan.Manifest, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := aw.AddBytes(manifest.ManifestFile, data, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, it := range plan.Parts[part] {
|
||||
if err := aw.AddTree(it.Src, it.Name, it.Skip); err != nil {
|
||||
return aw.Warnings, err
|
||||
}
|
||||
}
|
||||
return aw.Warnings, aw.Close()
|
||||
}
|
||||
|
||||
func writeLocal(plan *Plan, dir string) error {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, part := range plan.Manifest.Parts {
|
||||
p := filepath.Join(dir, part+".tar.zst")
|
||||
f, err := os.OpenFile(p, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
warns, err := writePart(f, plan, part)
|
||||
if cerr := f.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, w := range warns {
|
||||
log.Printf("! %s", w)
|
||||
}
|
||||
log.Printf("записано %s", p)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type countWriter struct {
|
||||
w io.Writer
|
||||
n int64
|
||||
}
|
||||
|
||||
func (c *countWriter) Write(p []byte) (int, error) {
|
||||
n, err := c.w.Write(p)
|
||||
c.n += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func human(n int64) string {
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
return fmt.Sprintf("%d Б", n)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for m := n / unit; m >= unit; m /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cБ", float64(n)/float64(div), []rune("КМГТП")[exp])
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const xuiDB = "/etc/x-ui/x-ui.db"
|
||||
|
||||
// snapshotXUI делает консистентную копию базы 3x-ui на горячую (VACUUM INTO),
|
||||
// без логина в панель и без остановки сервиса.
|
||||
func (c *collector) snapshotXUI(mc *manifest.Category) error {
|
||||
if _, err := os.Stat(xuiDB); err != nil {
|
||||
c.warn("xui: нет %s", xuiDB)
|
||||
return nil
|
||||
}
|
||||
dir, err := c.spoolDir(mc.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := filepath.Join(dir, "x-ui.db")
|
||||
if err := vacuumInto(xuiDB, out); err != nil {
|
||||
c.warn("xui: снимок базы не удался: %v", err)
|
||||
return nil
|
||||
}
|
||||
mc.Dumps = append(mc.Dumps, manifest.Dump{Name: "x-ui.db", Kind: "sqlite", Target: xuiDB})
|
||||
c.add(manifest.MainPart, dumpItem(mc.Name, out, "x-ui.db"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func vacuumInto(src, dst string) error {
|
||||
_ = os.Remove(dst)
|
||||
db, err := sql.Open("sqlite", "file:"+src+"?_pragma=busy_timeout(10000)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
_, err = db.Exec(fmt.Sprintf("VACUUM INTO '%s'", strings.ReplaceAll(dst, "'", "''")))
|
||||
return err
|
||||
}
|
||||
Reference in new issue
Block a user