auspex/matchcheck.go
2026-06-09 17:12:18 +03:00

586 lines
19 KiB
Go
Executable file
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.

package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
)
// ── Типы ─────────────────────────────────────────────────────────────────────
type mcStatistics struct {
TotalClients, ClientsOK, ClientsWarning, ClientsError int
}
type mcAlert struct {
Hostname, Client, Container, ErrorType, Description string
IsCritical bool
}
type mcContainerCheck struct {
Number, PortsOK, Errors, Warnings int
Ports []int
Running bool
}
type mcClientCheck struct {
Name string
HasAngie bool
AngiePorts []int
Containers []mcContainerCheck
Errors, Warnings int
}
var mcAlerts []mcAlert
// ── Точка входа ──────────────────────────────────────────────────────────────
func runMatchCheck(clientFilter string) error {
mcAlerts = nil
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "unknown"
}
mcPrintHeader()
if _, err := os.Stat(cfgPTAFBaseDir); os.IsNotExist(err) {
mcPrintError(fmt.Sprintf("Директория %s не найдена", cfgPTAFBaseDir))
return fmt.Errorf("директория %s не найдена", cfgPTAFBaseDir)
}
var clients []string
if clientFilter != "" {
clientPath := filepath.Join(cfgPTAFBaseDir, clientFilter)
if _, err := os.Stat(clientPath); os.IsNotExist(err) {
mcPrintError(fmt.Sprintf("Клиент %s не найден в %s", clientFilter, cfgPTAFBaseDir))
return fmt.Errorf("клиент %s не найден", clientFilter)
}
clients = []string{clientFilter}
} else {
entries, err := os.ReadDir(cfgPTAFBaseDir)
if err != nil {
return fmt.Errorf("ошибка чтения директории: %v", err)
}
for _, entry := range entries {
if entry.IsDir() {
clients = append(clients, entry.Name())
}
}
sort.Strings(clients)
}
if len(clients) == 0 {
mcPrintWarn(fmt.Sprintf("Клиенты не найдены в %s", cfgPTAFBaseDir))
return nil
}
fmt.Printf("%sНайдено клиентов: %d%s\n\n", cBlue, len(clients), cReset)
stats := &mcStatistics{TotalClients: len(clients)}
for _, client := range clients {
result := mcCheckClient(client, hostname)
if result.Errors == 0 && result.Warnings == 0 {
stats.ClientsOK++
} else if result.Errors == 0 {
stats.ClientsWarning++
} else {
stats.ClientsError++
}
}
mcPrintSummary(stats)
if len(mcAlerts) > 0 && isTelegramConfigured() {
mcSendAlerts(hostname, stats, mcAlerts)
}
if stats.ClientsError > 0 {
return fmt.Errorf("обнаружено %d клиентов с ошибками", stats.ClientsError)
}
return nil
}
// ── Проверка клиента ─────────────────────────────────────────────────────────
func mcCheckClient(client, hostname string) mcClientCheck {
result := mcClientCheck{Name: client}
mcPrintClientHeader(client)
angieConfPath := filepath.Join(cfgAngieConfDir, fmt.Sprintf("angie-ptaf-%s.conf", client))
if _, err := os.Stat(angieConfPath); os.IsNotExist(err) {
mcPrintWarn("Angie конфиг не найден")
fmt.Println(cYellow + " (возможно клиент отключен или находится на другом instance)" + cReset)
fmt.Println()
return result
}
result.HasAngie = true
mcPrintOK("✓ Angie конфиг найден")
angiePorts, err := mcExtractAngiePorts(angieConfPath)
if err != nil {
mcPrintWarn(fmt.Sprintf("Ошибка чтения Angie конфига: %v", err))
fmt.Println()
return result
}
if len(angiePorts) == 0 {
mcPrintWarn("Не найдено портов в Angie конфиге")
fmt.Println()
return result
}
result.AngiePorts = angiePorts
fmt.Printf(" Порты в Angie: %d портов - %v\n", len(angiePorts), mcPortsStr(angiePorts))
clientPath := filepath.Join(cfgPTAFBaseDir, client)
containers := mcFindContainers(clientPath)
fmt.Printf(" Контейнеров на диске: %d\n\n", len(containers))
if len(containers) == 0 {
mcPrintWarn("Контейнеры не найдены")
fmt.Println()
return result
}
for _, num := range containers {
cc := mcCheckContainer(client, num, angiePorts, hostname)
result.Containers = append(result.Containers, cc)
result.Errors += cc.Errors
result.Warnings += cc.Warnings
}
if result.Errors == 0 && result.Warnings == 0 {
fmt.Printf("%s✓ Клиент %s: все проверки пройдены%s\n", cGreen, client, cReset)
} else if result.Errors == 0 {
fmt.Printf("%s⚠ Клиент %s: %d предупреждений%s\n", cYellow, client, result.Warnings, cReset)
} else {
fmt.Printf("%s✗ Клиент %s: %d ошибок, %d предупреждений%s\n", cRed, client, result.Errors, result.Warnings, cReset)
}
fmt.Println()
return result
}
// ── Проверка контейнера ──────────────────────────────────────────────────────
func mcCheckContainer(client string, num int, angiePorts []int, hostname string) mcContainerCheck {
result := mcContainerCheck{Number: num}
fullName := mcBuildContainerName(client, hostname, num)
label := fmt.Sprintf("контейнер %03d", num)
fmt.Printf(" ┌─ Контейнер %03d\n", num)
clientPath := filepath.Join(cfgPTAFBaseDir, client)
dirName := fmt.Sprintf("%s-ptaf-agent%03d", client, num)
containerDir := filepath.Join(clientPath, dirName)
composeFile := filepath.Join(containerDir, "docker-compose.yml")
portsFile := filepath.Join(containerDir, ".ports.json")
// docker-compose.yml
if _, err := os.Stat(composeFile); os.IsNotExist(err) {
fmt.Printf(" %s│ ✗ docker-compose.yml не найден%s\n", cRed, cReset)
fmt.Println(" └─")
result.Errors++
mcAlerts = append(mcAlerts, mcAlert{hostname, client, label, "Missing File", "docker-compose.yml не найден", true})
return result
}
composePorts, err := mcExtractComposePorts(composeFile)
if err != nil {
fmt.Printf(" %s│ ✗ Ошибка чтения docker-compose.yml: %v%s\n", cRed, err, cReset)
fmt.Println(" └─")
result.Errors++
mcAlerts = append(mcAlerts, mcAlert{hostname, client, label, "Read Error", fmt.Sprintf("Ошибка чтения: %v", err), true})
return result
}
if len(composePorts) == 0 {
fmt.Printf(" %s│ ✗ Не найдено портов в docker-compose.yml%s\n", cRed, cReset)
fmt.Println(" └─")
result.Errors++
mcAlerts = append(mcAlerts, mcAlert{hostname, client, label, "No Ports", "Не найдено портов", true})
return result
}
result.Ports = composePorts
fmt.Printf(" │ Порты: %v\n", mcPortsStr(composePorts))
// .ports.json
if _, err := os.Stat(portsFile); err == nil {
if !mcIsValidJSON(portsFile) {
fmt.Printf(" %s│ ⚠ .ports.json некорректный JSON%s\n", cYellow, cReset)
result.Warnings++
}
} else {
fmt.Printf(" %s│ ⚠ .ports.json не найден%s\n", cYellow, cReset)
result.Warnings++
}
// Совпадение с Angie
containerOK := true
var missing []int
for _, port := range composePorts {
if !mcContainsInt(angiePorts, port) {
fmt.Printf(" %s│ ✗ Порт %d ОТСУТСТВУЕТ в Angie!%s\n", cRed, port, cReset)
result.Errors++
containerOK = false
missing = append(missing, port)
}
}
if len(missing) > 0 {
mcAlerts = append(mcAlerts, mcAlert{hostname, client, label, "Port Mismatch",
fmt.Sprintf("Порты отсутствуют в Angie: %v", missing), true})
}
// Проверка запуска
if mcIsContainerRunning(fullName) {
result.Running = true
fmt.Printf(" %s│ ✓ Контейнер запущен%s\n", cGreen, cReset)
portsOK := 0
var notListening []int
for _, port := range composePorts {
if mcIsPortListening(port) {
portsOK++
} else {
fmt.Printf(" %s│ ✗ Порт %d НЕ слушается%s\n", cRed, port, cReset)
result.Errors++
containerOK = false
notListening = append(notListening, port)
}
}
if len(notListening) > 0 {
mcAlerts = append(mcAlerts, mcAlert{hostname, client, label, "Port Not Listening",
fmt.Sprintf("Порты не слушаются: %v", notListening), true})
}
result.PortsOK = portsOK
if portsOK == len(composePorts) {
fmt.Printf(" %s│ ✓ Все %d портов активны%s\n", cGreen, portsOK, cReset)
}
} else {
fmt.Printf(" %s│ ⚠ Контейнер НЕ запущен%s\n", cYellow, cReset)
result.Warnings++
containerOK = false
mcAlerts = append(mcAlerts, mcAlert{hostname, client, label, "Container Stopped", "Контейнер не запущен", false})
}
if containerOK {
fmt.Printf(" %s└─ ✓ OK%s\n", cGreen, cReset)
} else {
fmt.Println(" └─")
}
fmt.Println()
return result
}
// ── Парсеры ──────────────────────────────────────────────────────────────────
func mcExtractAngiePorts(filePath string) ([]int, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, err
}
re := regexp.MustCompile(`server\s+127\.0\.0\.1:(\d+)`)
matches := re.FindAllStringSubmatch(string(data), -1)
pm := make(map[int]bool)
for _, m := range matches {
if len(m) > 1 {
var p int
fmt.Sscanf(m[1], "%d", &p)
pm[p] = true
}
}
ports := make([]int, 0, len(pm))
for p := range pm {
ports = append(ports, p)
}
sort.Ints(ports)
return ports, nil
}
func mcExtractComposePorts(filePath string) ([]int, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, err
}
re := regexp.MustCompile(`127\.0\.0\.1:(\d+):\d+/tcp`)
matches := re.FindAllStringSubmatch(string(data), -1)
pm := make(map[int]bool)
for _, m := range matches {
if len(m) > 1 {
var p int
fmt.Sscanf(m[1], "%d", &p)
pm[p] = true
}
}
ports := make([]int, 0, len(pm))
for p := range pm {
ports = append(ports, p)
}
sort.Ints(ports)
return ports, nil
}
func mcFindContainers(clientPath string) []int {
entries, err := os.ReadDir(clientPath)
if err != nil {
return nil
}
re := regexp.MustCompile(`-ptaf-agent(\d{3})$`)
var containers []int
for _, entry := range entries {
if !entry.IsDir() {
continue
}
m := re.FindStringSubmatch(entry.Name())
if len(m) > 1 {
var num int
fmt.Sscanf(m[1], "%d", &num)
containers = append(containers, num)
}
}
sort.Ints(containers)
return containers
}
func mcIsValidJSON(filePath string) bool {
data, err := os.ReadFile(filePath)
if err != nil {
return false
}
var js map[string]interface{}
return json.Unmarshal(data, &js) == nil
}
// mcBuildContainerName строит имя Docker-контейнера в зависимости от формата hostname.
//
// Продакшн (hostname содержит "node"):
//
// PTAF-TB-docker-AZ3-HW-node01 → {client}_AZ3_hn01_a{NNN}
// PTAF-TB-docker-AZ1-node04 → {client}_AZ1_n04_a{NNN}
//
// Тест (hostname не содержит "node"):
//
// PTAF-TB-docker-AZ1-test01 → ptaf_{client}_{NNN}
func mcBuildContainerName(client, hostname string, agentNum int) string {
// Тестовый хост — старый формат
if !strings.Contains(strings.ToLower(hostname), "node") {
return fmt.Sprintf("ptaf_%s_%03d", client, agentNum)
}
// Продакшн — новый формат с AZ и номером ноды
azRe := regexp.MustCompile(`AZ(\d+)`)
azMatch := azRe.FindStringSubmatch(hostname)
azPart := "AZ0"
if len(azMatch) > 1 {
azPart = "AZ" + azMatch[1]
}
nodePrefix := "n"
if strings.Contains(hostname, "-HW-") {
nodePrefix = "hn"
}
nodeRe := regexp.MustCompile(`node(\d+)`)
nodeMatch := nodeRe.FindStringSubmatch(hostname)
nodePart := "00"
if len(nodeMatch) > 1 {
num := nodeMatch[1]
if len(num) >= 2 {
nodePart = num[len(num)-2:]
} else {
nodePart = fmt.Sprintf("%02s", num)
}
}
return fmt.Sprintf("%s_%s_%s%s_a%03d", client, azPart, nodePrefix, nodePart, agentNum)
}
// ── Кэш системных вызовов ────────────────────────────────────────────────────
// docker ps и ss вызываются один раз на всё выполнение и кэшируются.
// Это критично при большом количестве контейнеров и портов (51+ портов на контейнер).
var (
mcRunningContainers map[string]bool // имя контейнера → запущен
mcListeningPorts map[int]bool // порт → слушается
mcCacheOnce sync.Once
)
func mcInitCache() {
mcCacheOnce.Do(func() {
mcRunningContainers = make(map[string]bool)
mcListeningPorts = make(map[int]bool)
// Один вызов docker ps — все запущенные контейнеры
if out, err := exec.Command("docker", "ps", "--format", "{{.Names}}").Output(); err == nil {
for _, line := range strings.Split(string(out), "\n") {
name := strings.TrimSpace(line)
if name != "" {
mcRunningContainers[name] = true
}
}
}
// Один вызов ss — все слушающие порты
out, err := exec.Command("ss", "-tlnp").Output()
if err != nil {
out, err = exec.Command("netstat", "-tlnp").Output()
}
if err == nil {
re := regexp.MustCompile(`127\.0\.0\.1:(\d+)`)
for _, m := range re.FindAllStringSubmatch(string(out), -1) {
if len(m) > 1 {
var p int
fmt.Sscanf(m[1], "%d", &p)
mcListeningPorts[p] = true
}
}
}
})
}
func mcIsContainerRunning(name string) bool {
mcInitCache()
return mcRunningContainers[name]
}
func mcIsPortListening(port int) bool {
mcInitCache()
return mcListeningPorts[port]
}
func mcContainsInt(slice []int, val int) bool {
for _, item := range slice {
if item == val {
return true
}
}
return false
}
func mcPortsStr(ports []int) string {
strs := make([]string, len(ports))
for i, p := range ports {
strs[i] = fmt.Sprintf("%d", p)
}
return strings.Join(strs, " ")
}
// ── Вывод ────────────────────────────────────────────────────────────────────
func mcPrintHeader() {
fmt.Printf("%s╔════════════════════════════════════════╗\n", cBlue)
fmt.Println("║ Проверка портов PTAF (Angie ↔ Docker) ║")
fmt.Printf("╚════════════════════════════════════════╝%s\n\n", cReset)
}
func mcPrintClientHeader(client string) {
fmt.Printf("%s==========================================\n", cCyan)
fmt.Printf("Клиент: %s\n", client)
fmt.Printf("==========================================%s\n\n", cReset)
}
func mcPrintSummary(stats *mcStatistics) {
fmt.Printf("%s╔════════════════════════════════════════╗\n", cBlue)
fmt.Println("║ Итоговая статистика ║")
fmt.Printf("╚════════════════════════════════════════╝%s\n\n", cReset)
fmt.Printf("Всего клиентов проверено: %d\n", stats.TotalClients)
fmt.Printf("%s✓ Без проблем: %d%s\n", cGreen, stats.ClientsOK, cReset)
if stats.ClientsWarning > 0 {
fmt.Printf("%s⚠ С предупреждениями: %d%s\n", cYellow, stats.ClientsWarning, cReset)
}
if stats.ClientsError > 0 {
fmt.Printf("%s✗ С ошибками: %d%s\n", cRed, stats.ClientsError, cReset)
}
fmt.Println()
}
func mcPrintOK(msg string) { fmt.Printf("%s%s%s\n", cGreen, msg, cReset) }
func mcPrintWarn(msg string) { fmt.Printf("%s⚠ %s%s\n", cYellow, msg, cReset) }
func mcPrintError(msg string) { fmt.Printf("%s✗ %s%s\n", cRed, msg, cReset) }
// ── Telegram ─────────────────────────────────────────────────────────────────
func mcSendAlerts(hostname string, stats *mcStatistics, alertList []mcAlert) {
var sb strings.Builder
sb.WriteString("🔴 <b>PTAF Docker Ports Check Alert</b>\n\n")
sb.WriteString(fmt.Sprintf("Хост: <code>%s</code>\n", hostname))
sb.WriteString(fmt.Sprintf("Проверено клиентов: %d\n", stats.TotalClients))
sb.WriteString(fmt.Sprintf("Без проблем: %d\n", stats.ClientsOK))
if stats.ClientsWarning > 0 {
sb.WriteString(fmt.Sprintf("С предупреждениями: %d\n", stats.ClientsWarning))
}
if stats.ClientsError > 0 {
sb.WriteString(fmt.Sprintf("С ошибками: %d\n", stats.ClientsError))
}
sb.WriteString("\n<b>Детали проблем:</b>\n\n")
clientAlerts := make(map[string][]mcAlert)
for _, a := range alertList {
clientAlerts[a.Client] = append(clientAlerts[a.Client], a)
}
var sortedClients []string
for c := range clientAlerts {
sortedClients = append(sortedClients, c)
}
sort.Strings(sortedClients)
for _, client := range sortedClients {
cas := clientAlerts[client]
crit, warn := 0, 0
for _, a := range cas {
if a.IsCritical {
crit++
} else {
warn++
}
}
if crit > 0 {
sb.WriteString(fmt.Sprintf("<b>%s</b> (%d ошибок", client, crit))
if warn > 0 {
sb.WriteString(fmt.Sprintf(", %d предупреждений", warn))
}
sb.WriteString(")\n")
} else {
sb.WriteString(fmt.Sprintf("<b>%s</b> (%d предупреждений)\n", client, warn))
}
for _, a := range cas {
sb.WriteString(fmt.Sprintf("<code>%s</code>: %s - %s\n", a.Container, a.ErrorType, a.Description))
}
}
sb.WriteString("Проверьте должны ли быть данные ресурсы на этом хосте.\n")
sb.WriteString("Если нет, то удалите все старые контейнеры и конфиги для Angie.\n")
if err := telegramSendHTML(sb.String()); err != nil {
fmt.Printf("%s[Telegram] ✗ Ошибка отправки: %v%s\n", cRed, err, cReset)
} else {
fmt.Printf("%s[Telegram] ✓ Алерт отправлен (%d проблем)%s\n", cGreen, len(alertList), cReset)
}
if err := emailSendHTML("Auspex: Несовпадения Angie ↔ Docker", sb.String()); err != nil {
fmt.Printf("%s[Email] ✗ Ошибка отправки: %v%s\n", cRed, err, cReset)
} else {
fmt.Printf("%s[Email] ✓ Алерт отправлен (%d проблем)%s\n", cGreen, len(alertList), cReset)
}
if err := mattermostSend(sb.String()); err != nil {
fmt.Printf("%s[Mattermost] ✗ Ошибка отправки: %v%s\n", cRed, err, cReset)
} else {
fmt.Printf("%s[Mattermost] ✓ Алерт отправлен (%d проблем)%s\n", cGreen, len(alertList), cReset)
}
}