# Phase 1B: Persistence + REST API — 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:** Дать проекту хранилище (PostgreSQL) и REST API: CRUD provider-учёток/шаблонов/доменов, импорт зон, сверка (check→diff) и применение (apply с guard на удаление) поверх готового ядра 1A. **Architecture:** Слои поверх ядра 1A. `store` (pgx/v5 + sqlc + goose-миграции) хранит multi-tenant данные; `crypto` шифрует секреты учёток; `provider/registry` резолвит провайдера по имени; `service` оркестрирует Check/Apply (store + registry + diff); `api` (chi) — транспорт; `cmd/server` связывает всё. Store покрыт интеграционными тестами через testcontainers-go. **Tech Stack:** Go, pgx/v5 (`pgxpool`), sqlc (`sql_package: pgx/v5`), goose (`embed.FS`), chi/v5, testcontainers-go (postgres), AES-256-GCM (stdlib crypto), google/uuid. ## Global Constraints - Module path: `github.com/vasyakrg/dns-autoresolver`. Go 1.26.x. - Ядро 1A уже готово и НЕ меняется: `internal/model` (`Record{Type,Name,TTL,Values}`, `RecordType`), `internal/diff` (`Diff`, `Changeset`, `Changeset.Updates()`, `Changeset.Prunes()`, `RecordDiff`), `internal/provider` (`Provider`, `Credentials{Secret}`, `Zone`), `internal/provider/selectel` (`New()`). - Env-переменные: `DNS_AR_DB_DSN` (postgres DSN), `DNS_AR_ENC_KEY` (base64, ровно 32 байта после декода), `DNS_AR_LISTEN` (default `:8080`). - Seed (default tenant, фиксированные UUID): user `00000000-0000-0000-0000-000000000001`, project `00000000-0000-0000-0000-000000000002`. - Инварианты безопасности: - Секрет учётки шифруется (AES-256-GCM) перед сохранением; расшифровывается только в `service`; **никогда** не сериализуется в API-ответ. - ENC-ключ обязателен и валиден (len==32) при старте; без него сервис не поднимается. - `apply`: `Changeset.Updates()` применяется всегда; `Changeset.Prunes()` (удаление лишних записей) — **только** при `applyPrunes=true`. По умолчанию `false`. - FQDN-имена зон с завершающей точкой (как в 1A). TTL 60–604800. - sqlc-код (`internal/store/db/`) генерируется, руками не редактируется. Миграции goose — единственный источник схемы (sqlc читает их как schema, игнорируя rollback). - Каждая задача завершается зелёными тестами и коммитом. Задачи с пометкой **[Docker]** требуют запущенного Docker (testcontainers). --- ### Task 1: Зависимости и пакет `config` **Files:** - Modify: `go.mod` (добавятся зависимости) - Create: `internal/config/config.go` - Create: `internal/config/config_test.go` **Interfaces:** - Produces: - `type Config struct { DBDSN string; EncKey []byte; ListenAddr string }` - `func Load() (*Config, error)` — читает env, base64-декодит `DNS_AR_ENC_KEY`, валидирует `len(EncKey)==32`, дефолт `ListenAddr=":8080"`. - [ ] **Step 1: Установить зависимости** ```bash go get github.com/jackc/pgx/v5@latest go get github.com/jackc/pgx/v5/pgxpool@latest go get github.com/jackc/pgx/v5/stdlib@latest go get github.com/pressly/goose/v3@latest go get github.com/go-chi/chi/v5@latest go get github.com/google/uuid@latest go get github.com/testcontainers/testcontainers-go@latest go get github.com/testcontainers/testcontainers-go/modules/postgres@latest ``` - [ ] **Step 2: Написать падающий тест config** `internal/config/config_test.go`: ```go package config import ( "encoding/base64" "testing" ) func setEnv(t *testing.T, k, v string) { t.Helper() t.Setenv(k, v) } func TestLoadValid(t *testing.T) { key := make([]byte, 32) for i := range key { key[i] = byte(i) } setEnv(t, "DNS_AR_DB_DSN", "postgres://u:p@localhost:5432/db") setEnv(t, "DNS_AR_ENC_KEY", base64.StdEncoding.EncodeToString(key)) // DNS_AR_LISTEN не задан — дефолт cfg, err := Load() if err != nil { t.Fatal(err) } if cfg.DBDSN == "" || len(cfg.EncKey) != 32 || cfg.ListenAddr != ":8080" { t.Fatalf("unexpected cfg: %+v", cfg) } } func TestLoadRejectsShortKey(t *testing.T) { setEnv(t, "DNS_AR_DB_DSN", "postgres://x") setEnv(t, "DNS_AR_ENC_KEY", base64.StdEncoding.EncodeToString([]byte("short"))) if _, err := Load(); err == nil { t.Fatal("expected error for non-32-byte key") } } func TestLoadRejectsMissingDSN(t *testing.T) { key := make([]byte, 32) setEnv(t, "DNS_AR_DB_DSN", "") setEnv(t, "DNS_AR_ENC_KEY", base64.StdEncoding.EncodeToString(key)) if _, err := Load(); err == nil { t.Fatal("expected error for missing DSN") } } ``` - [ ] **Step 3: Запустить — убедиться, что падает** Run: `go test ./internal/config/ -v` Expected: FAIL (undefined: Load/Config) - [ ] **Step 4: Реализовать config** `internal/config/config.go`: ```go package config import ( "encoding/base64" "fmt" "os" ) type Config struct { DBDSN string EncKey []byte ListenAddr string } func Load() (*Config, error) { dsn := os.Getenv("DNS_AR_DB_DSN") if dsn == "" { return nil, fmt.Errorf("config: DNS_AR_DB_DSN is required") } rawKey := os.Getenv("DNS_AR_ENC_KEY") if rawKey == "" { return nil, fmt.Errorf("config: DNS_AR_ENC_KEY is required") } key, err := base64.StdEncoding.DecodeString(rawKey) if err != nil { return nil, fmt.Errorf("config: DNS_AR_ENC_KEY must be base64: %w", err) } if len(key) != 32 { return nil, fmt.Errorf("config: DNS_AR_ENC_KEY must decode to 32 bytes, got %d", len(key)) } listen := os.Getenv("DNS_AR_LISTEN") if listen == "" { listen = ":8080" } return &Config{DBDSN: dsn, EncKey: key, ListenAddr: listen}, nil } ``` - [ ] **Step 5: Запустить тесты — зелёные** Run: `go test ./internal/config/ -v` Expected: PASS (3 теста) - [ ] **Step 6: Commit** ```bash git add go.mod go.sum internal/config/ git commit -m "feat(config): загрузка env-конфига (DSN, ENC-ключ, listen)" ``` --- ### Task 2: Пакет `crypto` (AES-256-GCM) **Files:** - Create: `internal/crypto/crypto.go` - Create: `internal/crypto/crypto_test.go` **Interfaces:** - Consumes: — - Produces: - `type Cipher struct { ... }` - `func NewCipher(key []byte) (*Cipher, error)` — требует `len(key)==32` - `func (c *Cipher) Encrypt(plaintext []byte) (string, error)` — возвращает `base64(nonce‖ciphertext)` - `func (c *Cipher) Decrypt(enc string) ([]byte, error)` - [ ] **Step 1: Написать падающий тест** `internal/crypto/crypto_test.go`: ```go package crypto import ( "bytes" "testing" ) func key32() []byte { k := make([]byte, 32) for i := range k { k[i] = byte(i + 1) } return k } func TestEncryptDecryptRoundTrip(t *testing.T) { c, err := NewCipher(key32()) if err != nil { t.Fatal(err) } plain := []byte("selectel-api-secret-token") enc, err := c.Encrypt(plain) if err != nil { t.Fatal(err) } if enc == string(plain) { t.Fatal("ciphertext must differ from plaintext") } dec, err := c.Decrypt(enc) if err != nil { t.Fatal(err) } if !bytes.Equal(dec, plain) { t.Fatalf("round-trip mismatch: %q != %q", dec, plain) } } func TestEncryptNonDeterministic(t *testing.T) { c, _ := NewCipher(key32()) a, _ := c.Encrypt([]byte("same")) b, _ := c.Encrypt([]byte("same")) if a == b { t.Fatal("nonce must randomize ciphertext") } } func TestDecryptTamperFails(t *testing.T) { c, _ := NewCipher(key32()) enc, _ := c.Encrypt([]byte("data")) // испортить последний символ base64 tampered := enc[:len(enc)-1] + "A" if tampered == enc { tampered = enc[:len(enc)-1] + "B" } if _, err := c.Decrypt(tampered); err == nil { t.Fatal("GCM must reject tampered ciphertext") } } func TestNewCipherRejectsBadKey(t *testing.T) { if _, err := NewCipher([]byte("short")); err == nil { t.Fatal("expected error for non-32-byte key") } } ``` - [ ] **Step 2: Запустить — падает** Run: `go test ./internal/crypto/ -v` Expected: FAIL (undefined: NewCipher/Cipher) - [ ] **Step 3: Реализовать crypto** `internal/crypto/crypto.go`: ```go package crypto import ( "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/base64" "fmt" "io" ) // Cipher performs AES-256-GCM encryption of provider secrets. type Cipher struct { gcm cipher.AEAD } func NewCipher(key []byte) (*Cipher, error) { if len(key) != 32 { return nil, fmt.Errorf("crypto: key must be 32 bytes, got %d", len(key)) } block, err := aes.NewCipher(key) if err != nil { return nil, err } gcm, err := cipher.NewGCM(block) if err != nil { return nil, err } return &Cipher{gcm: gcm}, nil } // Encrypt returns base64(nonce‖ciphertext). func (c *Cipher) Encrypt(plaintext []byte) (string, error) { nonce := make([]byte, c.gcm.NonceSize()) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return "", err } sealed := c.gcm.Seal(nonce, nonce, plaintext, nil) return base64.StdEncoding.EncodeToString(sealed), nil } // Decrypt reverses Encrypt. func (c *Cipher) Decrypt(enc string) ([]byte, error) { raw, err := base64.StdEncoding.DecodeString(enc) if err != nil { return nil, fmt.Errorf("crypto: invalid base64: %w", err) } ns := c.gcm.NonceSize() if len(raw) < ns { return nil, fmt.Errorf("crypto: ciphertext too short") } nonce, ct := raw[:ns], raw[ns:] return c.gcm.Open(nil, nonce, ct, nil) } ``` - [ ] **Step 4: Тесты зелёные** Run: `go test ./internal/crypto/ -v` Expected: PASS (4 теста) - [ ] **Step 5: Commit** ```bash git add internal/crypto/ git commit -m "feat(crypto): AES-256-GCM шифрование секретов учёток" ``` --- ### Task 3 [Docker]: Миграции goose + seed **Files:** - Create: `internal/store/migrations/0001_init.sql` - Create: `internal/store/migrate.go` - Create: `internal/store/migrate_test.go` - Create: `internal/store/testhelper_test.go` **Interfaces:** - Consumes: goose, pgx stdlib - Produces: - `func Migrate(ctx context.Context, dsn string) error` — применяет embed-миграции (goose, dialect postgres) - (test helper) `func startPostgres(t *testing.T) (dsn string)` — testcontainers, применяет Migrate, регистрирует cleanup **Seed UUID:** user `…0001`, project `…0002` (см. Global Constraints). - [ ] **Step 1: Миграция схемы + seed** `internal/store/migrations/0001_init.sql`: ```sql -- +goose Up CREATE TABLE users ( id uuid PRIMARY KEY, email text NOT NULL UNIQUE, created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE projects ( id uuid PRIMARY KEY, user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, name text NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE provider_accounts ( id uuid PRIMARY KEY, project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE, provider text NOT NULL, secret_enc text NOT NULL, comment text NOT NULL DEFAULT '', created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE templates ( id uuid PRIMARY KEY, project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE, name text NOT NULL, doc jsonb NOT NULL, version int NOT NULL DEFAULT 1, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE domains ( id uuid PRIMARY KEY, project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE, provider_account_id uuid NOT NULL REFERENCES provider_accounts(id) ON DELETE CASCADE, zone_name text NOT NULL, zone_id text NOT NULL, template_id uuid REFERENCES templates(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE check_runs ( id uuid PRIMARY KEY, domain_id uuid NOT NULL REFERENCES domains(id) ON DELETE CASCADE, result jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); -- seed default tenant (Фаза 1B без логина) INSERT INTO users (id, email) VALUES ('00000000-0000-0000-0000-000000000001', 'default@local'); INSERT INTO projects (id, user_id, name) VALUES ('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000001', 'default'); -- +goose Down DROP TABLE check_runs; DROP TABLE domains; DROP TABLE templates; DROP TABLE provider_accounts; DROP TABLE projects; DROP TABLE users; ``` - [ ] **Step 2: Реализовать Migrate** `internal/store/migrate.go`: ```go package store import ( "context" "database/sql" "embed" _ "github.com/jackc/pgx/v5/stdlib" // pgx database/sql driver "github.com/pressly/goose/v3" ) //go:embed migrations/*.sql var migrationsFS embed.FS // Migrate applies all pending goose migrations to the database at dsn. func Migrate(ctx context.Context, dsn string) error { db, err := sql.Open("pgx", dsn) if err != nil { return err } defer db.Close() goose.SetBaseFS(migrationsFS) if err := goose.SetDialect("postgres"); err != nil { return err } return goose.UpContext(ctx, db, "migrations") } ``` - [ ] **Step 3: Тест-хелпер testcontainers** `internal/store/testhelper_test.go`: ```go package store import ( "context" "testing" "time" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/postgres" "github.com/testcontainers/testcontainers-go/wait" ) // startPostgres spins up an ephemeral PostgreSQL, applies migrations, // and returns its DSN. Container is terminated on test cleanup. func startPostgres(t *testing.T) string { t.Helper() ctx := context.Background() container, err := postgres.Run(ctx, "postgres:16-alpine", postgres.WithDatabase("dns_ar_test"), postgres.WithUsername("test"), postgres.WithPassword("test"), postgres.WithWaitStrategy( wait.ForLog("database system is ready to accept connections"). WithOccurrence(2).WithStartupTimeout(60*time.Second)), ) if err != nil { t.Fatalf("start postgres: %v", err) } t.Cleanup(func() { _ = testcontainers.TerminateContainer(container) }) dsn, err := container.ConnectionString(ctx, "sslmode=disable") if err != nil { t.Fatalf("dsn: %v", err) } if err := Migrate(ctx, dsn); err != nil { t.Fatalf("migrate: %v", err) } return dsn } ``` - [ ] **Step 4: Тест миграций и seed** `internal/store/migrate_test.go`: ```go package store import ( "context" "testing" "github.com/jackc/pgx/v5/pgxpool" ) func TestMigrateCreatesTablesAndSeed(t *testing.T) { dsn := startPostgres(t) ctx := context.Background() pool, err := pgxpool.New(ctx, dsn) if err != nil { t.Fatal(err) } defer pool.Close() // таблицы существуют for _, table := range []string{"users", "projects", "provider_accounts", "templates", "domains", "check_runs"} { var exists bool err := pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name=$1)`, table).Scan(&exists) if err != nil || !exists { t.Fatalf("table %s missing (err=%v)", table, err) } } // seed default project присутствует var name string err = pool.QueryRow(ctx, `SELECT name FROM projects WHERE id='00000000-0000-0000-0000-000000000002'`).Scan(&name) if err != nil || name != "default" { t.Fatalf("seed project missing: name=%q err=%v", name, err) } } ``` - [ ] **Step 5: Запустить (нужен Docker)** Run: `go test ./internal/store/ -run TestMigrate -v` Expected: PASS (контейнер поднимается, миграции применяются, таблицы+seed на месте) - [ ] **Step 6: Commit** ```bash git add internal/store/migrations/ internal/store/migrate.go internal/store/migrate_test.go internal/store/testhelper_test.go git commit -m "feat(store): goose-миграции схемы + seed default tenant, тест на testcontainers" ``` --- ### Task 4 [Docker]: sqlc-код + Repository (CRUD) **Files:** - Create: `sqlc.yaml` - Create: `internal/store/dto/template_doc.go` - Create: `internal/store/dto/template_doc_test.go` - Create: `internal/store/queries/accounts.sql`, `queries/templates.sql`, `queries/domains.sql`, `queries/check_runs.sql` - Generate: `internal/store/db/*` (sqlc) - Create: `internal/store/store.go` - Create: `internal/store/store_test.go` **Interfaces:** - Consumes: `model.Record` (для dto), сгенерированный `db.Queries`, `pgxpool.Pool` - Produces: - `dto.TemplateDoc`, `dto.RecordDTO`, `func (TemplateDoc) ToModel() []model.Record`, `func FromModel([]model.Record) TemplateDoc` - `type Store struct { q *db.Queries; pool *pgxpool.Pool }`; `func New(pool *pgxpool.Pool) *Store` - методы Store, оборачивающие db.Queries (CreateAccount/GetAccount/ListAccounts/DeleteAccount, CreateTemplate/GetTemplate/ListTemplates/UpdateTemplate/DeleteTemplate, CreateDomain/GetDomain/ListDomains/DeleteDomain, CreateCheckRun) > **Замечание для реализатора:** dto создаём ДО генерации sqlc — override в `sqlc.yaml` ссылается на пакет `dto`, он должен существовать и компилироваться. Порядок шагов ниже это учитывает. - [ ] **Step 1: dto TemplateDoc (падающий тест)** `internal/store/dto/template_doc_test.go`: ```go package dto import ( "testing" "github.com/vasyakrg/dns-autoresolver/internal/model" ) func TestTemplateDocRoundTrip(t *testing.T) { recs := []model.Record{ {Type: model.A, Name: "www.example.com.", TTL: 300, Values: []string{"1.2.3.4"}}, {Type: model.MX, Name: "example.com.", TTL: 3600, Values: []string{"10 mx1.example.com."}}, } doc := FromModel(recs) if len(doc.Records) != 2 { t.Fatalf("want 2 records, got %d", len(doc.Records)) } back := doc.ToModel() if len(back) != 2 || back[0].Type != model.A || back[1].Type != model.MX { t.Fatalf("round-trip mismatch: %+v", back) } if back[0].Values[0] != "1.2.3.4" || back[1].TTL != 3600 { t.Fatalf("field mismatch: %+v", back) } } ``` - [ ] **Step 2: Реализовать dto** `internal/store/dto/template_doc.go`: ```go package dto import "github.com/vasyakrg/dns-autoresolver/internal/model" // RecordDTO is the JSONB representation of one DNS record in a template. type RecordDTO struct { Type string `json:"type"` Name string `json:"name"` TTL int `json:"ttl"` Values []string `json:"values"` } // TemplateDoc is stored in templates.doc (jsonb). type TemplateDoc struct { Records []RecordDTO `json:"records"` } func FromModel(recs []model.Record) TemplateDoc { out := TemplateDoc{Records: make([]RecordDTO, 0, len(recs))} for _, r := range recs { out.Records = append(out.Records, RecordDTO{ Type: string(r.Type), Name: r.Name, TTL: r.TTL, Values: r.Values, }) } return out } func (d TemplateDoc) ToModel() []model.Record { out := make([]model.Record, 0, len(d.Records)) for _, r := range d.Records { out = append(out, model.Record{ Type: model.RecordType(r.Type), Name: r.Name, TTL: r.TTL, Values: r.Values, }) } return out } ``` Run: `go test ./internal/store/dto/ -v` → PASS. - [ ] **Step 3: sqlc.yaml** `sqlc.yaml`: ```yaml version: "2" sql: - engine: postgresql schema: internal/store/migrations queries: internal/store/queries gen: go: package: db out: internal/store/db sql_package: pgx/v5 emit_json_tags: true emit_pointers_for_null_types: true overrides: - column: templates.doc go_type: import: github.com/vasyakrg/dns-autoresolver/internal/store/dto package: dto type: TemplateDoc pointer: true ``` - [ ] **Step 4: SQL-запросы** `internal/store/queries/accounts.sql`: ```sql -- name: CreateAccount :one INSERT INTO provider_accounts (id, project_id, provider, secret_enc, comment) VALUES ($1, $2, $3, $4, $5) RETURNING *; -- name: GetAccount :one SELECT * FROM provider_accounts WHERE id = $1 AND project_id = $2; -- name: ListAccounts :many SELECT * FROM provider_accounts WHERE project_id = $1 ORDER BY created_at; -- name: DeleteAccount :exec DELETE FROM provider_accounts WHERE id = $1 AND project_id = $2; ``` `internal/store/queries/templates.sql`: ```sql -- name: CreateTemplate :one INSERT INTO templates (id, project_id, name, doc, version) VALUES ($1, $2, $3, $4, 1) RETURNING *; -- name: GetTemplate :one SELECT * FROM templates WHERE id = $1 AND project_id = $2; -- name: ListTemplates :many SELECT * FROM templates WHERE project_id = $1 ORDER BY created_at; -- name: UpdateTemplate :one UPDATE templates SET name = $3, doc = $4, version = version + 1, updated_at = now() WHERE id = $1 AND project_id = $2 RETURNING *; -- name: DeleteTemplate :exec DELETE FROM templates WHERE id = $1 AND project_id = $2; ``` `internal/store/queries/domains.sql`: ```sql -- name: CreateDomain :one INSERT INTO domains (id, project_id, provider_account_id, zone_name, zone_id, template_id) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *; -- name: GetDomain :one SELECT * FROM domains WHERE id = $1 AND project_id = $2; -- name: ListDomains :many SELECT * FROM domains WHERE project_id = $1 ORDER BY created_at; -- name: DeleteDomain :exec DELETE FROM domains WHERE id = $1 AND project_id = $2; ``` `internal/store/queries/check_runs.sql`: ```sql -- name: CreateCheckRun :one INSERT INTO check_runs (id, domain_id, result) VALUES ($1, $2, $3) RETURNING *; ``` - [ ] **Step 5: Сгенерировать sqlc-код** Run (нужен sqlc CLI — установить `go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest` при отсутствии): ```bash sqlc generate ``` Expected: создан `internal/store/db/` (`models.go`, `db.go`, `*.sql.go`). `templates.doc` имеет тип `*dto.TemplateDoc`. Run: `go build ./internal/store/db/` → компилируется. - [ ] **Step 6: Repository-обёртка** `internal/store/store.go`: ```go package store import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/vasyakrg/dns-autoresolver/internal/store/db" ) // Store wraps sqlc-generated queries over a pgx pool. type Store struct { q *db.Queries pool *pgxpool.Pool } func New(pool *pgxpool.Pool) *Store { return &Store{q: db.New(pool), pool: pool} } // Queries exposes the generated queries for callers that need them directly. func (s *Store) Queries() *db.Queries { return s.q } ``` > Реализатор: если сервису (Task 7) удобнее узкий интерфейс — методы-обёртки можно добавить здесь. Минимально достаточно `Queries()`; тесты ниже используют его. - [ ] **Step 7: Интеграционный тест CRUD (падающий → зелёный)** `internal/store/store_test.go`: ```go package store import ( "context" "testing" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "github.com/vasyakrg/dns-autoresolver/internal/store/db" "github.com/vasyakrg/dns-autoresolver/internal/store/dto" ) var defaultProject = uuid.MustParse("00000000-0000-0000-0000-000000000002") func newStore(t *testing.T) (*Store, context.Context) { dsn := startPostgres(t) pool, err := pgxpool.New(context.Background(), dsn) if err != nil { t.Fatal(err) } t.Cleanup(pool.Close) return New(pool), context.Background() } func TestAccountCRUD(t *testing.T) { s, ctx := newStore(t) acc, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{ ID: uuid.New(), ProjectID: defaultProject, Provider: "selectel", SecretEnc: "enc-blob", Comment: "prod", }) if err != nil { t.Fatal(err) } got, err := s.Queries().GetAccount(ctx, db.GetAccountParams{ID: acc.ID, ProjectID: defaultProject}) if err != nil || got.Provider != "selectel" || got.SecretEnc != "enc-blob" { t.Fatalf("get mismatch: %+v err=%v", got, err) } } func TestTemplateJSONBRoundTrip(t *testing.T) { s, ctx := newStore(t) doc := dto.TemplateDoc{Records: []dto.RecordDTO{ {Type: "A", Name: "www.example.com.", TTL: 300, Values: []string{"1.2.3.4"}}, {Type: "SRV", Name: "_autodiscover._tcp.example.com.", TTL: 3600, Values: []string{"0 0 443 mail.example.com."}}, }} tpl, err := s.Queries().CreateTemplate(ctx, db.CreateTemplateParams{ ID: uuid.New(), ProjectID: defaultProject, Name: "base", Doc: &doc, }) if err != nil { t.Fatal(err) } got, err := s.Queries().GetTemplate(ctx, db.GetTemplateParams{ID: tpl.ID, ProjectID: defaultProject}) if err != nil { t.Fatal(err) } if got.Doc == nil || len(got.Doc.Records) != 2 || got.Doc.Records[1].Type != "SRV" { t.Fatalf("jsonb round-trip failed: %+v", got.Doc) } } ``` > Если сигнатуры сгенерированных Params отличаются (напр. поле `Doc` не `*dto.TemplateDoc`), сверьтесь с `internal/store/db/models.go` — это источник истины по типам; параметры выше отражают ожидаемую генерацию при указанном override. - [ ] **Step 8: Запустить (Docker)** Run: `go test ./internal/store/... -v` Expected: PASS (dto + CRUD + JSONB round-trip) - [ ] **Step 9: Commit** ```bash git add sqlc.yaml internal/store/ git commit -m "feat(store): sqlc-запросы, dto TemplateDoc, Repository, интеграционные тесты CRUD" ``` --- ### Task 5: Provider registry **Files:** - Create: `internal/provider/registry/registry.go` - Create: `internal/provider/registry/registry_test.go` **Interfaces:** - Consumes: `provider.Provider` - Produces: - `type Registry struct { ... }`; `func New() *Registry` - `func (r *Registry) Register(p provider.Provider)` — ключ = `p.Name()` - `func (r *Registry) ByName(name string) (provider.Provider, error)` — ошибка, если не найден - [ ] **Step 1: Падающий тест** `internal/provider/registry/registry_test.go`: ```go package registry import ( "context" "testing" "github.com/vasyakrg/dns-autoresolver/internal/diff" "github.com/vasyakrg/dns-autoresolver/internal/model" "github.com/vasyakrg/dns-autoresolver/internal/provider" ) type fakeProvider struct{ name string } func (f fakeProvider) Name() string { return f.name } func (fakeProvider) ListZones(context.Context, provider.Credentials) ([]provider.Zone, error) { return nil, nil } func (fakeProvider) GetRecords(context.Context, provider.Credentials, string) ([]model.Record, error) { return nil, nil } func (fakeProvider) ApplyChanges(context.Context, provider.Credentials, string, diff.Changeset) error { return nil } func TestRegistryByName(t *testing.T) { r := New() r.Register(fakeProvider{name: "selectel"}) p, err := r.ByName("selectel") if err != nil || p.Name() != "selectel" { t.Fatalf("expected selectel, got %v err=%v", p, err) } if _, err := r.ByName("unknown"); err == nil { t.Fatal("expected error for unknown provider") } } ``` - [ ] **Step 2: Запустить — падает** Run: `go test ./internal/provider/registry/ -v` → FAIL. - [ ] **Step 3: Реализовать registry** `internal/provider/registry/registry.go`: ```go package registry import ( "fmt" "github.com/vasyakrg/dns-autoresolver/internal/provider" ) // Registry resolves providers by name. type Registry struct { m map[string]provider.Provider } func New() *Registry { return &Registry{m: make(map[string]provider.Provider)} } func (r *Registry) Register(p provider.Provider) { r.m[p.Name()] = p } func (r *Registry) ByName(name string) (provider.Provider, error) { p, ok := r.m[name] if !ok { return nil, fmt.Errorf("registry: unknown provider %q", name) } return p, nil } ``` - [ ] **Step 4: Тесты зелёные** Run: `go test ./internal/provider/registry/ -v` → PASS. - [ ] **Step 5: Commit** ```bash git add internal/provider/registry/ git commit -m "feat(registry): резолвинг провайдера по имени" ``` --- ### Task 6: Service — Check и Apply **Files:** - Create: `internal/service/service.go` - Create: `internal/service/service_test.go` **Interfaces:** - Consumes: `registry.Registry`, `crypto.Cipher`, `provider.Provider`, `provider.Credentials`, `diff.Diff/Changeset`, `dto.TemplateDoc`, `model.Record` - Produces: - `type DomainRef struct { ZoneID string; Provider string; SecretEnc string; Template dto.TemplateDoc }` - `type Loader interface { LoadDomain(ctx, domainID uuid.UUID) (DomainRef, error) }` — сервис зависит от узкого интерфейса, не от всего store (тестируемость) - `type Recorder interface { SaveCheckRun(ctx, domainID uuid.UUID, cs diff.Changeset) error }` - `type ApplyRequest struct { ApplyUpdates bool; ApplyPrunes bool }` - `type DomainService struct { ... }`; `func New(loader Loader, rec Recorder, reg *registry.Registry, cipher *crypto.Cipher) *DomainService` - `func (s *DomainService) Check(ctx, domainID uuid.UUID) (diff.Changeset, error)` - `func (s *DomainService) Apply(ctx, domainID uuid.UUID, req ApplyRequest) (diff.Changeset, error)` — возвращает применённый набор **Правило apply (guard):** применяемый `diff.Changeset` собирается из `cs.Updates()` (если `ApplyUpdates`, по умолчанию да) и `cs.Prunes()` **только** если `ApplyPrunes`. ReadOnly (NS/SOA) не применяется никогда. - [ ] **Step 1: Падающий тест (мок loader/provider)** `internal/service/service_test.go`: ```go package service import ( "context" "testing" "github.com/google/uuid" "github.com/vasyakrg/dns-autoresolver/internal/crypto" "github.com/vasyakrg/dns-autoresolver/internal/diff" "github.com/vasyakrg/dns-autoresolver/internal/model" "github.com/vasyakrg/dns-autoresolver/internal/provider" "github.com/vasyakrg/dns-autoresolver/internal/provider/registry" "github.com/vasyakrg/dns-autoresolver/internal/store/dto" ) func testCipher(t *testing.T) *crypto.Cipher { t.Helper() key := make([]byte, 32) c, err := crypto.NewCipher(key) if err != nil { t.Fatal(err) } return c } // fakeProvider records applied changesets and returns canned zone records. type fakeProvider struct { actual []model.Record applied diff.Changeset } func (fakeProvider) Name() string { return "selectel" } func (fakeProvider) ListZones(context.Context, provider.Credentials) ([]provider.Zone, error) { return nil, nil } func (f *fakeProvider) GetRecords(context.Context, provider.Credentials, string) ([]model.Record, error) { return f.actual, nil } func (f *fakeProvider) ApplyChanges(_ context.Context, _ provider.Credentials, _ string, cs diff.Changeset) error { f.applied = cs return nil } type fakeLoader struct{ ref DomainRef } func (l fakeLoader) LoadDomain(context.Context, uuid.UUID) (DomainRef, error) { return l.ref, nil } type nopRecorder struct{} func (nopRecorder) SaveCheckRun(context.Context, uuid.UUID, diff.Changeset) error { return nil } func setup(t *testing.T, actual []model.Record, tmpl dto.TemplateDoc) (*DomainService, *fakeProvider) { fp := &fakeProvider{actual: actual} reg := registry.New() reg.Register(fp) cipher := testCipher(t) enc, _ := cipher.Encrypt([]byte("secret")) loader := fakeLoader{ref: DomainRef{ZoneID: "z1", Provider: "selectel", SecretEnc: enc, Template: tmpl}} return New(loader, nopRecorder{}, reg, cipher), fp } func TestCheckProducesDiff(t *testing.T) { actual := []model.Record{{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"9.9.9.9"}}} tmpl := dto.TemplateDoc{Records: []dto.RecordDTO{ {Type: "A", Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}, // update }} svc, _ := setup(t, actual, tmpl) cs, err := svc.Check(context.Background(), uuid.New()) if err != nil { t.Fatal(err) } if len(cs.Updates()) != 1 || cs.Updates()[0].Kind != diff.Update { t.Fatalf("expected 1 update, got %+v", cs.Updates()) } } func TestApplyRespectsPruneGuard(t *testing.T) { // зона содержит лишнюю запись b (нет в шаблоне) → Prune-кандидат 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{"2.2.2.2"}}, } tmpl := dto.TemplateDoc{Records: []dto.RecordDTO{ {Type: "A", Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}, // in sync }} // applyPrunes=false → удаление b НЕ применяется svc, fp := setup(t, actual, tmpl) if _, err := svc.Apply(context.Background(), uuid.New(), ApplyRequest{ApplyUpdates: true, ApplyPrunes: false}); err != nil { t.Fatal(err) } for _, d := range fp.applied.Diffs { if d.Kind == diff.Delete { t.Fatalf("prune must be skipped when ApplyPrunes=false, applied: %+v", fp.applied.Diffs) } } // applyPrunes=true → удаление b применяется svc2, fp2 := setup(t, actual, tmpl) if _, err := svc2.Apply(context.Background(), uuid.New(), ApplyRequest{ApplyUpdates: true, ApplyPrunes: true}); err != nil { t.Fatal(err) } var sawDelete bool for _, d := range fp2.applied.Diffs { if d.Kind == diff.Delete && d.Name == "b.example.com." { sawDelete = true } } if !sawDelete { t.Fatalf("prune must be applied when ApplyPrunes=true, applied: %+v", fp2.applied.Diffs) } } ``` - [ ] **Step 2: Запустить — падает** Run: `go test ./internal/service/ -v` → FAIL (undefined types). - [ ] **Step 3: Реализовать service** `internal/service/service.go`: ```go package service import ( "context" "github.com/google/uuid" "github.com/vasyakrg/dns-autoresolver/internal/crypto" "github.com/vasyakrg/dns-autoresolver/internal/diff" "github.com/vasyakrg/dns-autoresolver/internal/provider" "github.com/vasyakrg/dns-autoresolver/internal/provider/registry" "github.com/vasyakrg/dns-autoresolver/internal/store/dto" ) // DomainRef is the minimal data the service needs about a domain. type DomainRef struct { ZoneID string Provider string SecretEnc string Template dto.TemplateDoc } type Loader interface { LoadDomain(ctx context.Context, domainID uuid.UUID) (DomainRef, error) } type Recorder interface { SaveCheckRun(ctx context.Context, domainID uuid.UUID, cs diff.Changeset) error } type ApplyRequest struct { ApplyUpdates bool ApplyPrunes bool } type DomainService struct { loader Loader rec Recorder reg *registry.Registry cipher *crypto.Cipher } func New(loader Loader, rec Recorder, reg *registry.Registry, cipher *crypto.Cipher) *DomainService { return &DomainService{loader: loader, rec: rec, reg: reg, cipher: cipher} } // resolve loads the domain, its provider and decrypted credentials, and computes the diff. func (s *DomainService) resolve(ctx context.Context, domainID uuid.UUID) (provider.Provider, provider.Credentials, DomainRef, diff.Changeset, error) { ref, err := s.loader.LoadDomain(ctx, domainID) if err != nil { return nil, provider.Credentials{}, ref, diff.Changeset{}, err } p, err := s.reg.ByName(ref.Provider) if err != nil { return nil, provider.Credentials{}, ref, diff.Changeset{}, err } secret, err := s.cipher.Decrypt(ref.SecretEnc) if err != nil { return nil, provider.Credentials{}, ref, diff.Changeset{}, err } creds := provider.Credentials{Secret: string(secret)} actual, err := p.GetRecords(ctx, creds, ref.ZoneID) if err != nil { return nil, provider.Credentials{}, ref, diff.Changeset{}, err } cs := diff.Diff(ref.Template.ToModel(), actual) return p, creds, ref, cs, nil } // Check computes and records the diff between template and zone. func (s *DomainService) Check(ctx context.Context, domainID uuid.UUID) (diff.Changeset, error) { _, _, _, cs, err := s.resolve(ctx, domainID) if err != nil { return diff.Changeset{}, err } if err := s.rec.SaveCheckRun(ctx, domainID, cs); err != nil { return diff.Changeset{}, err } return cs, nil } // Apply applies updates always (when ApplyUpdates) and prunes only when ApplyPrunes. func (s *DomainService) Apply(ctx context.Context, domainID uuid.UUID, req ApplyRequest) (diff.Changeset, error) { p, creds, ref, cs, err := s.resolve(ctx, domainID) if err != nil { return diff.Changeset{}, err } var toApply []diff.RecordDiff if req.ApplyUpdates { toApply = append(toApply, cs.Updates()...) } if req.ApplyPrunes { toApply = append(toApply, cs.Prunes()...) } applied := diff.Changeset{Diffs: toApply} if len(toApply) > 0 { if err := p.ApplyChanges(ctx, creds, ref.ZoneID, applied); err != nil { return diff.Changeset{}, err } } return applied, nil } ``` - [ ] **Step 4: Тесты зелёные** Run: `go test ./internal/service/ -v` → PASS (Check + prune-guard в обе стороны). - [ ] **Step 5: Commit** ```bash git add internal/service/ git commit -m "feat(service): Check/Apply оркестрация с guard на prune" ``` --- ### Task 7: REST API (chi) **Files:** - Create: `internal/api/api.go` (роутер + зависимости) - Create: `internal/api/handlers.go` (хендлеры) - Create: `internal/api/dto.go` (request/response) - Create: `internal/api/api_test.go` **Interfaces:** - Consumes: `service.DomainService` (через узкий интерфейс), store (через узкий интерфейс для CRUD), `crypto.Cipher` (для шифрования секрета на входе), `registry` - Produces: - `type CheckApplier interface { Check(ctx, uuid.UUID) (diff.Changeset, error); Apply(ctx, uuid.UUID, service.ApplyRequest) (diff.Changeset, error) }` - `type API struct { ... }`; `func NewRouter(a *API) http.Handler` - JSON DTO: `accountRequest{Provider, Secret, Comment}` (secret только на вход), `accountResponse{ID, Provider, Comment}` (без секрета), аналогично templates/domains, `applyRequest{ApplyUpdates, ApplyPrunes}`, `changesetResponse` (Updates/Prunes/ReadOnly раздельно). > Реализатору: для фокуса теста в этой задаче достаточно замокать `CheckApplier` и один CRUD-ресурс (accounts) с проверкой, что секрет НЕ возвращается в ответе. Полный CRUD остальных ресурсов реализуется тем же паттерном (те же sqlc-методы из Task 4); каждый ресурс — отдельный набор хендлеров под `r.Route`. - [ ] **Step 1: Падающий тест (httptest + мок)** `internal/api/api_test.go`: ```go package api import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "github.com/google/uuid" "github.com/vasyakrg/dns-autoresolver/internal/diff" "github.com/vasyakrg/dns-autoresolver/internal/model" "github.com/vasyakrg/dns-autoresolver/internal/service" ) type mockCheckApplier struct { lastReq service.ApplyRequest } func (m *mockCheckApplier) Check(context.Context, uuid.UUID) (diff.Changeset, error) { d := model.Record{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}} return diff.Changeset{Diffs: []diff.RecordDiff{{Kind: diff.Add, Type: d.Type, Name: d.Name, Desired: &d}}}, nil } func (m *mockCheckApplier) Apply(_ context.Context, _ uuid.UUID, req service.ApplyRequest) (diff.Changeset, error) { m.lastReq = req return diff.Changeset{}, nil } func newTestAPI() (*API, *mockCheckApplier) { m := &mockCheckApplier{} return &API{Svc: m}, m // остальные зависимости (store/cipher) nil — CRUD-тесты добавит реализатор } func TestCheckEndpoint(t *testing.T) { a, _ := newTestAPI() router := NewRouter(a) did := uuid.New().String() req := httptest.NewRequest(http.MethodGet, "/api/v1/projects/00000000-0000-0000-0000-000000000002/domains/"+did+"/check", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status %d, body %s", w.Code, w.Body.String()) } var resp changesetResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatal(err) } if len(resp.Updates) != 1 { t.Fatalf("expected 1 update in response, got %+v", resp) } } func TestApplyDefaultsPruneFalse(t *testing.T) { a, m := newTestAPI() router := NewRouter(a) did := uuid.New().String() body := `{"applyUpdates":true}` // applyPrunes отсутствует → false req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/00000000-0000-0000-0000-000000000002/domains/"+did+"/apply", strings.NewReader(body)) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status %d body %s", w.Code, w.Body.String()) } if m.lastReq.ApplyPrunes != false || m.lastReq.ApplyUpdates != true { t.Fatalf("apply request mismatch: %+v", m.lastReq) } } func TestApplyBadUUID(t *testing.T) { a, _ := newTestAPI() router := NewRouter(a) req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/00000000-0000-0000-0000-000000000002/domains/not-a-uuid/apply", bytes.NewReader([]byte(`{}`))) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400 for bad uuid, got %d", w.Code) } } ``` - [ ] **Step 2: Запустить — падает** Run: `go test ./internal/api/ -v` → FAIL. - [ ] **Step 3: DTO** `internal/api/dto.go`: ```go package api import "github.com/vasyakrg/dns-autoresolver/internal/diff" type applyRequest struct { ApplyUpdates bool `json:"applyUpdates"` ApplyPrunes bool `json:"applyPrunes"` } type recordView struct { Kind string `json:"kind"` Type string `json:"type"` Name string `json:"name"` Desired []string `json:"desired,omitempty"` Actual []string `json:"actual,omitempty"` ReadOnly bool `json:"readOnly"` } type changesetResponse struct { Updates []recordView `json:"updates"` Prunes []recordView `json:"prunes"` ReadOnly []recordView `json:"readOnly"` InSync int `json:"inSyncCount"` } func toRecordView(d diff.RecordDiff) recordView { rv := recordView{Kind: string(d.Kind), Type: string(d.Type), Name: d.Name, ReadOnly: d.ReadOnly} if d.Desired != nil { rv.Desired = d.Desired.Values } if d.Actual != nil { rv.Actual = d.Actual.Values } return rv } func toChangesetResponse(cs diff.Changeset) changesetResponse { resp := changesetResponse{} for _, d := range cs.Updates() { resp.Updates = append(resp.Updates, toRecordView(d)) } for _, d := range cs.Prunes() { resp.Prunes = append(resp.Prunes, toRecordView(d)) } for _, d := range cs.Diffs { if d.ReadOnly { resp.ReadOnly = append(resp.ReadOnly, toRecordView(d)) } if d.Kind == diff.InSync { resp.InSync++ } } return resp } ``` - [ ] **Step 4: API + роутер + хендлеры** `internal/api/api.go`: ```go package api import ( "context" "net/http" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/google/uuid" "github.com/vasyakrg/dns-autoresolver/internal/diff" "github.com/vasyakrg/dns-autoresolver/internal/service" ) // CheckApplier is the service surface the API depends on. type CheckApplier interface { Check(ctx context.Context, domainID uuid.UUID) (diff.Changeset, error) Apply(ctx context.Context, domainID uuid.UUID, req service.ApplyRequest) (diff.Changeset, error) } // API holds handler dependencies. Store/Cipher are used by CRUD handlers // (added by the implementer following the accounts pattern). type API struct { Svc CheckApplier } func NewRouter(a *API) http.Handler { r := chi.NewRouter() r.Use(middleware.RequestID) r.Use(middleware.Recoverer) r.Route("/api/v1/projects/{pid}", func(r chi.Router) { r.Route("/domains/{did}", func(r chi.Router) { r.Get("/check", a.handleCheck) r.Post("/apply", a.handleApply) }) // accounts/templates/domains CRUD маунтятся тем же паттерном (Task 4 sqlc-методы) }) return r } ``` `internal/api/handlers.go`: ```go package api import ( "encoding/json" "net/http" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/vasyakrg/dns-autoresolver/internal/service" ) func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func writeErr(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } func (a *API) handleCheck(w http.ResponseWriter, r *http.Request) { did, err := uuid.Parse(chi.URLParam(r, "did")) if err != nil { writeErr(w, http.StatusBadRequest, "invalid domain id") return } cs, err := a.Svc.Check(r.Context(), did) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, toChangesetResponse(cs)) } func (a *API) handleApply(w http.ResponseWriter, r *http.Request) { did, err := uuid.Parse(chi.URLParam(r, "did")) if err != nil { writeErr(w, http.StatusBadRequest, "invalid domain id") return } var req applyRequest if r.Body != nil { // пустое тело допустимо → значения по умолчанию (prune=false) _ = json.NewDecoder(r.Body).Decode(&req) } cs, err := a.Svc.Apply(r.Context(), did, service.ApplyRequest{ ApplyUpdates: req.ApplyUpdates, ApplyPrunes: req.ApplyPrunes, }) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, toChangesetResponse(cs)) } ``` > Реализатор: CRUD-хендлеры accounts/templates/domains добавляются под `r.Route` (Task 4 предоставил sqlc-методы). Секрет учётки: на вход `accountRequest.Secret` → `cipher.Encrypt` → `CreateAccountParams.SecretEnc`; в ответе секрет НЕ включается. Тест на «секрет не в ответе» реализатор добавляет вместе с CRUD-хендлером accounts. - [ ] **Step 5: Тесты зелёные** Run: `go test ./internal/api/ -v` → PASS (check, apply-default-prune-false, bad uuid). - [ ] **Step 6: Commit** ```bash git add internal/api/ git commit -m "feat(api): chi-роутер, check/apply хендлеры, changeset DTO" ``` --- ### Task 8: Сборка приложения `cmd/server` **Files:** - Create: `internal/store/loader.go` (реализация `service.Loader`/`Recorder` поверх Store) - Create: `internal/store/loader_test.go` [Docker] - Create: `cmd/server/main.go` **Interfaces:** - Consumes: `config`, `store`, `pgxpool`, `crypto`, `registry`, `selectel`, `service`, `api` - Produces: - `func (s *Store) LoadDomain(ctx, domainID uuid.UUID) (service.DomainRef, error)` — join domains+accounts+templates → DomainRef - `func (s *Store) SaveCheckRun(ctx, domainID uuid.UUID, cs diff.Changeset) error` - `cmd/server/main.go` — точка входа - [ ] **Step 1: Запрос для загрузки домена** Добавить в `internal/store/queries/domains.sql`: ```sql -- name: LoadDomainFull :one SELECT d.zone_id, a.provider, a.secret_enc, t.doc FROM domains d JOIN provider_accounts a ON a.id = d.provider_account_id LEFT JOIN templates t ON t.id = d.template_id WHERE d.id = $1; ``` Перегенерировать: `sqlc generate`. - [ ] **Step 2: Реализовать Loader/Recorder на Store** `internal/store/loader.go`: ```go package store import ( "context" "encoding/json" "fmt" "github.com/google/uuid" "github.com/vasyakrg/dns-autoresolver/internal/diff" "github.com/vasyakrg/dns-autoresolver/internal/service" "github.com/vasyakrg/dns-autoresolver/internal/store/dto" ) func (s *Store) LoadDomain(ctx context.Context, domainID uuid.UUID) (service.DomainRef, error) { row, err := s.q.LoadDomainFull(ctx, domainID) if err != nil { return service.DomainRef{}, err } var doc dto.TemplateDoc if row.Doc != nil { doc = *row.Doc } else { return service.DomainRef{}, fmt.Errorf("store: domain %s has no template", domainID) } return service.DomainRef{ ZoneID: row.ZoneID, Provider: row.Provider, SecretEnc: row.SecretEnc, Template: doc, }, nil } func (s *Store) SaveCheckRun(ctx context.Context, domainID uuid.UUID, cs diff.Changeset) error { summary := map[string]int{ "updates": len(cs.Updates()), "prunes": len(cs.Prunes()), } raw, err := json.Marshal(summary) if err != nil { return err } _, err = s.q.CreateCheckRun(ctx, dbCreateCheckRunParams(domainID, raw)) return err } ``` > Реализатор: `dbCreateCheckRunParams` — подставьте фактический `db.CreateCheckRunParams{ID: uuid.New(), DomainID: domainID, Result: raw}` (тип `Result` — `[]byte`/`json.RawMessage` согласно сгенерированному `models.go`). Хелпер показан для краткости; используйте прямую структуру. `internal/store/loader_test.go` [Docker]: интеграционный тест — создать account+template+domain, вызвать `LoadDomain`, проверить, что `DomainRef` заполнен (ZoneID, Provider, Template.Records не пусты); вызвать `SaveCheckRun` и убедиться, что строка в `check_runs` появилась. - [ ] **Step 3: main.go** `cmd/server/main.go`: ```go package main import ( "context" "log" "net/http" "github.com/jackc/pgx/v5/pgxpool" "github.com/vasyakrg/dns-autoresolver/internal/api" "github.com/vasyakrg/dns-autoresolver/internal/config" "github.com/vasyakrg/dns-autoresolver/internal/crypto" "github.com/vasyakrg/dns-autoresolver/internal/provider/registry" "github.com/vasyakrg/dns-autoresolver/internal/provider/selectel" "github.com/vasyakrg/dns-autoresolver/internal/service" "github.com/vasyakrg/dns-autoresolver/internal/store" ) func main() { ctx := context.Background() cfg, err := config.Load() if err != nil { log.Fatalf("config: %v", err) } if err := store.Migrate(ctx, cfg.DBDSN); err != nil { log.Fatalf("migrate: %v", err) } pool, err := pgxpool.New(ctx, cfg.DBDSN) if err != nil { log.Fatalf("pool: %v", err) } defer pool.Close() cipher, err := crypto.NewCipher(cfg.EncKey) if err != nil { log.Fatalf("cipher: %v", err) } st := store.New(pool) reg := registry.New() reg.Register(selectel.New()) svc := service.New(st, st, reg, cipher) a := &api.API{Svc: svc} log.Printf("listening on %s", cfg.ListenAddr) if err := http.ListenAndServe(cfg.ListenAddr, api.NewRouter(a)); err != nil { log.Fatal(err) } } ``` - [ ] **Step 4: Собрать и прогнать** Run: `go build ./...` Expected: компилируется весь проект. Run: `go test ./... -v` (нужен Docker для store-тестов) Expected: PASS во всех пакетах. - [ ] **Step 5: Commit** ```bash git add internal/store/loader.go internal/store/loader_test.go internal/store/queries/domains.sql internal/store/db/ cmd/server/ git commit -m "feat(server): Loader/Recorder на Store, wiring cmd/server (config→migrate→pool→api)" ``` --- ## Self-Review - **Spec coverage:** config (T1), crypto AES-GCM (T2), goose-миграции+seed multi-tenant (T3), sqlc+dto JSONB+Repository CRUD (T4), provider registry (T5), service Check/Apply с guard prune (T6), chi REST API check/apply+DTO без секрета (T7), Loader/Recorder+wiring cmd/server (T8). Все пункты spec-секции 1B покрыты. CRUD остальных ресурсов в API описан паттерном (те же sqlc-методы) — реализатор достраивает по образцу accounts. - **Type consistency:** `dto.TemplateDoc{Records []RecordDTO}` и `ToModel/FromModel` едины в T4/T6/T8; `service.DomainRef`, `service.ApplyRequest`, `Loader`/`Recorder` едины в T6/T7/T8; `diff.Changeset.Updates()/Prunes()` (из 1A) — основа guard в T6 и DTO в T7; `provider.Credentials{Secret}` — расшифровка в T6. - **Placeholders:** реального кода-плейсхолдера нет. Три места с пометкой «реализатор» касаются (а) достройки CRUD остальных ресурсов по показанному паттерну, (б) сверки точных имён сгенерированных sqlc-типов с `models.go`, (в) прямой подстановки `db.CreateCheckRunParams` — во всех случаях образец кода приведён, это не «TODO», а точки сверки с кодогенерацией. ## Проверка (end-to-end) 1. Установить Docker (для testcontainers) и sqlc CLI (`go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest`). 2. `sqlc generate` — код в `internal/store/db/` актуален относительно миграций и запросов. 3. `go build ./...` — весь проект компилируется. 4. `go test ./... -v` — все пакеты зелёные (store/loader — на testcontainers). 5. Ручной прогон сервера: задать env `DNS_AR_DB_DSN`, `DNS_AR_ENC_KEY` (base64 32 байта), поднять локальный Postgres, `go run ./cmd/server`; создать учётку Selectel (POST accounts), импортировать зоны, `GET .../check` вернёт разделённый changeset, `POST .../apply {"applyUpdates":true}` не тронет prune-записи; повтор с `{"applyUpdates":true,"applyPrunes":true}` — применит удаления. 6. Убедиться в инварианте безопасности: ответы accounts не содержат поля секрета (grep по ответу).