// Package reconciler приводит DNS-записи Selectel в соответствие с роутерами Traefik. package reconciler import ( "context" "errors" "fmt" "log/slog" "sort" "sync" "time" "github.com/realmanual/traefik-selectel/internal/hostparse" "github.com/realmanual/traefik-selectel/internal/selectel" "github.com/realmanual/traefik-selectel/internal/traefik" ) // DNSClient — операции с DNS, необходимые reconciler-у. // Имена — нормализованные (lower-case, без точки в конце). type DNSClient interface { ListRRSets(ctx context.Context, zone string) ([]selectel.RRSet, error) CreateRRSet(ctx context.Context, zone string, rr selectel.RRSet) error UpdateRRSet(ctx context.Context, zone, rrsetID string, ttl int, records []selectel.Record, comment string) error DeleteRRSet(ctx context.Context, zone, rrsetID string) error } // Config — параметры reconcile. type Config struct { Zones []string // нормализованные разрешённые зоны IP string // IPv4 для A-записей TTL int Comment string // метка владения DryRun bool DeleteEnabled bool GracePeriod time.Duration } // Result — итоги одного цикла. type Result struct { Created, Updated, Deleted, Unchanged, SkippedForeign, SkippedConflict int PendingDelete int } // Reconciler — не потокобезопасен по вызовам Reconcile (вызывайте последовательно). type Reconciler struct { cfg Config source traefik.RouterSource dns DNSClient log *slog.Logger now func() time.Time hadRouters bool pending map[string]pendingDelete // ключ — имя записи warnedMu sync.Mutex warned map[string]struct{} } type pendingDelete struct { zone string since time.Time } // New создаёт reconciler. func New(cfg Config, source traefik.RouterSource, dns DNSClient, log *slog.Logger) *Reconciler { if log == nil { log = slog.Default() } return &Reconciler{ cfg: cfg, source: source, dns: dns, log: log, now: time.Now, pending: map[string]pendingDelete{}, warned: map[string]struct{}{}, } } // warnOnce логирует warning один раз на ключ (дальше — debug), чтобы не шуметь каждый цикл. func (r *Reconciler) warnOnce(key, msg string, args ...any) { r.warnedMu.Lock() _, seen := r.warned[key] r.warned[key] = struct{}{} r.warnedMu.Unlock() if seen { r.log.Debug(msg, args...) return } r.log.Warn(msg, args...) } type desiredSet struct { create map[string]string // имя -> зона (enabled-роутеры) present map[string]string // имя -> зона (enabled + warning): защита от удаления } // Reconcile выполняет один цикл. func (r *Reconciler) Reconcile(ctx context.Context) (Result, error) { var res Result routers, err := r.source.Routers(ctx) if err != nil { // Источник недоступен: ничего не создаём и, главное, ничего не удаляем. return res, fmt.Errorf("получение роутеров: %w", err) } if len(routers) == 0 && r.hadRouters { r.log.Warn("Traefik API вернул пустой список роутеров при ранее непустом — цикл пропущен, удаления заблокированы") return res, errors.New("пустой список роутеров при ранее непустом") } if len(routers) > 0 { r.hadRouters = true } desired := r.collect(routers) // Какие зоны надо читать: с желаемыми хостами, а при включённом удалении — все. zoneSet := map[string]struct{}{} for _, z := range desired.create { zoneSet[z] = struct{}{} } if r.cfg.DeleteEnabled { for _, z := range r.cfg.Zones { zoneSet[z] = struct{}{} } } zones := make([]string, 0, len(zoneSet)) for z := range zoneSet { zones = append(zones, z) } sort.Strings(zones) var errs []error listed := map[string][]selectel.RRSet{} for _, zone := range zones { rrsets, err := r.dns.ListRRSets(ctx, zone) if err != nil { errs = append(errs, fmt.Errorf("зона %s: список записей: %w", zone, err)) r.log.Error("не удалось получить записи зоны", "zone", zone, "error", err) continue } listed[zone] = rrsets } // Создание/обновление. names := make([]string, 0, len(desired.create)) for n := range desired.create { names = append(names, n) } sort.Strings(names) for _, name := range names { zone := desired.create[name] rrsets, ok := listed[zone] if !ok { continue } if err := r.ensure(ctx, zone, name, rrsets, &res); err != nil { errs = append(errs, err) } } if r.cfg.DeleteEnabled { errs = append(errs, r.deleteStale(ctx, listed, desired, &res)...) } res.PendingDelete = len(r.pending) return res, errors.Join(errs...) } // collect извлекает желаемые имена из роутеров. func (r *Reconciler) collect(routers []traefik.Router) desiredSet { d := desiredSet{create: map[string]string{}, present: map[string]string{}} for _, rt := range routers { if rt.Status != "enabled" && rt.Status != "warning" { continue } p := hostparse.Parse(rt.Rule) if p.RegexpCount > 0 { r.log.Debug("HostRegexp игнорируется", "router", rt.Name, "count", p.RegexpCount) } for _, w := range p.Wildcards { r.warnOnce("wildcard|"+rt.Name+"|"+w, "wildcard-хосты в Host() не поддерживаются, пропущено", "router", rt.Name, "host", w) } for _, inv := range p.Invalid { r.warnOnce("invalid|"+rt.Name+"|"+inv, "некорректное имя хоста в Host(), пропущено", "router", rt.Name, "host", inv) } for _, h := range p.Hosts { zone, ok := hostparse.MatchZone(h, r.cfg.Zones) if !ok { r.log.Debug("хост вне разрешённых зон, пропущен", "router", rt.Name, "host", h) continue } d.present[h] = zone if rt.Status == "enabled" { d.create[h] = zone } } } return d } func isManaged(rr selectel.RRSet, comment string) bool { return rr.Comment == comment } func (r *Reconciler) ensure(ctx context.Context, zone, name string, rrsets []selectel.RRSet, res *Result) error { var existingA *selectel.RRSet var cname *selectel.RRSet for i := range rrsets { rr := &rrsets[i] if rr.Name != name { continue } switch rr.Type { case "A": existingA = rr case "CNAME": cname = rr } } // Хост снова востребован — отменяем отложенное удаление. delete(r.pending, name) switch { case existingA != nil && !isManaged(*existingA, r.cfg.Comment): res.SkippedForeign++ r.warnOnce("foreign|"+name, "A-запись существует без метки владения, не трогаем", "name", name, "zone", zone) return nil case existingA != nil: if r.matches(*existingA) { res.Unchanged++ r.log.Debug("запись актуальна", "name", name) return nil } if r.cfg.DryRun { r.log.Info("dry-run: обновил бы A-запись", "name", name, "ip", r.cfg.IP, "ttl", r.cfg.TTL) return nil } if err := r.dns.UpdateRRSet(ctx, zone, existingA.ID, r.cfg.TTL, []selectel.Record{{Content: r.cfg.IP}}, r.cfg.Comment); err != nil { r.log.Error("не удалось обновить A-запись", "name", name, "error", err) return fmt.Errorf("обновление %s: %w", name, err) } res.Updated++ r.log.Info("A-запись обновлена", "name", name, "ip", r.cfg.IP, "ttl", r.cfg.TTL) return nil case cname != nil: res.SkippedConflict++ r.warnOnce("cname|"+name, "для имени существует CNAME, A-запись не создаём", "name", name, "zone", zone) return nil } if r.cfg.DryRun { r.log.Info("dry-run: создал бы A-запись", "name", name, "ip", r.cfg.IP, "ttl", r.cfg.TTL) return nil } err := r.dns.CreateRRSet(ctx, zone, selectel.RRSet{ Name: name, Type: "A", TTL: r.cfg.TTL, Records: []selectel.Record{{Content: r.cfg.IP}}, Comment: r.cfg.Comment, }) if err != nil { r.log.Error("не удалось создать A-запись", "name", name, "error", err) return fmt.Errorf("создание %s: %w", name, err) } res.Created++ r.log.Info("A-запись создана", "name", name, "ip", r.cfg.IP, "ttl", r.cfg.TTL) return nil } func (r *Reconciler) matches(rr selectel.RRSet) bool { return rr.TTL == r.cfg.TTL && len(rr.Records) == 1 && rr.Records[0].Content == r.cfg.IP && !rr.Records[0].Disabled } // deleteStale удаляет managed A-записи, для которых роутер отсутствует дольше GracePeriod. func (r *Reconciler) deleteStale(ctx context.Context, listed map[string][]selectel.RRSet, d desiredSet, res *Result) []error { var errs []error now := r.now() zones := make([]string, 0, len(listed)) for z := range listed { zones = append(zones, z) } sort.Strings(zones) stale := map[string]struct{}{} for _, zone := range zones { for _, rr := range listed[zone] { if rr.Type != "A" || !isManaged(rr, r.cfg.Comment) { continue } if _, ok := d.present[rr.Name]; ok { continue } stale[rr.Name] = struct{}{} p, ok := r.pending[rr.Name] if !ok { r.pending[rr.Name] = pendingDelete{zone: zone, since: now} r.log.Info("роут исчез, запись будет удалена после grace-периода", "name", rr.Name, "grace", r.cfg.GracePeriod.String()) continue } if now.Sub(p.since) < r.cfg.GracePeriod { continue } if r.cfg.DryRun { r.log.Info("dry-run: удалил бы A-запись", "name", rr.Name) continue } if err := r.dns.DeleteRRSet(ctx, zone, rr.ID); err != nil { r.log.Error("не удалось удалить A-запись", "name", rr.Name, "error", err) errs = append(errs, fmt.Errorf("удаление %s: %w", rr.Name, err)) continue } delete(r.pending, rr.Name) res.Deleted++ r.log.Info("A-запись удалена", "name", rr.Name) } } // Чистим состояние для успешно прочитанных зон: запись уже не stale (вернулась/удалена извне). for name, p := range r.pending { if _, zoneOK := listed[p.zone]; !zoneOK { continue } if _, ok := stale[name]; !ok { delete(r.pending, name) } } return errs }