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) } }