Раньше восстановленная копия получала конфиг и таймер агента со старым токеном (через категории etc и units), и бэкапы шли с двух серверов одновременно. - конфиг и юниты агента больше не бэкапятся - в конце restore спрашивает, сделать ли сервер основным: сервер бэкапов выпускает новый токен (POST /api/v1/restore/takeover), restore ставит агента; токен старого сервера отозван — его агент получает отказ - --takeover / --no-takeover; без них — вопрос (с --yes — да) - уведомление «Сервер восстановлен» в ntfy/Telegram Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
140 lines
5.3 KiB
Go
140 lines
5.3 KiB
Go
package agent
|
||
|
||
import (
|
||
"bufio"
|
||
"os"
|
||
"path/filepath"
|
||
"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"
|
||
)
|
||
|
||
// collectUnits собирает свои systemd-юниты (не из пакетов и не описанные другими
|
||
// категориями) вместе с состоянием enabled/active, а также drop-in каталоги *.d —
|
||
// переопределения пакетных юнитов (например, docker.service.d/override.conf).
|
||
func (c *collector) collectUnits(s catalog.Spec) *manifest.Category {
|
||
mc := &manifest.Category{Name: s.Name, Type: catalog.TypeUnits, PostRestore: s.PostRestore, RestoreAfter: s.RestoreAfter}
|
||
covered := c.covered()
|
||
services := map[string]bool{}
|
||
for _, cat := range c.plan.Manifest.Categories {
|
||
if cat.Service != nil {
|
||
services[cat.Service.Unit] = true
|
||
}
|
||
}
|
||
skip := archive.NewMatcher(c.cat.Exclude, s.Exclude)
|
||
|
||
for _, f := range c.unowned(s.Unowned) {
|
||
unit := filepath.Base(f)
|
||
name := strings.TrimSuffix(strings.TrimSuffix(unit, ".service"), ".timer")
|
||
// юниты самого агента не бэкапим: на новый сервер его ставит restore с новым токеном,
|
||
// иначе восстановленная копия слала бы бэкапы со старым токеном параллельно старому серверу
|
||
if strings.HasPrefix(unit, "backup-agent.") || archive.Covers(covered, filepath.ToSlash(f)) || services[name] || services[unit] {
|
||
continue
|
||
}
|
||
mc.Paths = append(mc.Paths, f)
|
||
c.add(manifest.MainPart, filesItem(s.Name, f, skip))
|
||
st := manifest.ServiceState{Unit: unit}
|
||
if sysx.UnitLoaded(c.ctx, unit) {
|
||
st.Enabled, st.Active = sysx.UnitState(c.ctx, unit)
|
||
}
|
||
mc.Units = append(mc.Units, st)
|
||
c.checkExec(unit, f, covered)
|
||
}
|
||
for _, d := range expandPaths(s.Paths) {
|
||
if fi, err := os.Stat(d); err != nil || !fi.IsDir() || archive.Covers(covered, filepath.ToSlash(d)) {
|
||
continue
|
||
}
|
||
mc.Paths = append(mc.Paths, d)
|
||
c.add(manifest.MainPart, filesItem(s.Name, d, skip))
|
||
}
|
||
if len(mc.Paths) == 0 {
|
||
return nil
|
||
}
|
||
return mc
|
||
}
|
||
|
||
// checkExec предупреждает, если юнит запускает программу, которая не попадает в бэкап
|
||
// и не ставится пакетом: после восстановления такой сервис не стартует.
|
||
func (c *collector) checkExec(unit, file string, covered []string) {
|
||
var missing []string
|
||
for _, p := range unitExecPaths(file) {
|
||
if archive.Covers(covered, p) || archive.Covers(systemBinDirs, p) || slices.Contains(missing, p) {
|
||
continue
|
||
}
|
||
if !sysx.Exists(p) {
|
||
continue
|
||
}
|
||
if sysx.DpkgOwned(c.ctx, []string{p})[p] {
|
||
continue
|
||
}
|
||
missing = append(missing, p)
|
||
}
|
||
for _, p := range missing {
|
||
c.warn("юнит %s запускает %s, но он не попадает в бэкап — добавьте путь в категорию", unit, p)
|
||
}
|
||
}
|
||
|
||
// unitExecPaths извлекает пути программ из Exec*= строк unit-файла.
|
||
func unitExecPaths(file string) []string {
|
||
f, err := os.Open(file)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
defer f.Close()
|
||
var out []string
|
||
sc := bufio.NewScanner(f)
|
||
for sc.Scan() {
|
||
k, v, ok := strings.Cut(strings.TrimSpace(sc.Text()), "=")
|
||
if !ok || !strings.HasPrefix(k, "Exec") {
|
||
continue
|
||
}
|
||
// префиксы systemd: -, @, :, +, !
|
||
v = strings.TrimLeft(strings.TrimSpace(v), "-@:+!")
|
||
if fields := strings.Fields(v); len(fields) > 0 && strings.HasPrefix(fields[0], "/") {
|
||
out = append(out, fields[0])
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// checkCoverage ищет типичные места, где лежит что-то своё, не попавшее в бэкап:
|
||
// каталоги в /opt, программы в /usr/local/bin и sbin, контейнеры без compose.
|
||
func (c *collector) checkCoverage() {
|
||
covered := c.covered()
|
||
ignore := []string{"/opt/containerd", "/usr/local/bin/backupctl"}
|
||
for _, s := range c.cat.Categories {
|
||
if s.Disabled && s.Dir != "" {
|
||
ignore = append(ignore, filepath.ToSlash(s.Dir)) // отключено сознательно
|
||
}
|
||
}
|
||
check := func(pattern, what string) {
|
||
matches, _ := filepath.Glob(pattern)
|
||
for _, p := range matches {
|
||
ps := filepath.ToSlash(p)
|
||
if archive.Covers(covered, ps) || slices.Contains(ignore, ps) || c.global.Match(ps) {
|
||
continue
|
||
}
|
||
c.warn("%s %s не попадает в бэкап — добавьте его в категорию или в глобальный exclude, если он не нужен", what, p)
|
||
}
|
||
}
|
||
check("/opt/*", "каталог")
|
||
check("/usr/local/bin/*", "программа")
|
||
check("/usr/local/sbin/*", "программа")
|
||
|
||
if sysx.HasCommand("docker") {
|
||
out, err := sysx.Output(c.ctx, "docker", "ps", "--format", `{{.Names}} {{.Label "com.docker.compose.project"}}`)
|
||
if err == nil {
|
||
for _, line := range strings.Split(out, "\n") {
|
||
name, project, _ := strings.Cut(line, "\t")
|
||
if name != "" && project == "" {
|
||
c.warn("контейнер %s запущен без docker compose (docker run) — его настройки и данные не бэкапятся", name)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|