Skip to content

Commit aa1e620

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 86b1a62 commit aa1e620

24 files changed

Lines changed: 496 additions & 239 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-otlp.xml
10+
11+
pytest-all: grpc-gen
12+
pytest --image="${FACT_IMAGE_NAME}-otel" --output=all --junit-xml=results-all.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: 36 additions & 66 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:
@@ -177,25 +175,26 @@ def container_id(self) -> str:
177175
def loginuid(self) -> int:
178176
return self._loginuid
179177

180-
def diff(self, other: ProcessSignal) -> dict | None:
178+
def diff(self, other: Process) -> dict | None:
181179
"""
182-
Compare this Process with a ProcessSignal protobuf message.
180+
Compare this Process with another Process instance.
181+
182+
PID comparison is skipped if self.pid is None.
183183
184184
Args:
185-
other: ProcessSignal protobuf message to compare against
185+
other: Process instance to compare against.
186186
187187
Returns:
188-
None if identical, dict of differences if not matching
188+
None if identical, dict of differences if not matching.
189189
"""
190190
diff = {}
191191

192-
# Compare each field
193192
if self.pid is not None:
194193
Event._diff_field(diff, 'pid', self.pid, other.pid)
195194

196195
Event._diff_field(diff, 'uid', self.uid, other.uid)
197196
Event._diff_field(diff, 'gid', self.gid, other.gid)
198-
Event._diff_field(diff, 'exe_path', self.exe_path, other.exec_file_path)
197+
Event._diff_field(diff, 'exe_path', self.exe_path, other.exe_path)
199198
Event._diff_field(diff, 'args', self.args, other.args)
200199
Event._diff_field(diff, 'name', self.name, other.name)
201200
Event._diff_field(
@@ -204,7 +203,7 @@ def diff(self, other: ProcessSignal) -> dict | None:
204203
self.container_id,
205204
other.container_id,
206205
)
207-
Event._diff_field(diff, 'loginuid', self.loginuid, other.login_uid)
206+
Event._diff_field(diff, 'loginuid', self.loginuid, other.loginuid)
208207

209208
return diff if diff else None
210209

@@ -302,106 +301,77 @@ def _diff_path(
302301
diff: dict,
303302
name: str,
304303
expected: str | Pattern[str] | None,
305-
actual: str,
304+
actual: str | Pattern[str] | None,
306305
):
307306
"""
308307
Compare paths with regex pattern support.
308+
309+
When expected is a compiled regex pattern, actual must be a
310+
string that matches it. Otherwise a simple equality check is
311+
performed.
309312
"""
310313
if isinstance(expected, Pattern):
311-
if not expected.match(actual):
314+
if not isinstance(actual, str) or not expected.match(actual):
312315
diff[name] = {'expected': f'{expected}', 'actual': actual}
313316
elif expected != actual:
314317
diff[name] = {'expected': expected, 'actual': actual}
315318

316-
def diff(self, other: FileActivity) -> dict | None:
319+
def diff(self, other: Event) -> dict | None:
317320
"""
318-
Compare this Event with a FileActivity protobuf message.
321+
Compare this Event with another Event instance.
322+
323+
Both gRPC and OTLP servers translate their native messages
324+
into Event objects, so this method provides a single
325+
protocol-agnostic comparison path.
319326
320327
Args:
321-
other: FileActivity protobuf message to compare against
328+
other: Event instance to compare against.
322329
323330
Returns:
324-
None if identical, dict of differences if not matching
331+
None if identical, dict of differences if not matching.
325332
"""
326333
diff = {}
327334

328-
# Check process differences first
329335
process_diff = self.process.diff(other.process)
330336
if process_diff is not None:
331337
diff['process'] = process_diff
332338

333-
# Check event type
334-
event_type_expected = self.event_type.name.lower()
335-
event_type_actual = other.WhichOneof('file')
336-
337339
Event._diff_field(
338340
diff,
339341
'event_type',
340-
event_type_expected,
341-
event_type_actual,
342+
self.event_type,
343+
other.event_type,
342344
)
343345
if diff:
344346
return diff
345347

346-
# Get the appropriate event field based on type
347-
event_field = getattr(other, event_type_expected)
348-
349348
# Rename handling is a bit different to the rest, since it has
350349
# new and old paths.
351-
if self.event_type == EventType.RENAME:
352-
Event._diff_path(diff, 'new_file', self.file, event_field.new.path)
350+
if self.event_type != EventType.RENAME:
351+
Event._diff_path(diff, 'file', self.file, other.file)
352+
Event._diff_path(diff, 'host_path', self.host_path, other.host_path)
353+
else:
354+
Event._diff_path(diff, 'new_file', self.file, other.file)
353355
Event._diff_path(
354-
diff,
355-
'new_host_path',
356-
self.host_path,
357-
event_field.new.host_path,
356+
diff, 'new_host_path', self.host_path, other.host_path
358357
)
358+
Event._diff_path(diff, 'old_file', self.old_file, other.old_file)
359359
Event._diff_path(
360-
diff,
361-
'old_file',
362-
self.old_file,
363-
event_field.old.path,
360+
diff, 'old_host_path', self.old_host_path, other.old_host_path
364361
)
365-
Event._diff_path(
366-
diff,
367-
'old_host_path',
368-
self.old_host_path,
369-
event_field.old.host_path,
370-
)
371-
return diff if diff else None
372-
373-
# Compare file and host_path (common to all event types)
374-
# All event types have .activity.path and .activity.host_path
375-
# accessed differently
376-
Event._diff_path(diff, 'file', self.file, event_field.activity.path)
377-
Event._diff_path(
378-
diff,
379-
'host_path',
380-
self.host_path,
381-
event_field.activity.host_path,
382-
)
383362

384363
if self.event_type == EventType.PERMISSION:
385-
Event._diff_field(diff, 'mode', self.mode, event_field.mode)
364+
Event._diff_field(diff, 'mode', self.mode, other.mode)
386365
elif self.event_type == EventType.OWNERSHIP:
387366
Event._diff_field(
388-
diff,
389-
'owner_uid',
390-
self.owner_uid,
391-
event_field.uid,
367+
diff, 'owner_uid', self.owner_uid, other.owner_uid
392368
)
393369
Event._diff_field(
394-
diff,
395-
'owner_gid',
396-
self.owner_gid,
397-
event_field.gid,
370+
diff, 'owner_gid', self.owner_gid, other.owner_gid
398371
)
399372
elif self.event_type in (EventType.XATTR_SET, EventType.XATTR_REMOVE):
400373
Event._diff_field(
401-
diff,
402-
'xattr_name',
403-
self.xattr_name,
404-
event_field.xattr_name,
374+
diff, 'xattr_name', self.xattr_name, other.xattr_name
405375
)
406376

407377
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.43.0
45
pytest==8.4.1
56
requests==2.32.4
67
pyyaml==6.0.3

0 commit comments

Comments
 (0)