Skip to content

Commit ecb23ad

Browse files
authored
test: add OTLP output support to integration tests (#1012)
Extend the test infrastructure to support both gRPC and OTLP output modes. Both servers translate their native message format into the test's Event/Process types, keeping protocol-specific code isolated. Changes: - Add EventServer base class with shared queue/wait logic - Add GrpcServer translating FileActivity protobufs into Events - Add OtlpServer receiving OTLP/HTTP binary protobuf log exports - Refactor Event.diff() and Process.diff() to compare Event vs Event - Add --output pytest option (grpc, otlp, all; default: grpc) - Parameterize server fixture so tests run per output mode - Add pytest-otlp and pytest-all Makefile targets - Fix rust_style_quote to match Rust shlex backslash handling - Add opentelemetry-proto dependency - Add CARGO_ARGS build arg to Containerfile - Add image-otel Makefile target Assisted-by: claude-opus-4-6@default <noreply@opencode.ai>
1 parent ed987b7 commit ecb23ad

25 files changed

Lines changed: 564 additions & 259 deletions

Containerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,10 @@ COPY . .
4040
FROM builder AS build
4141

4242
ARG FACT_VERSION
43+
ARG CARGO_ARGS=""
4344
RUN --mount=type=cache,target=/root/.cargo/registry \
4445
--mount=type=cache,target=/app/target \
45-
cargo build --release && \
46+
cargo build --release $CARGO_ARGS && \
4647
cp target/release/fact fact
4748

4849
FROM ubi-micro-base

Makefile

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ image:
2020
-t $(FACT_IMAGE_NAME) \
2121
$(CURDIR)
2222

23+
image-otel:
24+
$(DOCKER) build \
25+
-f Containerfile \
26+
--build-arg FACT_VERSION=$(FACT_VERSION) \
27+
--build-arg RUST_VERSION=$(RUST_VERSION) \
28+
--build-arg CARGO_ARGS="--features otel" \
29+
-t $(FACT_IMAGE_NAME)-otel \
30+
$(CURDIR)
31+
2332
licenses:THIRD_PARTY_LICENSES.html
2433

2534
THIRD_PARTY_LICENSES.html:Cargo.lock
@@ -54,4 +63,4 @@ format:
5463
make -C fact-ebpf format
5564
ruff format tests/
5665

57-
.PHONY: tag mock-server integration-tests image image-name licenses coverage lint clean
66+
.PHONY: tag mock-server integration-tests image image-otel image-name licenses coverage lint clean

tests/Makefile

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ include ../constants.mk
33
all: pytest
44

55
pytest: grpc-gen
6-
pytest --image="${FACT_IMAGE_NAME}" --junit-xml=results.xml
6+
pytest --image="${FACT_IMAGE_NAME}" --output=grpc --junit-xml=results.xml
7+
8+
pytest-otlp: grpc-gen
9+
pytest --image="${FACT_IMAGE_NAME}" --output=otlp --junit-xml=results.xml
10+
11+
pytest-all: grpc-gen
12+
pytest --image="${FACT_IMAGE_NAME}-otel" --output=all --junit-xml=results.xml
713

814
PYOUT = $(CURDIR)
915

@@ -29,4 +35,4 @@ clean:
2935
rm -rf logs.tar.gz
3036
rm -f results.xml
3137

32-
.PHONY: all pytest grpc-gen lint clean
38+
.PHONY: all pytest pytest-otlp pytest-all grpc-gen lint clean

tests/conftest.py

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import requests
1414
import yaml
1515

16-
from server import FileActivityService
16+
from server import EventServer, GrpcServer, OtlpServer
1717

1818
# Declare files holding fixtures
1919
pytest_plugins = ['test_editors.commons']
@@ -68,12 +68,34 @@ def docker_client():
6868
return docker.from_env()
6969

7070

71+
def _get_output_modes(config: pytest.Config) -> list[str]:
72+
output = config.getoption('--output')
73+
assert isinstance(output, str)
74+
if output == 'all':
75+
return ['grpc', 'otlp']
76+
return [output]
77+
78+
79+
def pytest_generate_tests(metafunc: pytest.Metafunc):
80+
if 'server' in metafunc.fixturenames:
81+
modes = _get_output_modes(metafunc.config)
82+
metafunc.parametrize('server', modes, indirect=True)
83+
84+
7185
@pytest.fixture
72-
def server():
86+
def server(request: pytest.FixtureRequest):
7387
"""
74-
Fixture to start and stop the FileActivityService.
88+
Start and stop an event server.
89+
90+
Parameterised via --output to create either a GrpcServer or an
91+
OtlpServer. When --output=all, every test that uses this fixture
92+
runs once per output mode.
7593
"""
76-
s = FileActivityService()
94+
mode = request.param
95+
if mode == 'otlp':
96+
s: EventServer = OtlpServer()
97+
else:
98+
s = GrpcServer()
7799
s.serve()
78100
yield s
79101
s.stop()
@@ -126,18 +148,16 @@ def fact_config(
126148
request: pytest.FixtureRequest,
127149
monitored_dir: str,
128150
logs_dir: str,
151+
server: EventServer,
129152
):
130153
cwd = os.getcwd()
131-
config = {
154+
config: dict = {
132155
'paths': [
133156
f'{monitored_dir}',
134157
f'{monitored_dir}/**/*',
135158
'/mounted/**/*',
136159
'/container-dir/**/*',
137160
],
138-
'grpc': {
139-
'url': 'http://127.0.0.1:9999',
140-
},
141161
'endpoint': {
142162
'address': '127.0.0.1:9000',
143163
'expose_metrics': True,
@@ -146,6 +166,12 @@ def fact_config(
146166
'json': True,
147167
'scan_interval': 0,
148168
}
169+
170+
if server.output_mode == 'otlp':
171+
config['otel'] = {'endpoint': 'http://127.0.0.1:4318/v1/logs'}
172+
else:
173+
config['grpc'] = {'url': 'http://127.0.0.1:9999'}
174+
149175
config_file = NamedTemporaryFile( # noqa: SIM115
150176
prefix='fact-config-',
151177
suffix='.yml',
@@ -202,7 +228,7 @@ def fact(
202228
request: pytest.FixtureRequest,
203229
docker_client: docker.DockerClient,
204230
fact_config: tuple[dict, str],
205-
server: FileActivityService,
231+
server: EventServer,
206232
logs_dir: str,
207233
test_file: str,
208234
):
@@ -218,6 +244,8 @@ def fact(
218244
environment={
219245
'FACT_LOGLEVEL': 'debug',
220246
'FACT_HOST_MOUNT': '/host',
247+
'OTEL_BLRP_SCHEDULE_DELAY': '100',
248+
'OTEL_BLRP_MAX_EXPORT_BATCH_SIZE': '1',
221249
},
222250
name='fact',
223251
network_mode='host',
@@ -281,3 +309,10 @@ def pytest_addoption(parser: pytest.Parser):
281309
default='quay.io/stackrox-io/fact:latest',
282310
help='The image to be used for testing',
283311
)
312+
parser.addoption(
313+
'--output',
314+
action='store',
315+
default='grpc',
316+
choices=['grpc', 'otlp', 'all'],
317+
help='Output mode to test: grpc, otlp, or all (default: grpc)',
318+
)

tests/event.py

Lines changed: 38 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@ def override(func): # type: ignore[reportMissingParameterType]
1515

1616

1717
import utils
18-
from internalapi.sensor.collector_pb2 import ProcessSignal
19-
from internalapi.sensor.sfa_pb2 import FileActivity
2018

2119

2220
def extract_container_id(cgroup: str) -> str:
@@ -191,25 +189,26 @@ def container_id(self) -> str:
191189
def loginuid(self) -> int:
192190
return self._loginuid
193191

194-
def diff(self, other: ProcessSignal) -> dict | None:
192+
def diff(self, other: Process) -> dict | None:
195193
"""
196-
Compare this Process with a ProcessSignal protobuf message.
194+
Compare this Process with another Process instance.
195+
196+
PID comparison is skipped if self.pid is None.
197197
198198
Args:
199-
other: ProcessSignal protobuf message to compare against
199+
other: Process instance to compare against.
200200
201201
Returns:
202-
None if identical, dict of differences if not matching
202+
None if identical, dict of differences if not matching.
203203
"""
204204
diff = {}
205205

206-
# Compare each field
207206
if self.pid is not None:
208207
Event._diff_field(diff, 'pid', self.pid, other.pid)
209208

210209
Event._diff_field(diff, 'uid', self.uid, other.uid)
211210
Event._diff_field(diff, 'gid', self.gid, other.gid)
212-
Event._diff_field(diff, 'exe_path', self.exe_path, other.exec_file_path)
211+
Event._diff_field(diff, 'exe_path', self.exe_path, other.exe_path)
213212
Event._diff_field(diff, 'args', self.args, other.args)
214213
Event._diff_field(diff, 'name', self.name, other.name)
215214
Event._diff_field(
@@ -218,7 +217,7 @@ def diff(self, other: ProcessSignal) -> dict | None:
218217
self.container_id,
219218
other.container_id,
220219
)
221-
Event._diff_field(diff, 'loginuid', self.loginuid, other.login_uid)
220+
Event._diff_field(diff, 'loginuid', self.loginuid, other.loginuid)
222221

223222
return diff if diff else None
224223

@@ -328,128 +327,91 @@ def _diff_path(
328327
diff: dict,
329328
name: str,
330329
expected: str | Pattern[str] | None,
331-
actual: str,
330+
actual: str | Pattern[str] | None,
332331
):
333332
"""
334333
Compare paths with regex pattern support.
334+
335+
When expected is a compiled regex pattern, actual must be a
336+
string that matches it. Otherwise a simple equality check is
337+
performed.
335338
"""
336339
if isinstance(expected, Pattern):
337-
if not expected.match(actual):
340+
if not isinstance(actual, str) or not expected.match(actual):
338341
diff[name] = {'expected': f'{expected}', 'actual': actual}
339342
elif expected != actual:
340343
diff[name] = {'expected': expected, 'actual': actual}
341344

342-
def diff(self, other: FileActivity) -> dict | None:
345+
def diff(self, other: Event) -> dict | None:
343346
"""
344-
Compare this Event with a FileActivity protobuf message.
347+
Compare this Event with another Event instance.
348+
349+
Both gRPC and OTLP servers translate their native messages
350+
into Event objects, so this method provides a single
351+
protocol-agnostic comparison path.
345352
346353
Args:
347-
other: FileActivity protobuf message to compare against
354+
other: Event instance to compare against.
348355
349356
Returns:
350-
None if identical, dict of differences if not matching
357+
None if identical, dict of differences if not matching.
351358
"""
352359
diff = {}
353360

354-
# Check process differences first
355361
process_diff = self.process.diff(other.process)
356362
if process_diff is not None:
357363
diff['process'] = process_diff
358364

359-
# Check event type
360-
event_type_expected = self.event_type.name.lower()
361-
event_type_actual = other.WhichOneof('file')
362-
363365
Event._diff_field(
364366
diff,
365367
'event_type',
366-
event_type_expected,
367-
event_type_actual,
368+
self.event_type,
369+
other.event_type,
368370
)
369371
if diff:
370372
return diff
371373

372-
# Get the appropriate event field based on type
373-
event_field = getattr(other, event_type_expected)
374-
375374
# Rename handling is a bit different to the rest, since it has
376375
# new and old paths.
377-
if self.event_type == EventType.RENAME:
378-
Event._diff_path(diff, 'new_file', self.file, event_field.new.path)
376+
if self.event_type != EventType.RENAME:
377+
Event._diff_path(diff, 'file', self.file, other.file)
378+
Event._diff_path(diff, 'host_path', self.host_path, other.host_path)
379+
else:
380+
Event._diff_path(diff, 'new_file', self.file, other.file)
379381
Event._diff_path(
380-
diff,
381-
'new_host_path',
382-
self.host_path,
383-
event_field.new.host_path,
382+
diff, 'new_host_path', self.host_path, other.host_path
384383
)
384+
Event._diff_path(diff, 'old_file', self.old_file, other.old_file)
385385
Event._diff_path(
386-
diff,
387-
'old_file',
388-
self.old_file,
389-
event_field.old.path,
386+
diff, 'old_host_path', self.old_host_path, other.old_host_path
390387
)
391-
Event._diff_path(
392-
diff,
393-
'old_host_path',
394-
self.old_host_path,
395-
event_field.old.host_path,
396-
)
397-
return diff if diff else None
398-
399-
# Compare file and host_path (common to all event types)
400-
# All event types have .activity.path and .activity.host_path
401-
# accessed differently
402-
Event._diff_path(diff, 'file', self.file, event_field.activity.path)
403-
Event._diff_path(
404-
diff,
405-
'host_path',
406-
self.host_path,
407-
event_field.activity.host_path,
408-
)
409388

410389
if self.event_type == EventType.PERMISSION:
411-
Event._diff_field(diff, 'mode', self.mode, event_field.mode)
390+
Event._diff_field(diff, 'mode', self.mode, other.mode)
412391
elif self.event_type == EventType.OWNERSHIP:
413392
Event._diff_field(
414-
diff,
415-
'owner_uid',
416-
self.owner_uid,
417-
event_field.uid,
393+
diff, 'owner_uid', self.owner_uid, other.owner_uid
418394
)
419395
Event._diff_field(
420-
diff,
421-
'owner_gid',
422-
self.owner_gid,
423-
event_field.gid,
396+
diff, 'owner_gid', self.owner_gid, other.owner_gid
424397
)
425398
elif self.event_type in (EventType.XATTR_SET, EventType.XATTR_REMOVE):
426399
Event._diff_field(
427-
diff,
428-
'xattr_name',
429-
self.xattr_name,
430-
event_field.xattr_name,
400+
diff, 'xattr_name', self.xattr_name, other.xattr_name
431401
)
432402
elif self.event_type == EventType.ACL:
433403
Event._diff_field(
434404
diff,
435405
'acl_type',
436406
self.acl_type,
437-
event_field.acl_type,
407+
other.acl_type,
438408
)
439409
if self.acl_entries is not None:
440-
actual_entries = [
441-
{
442-
'tag': e.tag,
443-
'perm': e.perm,
444-
'id': e.id,
445-
}
446-
for e in event_field.entries
447-
]
448410
Event._diff_field(
449411
diff,
450412
'acl_entries',
451413
self.acl_entries,
452-
actual_entries,
414+
other.acl_entries,
453415
)
454416

455417
return diff if diff else None

tests/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
docker==7.1.0
22
grpcio==1.76.0
33
grpcio-tools==1.76.0
4+
opentelemetry-proto==1.41.1
45
pytest==8.4.1
56
requests==2.32.4
67
pyyaml==6.0.3

0 commit comments

Comments
 (0)