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,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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user