Skip to content

Commit 693ce75

Browse files
committed
test: add OTLP output support to integration tests
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 0648d2d commit 693ce75

25 files changed

Lines changed: 566 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
@@ -12,7 +12,7 @@
1212
import requests
1313
import yaml
1414

15-
from server import FileActivityService
15+
from server import EventServer, GrpcServer, OtlpServer
1616

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

6969

70+
def _get_output_modes(config: pytest.Config) -> list[str]:
71+
output = config.getoption('--output')
72+
assert isinstance(output, str)
73+
if output == 'all':
74+
return ['grpc', 'otlp']
75+
return [output]
76+
77+
78+
def pytest_generate_tests(metafunc: pytest.Metafunc):
79+
if 'server' in metafunc.fixturenames:
80+
modes = _get_output_modes(metafunc.config)
81+
metafunc.parametrize('server', modes, indirect=True)
82+
83+
7084
@pytest.fixture
71-
def server():
85+
def server(request: pytest.FixtureRequest):
7286
"""
73-
Fixture to start and stop the FileActivityService.
87+
Start and stop an event server.
88+
89+
Parameterised via --output to create either a GrpcServer or an
90+
OtlpServer. When --output=all, every test that uses this fixture
91+
runs once per output mode.
7492
"""
75-
s = FileActivityService()
93+
mode = request.param
94+
if mode == 'otlp':
95+
s: EventServer = OtlpServer()
96+
else:
97+
s = GrpcServer()
7698
s.serve()
7799
yield s
78100
s.stop()
@@ -114,18 +136,16 @@ def fact_config(
114136
request: pytest.FixtureRequest,
115137
monitored_dir: str,
116138
logs_dir: str,
139+
server: EventServer,
117140
):
118141
cwd = os.getcwd()
119-
config = {
142+
config: dict = {
120143
'paths': [
121144
f'{monitored_dir}',
122145
f'{monitored_dir}/**/*',
123146
'/mounted/**/*',
124147
'/container-dir/**/*',
125148
],
126-
'grpc': {
127-
'url': 'http://127.0.0.1:9999',
128-
},
129149
'endpoint': {
130150
'address': '127.0.0.1:9000',
131151
'expose_metrics': True,
@@ -134,6 +154,12 @@ def fact_config(
134154
'json': True,
135155
'scan_interval': 0,
136156
}
157+
158+
if server.output_mode == 'otlp':
159+
config['otel'] = {'endpoint': 'http://127.0.0.1:4318/v1/logs'}
160+
else:
161+
config['grpc'] = {'url': 'http://127.0.0.1:9999'}
162+
137163
config_file = NamedTemporaryFile( # noqa: SIM115
138164
prefix='fact-config-',
139165
suffix='.yml',
@@ -190,7 +216,7 @@ def fact(
190216
request: pytest.FixtureRequest,
191217
docker_client: docker.DockerClient,
192218
fact_config: tuple[dict, str],
193-
server: FileActivityService,
219+
server: EventServer,
194220
logs_dir: str,
195221
test_file: str,
196222
):
@@ -206,6 +232,8 @@ def fact(
206232
environment={
207233
'FACT_LOGLEVEL': 'debug',
208234
'FACT_HOST_MOUNT': '/host',
235+
'OTEL_BLRP_SCHEDULE_DELAY': '100',
236+
'OTEL_BLRP_MAX_EXPORT_BATCH_SIZE': '1',
209237
},
210238
name='fact',
211239
network_mode='host',
@@ -264,3 +292,10 @@ def pytest_addoption(parser: pytest.Parser):
264292
default='quay.io/stackrox-io/fact:latest',
265293
help='The image to be used for testing',
266294
)
295+
parser.addoption(
296+
'--output',
297+
action='store',
298+
default='grpc',
299+
choices=['grpc', 'otlp', 'all'],
300+
help='Output mode to test: grpc, otlp, or all (default: grpc)',
301+
)

tests/event.py

Lines changed: 39 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:
@@ -40,6 +38,7 @@ def extract_container_id(cgroup: str) -> str:
4038
class EventType(Enum):
4139
"""Enumeration for different types of file activity events."""
4240

41+
UNKNOWN = 0
4342
OPEN = 1
4443
CREATION = 2
4544
UNLINK = 3
@@ -191,25 +190,26 @@ def container_id(self) -> str:
191190
def loginuid(self) -> int:
192191
return self._loginuid
193192

194-
def diff(self, other: ProcessSignal) -> dict | None:
193+
def diff(self, other: Process) -> dict | None:
195194
"""
196-
Compare this Process with a ProcessSignal protobuf message.
195+
Compare this Process with another Process instance.
196+
197+
PID comparison is skipped if self.pid is None.
197198
198199
Args:
199-
other: ProcessSignal protobuf message to compare against
200+
other: Process instance to compare against.
200201
201202
Returns:
202-
None if identical, dict of differences if not matching
203+
None if identical, dict of differences if not matching.
203204
"""
204205
diff = {}
205206

206-
# Compare each field
207207
if self.pid is not None:
208208
Event._diff_field(diff, 'pid', self.pid, other.pid)
209209

210210
Event._diff_field(diff, 'uid', self.uid, other.uid)
211211
Event._diff_field(diff, 'gid', self.gid, other.gid)
212-
Event._diff_field(diff, 'exe_path', self.exe_path, other.exec_file_path)
212+
Event._diff_field(diff, 'exe_path', self.exe_path, other.exe_path)
213213
Event._diff_field(diff, 'args', self.args, other.args)
214214
Event._diff_field(diff, 'name', self.name, other.name)
215215
Event._diff_field(
@@ -218,7 +218,7 @@ def diff(self, other: ProcessSignal) -> dict | None:
218218
self.container_id,
219219
other.container_id,
220220
)
221-
Event._diff_field(diff, 'loginuid', self.loginuid, other.login_uid)
221+
Event._diff_field(diff, 'loginuid', self.loginuid, other.loginuid)
222222

223223
return diff if diff else None
224224

@@ -328,128 +328,91 @@ def _diff_path(
328328
diff: dict,
329329
name: str,
330330
expected: str | Pattern[str] | None,
331-
actual: str,
331+
actual: str | Pattern[str] | None,
332332
):
333333
"""
334334
Compare paths with regex pattern support.
335+
336+
When expected is a compiled regex pattern, actual must be a
337+
string that matches it. Otherwise a simple equality check is
338+
performed.
335339
"""
336340
if isinstance(expected, Pattern):
337-
if not expected.match(actual):
341+
if not isinstance(actual, str) or not expected.match(actual):
338342
diff[name] = {'expected': f'{expected}', 'actual': actual}
339343
elif expected != actual:
340344
diff[name] = {'expected': expected, 'actual': actual}
341345

342-
def diff(self, other: FileActivity) -> dict | None:
346+
def diff(self, other: Event) -> dict | None:
343347
"""
344-
Compare this Event with a FileActivity protobuf message.
348+
Compare this Event with another Event instance.
349+
350+
Both gRPC and OTLP servers translate their native messages
351+
into Event objects, so this method provides a single
352+
protocol-agnostic comparison path.
345353
346354
Args:
347-
other: FileActivity protobuf message to compare against
355+
other: Event instance to compare against.
348356
349357
Returns:
350-
None if identical, dict of differences if not matching
358+
None if identical, dict of differences if not matching.
351359
"""
352360
diff = {}
353361

354-
# Check process differences first
355362
process_diff = self.process.diff(other.process)
356363
if process_diff is not None:
357364
diff['process'] = process_diff
358365

359-
# Check event type
360-
event_type_expected = self.event_type.name.lower()
361-
event_type_actual = other.WhichOneof('file')
362-
363366
Event._diff_field(
364367
diff,
365368
'event_type',
366-
event_type_expected,
367-
event_type_actual,
369+
self.event_type,
370+
other.event_type,
368371
)
369372
if diff:
370373
return diff
371374

372-
# Get the appropriate event field based on type
373-
event_field = getattr(other, event_type_expected)
374-
375375
# Rename handling is a bit different to the rest, since it has
376376
# new and old paths.
377-
if self.event_type == EventType.RENAME:
378-
Event._diff_path(diff, 'new_file', self.file, event_field.new.path)
377+
if self.event_type != EventType.RENAME:
378+
Event._diff_path(diff, 'file', self.file, other.file)
379+
Event._diff_path(diff, 'host_path', self.host_path, other.host_path)
380+
else:
381+
Event._diff_path(diff, 'new_file', self.file, other.file)
379382
Event._diff_path(
380-
diff,
381-
'new_host_path',
382-
self.host_path,
383-
event_field.new.host_path,
383+
diff, 'new_host_path', self.host_path, other.host_path
384384
)
385+
Event._diff_path(diff, 'old_file', self.old_file, other.old_file)
385386
Event._diff_path(
386-
diff,
387-
'old_file',
388-
self.old_file,
389-
event_field.old.path,
387+
diff, 'old_host_path', self.old_host_path, other.old_host_path
390388
)
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-
)
409389

410390
if self.event_type == EventType.PERMISSION:
411-
Event._diff_field(diff, 'mode', self.mode, event_field.mode)
391+
Event._diff_field(diff, 'mode', self.mode, other.mode)
412392
elif self.event_type == EventType.OWNERSHIP:
413393
Event._diff_field(
414-
diff,
415-
'owner_uid',
416-
self.owner_uid,
417-
event_field.uid,
394+
diff, 'owner_uid', self.owner_uid, other.owner_uid
418395
)
419396
Event._diff_field(
420-
diff,
421-
'owner_gid',
422-
self.owner_gid,
423-
event_field.gid,
397+
diff, 'owner_gid', self.owner_gid, other.owner_gid
424398
)
425399
elif self.event_type in (EventType.XATTR_SET, EventType.XATTR_REMOVE):
426400
Event._diff_field(
427-
diff,
428-
'xattr_name',
429-
self.xattr_name,
430-
event_field.xattr_name,
401+
diff, 'xattr_name', self.xattr_name, other.xattr_name
431402
)
432403
elif self.event_type == EventType.ACL:
433404
Event._diff_field(
434405
diff,
435406
'acl_type',
436407
self.acl_type,
437-
event_field.acl_type,
408+
other.acl_type,
438409
)
439410
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-
]
448411
Event._diff_field(
449412
diff,
450413
'acl_entries',
451414
self.acl_entries,
452-
actual_entries,
415+
other.acl_entries,
453416
)
454417

455418
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)