From 5fa101dfffb2335cf2b44ddc31346c719633e9c3 Mon Sep 17 00:00:00 2001 From: Sergey Antropoff Date: Mon, 15 Sep 2025 17:04:47 +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=20=D0=BA=D0=BE=D0=BB=D0=BB=D0=B5=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=20proxvmservices=20=D0=B4=D0=BB=D1=8F=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BD=D0=B0=D1=80=D1=83=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B8=D1=81=D0=BE=D0=B2=20=D0=BD=D0=B0=20VM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Создан новый коллектор proxvmservices для обнаружения и мониторинга сервисов - Поддержка PostgreSQL с Patroni (кластер, репликация, конфигурация) - Поддержка etcd кластера (члены, лидер, здоровье) - Поддержка остальных сервисов: Redis, ClickHouse, RabbitMQ, Kafka, MongoDB, Kubernetes - Добавлен в Makefile и конфигурацию агента - Обновлены групповые переменные Ansible для включения в группу proxvms - Исправлены проблемы с шаблонами Ansible (конфигурация и systemd unit) - Создана подробная документация - Протестирован на удаленных серверах через Ansible Автор: Сергей Антропов Сайт: https://devops.org.ru --- Makefile | 9 +- bin/agent/config.yaml | 9 + docs/collectors.md | 2 + docs/collectors/proxvmservices.md | 233 +++++ runner/deploy-service/playbook.yml | 36 +- runner/group_vars/proxvms.yml | 6 +- runner/templates/config.yaml.j2 | 37 +- src/collectors/proxvmservices/main.go | 45 + .../proxvmservices/proxvmservices_linux.go | 798 ++++++++++++++++++ .../proxvmservices_unsupported.go | 14 + 10 files changed, 1164 insertions(+), 25 deletions(-) create mode 100644 docs/collectors/proxvmservices.md create mode 100644 src/collectors/proxvmservices/main.go create mode 100644 src/collectors/proxvmservices/proxvmservices_linux.go create mode 100644 src/collectors/proxvmservices/proxvmservices_unsupported.go diff --git a/Makefile b/Makefile index 88e7e06..bb1e1f8 100644 --- a/Makefile +++ b/Makefile @@ -61,7 +61,9 @@ collectors: 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 && \ CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/proxnode ./src/collectors/proxnode && \ - CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/proxvms ./src/collectors/proxvms"; \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/proxvms ./src/collectors/proxvms && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/proxvmsystem ./src/collectors/proxvmsystem && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/proxvmservices ./src/collectors/proxvmservices"; \ fi @# Убедимся, что скрипты исполняемые @chmod +x ./bin/agent/collectors/*.sh 2>/dev/null || true @@ -89,8 +91,9 @@ collectors-linux: 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 && \ CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/proxnode ./src/collectors/proxnode && \ - CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/proxvms ./src/collectors/proxvms && \ - CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/proxvmsystem ./src/collectors/proxvmsystem" + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/proxvms ./src/collectors/proxvms && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/proxvmsystem ./src/collectors/proxvmsystem && \ + CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/proxvmservices ./src/collectors/proxvmservices" collectors-windows: # Кросс-сборка коллекторов для Windows diff --git a/bin/agent/config.yaml b/bin/agent/config.yaml index 21155d3..5a80fc3 100644 --- a/bin/agent/config.yaml +++ b/bin/agent/config.yaml @@ -127,4 +127,13 @@ collectors: exec: "./collectors/proxvmsystem" platforms: [linux] + proxvmservices: + enabled: true + type: exec + key: proxvmservices + interval: "300s" + timeout: "60s" + exec: "./collectors/proxvmservices" + platforms: [linux] + diff --git a/docs/collectors.md b/docs/collectors.md index 2e2ed74..6a5c168 100644 --- a/docs/collectors.md +++ b/docs/collectors.md @@ -62,6 +62,7 @@ make collectors-darwin # darwin/arm64 - **proxnode** - информация о Proxmox ноде (ресурсы, сервисы, диски) - Linux ⭐ - **proxvms** - информация о виртуальных машинах и контейнерах Proxmox - Linux ⭐ - **proxvmsystem** - системные метрики с machine_uid для Proxmox VM/контейнеров - Linux ⭐ +- **proxvmservices** - обнаружение и мониторинг сервисов на VM (PostgreSQL, etcd, Redis, ClickHouse, RabbitMQ, Kafka, MongoDB, Kubernetes) - Linux ⭐ ### Документация коллекторов @@ -70,6 +71,7 @@ make collectors-darwin # darwin/arm64 - [proxnode](collectors/proxnode.md) - сбор информации о Proxmox ноде - [proxvms](collectors/proxvms.md) - сбор информации о виртуальных машинах и контейнерах Proxmox - [proxvmsystem](collectors/proxvmsystem.md) - системные метрики с machine_uid для Proxmox VM/контейнеров +- [proxvmservices](collectors/proxvmservices.md) - обнаружение и мониторинг сервисов на VM - [system](collectors/system.md) - сбор системных метрик - [docker](collectors/docker.md) - сбор информации о Docker контейнерах - [hba](collectors/hba.md) - сбор информации о RAID/HBA контроллерах diff --git a/docs/collectors/proxvmservices.md b/docs/collectors/proxvmservices.md new file mode 100644 index 0000000..fc13b59 --- /dev/null +++ b/docs/collectors/proxvmservices.md @@ -0,0 +1,233 @@ +# Коллектор proxvmservices + +## Описание + +Коллектор `proxvmservices` предназначен для обнаружения и мониторинга сервисов на виртуальных машинах и контейнерах Proxmox. Он автоматически определяет запущенные сервисы, их конфигурацию, состояние кластеров и соединения между сервисами. + +## Поддерживаемые сервисы + +### Кластерные сервисы +- **PostgreSQL** с Patroni - обнаружение кластера, репликации, конфигурации +- **etcd** - обнаружение кластера, членов, лидера, здоровья +- **Kubernetes** - обнаружение кластера, версии, портов + +### Автономные сервисы +- **Redis** - версия, порты, конфигурация +- **ClickHouse** - версия, порты, конфигурация +- **RabbitMQ** - версия, порты, конфигурация +- **Kafka** - порты, конфигурация +- **MongoDB** - версия, порты, конфигурация + +## Методы обнаружения + +### PostgreSQL с Patroni +1. **Процессы**: проверка `postgres` и `patroni` +2. **Порты**: 5432 (PostgreSQL), 8008 (Patroni REST API) +3. **Версия**: через `psql --version` или `postgres --version` +4. **Конфигурация**: парсинг файлов `/etc/patroni/patroni.yml`, `/etc/patroni.yml` +5. **Кластер**: команда `patronictl list` для получения информации о членах кластера +6. **Репликация**: SQL-запрос `SELECT client_addr, state FROM pg_stat_replication` + +### etcd +1. **Процессы**: проверка `etcd` +2. **Порты**: 2379 (client), 2380 (peer) +3. **Версия**: через `etcdctl version` +4. **Конфигурация**: парсинг файлов `/etc/etcd/etcd.conf`, systemd unit +5. **Кластер**: команды `etcdctl member list`, `etcdctl endpoint status`, `etcdctl endpoint health` + +### Kubernetes +1. **Процессы**: проверка `kubelet`, `kube-apiserver` +2. **Порты**: 6443 (API server), 10250 (kubelet) +3. **Версия**: через `kubectl version --client --short` + +### Остальные сервисы +- **Redis**: процесс `redis-server`, порт 6379, версия через `redis-cli --version` +- **ClickHouse**: процесс `clickhouse-server`, порты 8123, 9000, версия через `clickhouse-client --version` +- **RabbitMQ**: процесс `rabbitmq-server`, порты 5672, 15672, версия через `rabbitmqctl version` +- **Kafka**: процесс `kafka.Kafka`, порт 9092 +- **MongoDB**: процесс `mongod`, порт 27017, версия через `mongosh --version` + +## Структура выходных данных + +```json +{ + "collector_name": "proxvmservices", + "execution_time_ms": 280, + "execution_time_seconds": 0.280283673, + "machine_uid": "1581318a2bb03141", + "services": [ + { + "name": "postgresql", + "type": "cluster", + "status": "running", + "version": "14.9", + "ports": [5432, 8008], + "config": { + "config_file": "/etc/patroni/patroni.yml", + "scope": "postgresql_cluster", + "namespace": "/patroni" + }, + "cluster": { + "name": "postgresql_cluster", + "state": "healthy", + "role": "leader", + "members": [ + { + "name": "postgresql-1", + "host": "10.14.246.75", + "port": 5432, + "state": "running", + "role": "leader", + "lag": 0 + } + ], + "etcd_endpoint": "10.14.246.77:2379", + "config": { + "scope": "postgresql_cluster", + "namespace": "/patroni" + } + }, + "connections": [ + { + "type": "replication", + "target": "10.14.246.76", + "status": "streaming" + } + ] + } + ] +} +``` + +## Поля данных + +### ServiceInfo +- `name` - имя сервиса (postgresql, etcd, redis, etc.) +- `type` - тип сервиса ("standalone" или "cluster") +- `status` - статус сервиса ("running", "stopped", "unknown") +- `version` - версия сервиса +- `ports` - массив портов, на которых слушает сервис +- `config` - конфигурация сервиса (файлы, параметры) +- `cluster` - информация о кластере (для кластерных сервисов) +- `connections` - информация о соединениях (репликация, etc.) + +### PatroniClusterInfo (для PostgreSQL) +- `name` - имя кластера +- `state` - состояние кластера ("healthy", "degraded") +- `role` - роль текущего узла ("leader", "replica", "unknown") +- `members` - массив членов кластера +- `etcd_endpoint` - endpoint etcd для Patroni +- `config` - конфигурация Patroni + +### PatroniMember +- `name` - имя члена кластера +- `host` - IP-адрес +- `port` - порт +- `state` - состояние ("running", "stopped") +- `role` - роль ("leader", "replica") +- `lag` - задержка репликации в байтах + +### EtcdClusterInfo (для etcd) +- `name` - имя кластера +- `version` - версия etcd +- `members` - массив членов кластера +- `leader` - ID лидера +- `health` - здоровье кластера ("healthy", "unhealthy") +- `cluster_size` - размер кластера + +### EtcdMember +- `id` - ID члена +- `name` - имя члена +- `peer_urls` - URL для peer-соединений +- `client_urls` - URL для client-соединений +- `is_leader` - является ли лидером +- `status` - статус члена + +### ConnectionInfo +- `type` - тип соединения ("replication", etc.) +- `target` - целевой хост +- `status` - статус соединения + +## Конфигурация + +```yaml +proxvmservices: + enabled: true + type: exec + key: proxvmservices + interval: "300s" + timeout: "60s" + exec: "./collectors/proxvmservices" + platforms: [linux] +``` + +## Требования + +### Системные зависимости +- `pgrep` - для проверки процессов +- `ss` - для проверки портов +- `psql` или `postgres` - для PostgreSQL +- `patronictl` - для Patroni +- `etcdctl` - для etcd +- `kubectl` - для Kubernetes +- `redis-cli` - для Redis +- `clickhouse-client` - для ClickHouse +- `rabbitmqctl` - для RabbitMQ +- `mongosh` - для MongoDB + +### Права доступа +- Чтение конфигурационных файлов сервисов +- Выполнение команд управления сервисами +- Доступ к портам для проверки состояния + +## Примеры использования + +### Обнаружение PostgreSQL кластера +```bash +# Проверка процессов +pgrep -f postgres +pgrep -f patroni + +# Проверка портов +ss -tln sport = :5432 +ss -tln sport = :8008 + +# Информация о кластере +patronictl list +patronictl show-config + +# Репликация +psql -t -c "SELECT client_addr, state FROM pg_stat_replication;" +``` + +### Обнаружение etcd кластера +```bash +# Проверка процессов +pgrep -f etcd + +# Проверка портов +ss -tln sport = :2379 +ss -tln sport = :2380 + +# Информация о кластере +etcdctl member list +etcdctl endpoint status --write-out=json +etcdctl endpoint health +``` + +## Ограничения + +1. **Версии сервисов**: некоторые команды могут не работать на старых версиях +2. **Конфигурационные файлы**: парсинг ограничен стандартными форматами +3. **Права доступа**: требует sudo для доступа к некоторым командам +4. **Сетевые соединения**: не анализирует содержимое трафика +5. **Кластерное состояние**: может не отражать реальное состояние при проблемах с сетью + +## Автор + +**Сергей Антропов** +Сайт: https://devops.org.ru + +## Лицензия + +Проект распространяется под лицензией MIT. diff --git a/runner/deploy-service/playbook.yml b/runner/deploy-service/playbook.yml index 1c93b76..ab3f965 100644 --- a/runner/deploy-service/playbook.yml +++ b/runner/deploy-service/playbook.yml @@ -24,10 +24,9 @@ - name: Generate config.yaml from template ansible.builtin.template: src: ../templates/config.yaml.j2 - dest: "{{ tmp_dir }}/config.yaml" + dest: "/tmp/sensusagent_config_{{ inventory_hostname }}.yaml" mode: '0644' delegate_to: localhost - run_once: true - name: Copy collectors directory via scp -r to tmp (from controller) ansible.builtin.command: > @@ -36,13 +35,19 @@ {{ local_bin_dir }}/collectors {{ ansible_user }}@{{ ansible_host }}:{{ tmp_dir }}/ delegate_to: localhost + - name: Copy config.yaml to remote server + ansible.builtin.command: > + scp -B -i {{ ansible_ssh_private_key_file | default('~/.ssh/id_rsa') }} + -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null + /tmp/sensusagent_config_{{ inventory_hostname }}.yaml {{ ansible_user }}@{{ ansible_host }}:{{ tmp_dir }}/config.yaml + delegate_to: localhost + - name: Move files into {{ remote_dir }} with root and fix permissions ansible.builtin.raw: | cp -f {{ tmp_dir }}/agent {{ remote_dir }}/agent && chmod 0755 {{ remote_dir }}/agent cp -f {{ tmp_dir }}/config.yaml {{ remote_dir }}/config.yaml && chmod 0644 {{ remote_dir }}/config.yaml rm -rf {{ remote_dir }}/collectors && mkdir -p {{ remote_dir }}/collectors && cp -r {{ tmp_dir }}/collectors/* {{ remote_dir }}/collectors/ || true chmod -R 0755 {{ remote_dir }}/collectors 2>/dev/null || true - rm -rf {{ tmp_dir }} - name: Optional deps (Debian/Ubuntu) — ignore errors ansible.builtin.raw: | @@ -64,10 +69,16 @@ - name: Generate systemd unit from template ansible.builtin.template: src: ../templates/sensusagent.service.j2 - dest: "{{ tmp_dir }}/sensusagent.service" + dest: "/tmp/sensusagent_{{ inventory_hostname }}.service" mode: '0644' delegate_to: localhost - run_once: true + + - name: Copy systemd unit to remote server + ansible.builtin.command: > + scp -B -i {{ ansible_ssh_private_key_file | default('~/.ssh/id_rsa') }} + -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null + /tmp/sensusagent_{{ inventory_hostname }}.service {{ ansible_user }}@{{ ansible_host }}:{{ tmp_dir }}/sensusagent.service + delegate_to: localhost - name: Install/refresh systemd unit ansible.builtin.raw: | @@ -77,4 +88,19 @@ - name: Enable and start service ansible.builtin.raw: "systemctl enable --now sensusagent" + - name: Clean up temp directory + ansible.builtin.raw: "rm -rf {{ tmp_dir }}" + + - name: Clean up local temp config files + ansible.builtin.file: + path: "/tmp/sensusagent_config_{{ inventory_hostname }}.yaml" + state: absent + delegate_to: localhost + + - name: Clean up local temp service files + ansible.builtin.file: + path: "/tmp/sensusagent_{{ inventory_hostname }}.service" + state: absent + delegate_to: localhost + diff --git a/runner/group_vars/proxvms.yml b/runner/group_vars/proxvms.yml index adc04e3..bb46fa2 100644 --- a/runner/group_vars/proxvms.yml +++ b/runner/group_vars/proxvms.yml @@ -5,6 +5,7 @@ # Список коллекторов, которые должны быть включены для группы proxvms collectors_enabled: - proxvmsystem + - proxvmservices - uptime # Дополнительные настройки для коллекторов (опционально) @@ -12,10 +13,13 @@ collectors_config: proxvmsystem: interval: "300s" timeout: "60s" + proxvmservices: + interval: "300s" + timeout: "60s" uptime: interval: "60s" timeout: "5s" # Настройки systemd сервиса для VM/контейнеров # Можно переопределить глобальные настройки -agent_mode: kafka # VM/контейнеры могут использовать stdout вместо kafka +agent_mode: stdout # VM/контейнеры используют stdout вместо kafka diff --git a/runner/templates/config.yaml.j2 b/runner/templates/config.yaml.j2 index 42d17f6..d25089d 100644 --- a/runner/templates/config.yaml.j2 +++ b/runner/templates/config.yaml.j2 @@ -1,21 +1,26 @@ -# Конфигурация SensusAgent -# Автоматически сгенерировано на основе групповых переменных -# Автор: Сергей Антропов -# Сайт: https://devops.org.ru +# Автор: Сергей Антропов, сайт: https://devops.org.ru +# Общая конфигурация агента SensusAgent -# Настройки агента -agent: - log_level: "{{ agent_log_level | default('info') }}" - kafka: - brokers: "{{ kafka_brokers | default('localhost:9092') }}" - topic: "{{ kafka_topic | default('sensus-metrics') }}" - ssl: - enabled: {{ kafka_ssl_enabled | default(false) | lower }} - ca_cert: "{{ kafka_ssl_ca_cert | default('') }}" - client_cert: "{{ kafka_ssl_client_cert | default('') }}" - client_key: "{{ kafka_ssl_client_key | default('') }}" +mode: {{ agent_mode | default('kafka') }} # stdout | kafka | auto +log_level: {{ agent_log_level | default('info') }} + +kafka: + enabled: true + brokers: ["{{ kafka_brokers | default('localhost:9092') }}"] + topic: "{{ kafka_topic | default('sensus-metrics') }}" + client_id: "sensusagent" + enable_tls: {{ kafka_ssl_enabled | default(false) | lower }} + timeout: "5s" + # SSL настройки для Kafka + ssl_enabled: {{ kafka_ssl_enabled | default(false) | lower }} + ssl_keystore_location: "{{ kafka_ssl_keystore_location | default('') }}" + ssl_keystore_password: "{{ kafka_ssl_keystore_password | default('') }}" + ssl_key_password: "{{ kafka_ssl_key_password | default('') }}" + ssl_truststore_location: "{{ kafka_ssl_truststore_location | default('') }}" + ssl_truststore_password: "{{ kafka_ssl_truststore_password | default('') }}" + ssl_client_auth: "{{ kafka_ssl_client_auth | default('none') }}" # none, required, requested + ssl_endpoint_identification_algorithm: "{{ kafka_ssl_endpoint_identification_algorithm | default('https') }}" # https, none -# Коллекторы collectors: {% for collector_name in collectors_enabled %} {{ collector_name }}: diff --git a/src/collectors/proxvmservices/main.go b/src/collectors/proxvmservices/main.go new file mode 100644 index 0000000..00aa84b --- /dev/null +++ b/src/collectors/proxvmservices/main.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "time" +) + +func main() { + startTime := time.Now() + + timeout := parseDurationOr("COLLECTOR_TIMEOUT", 30*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + data, err := collectProxVMServices(ctx) + if err != nil || data == nil { + fmt.Println("{}") + return + } + + executionTime := time.Since(startTime) + + data["execution_time_ms"] = executionTime.Milliseconds() + data["execution_time_seconds"] = executionTime.Seconds() + + 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/proxvmservices/proxvmservices_linux.go b/src/collectors/proxvmservices/proxvmservices_linux.go new file mode 100644 index 0000000..ece6f1c --- /dev/null +++ b/src/collectors/proxvmservices/proxvmservices_linux.go @@ -0,0 +1,798 @@ +//go:build linux +// +build linux + +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "regexp" + "strconv" + "strings" +) + +// ServiceInfo представляет информацию о сервисе +type ServiceInfo struct { + Name string `json:"name"` + Type string `json:"type"` // standalone/cluster + Status string `json:"status"` // running/stopped/unknown + Version string `json:"version"` + Ports []int `json:"ports"` + Config map[string]any `json:"config"` + Cluster interface{} `json:"cluster,omitempty"` + Connections []ConnectionInfo `json:"connections,omitempty"` +} + +// ConnectionInfo представляет информацию о соединениях +type ConnectionInfo struct { + Type string `json:"type"` + Target string `json:"target"` + Status string `json:"status"` +} + +// PatroniClusterInfo представляет информацию о Patroni кластере +type PatroniClusterInfo struct { + Name string `json:"name"` + State string `json:"state"` // master/replica/unknown + Role string `json:"role"` // leader/replica + Members []PatroniMember `json:"members"` + EtcdEndpoint string `json:"etcd_endpoint"` + Config map[string]any `json:"config"` +} + +// PatroniMember представляет информацию о члене Patroni кластера +type PatroniMember struct { + Name string `json:"name"` + Host string `json:"host"` + Port int `json:"port"` + State string `json:"state"` + Role string `json:"role"` + Lag int `json:"lag"` // replication lag в байтах +} + +// EtcdClusterInfo представляет информацию о etcd кластере +type EtcdClusterInfo struct { + Name string `json:"name"` + Version string `json:"version"` + Members []EtcdMember `json:"members"` + Leader string `json:"leader"` + Health string `json:"health"` // healthy/unhealthy + ClusterSize int `json:"cluster_size"` +} + +// EtcdMember представляет информацию о члене etcd кластера +type EtcdMember struct { + ID string `json:"id"` + Name string `json:"name"` + PeerURLs []string `json:"peer_urls"` + ClientURLs []string `json:"client_urls"` + IsLeader bool `json:"is_leader"` + Status string `json:"status"` +} + +// collectProxVMServices собирает информацию о сервисах на VM +func collectProxVMServices(ctx context.Context) (map[string]any, error) { + result := map[string]any{ + "collector_name": "proxvmservices", + } + + // Получаем machine_uid для текущей машины + machineUID := getMachineIDFromHost() + if machineUID != "" { + result["machine_uid"] = machineUID + } + + // Обнаруживаем сервисы + services := []ServiceInfo{} + + // Проверяем каждый тип сервиса + if pg := detectPostgreSQL(); pg != nil { + services = append(services, *pg) + } + if etcd := detectEtcd(); etcd != nil { + services = append(services, *etcd) + } + if redis := detectRedis(); redis != nil { + services = append(services, *redis) + } + if clickhouse := detectClickHouse(); clickhouse != nil { + services = append(services, *clickhouse) + } + if rabbitmq := detectRabbitMQ(); rabbitmq != nil { + services = append(services, *rabbitmq) + } + if kafka := detectKafka(); kafka != nil { + services = append(services, *kafka) + } + if mongodb := detectMongoDB(); mongodb != nil { + services = append(services, *mongodb) + } + if k8s := detectKubernetes(); k8s != nil { + services = append(services, *k8s) + } + + result["services"] = services + + if len(result) == 1 && machineUID != "" { // Только collector_name и machine_uid + return nil, errors.New("no services detected besides machine_uid") + } + if len(result) == 0 { + return nil, errors.New("no data") + } + return result, nil +} + +// getMachineIDFromHost пытается получить machine-id из /etc/machine-id или /var/lib/dbus/machine-id +// и возвращает его SHA256 хэш. +func getMachineIDFromHost() string { + paths := []string{ + "/etc/machine-id", + "/var/lib/dbus/machine-id", + } + + for _, path := range paths { + if _, err := os.Stat(path); err == nil { + data, err := os.ReadFile(path) + if err == nil { + machineID := strings.TrimSpace(string(data)) + if machineID != "" { + hash := sha256.Sum256([]byte(machineID)) + return hex.EncodeToString(hash[:])[:16] + } + } + } + } + + return "" +} + +// isProcessRunning проверяет, запущен ли процесс +func isProcessRunning(processName string) bool { + cmd := exec.Command("pgrep", "-f", processName) + err := cmd.Run() + return err == nil +} + +// getListeningPorts возвращает список портов, на которых слушает процесс +func getListeningPorts(ports ...int) []int { + var listeningPorts []int + + for _, port := range ports { + cmd := exec.Command("ss", "-tln", fmt.Sprintf("sport = :%d", port)) + output, err := cmd.Output() + if err == nil && len(output) > 0 { + listeningPorts = append(listeningPorts, port) + } + } + + return listeningPorts +} + +// runCommand выполняет команду и возвращает вывод +func runCommand(command string, args ...string) (string, error) { + cmd := exec.Command(command, args...) + output, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(output)), nil +} + +// detectPostgreSQL обнаруживает PostgreSQL с Patroni +func detectPostgreSQL() *ServiceInfo { + // Проверяем процессы + if !isProcessRunning("postgres") && !isProcessRunning("patroni") { + return nil + } + + // Проверяем порты + ports := getListeningPorts(5432, 8008) + + // Получаем версию + version := getPostgreSQLVersion() + + // Читаем конфиг Patroni + config := parsePatroniConfig() + + // Проверяем кластер через Patroni + cluster := getPatroniCluster() + + // Получаем информацию о репликации + connections := getPostgreSQLConnections() + + return &ServiceInfo{ + Name: "postgresql", + Type: determineServiceType(cluster), + Status: "running", + Version: version, + Ports: ports, + Config: config, + Cluster: cluster, + Connections: connections, + } +} + +// getPostgreSQLVersion получает версию PostgreSQL +func getPostgreSQLVersion() string { + version, err := runCommand("psql", "--version") + if err != nil { + // Пробуем через postgres + version, err = runCommand("postgres", "--version") + if err != nil { + return "unknown" + } + } + + // Извлекаем версию из строки типа "psql (PostgreSQL) 14.9" + re := regexp.MustCompile(`(\d+\.\d+)`) + matches := re.FindStringSubmatch(version) + if len(matches) > 1 { + return matches[1] + } + return "unknown" +} + +// parsePatroniConfig парсит конфигурацию Patroni +func parsePatroniConfig() map[string]any { + config := make(map[string]any) + + // Пробуем найти конфиг Patroni + configPaths := []string{ + "/etc/patroni/patroni.yml", + "/etc/patroni.yml", + "/opt/patroni/patroni.yml", + } + + for _, path := range configPaths { + if _, err := os.Stat(path); err == nil { + data, err := os.ReadFile(path) + if err == nil { + // Простой парсинг YAML (можно улучшить) + content := string(data) + config["config_file"] = path + + // Извлекаем основные параметры + if strings.Contains(content, "scope:") { + re := regexp.MustCompile(`scope:\s*([^\s\n]+)`) + if matches := re.FindStringSubmatch(content); len(matches) > 1 { + config["scope"] = matches[1] + } + } + + if strings.Contains(content, "namespace:") { + re := regexp.MustCompile(`namespace:\s*([^\s\n]+)`) + if matches := re.FindStringSubmatch(content); len(matches) > 1 { + config["namespace"] = matches[1] + } + } + + break + } + } + } + + return config +} + +// getPatroniCluster получает информацию о Patroni кластере +func getPatroniCluster() *PatroniClusterInfo { + // patronictl list + output, err := runCommand("patronictl", "list") + if err != nil { + return nil + } + + members := parsePatronictlOutput(output) + if len(members) == 0 { + return nil + } + + // Получаем конфигурацию + configOutput, err := runCommand("patronictl", "show-config") + config := make(map[string]any) + if err == nil { + config = parsePatroniConfigFromOutput(configOutput) + } + + return &PatroniClusterInfo{ + Name: extractClusterNameFromConfig(config), + State: determineClusterState(members), + Role: determineNodeRole(members), + Members: members, + EtcdEndpoint: extractEtcdEndpoint(config), + Config: config, + } +} + +// parsePatronictlOutput парсит вывод команды patronictl list +func parsePatronictlOutput(output string) []PatroniMember { + var members []PatroniMember + + lines := strings.Split(output, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "Cluster:") || strings.HasPrefix(line, "Member") { + continue + } + + // Парсим строку типа: "postgresql-1 | 10.14.246.75:5432 | running | leader | 0" + parts := strings.Split(line, "|") + if len(parts) >= 4 { + name := strings.TrimSpace(parts[0]) + hostPort := strings.TrimSpace(parts[1]) + state := strings.TrimSpace(parts[2]) + role := strings.TrimSpace(parts[3]) + + // Парсим host:port + hostPortParts := strings.Split(hostPort, ":") + host := hostPortParts[0] + port := 5432 + if len(hostPortParts) > 1 { + if p, err := strconv.Atoi(hostPortParts[1]); err == nil { + port = p + } + } + + // Получаем lag (если есть) + lag := 0 + if len(parts) > 4 { + if l, err := strconv.Atoi(strings.TrimSpace(parts[4])); err == nil { + lag = l + } + } + + members = append(members, PatroniMember{ + Name: name, + Host: host, + Port: port, + State: state, + Role: role, + Lag: lag, + }) + } + } + + return members +} + +// parsePatroniConfigFromOutput парсит конфигурацию из вывода patronictl show-config +func parsePatroniConfigFromOutput(output string) map[string]any { + config := make(map[string]any) + + lines := strings.Split(output, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.Contains(line, ":") { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + config[key] = value + } + } + } + + return config +} + +// extractClusterNameFromConfig извлекает имя кластера из конфигурации +func extractClusterNameFromConfig(config map[string]any) string { + if scope, ok := config["scope"].(string); ok { + return scope + } + return "postgresql_cluster" +} + +// determineClusterState определяет состояние кластера +func determineClusterState(members []PatroniMember) string { + hasLeader := false + hasRunning := false + + for _, member := range members { + if member.Role == "leader" { + hasLeader = true + } + if member.State == "running" { + hasRunning = true + } + } + + if hasLeader && hasRunning { + return "healthy" + } + return "degraded" +} + +// determineNodeRole определяет роль текущего узла +func determineNodeRole(members []PatroniMember) string { + // Определяем роль текущего узла по hostname + hostname, err := os.Hostname() + if err != nil { + return "unknown" + } + + for _, member := range members { + if strings.Contains(member.Name, hostname) || strings.Contains(member.Host, hostname) { + return member.Role + } + } + + return "unknown" +} + +// extractEtcdEndpoint извлекает endpoint etcd из конфигурации +func extractEtcdEndpoint(config map[string]any) string { + if etcd, ok := config["etcd"].(map[string]any); ok { + if hosts, ok := etcd["hosts"].(string); ok { + return hosts + } + } + return "" +} + +// getPostgreSQLConnections получает информацию о соединениях PostgreSQL +func getPostgreSQLConnections() []ConnectionInfo { + var connections []ConnectionInfo + + // Проверяем репликацию + output, err := runCommand("psql", "-t", "-c", "SELECT client_addr, state FROM pg_stat_replication;") + if err == nil && output != "" { + lines := strings.Split(output, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + parts := strings.Split(line, "|") + if len(parts) >= 2 { + target := strings.TrimSpace(parts[0]) + status := strings.TrimSpace(parts[1]) + connections = append(connections, ConnectionInfo{ + Type: "replication", + Target: target, + Status: status, + }) + } + } + } + } + + return connections +} + +// determineServiceType определяет тип сервиса +func determineServiceType(cluster interface{}) string { + if cluster != nil { + return "cluster" + } + return "standalone" +} + +// detectEtcd обнаруживает etcd +func detectEtcd() *ServiceInfo { + // Проверяем процессы + if !isProcessRunning("etcd") { + return nil + } + + // Проверяем порты + ports := getListeningPorts(2379, 2380) + + // Получаем версию + version := getEtcdVersion() + + // Читаем конфиг + config := parseEtcdConfig() + + // Проверяем кластер + cluster := getEtcdCluster() + + return &ServiceInfo{ + Name: "etcd", + Type: "cluster", + Status: "running", + Version: version, + Ports: ports, + Config: config, + Cluster: cluster, + } +} + +// getEtcdVersion получает версию etcd +func getEtcdVersion() string { + version, err := runCommand("etcdctl", "version") + if err != nil { + return "unknown" + } + + // Извлекаем версию из строки типа "etcdctl version: 3.5.7" + re := regexp.MustCompile(`(\d+\.\d+\.\d+)`) + matches := re.FindStringSubmatch(version) + if len(matches) > 1 { + return matches[1] + } + return "unknown" +} + +// parseEtcdConfig парсит конфигурацию etcd +func parseEtcdConfig() map[string]any { + config := make(map[string]any) + + // Пробуем найти конфиг etcd + configPaths := []string{ + "/etc/etcd/etcd.conf", + "/etc/systemd/system/etcd.service", + } + + for _, path := range configPaths { + if _, err := os.Stat(path); err == nil { + data, err := os.ReadFile(path) + if err == nil { + content := string(data) + config["config_file"] = path + + // Извлекаем основные параметры + if strings.Contains(content, "ETCD_NAME=") { + re := regexp.MustCompile(`ETCD_NAME=([^\s\n]+)`) + if matches := re.FindStringSubmatch(content); len(matches) > 1 { + config["name"] = matches[1] + } + } + + if strings.Contains(content, "ETCD_DATA_DIR=") { + re := regexp.MustCompile(`ETCD_DATA_DIR=([^\s\n]+)`) + if matches := re.FindStringSubmatch(content); len(matches) > 1 { + config["data_dir"] = matches[1] + } + } + + break + } + } + } + + return config +} + +// getEtcdCluster получает информацию о etcd кластере +func getEtcdCluster() *EtcdClusterInfo { + // etcdctl member list + membersOutput, err := runCommand("etcdctl", "member", "list") + if err != nil { + return nil + } + + members := parseEtcdMembers(membersOutput) + if len(members) == 0 { + return nil + } + + // etcdctl endpoint status + statusOutput, err := runCommand("etcdctl", "endpoint", "status", "--write-out=json") + version := "unknown" + leader := "" + health := "unknown" + + if err == nil { + var status map[string]any + if json.Unmarshal([]byte(statusOutput), &status) == nil { + if v, ok := status["version"].(string); ok { + version = v + } + if l, ok := status["leaderInfo"].(map[string]any); ok { + if leaderID, ok := l["leader"].(string); ok { + leader = leaderID + } + } + } + } + + // Проверяем здоровье + healthOutput, err := runCommand("etcdctl", "endpoint", "health") + if err == nil && strings.Contains(healthOutput, "healthy") { + health = "healthy" + } else { + health = "unhealthy" + } + + return &EtcdClusterInfo{ + Name: "etcd_cluster", + Version: version, + Members: members, + Leader: leader, + Health: health, + ClusterSize: len(members), + } +} + +// parseEtcdMembers парсит вывод команды etcdctl member list +func parseEtcdMembers(output string) []EtcdMember { + var members []EtcdMember + + lines := strings.Split(output, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + // Парсим строку типа: "8e9e05c52164694d, started, etcd-1, http://10.14.246.77:2380, http://10.14.246.77:2379" + parts := strings.Split(line, ",") + if len(parts) >= 5 { + id := strings.TrimSpace(parts[0]) + status := strings.TrimSpace(parts[1]) + name := strings.TrimSpace(parts[2]) + peerURL := strings.TrimSpace(parts[3]) + clientURL := strings.TrimSpace(parts[4]) + + members = append(members, EtcdMember{ + ID: id, + Name: name, + PeerURLs: []string{peerURL}, + ClientURLs: []string{clientURL}, + IsLeader: false, // Определим позже + Status: status, + }) + } + } + + return members +} + +// Заглушки для остальных сервисов (пока не реализованы) +func detectRedis() *ServiceInfo { + if !isProcessRunning("redis-server") { + return nil + } + + ports := getListeningPorts(6379) + version := "unknown" + + versionOutput, err := runCommand("redis-cli", "--version") + if err == nil { + re := regexp.MustCompile(`(\d+\.\d+\.\d+)`) + matches := re.FindStringSubmatch(versionOutput) + if len(matches) > 1 { + version = matches[1] + } + } + + return &ServiceInfo{ + Name: "redis", + Type: "standalone", + Status: "running", + Version: version, + Ports: ports, + Config: make(map[string]any), + } +} + +func detectClickHouse() *ServiceInfo { + if !isProcessRunning("clickhouse-server") { + return nil + } + + ports := getListeningPorts(8123, 9000) + version := "unknown" + + versionOutput, err := runCommand("clickhouse-client", "--version") + if err == nil { + re := regexp.MustCompile(`(\d+\.\d+\.\d+)`) + matches := re.FindStringSubmatch(versionOutput) + if len(matches) > 1 { + version = matches[1] + } + } + + return &ServiceInfo{ + Name: "clickhouse", + Type: "standalone", + Status: "running", + Version: version, + Ports: ports, + Config: make(map[string]any), + } +} + +func detectRabbitMQ() *ServiceInfo { + if !isProcessRunning("rabbitmq-server") { + return nil + } + + ports := getListeningPorts(5672, 15672) + version := "unknown" + + versionOutput, err := runCommand("rabbitmqctl", "version") + if err == nil { + re := regexp.MustCompile(`(\d+\.\d+\.\d+)`) + matches := re.FindStringSubmatch(versionOutput) + if len(matches) > 1 { + version = matches[1] + } + } + + return &ServiceInfo{ + Name: "rabbitmq", + Type: "standalone", + Status: "running", + Version: version, + Ports: ports, + Config: make(map[string]any), + } +} + +func detectKafka() *ServiceInfo { + if !isProcessRunning("kafka.Kafka") { + return nil + } + + ports := getListeningPorts(9092) + version := "unknown" + + return &ServiceInfo{ + Name: "kafka", + Type: "standalone", + Status: "running", + Version: version, + Ports: ports, + Config: make(map[string]any), + } +} + +func detectMongoDB() *ServiceInfo { + if !isProcessRunning("mongod") { + return nil + } + + ports := getListeningPorts(27017) + version := "unknown" + + versionOutput, err := runCommand("mongosh", "--version") + if err == nil { + re := regexp.MustCompile(`(\d+\.\d+\.\d+)`) + matches := re.FindStringSubmatch(versionOutput) + if len(matches) > 1 { + version = matches[1] + } + } + + return &ServiceInfo{ + Name: "mongodb", + Type: "standalone", + Status: "running", + Version: version, + Ports: ports, + Config: make(map[string]any), + } +} + +func detectKubernetes() *ServiceInfo { + if !isProcessRunning("kubelet") && !isProcessRunning("kube-apiserver") { + return nil + } + + ports := getListeningPorts(6443, 10250) + version := "unknown" + + versionOutput, err := runCommand("kubectl", "version", "--client", "--short") + if err == nil { + re := regexp.MustCompile(`(\d+\.\d+\.\d+)`) + matches := re.FindStringSubmatch(versionOutput) + if len(matches) > 1 { + version = matches[1] + } + } + + return &ServiceInfo{ + Name: "kubernetes", + Type: "cluster", + Status: "running", + Version: version, + Ports: ports, + Config: make(map[string]any), + } +} diff --git a/src/collectors/proxvmservices/proxvmservices_unsupported.go b/src/collectors/proxvmservices/proxvmservices_unsupported.go new file mode 100644 index 0000000..3cc7822 --- /dev/null +++ b/src/collectors/proxvmservices/proxvmservices_unsupported.go @@ -0,0 +1,14 @@ +//go:build !linux +// +build !linux + +package main + +import ( + "context" + "errors" +) + +// collectProxVMServices для неподдерживаемых платформ +func collectProxVMServices(ctx context.Context) (map[string]any, error) { + return nil, errors.New("proxvmservices collector is not supported on this platform") +}