diff --git a/collector/Dockerfile b/collector/Dockerfile deleted file mode 100644 index 67bda78a..00000000 --- a/collector/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM python:3.10.2-slim-buster - -WORKDIR /app - -COPY ./collector/requirements.txt /app - -RUN pip install -r /app/requirements.txt - -COPY ./collector/server.py /app - -EXPOSE 5000 - -ENTRYPOINT [ "python", "server.py" ] diff --git a/collector/requirements.txt b/collector/requirements.txt deleted file mode 100644 index 3e7d6aa2..00000000 --- a/collector/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -fastapi==0.84.0 -uvicorn==0.18.3 -py-grpc-prometheus==0.7.0 -pysnmp==4.4.12 -pyasn1==0.4.8 \ No newline at end of file diff --git a/collector/server.py b/collector/server.py deleted file mode 100644 index 17d64126..00000000 --- a/collector/server.py +++ /dev/null @@ -1,152 +0,0 @@ -import time -import argparse -from threading import Thread -import enum -import logging - -from fastapi import FastAPI, Response -from fastapi.middleware.cors import CORSMiddleware -import prometheus_client -from pysnmp.hlapi import * -import uvicorn - -snmp_metric = prometheus_client.Gauge( - "snmp_metric", - "ex: Number of pages printed", - ["name", "ip"], -) - -snmp_error = prometheus_client.Gauge( - "snmp_error", - "Error metrics", - ["name", "ip"], -) - -snmp_req_duration = prometheus_client.Summary( - "snmp_request_duration", - "Time it took for SNMP request", -) - -device_unreachable = prometheus_client.Gauge( - "device_unreachable", - "set to 1 when error", -) - -app = FastAPI() - -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], - allow_headers=["*"], -) - -logging.basicConfig( - # in mondo we trust - format="%(asctime)s.%(msecs)03dZ %(levelname)s:%(name)s:%(message)s", - datefmt="%Y-%m-%dT%H:%M:%S", - level=logging.INFO, -) - -class SnmpOid(enum.Enum): - INK_LEVEL = ("ink_level", "1.3.6.1.2.1.43.11.1.1.9.1.1") - INK_CAPACITY = ("ink_capacity", "1.3.6.1.2.1.43.11.1.1.8.1.1") - PAGE_COUNT = ("page_count", "1.3.6.1.2.1.43.10.2.1.4.1.1") - TRAY_EMPTY = ("tray_empty", "1.3.6.1.2.1.43.18.1.1.8.1.13", True) - # we observed each printer emitting a different SNMP OID for - # an empty paper tray, the below accounts for this second OID. - # the _2 at the end of this metric does imply that the printer has - # 2 trays. tray_empty_2 is an indication of the same exact issue - # as tray_empty: an empty paper tray. - TRAY_EMPTY_2 = ("tray_empty_2", "1.3.6.1.2.1.43.18.1.1.8.1.2", True) - - def __init__(self, metric_name, metric_value, is_error=False): - self.metric_name = metric_name - self.metric_value = metric_value - self.is_error = is_error - -def scrape_snmp(ip_list): - while True: - for ip in ip_list: - get_snmp_data(ip) - time.sleep(args.sleep_duration_minutes * 60) - -def get_snmp_data(ip): - for oid in SnmpOid: - with snmp_req_duration.time(): - errorIndication, errorStatus, errorIndex, varBinds = next( - getCmd(SnmpEngine(), - CommunityData('public', mpModel=0), - UdpTransportTarget((ip, 161)), - ContextData(), - ObjectType(ObjectIdentity(oid.metric_value))) - ) - if errorIndication: - logging.error(f"Error indication from {ip} for metric {oid.metric_value}: {errorIndication}") - device_unreachable.set(1) - continue - if errorStatus: - logging.error(f"Error status from {ip} for metric {oid.metric_value}: {errorStatus.prettyPrint()}") - # SNMP OIDs related to errors often dissappear when - # the associated issue that the metric refers to is - # no longer present (i.e. an empty tray now has - # paper). To avoid leaving an error metric as 1 - # which would create a false positive, set the metric - # to zero if the associated SNMP OID was not found - if oid.is_error: - snmp_error.labels(name=oid.metric_name, ip=ip).set(0) - continue - - device_unreachable.set(0) - if not varBinds: - continue - res = varBinds[0] - if oid.is_error: - snmp_error.labels(name=oid.metric_name, ip=ip).set(1) - continue - snmp_metric.labels(name=oid.metric_name, ip=ip).set(res[1]) - -@app.get("/metrics") -async def metrics(): - return Response( - content=prometheus_client.generate_latest(), - media_type="text/plain", - ) - -if __name__ == "__main__": - parser = argparse.ArgumentParser("snmp coolness") - - parser.add_argument( - "--ips", - help="List of IP addresses of snmp agent (default: 192.168.69.208,192.168.69.149)", - default="192.168.69.208,192.168.69.149" - ) - parser.add_argument( - "--host", - help="ip address to listen for requests on, i.e. 0.0.0.0", - default='0.0.0.0', - ) - parser.add_argument( - "--port", - type=int, - help="port for the server to listen on, default is 5000", - default=5000 - ) - parser.add_argument( - "--sleep-duration-minutes", - type=int, - help="update sleepy time, default is 2mins", - default=2 - ) - - args = parser.parse_args() - ip_list = args.ips.split(',') - - thread = Thread(target = scrape_snmp, args=(ip_list,), daemon=True) - thread.start() - uvicorn.run( - app, - host=args.host, - port=args.port, - # reload=True, - ) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 99eda4a4..3342d2e6 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -17,10 +17,4 @@ services: - --development - --port=14000 - --dont-delete-pdfs - snmp-collector: - build: - context: . - dockerfile: ./collector/Dockerfile - ports: - - 5000:5000 - + - --config-json-path=/app/config/config.json diff --git a/docker-compose.yml b/docker-compose.yml index a5e6b173..3863f65f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,10 +13,7 @@ services: - ~/.ssh/known_hosts:/app/known_hosts - "/etc/cups/ppd/:/etc/cups/ppd" tty: true - snmp-collector: - build: - context: . - dockerfile: ./collector/Dockerfile + # we attach the print container to an external docker # network called "poweredge". we do this so a prometheus # container can pull metrics from the server over HTTP diff --git a/printer/Dockerfile b/printer/Dockerfile index 37a2360f..ae411029 100644 --- a/printer/Dockerfile +++ b/printer/Dockerfile @@ -1,20 +1,19 @@ # Base image from https://github.com/DrPsychick/docker-cups-airprint # Docker images are here https://hub.docker.com/r/drpsychick/airprint-bridge/tags -FROM drpsychick/airprint-bridge:latest +FROM drpsychick/airprint-bridge:jammy WORKDIR /app - RUN apt-get update RUN apt install -y python3 python3-pip python3-venv jq ssh -# Create a virtual environment +# Create the virtual environment with Python RUN python3 -m venv /opt/venv # Set the virtual environment as the default Python environment ENV PATH="/opt/venv/bin:$PATH" -COPY ./printer/requirements.txt /app/printer/requirements.txt +COPY ./printer/requirements.txt /app/printer/requirements.txt RUN /opt/venv/bin/pip install -r /app/printer/requirements.txt @@ -29,3 +28,4 @@ EXPOSE 9000 # The below command runs the bash script that sets up the connection to the # printers and runs PrintHandler.js ENTRYPOINT [ "./printer/what.sh" ] + diff --git a/printer/collector.py b/printer/collector.py new file mode 100644 index 00000000..117648dd --- /dev/null +++ b/printer/collector.py @@ -0,0 +1,106 @@ +import time +import enum +import logging +import json +import asyncio + +from pysnmp.hlapi import * + +from metrics import MetricsHandler + +metrics_handler = MetricsHandler.instance() + + +logging.basicConfig( + # in mondo we trust + format="%(asctime)s.%(msecs)03dZ %(levelname)s:%(name)s:%(message)s", + datefmt="%Y-%m-%dT%H:%M:%S", + level=logging.INFO, +) + + +class SnmpOid(enum.Enum): + INK_LEVEL = ("ink_level", "1.3.6.1.2.1.43.11.1.1.9.1.1") + INK_CAPACITY = ("ink_capacity", "1.3.6.1.2.1.43.11.1.1.8.1.1") + PAGE_COUNT = ("page_count", "1.3.6.1.2.1.43.10.2.1.4.1.1") + TRAY_EMPTY = ("tray_empty", "1.3.6.1.2.1.43.18.1.1.8.1.13", True) + # we observed each printer emitting a different SNMP OID for + # an empty paper tray, the below accounts for this second OID. + # the _2 at the end of this metric does imply that the printer has + # 2 trays. tray_empty_2 is an indication of the same exact issue + # as tray_empty: an empty paper tray. + TRAY_EMPTY_2 = ("tray_empty_2", "1.3.6.1.2.1.43.18.1.1.8.1.2", True) + + def __init__(self, metric_name, metric_value, is_error=False): + self.metric_name = metric_name + self.metric_value = metric_value + self.is_error = is_error + + +def fetch_ips_from_config(config_file_path): + try: + with open(config_file_path, "r") as f: + config = json.load(f) + printer_configs = config.get("PRINTING") + if not printer_configs: + raise Exception("No printers defined in config file") + + ip_list = [] + for printer in printer_configs: + if isinstance(printer_configs[printer], dict): + ip = printer_configs[printer]["IP"] + logging.info(f"Adding printer {printer} with IP {ip}") + ip_list.append(ip) + return ip_list + + except Exception as e: + logging.error(f"error opening config file: {e}") + + +def scrape_snmp(ip_list, sleep_duration_minutes=5): + while True: + for ip in ip_list: + get_snmp_data(ip) + time.sleep(sleep_duration_minutes * 60) + + +def get_snmp_data(ip): + for oid in SnmpOid: + with metrics_handler.snmp_request_duration.time(): + errorIndication, errorStatus, errorIndex, varBinds = next( + getCmd( + SnmpEngine(), + CommunityData("public", mpModel=0), + UdpTransportTarget((ip, 161)), + ContextData(), + ObjectType(ObjectIdentity(oid.metric_value)), + ) + ) + if errorIndication: + logging.error( + f"Error indication from {ip} for metric {oid.metric_value}: {errorIndication}" + ) + metrics_handler.device_unreachable.set(1) + continue + if errorStatus: + logging.error( + f"Error status from {ip} for metric {oid.metric_value}: {errorStatus.prettyPrint()}" + ) + # SNMP OIDs related to errors often dissappear when + # the associated issue that the metric refers to is + # no longer present (i.e. an empty tray now has + # paper). To avoid leaving an error metric as 1 + # which would create a false positive, set the metric + # to zero if the associated SNMP OID was not found + if oid.is_error: + metrics_handler.snmp_error.labels(name=oid.metric_name, ip=ip).set(0) + continue + + metrics_handler.device_unreachable.set(0) + if not varBinds: + continue + res = varBinds[0] + if oid.is_error: + metrics_handler.snmp_error.labels(name=oid.metric_name, ip=ip).set(1) + continue + metrics_handler.snmp_metric.labels(name=oid.metric_name, ip=ip).set(res[1]) diff --git a/printer/metrics.py b/printer/metrics.py index 7cada02e..2b544fda 100644 --- a/printer/metrics.py +++ b/printer/metrics.py @@ -23,6 +23,31 @@ class Metrics(enum.Enum): "total bytes of files pointed to by cache", prometheus_client.Gauge, ) + SNMP_METRIC = ( + "snmp_metric", + "ex: Number of pages printed", + prometheus_client.Gauge, + ["name", "ip"], + ) + + SNMP_ERROR = ( + "snmp_error", + "Error metrics", + prometheus_client.Gauge, + ["name", "ip"], + ) + + SNMP_REQ_DURATION = ( + "snmp_request_duration", + "Time it took for SNMP request", + prometheus_client.Summary, + ) + + DEVICE_UNREACHABLE = ( + "device_unreachable", + "set to 1 when error", + prometheus_client.Gauge, + ) def __init__(self, title, description, prometheus_type, labels=()): # we use the above default value for labels because it matches what's used @@ -38,8 +63,8 @@ class MetricsHandler: _instance = None def __init__(self): - raise RuntimeError('Call MetricsHandler.instance() instead') - + raise RuntimeError("Call MetricsHandler.instance() instead") + def init(self) -> None: for metric in Metrics: setattr( diff --git a/printer/requirements.txt b/printer/requirements.txt index 5da669ca..56bd4bf9 100644 --- a/printer/requirements.txt +++ b/printer/requirements.txt @@ -4,3 +4,5 @@ py-grpc-prometheus==0.7.0 python-multipart==0.0.9 httpx==0.28.1 requests==2.32.3 +pysnmp==4.4.12 +pyasn1==0.4.8 diff --git a/printer/server.py b/printer/server.py index e71bc9de..980e09c2 100644 --- a/printer/server.py +++ b/printer/server.py @@ -6,6 +6,7 @@ import threading import time import uuid +import collector from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi.middleware.cors import CORSMiddleware @@ -63,7 +64,12 @@ def get_args() -> argparse.Namespace: default=False, help="specify if server should delete pdfs after printing", ) - + parser.add_argument( + "--sleep-duration-minutes", + type=int, + help="update sleepy time, default is 2mins", + default=2, + ) return parser.parse_args() @@ -214,5 +220,16 @@ async def read_item( ) t.start() + if not args.development and os.path.exists(args.config_json_path): + thread = threading.Thread( + target=collector.scrape_snmp, + args=( + collector.fetch_ips_from_config(args.config_json_path), + args.sleep_duration_minutes, + ), + daemon=True, + ) + thread.start() + if __name__ == "__main__": uvicorn.run("server:app", host=args.host, port=args.port, reload=True)