-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
7 changed files
with
459 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
name: Docker Build/Publish | ||
|
||
on: | ||
# schedule: | ||
# - cron: "20 4 * * *" | ||
push: | ||
branches: [main] | ||
# Publish semver tags as releases. | ||
tags: ["*.*.*"] | ||
paths: | ||
- "src/**" | ||
- "poetry.lock" | ||
- "pyProject.toml" | ||
- ".dockerignore" | ||
- "Dockerfile" | ||
pull_request: | ||
branches: [main] | ||
|
||
env: | ||
REGISTRY: ghcr.io | ||
IMAGE_NAME: ${{ github.repository }} | ||
|
||
jobs: | ||
build: | ||
runs-on: ubuntu-latest | ||
permissions: | ||
contents: read | ||
packages: write | ||
|
||
steps: | ||
- name: Checkout repository | ||
uses: actions/checkout@v4 | ||
|
||
# Login against a Docker registry except on PR | ||
# https://github.com/docker/login-action | ||
- name: Log into registry ${{ env.REGISTRY }} | ||
if: github.event_name != 'pull_request' | ||
uses: docker/login-action@v3 | ||
with: | ||
registry: ${{ env.REGISTRY }} | ||
username: ${{ github.actor }} | ||
password: ${{ secrets.GITHUB_TOKEN }} | ||
|
||
# Extract metadata (tags, labels) for Docker | ||
# https://github.com/docker/metadata-action | ||
- name: Extract Docker metadata | ||
id: meta | ||
uses: docker/metadata-action@v5 | ||
with: | ||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} | ||
|
||
# Build and push Docker image with Buildx (don't push on PR) | ||
# https://github.com/docker/build-push-action | ||
- name: Build and push Docker image | ||
uses: docker/build-push-action@v5 | ||
with: | ||
context: . | ||
push: ${{ github.event_name != 'pull_request' }} | ||
tags: ${{ steps.meta.outputs.tags }} | ||
labels: ${{ steps.meta.outputs.labels }} | ||
|
||
- name: Image digest | ||
run: echo ${{ steps.docker_build.outputs.digest }} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
.env | ||
.venv |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
FROM python:3.11 | ||
|
||
WORKDIR /usr/src/app | ||
|
||
RUN pip install poetry | ||
|
||
COPY poetry.lock pyproject.toml ./ | ||
RUN poetry install --no-root --no-cache | ||
|
||
COPY ./src ./ | ||
|
||
CMD ["poetry", "run", "python", "main.py"] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,3 @@ | ||
# geist-pdu-exporter | ||
|
||
Prometheus exporter for Geist PDUs |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
[tool.poetry] | ||
name = "geist-pdu-exporter" | ||
version = "0.1.0" | ||
description = "Prometheus exporter for Geist PDUs" | ||
authors = ["drewburr <[email protected]>"] | ||
license = "Apache-2.0" | ||
readme = "README.md" | ||
|
||
[tool.poetry.dependencies] | ||
python = "^3.10" | ||
requests = "^2.32.2" | ||
prometheus-client = "^0.20.0" | ||
python-dotenv = "^1.0.1" | ||
xmltodict = "^0.13.0" | ||
|
||
|
||
[build-system] | ||
requires = ["poetry-core"] | ||
build-backend = "poetry.core.masonry.api" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,157 @@ | ||
"""Application exporter""" | ||
|
||
import os | ||
import time | ||
from prometheus_client import start_http_server, Gauge, Counter, Enum | ||
import requests | ||
import xml.etree.ElementTree as ET | ||
from xml.etree.ElementTree import Element | ||
|
||
# pdu_ | ||
|
||
|
||
class Exporter: | ||
""" | ||
Representation of Prometheus metrics and loop to fetch and transform | ||
application metrics into Prometheus metrics. | ||
""" | ||
|
||
def __init__(self, address, port, polling_interval_seconds): | ||
self.address = address | ||
self.port = port | ||
self.polling_interval_seconds = polling_interval_seconds | ||
|
||
self.device_labels = ["id", "type"] | ||
self.outlet_labels = ["name", "num", "url"] | ||
|
||
self.device_metrics = { | ||
"KWatt-hrs-Total": Gauge( | ||
"pdu_kwh_total", "Total device KWh", self.device_labels | ||
), | ||
"KWatt-hrs-A": Gauge("pdu_kwh", "Device KWh", self.device_labels), | ||
"RealPower-Total": Gauge( | ||
"pdu_realpower_total", "Device RealPower", self.device_labels | ||
), | ||
"RealPower-A": Gauge( | ||
"pdu_realpower", "Device realpower", self.device_labels | ||
), | ||
"Volts-A": Gauge("pdu_volts", "Device voltage", self.device_labels), | ||
"Volt-Pk-A": Gauge( | ||
"pdu_volts_peak", "Device peak voltage", self.device_labels | ||
), | ||
"Amps-A": Gauge("pdu_amps", "Device amperage", self.device_labels), | ||
"Amps-Pk-A": Gauge( | ||
"pdu_amps_peak", "Device peak amperage", self.device_labels | ||
), | ||
"ApPower-A": Gauge( | ||
"pdu_apparent_power", "Device apparent power", self.device_labels | ||
), | ||
"Pwr-Factor%-A": Gauge( | ||
"pdu_power_factor_percent", | ||
"Power Factor Percentage", | ||
self.device_labels, | ||
), | ||
} | ||
|
||
self.outlet_metrics = { | ||
"amps": Gauge( | ||
"pdu_outlet_amps", | ||
"Outlet Amperage", | ||
self.outlet_labels + self.device_labels, | ||
), | ||
"kwatthrs": Gauge( | ||
"pdu_outlet_kwh_total", | ||
"Outlet Total KWh", | ||
self.outlet_labels + self.device_labels, | ||
), | ||
"watts": Gauge( | ||
"pdu_outlet_watts", | ||
"Outlet Watts", | ||
self.outlet_labels + self.device_labels, | ||
), | ||
} | ||
|
||
self.outlet_status = Enum( | ||
"pdu_outlet_status", | ||
"Outlet status", | ||
self.outlet_labels + self.device_labels, | ||
states=["On", "Off"], | ||
) | ||
|
||
def start_export_loop(self): | ||
"""Metrics fetching loop""" | ||
|
||
while True: | ||
self.process() | ||
time.sleep(self.polling_interval_seconds) | ||
|
||
def process(self): | ||
""" | ||
Generate metrics and publish | ||
""" | ||
root = self.fetch() | ||
devices = root.find("devices") | ||
|
||
for device in devices: | ||
self.process_device(device) | ||
|
||
def process_device(self, device: Element): | ||
outlets = device.find("outlets") | ||
if not outlets: | ||
return | ||
|
||
device_labels = {label: device.attrib[label] for label in self.device_labels} | ||
|
||
for child in device: | ||
if not child.tag == "field": | ||
continue | ||
|
||
metric = self.device_metrics.get(child.attrib["key"]) | ||
if metric: | ||
metric.labels(**device_labels).set(float(child.attrib["value"])) | ||
|
||
for outlet in outlets: | ||
self.process_outlet(outlet, device_labels) | ||
|
||
def process_outlet(self, outlet: Element, device_labels: dict[str, str]): | ||
outlet_labels = {label: outlet.attrib[label] for label in self.outlet_labels} | ||
outlet_labels = {**outlet_labels, **device_labels} | ||
|
||
for attr, metric in self.outlet_metrics.items(): | ||
metric.labels(**outlet_labels).set(float(outlet.attrib[attr])) | ||
|
||
self.outlet_status.labels(**outlet_labels).state(outlet.attrib["status"]) | ||
|
||
def fetch(self): | ||
""" | ||
Get metrics from PDU and return | ||
""" | ||
resp = requests.get(url=f"http://{self.address}:{self.port}/data.xml") | ||
root = ET.fromstring(resp.text) | ||
return root | ||
|
||
|
||
def main(): | ||
"""Main entry point""" | ||
from dotenv import load_dotenv | ||
|
||
load_dotenv( | ||
override=False | ||
) # load .env into environment variables, without override | ||
|
||
PDU_ADDRESS = os.getenv("PDU_ADDRESS") | ||
PDU_PORT = int(os.getenv("PDU_PORT", "80")) | ||
POLLING_INTERVAL_SECONDS = int(os.getenv("POLLING_INTERVAL_SECONDS", "5")) | ||
LISTEN_PORT = int(os.getenv("LISTEN_PORT", "9100")) | ||
|
||
exporter = Exporter( | ||
address=PDU_ADDRESS, | ||
port=PDU_PORT, | ||
polling_interval_seconds=POLLING_INTERVAL_SECONDS, | ||
) | ||
start_http_server(LISTEN_PORT) | ||
exporter.start_export_loop() | ||
|
||
|
||
if __name__ == "__main__": | ||
main() |