-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat: Add SNMP provider #5324
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: Add SNMP provider #5324
Conversation
- Add unit tests for SNMP provider with mock trap data validation - Add e2e tests following docker-compose patterns (no subprocess.run) - Add simulate_alert() method for e2e testing compatibility - Update poetry.lock with SNMP dependencies (pysnmp-lextudio, pyasn1, pyasn1-modules) - Add pyproject.toml dependencies for SNMP functionality - Include documentation and UI assets for SNMP provider Tests validate: - SNMP provider listens correctly (scope validation) - Transforms SNMP traps into Keep alerts (unit tests) - Alerts show up in Keep UI/API (e2e tests) All 20 unit tests passing ✅ E2E tests use trigger_alert() pattern as requested ✅ No subprocess.run usage in tests ✅
Someone is attempting to deploy a commit to the KeepHQ Team on Vercel. A member of the Team first needs to authorize it. |
Mohamed Mathari seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account. You have signed the CLA already but the status is still pending? Let us recheck it. |
@CodeRabbit if possible, have a look at the SNMP integration. |
🧩 Analysis chainI'll examine the SNMP integration implementation and tests to provide a comprehensive review. @melmathari I've attempted to examine the SNMP integration, but I'm unable to locate the SNMP provider files in the current repository state. This could be because:
To provide a comprehensive review of the SNMP integration, I would need access to:
Could you please:
Once I can access the actual SNMP integration code, I'll be able to provide detailed feedback on:
🏁 Script executed:
Length of output: 515 🏁 Script executed:
Length of output: 438 🏁 Script executed:
Length of output: 923 🏁 Script executed:
Length of output: 1911 🏁 Script executed:
Length of output: 1481 🏁 Script executed:
Length of output: 330 🏁 Script executed:
Length of output: 124024 🏁 Script executed:
Length of output: 173769 🏁 Script executed:
Length of output: 650 🏁 Script executed:
Length of output: 4173 🏁 Script executed:
Length of output: 2135 Let me examine the SNMP provider implementation and tests in detail. 🏁 Script executed:
Length of output: 18491 🏁 Script executed:
Length of output: 14248 🏁 Script executed:
Length of output: 6879 🏁 Script executed:
Length of output: 3989 🏁 Script executed:
Length of output: 1365 @melmathari I've completed my review of the SNMP integration. Great work on adding comprehensive testing for the SNMP provider! Here's my detailed feedback: ✅ Strengths1. Well-Structured Implementation
2. Test Coverage - Excellent!
3. Feature Completeness
4. Documentation
🔧 Suggestions for Improvement1. Security ConsiderationsConsider adding:
2. Performance OptimizationThe current implementation creates alerts synchronously. For high-volume environments, consider: # Add batching for multiple traps
async def _batch_process_traps(self):
"""Process traps in batches for better performance."""
while self.running:
if len(self.trap_queue) >= self.batch_size or time_elapsed > self.batch_timeout:
alerts = [self._create_alert(trap) for trap in self.trap_queue]
await self._push_alerts_batch(alerts)
self.trap_queue.clear() 3. Enhanced Trap ProcessingConsider adding support for:
4. Test Coverage EnhancementWhile the test coverage is excellent, consider adding:
5. Configuration ValidationAdd validation for the severity mapping JSON structure: def _validate_severity_mapping(self, mapping: dict) -> bool:
"""Validate that severity mapping values are valid AlertSeverity levels."""
valid_severities = ["INFO", "WARNING", "ERROR", "CRITICAL"]
for oid, severity in mapping.items():
if severity not in valid_severities:
self.logger.warning(f"Invalid severity '{severity}' for OID {oid}")
return False
return True 🐛 Minor Issues to Address
# Line 354 - improve error message
return {"receive_traps": f"Failed to bind to {self.authentication_config.listen_address}:{self.authentication_config.port}. Try using a port > 1024 or run with elevated privileges."}
def __init__(self, ...):
self.stop_event = threading.Event()
def dispose(self):
self.stop_event.set() # Signal thread to stop 📊 Test Results Summary
Overall AssessmentThis is a high-quality implementation that successfully adds SNMP trap reception capabilities to Keep. The code is well-tested, properly documented, and follows the project's established patterns. The suggestions above are mainly for future enhancements rather than critical issues. Great job on continuing the work from PR #4884 and bringing it to completion! 🎉 If you found this review helpful, would you consider giving us a shout-out on X? Thank you for using CodeRabbit! ✅ Actions performedReview triggered.
|
📝 WalkthroughWalkthroughAdds a new SNMP provider implementation, its configuration and runtime for receiving SNMP traps and turning them into alerts. Introduces provider docs and overview entries, an autogenerated snippet, new dependencies for PySNMP, and comprehensive unit and end-to-end tests validating configuration, severity mapping, trap handling, and lifecycle. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant UI as UI
participant Keep as Keep App
participant Prov as SnmpProvider
participant PySNMP as PySNMP Engine
participant Net as Network
rect rgb(235,245,255)
note over UI,Keep: Connect and start trap consumer
UI->>Keep: Configure SNMP (addr, port, community, mapping)
Keep->>Prov: validate_config()
Keep->>Prov: start_consume()
Prov->>Prov: Spawn thread + event loop
Prov->>PySNMP: Bind UDP, set v1/v2c, register callback
end
rect rgb(240,255,240)
note over Net,Prov: Trap reception and alert creation
Net-->>PySNMP: SNMP Trap (UDP)
PySNMP-->>Prov: onTrap(varBinds, ctx)
Prov->>Prov: _handle_trap() parse OIDs/values
Prov->>Prov: _determine_severity(mapping)
Prov->>Keep: push_alert(title, description, raw, severity, fingerprint)
end
rect rgb(255,245,235)
note over Keep,UI: Monitoring and shutdown
UI->>Keep: View logs/debug info
Keep->>Prov: get_logs()/debug_info()
UI->>Keep: Disconnect provider
Keep->>Prov: dispose()
Prov->>PySNMP: Stop dispatcher/cleanup
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60–90 minutes Poem
Pre-merge checks and finishing touches✅ Passed checks (5 passed)
✨ Finishing touches
🧪 Generate unit tests
Tip 👮 Agentic pre-merge checks are now available in preview!Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.
Please see the documentation for more information. Example: reviews:
pre_merge_checks:
custom_checks:
- name: "Undocumented Breaking Changes"
mode: "warning"
instructions: |
Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal). Please share your feedback with us on this Discord post. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 10
🧹 Nitpick comments (13)
docs/providers/overview.mdx (1)
799-805
: Add alt text to icon for accessibility.Include an alt attribute on the SNMP logo to aid screen readers.
- icon={ - <img src="https://img.logo.dev/net-snmp.org?token=pk_dfXfZBoKQMGDTIgqu7LvYg" /> - } + icon={ + <img src="https://img.logo.dev/net-snmp.org?token=pk_dfXfZBoKQMGDTIgqu7LvYg" alt="Net-SNMP logo" /> + }docs/providers/documentation/snmp-provider.mdx (1)
27-33
: Tighten “severity_mapping” description and list allowed values.The implementation appears to match exact OIDs (not patterns). Also clarify accepted severities.
-| severity_mapping | No | JSON mapping of OID patterns to Keep severity levels | null | +| severity_mapping | No | JSON mapping of OIDs to Keep severity levels (exact match; case-insensitive) | null |Outside this block, add a short note after the table:
- Allowed values: INFO, WARNING, ERROR, CRITICAL (case-insensitive).
tests/test_snmp_provider.py (2)
116-168
: Minor: simplify Mock stringification for OIDs/values.Overriding
__str__
via lambda on Mocks can be brittle. Prefer setting__str__.return_value
.- mock_oid1 = Mock() - mock_oid1.__str__ = lambda self: "1.3.6.1.6.3.1.1.5.1" + mock_oid1 = Mock() + mock_oid1.__str__.return_value = "1.3.6.1.6.3.1.1.5.1" - mock_oid2 = Mock() - mock_oid2.__str__ = lambda self: "1.3.6.1.2.1.1.1.0" + mock_oid2 = Mock() + mock_oid2.__str__.return_value = "1.3.6.1.2.1.1.1.0" - mock_val1 = Mock() - mock_val1.__class__.__name__ = "OctetString" - mock_val1.__str__ = lambda self: "coldStart" + mock_val1 = Mock() + mock_val1.__class__.__name__ = "OctetString" + mock_val1.__str__.return_value = "coldStart" - mock_val2 = Mock() - mock_val2.__class__.__name__ = "OctetString" - mock_val2.__str__ = lambda self: "Test Device" + mock_val2 = Mock() + mock_val2.__class__.__name__ = "OctetString" + mock_val2.__str__.return_value = "Test Device"
310-333
: Make dispose assertion resilient to internal job IDs.Asserting
jobFinished(1)
ties the test to an internal counter. Consider asserting the call occurred without strict args, or matchassert_called_with(mock.ANY)
.- mock_transport_dispatcher.jobFinished.assert_called_once_with(1) + assert mock_transport_dispatcher.jobFinished.call_count == 1tests/e2e_tests/test_snmp_provider.py (3)
18-19
: Fix comment: 1162 is non-privileged test port, not the standard.-DEFAULT_SNMP_PORT = 1162 # Standard SNMP trap port +DEFAULT_SNMP_PORT = 1162 # Non-privileged SNMP trap port used for tests
87-95
: Avoid DOM surgery for overlays; prefer stable waits/selectors.Directly removing overlays can hide real UI issues and introduce flakiness. Prefer waiting for the Connect button to be enabled/visible or closing modals via UI actions.
- browser.evaluate( - """() => { - const overlays = document.querySelectorAll('div[data-enter][data-closed][aria-hidden="true"], div[aria-hidden="true"], nextjs-portal'); - overlays.forEach(overlay => overlay.remove()); - }""" - ) - browser.wait_for_timeout(1000) + expect(browser.get_by_role("button", name="Connect", exact=True)).to_be_enabled()
137-154
: Prefer expect-based assertions over manual retries.Use Playwright’s expect with timeouts instead of manual loops for the Feed assertion.
- max_attempts = 5 - for attempt in range(max_attempts): - print(f"Attempt {attempt + 1} to load alerts...") - browser.get_by_role("link", name="Feed").click() - - try: - # Wait for SNMP trap alert to appear - browser.wait_for_selector("text=SNMP Trap", timeout=5000) - print("SNMP Trap alert loaded successfully.") - break - except Exception: - if attempt < max_attempts - 1: - print("SNMP alert not loaded yet. Retrying...") - browser.reload() - else: - print("Failed to load SNMP alert after maximum attempts.") - raise Exception("Failed to load SNMP alert after maximum attempts.") + expect(browser.locator("text=SNMP Trap")).to_be_visible(timeout=25000)keep/providers/snmp_provider/snmp_provider.py (6)
260-277
: Normalize severity strings; accept common synonymsMake parsing case-insensitive and accept “HIGH”.
- severity_map = { - "INFO": AlertSeverity.INFO, - "WARNING": AlertSeverity.WARNING, - "ERROR": AlertSeverity.HIGH, # 'ERROR' maps to 'high' in Keep system - "CRITICAL": AlertSeverity.CRITICAL, - } - - return severity_map.get(severity_str, AlertSeverity.WARNING) + severity_map = { + "INFO": AlertSeverity.INFO, + "WARNING": AlertSeverity.WARNING, + "ERROR": AlertSeverity.HIGH, # accept "ERROR" as "high" + "HIGH": AlertSeverity.HIGH, + "CRITICAL": AlertSeverity.CRITICAL, + } + key = (severity_str or "").strip().upper() + return severity_map.get(key, AlertSeverity.WARNING)
339-355
: Avoid false “port available” when already runningPort bind test will fail while provider holds the socket. Report “In use by provider” instead of rebinding.
def debug_info(self) -> Dict[str, Any]: """Get debugging information about the SNMP provider.""" # Test UDP port binding port_test = {"status": "Unknown", "message": "", "port": self.authentication_config.port} - try: - test_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - test_socket.bind((self.authentication_config.listen_address, self.authentication_config.port)) - test_socket.close() - port_test = {"status": "Success", "message": "Port is available", "port": self.authentication_config.port} - except Exception as e: - port_test = { - "status": "Failed", - "message": str(e), - "port": self.authentication_config.port, - "reason": f"Port {self.authentication_config.port} might already be in use or requires elevated privileges" - } + if self.running: + port_test = {"status": "In use", "message": "Bound by SNMP provider", "port": self.authentication_config.port} + else: + try: + test_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + test_socket.bind((self.authentication_config.listen_address, self.authentication_config.port)) + test_socket.close() + port_test = {"status": "Success", "message": "Port is available", "port": self.authentication_config.port} + except Exception as e: + port_test = { + "status": "Failed", + "message": str(e), + "port": self.authentication_config.port, + "reason": f"Port {self.authentication_config.port} might already be in use or requires elevated privileges" + }
382-392
: validate_scopes should not rebind when runningReturn True if already bound by the provider; otherwise test binding.
def validate_scopes(self) -> Dict[str, Union[bool, str]]: """Validate provider scopes.""" - # Check if we can bind to the specified UDP port - try: - test_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - test_socket.bind((self.authentication_config.listen_address, self.authentication_config.port)) - test_socket.close() - return {"receive_traps": True} - except Exception as e: - return {"receive_traps": f"Failed to bind to {self.authentication_config.listen_address}:{self.authentication_config.port}: {str(e)}"} + if self.running: + return {"receive_traps": True} + try: + test_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + test_socket.bind((self.authentication_config.listen_address, self.authentication_config.port)) + test_socket.close() + return {"receive_traps": True} + except Exception as e: + return {"receive_traps": f"Failed to bind to {self.authentication_config.listen_address}:{self.authentication_config.port}: {str(e)}"}
207-209
: Use stable hashed fingerprintLarge varbind lists can overflow downstream keys; hash OIDs for a bounded fingerprint.
- fingerprint = "-".join(trap_oids) + import hashlib + fingerprint = hashlib.sha1("-".join(trap_oids).encode("utf-8")).hexdigest()
189-197
: Prefer prettyPrint for value renderingOctetString str(val) can include type wrappers. prettyPrint is consistent across types.
- if val_type == 'Integer': - val_str = str(val) - elif val_type == 'OctetString': - try: - val_str = str(val) - except Exception: - val_str = val.prettyPrint() - else: - val_str = val.prettyPrint() + val_str = val.prettyPrint()
120-123
: Consider warning when community is default “public” on 0.0.0.0Security hardening: log a warning to change community in production.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (2)
keep-ui/public/icons/snmp-icon.png
is excluded by!**/*.png
poetry.lock
is excluded by!**/*.lock
📒 Files selected for processing (9)
docs/mint.json
(1 hunks)docs/providers/documentation/snmp-provider.mdx
(1 hunks)docs/providers/overview.md
(1 hunks)docs/providers/overview.mdx
(1 hunks)docs/snippets/providers/snmp-snippet-autogenerated.mdx
(1 hunks)keep/providers/snmp_provider/snmp_provider.py
(1 hunks)pyproject.toml
(1 hunks)tests/e2e_tests/test_snmp_provider.py
(1 hunks)tests/test_snmp_provider.py
(1 hunks)
🔇 Additional comments (4)
docs/providers/overview.md (1)
109-109
: LGTM: SNMP entry added to providers list.docs/mint.json (1)
247-247
: LGTM: SNMP provider docs added to navigation.pyproject.toml (1)
9-11
: Clarify SNMP deps; drop pyOpenSSL if not required (SNMPv2c-only)
- Scan returned no advisories for pysnmp-lextudio / pyasn1 / pyasn1-modules; pyOpenSSL has historical HIGH-severity advisories (affecting very old releases).
- Action: if SNMPv2c-only, remove pyOpenSSL to reduce attack surface; if SNMPv3/TLS is required, document the plan and pin pyOpenSSL to a modern non‑vulnerable release. Confirm the pinned SNMP versions (pysnmp-lextudio 5.0.34, pyasn1 0.4.8, pyasn1-modules 0.2.8) are intentional and resolve cleanly on all supported platforms.
File: pyproject.toml lines 9–11 (also line 13).
keep/providers/snmp_provider/snmp_provider.py (1)
399-404
: Question: Should schema include labels?simulate_alert returns labels but _handle_trap alerts do not. If UI/e2e depend on labels, add consistent labels (trap_oid, device, trap_type).
Would you like me to add minimal labels derived from trap_data?
```yaml | ||
services: | ||
snmp-agent: | ||
image: eclipse-mosquitto:latest | ||
container_name: snmp-agent | ||
ports: | ||
- "1883:1883" | ||
networks: | ||
- keep_default | ||
|
||
snmp-tools: | ||
image: debian:bullseye | ||
container_name: snmp-tools | ||
command: tail -f /dev/null | ||
networks: | ||
- keep_default | ||
volumes: | ||
- ./:/data | ||
|
||
networks: | ||
keep_default: | ||
external: true | ||
``` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Replace MQTT container with proper SNMP tooling; simplify test setup.
Eclipse Mosquitto is MQTT, not SNMP. Recommend a single tools container to send traps to Keep.
-services:
- snmp-agent:
- image: eclipse-mosquitto:latest
- container_name: snmp-agent
- ports:
- - "1883:1883"
- networks:
- - keep_default
-
- snmp-tools:
+services:
+ snmp-tools:
image: debian:bullseye
container_name: snmp-tools
- command: tail -f /dev/null
+ command: tail -f /dev/null
networks:
- keep_default
volumes:
- ./:/data
@@
-With this setup:
-1. The `snmp-agent` container runs the Eclipse Mosquitto image which can be used for message brokering
-2. The `snmp-tools` container provides a Debian environment where you can install and run SNMP tools
-3. Install SNMP tools in the container with: `docker exec snmp-tools apt-get update && docker exec snmp-tools apt-get install -y snmp snmptrapd`
-4. Generate test traps from the tools container: `docker exec snmp-tools snmptrap -v 2c -c public keep-api:162 '' 1.3.6.1.6.3.1.1.5.3 1.3.6.1.2.1.2.2.1.1.2 i 2`
+With this setup:
+1. Install SNMP tools: `docker exec snmp-tools bash -c "apt-get update && apt-get install -y snmp"`
+2. Send a test trap to the Keep API host/port:
+ `docker exec snmp-tools snmptrap -v 2c -c public keep-api:162 '' 1.3.6.1.6.3.1.1.5.3 1.3.6.1.2.1.2.2.1.1.2 i 2`
Optional: advise using an unprivileged port (e.g., 1162) in examples to avoid root binds.
Also applies to: 95-100
As "step" to query data, example: | ||
```yaml | ||
steps: | ||
- name: Query snmp | ||
provider: snmp | ||
config: "{{ provider.my_provider_name }}" | ||
|
||
|
||
``` | ||
|
||
|
||
As "action" to make changes or update data, example: | ||
```yaml | ||
actions: | ||
- name: Query snmp | ||
provider: snmp | ||
config: "{{ provider.my_provider_name }}" | ||
|
||
|
||
``` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Snippet implies query/action usage, but provider is consumer-only.
Unit tests show _query()
/_notify()
return None with warnings. Update the snippet generator to omit step/action examples for consumer-only providers and emphasize event-driven reception.
I can patch the docs snippet generator to detect is_consumer=True
and suppress “In workflows” step/action sections for SNMP. Want me to open a PR?
🤖 Prompt for AI Agents
In docs/snippets/providers/snmp-snippet-autogenerated.mdx around lines 21-40 the
snippet incorrectly shows "steps/actions" usage for SNMP even though the
provider is consumer-only; update the snippet generator to detect providers with
is_consumer=True and suppress generation of the "In workflows" step/action
examples for those providers, replacing them with a short note that the provider
is event-driven and receives data via events/notifications (and optionally link
to the event handling docs); ensure the generator unit tests reflect this
behavior and regenerate the SNMP snippet so _query/_notify warnings no longer
occur.
""" | ||
SNMP Provider for receiving SNMP traps. | ||
""" | ||
|
||
import asyncio | ||
import json | ||
import socket | ||
import threading | ||
from datetime import datetime | ||
from typing import Any, Dict, List, Union | ||
|
||
|
||
from pysnmp.carrier.asyncio.dgram import udp | ||
from pysnmp.entity import config, engine | ||
from pysnmp.entity.rfc3413 import ntfrcv | ||
|
||
import pydantic | ||
import dataclasses | ||
from keep.api.models.alert import AlertSeverity | ||
import traceback | ||
from keep.contextmanager.contextmanager import ContextManager | ||
from keep.providers.base.base_provider import BaseProvider | ||
from keep.providers.models.provider_config import ProviderConfig, ProviderScope | ||
|
||
|
||
@pydantic.dataclasses.dataclass | ||
class SnmpProviderAuthConfig: | ||
listen_address: str = dataclasses.field( | ||
metadata={ | ||
"required": False, | ||
"description": "IP address to listen on for SNMP traps", | ||
"config_main_group": "authentication", | ||
}, | ||
default="0.0.0.0", | ||
) | ||
|
||
port: int = dataclasses.field( | ||
metadata={ | ||
"required": False, | ||
"description": "UDP port to listen on for SNMP traps", | ||
"config_main_group": "authentication", | ||
}, | ||
default=162, | ||
) | ||
|
||
community: str = dataclasses.field( | ||
metadata={ | ||
"required": False, | ||
"description": "SNMP community string for authentication", | ||
"config_main_group": "authentication", | ||
"sensitive": True, | ||
}, | ||
default="public", | ||
) | ||
|
||
severity_mapping: str = dataclasses.field( | ||
metadata={ | ||
"required": False, | ||
"description": "JSON mapping of OID patterns to Keep severity levels", | ||
"config_main_group": "authentication", | ||
}, | ||
default=None, | ||
) | ||
|
||
|
||
class SnmpProvider(BaseProvider): | ||
""" | ||
SNMP Provider for receiving SNMP traps from network devices and converting them to Keep alerts. | ||
""" | ||
PROVIDER_SCOPES = [ | ||
ProviderScope( | ||
name="receive_traps", | ||
description="Receive and process SNMP traps", | ||
mandatory=True, | ||
alias="Receive SNMP Traps", | ||
) | ||
] | ||
|
||
PROVIDER_CATEGORY = ["Monitoring", "Network Management"] | ||
PROVIDER_DISPLAY_NAME = "SNMP" | ||
PROVIDER_TAGS = ["alert"] | ||
|
||
def __init__( | ||
self, context_manager: ContextManager, provider_id: str, config: ProviderConfig | ||
): | ||
super().__init__(context_manager, provider_id, config) | ||
self.snmp_engine = None | ||
self.trap_thread = None | ||
self.running = False | ||
self._severity_mapping = {} | ||
|
||
# Parse severity mapping if provided | ||
if self.authentication_config.severity_mapping: | ||
try: | ||
self._severity_mapping = json.loads(self.authentication_config.severity_mapping) | ||
self.logger.info(f"Loaded severity mapping: {self._severity_mapping}") | ||
except json.JSONDecodeError as e: | ||
self.logger.error(f"Failed to parse severity mapping JSON: {e}") | ||
|
||
def validate_config(self): | ||
"""Validate the provider configuration.""" | ||
self.authentication_config = SnmpProviderAuthConfig(**self.config.authentication) | ||
|
||
def _query(self, **kwargs): | ||
"""Query method for provider - not applicable for SNMP trap receiver.""" | ||
self.logger.warning("SNMP provider does not support querying") | ||
return None | ||
|
||
def _notify(self, **kwargs): | ||
"""SNMP provider doesn't support direct notification as it's a receiver.""" | ||
self.logger.warning("SNMP provider is a receiver and does not support direct notification") | ||
return None | ||
|
||
def start_consume(self): | ||
"""Start the SNMP trap receiver.""" | ||
if self.running: | ||
self.logger.warning("SNMP trap receiver is already running") | ||
return | ||
|
||
self.logger.info( | ||
f"Starting SNMP trap receiver on {self.authentication_config.listen_address}:{self.authentication_config.port}" | ||
) | ||
|
||
self.running = True | ||
self.trap_thread = threading.Thread( | ||
target=self._start_trap_receiver, | ||
daemon=True | ||
) | ||
self.trap_thread.start() | ||
self.logger.info(f"SNMP trap receiver thread started successfully on {self.authentication_config.listen_address}:{self.authentication_config.port}") | ||
|
||
return {"status": "SNMP trap receiver started"} | ||
|
||
def _start_trap_receiver(self): | ||
"""Start the SNMP trap receiver in a separate thread.""" | ||
try: | ||
loop = asyncio.new_event_loop() | ||
asyncio.set_event_loop(loop) | ||
|
||
# Create SNMP engine | ||
self.snmp_engine = engine.SnmpEngine() | ||
|
||
# Configure transport | ||
config.addTransport( | ||
self.snmp_engine, | ||
udp.domainName, | ||
udp.UdpTransport().openServerMode( | ||
(self.authentication_config.listen_address, self.authentication_config.port) | ||
) | ||
) | ||
|
||
# Configure community string for SNMP v1 and v2c | ||
config.addV1System( | ||
self.snmp_engine, | ||
'keep-snmp-security-domain', | ||
self.authentication_config.community | ||
) | ||
|
||
# Register callback | ||
ntfrcv.NotificationReceiver(self.snmp_engine, self._handle_trap) | ||
|
||
self.logger.info("SNMP trap receiver is ready to receive traps") | ||
|
||
# Start the event loop | ||
self.snmp_engine.transportDispatcher.jobStarted(1) | ||
loop.run_forever() | ||
|
||
except Exception as e: | ||
self.logger.error(f"Error starting SNMP trap receiver: {e}") | ||
self.running = False | ||
|
||
def _handle_trap(self, snmp_engine, state_reference, context_engine_id, context_name, var_binds, cb_ctx): | ||
"""Handle incoming SNMP traps.""" | ||
try: | ||
self.logger.debug("Received SNMP trap") | ||
|
||
# Extract trap data | ||
trap_data = {} | ||
trap_oids = [] | ||
|
||
# Process variable bindings (OIDs and values) | ||
for oid, val in var_binds: | ||
try: | ||
oid_str = str(oid) | ||
trap_oids.append(oid_str) | ||
|
||
# Convert value based on type | ||
val_type = val.__class__.__name__ | ||
if val_type == 'Integer': | ||
val_str = str(val) | ||
elif val_type == 'OctetString': | ||
try: | ||
val_str = str(val) | ||
except Exception: | ||
val_str = val.prettyPrint() | ||
else: | ||
val_str = val.prettyPrint() | ||
|
||
trap_data[oid_str] = val_str | ||
except Exception as val_err: | ||
self.logger.error(f"Error processing OID value: {str(val_err)}") | ||
# Continue with other OIDs even if one fails | ||
|
||
# Determine severity based on mapping | ||
severity = self._determine_severity(trap_oids, trap_data) | ||
|
||
# Create a unique fingerprint for the trap | ||
fingerprint = "-".join(trap_oids) | ||
|
||
# Format alert title and description | ||
alert_title = "SNMP Trap Received" | ||
# The OID 1.3.6.1.6.3.1.1.4.1.0 is the standard SNMP trap OID identifier | ||
if '1.3.6.1.6.3.1.1.4.1.0' in trap_data: | ||
trap_type_oid = trap_data['1.3.6.1.6.3.1.1.4.1.0'] | ||
alert_title = f"SNMP Trap: {trap_type_oid}" | ||
|
||
# Convert trap data to readable format | ||
alert_description = "\n".join([f"{oid}: {val}" for oid, val in trap_data.items()]) | ||
|
||
|
||
alert = { | ||
"title": alert_title, | ||
"description": f"SNMP Trap received with the following data:\n{alert_description}", | ||
"severity": severity.value, | ||
"fingerprint": fingerprint, | ||
"source": ["snmp"], | ||
"raw_data": json.dumps(trap_data), | ||
"created_at": datetime.utcnow().isoformat(), | ||
} | ||
|
||
self.logger.info(f"Sending alert for SNMP trap: {alert['title']}") | ||
self._push_alert(alert) | ||
|
||
except Exception as e: | ||
self.logger.error(f"Error processing SNMP trap: {str(e)}") | ||
self.logger.error(traceback.format_exc()) | ||
|
||
def _determine_severity(self, oids: List[str], data: Dict[str, str]) -> AlertSeverity: | ||
"""Determine alert severity based on the configured mapping.""" | ||
# Default severity | ||
default_severity = AlertSeverity.WARNING | ||
|
||
if not self._severity_mapping: | ||
return default_severity | ||
|
||
# Check if any OIDs match the patterns in the severity mapping | ||
for pattern, severity_str in self._severity_mapping.items(): | ||
# Check if pattern matches any OID | ||
for oid in oids: | ||
if pattern in oid: | ||
return self._parse_severity(severity_str) | ||
|
||
# Check if pattern matches any value | ||
for value in data.values(): | ||
if pattern in value: | ||
return self._parse_severity(severity_str) | ||
|
||
return default_severity | ||
|
||
def _parse_severity(self, severity_str: str) -> AlertSeverity: | ||
""" | ||
Parse severity string into AlertSeverity enum value. | ||
|
||
Args: | ||
severity_str: Severity string from trap data | ||
|
||
Returns: | ||
AlertSeverity enum value | ||
""" | ||
severity_map = { | ||
"INFO": AlertSeverity.INFO, | ||
"WARNING": AlertSeverity.WARNING, | ||
"ERROR": AlertSeverity.HIGH, # 'ERROR' maps to 'high' in Keep system | ||
"CRITICAL": AlertSeverity.CRITICAL, | ||
} | ||
|
||
return severity_map.get(severity_str, AlertSeverity.WARNING) | ||
|
||
def get_logs(self, limit: int = 100) -> List[Dict[str, Any]]: | ||
"""Get logs from the provider.""" | ||
logs = [] | ||
|
||
# Add debugging information | ||
debug_info = self.debug_info() | ||
logs.append({ | ||
"message": "SNMP Provider Debug Information", | ||
"timestamp": datetime.utcnow().isoformat(), | ||
"level": "INFO", | ||
"details": debug_info | ||
}) | ||
|
||
# Add basic status information | ||
status = "Running" if self.running else "Stopped" | ||
logs.append({ | ||
"message": f"SNMP trap receiver status: {status}", | ||
"timestamp": datetime.utcnow().isoformat(), | ||
"level": "INFO", | ||
"details": { | ||
"status": status, | ||
"listen_address": self.authentication_config.listen_address, | ||
"port": self.authentication_config.port | ||
} | ||
}) | ||
|
||
# Add log for when the trap receiver was started | ||
if self.running: | ||
logs.append({ | ||
"message": f"SNMP trap receiver is running on {self.authentication_config.listen_address}:{self.authentication_config.port}", | ||
"timestamp": datetime.utcnow().isoformat(), | ||
"level": "INFO", | ||
"details": { | ||
"community": "***" if self.authentication_config.community else "Not set" | ||
} | ||
}) | ||
|
||
# Check if we have a severity mapping | ||
if self._severity_mapping: | ||
severity_info = {k: v for k, v in self._severity_mapping.items()} | ||
logs.append({ | ||
"message": "SNMP trap severity mapping configured", | ||
"timestamp": datetime.utcnow().isoformat(), | ||
"level": "INFO", | ||
"details": { | ||
"severity_mapping": severity_info | ||
} | ||
}) | ||
else: | ||
logs.append({ | ||
"message": "No SNMP trap severity mapping configured", | ||
"timestamp": datetime.utcnow().isoformat(), | ||
"level": "WARNING", | ||
"details": { | ||
"default_severity": "WARNING" | ||
} | ||
}) | ||
|
||
return logs | ||
|
||
def debug_info(self) -> Dict[str, Any]: | ||
"""Get debugging information about the SNMP provider.""" | ||
# Test UDP port binding | ||
port_test = {"status": "Unknown", "message": "", "port": self.authentication_config.port} | ||
try: | ||
test_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) | ||
test_socket.bind((self.authentication_config.listen_address, self.authentication_config.port)) | ||
test_socket.close() | ||
port_test = {"status": "Success", "message": "Port is available", "port": self.authentication_config.port} | ||
except Exception as e: | ||
port_test = { | ||
"status": "Failed", | ||
"message": str(e), | ||
"port": self.authentication_config.port, | ||
"reason": f"Port {self.authentication_config.port} might already be in use or requires elevated privileges" | ||
} | ||
|
||
# Get information about the SNMP engine | ||
engine_info = {"status": "Not initialized"} | ||
if self.snmp_engine: | ||
try: | ||
engine_info = { | ||
"status": "Initialized", | ||
"transport_dispatcher_jobs": getattr(self.snmp_engine.transportDispatcher, "jobsAmount", "Unknown"), | ||
"snmp_engine_id": str(getattr(self.snmp_engine, "snmpEngineID", b"Not available")), | ||
} | ||
except Exception as e: | ||
engine_info = {"status": "Error", "message": str(e)} | ||
|
||
return { | ||
"provider_id": self.provider_id, | ||
"running": self.running, | ||
"configuration": { | ||
"listen_address": self.authentication_config.listen_address, | ||
"port": self.authentication_config.port, | ||
"community": "***" if self.authentication_config.community else "Not set", | ||
"has_severity_mapping": bool(self._severity_mapping) | ||
}, | ||
"port_test": port_test, | ||
"snmp_engine": engine_info, | ||
"thread_active": bool(self.trap_thread and self.trap_thread.is_alive()) if self.trap_thread else False, | ||
} | ||
|
||
def validate_scopes(self) -> Dict[str, Union[bool, str]]: | ||
"""Validate provider scopes.""" | ||
# Check if we can bind to the specified UDP port | ||
try: | ||
test_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) | ||
test_socket.bind((self.authentication_config.listen_address, self.authentication_config.port)) | ||
test_socket.close() | ||
return {"receive_traps": True} | ||
except Exception as e: | ||
return {"receive_traps": f"Failed to bind to {self.authentication_config.listen_address}:{self.authentication_config.port}: {str(e)}"} | ||
|
||
@staticmethod | ||
def get_alert_schema() -> Dict[str, Any]: | ||
"""Get the alert schema description for this provider.""" | ||
return { | ||
"type": "object", | ||
"properties": { | ||
"title": {"type": "string", "description": "Alert title"}, | ||
"description": {"type": "string", "description": "Detailed description of the SNMP trap"}, | ||
"severity": {"type": "string", "enum": ["info", "warning", "error", "critical"]}, | ||
"source": {"type": "array", "items": {"type": "string"}, "description": "Sources of the SNMP trap"}, | ||
"raw_data": {"type": "object", "description": "Raw trap data as OID-value pairs"}, | ||
} | ||
} | ||
|
||
def dispose(self): | ||
"""Clean up resources and release all ports used by the SNMP trap receiver.""" | ||
if not self.running: | ||
return | ||
|
||
self.logger.info("Stopping SNMP trap receiver") | ||
self.running = False | ||
|
||
if self.snmp_engine: | ||
try: | ||
transport_dispatcher = self.snmp_engine.transportDispatcher | ||
|
||
transport_dispatcher.jobFinished(1) | ||
|
||
transport_dispatcher.closeDispatcher() | ||
|
||
self.logger.info(f"SNMP engine transport dispatcher stopped, port {self.authentication_config.port} released") | ||
|
||
except Exception as e: | ||
self.logger.error(f"Error during SNMP engine cleanup: {e}") | ||
finally: | ||
self.snmp_engine = None | ||
|
||
|
||
if self.trap_thread and self.trap_thread.is_alive(): | ||
try: | ||
self.trap_thread.join(timeout=5.0) | ||
if self.trap_thread.is_alive(): | ||
self.logger.warning("SNMP trap thread did not stop gracefully within timeout") | ||
except Exception as e: | ||
self.logger.error(f"Error joining SNMP trap thread: {e}") | ||
finally: | ||
self.trap_thread = None | ||
|
||
@property | ||
def is_consumer(self) -> bool: | ||
"""Mark this provider as a consumer that can be started/stopped.""" | ||
return True | ||
|
||
def status(self) -> bool: | ||
"""Check if the SNMP trap receiver is running.""" | ||
return self.running | ||
|
||
@staticmethod | ||
def simulate_alert() -> Dict[str, Any]: | ||
"""Simulate an SNMP trap alert for testing purposes.""" | ||
return { | ||
"title": "SNMP Trap: coldStart", | ||
"description": "SNMP Trap received with the following data:\n1.3.6.1.6.3.1.1.5.1: coldStart\n1.3.6.1.2.1.1.1.0: Keep SNMP Test Device", | ||
"severity": "info", | ||
"fingerprint": f"snmp-test-trap-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}", | ||
"source": ["snmp"], | ||
"labels": { | ||
"trap_oid": "1.3.6.1.6.3.1.1.5.1", | ||
"device": "Keep SNMP Test Device", | ||
"trap_type": "coldStart" | ||
}, | ||
"raw_data": json.dumps({ | ||
"1.3.6.1.6.3.1.1.5.1": "coldStart", | ||
"1.3.6.1.2.1.1.1.0": "Keep SNMP Test Device" | ||
}), | ||
"created_at": datetime.utcnow().isoformat(), | ||
} No newline at end of file |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
Repo-wide verification suggestions
🏁 Script executed:
#!/bin/bash
# 1) Confirm AlertSeverity values expected by Keep ("high" vs "error")
rg -nP -C2 'class\s+AlertSeverity\b|AlertSeverity\.' --type=py
# 2) Find any code/tests expecting raw_data to be a JSON string
rg -nP -C2 '"raw_data"\s*:\s*json\.dumps\(|get\("raw_data"\)\.get\(|\["raw_data"\]' --type=py
# 3) Find references to default SNMP port constants in tests/docs
rg -nP -C2 'SNMP.*(port|1162|162)|DEFAULT_SNMP_PORT' --type=py -g '!**/dist/**' -g '!**/build/**'
# 4) Ensure no other providers rely on provider.status() returning more than bool
rg -nP -C2 '\.status\(\)' --type=py -g '!**/test/**'
# 5) Check tests referencing simulate_alert() shape (labels/raw_data)
rg -nP -C2 'simulate_alert\(' --type=py
Length of output: 119668
SNMP provider — severity/raw_data verified; schema enum mismatch must be fixed
- Confirmed: _parse_severity("ERROR") → AlertSeverity.HIGH is consistent with keep/api/models/alert.py and tests (tests/test_snmp_provider.py:72–76).
- Confirmed: raw_data is intentionally serialized with json.dumps(...) and tests consume it via json.loads(alert["raw_data"]) (tests/test_snmp_provider.py:165).
- Action required: get_alert_schema() currently lists "error" in the severity enum — this is inconsistent with AlertSeverity (uses "high"). Update keep/providers/snmp_provider/snmp_provider.py:get_alert_schema() to use "high" (["info","warning","high","critical"]) and align any docs/schemas.
- Note: debug_info(), validate_scopes(), and _start_trap perform UDP bind checks on port 162 — binding privileged ports may fail in non-root/test environments; consider a non-privileged default or document the privilege requirement.
🤖 Prompt for AI Agents
keep/providers/snmp_provider/snmp_provider.py lines 1-469: get_alert_schema()
returns severity enum ["info","warning","error","critical"] which mismatches
AlertSeverity (uses "high"); update the schema to use
["info","warning","high","critical"] so it matches _parse_severity and tests,
and update any related docstrings/comments to reference "high" instead of
"error". Also note the file currently binds to UDP port 162 in debug_info(),
validate_scopes() and _start_trap — either change the default to a
non-privileged port for tests or add documentation/comments that binding to port
162 requires elevated privileges.
from datetime import datetime | ||
from typing import Any, Dict, List, Union |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Make timestamps TZ-aware and enable Optional typing
Import timezone and Optional for fixes below.
-from datetime import datetime
-from typing import Any, Dict, List, Union
+from datetime import datetime, timezone
+from typing import Any, Dict, List, Union, Optional
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
from datetime import datetime | |
from typing import Any, Dict, List, Union | |
from datetime import datetime, timezone | |
from typing import Any, Dict, List, Union, Optional |
🤖 Prompt for AI Agents
In keep/providers/snmp_provider/snmp_provider.py around lines 9 to 10, the
module currently imports datetime and typing types but does not import timezone
or Optional; update the imports to include timezone from datetime and Optional
from typing, then make any created timestamps timezone-aware (e.g., use
datetime.now(timezone.utc) or attach a tzinfo) and adjust function/type hints to
use Optional[...] where a value may be None so static typing reflects optional
returns/parameters.
def __init__( | ||
self, context_manager: ContextManager, provider_id: str, config: ProviderConfig | ||
): | ||
super().__init__(context_manager, provider_id, config) | ||
self.snmp_engine = None | ||
self.trap_thread = None | ||
self.running = False | ||
self._severity_mapping = {} | ||
|
||
# Parse severity mapping if provided | ||
if self.authentication_config.severity_mapping: | ||
try: | ||
self._severity_mapping = json.loads(self.authentication_config.severity_mapping) | ||
self.logger.info(f"Loaded severity mapping: {self._severity_mapping}") | ||
except json.JSONDecodeError as e: | ||
self.logger.error(f"Failed to parse severity mapping JSON: {e}") | ||
|
||
def validate_config(self): | ||
"""Validate the provider configuration.""" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid early access to authentication_config in init
init references self.authentication_config before validate_config sets it. Initialize config first and move mapping parse into validate_config.
def __init__(
self, context_manager: ContextManager, provider_id: str, config: ProviderConfig
):
super().__init__(context_manager, provider_id, config)
self.snmp_engine = None
self.trap_thread = None
+ self._loop = None
+ self._ready = threading.Event()
self.running = False
self._severity_mapping = {}
-
- # Parse severity mapping if provided
- if self.authentication_config.severity_mapping:
- try:
- self._severity_mapping = json.loads(self.authentication_config.severity_mapping)
- self.logger.info(f"Loaded severity mapping: {self._severity_mapping}")
- except json.JSONDecodeError as e:
- self.logger.error(f"Failed to parse severity mapping JSON: {e}")
+ # Ensure authentication_config is initialized
+ self.validate_config()
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
def __init__( | |
self, context_manager: ContextManager, provider_id: str, config: ProviderConfig | |
): | |
super().__init__(context_manager, provider_id, config) | |
self.snmp_engine = None | |
self.trap_thread = None | |
self.running = False | |
self._severity_mapping = {} | |
# Parse severity mapping if provided | |
if self.authentication_config.severity_mapping: | |
try: | |
self._severity_mapping = json.loads(self.authentication_config.severity_mapping) | |
self.logger.info(f"Loaded severity mapping: {self._severity_mapping}") | |
except json.JSONDecodeError as e: | |
self.logger.error(f"Failed to parse severity mapping JSON: {e}") | |
def validate_config(self): | |
"""Validate the provider configuration.""" | |
def __init__( | |
self, context_manager: ContextManager, provider_id: str, config: ProviderConfig | |
): | |
super().__init__(context_manager, provider_id, config) | |
self.snmp_engine = None | |
self.trap_thread = None | |
self._loop = None | |
self._ready = threading.Event() | |
self.running = False | |
self._severity_mapping = {} | |
# Ensure authentication_config is initialized | |
self.validate_config() | |
def validate_config(self): | |
"""Validate the provider configuration.""" |
🤖 Prompt for AI Agents
In keep/providers/snmp_provider/snmp_provider.py around lines 83 to 101,
__init__ is accessing self.authentication_config before validate_config sets it;
remove any use of self.authentication_config in __init__ (leave
self._severity_mapping = {} and other simple initializations only) and move the
JSON parsing logic into validate_config so it runs after the config is
initialized; in validate_config, check that authentication_config and its
severity_mapping attribute exist, attempt json.loads in a try/except and log
success or the JSONDecodeError as before.
def validate_config(self): | ||
"""Validate the provider configuration.""" | ||
self.authentication_config = SnmpProviderAuthConfig(**self.config.authentication) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Load auth config defensively and parse severity mapping here
Centralize parsing and guard against missing authentication dict.
def validate_config(self):
"""Validate the provider configuration."""
- self.authentication_config = SnmpProviderAuthConfig(**self.config.authentication)
+ auth = getattr(self.config, "authentication", {}) or {}
+ self.authentication_config = SnmpProviderAuthConfig(**auth)
+ # Parse severity mapping if provided
+ self._severity_mapping = {}
+ if self.authentication_config.severity_mapping:
+ try:
+ self._severity_mapping = json.loads(self.authentication_config.severity_mapping)
+ self.logger.info(f"Loaded severity mapping: {self._severity_mapping}")
+ except json.JSONDecodeError as e:
+ self.logger.error(f"Failed to parse severity mapping JSON: {e}")
+ self._severity_mapping = {}
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
def validate_config(self): | |
"""Validate the provider configuration.""" | |
self.authentication_config = SnmpProviderAuthConfig(**self.config.authentication) | |
def validate_config(self): | |
"""Validate the provider configuration.""" | |
auth = getattr(self.config, "authentication", {}) or {} | |
self.authentication_config = SnmpProviderAuthConfig(**auth) | |
# Parse severity mapping if provided | |
self._severity_mapping = {} | |
if self.authentication_config.severity_mapping: | |
try: | |
self._severity_mapping = json.loads(self.authentication_config.severity_mapping) | |
self.logger.info(f"Loaded severity mapping: {self._severity_mapping}") | |
except json.JSONDecodeError as e: | |
self.logger.error(f"Failed to parse severity mapping JSON: {e}") | |
self._severity_mapping = {} |
self.logger.info( | ||
f"Starting SNMP trap receiver on {self.authentication_config.listen_address}:{self.authentication_config.port}" | ||
) | ||
|
||
self.running = True | ||
self.trap_thread = threading.Thread( | ||
target=self._start_trap_receiver, | ||
daemon=True | ||
) | ||
self.trap_thread.start() | ||
self.logger.info(f"SNMP trap receiver thread started successfully on {self.authentication_config.listen_address}:{self.authentication_config.port}") | ||
|
||
return {"status": "SNMP trap receiver started"} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix event-loop/thread lifecycle: startup race and shutdown leak
Currently: start returns “started” before bind; dispose never stops asyncio loop -> thread may hang. Add readiness Event, keep loop handle, stop loop in dispose, and remove jobStarted/jobFinished usage for asyncio carrier.
def start_consume(self):
"""Start the SNMP trap receiver."""
if self.running:
self.logger.warning("SNMP trap receiver is already running")
return
self.logger.info(
f"Starting SNMP trap receiver on {self.authentication_config.listen_address}:{self.authentication_config.port}"
)
self.running = True
self.trap_thread = threading.Thread(
target=self._start_trap_receiver,
daemon=True
)
self.trap_thread.start()
- self.logger.info(f"SNMP trap receiver thread started successfully on {self.authentication_config.listen_address}:{self.authentication_config.port}")
+ if not self._ready.wait(timeout=5):
+ self.running = False
+ self.logger.error("SNMP trap receiver failed to start within timeout")
+ return {"status": "failed", "reason": "startup timeout"}
+ self.logger.info(f"SNMP trap receiver thread started successfully on {self.authentication_config.listen_address}:{self.authentication_config.port}")
return {"status": "SNMP trap receiver started"}
def _start_trap_receiver(self):
"""Start the SNMP trap receiver in a separate thread."""
try:
- loop = asyncio.new_event_loop()
- asyncio.set_event_loop(loop)
+ self._loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(self._loop)
# Create SNMP engine
self.snmp_engine = engine.SnmpEngine()
# Configure transport
config.addTransport(
self.snmp_engine,
udp.domainName,
udp.UdpTransport().openServerMode(
(self.authentication_config.listen_address, self.authentication_config.port)
)
)
# Configure community string for SNMP v1 and v2c
config.addV1System(
self.snmp_engine,
'keep-snmp-security-domain',
self.authentication_config.community
)
# Register callback
ntfrcv.NotificationReceiver(self.snmp_engine, self._handle_trap)
self.logger.info("SNMP trap receiver is ready to receive traps")
-
- # Start the event loop
- self.snmp_engine.transportDispatcher.jobStarted(1)
- loop.run_forever()
+ # Signal readiness and start the event loop
+ self._ready.set()
+ self._loop.run_forever()
except Exception as e:
self.logger.error(f"Error starting SNMP trap receiver: {e}")
self.running = False
+ # Ensure start_consume unblocks on failure
+ self._ready.set()
def dispose(self):
"""Clean up resources and release all ports used by the SNMP trap receiver."""
if not self.running:
return
self.logger.info("Stopping SNMP trap receiver")
self.running = False
-
- if self.snmp_engine:
- try:
- transport_dispatcher = self.snmp_engine.transportDispatcher
-
- transport_dispatcher.jobFinished(1)
-
- transport_dispatcher.closeDispatcher()
-
- self.logger.info(f"SNMP engine transport dispatcher stopped, port {self.authentication_config.port} released")
-
- except Exception as e:
- self.logger.error(f"Error during SNMP engine cleanup: {e}")
- finally:
- self.snmp_engine = None
-
- if self.trap_thread and self.trap_thread.is_alive():
+ # Stop asyncio loop first to let the thread exit
+ if self._loop:
+ try:
+ self._loop.call_soon_threadsafe(self._loop.stop)
+ except Exception as e:
+ self.logger.error(f"Error stopping SNMP asyncio loop: {e}")
+
+ if self.trap_thread and self.trap_thread.is_alive():
try:
self.trap_thread.join(timeout=5.0)
if self.trap_thread.is_alive():
self.logger.warning("SNMP trap thread did not stop gracefully within timeout")
except Exception as e:
self.logger.error(f"Error joining SNMP trap thread: {e}")
finally:
self.trap_thread = None
+
+ if self.snmp_engine:
+ try:
+ self.snmp_engine.transportDispatcher.closeDispatcher()
+ self.logger.info(f"SNMP engine transport dispatcher stopped, port {self.authentication_config.port} released")
+ except Exception as e:
+ self.logger.error(f"Error during SNMP engine cleanup: {e}")
+ finally:
+ self.snmp_engine = None
+ self._loop = None
Also applies to: 134-171, 407-440
🤖 Prompt for AI Agents
In keep/providers/snmp_provider/snmp_provider.py around lines 120-132 (also
apply same changes to 134-171 and 407-440): the startup returns before the SNMP
socket is bound and dispose never stops the asyncio loop, causing a race on
startup and a thread/loop leak on shutdown; fix by creating a threading.Event
(e.g., readiness_event) that the trap thread sets after successful bind, have
start() wait on that event before returning, store the asyncio loop
object/handle created for the trap receiver on the provider instance, remove any
jobStarted/jobFinished usage tied to the asyncio carrier, and in dispose()
signal the loop to stop and join the thread (use
loop.call_soon_threadsafe(loop.stop) or similar) and clear handles so the thread
exits cleanly.
alert = { | ||
"title": alert_title, | ||
"description": f"SNMP Trap received with the following data:\n{alert_description}", | ||
"severity": severity.value, | ||
"fingerprint": fingerprint, | ||
"source": ["snmp"], | ||
"raw_data": json.dumps(trap_data), | ||
"created_at": datetime.utcnow().isoformat(), | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Align payload with schema and platform: raw_data object, TZ-aware created_at, severity enum
- Send raw_data as object (not JSON string).
- Use RFC3339/Z timestamps.
- Schema’s severity enum should reflect Keep’s “high” (not “error”).
alert = {
"title": alert_title,
"description": f"SNMP Trap received with the following data:\n{alert_description}",
"severity": severity.value,
"fingerprint": fingerprint,
"source": ["snmp"],
- "raw_data": json.dumps(trap_data),
- "created_at": datetime.utcnow().isoformat(),
+ "raw_data": trap_data,
+ "created_at": datetime.now(timezone.utc).isoformat(),
}
def get_alert_schema() -> Dict[str, Any]:
"""Get the alert schema description for this provider."""
return {
"type": "object",
"properties": {
"title": {"type": "string", "description": "Alert title"},
"description": {"type": "string", "description": "Detailed description of the SNMP trap"},
- "severity": {"type": "string", "enum": ["info", "warning", "error", "critical"]},
+ "severity": {"type": "string", "enum": ["info", "warning", "high", "critical"]},
"source": {"type": "array", "items": {"type": "string"}, "description": "Sources of the SNMP trap"},
- "raw_data": {"type": "object", "description": "Raw trap data as OID-value pairs"},
+ "raw_data": {"type": "object", "description": "Raw trap data as OID-value pairs"},
}
}
def simulate_alert() -> Dict[str, Any]:
"""Simulate an SNMP trap alert for testing purposes."""
return {
"title": "SNMP Trap: coldStart",
"description": "SNMP Trap received with the following data:\n1.3.6.1.6.3.1.1.5.1: coldStart\n1.3.6.1.2.1.1.1.0: Keep SNMP Test Device",
"severity": "info",
"fingerprint": f"snmp-test-trap-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}",
"source": ["snmp"],
"labels": {
"trap_oid": "1.3.6.1.6.3.1.1.5.1",
"device": "Keep SNMP Test Device",
"trap_type": "coldStart"
},
- "raw_data": json.dumps({
+ "raw_data": {
"1.3.6.1.6.3.1.1.5.1": "coldStart",
"1.3.6.1.2.1.1.1.0": "Keep SNMP Test Device"
- }),
- "created_at": datetime.utcnow().isoformat(),
+ },
+ "created_at": datetime.now(timezone.utc).isoformat(),
}
Also applies to: 393-405, 450-469
🤖 Prompt for AI Agents
In keep/providers/snmp_provider/snmp_provider.py around lines 221-229 (and also
update the similar blocks at 393-405 and 450-469), the alert payload must match
the platform schema: send raw_data as a native object rather than a JSON string,
make created_at a timezone-aware RFC3339 timestamp, and map the severity enum to
Keep’s values (use "high" instead of "error" where applicable). Change raw_data
from json.dumps(trap_data) to trap_data (or a dict), set created_at using a
UTC-aware ISO/RFC3339 timestamp (e.g., datetime.now(timezone.utc).isoformat()),
and adjust the severity assignment to use the platform enum (map error -> "high"
or use severity.value mapped to Keep’s strings). Ensure the same fixes are
applied to the other two blocks referenced.
Description
The SNMP Provider allows Keep to receive and process SNMP traps from network devices, converting them into alerts within the Keep platform.
Tests validate:
Continued where #4884 left off so I'd like to split the bounty if possible. @rahulrajesh21
Closes #2112
/claim #2112