From 077adc35ffed5dd0f4899836d134e88b292c91d8 Mon Sep 17 00:00:00 2001 From: Andreas Motl Date: Mon, 1 Aug 2022 22:31:41 +0200 Subject: [PATCH 1/8] build: Add project tooling --- Makefile | 34 ++++++++++++++++++++++++++++++++++ requirements-utils.txt | 5 +++++ 2 files changed, 39 insertions(+) create mode 100644 Makefile create mode 100644 requirements-utils.txt diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f6bb000 --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +$(eval venv := .venv) +$(eval pip := $(venv)/bin/pip) +$(eval python := $(venv)/bin/python) +$(eval black := $(venv)/bin/black) +$(eval isort := $(venv)/bin/isort) +$(eval pytest := $(venv)/bin/pytest) +$(eval twine := $(venv)/bin/twine) +$(eval flake8 := $(venv)/bin/pflake8) +$(eval proselint := $(venv)/bin/proselint) + +setup-virtualenv: + @test -e $(python) || python3 -m venv $(venv) || python -m venv $(venv) + +format: setup-virtualenv + $(pip) install --requirement=requirements-utils.txt + $(black) . + $(isort) . + +lint: setup-virtualenv + $(pip) install --requirement=requirements-utils.txt + $(flake8) --exit-zero *.py + $(MAKE) proselint + +proselint: + $(proselint) *.md || true + +test: setup-virtualenv + $(pip) install --editable=.[test] + $(pytest) + +publish: setup-virtualenv + $(pip) install build twine + $(python) -m build + $(twine) upload --skip-existing --verbose dist/*{.tar.gz,.whl} diff --git a/requirements-utils.txt b/requirements-utils.txt new file mode 100644 index 0000000..15f02a6 --- /dev/null +++ b/requirements-utils.txt @@ -0,0 +1,5 @@ +black +isort +pyproject-flake8 +flake8<5 +proselint From fff292baa8455e0ef2ce33014c48f7c6508c20a9 Mon Sep 17 00:00:00 2001 From: Andreas Motl Date: Mon, 1 Aug 2022 22:32:16 +0200 Subject: [PATCH 2/8] test: Add software test framework Just run `git clone ...` and `make test`. --- .gitignore | 2 ++ README.md | 14 ++++++-------- pyproject.toml | 28 ++++++++++++++++++++++++++++ setup.py | 7 +++++++ testing/test_cli.py | 40 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 pyproject.toml create mode 100644 testing/test_cli.py diff --git a/.gitignore b/.gitignore index 8468f27..d4d043a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ .venv* __pycache__ *.egg-info +.coverage +coverage.xml diff --git a/README.md b/README.md index a04fe8d..bcd40a0 100644 --- a/README.md +++ b/README.md @@ -80,20 +80,18 @@ ln -s $(which check_synology) /usr/lib/nagios/plugins/check_synology ## Development -For setting up a development sandbox, you might want to follow this walkthrough. +For setting up a development sandbox and running the software tests, you might +want to follow this walkthrough. -Acquire sources: ```shell git clone https://github.com/wernerfred/check_synology cd check_synology +make test ``` -Install program in development mode into a Python virtual environment: -```shell -python3 -m venv .venv -source .venv/bin/activate -pip install --editable=. -``` +By running `make test`, a Python virtual environment will be created within the +`.venv` folder of your working tree. Use `source .venv/bin/activate` to +activate it. ## Contributors ✨ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5d74761 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[tool.black] +line-length = 120 + +[tool.isort] +profile = "black" +line_length = 120 +multi_line_output = 3 + +[tool.flake8] +max-line-length = 120 + +[tool.pytest.ini_options] +minversion = "2.0" +addopts = "-rsfEX -p pytester --strict-markers --verbosity=3 --cov --cov-report=term-missing --cov-report=xml" +log_level = "DEBUG" +testpaths = ["testing"] +xfail_strict = true +markers = [ +] + +[tool.coverage.run] +omit = [ + "testing/*", +] + +[tool.coverage.report] +fail_under = 0 +show_missing = true diff --git a/setup.py b/setup.py index abc930f..607d042 100644 --- a/setup.py +++ b/setup.py @@ -27,6 +27,13 @@ def read(path): entry_points={"console_scripts": ["check_synology = check_synology"]}, python_requires=">=3.4", install_requires=["easysnmp>=0.2.6,<1"], + extras_require={ + "test": [ + "pytest<8", + "pytest-mock<4", + "pytest-cov<4", + ], + }, classifiers=[ "Development Status :: 4 - Beta", "License :: OSI Approved :: GNU Affero General Public License v3", diff --git a/testing/test_cli.py b/testing/test_cli.py new file mode 100644 index 0000000..8055d34 --- /dev/null +++ b/testing/test_cli.py @@ -0,0 +1,40 @@ +import sys + + +def run_program(*args) -> int: + """ + Run `check_synology` program and return exit code. + + Support a variable number of command line options. + """ + sys.argv = ["check_synology", *args] + try: + import check_synology + except SystemExit as ex: + return ex.code % 256 + + +def test_no_options(capsys): + """ + Verify running the program without options croaks as expected. + """ + exitcode = run_program() + response = capsys.readouterr() + assert exitcode == 2 + assert response.out == "" + assert ( + "check_synology: error: the following arguments are required: " + "hostname, username, authkey, privkey, mode" in response.err + ) + + +def test_help(capsys): + """ + Verify running the program with the `--help` option works as expected. + """ + exitcode = run_program("--help") + response = capsys.readouterr() + assert exitcode == 0 + assert "the hostname" in response.out + assert "critical value for selected mode" in response.out + assert response.err == "" From c3733e443de87598264276f90519bbf32e75bafa Mon Sep 17 00:00:00 2001 From: Andreas Motl Date: Mon, 1 Aug 2022 22:32:44 +0200 Subject: [PATCH 3/8] ci: Run software tests on GHA --- .github/workflows/tests.yml | 56 +++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..11cde2a --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,56 @@ +name: Tests + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + + tests: + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [ "ubuntu-latest", "macos-latest" ] + python-version: [ "3.6", "3.7", "3.8", "3.9", "3.10" ] + + env: + OS: ${{ matrix.os }} + PYTHON: ${{ matrix.python-version }} + + defaults: + run: + shell: bash + + name: Python ${{ matrix.python-version }} on ${{ matrix.os }} + steps: + + - name: Acquire sources + uses: actions/checkout@v3 + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + cache: 'pip' + cache-dependency-path: 'setup.py' + + - name: Run linter + run: | + make lint + + - name: Run tests, with coverage + run: | + make test + + - name: Upload coverage results to Codecov + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml + flags: unittests + env_vars: OS,PYTHON + name: codecov-umbrella + fail_ci_if_error: false From c45357113243581617db3714ec1e397cdaf524f5 Mon Sep 17 00:00:00 2001 From: Andreas Motl Date: Mon, 1 Aug 2022 22:40:54 +0200 Subject: [PATCH 4/8] ci: Install Net-SNMP package as prerequisite --- .github/workflows/tests.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 11cde2a..ab147ec 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,6 +15,7 @@ jobs: matrix: os: [ "ubuntu-latest", "macos-latest" ] python-version: [ "3.6", "3.7", "3.8", "3.9", "3.10" ] + fail-fast: false env: OS: ${{ matrix.os }} @@ -38,6 +39,16 @@ jobs: cache: 'pip' cache-dependency-path: 'setup.py' + - name: Install prerequisites (Linux) + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt install --yes libsnmp-dev snmp-mibs-downloader + + - name: Install prerequisites (macOS) + if: matrix.os == 'macos-latest' + run: | + brew install net-snmp + - name: Run linter run: | make lint From 638cd2595d17f0e3e50d74e388323cb69d3f81dc Mon Sep 17 00:00:00 2001 From: Andreas Motl Date: Mon, 1 Aug 2022 22:33:39 +0200 Subject: [PATCH 5/8] chore: Update package metadata with corresponding Python versions --- setup.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 607d042..8e1809d 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ def read(path): keywords="synology, synology-diskstation, snmp, snmpv3, monitoring, monitoring-plugin, nagios, icinga2, icinga2-plugin", py_modules=["check_synology"], entry_points={"console_scripts": ["check_synology = check_synology"]}, - python_requires=">=3.4", + python_requires=">=3.6", install_requires=["easysnmp>=0.2.6,<1"], extras_require={ "test": [ @@ -41,7 +41,13 @@ def read(path): "Operating System :: OS Independent", "Environment :: Console", "Programming Language :: Python", + "Programming Language :: Python", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "Programming Language :: Python :: Implementation :: CPython", "Intended Audience :: Developers", "Intended Audience :: Education", From 9fbf4a5730def0baff93aefe976bf6534051db06 Mon Sep 17 00:00:00 2001 From: Andreas Motl Date: Mon, 1 Aug 2022 22:46:02 +0200 Subject: [PATCH 6/8] docs: Update badges in README --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bcd40a0..34c2b85 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,8 @@ -# check_synology [![Release](https://img.shields.io/github/release/wernerfred/check_synology.svg)](https://github.com/wernerfred/check_synology/releases) +# check_synology + +[![Tests](https://github.com/wernerfred/check_synology/actions/workflows/tests.yml/badge.svg)](https://github.com/wernerfred/check_synology/actions/workflows/tests.yml) +[![Code coverage](https://codecov.io/gh/wernerfred/check_synology/branch/master/graph/badge.svg)](https://codecov.io/gh/wernerfred/check_synology) +[![Release](https://img.shields.io/github/release/wernerfred/check_synology.svg)](https://github.com/wernerfred/check_synology/releases) ## About From 75450655ee8c294f5a0f5643ad5ad62fb145aaff Mon Sep 17 00:00:00 2001 From: Andreas Motl Date: Tue, 9 Aug 2022 11:57:07 +0200 Subject: [PATCH 7/8] test: Add SNMP responder for testing the SNMP conversation(s) --- setup.py | 1 + testing/snmp_responder.py | 280 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 testing/snmp_responder.py diff --git a/setup.py b/setup.py index 8e1809d..d7ce04f 100644 --- a/setup.py +++ b/setup.py @@ -32,6 +32,7 @@ def read(path): "pytest<8", "pytest-mock<4", "pytest-cov<4", + "pysnmplib<6", ], }, classifiers=[ diff --git a/testing/snmp_responder.py b/testing/snmp_responder.py new file mode 100644 index 0000000..37cb83a --- /dev/null +++ b/testing/snmp_responder.py @@ -0,0 +1,280 @@ +""" +About +===== + +A custom MIB controller. Listen and respond to SNMP GET/SET/GETNEXT/GETBULK +queries with the following options: + +* SNMPv3 +* with USM username usr-none-none +* using alternative set of Managed Objects addressed by + contextName: my-context +* allow access to SNMPv2-MIB objects (1.3.6.1.2.1) +* over IPv4/UDP, listening at 127.0.0.1:161 + +The following Net-SNMP command will send GET request to this Agent:: + + snmpget -v3 -u usr-none-none -l noAuthNoPriv -Ir localhost:1161 "1.3.6.1.4.1.2021.10.1.5.1" + +Setup +===== +:: + + pip install pysnmplib + +References +========== +- https://github.com/pysnmp/pysnmp/blob/main/examples/v3arch/asyncore/agent/cmdrsp/custom-mib-controller.py +- https://github.com/pysnmp/pysnmp/blob/main/examples/v3arch/asyncore/agent/cmdrsp/multiple-usm-users.py +""" +import asyncio +import dataclasses +import sys +import threading +import typing as t +from enum import Enum + +from pyasn1.compat.octets import null +from pysnmp import debug +from pysnmp.carrier.asyncio.dgram import udp +from pysnmp.entity import config, engine +from pysnmp.entity.rfc3413 import cmdrsp, context +from pysnmp.proto.api import v2c +from pysnmp.proto.secmod.rfc3414.auth.base import AbstractAuthenticationService +from pysnmp.proto.secmod.rfc3414.priv.base import AbstractEncryptionService +from pysnmp.smi import instrum + + +@dataclasses.dataclass +class AuthenticationInformation: + userName: str + authProtocol: t.Union[str, AbstractAuthenticationService] + authKey: str + privProtocol: t.Union[str, AbstractEncryptionService] + privKey: str + + def __post_init__(self): + if isinstance(self.authProtocol, str): + if self.authProtocol == "MD5": + self.authProtocol = config.usmHMACMD5AuthProtocol + else: + raise KeyError(f"Authentication protocol {self.authProtocol} not implemented") + if isinstance(self.privProtocol, str): + if self.privProtocol == "AES128": + self.privProtocol = config.usmAesCfb128Protocol + else: + raise KeyError(f"Encryption protocol {self.privProtocol} not implemented") + + def asdict(self): + return dataclasses.asdict(self) + + +class SnmpResponder: + """ + A generic SNMP responder based on `pysnmplib`, using `asyncore`. + """ + + def __init__( + self, + host: str = "localhost", + port: int = 161, + auth_info: t.Optional[AuthenticationInformation] = None, + debug_flags: t.Optional[t.List[str]] = None, + ): + + self.host = host + self.port = port + self.auth_info = auth_info + + debug_flags = debug_flags or [] + if debug_flags: + debug.setLogger(debug.Debug(*debug_flags)) + + # Create SNMP engine + self.snmp_engine = engine.SnmpEngine() + + def setup(self): + # Transport setup + + # UDP over IPv4. + # TODO: Make selecting different transport configurable. + config.addTransport( + snmpEngine=self.snmp_engine, + transportDomain=udp.domainName, + transport=udp.UdpTransport().openServerMode((self.host, self.port)), + ) + + # SNMPv3/USM setup + + # user: usr-none-none, auth: NONE, priv NONE + # For testing with `snmpget`. + """ + config.addV3User( + snmpEngine=self.snmp_engine, + userName='usr-none-none', + ) + """ + + # user: foo, auth: MD5, priv AES128 + if self.auth_info: + config.addV3User( + snmpEngine=self.snmp_engine, + **self.auth_info.asdict(), + ) + + # Allow full MIB access for each user at VACM + # config.addVacmUser(snmpEngine, 3, 'usr-none-none', 'noAuthNoPriv', (1, 3, 6, 1, 2, 1), (1, 3, 6, 1, 2, 1)) + # config.addVacmUser(snmpEngine, 3, 'foo', 'authPriv', (1, 3, 6, 1, 2, 1), (1, 3, 6, 1, 2, 1)) + # config.addVacmUser(snmpEngine, 3, 'foo', 'authPriv', (1, 3, 6, 1, 4, 1), (1, 3, 6, 1, 4, 1)) + + def create_context(self, controller: instrum.AbstractMibInstrumController = None): + + # Create an SNMP context + snmp_context = context.SnmpContext(self.snmp_engine) + + # Register GET&SET Applications at the SNMP engine for a custom SNMP context + cmdrsp.GetCommandResponder(self.snmp_engine, snmp_context) + cmdrsp.SetCommandResponder(self.snmp_engine, snmp_context) + + # Create a custom Management Instrumentation Controller and register it + # as the default SNMP context controller. + if controller is not None: + snmp_context.contextNames[null] = controller + + return snmp_context + + def start(self): + # Register an imaginary never-ending job to keep I/O dispatcher running forever. + print("self.snmp_engine.transportDispatcher:", self.snmp_engine.transportDispatcher) + + self.snmp_engine.transportDispatcher.jobStarted(1) + + # Run I/O dispatcher which would receive queries and send responses. + self.snmp_engine.transportDispatcher.runDispatcher() + try: + self.snmp_engine.transportDispatcher.runDispatcher() + + finally: + self.snmp_engine.transportDispatcher.closeDispatcher() + + def start_background(self): + t = threading.Thread(target=self.start) + t.setDaemon(True) + t.start() + + def stop(self): + self.snmp_engine.transportDispatcher.closeDispatcher() + + +class MockedGenericMibController(instrum.AbstractMibInstrumController): + """ + A generic SNMP Management Instrumentation Controller. + + It supports only GET requests and always echos request var-binds in response. + """ + + # Map Python types to MIB V2C types. + V2C_TYPEMAP = { + int: v2c.Integer, + str: v2c.OctetString, + } + + def __init__(self): + self.readers: t.Dict[str, t.Union[str, int, t.Callable]] = {} + self.configure() + + def configure(self): + pass + + def register_read(self, oid: str, response: t.Union[str, int, t.Callable]): + self.readers[oid] = response + + def readVars(self, varBinds, acInfo=(None, None)): + for var in varBinds: + oid = str(var[0]) + + if oid not in self.readers: + yield self.handle_error(oid, f"ERROR: OID {oid} not supported") + continue + + try: + response_value = self.readers[oid] + except KeyError: + yield self.handle_error(oid, f"ERROR: No response value for OID {oid} found") + continue + + try: + if callable(response_value): + response_value = response_value(oid=oid) + except Exception: + yield self.handle_error(oid, f"ERROR: Running callback for OID {oid} failed") + continue + + try: + v2c_type = self.V2C_TYPEMAP[type(response_value)] + except KeyError: + yield self.handle_error(oid, f"ERROR: Resolving type for {oid} failed: {type(response_value)}") + continue + + try: + snmp_response = (oid, v2c_type(response_value)) + except KeyError: + yield self.handle_error(oid, f"ERROR: Creating response with oid={oid}, value={response_value} failed") + continue + + msg = f"INFO: Response value for OID {oid} is {response_value}" + self.log(msg) + yield snmp_response + + def handle_error(self, oid: str, message: str): + self.log(message) + return oid, v2c.OctetString(message) + + @staticmethod + def log(*msg): + print(*msg, file=sys.stderr) + + +class SynologyDiskStationMib(Enum): + """ + An enumeration of all MIB OIDs used by Synology DiskStation. + """ + + LOAD01 = "1.3.6.1.4.1.2021.10.1.5.1" + LOAD05 = "1.3.6.1.4.1.2021.10.1.5.2" + LOAD15 = "1.3.6.1.4.1.2021.10.1.5.3" + + +class MockedSynologyMibController(MockedGenericMibController): + """ + An SNMP Management Instrumentation Controller mocking a Synology DiskStation. + """ + + def configure(self): + self.register_read(oid=SynologyDiskStationMib.LOAD01.value, response=int(13.42 * 100)) + self.register_read(oid=SynologyDiskStationMib.LOAD05.value, response=int(8.13 * 100)) + self.register_read(oid=SynologyDiskStationMib.LOAD15.value, response=int(5.33 * 100)) + + +def main(): + + auth_info = AuthenticationInformation( + userName="test-user", + authProtocol="MD5", + authKey="test-authkey", + privProtocol="AES128", + privKey="test-privkey", + ) + + snmpd = SnmpResponder(port=1161, auth_info=auth_info) + # snmpd = SnmpResponder(port=1161, debug_flags=["all"]) + # snmpd = SnmpResponder(port=1161, debug_flags=["dsp", "msgproc", "secmod"]) + + snmpd.setup() + snmpd.create_context(MockedSynologyMibController()) + snmpd.start() + snmpd.stop() + + +if __name__ == "__main__": + main() From 8b2135679bc8c3dc6a21b0b2c316665174513228 Mon Sep 17 00:00:00 2001 From: Andreas Motl Date: Tue, 9 Aug 2022 11:57:53 +0200 Subject: [PATCH 8/8] test: Add SNMP responder fixture for pytest and test case for `load` --- testing/conftest.py | 27 +++++++++++++++++++++++++++ testing/test_modes.py | 11 +++++++++++ 2 files changed, 38 insertions(+) create mode 100644 testing/conftest.py create mode 100644 testing/test_modes.py diff --git a/testing/conftest.py b/testing/conftest.py new file mode 100644 index 0000000..9f86714 --- /dev/null +++ b/testing/conftest.py @@ -0,0 +1,27 @@ +import pytest + +from testing.snmp_responder import AuthenticationInformation, MockedSynologyMibController, SnmpResponder + + +@pytest.fixture +def synology_mock(): + + auth_info = AuthenticationInformation( + userName="test-user", + authProtocol="MD5", + authKey="test-authkey", + privProtocol="AES128", + privKey="test-privkey", + ) + + snmpd = SnmpResponder(port=1161, auth_info=auth_info) + # snmpd = SnmpResponder(port=1161, debug_flags=["all"]) + # snmpd = SnmpResponder(port=1161, debug_flags=["dsp", "msgproc", "secmod"]) + + snmpd.setup() + snmpd.create_context(MockedSynologyMibController()) + snmpd.start_background() + + yield snmpd + + snmpd.stop() diff --git a/testing/test_modes.py b/testing/test_modes.py new file mode 100644 index 0000000..19762fb --- /dev/null +++ b/testing/test_modes.py @@ -0,0 +1,11 @@ +from testing.test_cli import run_program + + +def test_load(synology_mock, capsys): + """ + Verify running the program with `mode=load`. + """ + exitcode = run_program("localhost:1161", "test-user", "test-authkey", "test-privkey", "load") + response = capsys.readouterr() + assert exitcode == 0 + assert response.out.strip() == "OK - load average: 13.42, 8.13, 5.33 | load1=13.42c load5=8.13c load15=5.33c"