Merge feature/tech-debt-docker: техдолг Фаз 1-3 + docker compose
T1 graceful scheduler shutdown + /healthz + healthcheck-режим; T2 per-channel notification metrics; T3 frontend code-splitting + allowlist; T4 multi-stage Dockerfile (distroless nonroot); T5 docker compose (app+postgres). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.superpowers
|
||||
docs
|
||||
web/node_modules
|
||||
web/dist
|
||||
internal/web/dist/*
|
||||
!internal/web/dist/index.html
|
||||
swarm-report
|
||||
*.md
|
||||
.env
|
||||
@@ -0,0 +1,11 @@
|
||||
# PostgreSQL
|
||||
POSTGRES_USER=dnsar
|
||||
POSTGRES_PASSWORD=change-me-strong-password
|
||||
POSTGRES_DB=dnsar
|
||||
|
||||
# Порт публикации приложения на хосте
|
||||
APP_PORT=8080
|
||||
|
||||
# Ключ шифрования секретов провайдеров/каналов — РОВНО 32 байта в base64.
|
||||
# Сгенерировать: openssl rand -base64 32
|
||||
DNS_AR_ENC_KEY=
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# --- web build ---
|
||||
FROM node:22-alpine AS web
|
||||
WORKDIR /src/web
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
# --- go build ---
|
||||
FROM golang:1.26.4-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
# SPA собрана на web-стадии — кладём в embed-путь ДО go build
|
||||
COPY --from=web /src/web/dist ./internal/web/dist
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/server
|
||||
|
||||
# --- runtime ---
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
COPY --from=build /out/app /app
|
||||
EXPOSE 8080
|
||||
USER nonroot:nonroot
|
||||
ENTRYPOINT ["/app"]
|
||||
@@ -14,3 +14,16 @@ web:
|
||||
|
||||
.PHONY: build-all
|
||||
build-all: web build
|
||||
|
||||
.PHONY: docker-build docker-up docker-down docker-logs
|
||||
docker-build:
|
||||
docker build -t dns-autoresolver:local .
|
||||
|
||||
docker-up:
|
||||
docker compose up -d --build
|
||||
|
||||
docker-down:
|
||||
docker compose down
|
||||
|
||||
docker-logs:
|
||||
docker compose logs -f app
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# DNS Autoresolver
|
||||
|
||||
Утилита автонастройки и проверки DNS-зон: multi-tenant сервис, который сверяет
|
||||
фактическое состояние зоны у провайдера (Selectel DNS API v2) с шаблоном
|
||||
записей, показывает диф и применяет изменения только вручную — никакого
|
||||
автоматического apply без подтверждения оператора.
|
||||
|
||||
## Возможности
|
||||
|
||||
- **Multi-tenant**: проекты, аккаунты провайдера, домены — с авторизацией
|
||||
(регистрация/логин, сессии).
|
||||
- **Провайдер Selectel**: чтение зон/RRSet, диф против шаблона, ручной apply.
|
||||
- **Шаблоны записей**: неймспейс-независимая модель `Record`, движок диффа
|
||||
шаблон ↔ зона.
|
||||
- **Диф + ручной apply**: изменения показываются перед применением, apply —
|
||||
явное действие оператора.
|
||||
- **Расписание проверок**: планировщик периодически гоняет read-only
|
||||
check+notify (без Apply), пишет историю проверок и статус drift.
|
||||
- **Уведомления**: каналы Telegram и Webhook, per-channel статус доставки.
|
||||
- **Метрики**: Prometheus `/metrics` (публичный, без auth, только агрегаты).
|
||||
- **Health-check**: `/healthz` — liveness-проба, используется как
|
||||
Docker `HEALTHCHECK` через встроенный CLI-режим `app -healthcheck`.
|
||||
|
||||
## Стек
|
||||
|
||||
Go 1.26 (statically-linked бинарь, SPA встроена через `embed`), React +
|
||||
Vite (SPA), PostgreSQL 17, Prometheus client, distroless/static-debian12
|
||||
рантайм-образ.
|
||||
|
||||
## Запуск в Docker
|
||||
|
||||
Требуется Docker Engine + Docker Compose v2.
|
||||
|
||||
1. Скопировать пример конфигурации:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. Сгенерировать ключ шифрования секретов (провайдеров/каналов) — ровно
|
||||
32 байта в base64 — и вписать его в `.env` как `DNS_AR_ENC_KEY`:
|
||||
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
Также задать `POSTGRES_PASSWORD` (без дефолта — сервис не поднимется без
|
||||
явного пароля).
|
||||
|
||||
3. Поднять стек (postgres + app), сборка образа приложения — на лету:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
# или: make docker-up
|
||||
```
|
||||
|
||||
`app` стартует только после того, как `postgres` станет healthy;
|
||||
миграции схемы БД приложение накатывает само при старте.
|
||||
|
||||
4. Открыть UI: http://localhost:8080
|
||||
|
||||
- Метрики (Prometheus): http://localhost:8080/metrics
|
||||
- Health-check: http://localhost:8080/healthz
|
||||
|
||||
Остановить стек: `docker compose down` (или `make docker-down`).
|
||||
Логи приложения: `docker compose logs -f app` (или `make docker-logs`).
|
||||
|
||||
### Переменные окружения (`.env`)
|
||||
|
||||
| Переменная | Назначение | По умолчанию |
|
||||
|---------------------|----------------------------------------------------------|--------------|
|
||||
| `POSTGRES_USER` | пользователь PostgreSQL | `dnsar` |
|
||||
| `POSTGRES_PASSWORD` | пароль PostgreSQL — **обязателен**, без дефолта | — |
|
||||
| `POSTGRES_DB` | имя БД | `dnsar` |
|
||||
| `APP_PORT` | порт публикации приложения на хосте | `8080` |
|
||||
| `DNS_AR_ENC_KEY` | ключ шифрования секретов, base64 → ровно 32 байта — **обязателен** | — |
|
||||
|
||||
Секреты передаются только через переменные окружения, никогда — в образ,
|
||||
логи или git.
|
||||
|
||||
## Локальная разработка (без Docker)
|
||||
|
||||
```bash
|
||||
make build # go build ./...
|
||||
make test # go test ./...
|
||||
make web # сборка SPA (npm ci && npm run build) в internal/web/dist
|
||||
make build-all # web + build
|
||||
```
|
||||
|
||||
Для запуска бинаря напрямую нужны те же переменные окружения:
|
||||
`DNS_AR_DB_DSN`, `DNS_AR_ENC_KEY` (обязательные), `DNS_AR_LISTEN`
|
||||
(по умолчанию `:8080`).
|
||||
+68
-15
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -49,7 +50,57 @@ func isAPIPath(path string) bool {
|
||||
return path == "/api" || strings.HasPrefix(path, "/api/")
|
||||
}
|
||||
|
||||
// buildMux wires the public /healthz + /metrics endpoints, the API router,
|
||||
// and the embedded SPA. /healthz and /metrics are intentionally auth-free —
|
||||
// /healthz is a liveness probe (always 200 while the process serves), and
|
||||
// metricsHandler only ever exposes aggregate counters/gauges.
|
||||
func buildMux(metricsHandler http.Handler, apiRouter http.Handler, webHandler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/healthz":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
case r.URL.Path == "/metrics":
|
||||
metricsHandler.ServeHTTP(w, r)
|
||||
case isAPIPath(r.URL.Path):
|
||||
apiRouter.ServeHTTP(w, r)
|
||||
case webHandler != nil:
|
||||
webHandler.ServeHTTP(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// healthcheck performs an in-process liveness probe used as the container
|
||||
// HEALTHCHECK — distroless images have no curl/wget. It GETs /healthz on the
|
||||
// configured listen address and maps 200 -> 0, anything else -> 1.
|
||||
func healthcheck() int {
|
||||
addr := os.Getenv("DNS_AR_LISTEN")
|
||||
if addr == "" {
|
||||
addr = ":8080"
|
||||
}
|
||||
// ":8080" -> "127.0.0.1:8080"
|
||||
if strings.HasPrefix(addr, ":") {
|
||||
addr = "127.0.0.1" + addr
|
||||
}
|
||||
c := &http.Client{Timeout: 3 * time.Second}
|
||||
resp, err := c.Get("http://" + addr + "/healthz")
|
||||
if err != nil {
|
||||
return 1
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "-healthcheck" {
|
||||
os.Exit(healthcheck())
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
@@ -97,22 +148,14 @@ func main() {
|
||||
// internally and never stop the loop; ctx cancellation (signal) is the
|
||||
// only thing that ends Run.
|
||||
sched := scheduler.New(st, svc, dispatcher, m)
|
||||
go sched.Run(ctx, schedulerTick)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
sched.Run(ctx, schedulerTick)
|
||||
}()
|
||||
|
||||
mux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/metrics":
|
||||
// Public by design (no auth) — Metrics.Handler only ever exposes
|
||||
// aggregate counters/gauges, never per-domain or secret data.
|
||||
m.Handler().ServeHTTP(w, r)
|
||||
case isAPIPath(r.URL.Path):
|
||||
apiRouter.ServeHTTP(w, r)
|
||||
case webHandler != nil:
|
||||
webHandler.ServeHTTP(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
mux := buildMux(m.Handler(), apiRouter, webHandler)
|
||||
|
||||
srv := &http.Server{Addr: cfg.ListenAddr, Handler: mux}
|
||||
|
||||
@@ -135,9 +178,19 @@ func main() {
|
||||
log.Printf("server: graceful shutdown failed: %v", err)
|
||||
}
|
||||
<-serveErr
|
||||
// Wait for the in-flight scheduler RunOnce (interrupted by the
|
||||
// cancelled ctx passed into checker.Check) to finish before exiting,
|
||||
// so we never kill the process mid-write of a check/notify status.
|
||||
wg.Wait()
|
||||
log.Printf("server stopped")
|
||||
case err := <-serveErr:
|
||||
if err != nil {
|
||||
// Unwind scheduler.Run via ctx.Done and wait for the in-flight
|
||||
// RunOnce to finish before exiting, so an unexpected serve error
|
||||
// mid-run doesn't kill the process during a check/notify write —
|
||||
// same hazard the ctx.Done branch above already guards against.
|
||||
stop()
|
||||
wg.Wait()
|
||||
log.Fatalf("server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+70
-1
@@ -1,6 +1,10 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsAPIPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
@@ -20,3 +24,68 @@ func TestIsAPIPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMux(t *testing.T) {
|
||||
var metricsHit, apiHit, webHit bool
|
||||
|
||||
metricsHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
metricsHit = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
apiRouter := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
apiHit = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
webHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
webHit = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
mux := buildMux(metricsHandler, apiRouter, webHandler)
|
||||
|
||||
t.Run("healthz returns 200 ok", func(t *testing.T) {
|
||||
metricsHit, apiHit, webHit = false, false, false
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
mux.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
if rr.Body.String() != "ok" {
|
||||
t.Fatalf("body = %q, want %q", rr.Body.String(), "ok")
|
||||
}
|
||||
if metricsHit || apiHit || webHit {
|
||||
t.Fatalf("healthz must not fall through to other handlers")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("metrics routed to metrics handler", func(t *testing.T) {
|
||||
metricsHit, apiHit, webHit = false, false, false
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
mux.ServeHTTP(rr, req)
|
||||
if !metricsHit {
|
||||
t.Fatalf("expected metrics handler to be hit")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("api path routed to api router", func(t *testing.T) {
|
||||
metricsHit, apiHit, webHit = false, false, false
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/domains", nil)
|
||||
mux.ServeHTTP(rr, req)
|
||||
if !apiHit {
|
||||
t.Fatalf("expected api router to be hit")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("other path routed to web handler", func(t *testing.T) {
|
||||
metricsHit, apiHit, webHit = false, false, false
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/domains/xyz", nil)
|
||||
mux.ServeHTTP(rr, req)
|
||||
if !webHit {
|
||||
t.Fatalf("expected web handler to be hit")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-dnsar}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set in .env}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-dnsar}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dnsar} -d ${POSTGRES_DB:-dnsar}"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
app:
|
||||
build: .
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DNS_AR_DB_DSN: postgres://${POSTGRES_USER:-dnsar}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-dnsar}?sslmode=disable
|
||||
DNS_AR_ENC_KEY: ${DNS_AR_ENC_KEY:?base64 32 bytes, see .env.example}
|
||||
DNS_AR_LISTEN: ":8080"
|
||||
ports:
|
||||
- "${APP_PORT:-8080}:8080"
|
||||
healthcheck:
|
||||
test: ["CMD", "/app", "-healthcheck"]
|
||||
interval: 10s
|
||||
timeout: 4s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -0,0 +1,462 @@
|
||||
# Tech-debt + Docker Compose Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
|
||||
|
||||
**Goal:** Закрыть накопленный техдолг Фаз 1-3 и упаковать всё приложение в docker compose (app + postgres), production-ready.
|
||||
|
||||
**Architecture:** Go-бинарь уже встраивает React SPA (`internal/web` embed) и сам гоняет миграции (`store.Migrate`) на старте. Multi-stage Docker образ (node build web → go build с embed → distroless static). compose поднимает postgres + app, app стартует после `postgres` healthy, свой healthcheck — через `-healthcheck` режим того же бинаря (в distroless нет shell/curl).
|
||||
|
||||
**Tech Stack:** Go 1.26.4, distroless/static, node:22-alpine (Vite 8), postgres:17-alpine, docker compose v2.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Секреты только через env: `DNS_AR_DB_DSN`, `DNS_AR_ENC_KEY` (base64, декодит в ровно 32 байта), `DNS_AR_LISTEN` (default `:8080`). Никаких секретов в образ/логи/git.
|
||||
- `/metrics` и `/healthz` — публичные, без auth и без секретов/PII.
|
||||
- Планировщик остаётся read-only (только check+notify); никакой Apply.
|
||||
- Комментарии в Go — на английском (как в существующих файлах), в web — как в окружающих файлах.
|
||||
- НЕ коммитить реальную сборку `internal/web/dist/*` — только плейсхолдер `index.html` (`.gitignore` уже настроен). Перед коммитом: `git checkout internal/web/dist/index.html`.
|
||||
- TDD: тест падает → минимальная реализация → тест проходит → commit.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Graceful scheduler shutdown + /healthz + healthcheck-режим
|
||||
|
||||
**Files:**
|
||||
- Modify: `cmd/server/main.go`
|
||||
- Test: `cmd/server/main_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `/healthz` endpoint → `200 OK` (публичный); `app -healthcheck` → exit 0 если `GET http://127.0.0.1<ListenAddr>/healthz` вернул 200, иначе exit 1; scheduler-горутина дожидается завершения при shutdown.
|
||||
|
||||
Проблемы (из финального ревью Фазы 3): (1) scheduler-горутина запускается через `go sched.Run(ctx, tick)` и при shutdown main не дожидается завершения текущего `RunOnce` — процесс может умереть посреди записи статуса. (2) Нет health-эндпоинта для compose healthcheck. (3) distroless-образ не содержит curl/wget — healthcheck нужен внутри бинаря.
|
||||
|
||||
- [ ] **Step 1: Вынести построение mux в тестируемую функцию + добавить /healthz**
|
||||
|
||||
В `main.go` вынести inline-`mux` в функцию (рядом с `isAPIPath`):
|
||||
|
||||
```go
|
||||
// buildMux wires the public /healthz + /metrics endpoints, the API router,
|
||||
// and the embedded SPA. /healthz and /metrics are intentionally auth-free —
|
||||
// /healthz is a liveness probe (always 200 while the process serves), and
|
||||
// metricsHandler only ever exposes aggregate counters/gauges.
|
||||
func buildMux(metricsHandler http.Handler, apiRouter http.Handler, webHandler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/healthz":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
case r.URL.Path == "/metrics":
|
||||
metricsHandler.ServeHTTP(w, r)
|
||||
case isAPIPath(r.URL.Path):
|
||||
apiRouter.ServeHTTP(w, r)
|
||||
case webHandler != nil:
|
||||
webHandler.ServeHTTP(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Заменить inline-mux на `mux := buildMux(m.Handler(), apiRouter, webHandler)`.
|
||||
|
||||
- [ ] **Step 2: Тест /healthz → 200 и роутинг**
|
||||
|
||||
`cmd/server/main_test.go`: `httptest` через `buildMux` с заглушками (metrics/api/web как `http.HandlerFunc`, помечающие вызов). Проверить: `GET /healthz` → 200 body "ok"; `GET /metrics` → уходит в metricsHandler; `GET /api/...` → apiRouter; `GET /домен-SPA` → webHandler. Run: `go test ./cmd/server/... -run Mux -v`. Ожидание: PASS.
|
||||
|
||||
- [ ] **Step 3: healthcheck CLI-режим**
|
||||
|
||||
В начале `main()` (до всего остального), обработать флаг:
|
||||
|
||||
```go
|
||||
if len(os.Args) > 1 && os.Args[1] == "-healthcheck" {
|
||||
os.Exit(healthcheck())
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// healthcheck performs an in-process liveness probe used as the container
|
||||
// HEALTHCHECK — distroless images have no curl/wget. It GETs /healthz on the
|
||||
// configured listen address and maps 200 -> 0, anything else -> 1.
|
||||
func healthcheck() int {
|
||||
addr := os.Getenv("DNS_AR_LISTEN")
|
||||
if addr == "" {
|
||||
addr = ":8080"
|
||||
}
|
||||
// ":8080" -> "127.0.0.1:8080"
|
||||
if strings.HasPrefix(addr, ":") {
|
||||
addr = "127.0.0.1" + addr
|
||||
}
|
||||
c := &http.Client{Timeout: 3 * time.Second}
|
||||
resp, err := c.Get("http://" + addr + "/healthz")
|
||||
if err != nil {
|
||||
return 1
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: WaitGroup для scheduler-горутины**
|
||||
|
||||
Заменить `go sched.Run(ctx, schedulerTick)` на:
|
||||
|
||||
```go
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
sched.Run(ctx, schedulerTick)
|
||||
}()
|
||||
```
|
||||
|
||||
В ветке `case <-ctx.Done():` после `<-serveErr` (или после `srv.Shutdown`) добавить `wg.Wait()` — дождаться, что текущий `RunOnce` завершился (он прервётся по отменённому `ctx`, переданному в `checker.Check`), прежде чем логировать "server stopped" и выходить.
|
||||
|
||||
- [ ] **Step 5: Прогон и коммит**
|
||||
|
||||
Run: `go build ./... && go test ./cmd/server/...`. Ожидание: PASS.
|
||||
```bash
|
||||
git add cmd/server/
|
||||
git commit -m "feat(server): graceful scheduler shutdown, /healthz, healthcheck mode"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Per-channel notification metrics
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/notify/dispatch.go`, `internal/scheduler/scheduler.go`
|
||||
- Test: `internal/notify/notify_test.go`, `internal/scheduler/scheduler_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `notify.ChannelResult{Type string, Err error}`; `Dispatcher.Send(ctx, projectID, ev) ([]ChannelResult, error)`; scheduler `NotifySender.Send` с той же сигнатурой; `IncNotification(ch.Type, "sent"|"failed")` по каждому каналу.
|
||||
|
||||
Проблема (финальное ревью): `scheduler.go` делает `IncNotification("dispatch", newStatus)` — один placeholder-инкремент безусловно, даже при полном провале доставки. Метрика `NotificationsTotal` — `CounterVec{channel,status}`, но заполняется мусором.
|
||||
|
||||
- [ ] **Step 1: Тест Dispatcher.Send возвращает per-channel результаты**
|
||||
|
||||
`internal/notify/notify_test.go`: фейковый `ChannelStore` возвращает 2 канала (telegram ok, webhook — Notifier ошибается). Собрать `Dispatcher` с подменёнными `byType`. Проверить: `results` длиной 2, `results[telegram].Err == nil`, `results[webhook].Err != nil`; агрегированная ошибка `!= nil`. Run: `go test ./internal/notify/... -run Dispatch -v`. Ожидание FAIL (сигнатура старая).
|
||||
|
||||
- [ ] **Step 2: Изменить Dispatcher.Send**
|
||||
|
||||
```go
|
||||
// ChannelResult is the per-channel delivery outcome, so callers can record
|
||||
// success/failure metrics per channel type instead of one aggregate blob.
|
||||
type ChannelResult struct {
|
||||
Type string
|
||||
Err error
|
||||
}
|
||||
```
|
||||
|
||||
`Send` теперь:
|
||||
```go
|
||||
func (d *Dispatcher) Send(ctx context.Context, projectID uuid.UUID, ev Event) ([]ChannelResult, error) {
|
||||
channels, err := d.store.ListEnabledChannels(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var results []ChannelResult
|
||||
var errs []error
|
||||
for _, ch := range channels {
|
||||
n, ok := d.byType[ch.Type]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
secret := ""
|
||||
if ch.SecretEnc != "" {
|
||||
b, derr := d.cipher.Decrypt(ch.SecretEnc)
|
||||
if derr != nil {
|
||||
errs = append(errs, derr)
|
||||
results = append(results, ChannelResult{Type: ch.Type, Err: derr})
|
||||
continue
|
||||
}
|
||||
secret = string(b)
|
||||
}
|
||||
serr := n.Send(ctx, ch.Config, secret, ev)
|
||||
if serr != nil {
|
||||
errs = append(errs, serr)
|
||||
}
|
||||
results = append(results, ChannelResult{Type: ch.Type, Err: serr})
|
||||
}
|
||||
return results, errors.Join(errs...)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Обновить scheduler**
|
||||
|
||||
`NotifySender` интерфейс:
|
||||
```go
|
||||
type NotifySender interface {
|
||||
Send(ctx context.Context, projectID uuid.UUID, ev notify.Event) ([]notify.ChannelResult, error)
|
||||
}
|
||||
```
|
||||
|
||||
В `checkDomain` заменить блок `IncNotification("dispatch", newStatus)`:
|
||||
```go
|
||||
results, err := s.notifier.Send(ctx, projectID, ev)
|
||||
if err != nil {
|
||||
log.Printf("scheduler: notify send for project %s domain %s failed: %v", projectID, d.ID, err)
|
||||
}
|
||||
for _, r := range results {
|
||||
status := "sent"
|
||||
if r.Err != nil {
|
||||
status = "failed"
|
||||
}
|
||||
s.metrics.IncNotification(r.Type, status)
|
||||
}
|
||||
```
|
||||
|
||||
Обновить фейковый `NotifySender` в `scheduler_test.go` под новую сигнатуру.
|
||||
|
||||
- [ ] **Step 4: Тест scheduler per-channel Inc**
|
||||
|
||||
В `scheduler_test.go` тест: фейк возвращает `[]notify.ChannelResult{{Type:"telegram"},{Type:"webhook",Err:errors.New("x")}}` при переходе `unknown→drift`; проверить через `testutil.ToFloat64(m.NotificationsTotal.WithLabelValues("telegram","sent"))==1` и `("webhook","failed")==1`.
|
||||
|
||||
- [ ] **Step 5: Прогон и коммит**
|
||||
|
||||
Run: `go build ./... && go test ./internal/notify/... ./internal/scheduler/...`. Ожидание PASS.
|
||||
```bash
|
||||
git add internal/notify/ internal/scheduler/
|
||||
git commit -m "feat(notify): per-channel delivery results + accurate notification metrics"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Frontend code-splitting + formatConfig allowlist
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/App.tsx`, `web/src/pages/ChannelsPage.tsx`
|
||||
- Test: `web/src/pages/ChannelsPage.test.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: маршрутные страницы грузятся лениво (`React.lazy` + `Suspense`), Vite бьёт бандл на per-route чанки; `formatConfig` печатает только whitelisted-поля по типу канала.
|
||||
|
||||
- [ ] **Step 1: code-splitting в App.tsx**
|
||||
|
||||
Заменить статические импорты страниц на `lazy`:
|
||||
```tsx
|
||||
import { lazy, Suspense } from "react"
|
||||
// ... оставить статичными только auth/лейаут-обёртки, которые нужны сразу:
|
||||
const DomainsPage = lazy(() => import("@/pages/DomainsPage").then(m => ({ default: m.DomainsPage })))
|
||||
const DomainDiffPage = lazy(() => import("@/pages/DomainDiffPage").then(m => ({ default: m.DomainDiffPage })))
|
||||
const AccountsPage = lazy(() => import("@/pages/AccountsPage").then(m => ({ default: m.AccountsPage })))
|
||||
const TemplatesPage = lazy(() => import("@/pages/TemplatesPage").then(m => ({ default: m.TemplatesPage })))
|
||||
const SchedulePage = lazy(() => import("@/pages/SchedulePage").then(m => ({ default: m.SchedulePage })))
|
||||
const ChannelsPage = lazy(() => import("@/pages/ChannelsPage").then(m => ({ default: m.ChannelsPage })))
|
||||
```
|
||||
(Страницы экспортируются именованно — `.then` разворачивает в `default`.) Обернуть `<Routes>` в `<Suspense fallback={<div className="p-6 text-muted-foreground">Загрузка…</div>}>`. `LoginPage`/`RegisterPage` можно оставить статичными (нужны на первом экране).
|
||||
|
||||
- [ ] **Step 2: formatConfig allowlist в ChannelsPage.tsx**
|
||||
|
||||
Заменить `Object.entries(config)` на явный вайтлист по типу канала (тип канала есть в `c.type` на строке рендера `{formatConfig(c.config)}` — передать его):
|
||||
```tsx
|
||||
// Печатаем ТОЛЬКО известные несекретные поля по типу канала — чтобы новый
|
||||
// тип канала с чувствительным полем в config не «протёк» в DOM автоматически.
|
||||
function formatConfig(type: string, config: object): string {
|
||||
const c = config as Record<string, unknown>
|
||||
if (type === "telegram") return c.chat_id ? `chat_id: ${String(c.chat_id)}` : ""
|
||||
if (type === "webhook") return c.url ? `url: ${String(c.url)}` : ""
|
||||
return ""
|
||||
}
|
||||
```
|
||||
Обновить вызов: `{formatConfig(c.type, c.config)}`.
|
||||
|
||||
- [ ] **Step 3: Тест allowlist**
|
||||
|
||||
В `ChannelsPage.test.tsx` добавить/расширить: канал с `config` содержащим лишнее поле (напр. `{ url: "https://x", token: "LEAK" }`) — в отрендеренном списке есть `url: https://x` и НЕТ `LEAK`. Прогнать существующие secret-тесты.
|
||||
|
||||
- [ ] **Step 4: Прогон и коммит**
|
||||
|
||||
Run: `cd web && npm run test -- --run && npx tsc --noEmit && npm run build`. Ожидание: тесты PASS, tsc чисто, build бьёт на несколько чанков.
|
||||
```bash
|
||||
cd .. && git checkout internal/web/dist/index.html
|
||||
git add web/src/
|
||||
git commit -m "perf(web): route-level code-splitting; harden channel config rendering"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Multi-stage Dockerfile
|
||||
|
||||
**Files:**
|
||||
- Create: `Dockerfile`, `.dockerignore`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: образ `dns-autoresolver` — statically-linked бинарь со встроенным SPA, entrypoint `/app`, слушает `:8080`, non-root.
|
||||
|
||||
- [ ] **Step 1: Dockerfile (3 стадии)**
|
||||
|
||||
```dockerfile
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# --- web build ---
|
||||
FROM node:22-alpine AS web
|
||||
WORKDIR /src/web
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
# --- go build ---
|
||||
FROM golang:1.26.4-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
# SPA собрана на предыдущей стадии — кладём в embed-путь ДО go build
|
||||
RUN rm -rf internal/web/dist && cp -r /src/web-dist internal/web/dist || true
|
||||
COPY --from=web /src/web/dist ./internal/web/dist
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/server
|
||||
|
||||
# --- runtime ---
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
COPY --from=build /out/app /app
|
||||
EXPOSE 8080
|
||||
USER nonroot:nonroot
|
||||
ENTRYPOINT ["/app"]
|
||||
```
|
||||
(Строку `RUN rm -rf ... || true` убрать — оставить только `COPY --from=web`. Порядок: сначала `COPY . .`, затем перезапись `internal/web/dist` из web-стадии, затем build.)
|
||||
|
||||
Финальный порядок go-стадии:
|
||||
```dockerfile
|
||||
COPY . .
|
||||
COPY --from=web /src/web/dist ./internal/web/dist
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/server
|
||||
```
|
||||
|
||||
- [ ] **Step 2: .dockerignore**
|
||||
|
||||
```
|
||||
.git
|
||||
.superpowers
|
||||
docs
|
||||
web/node_modules
|
||||
web/dist
|
||||
internal/web/dist/assets
|
||||
swarm-report
|
||||
*.md
|
||||
.env
|
||||
```
|
||||
(Плейсхолдер `internal/web/dist/index.html` оставить — он нужен как валидный embed-таргет до перезаписи; игнорируем только `assets`.)
|
||||
|
||||
- [ ] **Step 3: Проверка сборки**
|
||||
|
||||
Run: `docker build -t dns-autoresolver:local .`. Ожидание: образ собирается, финальный слой distroless. (Если docker недоступен в среде — зафиксировать в отчёте, синтаксис проверить визуально.)
|
||||
|
||||
- [ ] **Step 4: Коммит**
|
||||
|
||||
```bash
|
||||
git add Dockerfile .dockerignore
|
||||
git commit -m "build: multi-stage Dockerfile (node build -> go embed -> distroless)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: docker compose + .env.example + Makefile + docs
|
||||
|
||||
**Files:**
|
||||
- Create: `docker-compose.yml`, `.env.example`
|
||||
- Modify: `Makefile`, `README.md` (создать, если нет)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: образ из Task 4; `/healthz` и `-healthcheck` из Task 1; env-контракт из config.
|
||||
- Produces: `docker compose up` поднимает postgres + app, app стартует после healthy-postgres, миграции применяются самим app.
|
||||
|
||||
- [ ] **Step 1: docker-compose.yml**
|
||||
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-dnsar}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set in .env}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-dnsar}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dnsar} -d ${POSTGRES_DB:-dnsar}"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
app:
|
||||
build: .
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DNS_AR_DB_DSN: postgres://${POSTGRES_USER:-dnsar}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-dnsar}?sslmode=disable
|
||||
DNS_AR_ENC_KEY: ${DNS_AR_ENC_KEY:?base64 32 bytes, see .env.example}
|
||||
DNS_AR_LISTEN: ":8080"
|
||||
ports:
|
||||
- "${APP_PORT:-8080}:8080"
|
||||
healthcheck:
|
||||
test: ["CMD", "/app", "-healthcheck"]
|
||||
interval: 10s
|
||||
timeout: 4s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
```
|
||||
|
||||
- [ ] **Step 2: .env.example**
|
||||
|
||||
```dotenv
|
||||
# PostgreSQL
|
||||
POSTGRES_USER=dnsar
|
||||
POSTGRES_PASSWORD=change-me-strong-password
|
||||
POSTGRES_DB=dnsar
|
||||
|
||||
# Порт публикации приложения на хосте
|
||||
APP_PORT=8080
|
||||
|
||||
# Ключ шифрования секретов провайдеров/каналов — РОВНО 32 байта в base64.
|
||||
# Сгенерировать: openssl rand -base64 32
|
||||
DNS_AR_ENC_KEY=
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Makefile targets**
|
||||
|
||||
Добавить:
|
||||
```makefile
|
||||
.PHONY: docker-build docker-up docker-down docker-logs
|
||||
docker-build:
|
||||
docker build -t dns-autoresolver:local .
|
||||
|
||||
docker-up:
|
||||
docker compose up -d --build
|
||||
|
||||
docker-down:
|
||||
docker compose down
|
||||
|
||||
docker-logs:
|
||||
docker compose logs -f app
|
||||
```
|
||||
|
||||
- [ ] **Step 4: README раздел «Запуск в Docker»**
|
||||
|
||||
Создать/дополнить `README.md`: краткое описание проекта + раздел с шагами `cp .env.example .env`, генерация `DNS_AR_ENC_KEY` (`openssl rand -base64 32`), `docker compose up -d`, доступ на `http://localhost:8080`, метрики `http://localhost:8080/metrics`, healthcheck `/healthz`.
|
||||
|
||||
- [ ] **Step 5: Коммит**
|
||||
|
||||
```bash
|
||||
git add docker-compose.yml .env.example Makefile README.md
|
||||
git commit -m "build: docker compose (app + postgres) with healthchecks and .env"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Итоговая проверка (после всех задач)
|
||||
|
||||
- `go build ./... && go test ./...` — всё PASS.
|
||||
- `cd web && npm run test -- --run && npm run build` — PASS, бандл разбит на чанки.
|
||||
- `docker compose config` — валиден; (при доступном Docker) `docker compose up -d` → app healthy, UI открывается.
|
||||
- Секретов нет в git/образе/логах; `/healthz` и `/metrics` без auth и PII.
|
||||
@@ -49,14 +49,23 @@ func NewDispatcher(store ChannelStore, cipher Decryptor) *Dispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
// ChannelResult is the per-channel delivery outcome, so callers can record
|
||||
// success/failure metrics per channel type instead of one aggregate blob.
|
||||
type ChannelResult struct {
|
||||
Type string
|
||||
Err error
|
||||
}
|
||||
|
||||
// Send delivers ev to every enabled channel of projectID. Errors from
|
||||
// individual channels are aggregated (via errors.Join) rather than aborting
|
||||
// delivery to the remaining channels.
|
||||
func (d *Dispatcher) Send(ctx context.Context, projectID uuid.UUID, ev Event) error {
|
||||
// delivery to the remaining channels; the per-channel outcome is also
|
||||
// returned so callers can record accurate per-channel/status metrics.
|
||||
func (d *Dispatcher) Send(ctx context.Context, projectID uuid.UUID, ev Event) ([]ChannelResult, error) {
|
||||
channels, err := d.store.ListEnabledChannels(ctx, projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
var results []ChannelResult
|
||||
var errs []error
|
||||
for _, ch := range channels {
|
||||
n, ok := d.byType[ch.Type]
|
||||
@@ -65,18 +74,21 @@ func (d *Dispatcher) Send(ctx context.Context, projectID uuid.UUID, ev Event) er
|
||||
}
|
||||
secret := ""
|
||||
if ch.SecretEnc != "" {
|
||||
b, err := d.cipher.Decrypt(ch.SecretEnc)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
b, derr := d.cipher.Decrypt(ch.SecretEnc)
|
||||
if derr != nil {
|
||||
errs = append(errs, derr)
|
||||
results = append(results, ChannelResult{Type: ch.Type, Err: derr})
|
||||
continue
|
||||
}
|
||||
secret = string(b)
|
||||
}
|
||||
if err := n.Send(ctx, ch.Config, secret, ev); err != nil {
|
||||
errs = append(errs, err)
|
||||
serr := n.Send(ctx, ch.Config, secret, ev)
|
||||
if serr != nil {
|
||||
errs = append(errs, serr)
|
||||
}
|
||||
results = append(results, ChannelResult{Type: ch.Type, Err: serr})
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
return results, errors.Join(errs...)
|
||||
}
|
||||
|
||||
// SendTest sends a single synthetic Event directly through the Notifier for
|
||||
|
||||
@@ -3,6 +3,7 @@ package notify
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -330,7 +331,7 @@ func TestDispatcherSendsToAllChannelsAndAggregatesErrors(t *testing.T) {
|
||||
d.byType["webhook"] = &Webhook{HTTP: whSrv.Client(), allowPrivate: true}
|
||||
|
||||
ev := Event{Project: "proj", Domain: "example.com", Status: "drift", Summary: "changed", At: time.Now()}
|
||||
err := d.Send(context.Background(), projectID, ev)
|
||||
results, err := d.Send(context.Background(), projectID, ev)
|
||||
|
||||
if !tgCalled {
|
||||
t.Error("expected telegram notifier to be called")
|
||||
@@ -344,6 +345,54 @@ func TestDispatcherSendsToAllChannelsAndAggregatesErrors(t *testing.T) {
|
||||
if tgSecret != "decrypted-enc-token" {
|
||||
t.Fatalf("expected decrypted secret to be passed to telegram, got %q", tgSecret)
|
||||
}
|
||||
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("results = %d, want 2", len(results))
|
||||
}
|
||||
byType := make(map[string]ChannelResult, len(results))
|
||||
for _, r := range results {
|
||||
byType[r.Type] = r
|
||||
}
|
||||
if tg, ok := byType["telegram"]; !ok || tg.Err != nil {
|
||||
t.Fatalf("telegram result = %+v, want ok result", tg)
|
||||
}
|
||||
if wh, ok := byType["webhook"]; !ok || wh.Err == nil {
|
||||
t.Fatalf("webhook result = %+v, want error result", wh)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatcherSendReturnsPerChannelResults exercises the exact scenario
|
||||
// from the plan: one telegram channel succeeding, one webhook channel
|
||||
// failing at the Notifier — the metric consumer (scheduler) needs a result
|
||||
// per channel, not one aggregate blob, to record accurate per-channel/status
|
||||
// metrics.
|
||||
func TestDispatcherSendReturnsPerChannelResults(t *testing.T) {
|
||||
projectID := uuid.New()
|
||||
channels := []store.Channel{
|
||||
{ID: uuid.New(), ProjectID: projectID, Type: "telegram", Config: json.RawMessage(`{"chat_id":"1"}`), Enabled: true},
|
||||
{ID: uuid.New(), ProjectID: projectID, Type: "webhook", Config: json.RawMessage(`{"url":"http://x"}`), Enabled: true},
|
||||
}
|
||||
d := NewDispatcher(&mockChannelStore{channels: channels}, &mockDecryptor{})
|
||||
d.byType["telegram"] = notifierFunc(func(ctx context.Context, cfg json.RawMessage, secret string, ev Event) error {
|
||||
return nil
|
||||
})
|
||||
d.byType["webhook"] = notifierFunc(func(ctx context.Context, cfg json.RawMessage, secret string, ev Event) error {
|
||||
return errBoom
|
||||
})
|
||||
|
||||
results, err := d.Send(context.Background(), projectID, Event{Project: "p", Domain: "d", Status: "drift"})
|
||||
if err == nil {
|
||||
t.Fatal("expected aggregated error because webhook failed")
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("results = %d, want 2", len(results))
|
||||
}
|
||||
if results[0].Type != "telegram" || results[0].Err != nil {
|
||||
t.Fatalf("results[0] = %+v, want telegram/nil", results[0])
|
||||
}
|
||||
if results[1].Type != "webhook" || results[1].Err == nil {
|
||||
t.Fatalf("results[1] = %+v, want webhook/error", results[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcherSkipsUnknownChannelType(t *testing.T) {
|
||||
@@ -352,9 +401,13 @@ func TestDispatcherSkipsUnknownChannelType(t *testing.T) {
|
||||
{ID: uuid.New(), ProjectID: projectID, Type: "carrier-pigeon", Config: json.RawMessage(`{}`), Enabled: true},
|
||||
}
|
||||
d := NewDispatcher(&mockChannelStore{channels: channels}, &mockDecryptor{})
|
||||
if err := d.Send(context.Background(), projectID, Event{Project: "p", Domain: "d", Status: "drift"}); err != nil {
|
||||
results, err := d.Send(context.Background(), projectID, Event{Project: "p", Domain: "d", Status: "drift"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for unknown channel type: %v", err)
|
||||
}
|
||||
if len(results) != 0 {
|
||||
t.Fatalf("results = %d, want 0: unknown channel type must not produce a result", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcherDecryptFailureIsAggregatedNotFatal(t *testing.T) {
|
||||
@@ -375,13 +428,38 @@ func TestDispatcherDecryptFailureIsAggregatedNotFatal(t *testing.T) {
|
||||
// default; swap in an allowPrivate webhook so this test can still hit it.
|
||||
d.byType["webhook"] = &Webhook{HTTP: whSrv.Client(), allowPrivate: true}
|
||||
|
||||
err := d.Send(context.Background(), projectID, Event{Project: "p", Domain: "d", Status: "drift"})
|
||||
results, err := d.Send(context.Background(), projectID, Event{Project: "p", Domain: "d", Status: "drift"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error due to decrypt failure")
|
||||
}
|
||||
if !whCalled {
|
||||
t.Error("expected webhook channel to still be attempted after telegram decrypt failure")
|
||||
}
|
||||
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("results = %d, want 2", len(results))
|
||||
}
|
||||
byType := make(map[string]ChannelResult, len(results))
|
||||
for _, r := range results {
|
||||
byType[r.Type] = r
|
||||
}
|
||||
tg, ok := byType["telegram"]
|
||||
if !ok {
|
||||
t.Fatal("expected a telegram result")
|
||||
}
|
||||
if tg.Err == nil {
|
||||
t.Fatalf("telegram result = %+v, want decrypt error", tg)
|
||||
}
|
||||
if !errors.Is(tg.Err, errBoom) {
|
||||
t.Fatalf("telegram result err = %v, want errBoom", tg.Err)
|
||||
}
|
||||
wh, ok := byType["webhook"]
|
||||
if !ok {
|
||||
t.Fatal("expected a webhook result")
|
||||
}
|
||||
if wh.Err != nil {
|
||||
t.Fatalf("webhook result = %+v, want ok result (decrypt failure on telegram must not fail webhook)", wh)
|
||||
}
|
||||
}
|
||||
|
||||
// notifierFunc adapts a function to the Notifier interface for tests.
|
||||
|
||||
@@ -49,7 +49,7 @@ type Checker interface {
|
||||
// NotifySender delivers a status-change event to a project's notification
|
||||
// channels. internal/notify.Dispatcher satisfies this.
|
||||
type NotifySender interface {
|
||||
Send(ctx context.Context, projectID uuid.UUID, ev notify.Event) error
|
||||
Send(ctx context.Context, projectID uuid.UUID, ev notify.Event) ([]notify.ChannelResult, error)
|
||||
}
|
||||
|
||||
// Scheduler drives periodic domain checks for every due project schedule.
|
||||
@@ -172,10 +172,17 @@ func (s *Scheduler) checkDomain(ctx context.Context, projectID uuid.UUID, d stor
|
||||
Summary: summarize(newStatus, cs, checkErr),
|
||||
At: now,
|
||||
}
|
||||
if err := s.notifier.Send(ctx, projectID, ev); err != nil {
|
||||
results, err := s.notifier.Send(ctx, projectID, ev)
|
||||
if err != nil {
|
||||
log.Printf("scheduler: notify send for project %s domain %s failed: %v", projectID, d.ID, err)
|
||||
}
|
||||
s.metrics.IncNotification("dispatch", newStatus)
|
||||
for _, r := range results {
|
||||
status := "sent"
|
||||
if r.Err != nil {
|
||||
status = "failed"
|
||||
}
|
||||
s.metrics.IncNotification(r.Type, status)
|
||||
}
|
||||
}
|
||||
|
||||
return newStatus
|
||||
|
||||
@@ -94,17 +94,21 @@ func (c *mockChecker) Check(ctx context.Context, projectID, domainID uuid.UUID)
|
||||
return c.results[domainID], nil
|
||||
}
|
||||
|
||||
// mockNotifier records every Event it is asked to Send.
|
||||
// mockNotifier records every Event it is asked to Send, and returns a
|
||||
// canned set of per-channel results (results defaults to nil, i.e. no
|
||||
// channels) so tests can assert on scheduler.checkDomain's per-channel
|
||||
// metric recording.
|
||||
type mockNotifier struct {
|
||||
mu sync.Mutex
|
||||
events []notify.Event
|
||||
mu sync.Mutex
|
||||
events []notify.Event
|
||||
results []notify.ChannelResult
|
||||
}
|
||||
|
||||
func (n *mockNotifier) Send(ctx context.Context, projectID uuid.UUID, ev notify.Event) error {
|
||||
func (n *mockNotifier) Send(ctx context.Context, projectID uuid.UUID, ev notify.Event) ([]notify.ChannelResult, error) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.events = append(n.events, ev)
|
||||
return nil
|
||||
return n.results, nil
|
||||
}
|
||||
|
||||
func (n *mockNotifier) count() int {
|
||||
@@ -291,6 +295,45 @@ func TestRunOnce_SkipsDomainWithoutTemplate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunOnce_RecordsPerChannelNotificationMetrics exercises the fix for the
|
||||
// "IncNotification('dispatch', newStatus) unconditionally" bug: the
|
||||
// scheduler must record one NotificationsTotal increment per channel result,
|
||||
// labeled by that channel's actual type and its actual sent/failed outcome
|
||||
// — not a single "dispatch" placeholder blind to per-channel delivery.
|
||||
func TestRunOnce_RecordsPerChannelNotificationMetrics(t *testing.T) {
|
||||
projectID := uuid.New()
|
||||
templateID := uuid.New()
|
||||
domainA := store.Domain{ID: uuid.New(), ProjectID: projectID, TemplateID: &templateID}
|
||||
|
||||
st := newMockStore()
|
||||
st.schedules = []store.Schedule{{ID: uuid.New(), ProjectID: projectID, IntervalSeconds: 3600, Enabled: true}}
|
||||
st.domains[projectID] = []store.Domain{domainA}
|
||||
|
||||
checker := &mockChecker{
|
||||
results: map[uuid.UUID]diff.Changeset{domainA.ID: driftChangeset()},
|
||||
}
|
||||
notifier := &mockNotifier{
|
||||
results: []notify.ChannelResult{
|
||||
{Type: "telegram"},
|
||||
{Type: "webhook", Err: errors.New("x")},
|
||||
},
|
||||
}
|
||||
m := metrics.New()
|
||||
sched := New(st, checker, notifier, m)
|
||||
|
||||
// unknown -> drift: shouldNotify is true, so notifier.Send fires once.
|
||||
if err := sched.RunOnce(context.Background(), time.Now()); err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
|
||||
if got := testutil.ToFloat64(m.NotificationsTotal.WithLabelValues("telegram", "sent")); got != 1 {
|
||||
t.Fatalf("NotificationsTotal{telegram,sent} = %v, want 1", got)
|
||||
}
|
||||
if got := testutil.ToFloat64(m.NotificationsTotal.WithLabelValues("webhook", "failed")); got != 1 {
|
||||
t.Fatalf("NotificationsTotal{webhook,failed} = %v, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldNotify(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -24,7 +24,9 @@ test("renders navigation and redirects to domains", async () => {
|
||||
)
|
||||
// Sidebar nav also renders a "Domains" link label, so scope the assertion
|
||||
// to the routed page content to unambiguously confirm the redirect + page.
|
||||
// Suspense is now scoped inside <main>, so <main> mounts with the loading
|
||||
// fallback first — await the lazy chunk resolving to the actual page text.
|
||||
const main = await screen.findByRole("main")
|
||||
expect(within(main).getByText("Domains")).toBeInTheDocument()
|
||||
expect(await within(main).findByText("Domains")).toBeInTheDocument()
|
||||
expect(screen.getByRole("link", { name: /domains/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
+17
-7
@@ -1,22 +1,32 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { lazy, Suspense } from "react"
|
||||
import { Routes, Route, Navigate } from "react-router-dom"
|
||||
import { ProtectedRoute } from "@/auth/ProtectedRoute"
|
||||
import { Layout } from "@/components/Layout"
|
||||
import { AccountsPage } from "@/pages/AccountsPage"
|
||||
import { ChannelsPage } from "@/pages/ChannelsPage"
|
||||
import { DomainDiffPage } from "@/pages/DomainDiffPage"
|
||||
import { DomainsPage } from "@/pages/DomainsPage"
|
||||
import { LoginPage } from "@/pages/LoginPage"
|
||||
import { RegisterPage } from "@/pages/RegisterPage"
|
||||
import { SchedulePage } from "@/pages/SchedulePage"
|
||||
import { TemplatesPage } from "@/pages/TemplatesPage"
|
||||
|
||||
// Маршрутные страницы грузятся лениво — Vite бьёт бандл на per-route чанки,
|
||||
// первый экран (login/register) остаётся статичным, т.к. нужен сразу.
|
||||
const DomainsPage = lazy(() => import("@/pages/DomainsPage").then((m) => ({ default: m.DomainsPage })))
|
||||
const DomainDiffPage = lazy(() => import("@/pages/DomainDiffPage").then((m) => ({ default: m.DomainDiffPage })))
|
||||
const AccountsPage = lazy(() => import("@/pages/AccountsPage").then((m) => ({ default: m.AccountsPage })))
|
||||
const TemplatesPage = lazy(() => import("@/pages/TemplatesPage").then((m) => ({ default: m.TemplatesPage })))
|
||||
const SchedulePage = lazy(() => import("@/pages/SchedulePage").then((m) => ({ default: m.SchedulePage })))
|
||||
const ChannelsPage = lazy(() => import("@/pages/ChannelsPage").then((m) => ({ default: m.ChannelsPage })))
|
||||
|
||||
// Every non-auth route shares the same guard + chrome; wrapping here keeps
|
||||
// each <Route> below a one-liner instead of repeating both on every page.
|
||||
// Suspense is scoped to just the page body (not Layout) so lazy-loading a
|
||||
// route doesn't collapse the sidebar/header chrome to the fallback on nav.
|
||||
function Protected({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<Layout>{children}</Layout>
|
||||
<Layout>
|
||||
<Suspense fallback={<div className="p-6 text-muted-foreground">Загрузка…</div>}>
|
||||
{children}
|
||||
</Suspense>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -48,6 +48,16 @@ test("отрисовывает список каналов без секрета
|
||||
expect(screen.queryByDisplayValue(/123456/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test("formatConfig — вайтлист по типу канала не печатает незнакомые поля config", async () => {
|
||||
vi.spyOn(api, "listChannels").mockResolvedValue([
|
||||
{ id: "c5", type: "webhook", config: { url: "https://x", token: "LEAK" }, enabled: true },
|
||||
])
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText(/url: https:\/\/x/)).toBeInTheDocument()
|
||||
expect(document.body.textContent).not.toMatch(/LEAK/)
|
||||
})
|
||||
|
||||
test("создание telegram-канала собирает config.chat_id + secret=bot_token", async () => {
|
||||
const createSpy = vi.spyOn(api, "createChannel").mockResolvedValue({
|
||||
id: "c3", type: "telegram", config: { chat_id: "999" }, enabled: true,
|
||||
|
||||
@@ -69,13 +69,15 @@ type ChannelForm = z.infer<typeof channelFormSchema>
|
||||
|
||||
const EMPTY_FORM: ChannelForm = { type: "telegram", chatId: "", botToken: "", url: "" }
|
||||
|
||||
// Channel.config is a generic `object` end-to-end (T5/T7) — it never carries
|
||||
// the secret (bot_token/signing key), only the public half (chat_id/url), so
|
||||
// rendering every key is always safe to show in the list.
|
||||
function formatConfig(config: object): string {
|
||||
const entries = Object.entries(config as Record<string, unknown>)
|
||||
if (entries.length === 0) return "—"
|
||||
return entries.map(([k, v]) => `${k}=${String(v)}`).join(" · ")
|
||||
// Печатаем ТОЛЬКО известные несекретные поля по типу канала — чтобы новый
|
||||
// тип канала с чувствительным полем в config не «протёк» в DOM автоматически
|
||||
// (Object.entries по всему config печатал бы любое поле, включая случайно
|
||||
// сохранённый секрет).
|
||||
function formatConfig(type: string, config: object): string {
|
||||
const c = (config ?? {}) as Record<string, unknown>
|
||||
if (type === "telegram") return c.chat_id ? `chat_id: ${String(c.chat_id)}` : ""
|
||||
if (type === "webhook") return c.url ? `url: ${String(c.url)}` : ""
|
||||
return ""
|
||||
}
|
||||
|
||||
function ChannelForm({ onCreated }: { onCreated: () => void }) {
|
||||
@@ -294,7 +296,7 @@ export function ChannelsPage() {
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="font-dns">{c.type}</TableCell>
|
||||
<TableCell className="font-dns text-xs text-muted-foreground">
|
||||
{formatConfig(c.config)}
|
||||
{formatConfig(c.type, c.config)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
|
||||
Reference in New Issue
Block a user