feat: traefik-selectel-dns — A-записи Selectel по роутам Traefik + CI сборка образа
Build / Tests (push) Successful in 13s
Build / Build image (push) Failing after 15s
Build / Notify on failure (push) Failing after 0s

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 21:50:07 +07:00
co-authored by Claude Sonnet 5
commit 273cd54415
35 changed files with 3722 additions and 0 deletions
+186
View File
@@ -0,0 +1,186 @@
package selectel
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/realmanual/traefik-selectel/internal/httpx"
)
// DefaultAuthURL — Keystone (identity v3) Selectel.
const DefaultAuthURL = "https://cloud.api.selcloud.ru/identity/v3"
const (
tokenRefreshMargin = 10 * time.Minute
// Если expires_at не удалось разобрать: документация заявляет 24 часа, берём консервативно 1 час.
fallbackTokenTTL = time.Hour
)
// Credentials — учётные данные сервисного пользователя Selectel.
// Значения секретов не должны попадать в логи: String() их скрывает.
type Credentials struct {
Username string
Password string
AccountID string // имя домена Keystone (номер аккаунта)
ProjectID string
}
// String скрывает секреты.
func (c Credentials) String() string {
return fmt.Sprintf("Credentials{username=%q account=%q project=%q password=<redacted>}", c.Username, c.AccountID, c.ProjectID)
}
// Validate проверяет, что все поля заданы.
func (c Credentials) Validate() error {
var missing []string
if c.Username == "" {
missing = append(missing, "SELECTEL_USERNAME")
}
if c.Password == "" {
missing = append(missing, "SELECTEL_PASSWORD")
}
if c.AccountID == "" {
missing = append(missing, "SELECTEL_ACCOUNT_ID")
}
if c.ProjectID == "" {
missing = append(missing, "SELECTEL_PROJECT_ID")
}
if len(missing) > 0 {
return fmt.Errorf("selectel: не заданы учётные данные: %s", strings.Join(missing, ", "))
}
return nil
}
// TokenSource выдаёт IAM-токен проекта.
type TokenSource interface {
Token(ctx context.Context) (string, error)
// Invalidate сбрасывает кэш (например, после 401).
Invalidate()
}
// IAMTokenSource получает токен через Keystone и кэширует его в памяти.
type IAMTokenSource struct {
creds Credentials
authURL string
http *http.Client
retry httpx.Retrier
now func() time.Time
mu sync.Mutex
token string
expires time.Time
}
// NewIAMTokenSource создаёт источник токенов. authURL пустой — DefaultAuthURL.
func NewIAMTokenSource(creds Credentials, authURL string, timeout time.Duration) (*IAMTokenSource, error) {
if err := creds.Validate(); err != nil {
return nil, err
}
if authURL == "" {
authURL = DefaultAuthURL
}
return &IAMTokenSource{
creds: creds,
authURL: strings.TrimRight(authURL, "/"),
http: &http.Client{Timeout: timeout},
retry: httpx.DefaultRetrier(),
now: time.Now,
}, nil
}
// Token возвращает актуальный токен, обновляя его за tokenRefreshMargin до истечения.
func (s *IAMTokenSource) Token(ctx context.Context) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.token != "" && s.now().Add(tokenRefreshMargin).Before(s.expires) {
return s.token, nil
}
tok, exp, err := s.fetch(ctx)
if err != nil {
return "", err
}
s.token, s.expires = tok, exp
return tok, nil
}
// Invalidate сбрасывает кэшированный токен.
func (s *IAMTokenSource) Invalidate() {
s.mu.Lock()
s.token, s.expires = "", time.Time{}
s.mu.Unlock()
}
type authRequest struct {
Auth struct {
Identity struct {
Methods []string `json:"methods"`
Password struct {
User struct {
Name string `json:"name"`
Domain map[string]string `json:"domain"`
Password string `json:"password"`
} `json:"user"`
} `json:"password"`
} `json:"identity"`
Scope struct {
Project struct {
ID string `json:"id"`
Domain map[string]string `json:"domain"`
} `json:"project"`
} `json:"scope"`
} `json:"auth"`
}
func (s *IAMTokenSource) fetch(ctx context.Context) (string, time.Time, error) {
var ar authRequest
ar.Auth.Identity.Methods = []string{"password"}
ar.Auth.Identity.Password.User.Name = s.creds.Username
ar.Auth.Identity.Password.User.Password = s.creds.Password
ar.Auth.Identity.Password.User.Domain = map[string]string{"name": s.creds.AccountID}
ar.Auth.Scope.Project.ID = s.creds.ProjectID
ar.Auth.Scope.Project.Domain = map[string]string{"name": s.creds.AccountID}
payload, err := json.Marshal(ar)
if err != nil {
return "", time.Time{}, err
}
resp, err := s.retry.Do(ctx, s.http, func() (*http.Request, error) {
req, err := http.NewRequest(http.MethodPost, s.authURL+"/auth/tokens", bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
return req, nil
})
if err != nil {
return "", time.Time{}, fmt.Errorf("selectel: получение IAM-токена: %w", err)
}
defer resp.Body.Close()
body, _ := httpx.ReadBody(resp)
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
// Тело ответа Keystone намеренно не логируется.
return "", time.Time{}, fmt.Errorf("selectel: получение IAM-токена: статус %d", resp.StatusCode)
}
tok := resp.Header.Get("X-Subject-Token")
if tok == "" {
return "", time.Time{}, fmt.Errorf("selectel: в ответе нет заголовка X-Subject-Token")
}
exp := s.now().Add(fallbackTokenTTL)
var parsed struct {
Token struct {
ExpiresAt string `json:"expires_at"`
} `json:"token"`
}
if json.Unmarshal(body, &parsed) == nil && parsed.Token.ExpiresAt != "" {
if t, err := time.Parse(time.RFC3339Nano, parsed.Token.ExpiresAt); err == nil {
exp = t
}
}
return tok, exp, nil
}
+308
View File
@@ -0,0 +1,308 @@
// Package selectel — клиент Selectel DNS API v2 (https://api.selectel.ru/domains/v2).
//
// Аутентификация: IAM-токен проекта в заголовке X-Auth-Token.
// Имена в API — FQDN с точкой в конце; клиент снаружи оперирует
// нормализованными именами (lower-case, без точки) и добавляет точку сам.
package selectel
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/realmanual/traefik-selectel/internal/httpx"
)
// DefaultBaseURL — DNS-хостинг v2 (единый для всех регионов).
const DefaultBaseURL = "https://api.selectel.ru/domains/v2"
const (
pageLimit = 1000
maxPages = 1000
zoneCacheTTL = 10 * time.Minute
)
// ErrZoneNotFound — зона не найдена в проекте.
var ErrZoneNotFound = errors.New("selectel: зона не найдена")
// Record — значение записи RRSet.
type Record struct {
Content string `json:"content"`
Disabled bool `json:"disabled"`
}
// RRSet — набор записей одного имени и типа.
type RRSet struct {
ID string `json:"id"`
Name string `json:"name"` // нормализованное: lower-case, без точки в конце
Type string `json:"type"`
TTL int `json:"ttl"`
Records []Record `json:"records"`
Comment string `json:"comment"`
ManagedBy string `json:"managed_by,omitempty"`
}
// Client — клиент DNS API.
type Client struct {
baseURL string
tokens TokenSource
http *http.Client
retry httpx.Retrier
now func() time.Time
mu sync.Mutex
zones map[string]zoneEntry
}
type zoneEntry struct {
id string
expires time.Time
}
// NewClient создаёт клиент. baseURL пустой — DefaultBaseURL.
func NewClient(baseURL string, tokens TokenSource, timeout time.Duration) *Client {
if baseURL == "" {
baseURL = DefaultBaseURL
}
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
tokens: tokens,
http: &http.Client{Timeout: timeout},
retry: httpx.DefaultRetrier(),
now: time.Now,
zones: map[string]zoneEntry{},
}
}
// APIError — неуспешный ответ API.
type APIError struct {
Method string
Path string
Status int
Body string
}
func (e *APIError) Error() string {
return fmt.Sprintf("selectel: %s %s: статус %d: %s", e.Method, e.Path, e.Status, e.Body)
}
// do выполняет запрос с токеном; при 401 один раз сбрасывает токен и повторяет.
// Возвращает тело ответа (уже прочитанное) при 2xx.
func (c *Client) do(ctx context.Context, method, path string, query url.Values, payload any) ([]byte, error) {
var raw []byte
if payload != nil {
var err error
if raw, err = json.Marshal(payload); err != nil {
return nil, err
}
}
full := c.baseURL + path
if len(query) > 0 {
full += "?" + query.Encode()
}
for attempt := 0; attempt < 2; attempt++ {
token, err := c.tokens.Token(ctx)
if err != nil {
return nil, err
}
resp, err := c.retry.Do(ctx, c.http, func() (*http.Request, error) {
var body *bytes.Reader
if raw != nil {
body = bytes.NewReader(raw)
} else {
body = bytes.NewReader(nil)
}
req, err := http.NewRequest(method, full, body)
if err != nil {
return nil, err
}
req.Header.Set("X-Auth-Token", token)
req.Header.Set("Accept", "application/json")
if raw != nil {
req.Header.Set("Content-Type", "application/json")
}
return req, nil
})
if err != nil {
return nil, fmt.Errorf("selectel: %s %s: %w", method, path, err)
}
data, rerr := httpx.ReadBody(resp)
resp.Body.Close()
if rerr != nil {
return nil, fmt.Errorf("selectel: %s %s: чтение ответа: %w", method, path, rerr)
}
if resp.StatusCode == http.StatusUnauthorized && attempt == 0 {
c.tokens.Invalidate()
continue
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return nil, &APIError{Method: method, Path: path, Status: resp.StatusCode, Body: httpx.Snippet(data, 300)}
}
return data, nil
}
return nil, &APIError{Method: method, Path: path, Status: http.StatusUnauthorized, Body: "токен отклонён"}
}
func normalize(name string) string {
return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(name), "."))
}
func fqdn(name string) string { return normalize(name) + "." }
type listResponse[T any] struct {
Count int `json:"count"`
NextOffset int `json:"next_offset"`
Result []T `json:"result"`
}
type zoneDTO struct {
ID string `json:"id"`
Name string `json:"name"`
}
// zoneID возвращает id зоны по имени (с кэшем).
func (c *Client) zoneID(ctx context.Context, zone string, force bool) (string, error) {
zone = normalize(zone)
c.mu.Lock()
e, ok := c.zones[zone]
c.mu.Unlock()
if ok && !force && c.now().Before(e.expires) {
return e.id, nil
}
offset := 0
for page := 0; page < maxPages; page++ {
q := url.Values{}
q.Set("limit", strconv.Itoa(pageLimit))
q.Set("offset", strconv.Itoa(offset))
q.Set("filter", zone)
data, err := c.do(ctx, http.MethodGet, "/zones", q, nil)
if err != nil {
return "", err
}
var lr listResponse[zoneDTO]
if err := json.Unmarshal(data, &lr); err != nil {
return "", fmt.Errorf("selectel: разбор списка зон: %w", err)
}
for _, z := range lr.Result {
if normalize(z.Name) == zone {
c.mu.Lock()
c.zones[zone] = zoneEntry{id: z.ID, expires: c.now().Add(zoneCacheTTL)}
c.mu.Unlock()
return z.ID, nil
}
}
if len(lr.Result) < pageLimit {
break
}
offset += len(lr.Result)
}
return "", fmt.Errorf("%w: %s", ErrZoneNotFound, zone)
}
func (c *Client) forgetZone(zone string) {
c.mu.Lock()
delete(c.zones, normalize(zone))
c.mu.Unlock()
}
// withZone выполняет fn с id зоны; при 404 сбрасывает кэш и повторяет один раз.
func (c *Client) withZone(ctx context.Context, zone string, fn func(id string) error) error {
id, err := c.zoneID(ctx, zone, false)
if err != nil {
return err
}
err = fn(id)
var apiErr *APIError
if errors.As(err, &apiErr) && apiErr.Status == http.StatusNotFound {
c.forgetZone(zone)
if id, err = c.zoneID(ctx, zone, true); err != nil {
return err
}
return fn(id)
}
return err
}
// ListRRSets возвращает все RRSet зоны (все типы; фильтрация — на стороне вызывающего).
func (c *Client) ListRRSets(ctx context.Context, zone string) ([]RRSet, error) {
var out []RRSet
err := c.withZone(ctx, zone, func(id string) error {
out = out[:0]
offset := 0
for page := 0; page < maxPages; page++ {
q := url.Values{}
q.Set("limit", strconv.Itoa(pageLimit))
q.Set("offset", strconv.Itoa(offset))
data, err := c.do(ctx, http.MethodGet, "/zones/"+url.PathEscape(id)+"/rrset", q, nil)
if err != nil {
return err
}
var lr listResponse[RRSet]
if err := json.Unmarshal(data, &lr); err != nil {
return fmt.Errorf("selectel: разбор списка rrset: %w", err)
}
for _, rr := range lr.Result {
rr.Name = normalize(rr.Name)
rr.Type = strings.ToUpper(rr.Type)
out = append(out, rr)
}
if len(lr.Result) < pageLimit {
return nil
}
offset += len(lr.Result)
}
return fmt.Errorf("selectel: слишком много страниц rrset")
})
if err != nil {
return nil, err
}
return out, nil
}
type rrsetBody struct {
Name string `json:"name,omitempty"`
Type string `json:"type,omitempty"`
TTL int `json:"ttl"`
Records []Record `json:"records"`
Comment string `json:"comment"`
}
// CreateRRSet создаёт RRSet (name — без точки на конце, точка добавляется клиентом).
func (c *Client) CreateRRSet(ctx context.Context, zone string, rr RRSet) error {
body := rrsetBody{Name: fqdn(rr.Name), Type: strings.ToUpper(rr.Type), TTL: rr.TTL, Records: rr.Records, Comment: rr.Comment}
return c.withZone(ctx, zone, func(id string) error {
_, err := c.do(ctx, http.MethodPost, "/zones/"+url.PathEscape(id)+"/rrset", nil, body)
return err
})
}
// UpdateRRSet обновляет ttl, records и comment существующего RRSet.
func (c *Client) UpdateRRSet(ctx context.Context, zone, rrsetID string, ttl int, records []Record, comment string) error {
body := rrsetBody{TTL: ttl, Records: records, Comment: comment}
return c.withZone(ctx, zone, func(id string) error {
_, err := c.do(ctx, http.MethodPatch, "/zones/"+url.PathEscape(id)+"/rrset/"+url.PathEscape(rrsetID), nil, body)
return err
})
}
// DeleteRRSet удаляет RRSet.
// Ответ 404 считается успехом (идемпотентность).
func (c *Client) DeleteRRSet(ctx context.Context, zone, rrsetID string) error {
err := c.withZone(ctx, zone, func(id string) error {
_, err := c.do(ctx, http.MethodDelete, "/zones/"+url.PathEscape(id)+"/rrset/"+url.PathEscape(rrsetID), nil, nil)
return err
})
var apiErr *APIError
if errors.As(err, &apiErr) && apiErr.Status == http.StatusNotFound {
return nil
}
return err
}
+288
View File
@@ -0,0 +1,288 @@
package selectel
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
type staticTokens struct {
tok string
invalidated int32
}
func (s *staticTokens) Token(context.Context) (string, error) { return s.tok, nil }
func (s *staticTokens) Invalidate() { atomic.AddInt32(&s.invalidated, 1) }
func testCreds() Credentials {
return Credentials{Username: "u", Password: "secret-pass", AccountID: "123456", ProjectID: "proj-1"}
}
func TestCredentialsStringHidesPassword(t *testing.T) {
s := testCreds().String()
if strings.Contains(s, "secret-pass") {
t.Fatalf("пароль утёк в String(): %s", s)
}
if fmt := (Credentials{}).Validate(); fmt == nil {
t.Fatal("ожидалась ошибка валидации")
}
}
func TestIAMTokenSourceRequestAndCache(t *testing.T) {
var calls int32
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/identity/v3/auth/tokens" || r.Method != http.MethodPost {
t.Errorf("%s %s", r.Method, r.URL.Path)
}
atomic.AddInt32(&calls, 1)
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &gotBody)
w.Header().Set("X-Subject-Token", "tok-"+fmt.Sprint(atomic.LoadInt32(&calls)))
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"token":{"expires_at":"2030-01-01T00:00:00.000000Z"}}`))
}))
defer srv.Close()
ts, err := NewIAMTokenSource(testCreds(), srv.URL+"/identity/v3/", time.Second)
if err != nil {
t.Fatal(err)
}
now := time.Date(2029, 1, 1, 0, 0, 0, 0, time.UTC)
ts.now = func() time.Time { return now }
tok, err := ts.Token(context.Background())
if err != nil || tok != "tok-1" {
t.Fatalf("tok=%q err=%v", tok, err)
}
if tok2, _ := ts.Token(context.Background()); tok2 != "tok-1" || calls != 1 {
t.Fatalf("токен не закэширован: %q calls=%d", tok2, calls)
}
// структура запроса
auth := gotBody["auth"].(map[string]any)
user := auth["identity"].(map[string]any)["password"].(map[string]any)["user"].(map[string]any)
if user["name"] != "u" || user["password"] != "secret-pass" || user["domain"].(map[string]any)["name"] != "123456" {
t.Errorf("user = %v", user)
}
proj := auth["scope"].(map[string]any)["project"].(map[string]any)
if proj["id"] != "proj-1" || proj["domain"].(map[string]any)["name"] != "123456" {
t.Errorf("project = %v", proj)
}
// обновление за margin до истечения
now = time.Date(2029, 12, 31, 23, 55, 0, 0, time.UTC)
if tok3, _ := ts.Token(context.Background()); tok3 != "tok-2" || calls != 2 {
t.Fatalf("токен не обновлён: %q calls=%d", tok3, calls)
}
ts.Invalidate()
if tok4, _ := ts.Token(context.Background()); tok4 != "tok-3" {
t.Fatalf("после Invalidate: %q", tok4)
}
}
func TestIAMTokenSourceErrorDoesNotLeakBody(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("secret-pass echoed"))
}))
defer srv.Close()
ts, _ := NewIAMTokenSource(testCreds(), srv.URL, time.Second)
_, err := ts.Token(context.Background())
if err == nil || strings.Contains(err.Error(), "secret-pass") {
t.Fatalf("err = %v", err)
}
}
// fakeDNS — минимальный эмулятор DNS API.
type fakeDNS struct {
mu sync.Mutex
t *testing.T
requests []string
bodies map[string]string
rrsets []map[string]any
fail401 int32
}
func (f *fakeDNS) handler(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
if r.Header.Get("X-Auth-Token") != "tok" {
w.WriteHeader(http.StatusUnauthorized)
return
}
if atomic.LoadInt32(&f.fail401) > 0 {
atomic.AddInt32(&f.fail401, -1)
w.WriteHeader(http.StatusUnauthorized)
return
}
f.requests = append(f.requests, r.Method+" "+r.URL.Path)
b, _ := io.ReadAll(r.Body)
if len(b) > 0 {
if f.bodies == nil {
f.bodies = map[string]string{}
}
f.bodies[r.Method+" "+r.URL.Path] = string(b)
}
switch {
case r.Method == http.MethodGet && r.URL.Path == "/zones":
_ = json.NewEncoder(w).Encode(map[string]any{"count": 2, "next_offset": 0, "result": []map[string]any{
{"id": "z-other", "name": "notexample.com."},
{"id": "z1", "name": "example.com."},
}})
case r.Method == http.MethodGet && r.URL.Path == "/zones/z1/rrset":
_ = json.NewEncoder(w).Encode(map[string]any{"count": len(f.rrsets), "next_offset": 0, "result": f.rrsets})
case r.Method == http.MethodPost && r.URL.Path == "/zones/z1/rrset":
_, _ = w.Write([]byte(`{"id":"new"}`))
case r.Method == http.MethodPatch && strings.HasPrefix(r.URL.Path, "/zones/z1/rrset/"):
w.WriteHeader(http.StatusNoContent)
case r.Method == http.MethodDelete && r.URL.Path == "/zones/z1/rrset/gone":
w.WriteHeader(http.StatusNotFound)
case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/zones/z1/rrset/"):
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusNotFound)
}
}
func newTestClient(t *testing.T, f *fakeDNS) (*Client, *staticTokens) {
srv := httptest.NewServer(http.HandlerFunc(f.handler))
t.Cleanup(srv.Close)
tk := &staticTokens{tok: "tok"}
c := NewClient(srv.URL, tk, time.Second)
c.retry.Sleep = func(context.Context, time.Duration) error { return nil }
return c, tk
}
func TestListRRSetsNormalizesAndResolvesZone(t *testing.T) {
f := &fakeDNS{t: t, rrsets: []map[string]any{
{"id": "r1", "name": "App.Example.com.", "type": "A", "ttl": 300, "comment": "managed-by=x",
"records": []map[string]any{{"content": "1.2.3.4", "disabled": false}}},
}}
c, _ := newTestClient(t, f)
got, err := c.ListRRSets(context.Background(), "Example.com.")
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Name != "app.example.com" || got[0].Type != "A" || got[0].Records[0].Content != "1.2.3.4" || got[0].Comment != "managed-by=x" {
t.Fatalf("got = %+v", got)
}
// зона закэширована: второй вызов не ходит в /zones
if _, err := c.ListRRSets(context.Background(), "example.com"); err != nil {
t.Fatal(err)
}
zoneCalls := 0
for _, r := range f.requests {
if r == "GET /zones" {
zoneCalls++
}
}
if zoneCalls != 1 {
t.Fatalf("GET /zones вызван %d раз", zoneCalls)
}
}
func TestZoneNotFound(t *testing.T) {
f := &fakeDNS{t: t}
c, _ := newTestClient(t, f)
_, err := c.ListRRSets(context.Background(), "missing.org")
if !errors.Is(err, ErrZoneNotFound) {
t.Fatalf("err = %v", err)
}
}
func TestCreateUpdateDelete(t *testing.T) {
f := &fakeDNS{t: t}
c, _ := newTestClient(t, f)
ctx := context.Background()
err := c.CreateRRSet(ctx, "example.com", RRSet{Name: "app.example.com", Type: "a", TTL: 300,
Records: []Record{{Content: "1.2.3.4"}}, Comment: "managed-by=traefik-selectel-dns"})
if err != nil {
t.Fatal(err)
}
var body map[string]any
if err := json.Unmarshal([]byte(f.bodies["POST /zones/z1/rrset"]), &body); err != nil {
t.Fatal(err)
}
if body["name"] != "app.example.com." || body["type"] != "A" || body["ttl"].(float64) != 300 || body["comment"] != "managed-by=traefik-selectel-dns" {
t.Errorf("create body = %v", body)
}
recs := body["records"].([]any)
if len(recs) != 1 || recs[0].(map[string]any)["content"] != "1.2.3.4" {
t.Errorf("records = %v", recs)
}
if err := c.UpdateRRSet(ctx, "example.com", "r1", 600, []Record{{Content: "5.6.7.8"}}, "managed-by=traefik-selectel-dns"); err != nil {
t.Fatal(err)
}
var upd map[string]any
_ = json.Unmarshal([]byte(f.bodies["PATCH /zones/z1/rrset/r1"]), &upd)
if _, has := upd["name"]; has || upd["ttl"].(float64) != 600 {
t.Errorf("patch body = %v", upd)
}
if err := c.DeleteRRSet(ctx, "example.com", "r1"); err != nil {
t.Fatal(err)
}
if err := c.DeleteRRSet(ctx, "example.com", "gone"); err != nil {
t.Fatalf("404 на delete должен быть успехом: %v", err)
}
}
func TestUnauthorizedInvalidatesTokenAndRetriesOnce(t *testing.T) {
f := &fakeDNS{t: t, fail401: 1}
c, tk := newTestClient(t, f)
if _, err := c.ListRRSets(context.Background(), "example.com"); err != nil {
t.Fatal(err)
}
if tk.invalidated != 1 {
t.Fatalf("invalidated = %d", tk.invalidated)
}
}
func TestAPIErrorOn422(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/zones" {
_, _ = w.Write([]byte(`{"result":[{"id":"z1","name":"example.com."}]}`))
return
}
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"error":"bad"}`))
}))
defer srv.Close()
c := NewClient(srv.URL, &staticTokens{tok: "tok"}, time.Second)
err := c.CreateRRSet(context.Background(), "example.com", RRSet{Name: "a.example.com", Type: "A", TTL: 300, Records: []Record{{Content: "1.1.1.1"}}})
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.Status != 422 {
t.Fatalf("err = %v", err)
}
}
func TestRetryOn503(t *testing.T) {
var n int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if atomic.AddInt32(&n, 1) == 1 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
_, _ = w.Write([]byte(`{"result":[{"id":"z1","name":"example.com."}]}`))
}))
defer srv.Close()
c := NewClient(srv.URL, &staticTokens{tok: "tok"}, time.Second)
c.retry.Sleep = func(context.Context, time.Duration) error { return nil }
id, err := c.zoneID(context.Background(), "example.com", true)
if err != nil || id != "z1" || n != 2 {
t.Fatalf("id=%q err=%v n=%d", id, err, n)
}
}