Skip to content

Commit e624ca3

Browse files
authored
feat: add python diode otlp client support (#202)
1 parent 7d98fa4 commit e624ca3

File tree

11 files changed

+167
-34
lines changed

11 files changed

+167
-34
lines changed

device-discovery/device_discovery/client.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44

55
import logging
66
import threading
7+
from typing import Any
78

8-
from netboxlabs.diode.sdk import DiodeClient, DiodeDryRunClient
9+
from netboxlabs.diode.sdk import DiodeClient, DiodeDryRunClient, DiodeOTLPClient
910

1011
from device_discovery.translate import translate_data
1112
from device_discovery.version import version_semver
@@ -82,22 +83,29 @@ def init_client(
8283
app_name=f"{prefix}/{APP_NAME}" if prefix else APP_NAME,
8384
output_dir=dry_run_output_dir,
8485
)
85-
else:
86+
elif client_id is not None and client_secret is not None:
8687
self.diode_client = DiodeClient(
8788
target=target,
8889
app_name=f"{prefix}/{APP_NAME}" if prefix else APP_NAME,
8990
app_version=APP_VERSION,
9091
client_id=client_id,
9192
client_secret=client_secret,
9293
)
94+
else:
95+
logger.debug("Initializing Diode OTLP client")
96+
self.diode_client = DiodeOTLPClient(
97+
target=target,
98+
app_name=f"{prefix}/{APP_NAME}" if prefix else APP_NAME,
99+
app_version=APP_VERSION,
100+
)
93101

94-
def ingest(self, hostname: str, data: dict):
102+
def ingest(self, metadata: dict[str, Any] | None, data: dict):
95103
"""
96104
Ingest data using the Diode client after translating it.
97105
98106
Args:
99107
----
100-
hostname (str): The device hostname.
108+
metadata (dict[str, Any] | None): Metadata to attach to the ingestion request.
101109
data (dict): The data to be ingested.
102110
103111
Raises:
@@ -109,9 +117,17 @@ def ingest(self, hostname: str, data: dict):
109117
raise ValueError("Diode client not initialized")
110118

111119
with self._lock:
112-
response = self.diode_client.ingest(translate_data(data))
120+
translated_entities = translate_data(data)
121+
request_metadata = metadata or {}
122+
response = self.diode_client.ingest(
123+
entities=translated_entities, metadata=request_metadata
124+
)
125+
126+
hostname = request_metadata.get("hostname") or "unknown-host"
113127

114128
if response.errors:
115-
logger.error(f"ERROR ingestion failed for {hostname} : {response.errors}")
129+
logger.error(
130+
f"ERROR ingestion failed for {hostname} : {response.errors}"
131+
)
116132
else:
117133
logger.info(f"Hostname {hostname}: Successful ingestion")

device-discovery/device_discovery/main.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,6 @@ def main():
136136
name
137137
for name, val in [
138138
("--diode-target", args.diode_target),
139-
("--diode-client-id", args.diode_client_id),
140-
("--diode-client-secret", args.diode_client_secret),
141139
]
142140
if not val
143141
]

device-discovery/device_discovery/policy/runner.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,8 @@ def _collect_device_data(
187187
logger.warning(
188188
f"Policy {self.name}, Hostname {sanitized_hostname}: Error getting VLANs: {e}. Continuing without VLAN data."
189189
)
190-
Client().ingest(scope.hostname, data)
190+
metadata = {"policy_name": self.name, "hostname": sanitized_hostname}
191+
Client().ingest(metadata, data)
191192
discovery_success = get_metric("discovery_success")
192193
if discovery_success:
193194
discovery_success.add(1, {"policy": self.name})

device-discovery/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ dependencies = [
3030
"fastapi~=0.115",
3131
"httpx~=0.27",
3232
"napalm~=5.0",
33-
"netboxlabs-diode-sdk~=1.1",
33+
"netboxlabs-diode-sdk~=1.6",
3434
"pydantic~=2.9",
3535
"python-dotenv~=1.0",
3636
"uvicorn~=0.32",

device-discovery/tests/policy/test_runner.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,11 @@ def test_run_device_with_discovered_driver(policy_runner, sample_scopes, sample_
155155
# Verify driver discovery and ingestion
156156
mock_discover.assert_called_once_with(sample_scopes[0])
157157
mock_ingest.assert_called_once()
158-
data = mock_ingest.call_args[0][1]
158+
metadata_arg, data = mock_ingest.call_args[0]
159+
assert metadata_arg == {
160+
"policy_name": policy_runner.name,
161+
"hostname": sample_scopes[0].hostname,
162+
}
159163
assert data["driver"] == "ios"
160164
assert data["device"] == {"model": "SampleModel"}
161165
assert data["interface"] == {"eth0": "up"}

device-discovery/tests/test_client.py

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ def sample_data():
5959
}
6060

6161

62+
@pytest.fixture
63+
def sample_metadata():
64+
"""Sample metadata for testing ingestion."""
65+
return {"policy_name": "test-policy", "hostname": "router1"}
66+
67+
6268
@pytest.fixture
6369
def mock_version_semver():
6470
"""Mock the version_semver function."""
@@ -73,6 +79,13 @@ def mock_diode_client_class():
7379
yield mock
7480

7581

82+
@pytest.fixture
83+
def mock_diode_otlp_client_class():
84+
"""Mock the DiodeOTLPClient class."""
85+
with patch("device_discovery.client.DiodeOTLPClient") as mock:
86+
yield mock
87+
88+
7689
def test_init_client(mock_diode_client_class, mock_version_semver):
7790
"""Test the initialization of the Diode client."""
7891
client = Client()
@@ -92,7 +105,7 @@ def test_init_client(mock_diode_client_class, mock_version_semver):
92105
)
93106

94107

95-
def test_ingest_success(mock_diode_client_class, sample_data):
108+
def test_ingest_success(mock_diode_client_class, sample_data, sample_metadata):
96109
"""Test successful data ingestion."""
97110
client = Client()
98111
client.init_client(
@@ -101,18 +114,20 @@ def test_ingest_success(mock_diode_client_class, sample_data):
101114

102115
mock_diode_instance = mock_diode_client_class.return_value
103116
mock_diode_instance.ingest.return_value.errors = []
104-
hostname = sample_data["device"]["hostname"]
105-
117+
metadata = sample_metadata
106118
with patch(
107119
"device_discovery.client.translate_data",
108120
return_value=translate_data(sample_data),
109121
) as mock_translate_data:
110-
client.ingest(hostname, sample_data)
122+
client.ingest(metadata, sample_data)
111123
mock_translate_data.assert_called_once_with(sample_data)
112-
mock_diode_instance.ingest.assert_called_once()
124+
mock_diode_instance.ingest.assert_called_once_with(
125+
entities=mock_translate_data.return_value,
126+
metadata=metadata,
127+
)
113128

114129

115-
def test_ingest_failure(mock_diode_client_class, sample_data):
130+
def test_ingest_failure(mock_diode_client_class, sample_data, sample_metadata):
116131
"""Test data ingestion with errors."""
117132
client = Client()
118133
client.init_client(
@@ -124,25 +139,27 @@ def test_ingest_failure(mock_diode_client_class, sample_data):
124139

125140
mock_diode_instance = mock_diode_client_class.return_value
126141
mock_diode_instance.ingest.return_value.errors = ["Error1", "Error2"]
127-
hostname = sample_data["device"]["hostname"]
128-
142+
metadata = sample_metadata
129143
with patch(
130144
"device_discovery.client.translate_data",
131145
return_value=translate_data(sample_data),
132146
) as mock_translate_data:
133-
client.ingest(hostname, sample_data)
147+
client.ingest(metadata, sample_data)
134148
mock_translate_data.assert_called_once_with(sample_data)
135-
mock_diode_instance.ingest.assert_called_once()
149+
mock_diode_instance.ingest.assert_called_once_with(
150+
entities=mock_translate_data.return_value,
151+
metadata=metadata,
152+
)
136153

137154
assert len(mock_diode_instance.ingest.return_value.errors) > 0
138155

139156

140-
def test_ingest_without_initialization():
157+
def test_ingest_without_initialization(sample_metadata):
141158
"""Test ingestion without client initialization raises ValueError."""
142159
Client._instance = None # Reset the Client singleton instance
143160
client = Client()
144161
with pytest.raises(ValueError, match="Diode client not initialized"):
145-
client.ingest("", {})
162+
client.ingest(sample_metadata, {})
146163

147164

148165
def test_client_dry_run(tmp_path, sample_data):
@@ -154,7 +171,8 @@ def test_client_dry_run(tmp_path, sample_data):
154171
dry_run_output_dir=tmp_path,
155172
)
156173
hostname = sample_data["device"]["hostname"]
157-
client.ingest(hostname, sample_data)
174+
metadata = {"policy_name": "dry-run-policy", "hostname": hostname}
175+
client.ingest(metadata, sample_data)
158176
files = list(tmp_path.glob("prefix_device-discovery*.json"))
159177

160178
assert len(files) == 1
@@ -174,8 +192,24 @@ def test_client_dry_run_stdout(capsys, sample_data):
174192
)
175193

176194
hostname = sample_data["device"]["hostname"]
177-
client.ingest(hostname, sample_data)
195+
metadata = {"policy_name": "dry-run-policy", "hostname": hostname}
196+
client.ingest(metadata, sample_data)
178197

179198
captured = capsys.readouterr()
180199
assert sample_data["device"]["hostname"] in captured.out
181200
assert sample_data["interface"]["GigabitEthernet0/0"]["mac_address"] in captured.out
201+
202+
203+
def test_init_client_uses_otlp_when_credentials_missing(
204+
mock_diode_client_class, mock_diode_otlp_client_class, mock_version_semver
205+
):
206+
"""Ensure init_client falls back to DiodeOTLPClient when credentials are not provided."""
207+
client = Client()
208+
client.init_client(prefix="prefix", target="https://example.com")
209+
210+
assert not mock_diode_client_class.called
211+
mock_diode_otlp_client_class.assert_called_once_with(
212+
target="https://example.com",
213+
app_name="prefix/device-discovery",
214+
app_version=mock_version_semver(),
215+
)

worker/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ dependencies = [
2929
"croniter~=5.0",
3030
"fastapi~=0.115",
3131
"httpx~=0.27",
32-
"netboxlabs-diode-sdk~=1.1",
32+
"netboxlabs-diode-sdk~=1.6",
3333
"pydantic~=2.9",
3434
"uvicorn~=0.32",
3535
"PyYAML~=6.0",

worker/tests/policy/test_runner.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@
1616
@pytest.fixture
1717
def policy_runner():
1818
"""Fixture to create a PolicyRunner instance."""
19-
return PolicyRunner()
19+
runner = PolicyRunner()
20+
runner.metadata = Metadata(
21+
name="test_backend", app_name="test_app", app_version="1.0"
22+
)
23+
return runner
2024

2125

2226
@pytest.fixture
@@ -71,6 +75,15 @@ def mock_diode_client():
7175
mock_diode_client.return_value = mock_instance
7276
yield mock_diode_client
7377

78+
79+
@pytest.fixture
80+
def mock_diode_otlp_client():
81+
"""Fixture to mock the DiodeOTLPClient constructor."""
82+
with patch("worker.policy.runner.DiodeOTLPClient") as mock_diode_otlp_client:
83+
mock_instance = MagicMock()
84+
mock_diode_otlp_client.return_value = mock_instance
85+
yield mock_diode_otlp_client
86+
7487
@pytest.fixture
7588
def mock_diode_dry_run_client():
7689
"""Fixture to mock the DiodeDryRunClient constructor."""
@@ -138,6 +151,28 @@ def test_setup_policy_runner_with_one_time_run(
138151
assert mock_start.called
139152
assert policy_runner.status == Status.RUNNING
140153

154+
155+
def test_setup_policy_runner_uses_otlp_client(
156+
policy_runner,
157+
sample_policy,
158+
mock_load_class,
159+
mock_diode_client,
160+
mock_diode_otlp_client,
161+
):
162+
"""Ensure setup falls back to DiodeOTLPClient when credentials are missing."""
163+
otlp_config = DiodeConfig(target="http://localhost:8080", prefix="test-prefix")
164+
with patch.object(policy_runner.scheduler, "start") as mock_start, patch.object(
165+
policy_runner.scheduler, "add_job"
166+
) as mock_add_job:
167+
policy_runner.setup("policy1", otlp_config, sample_policy)
168+
169+
mock_start.assert_called_once()
170+
mock_add_job.assert_called_once()
171+
172+
mock_load_class.assert_called_once()
173+
assert not mock_diode_client.called
174+
mock_diode_otlp_client.assert_called_once()
175+
141176
def test_setup_policy_runner_dry_run(
142177
policy_runner,
143178
sample_diode_dry_run_config,
@@ -185,6 +220,29 @@ def test_run_success(policy_runner, sample_policy, mock_diode_client, mock_backe
185220
assert len(call_args) == 3
186221

187222

223+
def test_run_passes_metadata_to_ingest(
224+
policy_runner, sample_policy, mock_diode_client, mock_backend
225+
):
226+
"""Ensure run forwards policy/backend metadata to the Diode client."""
227+
policy_runner.name = "policy-meta"
228+
policy_runner.metadata = Metadata(
229+
name="custom_backend", app_name="custom", app_version="0.1"
230+
)
231+
232+
entity = ingester_pb2.Entity()
233+
entity.device.name = "device-1"
234+
mock_backend.run.return_value = [entity]
235+
mock_diode_client.ingest.return_value.errors = []
236+
237+
policy_runner.run(mock_diode_client, mock_backend, sample_policy)
238+
239+
_, kwargs = mock_diode_client.ingest.call_args
240+
assert kwargs["metadata"] == {
241+
"policy_name": "policy-meta",
242+
"worker_backend": "custom_backend",
243+
}
244+
245+
188246
def test_run_ingestion_errors(
189247
policy_runner,
190248
sample_policy,

worker/tests/test_main.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import pytest
99

10-
from worker.main import main
10+
from worker.main import main, resolve_env_var
1111

1212

1313
@pytest.fixture
@@ -169,3 +169,16 @@ def test_main_missing_policy(mock_parse_args):
169169
main()
170170
except Exception as e:
171171
assert str(e) == "Test Exit"
172+
173+
174+
def test_resolve_env_var_expands_environment(monkeypatch):
175+
"""Ensure resolve_env_var expands placeholders using environment variables."""
176+
monkeypatch.setenv("MY_ENDPOINT", "grpc://localhost:4317")
177+
assert resolve_env_var("${MY_ENDPOINT}") == "grpc://localhost:4317"
178+
179+
180+
def test_resolve_env_var_returns_original(monkeypatch):
181+
"""Ensure resolve_env_var returns original string when expansion is not possible."""
182+
monkeypatch.delenv("NOT_DEFINED", raising=False)
183+
assert resolve_env_var("plain-value") == "plain-value"
184+
assert resolve_env_var("${NOT_DEFINED}") == "${NOT_DEFINED}"

worker/worker/main.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,6 @@ def main():
136136
name
137137
for name, val in [
138138
("--diode-target", args.diode_target),
139-
("--diode-client-id", args.diode_client_id),
140-
("--diode-client-secret", args.diode_client_secret),
141139
]
142140
if not val
143141
]
@@ -174,7 +172,7 @@ def main():
174172
)
175173

176174
try:
177-
if not config.dry_run:
175+
if not config.dry_run and client_id is not None and client_secret is not None:
178176
DiodeClient(
179177
target=config.target,
180178
app_name="validate",

0 commit comments

Comments
 (0)