This repository was archived by the owner on Jan 23, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 18
snmp: initial SNMP driver for power control #251
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
58e2e18
snmp: initial SNMP driver for power control
bennyz 63ecbc5
inherit power client and standartize method names
bennyz e08fc88
add tests and doc
bennyz 0412944
snmp: remove cycle from snmp client
bennyz a6b9ca1
snmp: drop return values for power control methods
bennyz 255bfc8
snmp: close event loop if created
bennyz 66241a9
snmp: add doc to index
bennyz 7d781b3
Merge remote-tracking branch 'origin/main' into snmp-driver
bennyz 769aa90
Merge remote-tracking branch 'origin/main' into snmp-driver
bennyz 135610e
snmp: use doc reference properly
bennyz File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or 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
This file contains hidden or 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,67 @@ | ||
| # SNMP | ||
|
|
||
| **driver**: `jumpstarter_driver_snmp.driver.SNMPServer` | ||
|
|
||
| A driver for controlling power via SNMP-enabled PDUs (Power Distribution Units). | ||
|
|
||
| ## Driver configuration | ||
| ```yaml | ||
| export: | ||
| power: | ||
| type: "jumpstarter_driver_snmp.driver.SNMPServer" | ||
| config: | ||
| host: "pdu.mgmt.com" | ||
| user: "labuser" | ||
| plug: 32 | ||
| port: 161 | ||
| oid: "1.3.6.1.4.1.13742.6.4.1.2.1.2.1" | ||
| auth_protocol: "NONE" | ||
| auth_key: null | ||
| priv_protocol: "NONE" | ||
| priv_key: null | ||
| timeout: 5.0 | ||
| ``` | ||
|
|
||
| ### Config parameters | ||
|
|
||
| | Parameter | Description | Type | Required | Default | | ||
| |-----------|-------------|------|----------|---------| | ||
| | host | Hostname or IP address of the SNMP-enabled PDU | str | yes | | | ||
| | user | SNMP v3 username | str | yes | | | ||
| | plug | PDU outlet number to control | int | yes | | | ||
| | port | SNMP port number | int | no | 161 | | ||
| | oid | Base OID for power control | str | no | "1.3.6.1.4.1.13742.6.4.1.2.1.2.1" | | ||
| | auth_protocol | Authentication protocol ("NONE", "MD5", "SHA") | str | no | "NONE" | | ||
| | auth_key | Authentication key when auth_protocol is not "NONE" | str | no | null | | ||
| | priv_protocol | Privacy protocol ("NONE", "DES", "AES") | str | no | "NONE" | | ||
| | priv_key | Privacy key when priv_protocol is not "NONE" | str | no | null | | ||
| | timeout | SNMP timeout in seconds | float | no | 5.0 | | ||
|
|
||
| ## SNMPServerClient API | ||
|
|
||
| ### Methods | ||
|
|
||
| ```{eval-rst} | ||
| .. autoclass:: jumpstarter_driver_snmp.client.SNMPServerClient() | ||
| :members: | ||
| :show-inheritance: | ||
| ``` | ||
|
|
||
| ## Examples | ||
|
|
||
| Power cycling a device: | ||
| ```python | ||
| snmp_client.cycle(wait=3) | ||
| ``` | ||
|
|
||
| Basic power control: | ||
| ```python | ||
| snmp_client.off() | ||
| snmp_client.on() | ||
| ``` | ||
|
|
||
| Using the CLI: | ||
| ```bash | ||
| j power on | ||
| j power off | ||
| j power cycle --wait 3 |
1 change: 1 addition & 0 deletions
1
packages/jumpstarter-driver-composite/jumpstarter_driver_composite/driver_test.py
This file contains hidden or 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,3 +1,4 @@ | ||
|
|
||
| from jumpstarter_driver_power.driver import MockPower | ||
|
|
||
| from .driver import Composite | ||
|
|
||
This file contains hidden or 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
Empty file.
This file contains hidden or 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,18 @@ | ||
| apiVersion: jumpstarter.dev/v1alpha1 | ||
| kind: ExporterConfig | ||
| endpoint: grpc.jumpstarter.192.168.0.203.nip.io:8082 | ||
| metadata: | ||
| namespace: default | ||
| name: demo | ||
| tls: | ||
| ca: '' | ||
| insecure: true | ||
| token: <token> | ||
| export: | ||
| power: | ||
| type: "jumpstarter_driver_snmp.driver.SNMPServer" | ||
| config: | ||
| host: "pdu.mgmt.com" | ||
| user: "labuser" | ||
| plug: 32 | ||
| oid: "1.3.6.1.4.1.13742.6.4.1.2.1.2.1" |
Empty file.
28 changes: 28 additions & 0 deletions
28
packages/jumpstarter-driver-snmp/jumpstarter_driver_snmp/client.py
This file contains hidden or 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,28 @@ | ||
| from dataclasses import dataclass | ||
|
|
||
| import asyncclick as click | ||
| from jumpstarter_driver_power.client import PowerClient | ||
|
|
||
|
|
||
| @dataclass(kw_only=True) | ||
| class SNMPServerClient(PowerClient): | ||
| """Client interface for SNMP Power Control""" | ||
|
|
||
| def on(self): | ||
| """Turn power on""" | ||
| self.call("on") | ||
|
|
||
| def off(self): | ||
| """Turn power off""" | ||
| self.call("off") | ||
|
|
||
| def cli(self): | ||
| @click.group() | ||
| def snmp(): | ||
| """SNMP power control commands""" | ||
| pass | ||
|
|
||
| for cmd in super().cli().commands.values(): | ||
| snmp.add_command(cmd) | ||
|
|
||
| return snmp |
209 changes: 209 additions & 0 deletions
209
packages/jumpstarter-driver-snmp/jumpstarter_driver_snmp/driver.py
This file contains hidden or 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,209 @@ | ||
| import asyncio | ||
| import socket | ||
| from dataclasses import dataclass, field | ||
| from enum import Enum, IntEnum | ||
|
|
||
| from pysnmp.carrier.asyncio.dgram import udp | ||
| from pysnmp.entity import config, engine | ||
| from pysnmp.entity.rfc3413 import cmdgen | ||
| from pysnmp.proto import rfc1902 | ||
|
|
||
| from jumpstarter.driver import Driver, export | ||
|
|
||
|
|
||
| class AuthProtocol(str, Enum): | ||
| NONE = "NONE" | ||
| MD5 = "MD5" | ||
| SHA = "SHA" | ||
|
|
||
| class PrivProtocol(str, Enum): | ||
| NONE = "NONE" | ||
| DES = "DES" | ||
| AES = "AES" | ||
|
|
||
|
bennyz marked this conversation as resolved.
|
||
| class PowerState(IntEnum): | ||
| OFF = 0 | ||
| ON = 1 | ||
|
|
||
| class SNMPError(Exception): | ||
| """Base exception for SNMP errors""" | ||
| pass | ||
|
|
||
| @dataclass(kw_only=True) | ||
| class SNMPServer(Driver): | ||
| """SNMP Power Control Driver""" | ||
| host: str = field() | ||
| user: str = field() | ||
| port: int = field(default=161) | ||
| plug: int = field() | ||
| oid: str = field(default="1.3.6.1.4.1.13742.6.4.1.2.1.2.1") | ||
| auth_protocol: AuthProtocol = field(default=AuthProtocol.NONE) | ||
| auth_key: str | None = field(default=None) | ||
| priv_protocol: PrivProtocol = field(default=PrivProtocol.NONE) | ||
| priv_key: str | None = field(default=None) | ||
| timeout: float = field(default=5.0) | ||
|
|
||
| def __post_init__(self): | ||
| if hasattr(super(), "__post_init__"): | ||
| super().__post_init__() | ||
|
|
||
| try: | ||
| self.ip_address = socket.gethostbyname(self.host) | ||
| self.logger.debug(f"Resolved {self.host} to {self.ip_address}") | ||
| except socket.gaierror as e: | ||
| raise SNMPError(f"Failed to resolve hostname {self.host}: {e}") from e | ||
|
|
||
| self.full_oid = tuple(int(x) for x in self.oid.split('.')) + (self.plug,) | ||
|
|
||
| def _setup_snmp(self): | ||
| snmp_engine = engine.SnmpEngine() | ||
|
|
||
| AUTH_PROTOCOLS = { | ||
| AuthProtocol.NONE: config.USM_AUTH_NONE, | ||
| AuthProtocol.MD5: config.USM_AUTH_HMAC96_MD5, | ||
| AuthProtocol.SHA: config.USM_AUTH_HMAC96_SHA, | ||
| } | ||
|
|
||
| PRIV_PROTOCOLS = { | ||
| PrivProtocol.NONE: config.USM_PRIV_NONE, | ||
| PrivProtocol.DES: config. USM_PRIV_CBC56_DES, | ||
| PrivProtocol.AES: config.USM_PRIV_CFB128_AES, | ||
| } | ||
|
|
||
| auth_protocol = AUTH_PROTOCOLS[self.auth_protocol] | ||
| priv_protocol = PRIV_PROTOCOLS[self.priv_protocol] | ||
|
|
||
| if self.auth_protocol == AuthProtocol.NONE: | ||
| security_level = "noAuthNoPriv" | ||
| elif self.priv_protocol == PrivProtocol.NONE: | ||
| security_level = "authNoPriv" | ||
| else: | ||
| security_level = "authPriv" | ||
|
|
||
| if security_level == "noAuthNoPriv": | ||
| config.add_v3_user( | ||
| snmp_engine, | ||
| self.user | ||
| ) | ||
| elif security_level == "authNoPriv": | ||
| if not self.auth_key: | ||
| raise SNMPError("Authentication key required when auth_protocol is specified") | ||
| config.add_v3_user( | ||
| snmp_engine, | ||
| self.user, | ||
| auth_protocol, | ||
| self.auth_key | ||
| ) | ||
| else: | ||
| if not self.auth_key or not self.priv_key: | ||
| raise SNMPError("Both auth_key and priv_key required for authenticated privacy") | ||
| config.add_v3_user( | ||
| snmp_engine, | ||
| self.user, | ||
| auth_protocol, | ||
| self.auth_key, | ||
| priv_protocol, | ||
| self.priv_key | ||
| ) | ||
|
|
||
| config.add_target_parameters( | ||
| snmp_engine, | ||
| "my-creds", | ||
| self.user, | ||
| security_level | ||
| ) | ||
|
|
||
| config.add_target_address( | ||
| snmp_engine, | ||
| "my-target", | ||
| udp.DOMAIN_NAME, | ||
| (self.ip_address, self.port), | ||
| "my-creds", | ||
| timeout=int(self.timeout * 100), | ||
| ) | ||
|
|
||
| config.add_transport( | ||
| snmp_engine, | ||
| udp.DOMAIN_NAME, | ||
| udp.UdpAsyncioTransport().open_client_mode() | ||
| ) | ||
|
|
||
| return snmp_engine | ||
|
|
||
| @classmethod | ||
| def client(cls) -> str: | ||
| return "jumpstarter_driver_snmp.client.SNMPServerClient" | ||
|
|
||
| def _snmp_set(self, state: PowerState): | ||
| result = {"success": False, "error": None} | ||
|
|
||
| def callback(snmpEngine, sendRequestHandle, errorIndication, | ||
| errorStatus, errorIndex, varBinds, cbCtx): | ||
| self.logger.debug(f"Callback {errorIndication} {errorStatus} {errorIndex} {varBinds}") | ||
| if errorIndication: | ||
| self.logger.error(f"SNMP error: {errorIndication}") | ||
| result["error"] = f"SNMP error: {errorIndication}" | ||
| elif errorStatus: | ||
| self.logger.error(f"SNMP status: {errorStatus}") | ||
| result["error"] = ( | ||
| f"SNMP error: {errorStatus.prettyPrint()} at " | ||
| f"{varBinds[int(errorIndex) - 1][0] if errorIndex else '?'}" | ||
| ) | ||
| else: | ||
| result["success"] = True | ||
| for oid, val in varBinds: | ||
| self.logger.debug(f"{oid.prettyPrint()} = {val.prettyPrint()}") | ||
| self.logger.debug(f"SNMP set result: {result}") | ||
|
|
||
| try: | ||
| self.logger.info(f"Sending power {state.name} command to {self.host}") | ||
| created_loop = False | ||
|
|
||
| try: | ||
| asyncio.get_running_loop() | ||
| except RuntimeError: | ||
| loop = asyncio.new_event_loop() | ||
| asyncio.set_event_loop(loop) | ||
| created_loop = True | ||
|
|
||
| snmp_engine = self._setup_snmp() | ||
|
|
||
| cmdgen.SetCommandGenerator().send_varbinds( | ||
| snmp_engine, | ||
| "my-target", | ||
| None, | ||
| "", | ||
| [(self.full_oid, rfc1902.Integer(state.value))], | ||
| callback, | ||
| ) | ||
|
|
||
| snmp_engine.open_dispatcher(self.timeout) | ||
| snmp_engine.close_dispatcher() | ||
|
|
||
| if not result["success"]: | ||
| raise SNMPError(result["error"]) | ||
|
|
||
| return f"Power {state.name} command sent successfully" | ||
|
|
||
| except Exception as e: | ||
| error_msg = f"SNMP set failed: {str(e)}" | ||
| self.logger.error(error_msg) | ||
| raise SNMPError(error_msg) from e | ||
| finally: | ||
| if created_loop: | ||
| loop.close() | ||
|
|
||
| @export | ||
| def on(self): | ||
| """Turn power on""" | ||
| return self._snmp_set(PowerState.ON) | ||
|
|
||
| @export | ||
| def off(self): | ||
| """Turn power off""" | ||
| return self._snmp_set(PowerState.OFF) | ||
|
|
||
| def close(self): | ||
| """No cleanup needed since engines are created per operation""" | ||
| if hasattr(super(), "close"): | ||
| super().close() | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.