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