Files
traefik-selectel/internal/reconciler/reconciler.go
T
vasyanskandClaude Sonnet 5 273cd54415
Build / Tests (push) Successful in 13s
Build / Build image (push) Failing after 15s
Build / Notify on failure (push) Failing after 0s
feat: traefik-selectel-dns — A-записи Selectel по роутам Traefik + CI сборка образа
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 21:50:07 +07:00

322 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}