Files
dns-autoresolver/docs/superpowers/plans/2026-07-03-phase1a-core-selectel.md
2026-07-03 12:16:03 +07:00

31 KiB
Raw Permalink Blame History

Phase 1A: Доменное ядро + Selectel-провайдер — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Собрать расширяемое ядро DNS Autoresolver: нейтральная модель записей, чистый диф-движок шаблон↔зона и провайдер Selectel (список зон, чтение записей, применение изменений).

Architecture: Доменное ядро (model, diff) не знает про провайдеров и работает с нейтральной моделью Record. Слой provider объявляет интерфейс; provider/selectel реализует его поверх Selectel DNS API v2 через net/http, мапя RRSet ↔ Record. Всё покрыто модульными тестами; Selectel — тестами против httptest-сервера. БД, REST API и UI — в отдельных планах (1B, 1C).

Tech Stack: Go (stdlib: net/http, encoding/json, testing, httptest). Внешних зависимостей в 1A нет.

Global Constraints

  • Язык: Go. Module path: github.com/vasyakrg/dns-autoresolver.
  • Нейтральная модель Record — единственный тип, которым обмениваются ядро и провайдеры.
  • Применение изменений — только из явного Changeset; ядро не выполняет сетевых действий.
  • Управляемые типы (diff + apply): A, AAAA, CNAME, MX, TXT, SRV. Read-only: NS, SOA (ReadOnly=true в диффе, не применяются).
  • Все имена зон/записей — FQDN с завершающей точкой. TTL в диапазоне 60604800.
  • Selectel: аутентификация заголовком X-Auth-Token: <secret>. Base URL по умолчанию https://api.selectel.ru/domains/v2.
  • TXT-значения регистрозависимы — при нормализации регистр TXT не менять.
  • Каждая задача завершается зелёными тестами и коммитом.

Task 1: Go-модуль и модель Record

Files:

  • Create: go.mod
  • Create: internal/model/record.go
  • Create: internal/model/record_test.go
  • Create: Makefile

Interfaces:

  • Consumes: —

  • Produces:

    • type RecordType string; константы A, AAAA, CNAME, MX, TXT, SRV, NS, SOA
    • func (RecordType) Managed() bool
    • type Record struct { Type RecordType; Name string; TTL int; Values []string }
    • func (Record) Key() string"<TYPE> <normalized-name>"
    • func (Record) NormalizedValues() []string — отсортированные нормализованные значения
    • func (Record) Equal(o Record) bool — равенство по TTL и множеству значений
  • Step 1: Инициализировать модуль и Makefile

go mod init github.com/vasyakrg/dns-autoresolver

Makefile:

.PHONY: test
test:
	go test ./...

.PHONY: build
build:
	go build ./...
  • Step 2: Написать падающий тест модели

internal/model/record_test.go:

package model

import "testing"

func TestManaged(t *testing.T) {
	managed := []RecordType{A, AAAA, CNAME, MX, TXT, SRV}
	for _, rt := range managed {
		if !rt.Managed() {
			t.Errorf("%s should be managed", rt)
		}
	}
	for _, rt := range []RecordType{NS, SOA} {
		if rt.Managed() {
			t.Errorf("%s should be read-only", rt)
		}
	}
}

func TestKeyNormalizesName(t *testing.T) {
	r1 := Record{Type: A, Name: "www.Example.com"}
	r2 := Record{Type: A, Name: "www.example.com."}
	if r1.Key() != r2.Key() {
		t.Fatalf("keys differ: %q vs %q", r1.Key(), r2.Key())
	}
	if r1.Key() != "A www.example.com." {
		t.Fatalf("unexpected key %q", r1.Key())
	}
}

func TestEqualMXPriorityAndOrder(t *testing.T) {
	a := Record{Type: MX, Name: "example.com.", TTL: 3600, Values: []string{"10 mx1.example.com.", "20 mx2.Example.com."}}
	b := Record{Type: MX, Name: "example.com.", TTL: 3600, Values: []string{"20 mx2.example.com.", "10 mx1.example.com."}}
	if !a.Equal(b) {
		t.Fatal("MX records equal regardless of order and target case")
	}
	c := Record{Type: MX, Name: "example.com.", TTL: 3600, Values: []string{"30 mx1.example.com."}}
	if a.Equal(c) {
		t.Fatal("different priority must not be equal")
	}
}

func TestEqualTXTCaseSensitive(t *testing.T) {
	a := Record{Type: TXT, Name: "example.com.", TTL: 60, Values: []string{"v=DKIM1; p=AbC"}}
	b := Record{Type: TXT, Name: "example.com.", TTL: 60, Values: []string{"v=DKIM1; p=abc"}}
	if a.Equal(b) {
		t.Fatal("TXT is case-sensitive")
	}
}

func TestEqualTTLMatters(t *testing.T) {
	a := Record{Type: A, Name: "example.com.", TTL: 300, Values: []string{"1.2.3.4"}}
	b := Record{Type: A, Name: "example.com.", TTL: 600, Values: []string{"1.2.3.4"}}
	if a.Equal(b) {
		t.Fatal("different TTL must not be equal")
	}
}
  • Step 3: Запустить — убедиться, что не компилируется/падает

Run: go test ./internal/model/ -v Expected: FAIL (undefined: RecordType/Record и т.д.)

  • Step 4: Реализовать модель

internal/model/record.go:

package model

import (
	"sort"
	"strings"
)

type RecordType string

const (
	A     RecordType = "A"
	AAAA  RecordType = "AAAA"
	CNAME RecordType = "CNAME"
	MX    RecordType = "MX"
	TXT   RecordType = "TXT"
	SRV   RecordType = "SRV"
	NS    RecordType = "NS"
	SOA   RecordType = "SOA"
)

// Managed reports whether the type participates in diff+apply.
// NS and SOA are read-only.
func (t RecordType) Managed() bool {
	switch t {
	case A, AAAA, CNAME, MX, TXT, SRV:
		return true
	default:
		return false
	}
}

// Record is the provider-neutral representation of a DNS RRset.
// For MX the value is "<priority> <target>"; for SRV it is
// "<priority> <weight> <port> <target>". Values is an unordered set.
type Record struct {
	Type   RecordType
	Name   string
	TTL    int
	Values []string
}

// Key uniquely identifies an RRset within a zone.
func (r Record) Key() string {
	return string(r.Type) + " " + normalizeName(r.Name)
}

func normalizeName(name string) string {
	n := strings.ToLower(strings.TrimSpace(name))
	if n != "" && !strings.HasSuffix(n, ".") {
		n += "."
	}
	return n
}

// normalizeValue canonicalizes a single RR value for comparison.
func normalizeValue(t RecordType, content string) string {
	c := strings.Join(strings.Fields(content), " ") // collapse whitespace
	switch t {
	case TXT:
		return c // case-sensitive — keep as is
	case MX:
		parts := strings.SplitN(c, " ", 2)
		if len(parts) == 2 {
			return parts[0] + " " + normalizeName(parts[1])
		}
		return c
	case SRV:
		f := strings.Fields(c)
		if len(f) == 4 {
			return f[0] + " " + f[1] + " " + f[2] + " " + normalizeName(f[3])
		}
		return c
	case CNAME, NS:
		return normalizeName(c)
	default: // A, AAAA, SOA
		return strings.ToLower(c)
	}
}

// NormalizedValues returns sorted, normalized values.
func (r Record) NormalizedValues() []string {
	out := make([]string, len(r.Values))
	for i, v := range r.Values {
		out[i] = normalizeValue(r.Type, v)
	}
	sort.Strings(out)
	return out
}

// Equal reports whether two records have the same TTL and value set.
func (r Record) Equal(o Record) bool {
	if r.TTL != o.TTL {
		return false
	}
	a, b := r.NormalizedValues(), o.NormalizedValues()
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		if a[i] != b[i] {
			return false
		}
	}
	return true
}
  • Step 5: Запустить тесты — зелёные

Run: go test ./internal/model/ -v Expected: PASS (все 5 тестов)

  • Step 6: Commit
git add go.mod Makefile internal/model/
git commit -m "feat(model): нейтральная модель Record с нормализацией и Equal"

Task 2: Диф-движок

Files:

  • Create: internal/diff/diff.go
  • Create: internal/diff/diff_test.go

Interfaces:

  • Consumes: model.Record, model.RecordType, Record.Key(), Record.Equal(), RecordType.Managed()
  • Produces:
    • type ChangeKind string; константы InSync, Add, Update, Delete
    • type RecordDiff struct { Kind ChangeKind; Type model.RecordType; Name string; Desired *model.Record; Actual *model.Record; ReadOnly bool }
    • type Changeset struct { Diffs []RecordDiff }
    • func (Changeset) Actionable() []RecordDiff — managed и не InSync
    • func Diff(template, actual []model.Record) Changeset

Проектное решение (безопасность): записи, присутствующие в зоне, но отсутствующие в шаблоне, дают Delete-дифф. Они не удаляются автоматически — apply в задаче 4 применяет их только когда они переданы в Changeset явно (ручное подтверждение по каждой). NS/SOA-диффы всегда ReadOnly=true.

  • Step 1: Написать падающий тест диф-движка

internal/diff/diff_test.go:

package diff

import (
	"testing"

	"github.com/vasyakrg/dns-autoresolver/internal/model"
)

func find(cs Changeset, key string) *RecordDiff {
	for i := range cs.Diffs {
		d := cs.Diffs[i]
		var r *model.Record
		if d.Desired != nil {
			r = d.Desired
		} else {
			r = d.Actual
		}
		if r.Key() == key {
			return &cs.Diffs[i]
		}
	}
	return nil
}

func TestDiffAddUpdateDeleteInSync(t *testing.T) {
	tmpl := []model.Record{
		{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}},   // in sync
		{Type: model.A, Name: "b.example.com.", TTL: 300, Values: []string{"2.2.2.2"}},   // update
		{Type: model.A, Name: "c.example.com.", TTL: 300, Values: []string{"3.3.3.3"}},   // add
	}
	actual := []model.Record{
		{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}},
		{Type: model.A, Name: "b.example.com.", TTL: 300, Values: []string{"9.9.9.9"}},
		{Type: model.A, Name: "d.example.com.", TTL: 300, Values: []string{"4.4.4.4"}}, // delete (extra)
	}
	cs := Diff(tmpl, actual)

	if d := find(cs, "A a.example.com."); d == nil || d.Kind != InSync {
		t.Fatalf("a should be InSync, got %+v", d)
	}
	if d := find(cs, "A b.example.com."); d == nil || d.Kind != Update {
		t.Fatalf("b should be Update, got %+v", d)
	}
	if d := find(cs, "A c.example.com."); d == nil || d.Kind != Add {
		t.Fatalf("c should be Add, got %+v", d)
	}
	if d := find(cs, "A d.example.com."); d == nil || d.Kind != Delete {
		t.Fatalf("d should be Delete, got %+v", d)
	}
}

func TestDiffMarksReadOnlyForNSSOA(t *testing.T) {
	tmpl := []model.Record{{Type: model.NS, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com."}}}
	actual := []model.Record{{Type: model.NS, Name: "example.com.", TTL: 3600, Values: []string{"ns9.other.com."}}}
	cs := Diff(tmpl, actual)
	d := find(cs, "NS example.com.")
	if d == nil || d.Kind != Update || !d.ReadOnly {
		t.Fatalf("NS diff must be Update and ReadOnly, got %+v", d)
	}
}

func TestActionableExcludesInSyncAndReadOnly(t *testing.T) {
	tmpl := []model.Record{
		{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}, // in sync
		{Type: model.A, Name: "b.example.com.", TTL: 300, Values: []string{"2.2.2.2"}}, // add
		{Type: model.NS, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com."}}, // read-only add
	}
	actual := []model.Record{
		{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}},
	}
	act := Diff(tmpl, actual).Actionable()
	if len(act) != 1 || act[0].Name != "b.example.com." {
		t.Fatalf("only b.example.com. is actionable, got %+v", act)
	}
}
  • Step 2: Запустить — убедиться, что падает

Run: go test ./internal/diff/ -v Expected: FAIL (undefined: Diff/Changeset/…)

  • Step 3: Реализовать диф-движок

internal/diff/diff.go:

package diff

import "github.com/vasyakrg/dns-autoresolver/internal/model"

type ChangeKind string

const (
	InSync ChangeKind = "in_sync"
	Add    ChangeKind = "add"
	Update ChangeKind = "update"
	Delete ChangeKind = "delete"
)

// RecordDiff describes one RRset's deviation between template and zone.
type RecordDiff struct {
	Kind     ChangeKind
	Type     model.RecordType
	Name     string
	Desired  *model.Record // nil for Delete
	Actual   *model.Record // nil for Add
	ReadOnly bool          // NS/SOA — shown but never applied
}

type Changeset struct {
	Diffs []RecordDiff
}

// Actionable returns managed diffs that are not in sync.
func (c Changeset) Actionable() []RecordDiff {
	var out []RecordDiff
	for _, d := range c.Diffs {
		if d.ReadOnly || d.Kind == InSync {
			continue
		}
		out = append(out, d)
	}
	return out
}

// Diff compares a template against the actual zone records.
// Records present in the zone but absent from the template yield Delete.
func Diff(template, actual []model.Record) Changeset {
	current := index(actual)
	seen := make(map[string]bool, len(template))
	var diffs []RecordDiff

	for _, t := range template {
		tt := t
		key := tt.Key()
		seen[key] = true
		ro := !tt.Type.Managed()
		if a, ok := current[key]; ok {
			ac := a
			kind := Update
			if tt.Equal(ac) {
				kind = InSync
			}
			diffs = append(diffs, RecordDiff{Kind: kind, Type: tt.Type, Name: tt.Name, Desired: &tt, Actual: &ac, ReadOnly: ro})
		} else {
			diffs = append(diffs, RecordDiff{Kind: Add, Type: tt.Type, Name: tt.Name, Desired: &tt, ReadOnly: ro})
		}
	}
	for _, a := range actual {
		ac := a
		if seen[ac.Key()] {
			continue
		}
		diffs = append(diffs, RecordDiff{Kind: Delete, Type: ac.Type, Name: ac.Name, Actual: &ac, ReadOnly: !ac.Type.Managed()})
	}
	return Changeset{Diffs: diffs}
}

func index(recs []model.Record) map[string]model.Record {
	m := make(map[string]model.Record, len(recs))
	for _, r := range recs {
		m[r.Key()] = r
	}
	return m
}
  • Step 4: Запустить тесты — зелёные

Run: go test ./internal/diff/ -v Expected: PASS (3 теста)

  • Step 5: Commit
git add internal/diff/
git commit -m "feat(diff): диф-движок шаблон↔зона с Actionable и ReadOnly для NS/SOA"

Task 3: Интерфейс Provider

Files:

  • Create: internal/provider/provider.go
  • Create: internal/provider/provider_test.go

Interfaces:

  • Consumes: model.Record, diff.Changeset

  • Produces:

    • type Credentials struct { Secret string }
    • type Zone struct { ID string; Name string }
    • type Provider interface { Name() string; ListZones(ctx, Credentials) ([]Zone, error); GetRecords(ctx, Credentials, zoneID string) ([]model.Record, error); ApplyChanges(ctx, Credentials, zoneID string, cs diff.Changeset) error }
  • Step 1: Написать компиляционный тест интерфейса

internal/provider/provider_test.go:

package provider

import (
	"context"
	"testing"

	"github.com/vasyakrg/dns-autoresolver/internal/diff"
	"github.com/vasyakrg/dns-autoresolver/internal/model"
)

// stubProvider проверяет, что интерфейс реализуем.
type stubProvider struct{}

func (stubProvider) Name() string { return "stub" }
func (stubProvider) ListZones(context.Context, Credentials) ([]Zone, error) {
	return []Zone{{ID: "1", Name: "example.com."}}, nil
}
func (stubProvider) GetRecords(context.Context, Credentials, string) ([]model.Record, error) {
	return nil, nil
}
func (stubProvider) ApplyChanges(context.Context, Credentials, string, diff.Changeset) error {
	return nil
}

func TestProviderInterfaceSatisfied(t *testing.T) {
	var p Provider = stubProvider{}
	zs, err := p.ListZones(context.Background(), Credentials{Secret: "x"})
	if err != nil || len(zs) != 1 || zs[0].Name != "example.com." {
		t.Fatalf("unexpected: %v %v", zs, err)
	}
}
  • Step 2: Запустить — убедиться, что падает

Run: go test ./internal/provider/ -v Expected: FAIL (undefined: Provider/Credentials/Zone)

  • Step 3: Реализовать интерфейс

internal/provider/provider.go:

package provider

import (
	"context"

	"github.com/vasyakrg/dns-autoresolver/internal/diff"
	"github.com/vasyakrg/dns-autoresolver/internal/model"
)

// Credentials holds the secret used to authenticate against a provider.
// For Selectel this is the project-scoped token sent as X-Auth-Token.
type Credentials struct {
	Secret string
}

// Zone is a provider-neutral DNS zone reference.
type Zone struct {
	ID   string
	Name string
}

// Provider is implemented per DNS provider (Selectel first).
type Provider interface {
	Name() string
	ListZones(ctx context.Context, creds Credentials) ([]Zone, error)
	GetRecords(ctx context.Context, creds Credentials, zoneID string) ([]model.Record, error)
	ApplyChanges(ctx context.Context, creds Credentials, zoneID string, cs diff.Changeset) error
}
  • Step 4: Запустить тесты — зелёные

Run: go test ./internal/provider/ -v Expected: PASS

  • Step 5: Commit
git add internal/provider/provider.go internal/provider/provider_test.go
git commit -m "feat(provider): интерфейс Provider, Credentials, Zone"

Task 4: Selectel-провайдер (ListZones, GetRecords, ApplyChanges)

Files:

  • Create: internal/provider/selectel/selectel.go
  • Create: internal/provider/selectel/selectel_test.go

Interfaces:

  • Consumes: provider.Provider, provider.Credentials, provider.Zone, model.Record, diff.Changeset, diff.RecordDiff, diff.Add/Update/Delete
  • Produces:
    • type Client struct { BaseURL string; HTTP *http.Client }
    • func New() *ClientBaseURL=DefaultBaseURL, таймаут 30s
    • const DefaultBaseURL = "https://api.selectel.ru/domains/v2"
    • *Client реализует provider.Provider

Допущение о форме ответов API (подтвердить на этапе Проверки реальным запросом; при расхождении — поправить только json-теги): списки возвращаются как {"result": [...], "next_offset": N}; RRSet — {"id","name","type","ttl","records":[{"content","disabled"}]}. Тесты используют ту же форму, поэтому самосогласованы.

  • Step 1: Написать падающие тесты против httptest

internal/provider/selectel/selectel_test.go:

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)
		}
	}
}
  • Step 2: Запустить — убедиться, что падает/не компилируется

Run: go test ./internal/provider/selectel/ -v Expected: FAIL (undefined: Client/New/DefaultBaseURL)

  • Step 3: Реализовать Selectel-клиент

internal/provider/selectel/selectel.go:

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
}

// compile-time check
var _ provider.Provider = (*Client)(nil)

internal/provider/selectel/selectel.go (маппинг — в том же файле или отдельном; здесь inline):

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
}
  • Step 4: Запустить тесты — зелёные

Run: go test ./internal/provider/selectel/ -v Expected: PASS (3 теста: ListZones, GetRecords, ApplyChanges)

  • Step 5: Прогнать весь модуль

Run: make test Expected: PASS во всех пакетах; go vet ./... без замечаний.

  • Step 6: Commit
git add internal/provider/selectel/
git commit -m "feat(selectel): реализация Provider — ListZones, GetRecords, ApplyChanges"

Self-Review

  • Spec coverage: Provider-абстракция (Task 3), Selectel ListZones/GetRecords/ApplyChanges (Task 4), нейтральная модель Record (Task 1), диф-движок с ручным apply и ReadOnly для NS/SOA (Task 2), управляемые типы включая SRV/MX priority (Task 1 нормализация + тесты). БД/API/UI — вне 1A (планы 1B/1C), что соответствует фазовой декомпозиции spec.
  • Type consistency: Record{Type,Name,TTL,Values}, Record.Key(), Record.Equal() — используются единообразно во всех задачах; diff.Changeset/RecordDiff/ChangeKind совпадают между Task 2 и Task 4; provider.Credentials{Secret} — заголовок X-Auth-Token в Task 4.
  • Placeholders: реального кода-плейсхолдера нет. Единственное внешнее допущение — имена json-полей ответов Selectel (result/next_offset) — вынесено в раздел Проверки; код конкретен и тесты самосогласованы.

Проверка (end-to-end)

  1. make test — все пакеты зелёные, go vet ./... чист.
  2. Подтвердить форму ответов реального Selectel DNS API v2: выполнить GET /zones и GET /zones/{id}/rrset с настоящим X-Auth-Token (ключ из панели Selectel) и сверить имена полей списка (result vs иное) и структуру RRSet. При расхождении — поправить только json-теги в apiZoneList/apiRRSetList/apiRRSet и перезапустить тесты.
  3. Небольшой ручной прогон (одноразовый main или go test c реальным сервером под флагом): ListZones → выбрать зону → GetRecordsdiff.Diff(template, actual) → распечатать Actionable() → (опционально в тестовой зоне) ApplyChanges.