Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import requests
import yaml

from metrics import MetricsClient
from server import FileActivityService


Expand Down Expand Up @@ -132,6 +133,13 @@ def fact_config(request, monitored_dir, logs_dir):
config_file.close()


@pytest.fixture
def metrics(fact_config):
"""Client for taking metrics snapshots from the FACT endpoint."""
config, _ = fact_config
return MetricsClient(config['endpoint']['address'])


@pytest.fixture
def test_container(request, docker_client, monitored_dir, ignored_dir):
"""
Expand Down
92 changes: 92 additions & 0 deletions tests/metrics.py
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you considered using the parser included in the prometheus client_python package instead of building our own? https://prometheus.github.io/client_python/parser/

If you have and still decided to build our own I'll take a closer look at the code in this file.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was not aware of its existence. I'll update to use it

Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import requests
from prometheus_client.parser import text_string_to_metric_families


class MetricsSnapshot:
"""
A parsed snapshot of Prometheus/OpenMetrics metrics.

Supports querying by metric name and labels:

ss = metrics.snapshot()
assert ss.get("rate_limiter_events", label="Dropped") == 5
assert ss.get("bpf_events", label="Added") > 0

Metric names are matched without the "stackrox_fact_" prefix and
"_total" counter suffix, so "rate_limiter_events" matches
"stackrox_fact_rate_limiter_events_total".
"""

_PREFIX = "stackrox_fact_"
_TOTAL_SUFFIX = "_total"

def __init__(self, text):
self._entries = []
for family in text_string_to_metric_families(text):
for sample in family.samples:
self._entries.append((sample.name, sample.labels, sample.value))

@classmethod
def _normalize(cls, name):
return name.removeprefix(cls._PREFIX).removesuffix(cls._TOTAL_SUFFIX)

def get(self, metric, **labels):
"""
Get the value of a metric, optionally filtered by labels.

Args:
metric: Metric name, with or without the "stackrox_fact_"
prefix and "_total" suffix.
**labels: Label key=value pairs to match.

Returns:
The metric value as int or float.

Raises:
KeyError: If no matching metric is found.
ValueError: If multiple metrics match.
"""
target = self._normalize(metric)
matches = []
for name, entry_labels, value in self._entries:
if self._normalize(name) != target:
continue
if all(entry_labels.get(k) == v for k, v in labels.items()):
matches.append(value)

if not matches:
label_desc = ', '.join(f'{k}="{v}"' for k, v in labels.items())
key = f'{metric}{{{label_desc}}}' if label_desc else metric
available = '\n '.join(
f'{n} {ls} = {v}' for n, ls, v in self._entries
)
raise KeyError(
f'metric {key!r} not found. Available:\n {available}'
)
if len(matches) > 1:
raise ValueError(
f'{metric} matched {len(matches)} entries; use labels to '
f'narrow the result'
)
return matches[0]

def get_all(self, metric, **labels):
"""Like get(), but returns a list of all matching values."""
target = self._normalize(metric)
return [
value for name, entry_labels, value in self._entries
if self._normalize(name) == target
and all(entry_labels.get(k) == v for k, v in labels.items())
]


class MetricsClient:
"""Fetches metrics snapshots from a FACT endpoint."""

def __init__(self, address):
self._url = f'http://{address}/metrics'

def snapshot(self, timeout=30):
resp = requests.get(self._url, timeout=timeout)
resp.raise_for_status()
return MetricsSnapshot(resp.text)
1 change: 1 addition & 0 deletions tests/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
docker==7.1.0
grpcio==1.76.0
grpcio-tools==1.76.0
prometheus-client==0.22.1
pytest==8.4.1
requests==2.32.4
pyyaml==6.0.3
93 changes: 35 additions & 58 deletions tests/test_path_rmdir.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,39 +4,16 @@
import pytest

from event import Event, EventType, Process
from utils import get_metric_value


def get_inode_removed_count(fact_config):
"""
Query Prometheus metrics to get the count of removed inodes.

Args:
fact_config: The fact configuration tuple (config dict, config file path).

Returns:
The current value of host_scanner_scan{label="InodeRemoved"} metric.
"""
value = get_metric_value(fact_config, "host_scanner_scan", {"label": "InodeRemoved"})
return int(value) if value is not None else 0


def get_kernel_rmdir_processed(fact_config):
"""
Query Prometheus metrics to get the count of processed (non-ignored) rmdir events.

Args:
fact_config: The fact configuration tuple (config dict, config file path).

Returns:
The difference between Total and Ignored kernel_path_rmdir_events.
"""
total_str = get_metric_value(fact_config, "kernel_path_rmdir_events", {"label": "Total"})
ignored_str = get_metric_value(fact_config, "kernel_path_rmdir_events", {"label": "Ignored"})
def get_inode_removed_count(metrics):
return metrics.snapshot().get("host_scanner_scan", label="InodeRemoved")

total = int(total_str) if total_str is not None else 0
ignored = int(ignored_str) if ignored_str is not None else 0

def get_kernel_rmdir_processed(metrics):
ss = metrics.snapshot()
total = ss.get("kernel_path_rmdir_events", label="Total")
ignored = ss.get("kernel_path_rmdir_events", label="Ignored")
return total - ignored


Expand All @@ -46,7 +23,7 @@ def get_kernel_rmdir_processed(fact_config):
pytest.param('файл', id='Cyrillic'),
pytest.param('日本語', id='Japanese'),
])
def test_rmdir_empty(monitored_dir, server, fact_config, dirname):
def test_rmdir_empty(monitored_dir, server, metrics, dirname):
"""
Tests that removing an empty directory properly cleans up inode tracking.

Expand All @@ -60,14 +37,14 @@ def test_rmdir_empty(monitored_dir, server, fact_config, dirname):
Args:
monitored_dir: Temporary directory path for creating the test directory.
server: The server instance to communicate with.
fact_config: The fact configuration.
metrics: The metrics client fixture.
dirname: Directory name to test (including UTF-8 variants).
"""
process = Process.from_proc()

# Get baseline metric counts
initial_inode_removed = get_inode_removed_count(fact_config)
initial_kernel_rmdir = get_kernel_rmdir_processed(fact_config)
initial_inode_removed = get_inode_removed_count(metrics)
initial_kernel_rmdir = get_kernel_rmdir_processed(metrics)

# Create a directory
test_dir = os.path.join(monitored_dir, dirname)
Expand All @@ -94,7 +71,7 @@ def test_rmdir_empty(monitored_dir, server, fact_config, dirname):
server.wait_events([e2])

# Check that file deletion incremented the metric by exactly 1
count_after_file = get_inode_removed_count(fact_config)
count_after_file = get_inode_removed_count(metrics)
file_delta = count_after_file - initial_inode_removed
assert file_delta == 1, \
f"Expected exactly 1 inode removed for file deletion, got {file_delta}"
Expand All @@ -103,8 +80,8 @@ def test_rmdir_empty(monitored_dir, server, fact_config, dirname):
os.rmdir(test_dir)

# Check metrics after directory deletion
final_inode_removed = get_inode_removed_count(fact_config)
final_kernel_rmdir = get_kernel_rmdir_processed(fact_config)
final_inode_removed = get_inode_removed_count(metrics)
final_kernel_rmdir = get_kernel_rmdir_processed(metrics)

inode_delta = final_inode_removed - initial_inode_removed
kernel_delta = final_kernel_rmdir - initial_kernel_rmdir
Expand All @@ -115,7 +92,7 @@ def test_rmdir_empty(monitored_dir, server, fact_config, dirname):
f"Expected exactly 1 kernel rmdir event processed, got {kernel_delta}"


def test_rmdir_recursive(monitored_dir, server, fact_config):
def test_rmdir_recursive(monitored_dir, server, metrics):
"""
Tests that removing a directory tree recursively cleans up all inode tracking.

Expand All @@ -130,13 +107,13 @@ def test_rmdir_recursive(monitored_dir, server, fact_config):
Args:
monitored_dir: Temporary directory path for creating test directories.
server: The server instance to communicate with.
fact_config: The fact configuration.
metrics: The metrics client fixture.
"""
process = Process.from_proc()

# Get baseline metric counts
initial_inode_removed = get_inode_removed_count(fact_config)
initial_kernel_rmdir = get_kernel_rmdir_processed(fact_config)
initial_inode_removed = get_inode_removed_count(metrics)
initial_kernel_rmdir = get_kernel_rmdir_processed(metrics)

# Create nested directories
level1 = os.path.join(monitored_dir, 'level1')
Expand Down Expand Up @@ -186,8 +163,8 @@ def test_rmdir_recursive(monitored_dir, server, fact_config):
server.wait_events(unlink_events)

# Check that all inodes and kernel events were tracked
final_inode_removed = get_inode_removed_count(fact_config)
final_kernel_rmdir = get_kernel_rmdir_processed(fact_config)
final_inode_removed = get_inode_removed_count(metrics)
final_kernel_rmdir = get_kernel_rmdir_processed(metrics)

inode_delta = final_inode_removed - initial_inode_removed
kernel_delta = final_kernel_rmdir - initial_kernel_rmdir
Expand All @@ -198,7 +175,7 @@ def test_rmdir_recursive(monitored_dir, server, fact_config):
f"Expected exactly 3 kernel rmdir events processed, got {kernel_delta}"


def test_rmdir_ignored(monitored_dir, ignored_dir, server, fact_config):
def test_rmdir_ignored(monitored_dir, ignored_dir, server, metrics):
"""
Tests that directories removed outside monitored paths don't affect tracking.

Expand All @@ -208,13 +185,13 @@ def test_rmdir_ignored(monitored_dir, ignored_dir, server, fact_config):
monitored_dir: Temporary directory path that is monitored.
ignored_dir: Temporary directory path that is not monitored.
server: The server instance to communicate with.
fact_config: The fact configuration.
metrics: The metrics client fixture.
"""
process = Process.from_proc()

# Get baseline metric counts
initial_inode_removed = get_inode_removed_count(fact_config)
initial_kernel_rmdir = get_kernel_rmdir_processed(fact_config)
initial_inode_removed = get_inode_removed_count(metrics)
initial_kernel_rmdir = get_kernel_rmdir_processed(metrics)

# Create directory in ignored path
ignored_subdir = os.path.join(ignored_dir, 'ignored_subdir')
Expand All @@ -228,8 +205,8 @@ def test_rmdir_ignored(monitored_dir, ignored_dir, server, fact_config):
os.rmdir(ignored_subdir)

# Metrics should not have changed
inode_after_ignored = get_inode_removed_count(fact_config)
kernel_after_ignored = get_kernel_rmdir_processed(fact_config)
inode_after_ignored = get_inode_removed_count(metrics)
kernel_after_ignored = get_kernel_rmdir_processed(metrics)
assert inode_after_ignored == initial_inode_removed, \
f"Ignored path operations should not increment inode_removed metric"
assert kernel_after_ignored == initial_kernel_rmdir, \
Expand Down Expand Up @@ -260,8 +237,8 @@ def test_rmdir_ignored(monitored_dir, ignored_dir, server, fact_config):
server.wait_events(deletion_events)

# Metrics should have incremented by exactly 2 inodes and 1 kernel rmdir
final_inode_removed = get_inode_removed_count(fact_config)
final_kernel_rmdir = get_kernel_rmdir_processed(fact_config)
final_inode_removed = get_inode_removed_count(metrics)
final_kernel_rmdir = get_kernel_rmdir_processed(metrics)

inode_delta = final_inode_removed - initial_inode_removed
kernel_delta = final_kernel_rmdir - initial_kernel_rmdir
Expand All @@ -272,7 +249,7 @@ def test_rmdir_ignored(monitored_dir, ignored_dir, server, fact_config):
f"Expected exactly 1 kernel rmdir event processed, got {kernel_delta}"


def test_rmdir_with_parent_inode(monitored_dir, server, fact_config):
def test_rmdir_with_parent_inode(monitored_dir, server, metrics):
"""
Tests that directory deletion properly handles parent inode relationships.

Expand All @@ -282,13 +259,13 @@ def test_rmdir_with_parent_inode(monitored_dir, server, fact_config):
Args:
monitored_dir: Temporary directory path for creating test directories.
server: The server instance to communicate with.
fact_config: The fact configuration.
metrics: The metrics client fixture.
"""
process = Process.from_proc()

# Get baseline metric counts
initial_inode_removed = get_inode_removed_count(fact_config)
initial_kernel_rmdir = get_kernel_rmdir_processed(fact_config)
initial_inode_removed = get_inode_removed_count(metrics)
initial_kernel_rmdir = get_kernel_rmdir_processed(metrics)

# Create a subdirectory
subdir = os.path.join(monitored_dir, 'subdir')
Expand Down Expand Up @@ -325,8 +302,8 @@ def test_rmdir_with_parent_inode(monitored_dir, server, fact_config):
server.wait_events(deletion_events)

# Check metrics incremented (file + subdir)
inode_after_subdir = get_inode_removed_count(fact_config)
kernel_after_subdir = get_kernel_rmdir_processed(fact_config)
inode_after_subdir = get_inode_removed_count(metrics)
kernel_after_subdir = get_kernel_rmdir_processed(metrics)

inode_delta_subdir = inode_after_subdir - initial_inode_removed
kernel_delta_subdir = kernel_after_subdir - initial_kernel_rmdir
Expand Down Expand Up @@ -356,8 +333,8 @@ def test_rmdir_with_parent_inode(monitored_dir, server, fact_config):

# Final metric check: should be 3 total inodes (test_file, subdir, new_file)
# and 1 total kernel rmdir (subdir)
final_inode_removed = get_inode_removed_count(fact_config)
final_kernel_rmdir = get_kernel_rmdir_processed(fact_config)
final_inode_removed = get_inode_removed_count(metrics)
final_kernel_rmdir = get_kernel_rmdir_processed(metrics)

inode_total_delta = final_inode_removed - initial_inode_removed
kernel_total_delta = final_kernel_rmdir - initial_kernel_rmdir
Expand Down
Loading
Loading