docs: план реализации Фазы 3 (расписание, уведомления, метрики)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,499 @@
|
|||||||
|
# Phase 3: Расписание · Уведомления · Метрики — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Frontend-задачи ДОПОЛНИТЕЛЬНО используют superpowers:frontend-design. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** Периодические проверки доменов по расписанию (in-process планировщик), уведомления Telegram/Webhook при смене статуса домена, Prometheus-метрики (`/metrics`). Планировщик только проверяет и уведомляет — apply остаётся ручным.
|
||||||
|
|
||||||
|
**Architecture:** `internal/notify` (Notifier + Telegram/Webhook на `net/http` + Dispatcher с расшифровкой секрета). `internal/metrics` (custom Prometheus registry). `internal/scheduler` (goroutine-тикер → due-проекты → `service.Check` каждого домена → `check_run` + `last_check_status` → при смене статуса `Dispatcher.Send`). REST CRUD расписания/каналов + история под RequireAuth+RequireProjectAccess. Планировщик и `/metrics` поднимаются в `cmd/server`.
|
||||||
|
|
||||||
|
**Tech Stack:** Go, `github.com/prometheus/client_golang`, `net/http` (Telegram Bot API + Webhook), pgx/sqlc, chi; React + TanStack Query; Vitest.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Module `github.com/vasyakrg/dns-autoresolver`. Фронт `web/` (npm). sqlc в `~/go/bin` (`export PATH="$(go env GOPATH)/bin:$PATH"`). Store-тесты — Docker.
|
||||||
|
- Планировщик **НЕ применяет** изменения — только `service.Check` + сохранение результата + уведомление.
|
||||||
|
- **Гранулярность — на проект**: одно расписание на проект (`schedules.project_id UNIQUE`), проверяет все домены проекта.
|
||||||
|
- **Уведомление при СМЕНЕ статуса** домена (`unknown|in_sync|drift|error`) — сравнение с `domains.last_check_status`; при неизменном статусе уведомление НЕ шлётся (идемпотентность).
|
||||||
|
- Статус домена: `len(cs.Actionable())>0` → `drift`, иначе `in_sync`; ошибка `service.Check` → `error`.
|
||||||
|
- Каналы: `telegram` (Bot API `sendMessage`, `bot_token` шифруется `crypto.Cipher`, config `{chat_id}`), `webhook` (HTTP POST JSON, config `{url}`).
|
||||||
|
- `bot_token`/секреты каналов НИКОГДА не в ответах/логах. `/metrics` **публичный**, без секретов/PII (только агрегаты по `status`/`channel`).
|
||||||
|
- Всё scoped по проекту (RequireProjectAccess из Фазы 2 сохраняется; IDOR не переоткрывать).
|
||||||
|
- Секрет учётки/`password_hash` не регрессировать. Каждая задача — зелёные тесты (Go: `go test`; фронт: `npm run test`) и коммит. `[Docker]` — нужен Docker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1 [Docker]: Миграция + store (schedules, channels, domain status)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `internal/store/migrations/0004_schedule_notify.sql`
|
||||||
|
- Create: `internal/store/queries/schedules.sql`, `internal/store/queries/channels.sql`; Modify: `internal/store/queries/domains.sql`
|
||||||
|
- Regenerate `internal/store/db/*`; Modify: `internal/store/tenant.go`
|
||||||
|
- Create: `internal/store/schedule_test.go`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces (методы `*Store`; uuid — google/uuid):
|
||||||
|
- типы `Schedule{ID, ProjectID uuid.UUID; IntervalSeconds int32; Enabled bool; LastRunAt *time.Time}`, `Channel{ID, ProjectID uuid.UUID; Type string; Config json.RawMessage; SecretEnc string; Enabled bool}`
|
||||||
|
- `GetSchedule(ctx, projectID) (Schedule, error)` (нет строки → создать дефолт или ErrNoRows — см. ниже); `UpsertSchedule(ctx, projectID uuid.UUID, interval int32, enabled bool) (Schedule, error)`
|
||||||
|
- `ListDueSchedules(ctx, now time.Time) ([]Schedule, error)` — `enabled AND (last_run_at IS NULL OR last_run_at + interval <= now)`
|
||||||
|
- `TouchScheduleRun(ctx, projectID uuid.UUID, at time.Time) error` — set `last_run_at`
|
||||||
|
- `CreateChannel(ctx, projectID uuid.UUID, ctype string, config json.RawMessage, secretEnc string) (Channel, error)`; `ListChannels(ctx, projectID) ([]Channel, error)`; `GetChannel(ctx, id, projectID) (Channel, error)`; `DeleteChannel(ctx, id, projectID uuid.UUID) error`; `ListEnabledChannels(ctx, projectID) ([]Channel, error)`
|
||||||
|
- `GetDomainStatus(ctx, domainID uuid.UUID) (string, error)`; `SetDomainStatus(ctx, domainID uuid.UUID, status string) error`; `ListDomains` уже есть (добавить `LastCheckStatus` в тип `Domain`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Миграция**
|
||||||
|
|
||||||
|
`internal/store/migrations/0004_schedule_notify.sql`:
|
||||||
|
```sql
|
||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE schedules (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
project_id uuid NOT NULL UNIQUE REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
interval_seconds int NOT NULL DEFAULT 3600,
|
||||||
|
enabled boolean NOT NULL DEFAULT false,
|
||||||
|
last_run_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE notification_channels (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
type text NOT NULL,
|
||||||
|
config jsonb NOT NULL,
|
||||||
|
secret_enc text NOT NULL DEFAULT '',
|
||||||
|
enabled boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
ALTER TABLE domains ADD COLUMN last_check_status text NOT NULL DEFAULT 'unknown';
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE domains DROP COLUMN last_check_status;
|
||||||
|
DROP TABLE notification_channels;
|
||||||
|
DROP TABLE schedules;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Запросы**
|
||||||
|
|
||||||
|
`schedules.sql`:
|
||||||
|
```sql
|
||||||
|
-- name: GetSchedule :one
|
||||||
|
SELECT * FROM schedules WHERE project_id = $1;
|
||||||
|
|
||||||
|
-- name: UpsertSchedule :one
|
||||||
|
INSERT INTO schedules (id, project_id, interval_seconds, enabled)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (project_id) DO UPDATE SET interval_seconds = $3, enabled = $4
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: ListDueSchedules :many
|
||||||
|
SELECT * FROM schedules
|
||||||
|
WHERE enabled AND (last_run_at IS NULL OR last_run_at + (interval_seconds || ' seconds')::interval <= $1);
|
||||||
|
|
||||||
|
-- name: TouchScheduleRun :exec
|
||||||
|
UPDATE schedules SET last_run_at = $2 WHERE project_id = $1;
|
||||||
|
```
|
||||||
|
|
||||||
|
`channels.sql`:
|
||||||
|
```sql
|
||||||
|
-- name: CreateChannel :one
|
||||||
|
INSERT INTO notification_channels (id, project_id, type, config, secret_enc)
|
||||||
|
VALUES ($1, $2, $3, $4, $5) RETURNING *;
|
||||||
|
|
||||||
|
-- name: ListChannels :many
|
||||||
|
SELECT * FROM notification_channels WHERE project_id = $1 ORDER BY created_at;
|
||||||
|
|
||||||
|
-- name: ListEnabledChannels :many
|
||||||
|
SELECT * FROM notification_channels WHERE project_id = $1 AND enabled ORDER BY created_at;
|
||||||
|
|
||||||
|
-- name: GetChannel :one
|
||||||
|
SELECT * FROM notification_channels WHERE id = $1 AND project_id = $2;
|
||||||
|
|
||||||
|
-- name: DeleteChannel :exec
|
||||||
|
DELETE FROM notification_channels WHERE id = $1 AND project_id = $2;
|
||||||
|
```
|
||||||
|
|
||||||
|
`domains.sql` (добавить):
|
||||||
|
```sql
|
||||||
|
-- name: GetDomainStatus :one
|
||||||
|
SELECT last_check_status FROM domains WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: SetDomainStatus :exec
|
||||||
|
UPDATE domains SET last_check_status = $2 WHERE id = $1;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: sqlc generate**
|
||||||
|
|
||||||
|
Run: `export PATH="$(go env GOPATH)/bin:$PATH" && sqlc generate` → `internal/store/db/` обновлён; `go build ./internal/store/db/`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Store-обёртки**
|
||||||
|
|
||||||
|
Добавить в `internal/store/tenant.go` типы `Schedule`/`Channel` и методы из Interfaces. `config` — `json.RawMessage` (jsonb без override → `[]byte`). `last_run_at`/`interval` — сверить сгенерированные типы (`pgtype.Timestamptz`/`int32`), конвертировать. Добавить `LastCheckStatus string` в тип `Domain` и в `toDomain` (ListDomains теперь возвращает статус — обновить `SELECT`/scan, sqlc сам добавит колонку).
|
||||||
|
|
||||||
|
> Реализатор: `GetSchedule` при отсутствии строки — верни `ErrNoRows` (обёртку не создавать); API-слой (Task 5) вернёт дефолт `{interval:3600, enabled:false}`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Интеграционные тесты**
|
||||||
|
|
||||||
|
`internal/store/schedule_test.go` [Docker]: UpsertSchedule (insert затем update того же project — одна строка, поля обновлены); ListDueSchedules (enabled с last_run_at=nil → в выборке; disabled → нет; недавно запущенный (last_run_at=now) с большим интервалом → нет); TouchScheduleRun; CreateChannel/ListChannels/GetChannel/DeleteChannel scoped по project; GetChannel чужого project → ErrNoRows; SetDomainStatus/GetDomainStatus round-trip.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Проверки, коммит**
|
||||||
|
|
||||||
|
Run: `go test ./internal/store/... -v` (Docker); `go build ./...`; `go vet ./...`.
|
||||||
|
```bash
|
||||||
|
git add internal/store/migrations/0004_schedule_notify.sql internal/store/queries/ internal/store/db/ internal/store/tenant.go internal/store/schedule_test.go
|
||||||
|
git commit -m "feat(store): schedules, notification_channels, domain last_check_status + методы"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: `internal/notify` (Notifier + Telegram + Webhook + Dispatcher)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `internal/notify/notify.go`, `internal/notify/telegram.go`, `internal/notify/webhook.go`, `internal/notify/dispatch.go`
|
||||||
|
- Create: `internal/notify/notify_test.go`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces:
|
||||||
|
- `type Event struct { Project, Domain, Status, Summary string; At time.Time }`
|
||||||
|
- `type Notifier interface { Send(ctx context.Context, cfg json.RawMessage, secret string, ev Event) error }`
|
||||||
|
- `type Telegram struct { BaseURL string; HTTP *http.Client }` (BaseURL default `https://api.telegram.org`); реализует Notifier (config `{chat_id}`, secret=bot_token)
|
||||||
|
- `type Webhook struct { HTTP *http.Client }`; реализует Notifier (config `{url}`, secret игнорируется/подпись)
|
||||||
|
- `type ChannelStore interface { ListEnabledChannels(ctx, projectID uuid.UUID) ([]store.Channel, error) }`; `type Decryptor interface { Decrypt(enc string) ([]byte, error) }`
|
||||||
|
- `type Dispatcher struct { ... }`; `func NewDispatcher(store ChannelStore, cipher Decryptor) *Dispatcher` (регистрирует telegram+webhook); `func (*Dispatcher) Send(ctx, projectID uuid.UUID, ev Event) error` — по каждому enabled-каналу проекта выбирает Notifier, расшифровывает secret, шлёт
|
||||||
|
|
||||||
|
- [ ] **Step 1: Падающие тесты (httptest)**
|
||||||
|
|
||||||
|
`internal/notify/notify_test.go`:
|
||||||
|
- Telegram: `Send` шлёт POST на `${BaseURL}/bot${secret}/sendMessage` с `chat_id` из config и текстом (Summary/Domain/Status в теле); сервер 200 → nil; сервер 500 → ошибка.
|
||||||
|
- Webhook: `Send` шлёт POST на `url` из config с JSON Event; 2xx → nil; не-2xx → ошибка.
|
||||||
|
- Dispatcher: мок ChannelStore возвращает 2 канала (telegram+webhook), мок Decryptor; `Send` вызывает оба Notifier (перехват через подменённые BaseURL/URL на httptest); ошибка одного канала не срывает другой (агрегированная ошибка/лог, но обе попытки сделаны).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Реализовать**
|
||||||
|
|
||||||
|
`notify.go` — Event, Notifier, ошибки. `telegram.go`:
|
||||||
|
```go
|
||||||
|
type Telegram struct {
|
||||||
|
BaseURL string
|
||||||
|
HTTP *http.Client
|
||||||
|
}
|
||||||
|
func (t *Telegram) Send(ctx context.Context, cfg json.RawMessage, secret string, ev Event) error {
|
||||||
|
var c struct{ ChatID string `json:"chat_id"` }
|
||||||
|
if err := json.Unmarshal(cfg, &c); err != nil { return err }
|
||||||
|
body, _ := json.Marshal(map[string]string{
|
||||||
|
"chat_id": c.ChatID,
|
||||||
|
"text": fmt.Sprintf("[%s] %s → %s\n%s", ev.Project, ev.Domain, ev.Status, ev.Summary),
|
||||||
|
})
|
||||||
|
url := t.BaseURL + "/bot" + secret + "/sendMessage"
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := t.HTTP.Do(req)
|
||||||
|
if err != nil { return err }
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 { return fmt.Errorf("telegram: status %d", resp.StatusCode) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`webhook.go` — аналогично, POST JSON `ev` на `cfg.url`, не-2xx → ошибка. `dispatch.go`:
|
||||||
|
```go
|
||||||
|
type Dispatcher struct {
|
||||||
|
store ChannelStore
|
||||||
|
cipher Decryptor
|
||||||
|
byType map[string]Notifier
|
||||||
|
}
|
||||||
|
func NewDispatcher(store ChannelStore, cipher Decryptor) *Dispatcher {
|
||||||
|
return &Dispatcher{store: store, cipher: cipher, byType: map[string]Notifier{
|
||||||
|
"telegram": &Telegram{BaseURL: "https://api.telegram.org", HTTP: &http.Client{Timeout: 15 * time.Second}},
|
||||||
|
"webhook": &Webhook{HTTP: &http.Client{Timeout: 15 * time.Second}},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
func (d *Dispatcher) Send(ctx context.Context, projectID uuid.UUID, ev Event) error {
|
||||||
|
channels, err := d.store.ListEnabledChannels(ctx, projectID)
|
||||||
|
if err != nil { return err }
|
||||||
|
var errs []error
|
||||||
|
for _, ch := range channels {
|
||||||
|
n, ok := d.byType[ch.Type]
|
||||||
|
if !ok { continue }
|
||||||
|
secret := ""
|
||||||
|
if ch.SecretEnc != "" {
|
||||||
|
b, err := d.cipher.Decrypt(ch.SecretEnc)
|
||||||
|
if err != nil { errs = append(errs, err); continue }
|
||||||
|
secret = string(b)
|
||||||
|
}
|
||||||
|
if err := n.Send(ctx, ch.Config, secret, ev); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(Для тестов подмени BaseURL/URL — сделай `byType` настраиваемым или экспортируй сеттер; в тесте создавай Dispatcher с httptest-URL напрямую.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Тесты зелёные, коммит**
|
||||||
|
|
||||||
|
Run: `go test ./internal/notify/ -v`; `go build ./...`; `go vet ./...`.
|
||||||
|
```bash
|
||||||
|
git add internal/notify/
|
||||||
|
git commit -m "feat(notify): Telegram/Webhook нотификаторы + Dispatcher по каналам проекта"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: `internal/metrics` (Prometheus)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `internal/metrics/metrics.go`, `internal/metrics/metrics_test.go`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces:
|
||||||
|
- `type Metrics struct { Registry *prometheus.Registry; ChecksTotal *prometheus.CounterVec; CheckDuration prometheus.Histogram; DriftDomains prometheus.Gauge; NotificationsTotal *prometheus.CounterVec }`
|
||||||
|
- `func New() *Metrics` — custom registry + Go/Process collectors + метрики через `promauto.With(reg)`
|
||||||
|
- `func (m *Metrics) Handler() http.Handler` — `promhttp.HandlerFor(m.Registry, promhttp.HandlerOpts{})`
|
||||||
|
- хелперы: `ObserveCheck(status string, dur time.Duration)`, `SetDrift(n int)`, `IncNotification(channel, status string)`
|
||||||
|
|
||||||
|
- [ ] **Step 1: go get prometheus**
|
||||||
|
|
||||||
|
Run: `go get github.com/prometheus/client_golang/prometheus github.com/prometheus/client_golang/prometheus/promauto github.com/prometheus/client_golang/prometheus/promhttp github.com/prometheus/client_golang/prometheus/collectors`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Падающий тест (testutil)**
|
||||||
|
|
||||||
|
`internal/metrics/metrics_test.go`:
|
||||||
|
```go
|
||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMetricsRecord(t *testing.T) {
|
||||||
|
m := New()
|
||||||
|
m.ObserveCheck("drift", 100*time.Millisecond)
|
||||||
|
m.ObserveCheck("in_sync", 50*time.Millisecond)
|
||||||
|
m.IncNotification("telegram", "ok")
|
||||||
|
m.SetDrift(3)
|
||||||
|
|
||||||
|
if got := testutil.ToFloat64(m.ChecksTotal.WithLabelValues("drift")); got != 1 {
|
||||||
|
t.Fatalf("checks drift = %v", got)
|
||||||
|
}
|
||||||
|
if got := testutil.ToFloat64(m.DriftDomains); got != 3 {
|
||||||
|
t.Fatalf("drift gauge = %v", got)
|
||||||
|
}
|
||||||
|
if got := testutil.ToFloat64(m.NotificationsTotal.WithLabelValues("telegram", "ok")); got != 1 {
|
||||||
|
t.Fatalf("notif = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerExposesMetrics(t *testing.T) {
|
||||||
|
m := New()
|
||||||
|
m.ObserveCheck("in_sync", time.Millisecond)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
m.Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/metrics", nil))
|
||||||
|
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "dns_ar_checks_total") {
|
||||||
|
t.Fatalf("metrics not exposed: %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(добавь импорт `net/http/httptest`.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Реализовать metrics.go**
|
||||||
|
|
||||||
|
`New()` — `reg := prometheus.NewRegistry()`; `reg.MustRegister(collectors.NewGoCollector(), collectors.NewProcessCollector(...))`; метрики через `promauto.With(reg).NewCounterVec/NewHistogram/NewGauge` с именами `dns_ar_checks_total{status}`, `dns_ar_check_duration_seconds`, `dns_ar_drift_domains`, `dns_ar_notifications_total{channel,status}`. Хелперы инкрементируют.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Тесты зелёные, коммит**
|
||||||
|
|
||||||
|
Run: `go test ./internal/metrics/ -v`; `go build ./...`; `go vet ./...`.
|
||||||
|
```bash
|
||||||
|
git add internal/metrics/ go.mod go.sum
|
||||||
|
git commit -m "feat(metrics): Prometheus registry (checks/drift/notifications) + /metrics handler"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: REST API — schedule/channels/history (парал. с T2/T3 после T1)
|
||||||
|
|
||||||
|
> Нумерация: T4 (scheduler) зависит от T2/T3, поэтому идёт после. T5 зависит только от T1 — может выполняться параллельно с T2/T3.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `internal/api/schedule_handlers.go`, `internal/api/schedule_test.go`
|
||||||
|
- Modify: `internal/api/api.go` (роуты + интерфейс расширить), `internal/api/tenant_dto.go`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Расширить `TenantStore` (или отдельный интерфейс `ScheduleStore`) методами из T1: GetSchedule/UpsertSchedule, CreateChannel/ListChannels/GetChannel/DeleteChannel, история (`ListCheckRuns(ctx, domainID, projectID) ([]CheckRun, error)` — добавить в T1 при необходимости; или в этой задаче).
|
||||||
|
- Produces хендлеры: `GET/PUT /schedule`, `POST/GET/DELETE /channels`, `POST /channels/{cid}/test`, `GET /domains/{did}/history`; DTO без секрета.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Падающий тест (httptest + мок)**
|
||||||
|
|
||||||
|
`schedule_test.go`:
|
||||||
|
- `GET /schedule` (нет строки → дефолт `{intervalSeconds:3600, enabled:false}`); `PUT /schedule {intervalSeconds, enabled}` → UpsertSchedule; интервал валидируется (>=60).
|
||||||
|
- `POST /channels {type, config, secret}` → CreateChannel (secret шифруется через cipher, config сохраняется); ответ БЕЗ secret. `GET /channels` без секретов. `DELETE`.
|
||||||
|
- `POST /channels/{cid}/test` → Dispatcher/Notifier тест-отправка (мок) → 200/ошибка канала.
|
||||||
|
- `GET /domains/{did}/history` → список check_runs. pid/did из контекста middleware.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Реализовать хендлеры**
|
||||||
|
|
||||||
|
`schedule_handlers.go`: `pid := projectIDFrom(ctx)`. GET schedule → store.GetSchedule (ErrNoRows → дефолт-DTO). PUT → валидировать interval>=60 → UpsertSchedule. channels: POST — `cipher.Encrypt(secret)` → CreateChannel; response без secret (тип `channelResponse{id,type,config,enabled}`). test — загрузить канал, расшифровать, Notifier.Send тестового Event → 200 или 502 с ошибкой (без раскрытия секрета). history — ListCheckRuns (добавить query/метод, если нет).
|
||||||
|
Роуты в `api.go` под существующим `/api/v1/projects/{pid}` (уже под RequireAuth+RequireProjectAccess):
|
||||||
|
```go
|
||||||
|
r.Get("/schedule", a.handleGetSchedule)
|
||||||
|
r.Put("/schedule", a.handlePutSchedule)
|
||||||
|
r.Route("/channels", func(r chi.Router) {
|
||||||
|
r.Post("/", a.handleCreateChannel)
|
||||||
|
r.Get("/", a.handleListChannels)
|
||||||
|
r.Route("/{cid}", func(r chi.Router) {
|
||||||
|
r.Delete("/", a.handleDeleteChannel)
|
||||||
|
r.Post("/test", a.handleTestChannel)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// history — внутри domains/{did}
|
||||||
|
r.Get("/history", a.handleDomainHistory) // под /domains/{did}
|
||||||
|
```
|
||||||
|
`API` получает поля `Schedule ScheduleStore`, `Dispatch *notify.Dispatcher` (или узкий интерфейс для теста).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Тесты зелёные, коммит**
|
||||||
|
|
||||||
|
Run: `go test ./internal/api/ -run 'Schedule|Channel|History' -v`; `go build ./...`; `go vet ./...`.
|
||||||
|
```bash
|
||||||
|
git add internal/api/
|
||||||
|
git commit -m "feat(api): CRUD расписания/каналов + тест-отправка + история проверок"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: `internal/scheduler` (in-process планировщик)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `internal/scheduler/scheduler.go`, `internal/scheduler/scheduler_test.go`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: store (ListDueSchedules/TouchScheduleRun/ListDomains/GetDomainStatus/SetDomainStatus/SaveCheckRun), `service.DomainService` (Check), `notify.Dispatcher`, `metrics.Metrics`
|
||||||
|
- Produces:
|
||||||
|
- узкие интерфейсы `SchedStore` (нужные методы), `Checker { Check(ctx, projectID, domainID uuid.UUID) (diff.Changeset, error) }`, `NotifySender { Send(ctx, projectID uuid.UUID, ev notify.Event) error }`
|
||||||
|
- `type Scheduler struct { ... }`; `func New(store SchedStore, checker Checker, notifier NotifySender, m *metrics.Metrics) *Scheduler`
|
||||||
|
- `func (s *Scheduler) Run(ctx context.Context, tick time.Duration)` — цикл по `time.Ticker`, до `ctx.Done()`
|
||||||
|
- `func (s *Scheduler) RunOnce(ctx context.Context, now time.Time) error` — одна итерация (для теста): due-проекты → домены → Check → статус → notify
|
||||||
|
|
||||||
|
- [ ] **Step 1: Падающий тест (мок, RunOnce)**
|
||||||
|
|
||||||
|
`scheduler_test.go`: мок SchedStore (1 due-проект с 2 домена), мок Checker (домен A → Changeset с Actionable→drift; домен B → пустой→in_sync), мок NotifySender (записывает Events), metrics.New().
|
||||||
|
- `RunOnce`: для домена A статус меняется unknown→drift → Notify отправлен; для B unknown→in_sync → (по политике: in_sync из unknown — уведомлять? реши: уведомлять при ЛЮБОЙ смене, включая →in_sync после drift; из unknown→in_sync — НЕ спамить (первичная синхронизация без тревоги). Тест фиксирует: A (drift) уведомляет, B (in_sync из unknown) — нет). SaveCheckRun вызван для обоих. TouchScheduleRun вызван. Метрики: ChecksTotal drift=1, in_sync=1.
|
||||||
|
- Идемпотентность: повторный RunOnce при неизменном статусе A (drift→drift) → повторного Notify НЕТ.
|
||||||
|
- Ошибка Check домена → статус error, метрика error, уведомление (error — тревожное, уведомлять при смене в error).
|
||||||
|
|
||||||
|
> Политика уведомлений (зафиксировать в реализации и тесте): уведомлять при переходе в `drift` или `error`, и при восстановлении `drift→in_sync` (resolved). Переход `unknown→in_sync` — без уведомления (первичная норма). `unknown→drift`/`unknown→error` — уведомлять.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Реализовать scheduler.go**
|
||||||
|
|
||||||
|
`RunOnce`: `due := store.ListDueSchedules(ctx, now)`; для каждого расписания — `domains := store.ListDomains(ctx, projectID)`; для каждого домена: старт таймера, `cs, err := checker.Check(ctx, projectID, domainID)`; вычислить `newStatus` (err→"error", Actionable>0→"drift", иначе "in_sync"); `m.ObserveCheck(newStatus, dur)`; `prev := store.GetDomainStatus`; `store.SaveCheckRun`; `store.SetDomainStatus(newStatus)`; если `shouldNotify(prev, newStatus)` → `notifier.Send(ctx, projectID, Event{...})` + `m.IncNotification`; после всех доменов проекта — `store.TouchScheduleRun(projectID, now)`; обновить `m.SetDrift(текущее число drift-доменов)`. `shouldNotify`: true если `newStatus∈{drift,error}` и `newStatus!=prev`, или `prev==drift && newStatus==in_sync`. `Run`: `ticker := time.NewTicker(tick)`; loop `select ctx.Done()/ticker.C → RunOnce(ctx, time.Now())` (ошибки логировать, не падать).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Тесты зелёные, коммит**
|
||||||
|
|
||||||
|
Run: `go test ./internal/scheduler/ -v`; `go build ./...`; `go vet ./...`.
|
||||||
|
```bash
|
||||||
|
git add internal/scheduler/
|
||||||
|
git commit -m "feat(scheduler): in-process планировщик проверок + смена статуса + уведомления + метрики"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Wiring cmd/server (планировщик + /metrics + graceful shutdown)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `cmd/server/main.go`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Собирает: `metrics.New()`, `notify.NewDispatcher(st, cipher)`, `scheduler.New(st, svc, dispatcher, m)`; запускает `go scheduler.Run(ctx, tick)`; монтирует `/metrics` (публично, вне `/api/v1`); graceful shutdown (`signal.NotifyContext` + `http.Server.Shutdown`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Реализовать wiring**
|
||||||
|
|
||||||
|
`main.go`: `ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)`; `m := metrics.New()`; `dispatcher := notify.NewDispatcher(st, cipher)`; `sched := scheduler.New(st, svc, dispatcher, m)`; `go sched.Run(ctx, time.Minute)`; собрать общий `http.ServeMux`/обёртку: `/metrics` → `m.Handler()`, `/api/...` → API-роутер (chi), `/` → web (порядок: metrics, api, web). Использовать `http.Server{Handler: root}` + `srv.Shutdown` при `ctx.Done()`. API `api.API{...}` дополнить `Schedule: st, Dispatch: dispatcher`. Инструментировать `service.Check/Apply` метриками (опционально здесь или через обёртку в scheduler — минимально: scheduler уже пишет ObserveCheck).
|
||||||
|
|
||||||
|
> Реализатор: аккуратно с приоритетом путей — `/metrics` и `/api/v1/*` перехватываются до web SPA-fallback (см. `isAPIPath` из Фазы 1C; добавь аналогичный `path=="/metrics"`).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Проверки, коммит**
|
||||||
|
|
||||||
|
Run: `go build ./...`; `go vet ./...`; `go test ./... ` (Docker) — все зелёные; ручной smoke: `curl localhost:8080/metrics` содержит `dns_ar_*` (при локальном запуске).
|
||||||
|
```bash
|
||||||
|
git add cmd/server/main.go internal/api/api.go
|
||||||
|
git commit -m "feat(server): запуск планировщика, /metrics, graceful shutdown"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Frontend — клиент/типы/хуки (schedule, channels, history, статус)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/api/types.ts`, `web/src/api/client.ts`, `web/src/hooks/useApi.ts`
|
||||||
|
- Create/Modify: `web/src/api/client.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Типы: `Schedule{intervalSeconds, enabled}`, `Channel{id,type,config,enabled}`, `CreateChannelInput{type,config,secret}`, `CheckRun{id,createdAt,result}`; `Domain` дополнить `lastCheckStatus`.
|
||||||
|
- `api`: `getSchedule(pid)`, `putSchedule(pid, {intervalSeconds,enabled})`, `listChannels(pid)`, `createChannel(pid, input)`, `deleteChannel(pid, id)`, `testChannel(pid, id)`, `domainHistory(pid, did)`.
|
||||||
|
- Хуки: `useSchedule/useUpdateSchedule`, `useChannels/useCreateChannel/useDeleteChannel/useTestChannel`, `useDomainHistory(did)`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Падающий тест клиента**
|
||||||
|
|
||||||
|
`client.test.ts`: `api.putSchedule(pid,{...})` → PUT `/api/v1/projects/${pid}/schedule`; `api.createChannel(pid,{type,config,secret})` POST с secret в теле; `api.testChannel` POST `/channels/${id}/test`; `api.domainHistory(pid,did)` GET `/domains/${did}/history`. credentials:include.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Реализовать client + types + hooks**
|
||||||
|
|
||||||
|
По образцу существующих (projectId первым аргументом; хуки берут project из useAuth; invalidateQueries). Секрет канала — только на вход.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Тесты зелёные, коммит**
|
||||||
|
|
||||||
|
Run: `cd web && npm run test -- client`; `npx tsc --noEmit`.
|
||||||
|
```bash
|
||||||
|
git add web/src/api web/src/hooks
|
||||||
|
git commit -m "feat(web): API-клиент и хуки расписания/каналов/истории"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Frontend — UI (расписание, каналы, история, drift-badge)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/src/pages/SchedulePage.tsx` (или блок), `web/src/pages/ChannelsPage.tsx`, тесты
|
||||||
|
- Create: `web/src/components/StatusBadge.tsx`, `web/src/components/DomainHistory.tsx`
|
||||||
|
- Modify: `web/src/pages/DomainsPage.tsx` (drift-badge), `web/src/App.tsx` (роуты), `web/src/components/Layout.tsx` (навигация)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes хуки из T7. Produces страницы/компоненты.
|
||||||
|
|
||||||
|
**Дизайн:** используй skill frontend-design (тёмная «technical console», консистентно). `StatusBadge`: in_sync=emerald, drift=amber, error=rose, unknown=muted.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Падающие тесты**
|
||||||
|
|
||||||
|
- `StatusBadge.test`: рендерит правильный цвет/текст по статусу.
|
||||||
|
- `ChannelsPage.test`: список каналов (без секрета), создание (type=telegram → config{chat_id}+secret bot_token; type=webhook → config{url}) вызывает createChannel; удаление; тест-отправка вызывает testChannel; секрет не отображается.
|
||||||
|
- `SchedulePage.test`: показывает интервал/enabled, сохранение вызывает updateSchedule; валидация интервала.
|
||||||
|
- `DomainsPage`: рендерит StatusBadge по domain.lastCheckStatus (адаптировать существующий тест).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Реализовать**
|
||||||
|
|
||||||
|
- `StatusBadge.tsx` — семантический бейдж (mono, цвет по статусу).
|
||||||
|
- `SchedulePage.tsx` — форма интервал (число секунд/выбор) + переключатель enabled → useUpdateSchedule.
|
||||||
|
- `ChannelsPage.tsx` — список + форма создания (переключение telegram/webhook меняет поля config/secret) + удаление + кнопка «Тест». Секрет type=password, только на вход.
|
||||||
|
- `DomainHistory.tsx` — список последних проверок домена (время + сводка) на DomainDiffPage или отдельно.
|
||||||
|
- `DomainsPage.tsx` — колонка StatusBadge (domain.lastCheckStatus).
|
||||||
|
- `App.tsx`/`Layout.tsx` — роуты `/schedule`, `/channels` + навигация (под ProtectedRoute).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Тесты зелёные, сборка, коммит**
|
||||||
|
|
||||||
|
Run: `cd web && npm run test` (весь зелёный); `npx tsc --noEmit`; `npm run build`.
|
||||||
|
```bash
|
||||||
|
git add web/src/pages web/src/components web/src/App.tsx web/src/components/Layout.tsx
|
||||||
|
git commit -m "feat(web): расписание, каналы уведомлений, история проверок, drift-badge"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
- **Spec coverage:** миграция+store (T1), notify Telegram/Webhook+Dispatcher (T2), Prometheus metrics (T3), API schedule/channels/history (T5), scheduler in-process со сменой статуса+уведомлениями+метриками (T4), wiring+/metrics+graceful (T6), фронт клиент/хуки (T7), UI расписание/каналы/история/badge (T8). Все пункты spec-секции Фазы 3 покрыты. Планировщик не применяет (только check+notify). Уведомления идемпотентны по смене статуса.
|
||||||
|
- **Type consistency:** `store.Schedule/Channel`, `Domain.LastCheckStatus` едины (T1→T4→T5); `notify.Event/Notifier/Dispatcher` (T2→T4→T6); `metrics.Metrics` (T3→T4→T6); scheduler узкие интерфейсы SchedStore/Checker/NotifySender (T4); фронт `Schedule/Channel/CheckRun`, `api.*`, хуки (T7→T8). Статусы `unknown|in_sync|drift|error` едины backend/frontend.
|
||||||
|
- **Placeholders:** нет кода-плейсхолдера. Пометки «реализатор» — точки сверки sqlc-типов (pgtype) и настройки httptest-URL нотификаторов; образцы приведены. Политика уведомлений (shouldNotify) явно зафиксирована в T4.
|
||||||
|
|
||||||
|
## Проверка (end-to-end)
|
||||||
|
|
||||||
|
1. `go test ./... -v` (Docker) — все Go-пакеты зелёные (store/scheduler/notify/metrics/api).
|
||||||
|
2. `cd web && npm run test && npx tsc --noEmit && npm run build` — фронт зелёный.
|
||||||
|
3. `make web && go run ./cmd/server` (Postgres+env): войти; включить расписание (интервал 60с); добавить Telegram-канал (bot_token+chat_id) и Webhook; дождаться тика → при дрейфе домена приходит уведомление (смена статуса), повторный тик без изменений — молчит; `curl localhost:8080/metrics` содержит `dns_ar_checks_total`/`dns_ar_drift_domains`; drift-badge в списке доменов отражает статус; история проверок домена заполняется.
|
||||||
|
4. Проверить инварианты: секрет канала/bot_token не в ответах API и не в `/metrics`; планировщик ничего не применяет (зона не меняется без ручного apply).
|
||||||
Reference in New Issue
Block a user