diff --git a/config.go b/config.go index bf08fb4..ccdaa08 100644 --- a/config.go +++ b/config.go @@ -56,6 +56,7 @@ var ( cfgNginxConfigGlob = "/home/install/conf/ptaf-nginx/*/*/*/*.conf" cfgStateFilePath = "/tmp/port_checker_state.json" cfgServiceStateFilePath = "/tmp/auspex_service_state.json" + cfgDockerStateFilePath = "/tmp/auspex_docker_state.json" ) // --- Параметры проверки портов --- @@ -183,6 +184,9 @@ func applyConfig(env map[string]string) { if v, ok := env["SERVICE_STATE_FILE_PATH"]; ok && v != "" { cfgServiceStateFilePath = v } + if v, ok := env["DOCKER_STATE_FILE_PATH"]; ok && v != "" { + cfgDockerStateFilePath = v + } if v, ok := env["LOG_DIR"]; ok && v != "" { cfgLogDir = v } diff --git a/dockercheck.go b/dockercheck.go new file mode 100644 index 0000000..72fc039 --- /dev/null +++ b/dockercheck.go @@ -0,0 +1,499 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + "strings" + "time" +) + +// ── Типы ───────────────────────────────────────────────────────────────────── + +type dcContainerInfo struct { + ID string + Name string + Status string // running, exited, dead, restarting, paused, created + Health string // healthy, unhealthy, starting, none + Image string +} + +type dcStateFile struct { + Timestamp string `json:"timestamp"` + Containers map[string]dcContainerState `json:"containers"` // name → state +} + +type dcContainerState struct { + Status string `json:"status"` + Health string `json:"health"` +} + +type dcChangeKind string + +const ( + dcKindStatusChanged dcChangeKind = "status_changed" + dcKindHealthChanged dcChangeKind = "health_changed" + dcKindDisappeared dcChangeKind = "disappeared" +) + +type dcStateChange struct { + Name string + Kind dcChangeKind + PrevState dcContainerState + CurrState dcContainerState + Image string +} + +func (ch dcStateChange) isProblem() bool { + switch ch.Kind { + case dcKindDisappeared: + return true + case dcKindStatusChanged: + return ch.CurrState.Status != "running" + case dcKindHealthChanged: + return ch.CurrState.Health == "unhealthy" + } + return false +} + +func (ch dcStateChange) isRecovery() bool { + switch ch.Kind { + case dcKindStatusChanged: + return ch.CurrState.Status == "running" && ch.PrevState.Status != "running" + case dcKindHealthChanged: + return ch.CurrState.Health == "healthy" && ch.PrevState.Health == "unhealthy" + } + return false +} + +// ── Точка входа ─────────────────────────────────────────────────────────────── + +func runDockerCheck() error { + hostname, err := os.Hostname() + if err != nil { + hostname = "unknown" + log.Printf("⚠️ Не удалось получить hostname: %v", err) + } + + log.Printf("🔍 DockerCheck запущен на хосте: %s", hostname) + + // Проверяем доступность docker + if err := dcCheckDockerAvailable(); err != nil { + return fmt.Errorf("docker недоступен: %v", err) + } + + // Получаем текущий список контейнеров + containers, err := dcGetContainers() + if err != nil { + return fmt.Errorf("ошибка получения списка контейнеров: %v", err) + } + + if len(containers) == 0 { + log.Println("ℹ️ Запущенных контейнеров не найдено") + } + + log.Printf("📦 Найдено контейнеров: %d", len(containers)) + + // Выводим текущее состояние + for _, c := range containers { + emoji := dcStatusEmoji(c.Status, c.Health) + if c.Health != "" && c.Health != "none" { + log.Printf("%s %-30s | %-12s | health: %s | %s", emoji, c.Name, c.Status, c.Health, c.Image) + } else { + log.Printf("%s %-30s | %-12s | %s", emoji, c.Name, c.Status, c.Image) + } + } + + // Загружаем предыдущее состояние + prevState := dcLoadState() + + // Строим map текущего состояния + current := make(map[string]dcContainerInfo, len(containers)) + for _, c := range containers { + current[c.Name] = c + } + + // Сравниваем + changes := dcCompareStates(prevState.Containers, current) + + // Статистика + runningCount := 0 + problemCount := 0 + for _, c := range containers { + if c.Status == "running" && c.Health != "unhealthy" { + runningCount++ + } else { + problemCount++ + } + } + + log.Println("\n📊 Итоги проверки контейнеров:") + log.Printf(" ✅ Работают нормально: %d", runningCount) + log.Printf(" ❌ Проблемных: %d", problemCount) + log.Printf(" 🔄 Изменений статуса: %d", len(changes)) + + if len(changes) > 0 { + log.Println("\n🔄 Изменения с последней проверки:") + log.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + for _, ch := range changes { + arrow := dcChangeArrow(ch) + switch ch.Kind { + case dcKindDisappeared: + log.Printf(" %s %-30s | исчез (был: %s)", arrow, ch.Name, ch.PrevState.Status) + case dcKindStatusChanged: + log.Printf(" %s %-30s | %s → %s", arrow, ch.Name, ch.PrevState.Status, ch.CurrState.Status) + case dcKindHealthChanged: + log.Printf(" %s %-30s | health: %s → %s", arrow, ch.Name, ch.PrevState.Health, ch.CurrState.Health) + } + } + log.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + + if isTelegramConfigured() { + dcSendAlert(hostname, changes) + } else { + log.Println("\nℹ️ Telegram уведомления отключены (не настроены данные)") + } + } else { + if len(prevState.Containers) == 0 { + log.Println("\nℹ️ Первый запуск — состояние сохранено, уведомления при изменениях") + } else { + log.Println("\n✅ Изменений нет — уведомления не отправляются") + } + } + + // Сохраняем текущее состояние + dcSaveState(current) + + log.Println("=== Проверка контейнеров завершена ===") + return nil +} + +// ── Получение контейнеров ──────────────────────────────────────────────────── + +func dcCheckDockerAvailable() error { + err := exec.Command("docker", "info").Run() + if err != nil { + return fmt.Errorf("docker info завершился с ошибкой: %v", err) + } + return nil +} + +func dcGetContainers() ([]dcContainerInfo, error) { + // Получаем все контейнеры (включая не-running) через Go-шаблон + // Поля: ID, Name, Status, Health, Image — разделитель | + out, err := exec.Command( + "docker", "ps", "-a", + "--format", "{{.ID}}|{{.Names}}|{{.Status}}|{{.Health}}|{{.Image}}", + ).Output() + if err != nil { + return nil, fmt.Errorf("docker ps завершился с ошибкой: %v", err) + } + + var containers []dcContainerInfo + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line == "" { + continue + } + parts := strings.SplitN(line, "|", 5) + if len(parts) != 5 { + continue + } + + c := dcContainerInfo{ + ID: strings.TrimSpace(parts[0]), + Name: strings.TrimSpace(parts[1]), + Image: strings.TrimSpace(parts[4]), + Health: strings.ToLower(strings.TrimSpace(parts[3])), + } + + // Status из docker ps выглядит как "Up 2 hours" или "Exited (1) 3 minutes ago" + // Нормализуем до короткого статуса + c.Status = dcNormalizeStatus(parts[2]) + + containers = append(containers, c) + } + + return containers, nil +} + +// dcNormalizeStatus приводит вывод docker ps к короткому статусу +func dcNormalizeStatus(raw string) string { + raw = strings.TrimSpace(raw) + lower := strings.ToLower(raw) + switch { + case strings.HasPrefix(lower, "up"): + return "running" + case strings.HasPrefix(lower, "exited"): + return "exited" + case strings.HasPrefix(lower, "restarting"): + return "restarting" + case strings.HasPrefix(lower, "dead"): + return "dead" + case strings.HasPrefix(lower, "paused"): + return "paused" + case strings.HasPrefix(lower, "created"): + return "created" + case strings.HasPrefix(lower, "removal"): + return "removing" + default: + return lower + } +} + +// ── Сравнение состояний ────────────────────────────────────────────────────── + +func dcCompareStates(prev map[string]dcContainerState, curr map[string]dcContainerInfo) []dcStateChange { + var changes []dcStateChange + + // Проверяем текущие контейнеры + for name, currInfo := range curr { + prevInfo, existed := prev[name] + + if !existed { + // Новый контейнер — не алертим, просто добавится в state + continue + } + + currState := dcContainerState{Status: currInfo.Status, Health: currInfo.Health} + + // Изменение статуса + if prevInfo.Status != currState.Status { + changes = append(changes, dcStateChange{ + Name: name, + Kind: dcKindStatusChanged, + PrevState: prevInfo, + CurrState: currState, + Image: currInfo.Image, + }) + } + + // Изменение health (независимо от статуса) + if prevInfo.Health != currState.Health && + currState.Health != "" && currState.Health != "none" && + prevInfo.Health != "" && prevInfo.Health != "none" { + changes = append(changes, dcStateChange{ + Name: name, + Kind: dcKindHealthChanged, + PrevState: prevInfo, + CurrState: currState, + Image: currInfo.Image, + }) + } + } + + // Проверяем исчезнувшие контейнеры (были в state, нет сейчас) + for name, prevInfo := range prev { + if _, exists := curr[name]; !exists { + changes = append(changes, dcStateChange{ + Name: name, + Kind: dcKindDisappeared, + PrevState: prevInfo, + CurrState: dcContainerState{}, + Image: "", + }) + } + } + + return changes +} + +// ── State-файл ──────────────────────────────────────────────────────────────── + +func dcLoadState() dcStateFile { + data, err := os.ReadFile(cfgDockerStateFilePath) + if err != nil { + return dcStateFile{Containers: make(map[string]dcContainerState)} + } + + var state dcStateFile + if err := json.Unmarshal(data, &state); err != nil { + log.Printf("⚠️ Не удалось прочитать state-файл docker: %v", err) + return dcStateFile{Containers: make(map[string]dcContainerState)} + } + + return state +} + +func dcSaveState(current map[string]dcContainerInfo) { + containers := make(map[string]dcContainerState, len(current)) + for name, info := range current { + containers[name] = dcContainerState{ + Status: info.Status, + Health: info.Health, + } + } + + state := dcStateFile{ + Timestamp: time.Now().Format("2006-01-02 15:04:05"), + Containers: containers, + } + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + log.Printf("⚠️ Ошибка сериализации state-файла docker: %v", err) + return + } + + if err := os.WriteFile(cfgDockerStateFilePath, data, 0644); err != nil { + log.Printf("⚠️ Ошибка записи state-файла %s: %v", cfgDockerStateFilePath, err) + } +} + +// ── Отправка алерта ────────────────────────────────────────────────────────── + +func dcSendAlert(hostname string, changes []dcStateChange) { + var problems, recoveries []dcStateChange + for _, ch := range changes { + if ch.isRecovery() { + recoveries = append(recoveries, ch) + } else if ch.isProblem() { + problems = append(problems, ch) + } + } + + // Если нет ни проблем, ни восстановлений — молчим + if len(problems) == 0 && len(recoveries) == 0 { + return + } + + // Заголовок по наиболее критичному событию + var header string + switch { + case len(problems) > 0 && len(recoveries) > 0: + header = "⚠️ Изменения в состоянии контейнеров" + case len(problems) > 0: + header = "🚨 АЛЕРТ: Проблемы с контейнерами!" + default: + header = "✅ Контейнеры восстановлены" + } + + message := fmt.Sprintf( + "%s\n\n"+ + "🖥 Хост: %s\n"+ + "⏰ Время: %s\n\n", + header, + hostname, + time.Now().Format("2006-01-02 15:04:05"), + ) + + // Проблемные контейнеры + if len(problems) > 0 { + message += "━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" + message += fmt.Sprintf("❌ Проблемы (%d):\n\n", len(problems)) + for _, ch := range problems { + emoji := dcChangeEmoji(ch) + switch ch.Kind { + case dcKindDisappeared: + message += fmt.Sprintf( + "%s %s\n"+ + " 📌 Контейнер исчез (был: %s)\n\n", + emoji, ch.Name, ch.PrevState.Status, + ) + case dcKindStatusChanged: + message += fmt.Sprintf( + "%s %s\n"+ + " 📌 %s%s\n"+ + " 🐳 %s\n"+ + " 🔧 docker logs %s --tail 50\n\n", + emoji, ch.Name, + ch.PrevState.Status, ch.CurrState.Status, + ch.Image, ch.Name, + ) + case dcKindHealthChanged: + message += fmt.Sprintf( + "%s %s\n"+ + " 📌 health: %s%s\n"+ + " 🐳 %s\n"+ + " 🔧 docker inspect %s\n\n", + emoji, ch.Name, + ch.PrevState.Health, ch.CurrState.Health, + ch.Image, ch.Name, + ) + } + } + } + + // Восстановленные контейнеры + if len(recoveries) > 0 { + message += "━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" + message += fmt.Sprintf("✅ Восстановились (%d):\n\n", len(recoveries)) + for _, ch := range recoveries { + switch ch.Kind { + case dcKindStatusChanged: + message += fmt.Sprintf( + "✅ %s\n"+ + " 📌 %s%s\n\n", + ch.Name, ch.PrevState.Status, ch.CurrState.Status, + ) + case dcKindHealthChanged: + message += fmt.Sprintf( + "✅ %s\n"+ + " 📌 health: %s%s\n\n", + ch.Name, ch.PrevState.Health, ch.CurrState.Health, + ) + } + } + } + + message += "━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" + message += "💡 docker ps -a | docker logs <имя> --tail 50" + + if err := telegramSendHTML(message); err != nil { + log.Printf("❌ Ошибка отправки в Telegram: %v", err) + } else { + log.Println("✅ Алерт успешно отправлен в Telegram") + } +} + +// ── Вспомогательные функции ────────────────────────────────────────────────── + +func dcStatusEmoji(status, health string) string { + if health == "unhealthy" { + return "🤒" + } + switch status { + case "running": + return "✅" + case "restarting": + return "🔄" + case "exited": + return "🔴" + case "dead": + return "💀" + case "paused": + return "⏸" + default: + return "❓" + } +} + +func dcChangeEmoji(ch dcStateChange) string { + switch ch.Kind { + case dcKindDisappeared: + return "👻" + case dcKindHealthChanged: + return "🤒" + case dcKindStatusChanged: + switch ch.CurrState.Status { + case "restarting": + return "🔄" + case "exited": + return "🔴" + case "dead": + return "💀" + } + } + return "❓" +} + +func dcChangeArrow(ch dcStateChange) string { + if ch.isRecovery() { + return "✅" + } + if ch.isProblem() { + return "❌" + } + return "🔄" +} diff --git a/main.go b/main.go index daa957c..e3d7b41 100644 --- a/main.go +++ b/main.go @@ -7,7 +7,7 @@ import ( "strings" ) -const appVersion = "1.1.0" +const appVersion = "1.2.0" // Цвета терминала (общие для всех модулей) const ( @@ -86,6 +86,13 @@ func runByFlag(flag string, extraArgs []string) { os.Exit(1) } + case "-dc", "-d": + fmt.Printf("%s🐳 Auspex — Проверка контейнеров Docker%s\n\n", cCyan, cReset) + if err := runDockerCheck(); err != nil { + fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) + os.Exit(1) + } + case "-v", "--version": fmt.Printf("auspex v%s\n", appVersion) @@ -109,9 +116,10 @@ func showMenu() { fmt.Printf(" %s4%s) %s-m %s Проверка совпадений портов Angie ↔ Docker\n", cGreen, cReset, cCyan, cReset) fmt.Printf(" %s5%s) %s-l %s Проверка активности логов (PostgreSQL)\n", cGreen, cReset, cCyan, cReset) fmt.Printf(" %s6%s) %s-sv%s Проверка состояния служб (systemd)\n", cGreen, cReset, cCyan, cReset) + fmt.Printf(" %s7%s) %s-dc%s Проверка контейнеров Docker\n", cGreen, cReset, cCyan, cReset) fmt.Printf(" %s0%s) Выход\n\n", cGreen, cReset) fmt.Printf("%sДля быстрого вызова: auspex <ключ>%s\n", cYellow, cReset) - fmt.Printf("%sПример: auspex -ss | auspex -c | auspex -p | auspex -m [клиент] | auspex -l | auspex -sv%s\n\n", cYellow, cReset) + fmt.Printf("%sПример: auspex -ss | auspex -c | auspex -p | auspex -m [клиент] | auspex -l | auspex -sv | auspex -dc%s\n\n", cYellow, cReset) reader := bufio.NewReader(os.Stdin) @@ -154,11 +162,16 @@ func showMenu() { fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) } return + case "7", "-dc", "-d", "dc", "d": + if err := runDockerCheck(); err != nil { + fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) + } + return case "0", "q", "quit", "exit": fmt.Println("Выход.") return default: - fmt.Printf("%sНеизвестная команда. Введите 1-6 или 0 для выхода.%s\n", cRed, cReset) + fmt.Printf("%sНеизвестная команда. Введите 1-7 или 0 для выхода.%s\n", cRed, cReset) } } } @@ -184,6 +197,7 @@ func printUsage() { fmt.Printf(" %-12s Проверка совпадений портов Angie ↔ Docker [имя_клиента]\n", "-m") fmt.Printf(" %-12s Проверка активности логов (PostgreSQL)\n", "-l") fmt.Printf(" %-12s Проверка состояния служб systemd\n", "-sv") + fmt.Printf(" %-12s Проверка контейнеров Docker\n", "-dc") fmt.Printf(" %-12s Показать версию\n", "-v") fmt.Printf(" %-12s Показать эту справку\n\n", "-h") fmt.Printf("Без аргументов запускается интерактивное меню.\n")