369 lines
12 KiB
Go
369 lines
12 KiB
Go
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)
|
|
}
|
|
}
|