Skip to content

Commit 38f5158

Browse files
committed
snmp: initial SNMP driver for power control
Signed-off-by: Benny Zlotnik <[email protected]>
1 parent 18994d0 commit 38f5158

File tree

6 files changed

+259
-0
lines changed

6 files changed

+259
-0
lines changed

packages/jumpstarter-driver-snmp/README.md

Whitespace-only changes.

packages/jumpstarter-driver-snmp/jumpstarter_driver_snmp/__init__.py

Whitespace-only changes.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from dataclasses import dataclass
2+
3+
import asyncclick as click
4+
5+
from jumpstarter.client import DriverClient
6+
7+
8+
@dataclass(kw_only=True)
9+
class SNMPServerClient(DriverClient):
10+
"""Client interface for SNMP Power Control"""
11+
12+
def power_on(self):
13+
"""Turn power on"""
14+
return self.call("power_on")
15+
16+
def power_off(self):
17+
"""Turn power off"""
18+
return self.call("power_off")
19+
20+
def power_cycle(self):
21+
"""Power cycle the device"""
22+
return self.call("power_cycle")
23+
24+
def cli(self):
25+
@click.group()
26+
def snmp():
27+
"""SNMP power control commands"""
28+
pass
29+
30+
@snmp.command()
31+
def on():
32+
"""Turn power on"""
33+
result = self.power_on()
34+
click.echo(result)
35+
36+
@snmp.command()
37+
def off():
38+
"""Turn power off"""
39+
result = self.power_off()
40+
click.echo(result)
41+
42+
@snmp.command()
43+
def cycle():
44+
"""Power cycle the device"""
45+
result = self.power_cycle()
46+
click.echo(result)
47+
48+
return snmp
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import asyncio
2+
import socket
3+
import time
4+
from dataclasses import dataclass, field
5+
from enum import IntEnum
6+
7+
from pysnmp.carrier.asyncio.dgram import udp
8+
from pysnmp.entity import config, engine
9+
from pysnmp.entity.rfc3413 import cmdgen
10+
from pysnmp.proto import rfc1902
11+
12+
from jumpstarter.driver import Driver, export
13+
14+
15+
class PowerState(IntEnum):
16+
OFF = 0
17+
ON = 1
18+
19+
class SNMPError(Exception):
20+
"""Base exception for SNMP errors"""
21+
pass
22+
23+
@dataclass(kw_only=True)
24+
class SNMPServer(Driver):
25+
"""SNMP Power Control Driver"""
26+
host: str = field()
27+
user: str = field()
28+
port: int = field(default=161)
29+
quiescent_period: int = field(default=5)
30+
timeout: int = 3
31+
plug: int = field()
32+
oid: str = field(default="1.3.6.1.4.1.13742.6.4.1.2.1.2.1")
33+
34+
def __post_init__(self):
35+
if hasattr(super(), "__post_init__"):
36+
super().__post_init__()
37+
38+
try:
39+
self.ip_address = socket.gethostbyname(self.host)
40+
self.logger.info(f"Resolved {self.host} to {self.ip_address}")
41+
except socket.gaierror as e:
42+
raise SNMPError(f"Failed to resolve hostname {self.host}: {e}") from e
43+
44+
self.full_oid = tuple(int(x) for x in self.oid.split('.')) + (self.plug,)
45+
46+
def _setup_snmp(self):
47+
try:
48+
# TODO: switch to anyio?
49+
asyncio.get_running_loop()
50+
except RuntimeError:
51+
loop = asyncio.new_event_loop()
52+
asyncio.set_event_loop(loop)
53+
54+
55+
snmp_engine = engine.SnmpEngine()
56+
57+
config.add_v3_user(
58+
snmp_engine,
59+
self.user,
60+
config.USM_AUTH_NONE,
61+
None
62+
)
63+
config.add_target_parameters(
64+
snmp_engine,
65+
"my-creds",
66+
self.user,
67+
"noAuthNoPriv"
68+
)
69+
70+
config.add_transport(
71+
snmp_engine,
72+
udp.DOMAIN_NAME,
73+
udp.UdpAsyncioTransport().open_client_mode()
74+
)
75+
76+
config.add_target_address(
77+
snmp_engine,
78+
"my-target",
79+
udp.DOMAIN_NAME,
80+
(self.ip_address, self.port),
81+
"my-creds"
82+
)
83+
84+
return snmp_engine
85+
86+
@classmethod
87+
def client(cls) -> str:
88+
return "jumpstarter_driver_snmp.client.SNMPServerClient"
89+
90+
def _snmp_set(self, state: PowerState):
91+
result = {"success": False, "error": None}
92+
93+
def callback(snmpEngine, sendRequestHandle, errorIndication,
94+
errorStatus, errorIndex, varBinds, cbCtx):
95+
self.logger.info(f"Callback {errorIndication} {errorStatus} {errorIndex} {varBinds}")
96+
if errorIndication:
97+
self.logger.info(f"SNMP error: {errorIndication}")
98+
result["error"] = f"SNMP error: {errorIndication}"
99+
elif errorStatus:
100+
self.logger.info(f"SNMP status: {errorStatus}")
101+
result["error"] = (
102+
f"SNMP error: {errorStatus.prettyPrint()} at "
103+
f"{varBinds[int(errorIndex) - 1][0] if errorIndex else '?'}"
104+
)
105+
else:
106+
result["success"] = True
107+
for oid, val in varBinds:
108+
self.logger.debug(f"{oid.prettyPrint()} = {val.prettyPrint()}")
109+
self.logger.info(f"SNMP set result: {result}")
110+
111+
try:
112+
self.logger.info(f"Sending power {state.name} command to {self.host}")
113+
114+
snmp_engine = self._setup_snmp()
115+
116+
cmdgen.SetCommandGenerator().send_varbinds(
117+
snmp_engine,
118+
"my-target",
119+
None,
120+
"",
121+
[(self.full_oid, rfc1902.Integer(state.value))],
122+
callback,
123+
)
124+
125+
snmp_engine.open_dispatcher(self.timeout)
126+
snmp_engine.close_dispatcher()
127+
128+
if not result["success"]:
129+
raise SNMPError(result["error"])
130+
131+
return f"Power {state.name} command sent successfully"
132+
133+
except Exception as e:
134+
error_msg = f"SNMP set failed: {str(e)}"
135+
self.logger.error(error_msg)
136+
raise SNMPError(error_msg) from e
137+
138+
@export
139+
def power_on(self):
140+
"""Turn power on"""
141+
return self._snmp_set(PowerState.ON)
142+
143+
@export
144+
def power_off(self):
145+
"""Turn power off"""
146+
return self._snmp_set(PowerState.OFF)
147+
148+
@export
149+
def power_cycle(self):
150+
"""Power cycle the device"""
151+
try:
152+
self.logger.info("Starting power cycle sequence")
153+
self.power_off()
154+
self.logger.info(f"Waiting {self.quiescent_period} seconds...")
155+
time.sleep(self.quiescent_period)
156+
self.power_on()
157+
return "Power cycle completed successfully"
158+
except Exception as e:
159+
error_msg = f"Power cycle failed: {str(e)}"
160+
self.logger.error(error_msg)
161+
raise SNMPError(error_msg) from e
162+
163+
def close(self):
164+
"""No cleanup needed since engines are created per operation"""
165+
if hasattr(super(), "close"):
166+
super().close()
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
[project]
2+
name = "jumpstarter-driver-snmp"
3+
version = "0.1.0"
4+
description = "SNMP driver"
5+
readme = "README.md"
6+
requires-python = ">=3.12.3"
7+
license = { text = "Apache-2.0" }
8+
authors = [
9+
{ name = "Benny Zlotnik", email = "[email protected]" }
10+
]
11+
12+
dependencies = [
13+
"jumpstarter",
14+
"pysnmp==7.1.16"
15+
]
16+
17+
18+
[dependency-groups]
19+
dev = [
20+
"pytest>=8.3.2",
21+
"pytest-cov>=6.0.0",
22+
"pytest-anyio>=0.0.0",
23+
"pytest-asyncio>=0.0.0",
24+
"jumpstarter-testing"
25+
]
26+
27+
28+
[tool.pytest.ini_options]
29+
log_cli = true
30+
log_cli_level = "INFO"
31+
testpaths = ["jumpstarter_driver_snmp"]
32+
asyncio_mode = "auto"
33+
34+
[tool.hatch.metadata.hooks.vcs.urls]
35+
Homepage = "https://jumpstarter.dev"
36+
source_archive = "https://github.com/jumpstarter-dev/repo/archive/{commit_hash}.zip"
37+
38+
[tool.hatch.version]
39+
source = "vcs"
40+
raw-options = { 'root' = '../../'}
41+
42+
[build-system]
43+
requires = ["hatchling", "hatch-vcs"]
44+
build-backend = "hatchling.build"

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ jumpstarter-driver-power = { workspace = true }
1919
jumpstarter-driver-pyserial = { workspace = true }
2020
jumpstarter-driver-sdwire = { workspace = true }
2121
jumpstarter-driver-tftp = { workspace = true }
22+
jumpstarter-driver-snmp = { workspace = true }
2223
jumpstarter-driver-ustreamer = { workspace = true }
2324
jumpstarter-imagehash = { workspace = true }
2425
jumpstarter-kubernetes = { workspace = true }

0 commit comments

Comments
 (0)