feat(selectel): реализация Provider — ListZones, GetRecords, ApplyChanges
This commit is contained in:
@@ -0,0 +1,206 @@
|
|||||||
|
package selectel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/vasyakrg/dns-autoresolver/internal/diff"
|
||||||
|
"github.com/vasyakrg/dns-autoresolver/internal/model"
|
||||||
|
"github.com/vasyakrg/dns-autoresolver/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultBaseURL = "https://api.selectel.ru/domains/v2"
|
||||||
|
|
||||||
|
// Client implements provider.Provider for Selectel DNS API v2.
|
||||||
|
type Client struct {
|
||||||
|
BaseURL string
|
||||||
|
HTTP *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Client {
|
||||||
|
return &Client{BaseURL: DefaultBaseURL, HTTP: &http.Client{Timeout: 30 * time.Second}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Name() string { return "selectel" }
|
||||||
|
|
||||||
|
// --- wire types ---
|
||||||
|
|
||||||
|
type apiZone struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
type apiZoneList struct {
|
||||||
|
Result []apiZone `json:"result"`
|
||||||
|
NextOffset int `json:"next_offset"`
|
||||||
|
}
|
||||||
|
type apiRec struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Disabled bool `json:"disabled,omitempty"`
|
||||||
|
}
|
||||||
|
type apiRRSet struct {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
TTL int `json:"ttl"`
|
||||||
|
Records []apiRec `json:"records"`
|
||||||
|
}
|
||||||
|
type apiRRSetList struct {
|
||||||
|
Result []apiRRSet `json:"result"`
|
||||||
|
NextOffset int `json:"next_offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTTP helper ---
|
||||||
|
|
||||||
|
func (c *Client) do(ctx context.Context, method, path, token string, body any, out any) error {
|
||||||
|
var reader io.Reader
|
||||||
|
if body != nil {
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reader = bytes.NewReader(b)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, reader)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("X-Auth-Token", token)
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
msg, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("selectel %s %s: %d: %s", method, path, resp.StatusCode, string(msg))
|
||||||
|
}
|
||||||
|
if out != nil {
|
||||||
|
return json.NewDecoder(resp.Body).Decode(out)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Provider implementation ---
|
||||||
|
|
||||||
|
func (c *Client) ListZones(ctx context.Context, creds provider.Credentials) ([]provider.Zone, error) {
|
||||||
|
var zones []provider.Zone
|
||||||
|
offset := 0
|
||||||
|
for {
|
||||||
|
var page apiZoneList
|
||||||
|
path := fmt.Sprintf("/zones?limit=1000&offset=%d", offset)
|
||||||
|
if err := c.do(ctx, http.MethodGet, path, creds.Secret, nil, &page); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, z := range page.Result {
|
||||||
|
zones = append(zones, provider.Zone{ID: z.ID, Name: z.Name})
|
||||||
|
}
|
||||||
|
if page.NextOffset == 0 || len(page.Result) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
offset = page.NextOffset
|
||||||
|
}
|
||||||
|
return zones, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetRecords(ctx context.Context, creds provider.Credentials, zoneID string) ([]model.Record, error) {
|
||||||
|
rrsets, err := c.listRRSets(ctx, creds.Secret, zoneID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
recs := make([]model.Record, 0, len(rrsets))
|
||||||
|
for _, rr := range rrsets {
|
||||||
|
recs = append(recs, toRecord(rr))
|
||||||
|
}
|
||||||
|
return recs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) listRRSets(ctx context.Context, token, zoneID string) ([]apiRRSet, error) {
|
||||||
|
var all []apiRRSet
|
||||||
|
offset := 0
|
||||||
|
for {
|
||||||
|
var page apiRRSetList
|
||||||
|
path := fmt.Sprintf("/zones/%s/rrset?limit=1000&offset=%d", url.PathEscape(zoneID), offset)
|
||||||
|
if err := c.do(ctx, http.MethodGet, path, token, nil, &page); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
all = append(all, page.Result...)
|
||||||
|
if page.NextOffset == 0 || len(page.Result) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
offset = page.NextOffset
|
||||||
|
}
|
||||||
|
return all, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ApplyChanges(ctx context.Context, creds provider.Credentials, zoneID string, cs diff.Changeset) error {
|
||||||
|
// resolve rrset ids for update/delete
|
||||||
|
existing, err := c.listRRSets(ctx, creds.Secret, zoneID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
idByKey := make(map[string]string, len(existing))
|
||||||
|
for _, rr := range existing {
|
||||||
|
idByKey[toRecord(rr).Key()] = rr.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
base := "/zones/" + url.PathEscape(zoneID) + "/rrset"
|
||||||
|
for _, d := range cs.Diffs {
|
||||||
|
if d.ReadOnly || d.Kind == diff.InSync {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch d.Kind {
|
||||||
|
case diff.Add:
|
||||||
|
if err := c.do(ctx, http.MethodPost, base, creds.Secret, toRRSet(*d.Desired), nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case diff.Update:
|
||||||
|
id, ok := idByKey[d.Desired.Key()]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("cannot update: rrset %s not found in zone", d.Desired.Key())
|
||||||
|
}
|
||||||
|
if err := c.do(ctx, http.MethodPatch, base+"/"+url.PathEscape(id), creds.Secret, toRRSet(*d.Desired), nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case diff.Delete:
|
||||||
|
id, ok := idByKey[d.Actual.Key()]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("cannot delete: rrset %s not found in zone", d.Actual.Key())
|
||||||
|
}
|
||||||
|
if err := c.do(ctx, http.MethodDelete, base+"/"+url.PathEscape(id), creds.Secret, nil, nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toRecord(rr apiRRSet) model.Record {
|
||||||
|
vals := make([]string, 0, len(rr.Records))
|
||||||
|
for _, r := range rr.Records {
|
||||||
|
if r.Disabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vals = append(vals, r.Content)
|
||||||
|
}
|
||||||
|
return model.Record{Type: model.RecordType(rr.Type), Name: rr.Name, TTL: rr.TTL, Values: vals}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toRRSet(rec model.Record) apiRRSet {
|
||||||
|
rs := apiRRSet{Name: rec.Name, Type: string(rec.Type), TTL: rec.TTL}
|
||||||
|
for _, v := range rec.Values {
|
||||||
|
rs.Records = append(rs.Records, apiRec{Content: v})
|
||||||
|
}
|
||||||
|
return rs
|
||||||
|
}
|
||||||
|
|
||||||
|
// compile-time check
|
||||||
|
var _ provider.Provider = (*Client)(nil)
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package selectel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/vasyakrg/dns-autoresolver/internal/diff"
|
||||||
|
"github.com/vasyakrg/dns-autoresolver/internal/model"
|
||||||
|
"github.com/vasyakrg/dns-autoresolver/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
func creds() provider.Credentials { return provider.Credentials{Secret: "secret-token"} }
|
||||||
|
|
||||||
|
func newTestClient(h http.Handler) (*Client, *httptest.Server) {
|
||||||
|
srv := httptest.NewServer(h)
|
||||||
|
return &Client{BaseURL: srv.URL, HTTP: srv.Client()}, srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListZonesSendsTokenAndParses(t *testing.T) {
|
||||||
|
var gotToken string
|
||||||
|
c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotToken = r.Header.Get("X-Auth-Token")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"result": []map[string]any{
|
||||||
|
{"id": "z1", "name": "example.com."},
|
||||||
|
{"id": "z2", "name": "test.org."},
|
||||||
|
},
|
||||||
|
"next_offset": 0,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
zs, err := c.ListZones(context.Background(), creds())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gotToken != "secret-token" {
|
||||||
|
t.Fatalf("token not sent, got %q", gotToken)
|
||||||
|
}
|
||||||
|
if len(zs) != 2 || zs[0].ID != "z1" || zs[1].Name != "test.org." {
|
||||||
|
t.Fatalf("unexpected zones: %+v", zs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRecordsMapsRRSet(t *testing.T) {
|
||||||
|
c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"result": []map[string]any{
|
||||||
|
{"id": "r1", "name": "example.com.", "type": "MX", "ttl": 3600,
|
||||||
|
"records": []map[string]any{{"content": "10 mx1.example.com.", "disabled": false}}},
|
||||||
|
{"id": "r2", "name": "www.example.com.", "type": "A", "ttl": 300,
|
||||||
|
"records": []map[string]any{{"content": "1.2.3.4"}, {"content": "5.6.7.8", "disabled": true}}},
|
||||||
|
},
|
||||||
|
"next_offset": 0,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
recs, err := c.GetRecords(context.Background(), creds(), "z1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(recs) != 2 {
|
||||||
|
t.Fatalf("want 2 records, got %d", len(recs))
|
||||||
|
}
|
||||||
|
var a model.Record
|
||||||
|
for _, r := range recs {
|
||||||
|
if r.Type == model.A {
|
||||||
|
a = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// disabled record dropped -> only one value
|
||||||
|
if len(a.Values) != 1 || a.Values[0] != "1.2.3.4" {
|
||||||
|
t.Fatalf("disabled record must be skipped, got %+v", a.Values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyChangesRoutesVerbs(t *testing.T) {
|
||||||
|
type call struct{ method, path string }
|
||||||
|
var calls []call
|
||||||
|
c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// GET rrset -> return existing set with ids for update/delete resolution
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"result": []map[string]any{
|
||||||
|
{"id": "up1", "name": "b.example.com.", "type": "A", "ttl": 300,
|
||||||
|
"records": []map[string]any{{"content": "9.9.9.9"}}},
|
||||||
|
{"id": "del1", "name": "d.example.com.", "type": "A", "ttl": 300,
|
||||||
|
"records": []map[string]any{{"content": "4.4.4.4"}}},
|
||||||
|
},
|
||||||
|
"next_offset": 0,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
calls = append(calls, call{r.Method, r.URL.Path})
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
add := model.Record{Type: model.A, Name: "c.example.com.", TTL: 300, Values: []string{"3.3.3.3"}}
|
||||||
|
updDesired := model.Record{Type: model.A, Name: "b.example.com.", TTL: 300, Values: []string{"2.2.2.2"}}
|
||||||
|
delActual := model.Record{Type: model.A, Name: "d.example.com.", TTL: 300, Values: []string{"4.4.4.4"}}
|
||||||
|
ns := model.Record{Type: model.NS, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com."}}
|
||||||
|
|
||||||
|
cs := diff.Changeset{Diffs: []diff.RecordDiff{
|
||||||
|
{Kind: diff.Add, Type: add.Type, Name: add.Name, Desired: &add},
|
||||||
|
{Kind: diff.Update, Type: updDesired.Type, Name: updDesired.Name, Desired: &updDesired},
|
||||||
|
{Kind: diff.Delete, Type: delActual.Type, Name: delActual.Name, Actual: &delActual},
|
||||||
|
{Kind: diff.Update, Type: ns.Type, Name: ns.Name, Desired: &ns, ReadOnly: true}, // must be skipped
|
||||||
|
}}
|
||||||
|
|
||||||
|
if err := c.ApplyChanges(context.Background(), creds(), "z1", cs); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := map[string]bool{
|
||||||
|
"POST /zones/z1/rrset": true,
|
||||||
|
"PATCH /zones/z1/rrset/up1": true,
|
||||||
|
"DELETE /zones/z1/rrset/del1": true,
|
||||||
|
}
|
||||||
|
if len(calls) != len(want) {
|
||||||
|
t.Fatalf("want %d calls, got %v", len(want), calls)
|
||||||
|
}
|
||||||
|
for _, cl := range calls {
|
||||||
|
if !want[cl.method+" "+cl.path] {
|
||||||
|
t.Fatalf("unexpected call %s %s", cl.method, cl.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user