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.