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