Files
dns-autoresolver/docs/superpowers/plans/2026-07-04-tech-debt-docker.md
T
2026-07-04 15:43:00 +07:00

463 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.