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,90 @@
|
||||
// Package traefik — клиент Traefik API (GET /api/http/routers).
|
||||
package traefik
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/realmanual/traefik-selectel/internal/httpx"
|
||||
)
|
||||
|
||||
// Router — подмножество полей роутера Traefik.
|
||||
type Router struct {
|
||||
Name string `json:"name"`
|
||||
Rule string `json:"rule"`
|
||||
Status string `json:"status"`
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
|
||||
// RouterSource — источник роутов (интерфейс для тестов).
|
||||
type RouterSource interface {
|
||||
Routers(ctx context.Context) ([]Router, error)
|
||||
}
|
||||
|
||||
// Client читает роутеры из Traefik API.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
http *http.Client
|
||||
retry httpx.Retrier
|
||||
}
|
||||
|
||||
// NewClient создаёт клиент. timeout — таймаут одного HTTP-запроса.
|
||||
func NewClient(apiURL string, timeout time.Duration) (*Client, error) {
|
||||
u, err := url.Parse(apiURL)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return nil, fmt.Errorf("traefik: некорректный apiURL %q", apiURL)
|
||||
}
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(u.String(), "/"),
|
||||
http: &http.Client{Timeout: timeout},
|
||||
retry: httpx.DefaultRetrier(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
const perPage = 500
|
||||
const maxPages = 100
|
||||
|
||||
// Routers возвращает все HTTP-роутеры (с учётом пагинации X-Next-Page).
|
||||
func (c *Client) Routers(ctx context.Context) ([]Router, error) {
|
||||
var all []Router
|
||||
page := 1
|
||||
for i := 0; i < maxPages; i++ {
|
||||
u := fmt.Sprintf("%s/api/http/routers?page=%d&per_page=%d", c.baseURL, page, perPage)
|
||||
resp, err := c.retry.Do(ctx, c.http, func() (*http.Request, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
return req, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("traefik: запрос роутеров: %w", err)
|
||||
}
|
||||
body, err := httpx.ReadBody(resp)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("traefik: чтение ответа: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("traefik: статус %d: %s", resp.StatusCode, httpx.Snippet(body, 200))
|
||||
}
|
||||
var chunk []Router
|
||||
if err := json.Unmarshal(body, &chunk); err != nil {
|
||||
return nil, fmt.Errorf("traefik: разбор JSON: %w", err)
|
||||
}
|
||||
all = append(all, chunk...)
|
||||
next, _ := strconv.Atoi(resp.Header.Get("X-Next-Page"))
|
||||
if next <= page {
|
||||
return all, nil
|
||||
}
|
||||
page = next
|
||||
}
|
||||
return nil, fmt.Errorf("traefik: слишком много страниц роутеров")
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package traefik
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRoutersPagination(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/http/routers" {
|
||||
t.Errorf("path = %s", r.URL.Path)
|
||||
}
|
||||
switch r.URL.Query().Get("page") {
|
||||
case "1":
|
||||
w.Header().Set("X-Next-Page", "2")
|
||||
_, _ = w.Write([]byte(`[{"name":"a@docker","rule":"Host(` + "`a.example.com`" + `)","status":"enabled","provider":"docker"}]`))
|
||||
case "2":
|
||||
w.Header().Set("X-Next-Page", "2")
|
||||
_, _ = w.Write([]byte(`[{"name":"b@file","rule":"Host(` + "`b.example.com`" + `)","status":"disabled","provider":"file"}]`))
|
||||
default:
|
||||
t.Errorf("unexpected page %q", r.URL.Query().Get("page"))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
c, err := NewClient(srv.URL, time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rs, err := c.Routers(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rs) != 2 || rs[0].Name != "a@docker" || rs[1].Status != "disabled" {
|
||||
t.Fatalf("routers = %+v", rs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutersErrorStatus(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c, _ := NewClient(srv.URL, time.Second)
|
||||
if _, err := c.Routers(context.Background()); err == nil {
|
||||
t.Fatal("ожидалась ошибка")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientInvalidURL(t *testing.T) {
|
||||
for _, u := range []string{"", "traefik:8080", "ftp://x"} {
|
||||
if _, err := NewClient(u, time.Second); err == nil {
|
||||
t.Errorf("%q: ожидалась ошибка", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user