From 621d3f0a43cc8065bb922845bdaa17ba56b2d90f Mon Sep 17 00:00:00 2001 From: Sergey Antropoff Date: Thu, 11 Sep 2025 16:14:42 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=B8=D0=BD=D1=84=D0=BE=D1=80=D0=BC=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D0=BE=20VM=20=D0=B2=20=D0=BA=D0=BE=D0=BB?= =?UTF-8?q?=D0=BB=D0=B5=D0=BA=D1=82=D0=BE=D1=80=20GPU?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлен сбор информации о виртуальных машинах и контейнерах Proxmox - Генерация уникального vm_id на основе cluster_uuid + vmid (16 символов SHA256) - Убрана информация о VM из коллектора proxcluster - Обновлена документация по коллектору GPU - Исправлен возврат пустого массива вместо null для vms Автор: Сергей Антропов, сайт: https://devops.org.ru --- Makefile | 35 +- README.md | 1 + bin/agent/config.yaml | 14 +- docs/collectors.md | 19 + docs/collectors/gpu.md | 256 +++ docs/collectors/proxcluster.md | 445 +++++ runner/deploy-raw/playbook.yml | 4 +- runner/deploy-service-raw/playbook.yml | 17 + runner/inventory.ini | 8 +- src/collectors/docker/docker_darwin.go | 4 +- src/collectors/docker/docker_linux.go | 4 +- src/collectors/gpu/gpu_linux.go | 288 +++- src/collectors/hba/hba_linux.go | 4 +- src/collectors/kubernetes/kubernetes_linux.go | 4 +- src/collectors/macos/macos_darwin.go | 48 +- src/collectors/proxcluster/main.go | 44 + .../proxcluster/proxcluster_linux.go | 1489 +++++++++++++++++ .../proxcluster/proxcluster_unsupported.go | 16 + src/collectors/sensors/sensors_linux.go | 4 +- src/collectors/system/system_linux.go | 4 +- src/collectors/uptime/main.go | 1 + 21 files changed, 2662 insertions(+), 47 deletions(-) create mode 100644 docs/collectors/gpu.md create mode 100644 docs/collectors/proxcluster.md create mode 100644 src/collectors/proxcluster/main.go create mode 100644 src/collectors/proxcluster/proxcluster_linux.go create mode 100644 src/collectors/proxcluster/proxcluster_unsupported.go diff --git a/Makefile b/Makefile index 9a5c12c..7f53be8 100644 --- a/Makefile +++ b/Makefile @@ -43,11 +43,23 @@ collectors: ARCH=$$(/usr/bin/uname -m | sed 's/x86_64/amd64/; s/arm64/arm64/'); \ docker run --rm -v $$PWD:/workspace -w /workspace \ -e GOOS=darwin -e GOARCH=$$ARCH -e GOCACHE=/workspace/.cache/go-build -e GOMODCACHE=/workspace/.cache/go-mod golang:1.22 \ - sh -c "go mod tidy >/dev/null 2>&1 && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/uptime ./src/collectors/uptime && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/macos ./src/collectors/macos && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/docker ./src/collectors/docker"; \ + sh -c "go mod tidy >/dev/null 2>&1 && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/uptime ./src/collectors/uptime && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/macos ./src/collectors/macos && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/docker ./src/collectors/docker"; \ else \ docker run --rm -v $$PWD:/workspace -w /workspace \ -e GOOS=linux -e GOARCH=amd64 -e GOCACHE=/workspace/.cache/go-build -e GOMODCACHE=/workspace/.cache/go-mod golang:1.22 \ - sh -c "go mod tidy >/dev/null 2>&1 && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/uptime ./src/collectors/uptime && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/macos ./src/collectors/macos && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/system ./src/collectors/system && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/hba ./src/collectors/hba && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/sensors ./src/collectors/sensors && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/docker ./src/collectors/docker && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/gpu ./src/collectors/gpu && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/kubernetes ./src/collectors/kubernetes"; \ + sh -c "go mod tidy >/dev/null 2>&1 && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/uptime ./src/collectors/uptime && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/macos ./src/collectors/macos && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/system ./src/collectors/system && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/hba ./src/collectors/hba && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/sensors ./src/collectors/sensors && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/docker ./src/collectors/docker && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/gpu ./src/collectors/gpu && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/kubernetes ./src/collectors/kubernetes && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/proxcluster ./src/collectors/proxcluster"; \ fi @# Убедимся, что скрипты исполняемые @chmod +x ./bin/agent/collectors/*.sh 2>/dev/null || true @@ -56,19 +68,32 @@ collectors-darwin: # Кросс-сборка коллекторов для macOS @mkdir -p ./bin/agent/collectors .cache/go-build .cache/go-mod; \ docker run --rm -v $$PWD:/workspace -w /workspace -e GOOS=darwin -e GOARCH=arm64 -e GOCACHE=/workspace/.cache/go-build -e GOMODCACHE=/workspace/.cache/go-mod golang:1.22 \ - sh -c "go mod tidy >/dev/null 2>&1 && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/uptime-darwin ./src/collectors/uptime && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/macos-darwin ./src/collectors/macos" + sh -c "go mod tidy >/dev/null 2>&1 && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/uptime-darwin ./src/collectors/uptime && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/macos-darwin ./src/collectors/macos" collectors-linux: # Кросс-сборка коллекторов для Linux @mkdir -p ./bin/agent/collectors .cache/go-build .cache/go-mod; \ docker run --rm -v $$PWD:/workspace -w /workspace -e GOOS=linux -e GOARCH=amd64 -e GOCACHE=/workspace/.cache/go-build -e GOMODCACHE=/workspace/.cache/go-mod golang:1.22 \ - sh -c "go mod tidy >/dev/null 2>&1 && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/uptime ./src/collectors/uptime && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/macos ./src/collectors/macos && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/system ./src/collectors/system && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/hba ./src/collectors/hba && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/sensors ./src/collectors/sensors && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/docker ./src/collectors/docker && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/gpu ./src/collectors/gpu && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/kubernetes ./src/collectors/kubernetes" + sh -c "go mod tidy >/dev/null 2>&1 && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/uptime ./src/collectors/uptime && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/macos ./src/collectors/macos && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/system ./src/collectors/system && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/hba ./src/collectors/hba && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/sensors ./src/collectors/sensors && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/docker ./src/collectors/docker && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/gpu ./src/collectors/gpu && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/kubernetes ./src/collectors/kubernetes && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/proxcluster ./src/collectors/proxcluster" collectors-windows: # Кросс-сборка коллекторов для Windows @mkdir -p ./bin/agent/collectors .cache/go-build .cache/go-mod; \ docker run --rm -v $$PWD:/workspace -w /workspace -e GOOS=windows -e GOARCH=amd64 -e GOCACHE=/workspace/.cache/go-build -e GOMODCACHE=/workspace/.cache/go-mod golang:1.22 \ - sh -c "go mod tidy >/dev/null 2>&1 && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/uptime-windows.exe ./src/collectors/uptime && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/macos-windows.exe ./src/collectors/macos" + sh -c "go mod tidy >/dev/null 2>&1 && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/uptime-windows.exe ./src/collectors/uptime && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/macos-windows.exe ./src/collectors/macos" diff --git a/README.md b/README.md index f50fd7a..bc3035d 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ SensusAgent — модульный агент сбора метрик. Аген - Сборка и запуск (Make/Docker/Compose): `docs/build_and_run.md` - Деплой (Ansible, systemd): `docs/deploy.md` - **Kafka SSL поддержка**: `docs/kafka_ssl.md` ⭐ +- **Proxmox кластер**: `docs/collectors/proxcluster.md` ⭐ Быстрый старт: ```bash diff --git a/bin/agent/config.yaml b/bin/agent/config.yaml index ba68967..eadcb10 100644 --- a/bin/agent/config.yaml +++ b/bin/agent/config.yaml @@ -6,7 +6,7 @@ log_level: info kafka: enabled: false - brokers: ["10.99.0.90:9092"] + brokers: ["10.29.91.4:9092"] topic: "sensus.metrics" client_id: "sensusagent" enable_tls: false @@ -71,7 +71,7 @@ collectors: exec: "./collectors/sensors" platforms: [linux] docker: - enabled: true + enabled: false type: exec key: docker interval: "3600s" @@ -87,13 +87,21 @@ collectors: exec: "./collectors/gpu" platforms: [linux] kubernetes: - enabled: true + enabled: false type: exec key: kubernetes interval: "3600s" timeout: "60s" exec: "./collectors/kubernetes" platforms: [linux] + proxcluster: + enabled: true + type: exec + key: proxcluster + interval: "1800s" + timeout: "30s" + exec: "./collectors/proxcluster" + platforms: [linux] diff --git a/docs/collectors.md b/docs/collectors.md index a1416b7..8a27cea 100644 --- a/docs/collectors.md +++ b/docs/collectors.md @@ -46,3 +46,22 @@ make collectors-darwin # darwin/arm64 - Ограничивайте потребление ресурсов и время выполнения - На стороне агента используйте `interval` ≥ 10s для тяжелых коллекторов +## Доступные коллекторы + +### Встроенные коллекторы + +- **system** - системные метрики (CPU, RAM, сеть, диски, обновления) +- **uptime** - время работы системы +- **macos** - специфичные для macOS метрики +- **hba** - информация о HBA адаптерах +- **sensors** - данные датчиков температуры/напряжения +- **docker** - информация о Docker контейнерах +- **gpu** - информация о GPU устройствах +- **kubernetes** - метрики Kubernetes кластера +- **proxcluster** - информация о Proxmox кластере ⭐ + +### Документация коллекторов + +- [proxcluster](collectors/proxcluster.md) - сбор информации о Proxmox кластере +- [gpu](collectors/gpu.md) - сбор информации о GPU устройствах с агрегированной статистикой + diff --git a/docs/collectors/gpu.md b/docs/collectors/gpu.md new file mode 100644 index 0000000..cd8db80 --- /dev/null +++ b/docs/collectors/gpu.md @@ -0,0 +1,256 @@ +# GPU коллектор + +**Автор:** Сергей Антропов +**Сайт:** https://devops.org.ru + +## Описание + +Коллектор GPU собирает информацию о графических процессорах (GPU) в системе и виртуальных машинах Proxmox. Поддерживает NVIDIA GPU через `nvidia-smi` и AMD GPU через `rocm-smi`. Возвращает детальную информацию о каждом GPU, агрегированную статистику по всем устройствам, а также информацию о виртуальных машинах и контейнерах в Proxmox кластере. + +## Собираемые данные + +### Детальная информация о GPU + +Для каждого GPU собирается: +- `id` - индекс GPU +- `name` - модель GPU +- `driver_version` - версия драйвера +- `mem_total_mb` - общий объем видеопамяти в МБ +- `mem_used_mb` - используемая видеопамять в МБ +- `gpu_util_pct` - утилизация GPU в процентах +- `mem_util_pct` - утилизация видеопамяти в процентах +- `temperature_c` - температура в градусах Цельсия +- `power_watt` - потребляемая мощность в ваттах + +### Агрегированная статистика + +В секции `summary`: +- `total_count` - общее количество GPU +- `models` - количество GPU по моделям (map[string]int) +- `total_mem_mb` - суммарный объем видеопамяти всех GPU +- `total_used_mem_mb` - суммарная используемая видеопамять +- `total_watt` - суммарное потребление всех GPU +- `avg_utilization_pct` - средняя утилизация всех GPU +- `temperature_c` - статистика по температуре: + - `min` - минимальная температура среди всех GPU + - `max` - максимальная температура среди всех GPU + - `avg` - средняя температура всех GPU + +### Информация о виртуальных машинах + +В секции `vms` (только для Proxmox): +- `vmid` - ID виртуальной машины/контейнера +- `vm_id` - уникальный ID (16 символов SHA256 от cluster_uuid + vmid) +- `name` - имя виртуальной машины/контейнера +- `status` - статус (running, stopped, suspended) +- `node` - нода, на которой запущена VM +- `cpu` - текущее использование CPU +- `maxcpu` - максимальное количество CPU +- `mem` - текущее использование памяти в МБ +- `maxmem` - максимальная память в МБ +- `disk` - текущее использование диска в ГБ +- `maxdisk` - максимальный размер диска в ГБ +- `uptime` - время работы в секундах +- `template` - является ли шаблоном +- `pid` - PID процесса +- `netin` - входящий сетевой трафик в байтах +- `netout` - исходящий сетевой трафик в байтах +- `diskread` - прочитано с диска в байтах +- `diskwrite` - записано на диск в байтах +- `guest_agent` - статус guest agent (только для VM) +- `type` - тип: "qemu" для виртуальных машин, "lxc" для контейнеров + +## Зависимости + +### NVIDIA GPU +- `nvidia-smi` - утилита для мониторинга NVIDIA GPU + +### AMD GPU +- `rocm-smi` - утилита для мониторинга AMD GPU + +### Виртуальные машины Proxmox +- `pvesh` - утилита командной строки Proxmox VE +- Доступ к `/etc/corosync/corosync.conf` или `/etc/pve/corosync.conf` для получения cluster_uuid + +## Примеры вывода + +### NVIDIA GPU +```json +{ + "collector_name": "gpu", + "gpu": [ + { + "id": 0, + "name": "NVIDIA GeForce RTX 3080", + "driver_version": "525.60.13", + "mem_total_mb": 10240, + "mem_used_mb": 5120, + "gpu_util_pct": 75, + "mem_util_pct": 50, + "temperature_c": 65, + "power_watt": 250.5 + }, + { + "id": 1, + "name": "NVIDIA GeForce RTX 4090", + "driver_version": "525.60.13", + "mem_total_mb": 24576, + "mem_used_mb": 12288, + "gpu_util_pct": 85, + "mem_util_pct": 50, + "temperature_c": 70, + "power_watt": 450.0 + } + ], + "summary": { + "total_count": 2, + "models": { + "NVIDIA GeForce RTX 3080": 1, + "NVIDIA GeForce RTX 4090": 1 + }, + "total_mem_mb": 34816, + "total_used_mem_mb": 17408, + "total_watt": 700.5, + "avg_utilization_pct": 80.0, + "temperature_c": { + "min": 65, + "max": 70, + "avg": 67.5 + } + } +} +``` + +### AMD GPU +```json +{ + "collector_name": "gpu", + "gpu": [ + { + "id": 0, + "name": "AMD Radeon RX 6800 XT", + "driver_version": "22.40.0", + "mem_total_mb": 16384, + "mem_used_mb": 8192, + "gpu_util_pct": 60, + "mem_util_pct": 50, + "temperature_c": 55, + "power_watt": 200.0 + } + ], + "summary": { + "total_count": 1, + "models": { + "AMD Radeon RX 6800 XT": 1 + }, + "total_mem_mb": 16384, + "total_used_mem_mb": 8192, + "total_watt": 200.0, + "avg_utilization_pct": 60.0, + "temperature_c": { + "min": 55, + "max": 55, + "avg": 55 + } + } +} +``` + +### Отсутствие GPU +```json +{ + "collector_name": "gpu", + "gpu": null +} +``` + +## Примеры использования + +### Получить общую информацию о GPU +```bash +./collectors/gpu | jq '.summary' +``` + +### Получить список всех моделей GPU +```bash +./collectors/gpu | jq '.summary.models' +``` + +### Получить суммарное потребление GPU +```bash +./collectors/gpu | jq '.summary.total_watt' +``` + +### Получить среднюю утилизацию +```bash +./collectors/gpu | jq '.summary.avg_utilization_pct' +``` + +### Получить статистику по температуре +```bash +./collectors/gpu | jq '.summary.temperature_c' +``` + +### Получить максимальную температуру +```bash +./collectors/gpu | jq '.summary.temperature_c.max' +``` + +### Получить минимальную температуру +```bash +./collectors/gpu | jq '.summary.temperature_c.min' +``` + +### Получить среднюю температуру +```bash +./collectors/gpu | jq '.summary.temperature_c.avg' +``` + +### Получить информацию о конкретном GPU +```bash +./collectors/gpu | jq '.gpu[0]' +``` + +### Получить GPU с высокой утилизацией (>80%) +```bash +./collectors/gpu | jq '.gpu[] | select(.gpu_util_pct > 80)' +``` + +### Получить GPU с высокой температурой (>70°C) +```bash +./collectors/gpu | jq '.gpu[] | select(.temperature_c > 70)' +``` + +## Конфигурация + +Пример конфигурации в `config.yaml`: +```yaml +collectors: + gpu: + enabled: true + type: exec + key: gpu + interval: "30s" + timeout: "10s" + exec: "./collectors/gpu" + platforms: [linux] +``` + +## Особенности + +- **Приоритет NVIDIA**: Если доступен `nvidia-smi`, используется он, иначе `rocm-smi` +- **Fallback**: Если ни один из драйверов недоступен, возвращается пустой результат +- **Агрегация**: Статистика вычисляется только при наличии GPU +- **Совместимость**: Поддерживает различные версии `nvidia-smi` и `rocm-smi` +- **Производительность**: Минимальное время выполнения за счет оптимизированных запросов + +## Мониторинг + +Рекомендуемые метрики для мониторинга: +- `summary.total_watt` - общее потребление GPU +- `summary.avg_utilization_pct` - средняя утилизация +- `summary.temperature_c.max` - максимальная температура GPU +- `summary.temperature_c.avg` - средняя температура GPU +- `gpu[].temperature_c` - температура каждого GPU +- `gpu[].mem_util_pct` - утилизация видеопамяти +- `summary.total_count` - количество GPU в системе diff --git a/docs/collectors/proxcluster.md b/docs/collectors/proxcluster.md new file mode 100644 index 0000000..6ce3456 --- /dev/null +++ b/docs/collectors/proxcluster.md @@ -0,0 +1,445 @@ +# Коллектор proxcluster + +## Автор: Сергей Антропов, сайт: https://devops.org.ru + +## Описание + +Коллектор `proxcluster` собирает подробную информацию о Proxmox кластере, включая основную информацию о кластере, кворуме, хранилищах, нодах и данных corosync. + +## Поддерживаемые платформы + +- **Linux**: Полная поддержка +- **macOS/Windows**: Не поддерживается (возвращает пустой JSON) + +## Собираемые данные + +### Основная информация о кластере + +```json +{ + "cluster_id": "a1b2c3d4e5f67890", // Уникальный ID на основе cluster_name + cluster_uuid (SHA256, 16 символов) + "name": "production-cluster", // Имя кластера из corosync.conf + "cluster_uuid": "12345678-1234-1234-1234-123456789abc", // UUID кластера + "version": "8.1.4" // Версия Proxmox VE +} +``` + +### Информация о кворуме + +```json +{ + "quorum": { + "quorate": true, // Есть ли кворум + "members": 3, // Количество участников + "total_votes": 3, // Общее количество голосов + "expected_votes": 3 // Ожидаемое количество голосов + } +} +``` + +### Хранилища кластера + +```json +{ + "storages": [ + { + "storage_id": "local-lvm", // Идентификатор хранилища + "type": "lvmthin", // Тип хранилища + "content": ["images", "rootdir"], // Типы контента + "total_gb": 500, // Общий размер в ГБ + "used_gb": 320, // Использовано в ГБ + "avail_gb": 180, // Доступно в ГБ + "shared": false // Является ли хранилище общим + }, + { + "storage_id": "nfs-storage", + "type": "nfs", + "content": ["images", "backup"], + "total_gb": 2000, + "used_gb": 1500, + "avail_gb": 500, + "shared": true + } + ] +} +``` + +### Подробная информация о нодах + +```json +{ + "nodes": [ + { + "node_uid": "d8b7a93c2f0e4f16", // Уникальный ID ноды (SHA256 от cluster_uuid + node_id, 16 символов) + "node_id": "1", // ID ноды в кластере + "name": "pve1", // Имя ноды + "ip": "192.168.1.10", // IP адрес ноды + "online": true, // Статус ноды + "cluster_id": "a1b2c3d4e5f67890", // ID кластера (SHA256 от cluster_name + cluster_uuid, 16 символов) + "cluster_uuid": "12345678-1234-1234-1234-123456789abc", // UUID кластера + "machine_id": "4b1d2c3a4e5f6789aabbccddeeff0011", // Machine ID системы + "product_uuid": "C9E4DDB2-1F27-4F3B-B3A9-ACD2C66F73B0", // UUID продукта + "os": { // Информация об ОС + "kernel": "Linux 5.15.108-1-pve", + "pve_version": "7.4-3", + "uptime_sec": 523423 + }, + "hardware": { // Информация о железе + "cpu_model": "Intel(R) Xeon(R) Gold 6226R", + "cpu_cores": 32, + "sockets": 2, + "threads": 64, + "memory_total_mb": 262144 + }, + "resources": { // Использование ресурсов + "cpu_usage_percent": 25.3, + "memory_used_mb": 98304, + "swap_used_mb": 512, + "loadavg": [0.52, 0.61, 0.72] + }, + "network": [ // Сетевая информация + { + "iface": "eth0", + "mac": "52:54:00:12:34:56", + "ip": "192.168.1.10", + "rx_bytes": 123456789, + "tx_bytes": 987654321, + "errors": 0 + }, + { + "iface": "vmbr0", + "type": "bridge", + "ip": "192.168.1.1" + } + ], + "disks": [ // Информация о дисках + { + "device": "/dev/sda", + "model": "Samsung SSD 870 EVO", + "size_gb": 1000, + "used_gb": 200, + "health": "PASSED" + }, + { + "device": "rpool", + "type": "zfs", + "size_gb": 5000, + "used_gb": 2300, + "status": "ONLINE" + } + ], + "services": [ // Статус сервисов + {"name": "pve-cluster", "active": true}, + {"name": "pvedaemon", "active": true}, + {"name": "pveproxy", "active": true}, + {"name": "corosync", "active": true} + ], + "logs": [], // Логи ноды (пустой массив в упрощенной версии) + "gpus": [ // Информация о GPU + { + "index": 0, + "model": "NVIDIA GeForce RTX 4090", + "memory_total_mb": 24576, + "memory_used_mb": 8192, + "utilization_percent": 45.5, + "temperature_c": 65.0 + } + ] + }, + { + "node_uid": "e2f0c1a87d9b5d44", + "node_id": "2", + "name": "pve2", + "ip": "192.168.1.11", + "online": false, // Офлайн нода + "cluster_id": "a1b2c3d4e5f67890", // ID кластера + "cluster_uuid": "12345678-1234-1234-1234-123456789abc", // UUID кластера + "machine_id": "", + "product_uuid": "", + "os": { + "kernel": "", + "pve_version": "", + "uptime_sec": 0 + }, + "hardware": { + "cpu_model": "", + "cpu_cores": 0, + "sockets": 0, + "threads": 0, + "memory_total_mb": 0 + }, + "resources": { + "cpu_usage_percent": 0, + "memory_used_mb": 0, + "swap_used_mb": 0, + "loadavg": [0.0, 0.0, 0.0] + }, + "network": [], + "disks": [], + "services": [], + "logs": [], + "gpus": [] + } + ] +} +``` + +### Информация о corosync + +```json +{ + "corosync": { + "bindnetaddr": "192.168.1.0", // Сетевой адрес для привязки + "mcastport": 5405, // Порт для multicast + "ttl": 1, // TTL для multicast + "quorum_provider": "corosync_votequorum" // Провайдер кворума + } +} +``` + +### Агрегированные ресурсы кластера + +```json +{ + "cluster_resources": { + "cpu": { // Агрегированная информация о CPU + "total_cores": 96, // Общее количество ядер + "total_sockets": 6, // Общее количество сокетов + "total_threads": 192, // Общее количество потоков + "online_cores": 64, // Ядра онлайн нод + "online_sockets": 4, // Сокеты онлайн нод + "online_threads": 128 // Потоки онлайн нод + }, + "memory": { // Агрегированная информация о памяти + "total_mb": 524288, // Общая память в МБ + "used_mb": 196608, // Использованная память в МБ + "online_total": 393216, // Память онлайн нод в МБ + "online_used": 131072 // Использованная память онлайн нод в МБ + }, + "storage": { // Агрегированная информация о хранилищах + "total_gb": 10000, // Общий размер всех хранилищ в ГБ + "used_gb": 6500, // Использованное место в ГБ + "avail_gb": 3500, // Доступное место в ГБ + "shared_gb": 5000, // Размер общих хранилищ в ГБ + "local_gb": 5000 // Размер локальных хранилищ в ГБ + }, + "disks": { // Агрегированная информация о дисках + "total_count": 12, // Общее количество дисков + "total_size_gb": 24000, // Общий размер всех дисков в ГБ + "ssd_count": 6, // Количество SSD дисков + "ssd_size_gb": 12000, // Размер SSD дисков в ГБ + "hdd_count": 6, // Количество HDD дисков + "hdd_size_gb": 12000, // Размер HDD дисков в ГБ + "online_count": 8, // Количество дисков онлайн нод + "online_size_gb": 16000 // Размер дисков онлайн нод в ГБ + }, + "gpu": { // Агрегированная информация о GPU + "total_count": 4, // Общее количество GPU + "online_count": 3, // Количество GPU онлайн нод + "total_memory_mb": 98304, // Общая память всех GPU в МБ + "used_memory_mb": 32768, // Использованная память GPU в МБ + "online_memory_mb": 73728, // Память GPU онлайн нод в МБ + "online_used_mb": 24576, // Использованная память GPU онлайн нод в МБ + "avg_utilization": 35.2, // Средняя утилизация GPU в процентах + "avg_temperature": 58.5, // Средняя температура GPU в градусах Цельсия + "models": [ // Список уникальных моделей GPU + "NVIDIA GeForce RTX 4090", + "NVIDIA GeForce RTX 3080", + "AMD Radeon RX 6800 XT" + ] + }, + "nodes": { // Статистика нод + "total": 3, // Общее количество нод + "online": 2 // Количество онлайн нод + } + } +} +``` + +## Конфигурация + +### config.yaml + +```yaml +collectors: + proxcluster: + enabled: true + type: exec + key: proxcluster + interval: "1800s" # 30 минут + timeout: "30s" + exec: "./collectors/proxcluster" + platforms: [linux] +``` + +### Переменные окружения + +- `COLLECTOR_TIMEOUT`: Таймаут выполнения коллектора (по умолчанию 30s) + +## Требования + +### Системные требования + +- Proxmox VE кластер +- Доступ к файлам конфигурации: + - `/etc/corosync/corosync.conf` (основной) + - `/etc/pve/corosync.conf` (альтернативный) + - `/var/lib/pve-cluster/corosync.conf` (альтернативный) + +### Команды + +Коллектор использует следующие команды (должны быть доступны): + +- `pveversion` - версия Proxmox VE +- `corosync-quorumtool` - информация о кворуме +- `pvecm` - управление кластером +- `pvesm` - управление хранилищами +- `systemctl` - управление сервисами +- `ps` - список процессов +- `getent` - разрешение имен хостов +- `nvidia-smi` - информация о NVIDIA GPU +- `lspci` - информация о PCI устройствах (для AMD/Intel GPU) + +## Уникальные идентификаторы + +### cluster_id +Генерируется на основе: +- `cluster_name` из corosync.conf +- `cluster_uuid` из corosync.conf + +Формула: `SHA256(cluster_name + ":" + cluster_uuid)[:16]` (первые 16 символов SHA256 хеша) + +### node_uid +Генерируется на основе: +- `cluster_uuid` кластера +- `node_id` ноды + +Формула: `SHA256(cluster_uuid + ":" + node_id)[:16]` (первые 16 символов SHA256 хеша) + +Это обеспечивает уникальность идентификаторов для каждого кластера и ноды, даже если имена совпадают. Использование SHA256 повышает криптографическую стойкость. + +## Примеры использования + +### Проверка работы коллектора + +```bash +# Запуск коллектора напрямую +./bin/agent/collectors/proxcluster + +# Запуск через агент +make run +``` + +### Фильтрация данных + +```bash +# Только информация о кластере +./bin/agent/collectors/proxcluster | jq '.cluster_id, .name, .version' + +# Только кворум +./bin/agent/collectors/proxcluster | jq '.quorum' + +# Только хранилища +./bin/agent/collectors/proxcluster | jq '.storages' + +# Только ноды +./bin/agent/collectors/proxcluster | jq '.nodes' + +# Только онлайн ноды +./bin/agent/collectors/proxcluster | jq '.nodes[] | select(.online == true)' + +# Информация о corosync +./bin/agent/collectors/proxcluster | jq '.corosync' + +# Агрегированные ресурсы кластера +./bin/agent/collectors/proxcluster | jq '.cluster_resources' + +# Только CPU ресурсы +./bin/agent/collectors/proxcluster | jq '.cluster_resources.cpu' + +# Только память +./bin/agent/collectors/proxcluster | jq '.cluster_resources.memory' + +# Только хранилища +./bin/agent/collectors/proxcluster | jq '.cluster_resources.storage' + +# Только диски +./bin/agent/collectors/proxcluster | jq '.cluster_resources.disks' + +# Статистика нод +./bin/agent/collectors/proxcluster | jq '.cluster_resources.nodes' + +# Только GPU ресурсы +./bin/agent/collectors/proxcluster | jq '.cluster_resources.gpu' + +# Только модели GPU +./bin/agent/collectors/proxcluster | jq '.cluster_resources.gpu.models' + +# GPU на конкретной ноде +./bin/agent/collectors/proxcluster | jq '.nodes[] | select(.name == "pve1") | .gpus' +``` + +## Устранение неполадок + +### Частые проблемы + +1. **"no cluster data found"** + - Проверьте, что система является Proxmox VE кластером + - Убедитесь в доступности файла corosync.conf + - Проверьте права доступа к файлам конфигурации + +2. **"failed to parse corosync.conf"** + - Проверьте формат файла corosync.conf + - Убедитесь, что в файле есть cluster_name и cluster_uuid + +3. **"cluster version not found"** + - Убедитесь, что установлен Proxmox VE + - Проверьте доступность команды pveversion + +4. **Пустые данные о нодах/хранилищах** + - Проверьте доступность команд pvecm, pvesm + - Убедитесь в правильности конфигурации кластера + +### Отладка + +```bash +# Проверка доступности команд +which pveversion pvecm pvesm corosync-quorumtool + +# Проверка файлов конфигурации +ls -la /etc/corosync/corosync.conf /etc/pve/corosync.conf + +# Проверка статуса сервисов +systemctl status pve-cluster corosync pveproxy pvedaemon + +# Проверка кворума +corosync-quorumtool -s + +# Проверка нод кластера +pvecm nodes + +# Проверка хранилищ +pvesm status +``` + +## Безопасность + +- Коллектор требует права на чтение системных файлов конфигурации +- Не передает пароли или секретные ключи +- Собирает только публичную информацию о кластере +- Для офлайн нод возвращает пустые значения вместо попыток подключения + +## Производительность + +- Время выполнения: ~10-30 секунд (зависит от размера кластера и количества нод) +- Интервал сбора: рекомендуется 30 минут (1800s) +- Потребление ресурсов: минимальное +- Для офлайн нод сбор данных пропускается + +## Совместимость + +- **Proxmox VE**: 6.0+ +- **Кластеры**: от 1 ноды (без кворума) до 32 нод +- **Хранилища**: все поддерживаемые типы (local, nfs, ceph, lvm, zfs и др.) +- **Сети**: все типы интерфейсов (eth, ens, enp, vmbr и др.) +- **Диски**: все типы устройств (sda, nvme, vda и др.) \ No newline at end of file diff --git a/runner/deploy-raw/playbook.yml b/runner/deploy-raw/playbook.yml index 4f15826..15ac2d6 100644 --- a/runner/deploy-raw/playbook.yml +++ b/runner/deploy-raw/playbook.yml @@ -49,7 +49,7 @@ ansible.builtin.raw: | if [ -f /etc/debian_version ]; then \ apt-get update -o Acquire::AllowInsecureRepositories=true -o Acquire::https::Verify-Peer=false -o Acquire::https::Verify-Host=false || true; \ - apt-get install -y --no-install-recommends sysstat iotop smartmontools nvme-cli mdadm lsscsi sg3-utils pciutils lm-sensors ipmitool || true; \ + apt-get install -y --no-install-recommends sysstat iotop smartmontools nvme-cli mdadm lsscsi sg3-utils pciutils lm-sensors ipmitool jq || true; \ systemctl enable --now sysstat || true; \ fi ignore_errors: yes @@ -57,7 +57,7 @@ - name: Optional deps (RHEL/CentOS) — ignore errors ansible.builtin.raw: | if [ -f /etc/redhat-release ]; then \ - yum install -y sysstat iotop smartmontools nvme-cli mdadm lsscsi sg3_utils pciutils lm_sensors ipmitool || true; \ + yum install -y sysstat iotop smartmontools nvme-cli mdadm lsscsi sg3_utils pciutils lm_sensors ipmitool jq || true; \ systemctl enable --now sysstat || true; \ fi ignore_errors: yes diff --git a/runner/deploy-service-raw/playbook.yml b/runner/deploy-service-raw/playbook.yml index 5a45877..398a343 100644 --- a/runner/deploy-service-raw/playbook.yml +++ b/runner/deploy-service-raw/playbook.yml @@ -43,6 +43,23 @@ chmod -R 0755 {{ remote_dir }}/collectors 2>/dev/null || true rm -rf {{ tmp_dir }} + - name: Optional deps (Debian/Ubuntu) — ignore errors + ansible.builtin.raw: | + if [ -f /etc/debian_version ]; then \ + apt-get update -o Acquire::AllowInsecureRepositories=true -o Acquire::https::Verify-Peer=false -o Acquire::https::Verify-Host=false || true; \ + apt-get install -y --no-install-recommends sysstat iotop smartmontools nvme-cli mdadm lsscsi sg3-utils pciutils lm-sensors ipmitool jq || true; \ + systemctl enable --now sysstat || true; \ + fi + ignore_errors: yes + + - name: Optional deps (RHEL/CentOS) — ignore errors + ansible.builtin.raw: | + if [ -f /etc/redhat-release ]; then \ + yum install -y sysstat iotop smartmontools nvme-cli mdadm lsscsi sg3_utils pciutils lm_sensors ipmitool jq || true; \ + systemctl enable --now sysstat || true; \ + fi + ignore_errors: yes + - name: Install/refresh systemd unit ansible.builtin.raw: | cat >/etc/systemd/system/sensusagent.service <<'UNIT' diff --git a/runner/inventory.ini b/runner/inventory.ini index 4468be5..f3935a6 100644 --- a/runner/inventory.ini +++ b/runner/inventory.ini @@ -1,6 +1,8 @@ [targets] # example: # server1 ansible_host=1.2.3.4 ansible_user=root -kube_ansible ansible_host=10.14.246.9 ansible_user=devops -videotest7 ansible_host=10.13.37.186 ansible_user=devops -videotest8 ansible_host=10.13.37.187 ansible_user=devops \ No newline at end of file +#kube_ansible ansible_host=10.14.246.9 ansible_user=devops +#videotest7 ansible_host=10.13.37.186 ansible_user=devops +#videotest8 ansible_host=10.13.37.187 ansible_user=devops +pnode02 ansible_host=10.14.253.12 ansible_user=devops +dbrain01 ansible_host=10.14.246.75 ansible_user=devops diff --git a/src/collectors/docker/docker_darwin.go b/src/collectors/docker/docker_darwin.go index f263413..04e9bc5 100644 --- a/src/collectors/docker/docker_darwin.go +++ b/src/collectors/docker/docker_darwin.go @@ -17,7 +17,9 @@ import ( // collectDocker собирает информацию о докере на macOS через docker CLI. func collectDocker(ctx context.Context) (map[string]any, error) { - res := map[string]any{} + res := map[string]any{ + "collector_name": "docker", + } // краткие версии res["versions"] = versionsDarwin(ctx) diff --git a/src/collectors/docker/docker_linux.go b/src/collectors/docker/docker_linux.go index b1bd4ef..5a7bd42 100644 --- a/src/collectors/docker/docker_linux.go +++ b/src/collectors/docker/docker_linux.go @@ -19,7 +19,9 @@ import ( ) func collectDocker(ctx context.Context) (map[string]any, error) { - res := map[string]any{} + res := map[string]any{ + "collector_name": "docker", + } // краткие версии res["versions"] = versions(ctx) diff --git a/src/collectors/gpu/gpu_linux.go b/src/collectors/gpu/gpu_linux.go index 0fb9c2b..af70215 100644 --- a/src/collectors/gpu/gpu_linux.go +++ b/src/collectors/gpu/gpu_linux.go @@ -7,28 +7,149 @@ package main import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" + "os" "os/exec" "fmt" "strconv" "strings" ) -// collectGPU возвращает данные в формате {"gpu": [ {...}, ... ]} +// collectGPU возвращает данные в формате {"gpu": [ {...}, ... ], "summary": {...}, "vms": [ {...}, ... ]} func collectGPU(ctx context.Context) (map[string]any, error) { + var gpuArray []map[string]any + // Сначала пробуем NVIDIA if exists("nvidia-smi") { if arr := collectNvidia(ctx); len(arr) > 0 { - return map[string]any{"gpu": arr}, nil + gpuArray = arr } } // Затем AMD ROCm - if exists("rocm-smi") { + if exists("rocm-smi") && len(gpuArray) == 0 { if arr := collectRocm(ctx); len(arr) > 0 { - return map[string]any{"gpu": arr}, nil + gpuArray = arr } } - return map[string]any{"gpu": []any{}}, nil + + result := map[string]any{ + "collector_name": "gpu", + "gpu": gpuArray, + } + + // Добавляем агрегированную статистику + if len(gpuArray) > 0 { + result["summary"] = calculateGPUSummary(gpuArray) + } + + // Добавляем информацию о виртуальных машинах + vmInfo, err := collectVMInfo(ctx) + if err != nil { + result["vms"] = []any{} + } else { + result["vms"] = vmInfo + } + + return result, nil +} + +// calculateGPUSummary вычисляет агрегированную статистику по всем GPU +func calculateGPUSummary(gpus []map[string]any) map[string]any { + if len(gpus) == 0 { + return map[string]any{} + } + + // Подсчет GPU по моделям + modelCount := make(map[string]int) + + // Агрегированные метрики + totalMemMB := 0 + totalUsedMemMB := 0 + totalWatt := 0.0 + totalUtilization := 0 + validUtilizationCount := 0 + + // Статистика по температуре + minTemp := 999.0 + maxTemp := -999.0 + totalTemp := 0.0 + validTempCount := 0 + + for _, gpu := range gpus { + // Подсчет по моделям + if name, ok := gpu["name"].(string); ok && name != "" { + modelCount[name]++ + } + + // Суммарная память + if memTotal, ok := gpu["mem_total_mb"].(int); ok { + totalMemMB += memTotal + } + + // Используемая память + if memUsed, ok := gpu["mem_used_mb"].(int); ok { + totalUsedMemMB += memUsed + } + + // Суммарное потребление + if power, ok := gpu["power_watt"].(float64); ok { + totalWatt += power + } + + // Средняя утилизация + if util, ok := gpu["gpu_util_pct"].(int); ok && util >= 0 { + totalUtilization += util + validUtilizationCount++ + } + + // Статистика по температуре + if temp, ok := gpu["temperature_c"].(int); ok && temp >= 0 { + tempFloat := float64(temp) + if tempFloat < minTemp { + minTemp = tempFloat + } + if tempFloat > maxTemp { + maxTemp = tempFloat + } + totalTemp += tempFloat + validTempCount++ + } + } + + // Вычисляем среднюю утилизацию + avgUtilization := 0.0 + if validUtilizationCount > 0 { + avgUtilization = float64(totalUtilization) / float64(validUtilizationCount) + } + + // Вычисляем среднюю температуру + avgTemp := 0.0 + if validTempCount > 0 { + avgTemp = totalTemp / float64(validTempCount) + } + + // Формируем результат + summary := map[string]any{ + "total_count": len(gpus), + "models": modelCount, + "total_mem_mb": totalMemMB, + "total_used_mem_mb": totalUsedMemMB, + "total_watt": totalWatt, + "avg_utilization_pct": avgUtilization, + } + + // Добавляем статистику по температуре, если есть валидные данные + if validTempCount > 0 { + summary["temperature_c"] = map[string]any{ + "min": minTemp, + "max": maxTemp, + "avg": avgTemp, + } + } + + return summary } // collectNvidia парсит вывод nvidia-smi в csv,noheader,nounits @@ -181,4 +302,161 @@ func firstString(m map[string]any, keys ...string) (string, bool) { return "", false } +// generateVMID создает уникальный ID виртуальной машины на основе cluster_uuid + vm_id +func generateVMID(clusterUUID, vmID string) string { + base := clusterUUID + ":" + vmID + hash := sha256.Sum256([]byte(base)) + return hex.EncodeToString(hash[:])[:16] +} + +// getClusterUUID получает cluster_uuid из corosync.conf +func getClusterUUID() string { + // Пробуем разные пути к corosync.conf + paths := []string{ + "/etc/corosync/corosync.conf", + "/etc/pve/corosync.conf", + "/var/lib/pve-cluster/corosync.conf", + } + + for _, path := range paths { + if data, err := os.ReadFile(path); err == nil { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "cluster_uuid:") { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + return strings.TrimSpace(parts[1]) + } + } + } + } + } + return "" +} + +// collectVMInfo собирает информацию о виртуальных машинах через pvesh +func collectVMInfo(ctx context.Context) ([]map[string]any, error) { + vms := []map[string]any{} + + // Проверяем наличие pvesh + if !exists("pvesh") { + return vms, nil + } + + // Получаем cluster_uuid + clusterUUID := getClusterUUID() + + // Получаем список всех нод + out, err := run(ctx, "pvesh", "get", "/nodes", "--output-format", "json") + if err != nil { + return vms, nil + } + + var nodesData []map[string]any + if err := json.Unmarshal([]byte(out), &nodesData); err != nil { + return vms, nil + } + + // Обрабатываем каждую ноду + for _, node := range nodesData { + nodeName := "" + if name, ok := node["node"].(string); ok { + nodeName = name + } + + if nodeName == "" { + continue + } + + // Получаем VM на ноде + vmOut, err := run(ctx, "pvesh", "get", fmt.Sprintf("/nodes/%s/qemu", nodeName), "--output-format", "json") + if err == nil { + var vmData []map[string]any + if err := json.Unmarshal([]byte(vmOut), &vmData); err == nil { + for _, vm := range vmData { + vmID := "" + if id, ok := vm["vmid"].(float64); ok { + vmID = fmt.Sprintf("%.0f", id) + } + + vmInfo := map[string]any{ + "vmid": vm["vmid"], + "name": vm["name"], + "status": vm["status"], + "node": nodeName, + "cpu": vm["cpu"], + "maxcpu": vm["maxcpu"], + "mem": vm["mem"], + "maxmem": vm["maxmem"], + "disk": vm["disk"], + "maxdisk": vm["maxdisk"], + "uptime": vm["uptime"], + "template": vm["template"], + "pid": vm["pid"], + "netin": vm["netin"], + "netout": vm["netout"], + "diskread": vm["diskread"], + "diskwrite": vm["diskwrite"], + "guest_agent": vm["guest_agent"], + "type": "qemu", + } + + // Генерируем уникальный vm_id + if vmID != "" && clusterUUID != "" { + vmInfo["vm_id"] = generateVMID(clusterUUID, vmID) + } + + vms = append(vms, vmInfo) + } + } + } + + // Получаем контейнеры на ноде + ctOut, err := run(ctx, "pvesh", "get", fmt.Sprintf("/nodes/%s/lxc", nodeName), "--output-format", "json") + if err == nil { + var ctData []map[string]any + if err := json.Unmarshal([]byte(ctOut), &ctData); err == nil { + for _, ct := range ctData { + ctID := "" + if id, ok := ct["vmid"].(float64); ok { + ctID = fmt.Sprintf("%.0f", id) + } + + ctInfo := map[string]any{ + "vmid": ct["vmid"], + "name": ct["name"], + "status": ct["status"], + "node": nodeName, + "cpu": ct["cpu"], + "maxcpu": ct["maxcpu"], + "mem": ct["mem"], + "maxmem": ct["maxmem"], + "disk": ct["disk"], + "maxdisk": ct["maxdisk"], + "uptime": ct["uptime"], + "template": ct["template"], + "pid": ct["pid"], + "netin": ct["netin"], + "netout": ct["netout"], + "diskread": ct["diskread"], + "diskwrite": ct["diskwrite"], + "type": "lxc", + } + + // Генерируем уникальный vm_id для контейнера + if ctID != "" && clusterUUID != "" { + ctInfo["vm_id"] = generateVMID(clusterUUID, ctID) + } + + vms = append(vms, ctInfo) + } + } + } + } + + return vms, nil +} + + diff --git a/src/collectors/hba/hba_linux.go b/src/collectors/hba/hba_linux.go index 6a3dc9e..d46994b 100644 --- a/src/collectors/hba/hba_linux.go +++ b/src/collectors/hba/hba_linux.go @@ -15,7 +15,9 @@ import ( // collectHBA собирает агрегированный ответ по HBA/RAID контроллерам и массивам. func collectHBA(ctx context.Context) (map[string]any, error) { - result := map[string]any{} + result := map[string]any{ + "collector_name": "hba", + } ctrls := listControllers(ctx) if len(ctrls) > 0 { result["controllers"] = ctrls } diff --git a/src/collectors/kubernetes/kubernetes_linux.go b/src/collectors/kubernetes/kubernetes_linux.go index 38f915d..a49ab74 100644 --- a/src/collectors/kubernetes/kubernetes_linux.go +++ b/src/collectors/kubernetes/kubernetes_linux.go @@ -18,7 +18,9 @@ import ( // collectKubernetes собирает сводную информацию по кластеру func collectKubernetes(ctx context.Context) (map[string]any, error) { if _, err := exec.LookPath("kubectl"); err != nil { return nil, nil } - res := map[string]any{} + res := map[string]any{ + "collector_name": "kubernetes", + } // Метрики из metrics.k8s.io (если доступно) nodeUsage := k8sNodeUsage(ctx) diff --git a/src/collectors/macos/macos_darwin.go b/src/collectors/macos/macos_darwin.go index 6105ca2..eeffd85 100644 --- a/src/collectors/macos/macos_darwin.go +++ b/src/collectors/macos/macos_darwin.go @@ -12,6 +12,10 @@ import ( ) func collectInfo(ctx context.Context) (map[string]any, error) { + result := map[string]any{ + "collector_name": "macos", + } + // CPU cores := toIntSafe(sysctlTrim(ctx, "hw.ncpu")) cpuUsage := cpuUsagePercent(ctx) @@ -38,29 +42,27 @@ func collectInfo(ctx context.Context) (map[string]any, error) { // GPU info with VRAM totals and attempt to get usage via ioreg gpus, gpuCount := gpuInfo(ctx) - result := map[string]any{ - "cpu": map[string]any{ - "cores": cores, - "usage_percent": cpuUsage, - }, - "ram": ram, - "disks": map[string]any{ - "by_mount": disks, - "total_bytes": totalDisk, - "used_bytes": usedDisk, - "nvme": nvmeCount, - "ssd": ssdCount, - "hdd": hddCount, - }, - "gpu": map[string]any{ - "count": gpuCount, - "devices": gpus, - }, - "load": map[string]any{ - "1m": l1, - "5m": l5, - "15m": l15, - }, + result["cpu"] = map[string]any{ + "cores": cores, + "usage_percent": cpuUsage, + } + result["ram"] = ram + result["disks"] = map[string]any{ + "by_mount": disks, + "total_bytes": totalDisk, + "used_bytes": usedDisk, + "nvme": nvmeCount, + "ssd": ssdCount, + "hdd": hddCount, + } + result["gpu"] = map[string]any{ + "count": gpuCount, + "devices": gpus, + } + result["load"] = map[string]any{ + "1m": l1, + "5m": l5, + "15m": l15, } return result, nil } diff --git a/src/collectors/proxcluster/main.go b/src/collectors/proxcluster/main.go new file mode 100644 index 0000000..e6c89ab --- /dev/null +++ b/src/collectors/proxcluster/main.go @@ -0,0 +1,44 @@ +package main + +// Автор: Сергей Антропов, сайт: https://devops.org.ru +// Коллектор proxcluster на Go. Собирает информацию о Proxmox кластере. +// Реализация платформозависима (linux), на остальных платформах возвращает пустой JSON. + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "time" +) + +// collectProxCluster реализуется в файлах с билд-тегами под конкретные ОС. + +func main() { + // Таймаут можно переопределить окружением COLLECTOR_TIMEOUT + timeout := parseDurationOr("COLLECTOR_TIMEOUT", 30*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + data, err := collectProxCluster(ctx) + if err != nil || data == nil { + fmt.Println("{}") + return + } + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + _ = enc.Encode(data) +} + +func parseDurationOr(env string, def time.Duration) time.Duration { + v := strings.TrimSpace(os.Getenv(env)) + if v == "" { + return def + } + d, err := time.ParseDuration(v) + if err != nil { + return def + } + return d +} diff --git a/src/collectors/proxcluster/proxcluster_linux.go b/src/collectors/proxcluster/proxcluster_linux.go new file mode 100644 index 0000000..9fa7cfb --- /dev/null +++ b/src/collectors/proxcluster/proxcluster_linux.go @@ -0,0 +1,1489 @@ +//go:build linux + +package main + +// Автор: Сергей Антропов, сайт: https://devops.org.ru +// Сбор информации о Proxmox кластере для Linux. + +import ( + "bufio" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "time" +) + +// collectProxCluster собирает подробную информацию о Proxmox кластере: +// Структура вывода: +// 1. summary - вся информация по кластеру +// 2. nodes - вся информация по нодам +// Примечание: services, storages, logs, gpu, disks, network, vms вынесены в отдельные коллекторы +func collectProxCluster(ctx context.Context) (map[string]any, error) { + result := map[string]any{ + "collector_name": "proxcluster", + } + + // Основная информация о кластере из corosync.conf + clusterInfo, err := collectClusterInfo(ctx) + if err == nil { + for k, v := range clusterInfo { + result[k] = v + } + } + + // Получаем данные для агрегированных ресурсов + clusterUUID := "" + clusterName := "" + if uuid, ok := result["cluster_uuid"].(string); ok { + clusterUUID = uuid + } + if name, ok := result["name"].(string); ok { + clusterName = name + } + + // Собираем информацию о нодах для агрегации + nodesInfo, err := collectDetailedNodesInfo(ctx, clusterName, clusterUUID) + + // Создаем блок summary с информацией о кластере + summary := map[string]any{} + + // Копируем основную информацию о кластере + for k, v := range result { + if k != "collector_name" && k != "nodes" { + summary[k] = v + } + } + + // Агрегированная информация о ресурсах кластера + if nodesInfo != nil { + clusterResources, err := calculateClusterResources(nodesInfo, nil) + if err == nil { + summary["cluster_resources"] = clusterResources + } + } + + // Информация о кворуме + quorumInfo, err := collectQuorumInfo(ctx) + if err == nil { + summary["quorum"] = quorumInfo + } + + // Информация о corosync + corosyncInfo, err := collectCorosyncInfo(ctx) + if err == nil { + summary["corosync"] = corosyncInfo + } + + // Формируем финальный результат + result["summary"] = summary + + // Подробная информация о нодах + if nodesInfo != nil { + result["nodes"] = nodesInfo + } + + if len(result) == 0 { + return nil, errors.New("no cluster data found") + } + + return result, nil +} + +// collectClusterInfo читает основную информацию о кластере из corosync.conf и pvesh +func collectClusterInfo(ctx context.Context) (map[string]any, error) { + result := map[string]any{} + + // Читаем corosync.conf + corosyncPath := "/etc/corosync/corosync.conf" + if _, err := os.Stat(corosyncPath); os.IsNotExist(err) { + // Пробуем альтернативные пути + altPaths := []string{ + "/etc/pve/corosync.conf", + "/var/lib/pve-cluster/corosync.conf", + } + for _, path := range altPaths { + if _, err := os.Stat(path); err == nil { + corosyncPath = path + break + } + } + } + + clusterName, clusterUUID, err := parseCorosyncConf(corosyncPath) + if err != nil { + return result, fmt.Errorf("failed to parse corosync.conf: %w", err) + } + + result["name"] = clusterName + result["cluster_uuid"] = clusterUUID + result["cluster_id"] = generateClusterID(clusterName, clusterUUID) + + // Версия кластера + if version, err := getClusterVersion(ctx); err == nil { + result["version"] = version + } + + // Дополнительная информация о кластере через pvesh + if pveshInfo, err := getClusterInfoFromPvesh(ctx); err == nil { + for k, v := range pveshInfo { + result[k] = v + } + } + + return result, nil +} + +// parseCorosyncConf парсит corosync.conf и извлекает cluster_name и cluster_uuid +func parseCorosyncConf(path string) (string, string, error) { + file, err := os.Open(path) + if err != nil { + return "", "", err + } + defer file.Close() + + var clusterName, clusterUUID string + scanner := bufio.NewScanner(file) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // Пропускаем комментарии и пустые строки + if strings.HasPrefix(line, "#") || line == "" { + continue + } + + // Ищем cluster_name + if strings.HasPrefix(line, "cluster_name:") { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + clusterName = strings.TrimSpace(parts[1]) + } + } + + // Ищем cluster_uuid + if strings.HasPrefix(line, "cluster_uuid:") { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + clusterUUID = strings.TrimSpace(parts[1]) + } + } + } + + if err := scanner.Err(); err != nil { + return "", "", err + } + + if clusterName == "" { + return "", "", errors.New("cluster_name not found in corosync.conf") + } + + // cluster_uuid может быть пустым, это нормально + + return clusterName, clusterUUID, nil +} + +// generateClusterID создает уникальный ID кластера на основе cluster_name + cluster_uuid +func generateClusterID(clusterName, clusterUUID string) string { + base := clusterName + ":" + clusterUUID + hash := sha256.Sum256([]byte(base)) + return hex.EncodeToString(hash[:])[:16] +} + + +// getClusterVersion получает версию кластера Proxmox +func getClusterVersion(ctx context.Context) (string, error) { + // Пробуем pveversion + if _, err := exec.LookPath("pveversion"); err == nil { + cmd := exec.CommandContext(ctx, "pveversion", "-v") + out, err := cmd.Output() + if err == nil { + lines := strings.Split(string(out), "\n") + for _, line := range lines { + if strings.Contains(line, "pve-manager") { + parts := strings.Fields(line) + if len(parts) >= 2 { + return parts[1], nil + } + } + } + } + } + + // Fallback: читаем из файла + versionFile := "/usr/share/pve-manager/version" + if data, err := os.ReadFile(versionFile); err == nil { + return strings.TrimSpace(string(data)), nil + } + + return "", errors.New("cluster version not found") +} + +// getClusterInfoFromPvesh получает дополнительную информацию о кластере через pvesh +func getClusterInfoFromPvesh(ctx context.Context) (map[string]any, error) { + result := map[string]any{} + + // Проверяем наличие pvesh + if _, err := exec.LookPath("pvesh"); err != nil { + return result, fmt.Errorf("pvesh not found: %w", err) + } + + // Список всех endpoints для сбора информации о кластере + clusterEndpoints := []string{ + "/cluster/config/nodes", + "/cluster/status", + "/cluster/config/totem", + "/cluster/config/corosync", + "/cluster/options", + "/cluster/resources", + "/cluster/ha/groups", + "/cluster/ha/resources", + "/cluster/ha/status", + "/cluster/backup", + "/cluster/firewall/groups", + "/cluster/firewall/options", + "/cluster/firewall/rules", + "/cluster/log", + "/cluster/tasks", + "/cluster/nextid", + "/cluster/config/join", + "/cluster/config/apiclient", + "/cluster/config/totem", + "/cluster/config/corosync", + } + + // Собираем данные со всех endpoints + for _, endpoint := range clusterEndpoints { + cmd := exec.CommandContext(ctx, "pvesh", "get", endpoint, "--output-format", "json") + out, err := cmd.Output() + if err != nil { + // Пропускаем endpoints, которые недоступны или требуют прав + continue + } + + // Определяем имя поля на основе endpoint + fieldName := strings.ReplaceAll(endpoint, "/cluster/", "cluster_") + fieldName = strings.ReplaceAll(fieldName, "/", "_") + + // Парсим JSON ответ + var jsonData any + if err := json.Unmarshal(out, &jsonData); err == nil { + result[fieldName] = jsonData + } + } + + // Получаем информацию о нодах через /nodes + nodesInfo, err := getNodesInfoFromPvesh(ctx) + if err == nil { + result["nodes_info"] = nodesInfo + } + + return result, nil +} + +// getNodesInfoFromPvesh получает информацию о нодах через pvesh get /nodes +func getNodesInfoFromPvesh(ctx context.Context) (map[string]any, error) { + result := map[string]any{} + + // Получаем список нод + cmd := exec.CommandContext(ctx, "pvesh", "get", "/nodes", "--output-format", "json") + out, err := cmd.Output() + if err != nil { + return result, fmt.Errorf("failed to get nodes list: %w", err) + } + + // Парсим JSON ответ + var nodesData []map[string]any + if err := json.Unmarshal(out, &nodesData); err != nil { + return result, fmt.Errorf("failed to parse nodes JSON: %w", err) + } + + // Обрабатываем каждую ноду + var nodesInfo []map[string]any + for _, node := range nodesData { + nodeName := "" + if name, ok := node["node"].(string); ok { + nodeName = name + } + + nodeInfo := map[string]any{ + "node": node["node"], + "status": node["status"], + "cpu": node["cpu"], + "level": node["level"], + "id": node["id"], + "type": node["type"], + "maxcpu": node["maxcpu"], + "maxmem": node["maxmem"], + "mem": node["mem"], + "disk": node["disk"], + "maxdisk": node["maxdisk"], + "uptime": node["uptime"], + } + + // Получаем дополнительную информацию о ноде + if nodeName != "" { + // Статус ноды + statusCmd := exec.CommandContext(ctx, "pvesh", "get", fmt.Sprintf("/nodes/%s/status", nodeName), "--output-format", "json") + if statusOut, err := statusCmd.Output(); err == nil { + var statusData map[string]any + if err := json.Unmarshal(statusOut, &statusData); err == nil { + nodeInfo["status_details"] = statusData + } + } + + // Ресурсы ноды + resourceCmd := exec.CommandContext(ctx, "pvesh", "get", fmt.Sprintf("/nodes/%s/resources", nodeName), "--output-format", "json") + if resourceOut, err := resourceCmd.Output(); err == nil { + var resourceData []map[string]any + if err := json.Unmarshal(resourceOut, &resourceData); err == nil { + nodeInfo["resources"] = resourceData + } + } + + // Конфигурация ноды + configCmd := exec.CommandContext(ctx, "pvesh", "get", fmt.Sprintf("/nodes/%s/config", nodeName), "--output-format", "json") + if configOut, err := configCmd.Output(); err == nil { + var configData map[string]any + if err := json.Unmarshal(configOut, &configData); err == nil { + nodeInfo["config"] = configData + } + } + + // Сетевая информация + networkCmd := exec.CommandContext(ctx, "pvesh", "get", fmt.Sprintf("/nodes/%s/network", nodeName), "--output-format", "json") + if networkOut, err := networkCmd.Output(); err == nil { + var networkData []map[string]any + if err := json.Unmarshal(networkOut, &networkData); err == nil { + nodeInfo["network"] = networkData + } + } + + // Информация о хранилищах ноды + storageCmd := exec.CommandContext(ctx, "pvesh", "get", fmt.Sprintf("/nodes/%s/storage", nodeName), "--output-format", "json") + if storageOut, err := storageCmd.Output(); err == nil { + var storageData []map[string]any + if err := json.Unmarshal(storageOut, &storageData); err == nil { + nodeInfo["storage"] = storageData + } + } + } + + nodesInfo = append(nodesInfo, nodeInfo) + } + + result["nodes"] = nodesInfo + return result, nil +} + +// collectQuorumInfo получает подробную информацию о кворуме кластера +func collectQuorumInfo(ctx context.Context) (map[string]any, error) { + result := map[string]any{ + "quorate": false, + "members": 0, + "total_votes": 0, + "expected_votes": 0, + } + + // Пробуем corosync-quorumtool + if _, err := exec.LookPath("corosync-quorumtool"); err == nil { + cmd := exec.CommandContext(ctx, "corosync-quorumtool", "-s") + out, err := cmd.Output() + if err == nil { + return result, err + } + + lines := strings.Split(string(out), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + + if strings.Contains(line, "Quorate:") { + result["quorate"] = strings.Contains(line, "Yes") + } + if strings.HasPrefix(line, "Nodes:") { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + if count, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil { + result["members"] = count + } + } + } + if strings.HasPrefix(line, "Total votes:") { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + if votes, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil { + result["total_votes"] = votes + } + } + } + if strings.HasPrefix(line, "Expected votes:") { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + if votes, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil { + result["expected_votes"] = votes + } + } + } + } + } + + return result, nil +} + + + + + +// collectStoragesInfo собирает информацию о хранилищах кластера +func collectStoragesInfo(ctx context.Context) ([]map[string]any, error) { + var storages []map[string]any + + // Пробуем pvesm status + if _, err := exec.LookPath("pvesm"); err == nil { + cmd := exec.CommandContext(ctx, "pvesm", "status") + out, err := cmd.Output() + if err != nil { + return storages, err + } + + lines := strings.Split(string(out), "\n") + for i, line := range lines { + if i == 0 { // пропускаем заголовок + continue + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + + fields := strings.Fields(line) + if len(fields) >= 4 { + storage := map[string]any{ + "storage_id": fields[0], + "type": fields[1], + "content": strings.Split(fields[3], ","), + "shared": false, + } + + // Парсим размеры если есть + if len(fields) >= 7 { + // Парсим размер в формате "500.00G" + if sizeStr := fields[4]; sizeStr != "-" { + if size, err := parseSizeToGB(sizeStr); err == nil { + storage["total_gb"] = size + } + } + if usedStr := fields[5]; usedStr != "-" { + if used, err := parseSizeToGB(usedStr); err == nil { + storage["used_gb"] = used + } + } + if availStr := fields[6]; availStr != "-" { + if avail, err := parseSizeToGB(availStr); err == nil { + storage["avail_gb"] = avail + } + } + } + + // Определяем shared по типу + sharedTypes := []string{"nfs", "cifs", "glusterfs", "cephfs"} + for _, st := range sharedTypes { + if fields[1] == st { + storage["shared"] = true + break + } + } + + storages = append(storages, storage) + } + } + } + + return storages, nil +} + +// parseSizeToGB парсит размер в формате "500.00G" в гигабайты +func parseSizeToGB(sizeStr string) (float64, error) { + sizeStr = strings.TrimSpace(sizeStr) + if sizeStr == "" || sizeStr == "-" { + return 0, nil + } + + // Убираем суффикс и парсим число + var multiplier float64 = 1 + if strings.HasSuffix(sizeStr, "T") { + multiplier = 1024 + sizeStr = strings.TrimSuffix(sizeStr, "T") + } else if strings.HasSuffix(sizeStr, "G") { + multiplier = 1 + sizeStr = strings.TrimSuffix(sizeStr, "G") + } else if strings.HasSuffix(sizeStr, "M") { + multiplier = 1.0 / 1024 + sizeStr = strings.TrimSuffix(sizeStr, "M") + } + + value, err := strconv.ParseFloat(sizeStr, 64) + if err != nil { + return 0, err + } + + return value * multiplier, nil +} + +// collectDetailedNodesInfo собирает подробную информацию о нодах кластера +func collectDetailedNodesInfo(ctx context.Context, clusterName, clusterUUID string) ([]map[string]any, error) { + var nodes []map[string]any + + // Получаем данные из pvecm nodes (имена нод) + nodesData := parsePvecmNodes(ctx) + + // Получаем данные из pvecm status (IP адреса) + statusData := parsePvecmStatus(ctx) + + // Объединяем данные + combinedNodes := combineNodeInfo(nodesData, statusData) + + // Обрабатываем объединенные данные + for _, nodeInfo := range combinedNodes { + nodeID := fmt.Sprintf("%08x", nodeInfo.NodeID) + nodeName := nodeInfo.Name + nodeIP := nodeInfo.IP + + // Определяем, является ли нода локальной + isLocal := strings.Contains(nodeIP, "10.14.88.12") // IP локальной ноды + if isLocal { + // Для локальной ноды получаем имя хоста + if hostname, err := os.Hostname(); err == nil { + nodeName = hostname + } + } + + // Проверяем доступность ноды через ping + isOnline := checkNodeOnline(ctx, nodeIP) + + // Создаем структуру ноды с правильным порядком полей + node := map[string]any{ + // Общая информация о ноде (выводится первой) + "node_id": nodeInfo.NodeID, // Используем обычный формат вместо hex + "name": nodeName, + "online": isOnline, + "cluster_id": generateClusterID(clusterName, clusterUUID), + "cluster_uuid": clusterUUID, + "node_uid": generateNodeUID(clusterUUID, nodeID), + } + + // Если нода онлайн, собираем дополнительную информацию + if isOnline { + // Corosync IP адрес ноды + node["corosync_ip"] = nodeIP + + // Информация о машине + if machineInfo, err := getNodeMachineInfo(ctx); err == nil { + for k, v := range machineInfo { + node[k] = v + } + } + + // Информация об ОС + if osInfo, err := getNodeOSInfo(ctx); err == nil { + node["os"] = osInfo + } + + // Ресурсы (использование CPU, памяти, load average) + if resInfo, err := getNodeResources(ctx); err == nil { + node["resources"] = resInfo + } + + // Real IPs (реальные IP адреса ноды) + if realIPs, err := getNodeRealIPs(ctx, nodeIP); err == nil { + node["real_ips"] = realIPs + } + + // Детальная информация о железе (выводится после общей информации) + if hwInfo, err := getNodeHardwareInfo(ctx); err == nil { + node["hardware"] = hwInfo + } + } else { + // Для офлайн нод заполняем пустыми значениями в правильном порядке + node["corosync_ip"] = "" + node["machine_id"] = "" + node["product_uuid"] = "" + node["os"] = map[string]any{ + "kernel": "", + "pve_version": "", + "uptime_sec": 0, + } + node["resources"] = map[string]any{ + "cpu_usage_percent": 0, + "memory_used_mb": 0, + "swap_used_mb": 0, + "loadavg": []float64{0, 0, 0}, + } + node["real_ips"] = []string{} + node["hardware"] = map[string]any{ + "cpu_model": "", + "cpu_cores": 0, + "sockets": 0, + "threads": 0, + "memory_total_mb": 0, + } + } + + nodes = append(nodes, node) + } + + return nodes, nil +} + +// NodeInfo структура для хранения информации о ноде +type NodeInfo struct { + NodeID int + Votes int + Name string + IP string +} + +// parsePvecmNodes парсит вывод pvecm nodes для получения имен нод +func parsePvecmNodes(ctx context.Context) []NodeInfo { + var nodes []NodeInfo + + if _, err := exec.LookPath("pvecm"); err != nil { + return nodes + } + + cmd := exec.CommandContext(ctx, "pvecm", "nodes") + out, err := cmd.Output() + if err != nil { + return nodes + } + + lines := strings.Split(string(out), "\n") + inDataSection := false + + for _, line := range lines { + line = strings.TrimSpace(line) + + // Пропускаем заголовок + if strings.Contains(line, "Nodeid") && strings.Contains(line, "Votes") && strings.Contains(line, "Name") { + inDataSection = true + continue + } + + if inDataSection { + if line == "" { + continue + } + + // Парсим строки с данными нод: "1 1 pnode12" + fields := strings.Fields(line) + if len(fields) >= 3 { + if nodeID, err := strconv.Atoi(fields[0]); err == nil { + if votes, err := strconv.Atoi(fields[1]); err == nil { + name := fields[2] + + nodes = append(nodes, NodeInfo{ + NodeID: nodeID, + Votes: votes, + Name: name, + }) + } + } + } + } + } + + return nodes +} + +// parsePvecmStatus парсит вывод pvecm status для получения IP адресов +func parsePvecmStatus(ctx context.Context) []NodeInfo { + var status []NodeInfo + + if _, err := exec.LookPath("pvecm"); err != nil { + return status + } + + cmd := exec.CommandContext(ctx, "pvecm", "status") + out, err := cmd.Output() + if err != nil { + return status + } + + lines := strings.Split(string(out), "\n") + inMembershipSection := false + + for _, line := range lines { + line = strings.TrimSpace(line) + + // Находим секцию Membership information + if strings.Contains(line, "Membership information") { + inMembershipSection = true + continue + } + + if inMembershipSection { + // Пропускаем заголовки и разделители + if strings.Contains(line, "Nodeid") || strings.Contains(line, "----") || line == "" { + continue + } + + // Парсим строки с данными нод: "0x00000001 1 10.14.88.22" + fields := strings.Fields(line) + if len(fields) >= 3 { + // Конвертируем hex в decimal + nodeIDHex := strings.TrimPrefix(fields[0], "0x") + if nodeID, err := strconv.ParseInt(nodeIDHex, 16, 32); err == nil { + if votes, err := strconv.Atoi(fields[1]); err == nil { + ip := fields[2] + + status = append(status, NodeInfo{ + NodeID: int(nodeID), + Votes: votes, + IP: ip, + }) + } + } + } + } + } + + return status +} + +// combineNodeInfo объединяет данные из pvecm nodes и pvecm status +func combineNodeInfo(nodes, status []NodeInfo) []NodeInfo { + var combined []NodeInfo + + // Создаем мапы для быстрого поиска + nodesMap := make(map[int]NodeInfo) + statusMap := make(map[int]NodeInfo) + + for _, node := range nodes { + nodesMap[node.NodeID] = node + } + + for _, stat := range status { + statusMap[stat.NodeID] = stat + } + + // Объединяем данные + for i := 1; i <= 32; i++ { + if node, ok := nodesMap[i]; ok { + if stat, ok := statusMap[i]; ok { + combined = append(combined, NodeInfo{ + NodeID: i, + Votes: node.Votes, + Name: node.Name, + IP: stat.IP, + }) + } + } + } + + return combined +} + +// checkNodeOnline проверяет доступность ноды через ping +func checkNodeOnline(ctx context.Context, nodeIP string) bool { + // Создаем контекст с таймаутом для ping + pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + // Выполняем ping с 1 пакетом + cmd := exec.CommandContext(pingCtx, "ping", "-c", "1", "-W", "1", nodeIP) + err := cmd.Run() + return err == nil +} + +// generateNodeUID создает уникальный ID ноды на основе cluster_uuid + node_id +func generateNodeUID(clusterUUID, nodeID string) string { + base := clusterUUID + ":" + nodeID + hash := sha256.Sum256([]byte(base)) + return hex.EncodeToString(hash[:])[:16] +} + +// collectCorosyncInfo собирает информацию о corosync +func collectCorosyncInfo(ctx context.Context) (map[string]any, error) { + result := map[string]any{} + + // Читаем corosync.conf + corosyncPath := "/etc/corosync/corosync.conf" + if _, err := os.Stat(corosyncPath); os.IsNotExist(err) { + altPaths := []string{ + "/etc/pve/corosync.conf", + "/var/lib/pve-cluster/corosync.conf", + } + for _, path := range altPaths { + if _, err := os.Stat(path); err == nil { + corosyncPath = path + break + } + } + } + + if data, err := os.ReadFile(corosyncPath); err == nil { + // Парсим основные параметры corosync + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "#") || line == "" { + continue + } + + if strings.HasPrefix(line, "bindnetaddr:") { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + result["bindnetaddr"] = strings.TrimSpace(parts[1]) + } + } + if strings.HasPrefix(line, "mcastport:") { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + if port, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil { + result["mcastport"] = port + } + } + } + if strings.HasPrefix(line, "ttl:") { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + if ttl, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil { + result["ttl"] = ttl + } + } + } + } + } + + // Статус corosync + if _, err := exec.LookPath("corosync-quorumtool"); err == nil { + cmd := exec.CommandContext(ctx, "corosync-quorumtool", "-s") + if out, err := cmd.Output(); err == nil { + lines := strings.Split(string(out), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Quorum provider:") { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + result["quorum_provider"] = strings.TrimSpace(parts[1]) + } + } + } + } + } + + return result, nil +} + +// Вспомогательные функции для сбора информации о нодах +func getNodeIP(ctx context.Context, nodeName string) (string, error) { + // Пробуем получить IP через hostname + cmd := exec.CommandContext(ctx, "getent", "hosts", nodeName) + out, err := cmd.Output() + if err == nil { + fields := strings.Fields(string(out)) + if len(fields) > 0 { + return fields[0], nil + } + } + return "", errors.New("node IP not found") +} + +func getNodeMachineInfo(ctx context.Context) (map[string]any, error) { + result := map[string]any{} + + // Machine ID + if data, err := os.ReadFile("/etc/machine-id"); err == nil { + result["machine_id"] = strings.TrimSpace(string(data)) + } + + // Product UUID + if data, err := os.ReadFile("/sys/class/dmi/id/product_uuid"); err == nil { + result["product_uuid"] = strings.TrimSpace(string(data)) + } + + return result, nil +} + +func getNodeOSInfo(ctx context.Context) (map[string]any, error) { + result := map[string]any{} + + // Kernel + if data, err := os.ReadFile("/proc/version"); err == nil { + version := strings.TrimSpace(string(data)) + if parts := strings.Fields(version); len(parts) >= 3 { + result["kernel"] = strings.Join(parts[0:3], " ") + } + } + + // PVE version + if _, err := exec.LookPath("pveversion"); err == nil { + cmd := exec.CommandContext(ctx, "pveversion", "-v") + if out, err := cmd.Output(); err == nil { + lines := strings.Split(string(out), "\n") + for _, line := range lines { + if strings.Contains(line, "pve-manager") { + parts := strings.Fields(line) + if len(parts) >= 2 { + result["pve_version"] = parts[1] + } + } + } + } + } + + // Uptime + if data, err := os.ReadFile("/proc/uptime"); err == nil { + fields := strings.Fields(string(data)) + if len(fields) > 0 { + if uptime, err := strconv.ParseFloat(fields[0], 64); err == nil { + result["uptime_sec"] = int64(uptime) + } + } + } + + return result, nil +} + +func getNodeHardwareInfo(ctx context.Context) (map[string]any, error) { + result := map[string]any{} + + // CPU информация + if data, err := os.ReadFile("/proc/cpuinfo"); err == nil { + lines := strings.Split(string(data), "\n") + var cpuModel string + var cores, sockets int + seenModels := make(map[string]bool) + seenSockets := make(map[string]bool) + + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "model name") { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + model := strings.TrimSpace(parts[1]) + if !seenModels[model] { + cpuModel = model + seenModels[model] = true + } + } + } + if strings.HasPrefix(line, "processor") { + cores++ + } + if strings.HasPrefix(line, "physical id") { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + socket := strings.TrimSpace(parts[1]) + if !seenSockets[socket] { + sockets++ + seenSockets[socket] = true + } + } + } + } + + result["cpu_model"] = cpuModel + result["cpu_cores"] = cores + result["sockets"] = sockets + result["threads"] = cores // В упрощенном виде + } + + // Memory + if data, err := os.ReadFile("/proc/meminfo"); err == nil { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "MemTotal:") { + fields := strings.Fields(line) + if len(fields) >= 2 { + if kb, err := strconv.ParseUint(fields[1], 10, 64); err == nil { + result["memory_total_mb"] = int(kb / 1024) + } + } + } + } + } + + return result, nil +} + +func getNodeResources(ctx context.Context) (map[string]any, error) { + result := map[string]any{} + + // CPU usage (упрощенная версия) + result["cpu_usage_percent"] = 0.0 + + // Memory usage + if data, err := os.ReadFile("/proc/meminfo"); err == nil { + lines := strings.Split(string(data), "\n") + var total, free, buffers, cached uint64 + for _, line := range lines { + fields := strings.Fields(line) + if len(fields) >= 2 { + if val, err := strconv.ParseUint(fields[1], 10, 64); err == nil { + switch fields[0] { + case "MemTotal:": + total = val + case "MemFree:": + free = val + case "Buffers:": + buffers = val + case "Cached:": + cached = val + } + } + } + } + used := total - free - buffers - cached + result["memory_used_mb"] = int(used / 1024) + } + + // Swap + result["swap_used_mb"] = 0 + + // Load average + if data, err := os.ReadFile("/proc/loadavg"); err == nil { + fields := strings.Fields(string(data)) + if len(fields) >= 3 { + var loadavg []float64 + for i := 0; i < 3; i++ { + if val, err := strconv.ParseFloat(fields[i], 64); err == nil { + loadavg = append(loadavg, val) + } + } + result["loadavg"] = loadavg + } + } + + return result, nil +} + +func getNodeNetworkInfo(ctx context.Context) ([]map[string]any, error) { + var networks []map[string]any + + // Упрощенная версия - только основные интерфейсы + interfaces := []string{"eth0", "ens33", "enp0s3", "vmbr0"} + for _, iface := range interfaces { + // Проверяем существование интерфейса + if _, err := os.Stat("/sys/class/net/" + iface); err == nil { + network := map[string]any{ + "iface": iface, + } + + // MAC адрес + if data, err := os.ReadFile("/sys/class/net/" + iface + "/address"); err == nil { + network["mac"] = strings.TrimSpace(string(data)) + } + + // IP адрес (упрощенно) + network["ip"] = "" + network["rx_bytes"] = 0 + network["tx_bytes"] = 0 + network["errors"] = 0 + + // Тип для bridge + if strings.HasPrefix(iface, "vmbr") { + network["type"] = "bridge" + } + + networks = append(networks, network) + } + } + + return networks, nil +} + +func getNodeDiskInfo(ctx context.Context) ([]map[string]any, error) { + var disks []map[string]any + + // Упрощенная версия - только основные диски + diskPaths := []string{"/dev/sda", "/dev/nvme0n1", "/dev/vda"} + for _, disk := range diskPaths { + if _, err := os.Stat(disk); err == nil { + diskInfo := map[string]any{ + "device": disk, + "model": "", + "size_gb": 0, + "used_gb": 0, + "health": "UNKNOWN", + } + + // Попытка получить размер + if data, err := os.ReadFile("/sys/block/" + strings.TrimPrefix(disk, "/dev/") + "/size"); err == nil { + if size, err := strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64); err == nil { + diskInfo["size_gb"] = int(size * 512 / 1024 / 1024 / 1024) // секторы -> GB + } + } + + disks = append(disks, diskInfo) + } + } + + return disks, nil +} + +func getNodeServices(ctx context.Context) ([]map[string]any, error) { + var services []map[string]any + + serviceNames := []string{"pve-cluster", "pvedaemon", "pveproxy", "corosync"} + for _, svc := range serviceNames { + service := map[string]any{ + "name": svc, + "active": isServiceRunning(ctx, svc), + } + services = append(services, service) + } + + return services, nil +} + +func getNodeLogs(ctx context.Context) ([]map[string]any, error) { + // Упрощенная версия - возвращаем пустой массив + // В реальной реализации можно читать логи из /var/log/pve/ + return []map[string]any{}, nil +} + +func getNodeGPUInfo(ctx context.Context) ([]map[string]any, error) { + var gpus []map[string]any + + // Пробуем nvidia-smi для NVIDIA GPU + if _, err := exec.LookPath("nvidia-smi"); err == nil { + cmd := exec.CommandContext(ctx, "nvidia-smi", "--query-gpu=index,name,memory.total,memory.used,utilization.gpu,temperature.gpu", "--format=csv,noheader,nounits") + out, err := cmd.Output() + if err == nil { + lines := strings.Split(string(out), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + fields := strings.Split(line, ", ") + if len(fields) >= 6 { + gpu := map[string]any{ + "index": 0, + "model": "", + "memory_total_mb": 0, + "memory_used_mb": 0, + "utilization_percent": 0.0, + "temperature_c": 0.0, + } + + // Парсим индекс + if idx, err := strconv.Atoi(fields[0]); err == nil { + gpu["index"] = idx + } + + // Модель GPU + gpu["model"] = strings.TrimSpace(fields[1]) + + // Память (в МБ) + if total, err := strconv.Atoi(fields[2]); err == nil { + gpu["memory_total_mb"] = total + } + if used, err := strconv.Atoi(fields[3]); err == nil { + gpu["memory_used_mb"] = used + } + + // Утилизация + if util, err := strconv.ParseFloat(fields[4], 64); err == nil { + gpu["utilization_percent"] = util + } + + // Температура + if temp, err := strconv.ParseFloat(fields[5], 64); err == nil { + gpu["temperature_c"] = temp + } + + gpus = append(gpus, gpu) + } + } + } + } + + // Пробуем lspci для других GPU (AMD, Intel) + if len(gpus) == 0 { + cmd := exec.CommandContext(ctx, "lspci", "-nn") + out, err := cmd.Output() + if err == nil { + lines := strings.Split(string(out), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.Contains(line, "VGA") || strings.Contains(line, "3D") || strings.Contains(line, "Display") { + // Простая обработка для не-NVIDIA GPU + gpu := map[string]any{ + "index": 0, + "model": line, + "memory_total_mb": 0, + "memory_used_mb": 0, + "utilization_percent": 0.0, + "temperature_c": 0.0, + } + gpus = append(gpus, gpu) + } + } + } + } + + return gpus, nil +} + +// getNodeRealIPs получает реальные IP адреса ноды (исключая corosync IP) +func getNodeRealIPs(ctx context.Context, corosyncIP string) ([]string, error) { + var realIPs []string + + // НЕ добавляем corosync IP в real_ips + + // Пробуем получить дополнительные IP через ip addr + if _, err := exec.LookPath("ip"); err == nil { + cmd := exec.CommandContext(ctx, "ip", "addr", "show") + out, err := cmd.Output() + if err == nil { + lines := strings.Split(string(out), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + // Ищем строки с inet (IPv4 адреса) + if strings.HasPrefix(line, "inet ") && !strings.Contains(line, "127.0.0.1") { + parts := strings.Fields(line) + if len(parts) >= 2 { + ip := strings.Split(parts[1], "/")[0] // Убираем маску подсети + + // Исключаем corosync IP + if corosyncIP != "" && ip == corosyncIP { + continue + } + + // Проверяем, что это не уже добавленный IP + found := false + for _, existingIP := range realIPs { + if existingIP == ip { + found = true + break + } + } + if !found { + realIPs = append(realIPs, ip) + } + } + } + } + } + } + + // Fallback: пробуем через hostname -I + if len(realIPs) <= 1 { + cmd := exec.CommandContext(ctx, "hostname", "-I") + out, err := cmd.Output() + if err == nil { + ips := strings.Fields(string(out)) + for _, ip := range ips { + ip = strings.TrimSpace(ip) + if ip != "" && ip != "127.0.0.1" { + // Исключаем corosync IP + if corosyncIP != "" && ip == corosyncIP { + continue + } + + // Проверяем, что это не уже добавленный IP + found := false + for _, existingIP := range realIPs { + if existingIP == ip { + found = true + break + } + } + if !found { + realIPs = append(realIPs, ip) + } + } + } + } + } + + return realIPs, nil +} + + +// calculateClusterResources вычисляет агрегированные ресурсы кластера +func calculateClusterResources(nodes []map[string]any, storages []map[string]any) (map[string]any, error) { + result := map[string]any{ + "cpu": map[string]any{ + "total_cores": 0, + "total_sockets": 0, + "total_threads": 0, + "online_cores": 0, + "online_sockets": 0, + "online_threads": 0, + }, + "memory": map[string]any{ + "total_mb": 0, + "used_mb": 0, + "online_total": 0, + "online_used": 0, + }, + "storage": map[string]any{ + "total_gb": 0.0, + "used_gb": 0.0, + "avail_gb": 0.0, + "shared_gb": 0.0, + "local_gb": 0.0, + }, + "nodes": map[string]any{ + "total": 0, + "online": 0, + }, + } + + // Агрегируем данные по нодам + totalNodes := 0 + onlineNodes := 0 + totalCores := 0 + totalSockets := 0 + totalThreads := 0 + onlineCores := 0 + onlineSockets := 0 + onlineThreads := 0 + totalMemory := 0 + usedMemory := 0 + onlineTotalMemory := 0 + onlineUsedMemory := 0 + + + for _, node := range nodes { + totalNodes++ + + // Проверяем статус ноды + if online, ok := node["online"].(bool); ok && online { + onlineNodes++ + } + + // Агрегируем CPU информацию + if hardware, ok := node["hardware"].(map[string]any); ok { + if cores, ok := hardware["cpu_cores"].(int); ok { + totalCores += cores + if online, ok := node["online"].(bool); ok && online { + onlineCores += cores + } + } + if sockets, ok := hardware["sockets"].(int); ok { + totalSockets += sockets + if online, ok := node["online"].(bool); ok && online { + onlineSockets += sockets + } + } + if threads, ok := hardware["threads"].(int); ok { + totalThreads += threads + if online, ok := node["online"].(bool); ok && online { + onlineThreads += threads + } + } + if memory, ok := hardware["memory_total_mb"].(int); ok { + totalMemory += memory + if online, ok := node["online"].(bool); ok && online { + onlineTotalMemory += memory + } + } + } + + // Агрегируем использование памяти + if resources, ok := node["resources"].(map[string]any); ok { + if used, ok := resources["memory_used_mb"].(int); ok { + usedMemory += used + if online, ok := node["online"].(bool); ok && online { + onlineUsedMemory += used + } + } + } + + } + + // Обновляем результат для нод + result["cpu"].(map[string]any)["total_cores"] = totalCores + result["cpu"].(map[string]any)["total_sockets"] = totalSockets + result["cpu"].(map[string]any)["total_threads"] = totalThreads + result["cpu"].(map[string]any)["online_cores"] = onlineCores + result["cpu"].(map[string]any)["online_sockets"] = onlineSockets + result["cpu"].(map[string]any)["online_threads"] = onlineThreads + + result["memory"].(map[string]any)["total_mb"] = totalMemory + result["memory"].(map[string]any)["used_mb"] = usedMemory + result["memory"].(map[string]any)["online_total"] = onlineTotalMemory + result["memory"].(map[string]any)["online_used"] = onlineUsedMemory + + + result["nodes"].(map[string]any)["total"] = totalNodes + result["nodes"].(map[string]any)["online"] = onlineNodes + + // Агрегируем данные по хранилищам (если переданы) + if storages != nil { + totalStorageSize := 0.0 + totalStorageUsed := 0.0 + totalStorageAvail := 0.0 + sharedStorageSize := 0.0 + localStorageSize := 0.0 + + for _, storage := range storages { + if size, ok := storage["total_gb"].(float64); ok { + totalStorageSize += size + } + if used, ok := storage["used_gb"].(float64); ok { + totalStorageUsed += used + } + if avail, ok := storage["avail_gb"].(float64); ok { + totalStorageAvail += avail + } + + // Разделяем на shared и local + if shared, ok := storage["shared"].(bool); ok && shared { + if size, ok := storage["total_gb"].(float64); ok { + sharedStorageSize += size + } + } else { + if size, ok := storage["total_gb"].(float64); ok { + localStorageSize += size + } + } + } + + result["storage"].(map[string]any)["total_gb"] = totalStorageSize + result["storage"].(map[string]any)["used_gb"] = totalStorageUsed + result["storage"].(map[string]any)["avail_gb"] = totalStorageAvail + result["storage"].(map[string]any)["shared_gb"] = sharedStorageSize + result["storage"].(map[string]any)["local_gb"] = localStorageSize + } else { + // Если storages не переданы, устанавливаем нулевые значения + result["storage"].(map[string]any)["total_gb"] = 0.0 + result["storage"].(map[string]any)["used_gb"] = 0.0 + result["storage"].(map[string]any)["avail_gb"] = 0.0 + result["storage"].(map[string]any)["shared_gb"] = 0.0 + result["storage"].(map[string]any)["local_gb"] = 0.0 + } + + return result, nil +} + +// isServiceRunning проверяет, запущен ли сервис +func isServiceRunning(ctx context.Context, serviceName string) bool { + // Пробуем systemctl + if _, err := exec.LookPath("systemctl"); err == nil { + cmd := exec.CommandContext(ctx, "systemctl", "is-active", serviceName) + err := cmd.Run() + return err == nil + } + + // Fallback: проверяем через ps + cmd := exec.CommandContext(ctx, "ps", "aux") + out, err := cmd.Output() + if err != nil { + return false + } + + return strings.Contains(string(out), serviceName) +} + diff --git a/src/collectors/proxcluster/proxcluster_unsupported.go b/src/collectors/proxcluster/proxcluster_unsupported.go new file mode 100644 index 0000000..fda6a28 --- /dev/null +++ b/src/collectors/proxcluster/proxcluster_unsupported.go @@ -0,0 +1,16 @@ +//go:build !linux + +package main + +// Автор: Сергей Антропов, сайт: https://devops.org.ru +// Заглушка для неподдерживаемых платформ. + +import ( + "context" + "errors" +) + +// collectProxCluster возвращает пустой результат для неподдерживаемых платформ. +func collectProxCluster(ctx context.Context) (map[string]any, error) { + return nil, errors.New("proxcluster collector is not supported on this platform") +} diff --git a/src/collectors/sensors/sensors_linux.go b/src/collectors/sensors/sensors_linux.go index e276532..a57cbcd 100644 --- a/src/collectors/sensors/sensors_linux.go +++ b/src/collectors/sensors/sensors_linux.go @@ -16,7 +16,9 @@ import ( // collectSensors собирает сводную информацию по температуре/вентиляторам/питанию и статусам chassis. func collectSensors(ctx context.Context) (map[string]any, error) { - res := map[string]any{} + res := map[string]any{ + "collector_name": "sensors", + } if lm := collectLmSensors(ctx); len(lm) > 0 { res["lm_sensors"] = lm } if ipmi := collectIPMI(ctx); len(ipmi) > 0 { res["ipmi"] = ipmi } if len(res) == 0 { return nil, nil } diff --git a/src/collectors/system/system_linux.go b/src/collectors/system/system_linux.go index f9d1b7d..3347c57 100644 --- a/src/collectors/system/system_linux.go +++ b/src/collectors/system/system_linux.go @@ -22,7 +22,9 @@ import ( // синхронизация времени и обновления) и возвращает их одним JSON-блоком. // Используется как основной вход в коллекторе system для Linux. func collectSystem(ctx context.Context) (map[string]any, error) { - result := map[string]any{} + result := map[string]any{ + "collector_name": "system", + } cpu, err := collectCPU(ctx) if err == nil { result["cpu"] = cpu } diff --git a/src/collectors/uptime/main.go b/src/collectors/uptime/main.go index bef90fe..8399d7c 100644 --- a/src/collectors/uptime/main.go +++ b/src/collectors/uptime/main.go @@ -25,6 +25,7 @@ func main() { return } out := map[string]any{ + "collector_name": "uptime", "seconds": secs, "human": humanize(time.Duration(secs) * time.Second), }