// Package catalog описывает категории бэкапа. Встроенный каталог (default.yaml) // покрывает типовой набор софта, конфиг клиента с сервера переопределяет его по имени. package catalog import ( _ "embed" "fmt" "gopkg.in/yaml.v3" ) //go:embed default.yaml var defaultYAML []byte const ( TypeFiles = "files" TypeSystemd = "systemd" TypeXUI = "xui" TypeCompose = "compose" // TypeUnits — свои systemd-юниты (не из пакетов) с состоянием enabled/active. TypeUnits = "units" ) type Config struct { // AutoCompose — бэкапить compose-проекты, которых нет в каталоге. AutoCompose *bool `yaml:"auto_compose,omitempty"` Exclude []string `yaml:"exclude,omitempty"` // глобальные исключения Categories []Spec `yaml:"categories"` } type Spec struct { Name string `yaml:"name"` Type string `yaml:"type"` Disabled bool `yaml:"disabled,omitempty"` Detect []string `yaml:"detect,omitempty"` // категория активна, если существует хоть один путь Paths []string `yaml:"paths,omitempty"` Exclude []string `yaml:"exclude,omitempty"` // Unowned — маски файлов, которые берутся, только если они не принадлежат // ни одному пакету (dpkg -S): свои файлы в /etc/cron.d, /etc/systemd/system и т.п. Unowned []string `yaml:"unowned,omitempty"` // ModifiedConffiles — добавить конфиги пакетов, изменённые относительно поставки. ModifiedConffiles bool `yaml:"modified_conffiles,omitempty"` Service string `yaml:"service,omitempty"` VersionCmd []string `yaml:"version_cmd,omitempty"` VersionRegex string `yaml:"version_regex,omitempty"` Install string `yaml:"install,omitempty"` // recipe: nebula, xui, sing-box-extended KeepBinaries []string `yaml:"keep_binaries,omitempty"` // fallback-копии бинарников // KeepBinariesIf — shell-условие: keep_binaries берутся, только если оно истинно // (например, Caddy собран с плагинами и из apt его не вернуть). KeepBinariesIf string `yaml:"keep_binaries_if,omitempty"` EnsureDirs []string `yaml:"ensure_dirs,omitempty"` PostRestore []string `yaml:"post_restore,omitempty"` Dumps []CmdDump `yaml:"dumps,omitempty"` // compose Dir string `yaml:"dir,omitempty"` Project string `yaml:"project,omitempty"` SkipServices []string `yaml:"skip_services,omitempty"` HeavyPaths []string `yaml:"heavy_paths,omitempty"` // относительно dir (или абсолютные) RestoreAfter []string `yaml:"restore_after,omitempty"` } // CmdDump — произвольный дамп: stdout команды cmd сохраняется в файл name. type CmdDump struct { Name string `yaml:"name"` Cmd string `yaml:"cmd"` RestoreCmd string `yaml:"restore_cmd,omitempty"` } func (c *Config) AutoComposeEnabled() bool { return c.AutoCompose == nil || *c.AutoCompose } // Default возвращает встроенный каталог. func Default() (*Config, error) { var c Config if err := yaml.Unmarshal(defaultYAML, &c); err != nil { return nil, fmt.Errorf("встроенный каталог: %w", err) } return &c, c.Validate() } // Parse разбирает YAML-переопределения клиента. func Parse(data []byte) (*Config, error) { var c Config if err := yaml.Unmarshal(data, &c); err != nil { return nil, err } return &c, nil } // Merge накладывает override на base: категории с тем же именем заменяются целиком, // новые добавляются в конец, глобальные исключения складываются. func Merge(base, override *Config) *Config { out := &Config{AutoCompose: base.AutoCompose} out.Exclude = append(out.Exclude, base.Exclude...) out.Categories = append(out.Categories, base.Categories...) if override == nil { return out } if override.AutoCompose != nil { out.AutoCompose = override.AutoCompose } out.Exclude = append(out.Exclude, override.Exclude...) for _, s := range override.Categories { replaced := false for i := range out.Categories { if out.Categories[i].Name == s.Name { // "name + disabled: true" — короткая форма отключения встроенной категории if s.Disabled && s.Type == "" { out.Categories[i].Disabled = true } else { out.Categories[i] = s } replaced = true break } } if !replaced { out.Categories = append(out.Categories, s) } } return out } func (c *Config) Validate() error { seen := map[string]bool{} for _, s := range c.Categories { if s.Name == "" { return fmt.Errorf("категория без имени") } if seen[s.Name] { return fmt.Errorf("категория %q описана дважды", s.Name) } seen[s.Name] = true if s.Disabled { continue } switch s.Type { case TypeFiles, TypeSystemd, TypeXUI, TypeUnits: case TypeCompose: if s.Dir == "" && s.Project == "" { return fmt.Errorf("категория %q: для compose нужен dir или project", s.Name) } default: return fmt.Errorf("категория %q: неизвестный тип %q", s.Name, s.Type) } } return nil } // Find возвращает категорию по имени. func (c *Config) Find(name string) *Spec { for i := range c.Categories { if c.Categories[i].Name == name { return &c.Categories[i] } } return nil }