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 expandPaths(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 expandPaths(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 } // expandPaths раскрывает маски (*, ?, [...]) в путях категории; пути без масок — как есть. func expandPaths(paths []string) []string { var out []string for _, p := range paths { if !strings.ContainsAny(p, "*?[") { out = append(out, p) continue } m, _ := filepath.Glob(p) out = append(out, m...) } return out } 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 }