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
+90
View File
@@ -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: слишком много страниц роутеров")
}
+58
View File
@@ -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)
}
}
}