diff --git a/workflows/main.yml b/workflows/main.yml new file mode 100644 index 0000000..53b72a7 --- /dev/null +++ b/workflows/main.yml @@ -0,0 +1,86 @@ +name: Build and Push +on: + push: + #branches: + # - main + # - 'feature/**' + +jobs: + maintence: + runs-on: srvdnd + outputs: + date: ${{ steps.date.outputs.date }} + steps: + - name: Get current date + id: date + run: echo "date=$(TZ=":Asia/Yakutsk" date +'%FT%H-%M-%SR')" >> $GITHUB_OUTPUT + + buildApp: + needs: [maintence] + runs-on: srvdnd + env: + TAG_NAME: ${{ needs.maintence.outputs.date }}${{ gitea.RUN_ID }} + steps: + - name: Check out repository code + uses: https://git.tlgrf.ru/actions/checkout@v4 + - name: Create docker image! + run: docker build -t git.tlgrf.local/${{ gitea.REPOSITORY }}/${{ gitea.REF_NAME }}:$TAG_NAME -t git.tlgrf.ru/${{ gitea.REPOSITORY }}/${{ gitea.REF_NAME }}:$TAG_NAME -t git.tlgrf.ru/${{ gitea.REPOSITORY }}/${{ gitea.REF_NAME }}:latest ./zont-exporter -f ./zont-exporter/Dockerfile + + pushApp: + needs: [buildApp, maintence] + runs-on: srvdnd + env: + TAG_NAME: ${{ needs.maintence.outputs.date }}${{ gitea.RUN_ID }} + steps: + - name: Login to git.tlgrf.ru + uses: https://git.tlgrf.ru/actions/docker-login-action@v3 + with: + registry: git.tlgrf.ru + username: gitea + password: ${{ secrets.GITEAPASSWORD }} + - name: Push docker image! + run: docker push git.tlgrf.ru/${{ gitea.REPOSITORY }}/${{ gitea.REF_NAME }}:$TAG_NAME && docker push git.tlgrf.ru/${{ gitea.REPOSITORY }}/${{ gitea.REF_NAME }}:latest + + - name: Push notification + uses: https://git.tlgrf.ru/actions/rocketchat-notification-action@v3.0.0 + with: + server: https://rc.tlgrf.ru + auth-token: ${{ secrets.ROCKETCHAT_AUTH_TOKEN }} + user-id: ${{ secrets.ROCKETCHAT_USER_ID }} + channel: python_rocketchat_api + message: | + Gitea | TLGRF + + Repository: ${{ gitea.REPOSITORY }} + *${{ gitea.ACTOR }}* pushed to *${{ gitea.REF_NAME }}* + + Commit message: ${{ gitea.event.commits[0].message }} + See changes: https://git.tlgrf.ru/${{ gitea.REPOSITORY }}/commit/${{gitea.SHA}} + ``` + docker pull git.tlgrf.ru/${{ gitea.REPOSITORY }}/${{ gitea.REF_NAME }}:${{ env.TAG_NAME }} + ``` + ``` + ${{ env.TAG_NAME }} + ``` + + #- name: send telegram message on push + # uses: appleboy/telegram-action@master + # with: + # to: -4251930066 + # token: ${{ secrets.TELEGRAM_TOKEN_MIKUPTIME }} + # format: markdown + # message: | + # Gitea | TLGRF +# + # Repository: ${{ gitea.REPOSITORY }} + # *${{ gitea.ACTOR }}* pushed to *${{ gitea.REF_NAME }}* +# + # Commit message: ${{ gitea.event.commits[0].message }} + # See changes: https://git.tlgrf.ru/${{ gitea.REPOSITORY }}/commit/${{gitea.SHA}} + # ``` + # docker pull git.tlgrf.ru/${{ gitea.REPOSITORY }}/${{ gitea.REF_NAME }}:${{ env.TAG_NAME }} + # ``` + # ``` + # ${{ env.TAG_NAME }} + # ``` + \ No newline at end of file diff --git a/zont-exporter/Dockerfile b/zont-exporter/Dockerfile new file mode 100644 index 0000000..d4973eb --- /dev/null +++ b/zont-exporter/Dockerfile @@ -0,0 +1,29 @@ +FROM python:3.12-slim + +LABEL maintainer="zont-exporter" + +# Отключаем создание .pyc файлов и включаем небуферизованный вывод +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +# Устанавливаем зависимости +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Копируем исходный код +COPY zont_exporter.py . + +# Создаём непривилегированного пользователя +RUN useradd -m -u 1000 exporter && chown -R exporter:exporter /app +USER exporter + +# Открываем порт экспортера +EXPOSE 8000 + +# Healthcheck — проверяем, что /metrics отвечает +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/metrics').read()" || exit 1 + +CMD ["python", "zont_exporter.py"] \ No newline at end of file diff --git a/zont-exporter/requirements.txt b/zont-exporter/requirements.txt new file mode 100644 index 0000000..da65529 --- /dev/null +++ b/zont-exporter/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.31.0 +prometheus-client>=0.19.0 \ No newline at end of file diff --git a/zont-exporter/zont_exporter.py b/zont-exporter/zont_exporter.py new file mode 100644 index 0000000..818fa61 --- /dev/null +++ b/zont-exporter/zont_exporter.py @@ -0,0 +1,173 @@ +import os +import time +import logging +import requests +from prometheus_client import start_http_server, Gauge +from prometheus_client.core import GaugeMetricFamily, REGISTRY + +# --- Настройка логирования --- +logging.basicConfig( + level=os.getenv('LOG_LEVEL', 'INFO').upper(), + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger('zont-exporter') + +# --- Конфигурация из переменных окружения --- +ZONT_LOGIN = os.getenv('ZONT_LOGIN') +ZONT_PASSWORD = os.getenv('ZONT_PASSWORD') +ZONT_CLIENT_EMAIL = os.getenv('ZONT_CLIENT_EMAIL', 'exporter@localhost') +API_URL = os.getenv('ZONT_API_URL', 'https://my.zont.online/api') +SCRAPE_INTERVAL = int(os.getenv('SCRAPE_INTERVAL', '60')) +EXPORTER_PORT = int(os.getenv('EXPORTER_PORT', '8000')) + +# Проверка обязательных параметров +if not ZONT_LOGIN or not ZONT_PASSWORD: + logger.error("Не заданы ZONT_LOGIN и/или ZONT_PASSWORD") + raise SystemExit(1) + + +class ZontCollector(object): + """Кастомный коллектор, который собирает метрики с API ZONT.""" + + def __init__(self): + self.token = None + + def _get_auth_token(self): + """Получает или обновляет токен авторизации.""" + try: + response = requests.post( + f'{API_URL}/get_authtoken', + auth=(ZONT_LOGIN, ZONT_PASSWORD), + headers={'X-ZONT-Client': ZONT_CLIENT_EMAIL}, + json={"client_name": "Prometheus-Exporter"}, + timeout=15 + ) + response.raise_for_status() + data = response.json() + if data.get('ok'): + self.token = data['token'] + logger.info("Токен успешно получен.") + return True + else: + logger.error(f"Ошибка получения токена: {data.get('error_ui')}") + except requests.exceptions.RequestException as e: + logger.error(f"Сетевая ошибка при получении токена: {e}") + except Exception as e: + logger.exception(f"Неожиданная ошибка при получении токена: {e}") + return False + + def _fetch_data(self): + """Запрашивает данные по всем устройствам.""" + if not self.token: + if not self._get_auth_token(): + return None + + try: + response = requests.post( + f'{API_URL}/devices', + headers={ + 'X-ZONT-Client': ZONT_CLIENT_EMAIL, + 'X-ZONT-Token': self.token + }, + json={'load_io': True}, + timeout=15 + ) + + if response.status_code == 403: + logger.warning("Токен недействителен, обновляем...") + if self._get_auth_token(): + return self._fetch_data() + return None + + response.raise_for_status() + data = response.json() + if data.get('ok'): + return data.get('devices', []) + else: + logger.error(f"Ошибка в ответе devices: {data.get('error_ui')}") + except requests.exceptions.RequestException as e: + logger.error(f"Сетевая ошибка при запросе devices: {e}") + except Exception as e: + logger.exception(f"Неожиданная ошибка при запросе devices: {e}") + return None + + def collect(self): + """Основной метод, который вызывает Prometheus.""" + devices = self._fetch_data() + if not devices: + logger.warning("Не удалось получить данные, пропускаем цикл.") + return + + # --- Метрики температур --- + temp_gauge = GaugeMetricFamily( + 'zont_temperature_celsius', + 'Температура с датчиков ZONT', + labels=['device_id', 'device_name', 'sensor_name'] + ) + + # --- Метрики состояния котла --- + boiler_work_gauge = GaugeMetricFamily( + 'zont_boiler_working', + 'Работает ли котел (1 - да, 0 - нет)', + labels=['device_id', 'device_name'] + ) + + # --- Метрики уровня сигнала --- + signal_gauge = GaugeMetricFamily( + 'zont_signal_level', + 'Уровень сигнала (GSM или Wi-Fi)', + labels=['device_id', 'device_name', 'type'] + ) + + # --- Метрики состояния охраны --- + guard_gauge = GaugeMetricFamily( + 'zont_guard_state', + 'Состояние охраны (1 - включена, 0 - выключена)', + labels=['device_id', 'device_name'] + ) + + for device in devices: + dev_id = str(device.get('id')) + dev_name = device.get('name', 'unknown') + io_data = device.get('io', {}) + if not io_data: + continue + + # --- Обработка термодатчиков --- + for sensor in device.get('thermometers', []): + if sensor.get('is_assigned_to_slot') and sensor.get('last_value') is not None: + temp_gauge.add_metric( + [dev_id, dev_name, sensor.get('name', 'sensor')], + sensor['last_value'] + ) + + # --- Обработка состояния котла --- + boiler_state = io_data.get('last-boiler-state') + if boiler_state: + is_working = 1 if boiler_state.get('boiler_work_time', 0) > 0 else 0 + boiler_work_gauge.add_metric([dev_id, dev_name], is_working) + + # --- Обработка уровня сигнала GSM --- + gsm_state = io_data.get('gsm-state') + if gsm_state and gsm_state.get('level') is not None: + signal_gauge.add_metric([dev_id, dev_name, 'gsm'], gsm_state['level']) + + # --- Обработка состояния охраны --- + guard_state = io_data.get('guard-state') + if guard_state is not None: + is_guarded = 1 if guard_state == 'enabled' else 0 + guard_gauge.add_metric([dev_id, dev_name], is_guarded) + + yield temp_gauge + yield boiler_work_gauge + yield signal_gauge + yield guard_gauge + + +if __name__ == '__main__': + REGISTRY.register(ZontCollector()) + start_http_server(EXPORTER_PORT) + logger.info(f"Экспортер запущен на порту {EXPORTER_PORT}") + + while True: + time.sleep(10) \ No newline at end of file