From c265d36bdbadbcbf9621493c8afabef278472830 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 15:43:00 +0700 Subject: [PATCH 01/10] docs: plan for tech-debt cleanup + docker compose Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3 --- .../plans/2026-07-04-tech-debt-docker.md | 462 ++++++++++++++++++ 1 file changed, 462 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-04-tech-debt-docker.md diff --git a/docs/superpowers/plans/2026-07-04-tech-debt-docker.md b/docs/superpowers/plans/2026-07-04-tech-debt-docker.md new file mode 100644 index 0000000..271bf56 --- /dev/null +++ b/docs/superpowers/plans/2026-07-04-tech-debt-docker.md @@ -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/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`.) Обернуть `` в `Загрузка…}>`. `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 + 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. From a27ddc79e85e82276422c7e7193afa3c8b4749b3 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 15:46:56 +0700 Subject: [PATCH 02/10] feat(server): graceful scheduler shutdown, /healthz, healthcheck mode Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3 --- cmd/server/main.go | 77 +++++++++++++++++++++++++++++++++-------- cmd/server/main_test.go | 71 ++++++++++++++++++++++++++++++++++++- 2 files changed, 132 insertions(+), 16 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index c29fbb0..08ecbe9 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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,6 +178,10 @@ 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 { diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index e056b4e..3424c8b 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -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") + } + }) +} From e9a100ab4a1c62bce40617857533e15cc79fbd70 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 15:52:40 +0700 Subject: [PATCH 03/10] fix(server): drain scheduler on unexpected serve error before exit Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/server/main.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cmd/server/main.go b/cmd/server/main.go index 08ecbe9..dd40bfd 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -185,6 +185,12 @@ func main() { 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) } } From f14916396cf4ca746887c76cad34f89eea21bd43 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 15:56:15 +0700 Subject: [PATCH 04/10] feat(notify): per-channel delivery results + accurate notification metrics Dispatcher.Send now returns []ChannelResult{Type, Err} alongside the aggregated error, and scheduler.checkDomain increments NotificationsTotal per channel type/status instead of a single unconditional IncNotification("dispatch", newStatus) placeholder that ignored per-channel delivery outcome. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3 --- internal/notify/dispatch.go | 30 +++++++++++----- internal/notify/notify_test.go | 54 ++++++++++++++++++++++++++-- internal/scheduler/scheduler.go | 13 +++++-- internal/scheduler/scheduler_test.go | 53 ++++++++++++++++++++++++--- 4 files changed, 130 insertions(+), 20 deletions(-) diff --git a/internal/notify/dispatch.go b/internal/notify/dispatch.go index 357e772..670ddb5 100644 --- a/internal/notify/dispatch.go +++ b/internal/notify/dispatch.go @@ -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 diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go index 62a7c0d..63ea1db 100644 --- a/internal/notify/notify_test.go +++ b/internal/notify/notify_test.go @@ -330,7 +330,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 +344,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,7 +400,7 @@ 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 { + if _, err := d.Send(context.Background(), projectID, Event{Project: "p", Domain: "d", Status: "drift"}); err != nil { t.Fatalf("unexpected error for unknown channel type: %v", err) } } @@ -375,7 +423,7 @@ 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"}) + _, err := d.Send(context.Background(), projectID, Event{Project: "p", Domain: "d", Status: "drift"}) if err == nil { t.Fatal("expected error due to decrypt failure") } diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 240ad9d..47b443b 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -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 diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go index 280e1d6..d6a6c75 100644 --- a/internal/scheduler/scheduler_test.go +++ b/internal/scheduler/scheduler_test.go @@ -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 From 41844d49a0c262a0c69c1afd592d22482950d63f Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 16:01:14 +0700 Subject: [PATCH 05/10] test(notify): assert per-channel results on decrypt-fail and unknown-type Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/notify/notify_test.go | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go index 63ea1db..7ea50d9 100644 --- a/internal/notify/notify_test.go +++ b/internal/notify/notify_test.go @@ -3,6 +3,7 @@ package notify import ( "context" "encoding/json" + "errors" "net" "net/http" "net/http/httptest" @@ -400,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) { @@ -423,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. From 8c35aed8f2640ce3b8cdac9ff7de717b0af07299 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 16:04:17 +0700 Subject: [PATCH 06/10] perf(web): route-level code-splitting; harden channel config rendering Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3 --- web/src/App.tsx | 40 +++++++++++++++++------------ web/src/pages/ChannelsPage.test.tsx | 10 ++++++++ web/src/pages/ChannelsPage.tsx | 18 +++++++------ 3 files changed, 43 insertions(+), 25 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 8f81bb1..a83f54c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,15 +1,19 @@ 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 below a one-liner instead of repeating both on every page. @@ -23,16 +27,18 @@ function Protected({ children }: { children: ReactNode }) { export function App() { return ( - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + Загрузка…}> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ) } diff --git a/web/src/pages/ChannelsPage.test.tsx b/web/src/pages/ChannelsPage.test.tsx index 162041f..de9a8e4 100644 --- a/web/src/pages/ChannelsPage.test.tsx +++ b/web/src/pages/ChannelsPage.test.tsx @@ -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, diff --git a/web/src/pages/ChannelsPage.tsx b/web/src/pages/ChannelsPage.tsx index ad51923..f224640 100644 --- a/web/src/pages/ChannelsPage.tsx +++ b/web/src/pages/ChannelsPage.tsx @@ -69,13 +69,15 @@ type ChannelForm = z.infer 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) - 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 + 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() { {c.type} - {formatConfig(c.config)} + {formatConfig(c.type, c.config)} Date: Sat, 4 Jul 2026 16:12:21 +0700 Subject: [PATCH 07/10] fix(web): scope Suspense to page body; guard formatConfig against null config Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/App.test.tsx | 4 +++- web/src/App.tsx | 32 ++++++++++++++++++-------------- web/src/pages/ChannelsPage.tsx | 2 +- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 2b72b2f..7568f28 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -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
, so
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() }) diff --git a/web/src/App.tsx b/web/src/App.tsx index a83f54c..dea713a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -17,28 +17,32 @@ const ChannelsPage = lazy(() => import("@/pages/ChannelsPage").then((m) => ({ de // Every non-auth route shares the same guard + chrome; wrapping here keeps // each 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 ( - {children} + + Загрузка…}> + {children} + + ) } export function App() { return ( - Загрузка…}> - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + ) } diff --git a/web/src/pages/ChannelsPage.tsx b/web/src/pages/ChannelsPage.tsx index f224640..4abf430 100644 --- a/web/src/pages/ChannelsPage.tsx +++ b/web/src/pages/ChannelsPage.tsx @@ -74,7 +74,7 @@ const EMPTY_FORM: ChannelForm = { type: "telegram", chatId: "", botToken: "", ur // (Object.entries по всему config печатал бы любое поле, включая случайно // сохранённый секрет). function formatConfig(type: string, config: object): string { - const c = config as Record + const c = (config ?? {}) as Record if (type === "telegram") return c.chat_id ? `chat_id: ${String(c.chat_id)}` : "" if (type === "webhook") return c.url ? `url: ${String(c.url)}` : "" return "" From 7d875ea19a1e10ebb75ae3c2f5b4c64a0dd080b3 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 16:15:55 +0700 Subject: [PATCH 08/10] build: multi-stage Dockerfile (node build -> go embed -> distroless) Three-stage image: node:22-alpine builds the Vite SPA, golang:1.26.4-alpine compiles the server with the built SPA copied into the //go:embed path before build, distroless/static-debian12:nonroot runs the static binary as non-root on :8080. .dockerignore keeps node_modules/dist/docs/git out of the build context while preserving the internal/web/dist/index.html placeholder needed for a valid embed target pre-COPY. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3 --- .dockerignore | 9 +++++++++ Dockerfile | 26 ++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7469d15 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.superpowers +docs +web/node_modules +web/dist +internal/web/dist/assets +swarm-report +*.md +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7e0c0fc --- /dev/null +++ b/Dockerfile @@ -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"] From 675136e4889c398c5319fff2464436847810c574 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 16:23:20 +0700 Subject: [PATCH 09/10] build: mirror .gitignore dist rules in .dockerignore for hermetic builds Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3 --- .dockerignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index 7469d15..de3837a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,7 +3,8 @@ docs web/node_modules web/dist -internal/web/dist/assets +internal/web/dist/* +!internal/web/dist/index.html swarm-report *.md .env From 77ca0200aea9fc5135aa893632c8231238bff635 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 16:27:16 +0700 Subject: [PATCH 10/10] build: docker compose (app + postgres) with healthchecks and .env Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3 --- .env.example | 11 ++++++ Makefile | 13 +++++++ README.md | 92 ++++++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 37 +++++++++++++++++++ 4 files changed, 153 insertions(+) create mode 100644 .env.example create mode 100644 README.md create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9412a05 --- /dev/null +++ b/.env.example @@ -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= diff --git a/Makefile b/Makefile index 2bf9af6..3031625 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..dcb5cf7 --- /dev/null +++ b/README.md @@ -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`). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d575c9f --- /dev/null +++ b/docker-compose.yml @@ -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: