# 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`.