Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
54 KiB
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, project00000000-0000-0000-0000-000000000002. - Инварианты безопасности:
- Секрет учётки шифруется (AES-256-GCM) перед сохранением; расшифровывается только в
service; никогда не сериализуется в API-ответ. - ENC-ключ обязателен и валиден (len==32) при старте; без него сервис не поднимается.
apply:Changeset.Updates()применяется всегда;Changeset.Prunes()(удаление лишних записей) — только приapplyPrunes=true. По умолчаниюfalse.
- Секрет учётки шифруется (AES-256-GCM) перед сохранением; расшифровывается только в
- 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: Установить зависимости
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)==32func (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) TemplateDoctype 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() *Registryfunc (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) *DomainServicefunc (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.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
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 → DomainReffunc (s *Store) SaveCheckRun(ctx, domainID uuid.UUID, cs diff.Changeset) errorcmd/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)
- Установить Docker (для testcontainers) и sqlc CLI (
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest). sqlc generate— код вinternal/store/db/актуален относительно миграций и запросов.go build ./...— весь проект компилируется.go test ./... -v— все пакеты зелёные (store/loader — на testcontainers).- Ручной прогон сервера: задать 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}— применит удаления. - Убедиться в инварианте безопасности: ответы accounts не содержат поля секрета (grep по ответу).