Files
dns-autoresolver/docs/superpowers/plans/2026-07-03-phase1b-persistence-api.md
T
2026-07-03 13:31:36 +07:00

54 KiB
Raw Blame History

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 60604800.
  • 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: Установить зависимости

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:

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:

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
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:

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:

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
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:

-- +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:

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:

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:

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
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:

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:

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:

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:

-- 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:

-- 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:

-- 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:

-- 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 при отсутствии):

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:

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:

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
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:

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:

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
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:

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:

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
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:

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:

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:

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:

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.Secretcipher.EncryptCreateAccountParams.SecretEnc; в ответе секрет НЕ включается. Тест на «секрет не в ответе» реализатор добавляет вместе с CRUD-хендлером accounts.

  • Step 5: Тесты зелёные

Run: go test ./internal/api/ -v → PASS (check, apply-default-prune-false, bad uuid).

  • Step 6: Commit
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:

-- 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:

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:

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
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 по ответу).