Files
grendervill 5668d779df feat(instance): метрики всей машины вторым рядом дашборда
Дашборд показывал только контейнер (лимиты профиля), поэтому не было видно,
сколько ресурсов у хоста всего.

- `sysinfo.NewHostSampler` и `ReadHostMemory` всегда читают `/proc/stat` и
  `/proc/meminfo`, игнорируя cgroup;
- в ответе метрик появились `host_cpu` и `host_memory` (`source: host`);
- недоступные источники попадают в проверку `metrics`, а не ломают ручку.
2026-09-20 22:10:09 +03:00

203 lines
8.5 KiB
Go

package server
import (
"net/http"
"testing"
"time"
"glchat/internal/httpx"
)
// Тесты дашборда инстанса: доступ только администратору, состав метрик и
// healthcheck, отдельный лимит частоты для опроса раз в секунду (AGENT.md 7.19).
type metricsBody struct {
Metrics struct {
CPU struct {
Percent float64 `json:"percent"`
Cores float64 `json:"cores"`
Source string `json:"source"`
} `json:"cpu"`
Memory struct {
UsedBytes int64 `json:"used_bytes"`
TotalBytes int64 `json:"total_bytes"`
Percent float64 `json:"percent"`
Source string `json:"source"`
} `json:"memory"`
HostCPU struct {
Percent float64 `json:"percent"`
Cores float64 `json:"cores"`
Source string `json:"source"`
} `json:"host_cpu"`
HostMemory struct {
UsedBytes int64 `json:"used_bytes"`
TotalBytes int64 `json:"total_bytes"`
Percent float64 `json:"percent"`
Source string `json:"source"`
} `json:"host_memory"`
Disk struct {
UsedBytes int64 `json:"used_bytes"`
TotalBytes int64 `json:"total_bytes"`
FreeBytes int64 `json:"free_bytes"`
} `json:"disk"`
Database struct {
Bytes int64 `json:"bytes"`
WalBytes int64 `json:"wal_bytes"`
} `json:"database"`
Checks []struct {
Name string `json:"name"`
Status string `json:"status"`
Message string `json:"message"`
} `json:"checks"`
Health string `json:"health"`
UptimeSeconds int64 `json:"uptime_seconds"`
Version string `json:"version"`
Commit string `json:"commit"`
CollectedAt string `json:"collected_at"`
} `json:"metrics"`
}
// metricsServer готовит сервер с настоящими источниками метрик (cgroup/proc
// текущей машины) — тесты проверяют и разбор, и отдачу значений.
func metricsServer(t *testing.T) (*Server, *http.Cookie) {
t.Helper()
srv, _ := newTestServer(t)
cookie := registerAndLogin(t, srv, "metrics_admin", "metrics@example.com")
promoteAdmin(t, srv, "metrics@example.com")
return srv, cookie
}
func TestInstanceMetricsRequireAdmin(t *testing.T) {
srv, _ := newTestServer(t)
member := registerAndLogin(t, srv, "metrics_member", "metrics-member@example.com")
rec := doJSON(t, srv, http.MethodGet, "/api/v1/instance/metrics", "", member)
if rec.Code != http.StatusForbidden {
t.Fatalf("обычный участник: статус %d, ожидался 403", rec.Code)
}
anonymous := doJSON(t, srv, http.MethodGet, "/api/v1/instance/metrics", "")
if anonymous.Code != http.StatusUnauthorized {
t.Fatalf("без сессии: статус %d, ожидался 401", anonymous.Code)
}
}
func TestInstanceMetricsPayload(t *testing.T) {
srv, cookie := metricsServer(t)
// Первый вызов только снимает показания процессора, второй считает дельту.
doJSON(t, srv, http.MethodGet, "/api/v1/instance/metrics", "", cookie)
time.Sleep(20 * time.Millisecond)
rec := doJSON(t, srv, http.MethodGet, "/api/v1/instance/metrics", "", cookie)
if rec.Code != http.StatusOK {
t.Fatalf("статус = %d, тело = %s", rec.Code, rec.Body.String())
}
payload := decodeResponse[metricsBody](t, rec).Metrics
if payload.Version == "" || payload.Commit != "deadbee" {
t.Fatalf("версия/коммит = %q/%q", payload.Version, payload.Commit)
}
if payload.UptimeSeconds < 0 || payload.CollectedAt == "" {
t.Fatalf("время работы/сбора = %d/%q", payload.UptimeSeconds, payload.CollectedAt)
}
// На macOS нет ни cgroup, ни /proc: источник недоступен, и ручка обязана
// деградировать мягко (проверка «metrics» в статусе warn).
switch payload.CPU.Source {
case "":
t.Log("источники процессора недоступны (не Linux) — проверка пропущена")
case "cgroup", "host":
if payload.CPU.Percent < 0 || payload.CPU.Percent > 100 || payload.CPU.Cores <= 0 {
t.Fatalf("процессор = %+v", payload.CPU)
}
default:
t.Fatalf("неизвестный источник процессора = %q", payload.CPU.Source)
}
if payload.Memory.TotalBytes == 0 {
t.Log("память недоступна (не Linux) — проверка пропущена")
} else if payload.Memory.UsedBytes <= 0 || payload.Memory.Percent <= 0 || payload.Memory.Percent > 100 {
t.Fatalf("память = %+v", payload.Memory)
}
// Второй ряд дашборда — вся машина.
if payload.HostCPU.Source == "" {
t.Log("источники процессора хоста недоступны (не Linux) — проверка пропущена")
} else if payload.HostCPU.Source != "host" || payload.HostCPU.Cores <= 0 {
t.Fatalf("процессор хоста = %+v", payload.HostCPU)
}
if payload.HostMemory.TotalBytes == 0 {
t.Log("память хоста недоступна (не Linux) — проверка пропущена")
} else if payload.HostMemory.Source != "host" || payload.HostMemory.Percent <= 0 {
t.Fatalf("память хоста = %+v", payload.HostMemory)
}
if payload.Disk.TotalBytes <= 0 || payload.Disk.UsedBytes <= 0 || payload.Disk.FreeBytes <= 0 {
t.Fatalf("диск = %+v", payload.Disk)
}
if payload.Database.Bytes <= 0 {
t.Fatalf("размер базы = %d", payload.Database.Bytes)
}
// Healthcheck: приложение, база, диск, запись, gateway.
statuses := map[string]string{}
for _, check := range payload.Checks {
statuses[check.Name] = check.Status
}
for _, name := range []string{"app", "database", "disk", "storage", "gateway"} {
if statuses[name] != "ok" {
t.Fatalf("проверка %s = %q (все: %+v)", name, statuses[name], payload.Checks)
}
}
// На Linux без cgroup/proc не бывает, поэтому «metrics» появляется только
// на не-Linux: тогда health = warn, иначе ok.
if statuses["metrics"] == "warn" {
if payload.Health != "warn" {
t.Fatalf("состояние = %q при проблеме с метриками", payload.Health)
}
} else if payload.Health != "ok" {
t.Fatalf("итоговое состояние = %q, проверки: %+v", payload.Health, payload.Checks)
}
}
func TestInstanceMetricsRateLimitIsSeparate(t *testing.T) {
srv, cookie := metricsServer(t)
// Лимит дашборда — 120/мин с запасом 30: серия из 40 запросов проходит,
// хотя общий лимит API (120/мин) в тестах уже израсходован регистрацией.
for i := 0; i < 25; i++ {
rec := doJSON(t, srv, http.MethodGet, "/api/v1/instance/metrics", "", cookie)
if rec.Code != http.StatusOK {
t.Fatalf("запрос %d: статус %d (%s)", i+1, rec.Code, rec.Body.String())
}
}
// Исчерпание своего лимита даёт 429 с подсказкой по паузе: ставим лимитер
// на один запрос и расходуем токен.
srv.metricsLimiter = httpx.NewRateLimiterWindow(1, time.Hour, 1)
doJSON(t, srv, http.MethodGet, "/api/v1/instance/metrics", "", cookie)
rec := doJSON(t, srv, http.MethodGet, "/api/v1/instance/metrics", "", cookie)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("статус = %d, ожидался 429", rec.Code)
}
payload := decodeResponse[struct {
Error struct {
Code string `json:"code"`
RetryAfterMS int `json:"retry_after_ms"`
} `json:"error"`
}](t, rec)
if payload.Error.Code != "rate_limited" || payload.Error.RetryAfterMS <= 0 {
t.Fatalf("ошибка = %+v", payload.Error)
}
}
func TestInstanceMetricsHealthCacheReusesChecks(t *testing.T) {
srv, cookie := metricsServer(t)
first := decodeResponse[metricsBody](t, doJSON(t, srv, http.MethodGet, "/api/v1/instance/metrics", "", cookie)).Metrics
second := decodeResponse[metricsBody](t, doJSON(t, srv, http.MethodGet, "/api/v1/instance/metrics", "", cookie)).Metrics
if len(first.Checks) != len(second.Checks) {
t.Fatalf("набор проверок изменился: %d → %d", len(first.Checks), len(second.Checks))
}
// Проверки берутся из кэша (TTL 5 секунд) — время сбора метрик при этом
// обновляется на каждом запросе.
if first.CollectedAt == second.CollectedAt {
t.Fatal("метрики должны пересчитываться на каждый запрос")
}
}