c265d36bdb
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
463 lines
19 KiB
Markdown
463 lines
19 KiB
Markdown
# 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.
|