feat: добавлен коллектор proxvmservices для обнаружения сервисов на VM

- Создан новый коллектор proxvmservices для обнаружения и мониторинга сервисов
- Поддержка PostgreSQL с Patroni (кластер, репликация, конфигурация)
- Поддержка etcd кластера (члены, лидер, здоровье)
- Поддержка остальных сервисов: Redis, ClickHouse, RabbitMQ, Kafka, MongoDB, Kubernetes
- Добавлен в Makefile и конфигурацию агента
- Обновлены групповые переменные Ansible для включения в группу proxvms
- Исправлены проблемы с шаблонами Ansible (конфигурация и systemd unit)
- Создана подробная документация
- Протестирован на удаленных серверах через Ansible

Автор: Сергей Антропов
Сайт: https://devops.org.ru
This commit is contained in:
Sergey Antropoff 2025-09-15 17:04:47 +03:00
parent 89512f66bc
commit 5fa101dfff
10 changed files with 1164 additions and 25 deletions

View File

@ -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
@ -90,7 +92,8 @@ collectors-linux:
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/proxvmsystem ./src/collectors/proxvmsystem && \
CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/proxvmservices ./src/collectors/proxvmservices"
collectors-windows:
# Кросс-сборка коллекторов для Windows

View File

@ -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]

View File

@ -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 контроллерах

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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') }}"
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') }}"
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('') }}"
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 }}:

View File

@ -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
}

View File

@ -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),
}
}

View File

@ -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")
}