feat: traefik-selectel-dns — A-записи Selectel по роутам Traefik + CI сборка образа
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
package reconciler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CycleTimeout — верхняя граница одного цикла reconcile.
|
||||
const CycleTimeout = 5 * time.Minute
|
||||
|
||||
// Run запускает цикл: сразу, затем каждые interval, а также по сигналу trigger
|
||||
// (после debounce, чтобы схлопнуть пачку событий). Блокируется до отмены ctx.
|
||||
// report вызывается после каждого цикла.
|
||||
func (r *Reconciler) Run(ctx context.Context, interval, debounce time.Duration, trigger <-chan struct{}, report func(Result, error)) {
|
||||
cycle := func() {
|
||||
cctx, cancel := context.WithTimeout(ctx, CycleTimeout)
|
||||
defer cancel()
|
||||
res, err := r.Reconcile(cctx)
|
||||
if err != nil {
|
||||
r.log.Error("цикл reconcile завершён с ошибками", "error", err)
|
||||
} else {
|
||||
r.log.Info("цикл reconcile завершён",
|
||||
"created", res.Created, "updated", res.Updated, "deleted", res.Deleted,
|
||||
"unchanged", res.Unchanged, "skippedForeign", res.SkippedForeign,
|
||||
"skippedConflict", res.SkippedConflict, "pendingDelete", res.PendingDelete)
|
||||
}
|
||||
if report != nil {
|
||||
report(res, err)
|
||||
}
|
||||
}
|
||||
|
||||
cycle()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
cycle()
|
||||
case <-trigger:
|
||||
if debounce > 0 {
|
||||
t := time.NewTimer(debounce)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Stop()
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
// схлопываем накопившиеся сигналы
|
||||
select {
|
||||
case <-trigger:
|
||||
default:
|
||||
}
|
||||
r.log.Debug("reconcile по событию Docker")
|
||||
cycle()
|
||||
ticker.Reset(interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package reconciler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/realmanual/traefik-selectel/internal/traefik"
|
||||
)
|
||||
|
||||
func TestRunTicksAndTriggers(t *testing.T) {
|
||||
e := newEnv(nil)
|
||||
e.src.routers = []traefik.Router{router("a", "Host(`app.example.com`)", "enabled")}
|
||||
trigger := make(chan struct{}, 1)
|
||||
var cycles int32
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
e.rec.Run(ctx, time.Hour, 5*time.Millisecond, trigger, func(Result, error) { atomic.AddInt32(&cycles, 1) })
|
||||
close(done)
|
||||
}()
|
||||
waitCycles := func(n int32) {
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for atomic.LoadInt32(&cycles) < n {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("ожидали %d циклов, было %d", n, atomic.LoadInt32(&cycles))
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
waitCycles(1) // стартовый
|
||||
trigger <- struct{}{}
|
||||
waitCycles(2)
|
||||
cancel()
|
||||
<-done
|
||||
if len(e.dns.created) != 1 {
|
||||
t.Fatalf("created = %v", e.dns.created)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
package reconciler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/realmanual/traefik-selectel/internal/selectel"
|
||||
"github.com/realmanual/traefik-selectel/internal/traefik"
|
||||
)
|
||||
|
||||
const marker = "managed-by=traefik-selectel-dns"
|
||||
|
||||
type fakeSource struct {
|
||||
routers []traefik.Router
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeSource) Routers(context.Context) ([]traefik.Router, error) { return f.routers, f.err }
|
||||
|
||||
type fakeDNS struct {
|
||||
zones map[string][]selectel.RRSet
|
||||
listErr map[string]error
|
||||
created []selectel.RRSet
|
||||
updated []string
|
||||
deleted []string
|
||||
nextID int
|
||||
}
|
||||
|
||||
func (f *fakeDNS) ListRRSets(_ context.Context, zone string) ([]selectel.RRSet, error) {
|
||||
if err := f.listErr[zone]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append([]selectel.RRSet(nil), f.zones[zone]...), nil
|
||||
}
|
||||
|
||||
func (f *fakeDNS) CreateRRSet(_ context.Context, zone string, rr selectel.RRSet) error {
|
||||
f.created = append(f.created, rr)
|
||||
f.nextID++
|
||||
rr.ID = "new-" + string(rune('0'+f.nextID))
|
||||
f.zones[zone] = append(f.zones[zone], rr)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeDNS) UpdateRRSet(_ context.Context, zone, id string, ttl int, recs []selectel.Record, comment string) error {
|
||||
f.updated = append(f.updated, id)
|
||||
for i, rr := range f.zones[zone] {
|
||||
if rr.ID == id {
|
||||
f.zones[zone][i].TTL, f.zones[zone][i].Records, f.zones[zone][i].Comment = ttl, recs, comment
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeDNS) DeleteRRSet(_ context.Context, zone, id string) error {
|
||||
f.deleted = append(f.deleted, id)
|
||||
var keep []selectel.RRSet
|
||||
for _, rr := range f.zones[zone] {
|
||||
if rr.ID != id {
|
||||
keep = append(keep, rr)
|
||||
}
|
||||
}
|
||||
f.zones[zone] = keep
|
||||
return nil
|
||||
}
|
||||
|
||||
func router(name, rule, status string) traefik.Router {
|
||||
return traefik.Router{Name: name, Rule: rule, Status: status}
|
||||
}
|
||||
|
||||
func aRec(id, name, ip, comment string) selectel.RRSet {
|
||||
return selectel.RRSet{ID: id, Name: name, Type: "A", TTL: 300, Comment: comment, Records: []selectel.Record{{Content: ip}}}
|
||||
}
|
||||
|
||||
type env struct {
|
||||
src *fakeSource
|
||||
dns *fakeDNS
|
||||
rec *Reconciler
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func newEnv(cfgMod func(*Config)) *env {
|
||||
cfg := Config{Zones: []string{"example.com"}, IP: "1.2.3.4", TTL: 300, Comment: marker, GracePeriod: 10 * time.Minute}
|
||||
if cfgMod != nil {
|
||||
cfgMod(&cfg)
|
||||
}
|
||||
e := &env{
|
||||
src: &fakeSource{},
|
||||
dns: &fakeDNS{zones: map[string][]selectel.RRSet{"example.com": nil}, listErr: map[string]error{}},
|
||||
now: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
e.rec = New(cfg, e.src, e.dns, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
e.rec.now = func() time.Time { return e.now }
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *env) run(t *testing.T) Result {
|
||||
t.Helper()
|
||||
res, err := e.rec.Reconcile(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("reconcile: %v", err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func TestCreate(t *testing.T) {
|
||||
e := newEnv(nil)
|
||||
e.src.routers = []traefik.Router{
|
||||
router("a@docker", "Host(`app.example.com`)", "enabled"),
|
||||
router("b@file", "Host(`x.other.net`)", "enabled"), // вне зон
|
||||
router("c@docker", "Host(`off.example.com`)", "disabled"), // не enabled
|
||||
router("d@docker", "HostRegexp(`^.+\\.example\\.com$`)", "enabled"),
|
||||
router("e@docker", "Host(`*.example.com`)", "enabled"),
|
||||
}
|
||||
res := e.run(t)
|
||||
if res.Created != 1 || len(e.dns.created) != 1 {
|
||||
t.Fatalf("res=%+v created=%v", res, e.dns.created)
|
||||
}
|
||||
c := e.dns.created[0]
|
||||
if c.Name != "app.example.com" || c.Type != "A" || c.TTL != 300 || c.Comment != marker || c.Records[0].Content != "1.2.3.4" {
|
||||
t.Errorf("created = %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneApexAndIdempotency(t *testing.T) {
|
||||
e := newEnv(nil)
|
||||
e.src.routers = []traefik.Router{router("a", "Host(`example.com`) || Host(`www.example.com`)", "enabled")}
|
||||
if res := e.run(t); res.Created != 2 {
|
||||
t.Fatalf("res=%+v", res)
|
||||
}
|
||||
res := e.run(t)
|
||||
if res.Created != 0 || res.Updated != 0 || res.Unchanged != 2 || len(e.dns.created) != 2 {
|
||||
t.Fatalf("второй цикл не идемпотентен: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateManagedWhenDiffers(t *testing.T) {
|
||||
e := newEnv(nil)
|
||||
e.dns.zones["example.com"] = []selectel.RRSet{aRec("r1", "app.example.com", "9.9.9.9", marker)}
|
||||
e.src.routers = []traefik.Router{router("a", "Host(`app.example.com`)", "enabled")}
|
||||
res := e.run(t)
|
||||
if res.Updated != 1 || len(e.dns.updated) != 1 || e.dns.zones["example.com"][0].Records[0].Content != "1.2.3.4" {
|
||||
t.Fatalf("res=%+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateOnTTLChangeAndDisabledRecord(t *testing.T) {
|
||||
e := newEnv(nil)
|
||||
rr := aRec("r1", "app.example.com", "1.2.3.4", marker)
|
||||
rr.TTL = 60
|
||||
rr2 := aRec("r2", "b.example.com", "1.2.3.4", marker)
|
||||
rr2.Records[0].Disabled = true
|
||||
e.dns.zones["example.com"] = []selectel.RRSet{rr, rr2}
|
||||
e.src.routers = []traefik.Router{router("a", "Host(`app.example.com`, `b.example.com`)", "enabled")}
|
||||
if res := e.run(t); res.Updated != 2 {
|
||||
t.Fatalf("res=%+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkipForeignRecord(t *testing.T) {
|
||||
e := newEnv(nil)
|
||||
e.dns.zones["example.com"] = []selectel.RRSet{
|
||||
aRec("r1", "app.example.com", "9.9.9.9", ""),
|
||||
aRec("r2", "b.example.com", "9.9.9.9", "created by hand"),
|
||||
}
|
||||
e.src.routers = []traefik.Router{router("a", "Host(`app.example.com`) || Host(`b.example.com`)", "enabled")}
|
||||
res := e.run(t)
|
||||
if res.SkippedForeign != 2 || len(e.dns.created)+len(e.dns.updated)+len(e.dns.deleted) != 0 {
|
||||
t.Fatalf("res=%+v dns=%+v", res, e.dns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkipWhenCNAMEExists(t *testing.T) {
|
||||
e := newEnv(nil)
|
||||
e.dns.zones["example.com"] = []selectel.RRSet{{ID: "c1", Name: "app.example.com", Type: "CNAME", TTL: 300,
|
||||
Records: []selectel.Record{{Content: "other.net."}}}}
|
||||
e.src.routers = []traefik.Router{router("a", "Host(`app.example.com`)", "enabled")}
|
||||
if res := e.run(t); res.SkippedConflict != 1 || len(e.dns.created) != 0 {
|
||||
t.Fatalf("res=%+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOtherTypesDoNotBlockCreate(t *testing.T) {
|
||||
e := newEnv(nil)
|
||||
e.dns.zones["example.com"] = []selectel.RRSet{{ID: "t1", Name: "app.example.com", Type: "TXT", TTL: 300}}
|
||||
e.src.routers = []traefik.Router{router("a", "Host(`app.example.com`)", "enabled")}
|
||||
if res := e.run(t); res.Created != 1 {
|
||||
t.Fatalf("res=%+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunDoesNotMutate(t *testing.T) {
|
||||
e := newEnv(func(c *Config) { c.DryRun = true })
|
||||
e.dns.zones["example.com"] = []selectel.RRSet{aRec("r1", "old.example.com", "9.9.9.9", marker)}
|
||||
e.src.routers = []traefik.Router{router("a", "Host(`app.example.com`)", "enabled"), router("b", "Host(`old.example.com`)", "enabled")}
|
||||
e.run(t)
|
||||
if len(e.dns.created)+len(e.dns.updated)+len(e.dns.deleted) != 0 {
|
||||
t.Fatalf("dry-run изменил DNS: %+v", e.dns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDisabledByDefault(t *testing.T) {
|
||||
e := newEnv(nil)
|
||||
e.dns.zones["example.com"] = []selectel.RRSet{aRec("r1", "gone.example.com", "1.2.3.4", marker)}
|
||||
e.src.routers = []traefik.Router{router("a", "Host(`app.example.com`)", "enabled")}
|
||||
e.run(t)
|
||||
e.now = e.now.Add(24 * time.Hour)
|
||||
e.run(t)
|
||||
if len(e.dns.deleted) != 0 {
|
||||
t.Fatalf("удаление должно быть выключено: %v", e.dns.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAfterGracePeriod(t *testing.T) {
|
||||
e := newEnv(func(c *Config) { c.DeleteEnabled = true })
|
||||
e.dns.zones["example.com"] = []selectel.RRSet{
|
||||
aRec("r1", "gone.example.com", "1.2.3.4", marker),
|
||||
aRec("r2", "foreign.example.com", "1.2.3.4", ""), // чужая — никогда не удаляем
|
||||
}
|
||||
e.src.routers = []traefik.Router{router("a", "Host(`app.example.com`)", "enabled")}
|
||||
|
||||
res := e.run(t) // старт отсчёта
|
||||
if len(e.dns.deleted) != 0 || res.PendingDelete != 1 {
|
||||
t.Fatalf("res=%+v deleted=%v", res, e.dns.deleted)
|
||||
}
|
||||
e.now = e.now.Add(9 * time.Minute)
|
||||
e.run(t)
|
||||
if len(e.dns.deleted) != 0 {
|
||||
t.Fatal("удалено раньше grace-периода")
|
||||
}
|
||||
e.now = e.now.Add(2 * time.Minute)
|
||||
res = e.run(t)
|
||||
if res.Deleted != 1 || len(e.dns.deleted) != 1 || e.dns.deleted[0] != "r1" {
|
||||
t.Fatalf("res=%+v deleted=%v", res, e.dns.deleted)
|
||||
}
|
||||
for _, rr := range e.dns.zones["example.com"] {
|
||||
if rr.ID == "r2" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("чужая запись удалена")
|
||||
}
|
||||
|
||||
func TestGraceResetWhenRouterReturns(t *testing.T) {
|
||||
e := newEnv(func(c *Config) { c.DeleteEnabled = true })
|
||||
e.dns.zones["example.com"] = []selectel.RRSet{aRec("r1", "app.example.com", "1.2.3.4", marker)}
|
||||
other := router("x", "Host(`other.example.com`)", "enabled")
|
||||
e.src.routers = []traefik.Router{other}
|
||||
e.run(t)
|
||||
e.now = e.now.Add(8 * time.Minute)
|
||||
e.src.routers = []traefik.Router{other, router("a", "Host(`app.example.com`)", "enabled")} // вернулся
|
||||
e.run(t)
|
||||
e.now = e.now.Add(8 * time.Minute)
|
||||
e.src.routers = []traefik.Router{other} // снова исчез — отсчёт заново
|
||||
e.run(t)
|
||||
e.now = e.now.Add(8 * time.Minute)
|
||||
e.run(t)
|
||||
if len(e.dns.deleted) != 0 {
|
||||
t.Fatalf("grace не сброшен: %v", e.dns.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarningRouterProtectsFromDelete(t *testing.T) {
|
||||
e := newEnv(func(c *Config) { c.DeleteEnabled = true })
|
||||
e.dns.zones["example.com"] = []selectel.RRSet{aRec("r1", "app.example.com", "1.2.3.4", marker)}
|
||||
e.src.routers = []traefik.Router{router("a", "Host(`app.example.com`)", "warning")}
|
||||
e.run(t)
|
||||
e.now = e.now.Add(time.Hour)
|
||||
e.run(t)
|
||||
if len(e.dns.deleted) != 0 {
|
||||
t.Fatal("router со статусом warning не должен приводить к удалению")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeGuardTraefikError(t *testing.T) {
|
||||
e := newEnv(func(c *Config) { c.DeleteEnabled = true })
|
||||
e.dns.zones["example.com"] = []selectel.RRSet{aRec("r1", "gone.example.com", "1.2.3.4", marker)}
|
||||
e.src.routers = []traefik.Router{router("a", "Host(`app.example.com`)", "enabled")}
|
||||
e.run(t) // gone -> pending
|
||||
e.now = e.now.Add(time.Hour)
|
||||
e.src.err = errors.New("connection refused")
|
||||
if _, err := e.rec.Reconcile(context.Background()); err == nil {
|
||||
t.Fatal("ожидалась ошибка")
|
||||
}
|
||||
if len(e.dns.deleted) != 0 {
|
||||
t.Fatal("удаление при ошибке Traefik API")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeGuardEmptyListAfterNonEmpty(t *testing.T) {
|
||||
e := newEnv(func(c *Config) { c.DeleteEnabled = true })
|
||||
e.dns.zones["example.com"] = []selectel.RRSet{aRec("r1", "app.example.com", "1.2.3.4", marker)}
|
||||
e.src.routers = []traefik.Router{router("a", "Host(`app.example.com`)", "enabled")}
|
||||
e.run(t)
|
||||
e.src.routers = nil
|
||||
e.now = e.now.Add(time.Hour)
|
||||
if _, err := e.rec.Reconcile(context.Background()); err == nil {
|
||||
t.Fatal("ожидалась ошибка на пустой список")
|
||||
}
|
||||
e.now = e.now.Add(time.Hour)
|
||||
_, _ = e.rec.Reconcile(context.Background())
|
||||
if len(e.dns.deleted) != 0 {
|
||||
t.Fatal("удаление при пустом списке роутеров")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneListErrorSkipsZoneOnly(t *testing.T) {
|
||||
e := newEnv(func(c *Config) { c.Zones = []string{"example.com", "other.org"}; c.DeleteEnabled = true })
|
||||
e.dns.zones["other.org"] = []selectel.RRSet{aRec("r9", "old.other.org", "1.2.3.4", marker)}
|
||||
e.dns.listErr["other.org"] = errors.New("boom")
|
||||
e.src.routers = []traefik.Router{router("a", "Host(`app.example.com`) || Host(`x.other.org`)", "enabled")}
|
||||
res, err := e.rec.Reconcile(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка зоны")
|
||||
}
|
||||
if res.Created != 1 || e.dns.created[0].Name != "app.example.com" {
|
||||
t.Fatalf("res=%+v created=%v", res, e.dns.created)
|
||||
}
|
||||
e.now = e.now.Add(time.Hour)
|
||||
_, _ = e.rec.Reconcile(context.Background())
|
||||
if len(e.dns.deleted) != 0 {
|
||||
t.Fatal("удаление в зоне с ошибкой чтения")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLongestZoneWins(t *testing.T) {
|
||||
e := newEnv(func(c *Config) { c.Zones = []string{"example.com", "dev.example.com"} })
|
||||
e.dns.zones["dev.example.com"] = nil
|
||||
e.src.routers = []traefik.Router{router("a", "Host(`app.dev.example.com`)", "enabled")}
|
||||
e.run(t)
|
||||
if len(e.dns.zones["dev.example.com"]) != 1 || len(e.dns.zones["example.com"]) != 0 {
|
||||
t.Fatalf("zones = %+v", e.dns.zones)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateErrorReported(t *testing.T) {
|
||||
e := newEnv(nil)
|
||||
e.dns.zones["example.com"] = nil
|
||||
e.src.routers = []traefik.Router{router("a", "Host(`app.example.com`)", "enabled")}
|
||||
fd := &failingCreate{fakeDNS: e.dns}
|
||||
e.rec.dns = fd
|
||||
if _, err := e.rec.Reconcile(context.Background()); err == nil {
|
||||
t.Fatal("ожидалась ошибка")
|
||||
}
|
||||
}
|
||||
|
||||
type failingCreate struct{ *fakeDNS }
|
||||
|
||||
func (f *failingCreate) CreateRRSet(context.Context, string, selectel.RRSet) error {
|
||||
return errors.New("422")
|
||||
}
|
||||
|
||||
func TestNamesDeterministic(t *testing.T) {
|
||||
e := newEnv(nil)
|
||||
e.src.routers = []traefik.Router{router("a", "Host(`c.example.com`,`a.example.com`,`b.example.com`)", "enabled")}
|
||||
e.run(t)
|
||||
var got []string
|
||||
for _, c := range e.dns.created {
|
||||
got = append(got, c.Name)
|
||||
}
|
||||
if !sort.StringsAreSorted(got) {
|
||||
t.Fatalf("порядок создания не детерминирован: %v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user