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

19 KiB
Raw Blame History

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):

// 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() (до всего остального), обработать флаг:

if len(os.Args) > 1 && os.Args[1] == "-healthcheck" {
	os.Exit(healthcheck())
}
// 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) на:

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.

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-инкремент безусловно, даже при полном провале доставки. Метрика NotificationsTotalCounterVec{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
// 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 теперь:

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 интерфейс:

type NotifySender interface {
	Send(ctx context.Context, projectID uuid.UUID, ev notify.Event) ([]notify.ChannelResult, error)
}

В checkDomain заменить блок IncNotification("dispatch", newStatus):

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.

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:

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)} — передать его):

// Печатаем ТОЛЬКО известные несекретные поля по типу канала — чтобы новый
// тип канала с чувствительным полем в 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 бьёт на несколько чанков.

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 стадии)

# 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-стадии:

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: Коммит
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

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
# 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

Добавить:

.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: Коммит
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.