From c738d05241542ce8ab70e2f8e4aaac16215770b2 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 12:16:03 +0700 Subject: [PATCH] =?UTF-8?q?docs:=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20=D0=A4=D0=B0?= =?UTF-8?q?=D0=B7=D1=8B=201A=20(=D1=8F=D0=B4=D1=80=D0=BE=20+=20Selectel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-03-phase1a-core-selectel.md | 959 ++++++++++++++++++ 1 file changed, 959 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-03-phase1a-core-selectel.md diff --git a/docs/superpowers/plans/2026-07-03-phase1a-core-selectel.md b/docs/superpowers/plans/2026-07-03-phase1a-core-selectel.md new file mode 100644 index 0000000..06edb4e --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-phase1a-core-selectel.md @@ -0,0 +1,959 @@ +# 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 в диапазоне **60–604800**. +- Selectel: аутентификация заголовком **`X-Auth-Token: `**. 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` — `" "` + - `func (Record) NormalizedValues() []string` — отсортированные нормализованные значения + - `func (Record) Equal(o Record) bool` — равенство по TTL и множеству значений + +- [ ] **Step 1: Инициализировать модуль и Makefile** + +```bash +go mod init github.com/vasyakrg/dns-autoresolver +``` + +`Makefile`: +```makefile +.PHONY: test +test: + go test ./... + +.PHONY: build +build: + go build ./... +``` + +- [ ] **Step 2: Написать падающий тест модели** + +`internal/model/record_test.go`: +```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`: +```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 " "; for SRV it is +// " ". 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** + +```bash +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`: +```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`: +```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** + +```bash +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`: +```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`: +```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** + +```bash +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() *Client` — `BaseURL=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`: +```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`: +```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): +```go +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** + +```bash +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` → выбрать зону → `GetRecords` → `diff.Diff(template, actual)` → распечатать `Actionable()` → (опционально в тестовой зоне) `ApplyChanges`.