|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
5 | 5 | import json |
| 6 | +import re |
| 7 | +import subprocess |
| 8 | +import sys |
| 9 | +import time |
6 | 10 | import urllib.request |
| 11 | +import uuid |
7 | 12 | from pathlib import Path |
8 | 13 |
|
9 | 14 | import pytest |
|
12 | 17 | from opentelemetry.proto.metrics.v1.metrics_pb2 import AGGREGATION_TEMPORALITY_DELTA |
13 | 18 |
|
14 | 19 | from tools.ci.tests.test_collector_trace_privacy import SENTINEL, run, wait_for |
| 20 | +from tools.ci.tests.test_helm_render import render_collector_config |
15 | 21 |
|
16 | 22 | pytestmark = pytest.mark.docker |
17 | 23 |
|
@@ -134,6 +140,210 @@ def batches(path: Path): |
134 | 140 | return [json.loads(line) for line in path.read_text().splitlines()] |
135 | 141 |
|
136 | 142 |
|
| 143 | +def model_loading_payload(): |
| 144 | + wire = ExportMetricsServiceRequest() |
| 145 | + resource = wire.resource_metrics.add() |
| 146 | + for key, value in { |
| 147 | + "service.name": "sie-gateway", |
| 148 | + "service.instance.id": "model-loading-test", |
| 149 | + "service.version": "test-version", |
| 150 | + }.items(): |
| 151 | + add(resource.resource.attributes, key, value) |
| 152 | + metric = resource.scope_metrics.add().metrics.add(name="sie.gateway.model_loads_in_progress", unit="1") |
| 153 | + for profile, bundle, value in [("cpu", "default", 0), ("l4", "default", 1), ("cpu", "candle", 0)]: |
| 154 | + point = metric.gauge.data_points.add(time_unix_nano=time.time_ns(), as_double=value) |
| 155 | + for key, label in {"pool": "default", "machine_profile": profile, "bundle": bundle}.items(): |
| 156 | + add(point.attributes, key, label) |
| 157 | + return wire.SerializeToString() |
| 158 | + |
| 159 | + |
| 160 | +def require_cached_collector_image(image): |
| 161 | + try: |
| 162 | + run("docker", "image", "inspect", image) |
| 163 | + except subprocess.CalledProcessError: |
| 164 | + pytest.fail( |
| 165 | + f"Docker must be available with {image} already cached; this test never pulls images.", pytrace=False |
| 166 | + ) |
| 167 | + |
| 168 | + |
| 169 | +def cleanup_model_loading_collector(container, original_error): |
| 170 | + try: |
| 171 | + removed = subprocess.run( # noqa: S603 - exact test-owned container |
| 172 | + ["docker", "rm", "--force", container], # noqa: S607 - local Docker CLI |
| 173 | + check=False, |
| 174 | + capture_output=True, |
| 175 | + text=True, |
| 176 | + timeout=20, |
| 177 | + ) |
| 178 | + remaining = subprocess.run( # noqa: S603 - exact test-owned container |
| 179 | + ["docker", "ps", "--all", "--filter", f"name=^/{container}$", "--format", "{{.Names}}"], # noqa: S607 - local Docker CLI |
| 180 | + check=True, |
| 181 | + capture_output=True, |
| 182 | + text=True, |
| 183 | + timeout=10, |
| 184 | + ) |
| 185 | + if remaining.stdout.strip(): |
| 186 | + raise RuntimeError(f"test-owned collector was not removed: {container}") |
| 187 | + if original_error is None: |
| 188 | + removed.check_returncode() |
| 189 | + except Exception as cleanup_error: |
| 190 | + if original_error is not None: |
| 191 | + raise BaseExceptionGroup("collector test and cleanup failed", [original_error, cleanup_error]) from None |
| 192 | + raise |
| 193 | + |
| 194 | + |
| 195 | +def test_model_loading_image_prerequisite_never_pulls(monkeypatch): |
| 196 | + calls = [] |
| 197 | + |
| 198 | + def missing(*args): |
| 199 | + calls.append(args) |
| 200 | + raise subprocess.CalledProcessError(1, args) |
| 201 | + |
| 202 | + monkeypatch.setitem(require_cached_collector_image.__globals__, "run", missing) |
| 203 | + image = "otel/opentelemetry-collector-contrib:0.119.0" |
| 204 | + with pytest.raises(pytest.fail.Exception, match="already cached; this test never pulls"): |
| 205 | + require_cached_collector_image(image) |
| 206 | + assert calls == [("docker", "image", "inspect", image)] |
| 207 | + |
| 208 | + |
| 209 | +@pytest.mark.parametrize("removed", [False, True]) |
| 210 | +@pytest.mark.parametrize("original_error", [None, RuntimeError("startup or test failure")]) |
| 211 | +def test_model_loading_cleanup_preserves_failures(monkeypatch, removed, original_error): |
| 212 | + container = "sie-model-loading-test" |
| 213 | + calls = [] |
| 214 | + |
| 215 | + def fake_run(args, **kwargs): |
| 216 | + calls.append((args, kwargs)) |
| 217 | + return subprocess.CompletedProcess(args, 0 if removed or args[1] == "ps" else 1, "", "") |
| 218 | + |
| 219 | + monkeypatch.setattr(subprocess, "run", fake_run) |
| 220 | + expected = type(original_error) if original_error else None if removed else subprocess.CalledProcessError |
| 221 | + if expected: |
| 222 | + with pytest.raises(expected) as caught: |
| 223 | + try: |
| 224 | + if original_error: |
| 225 | + raise original_error |
| 226 | + finally: |
| 227 | + cleanup_model_loading_collector(container, sys.exception()) |
| 228 | + if original_error: |
| 229 | + assert caught.value is original_error |
| 230 | + else: |
| 231 | + cleanup_model_loading_collector(container, None) |
| 232 | + assert calls[0][0] == ["docker", "rm", "--force", container] |
| 233 | + assert calls[1][0] == ["docker", "ps", "--all", "--filter", f"name=^/{container}$", "--format", "{{.Names}}"] |
| 234 | + assert [kwargs["timeout"] for _, kwargs in calls] == [20, 10] |
| 235 | + |
| 236 | + |
| 237 | +@pytest.mark.parametrize("verification_fails", [False, True]) |
| 238 | +def test_model_loading_cleanup_failure_reports_both_errors(monkeypatch, verification_fails): |
| 239 | + original_error = RuntimeError("original failure") |
| 240 | + |
| 241 | + def fake_run(args, **_kwargs): |
| 242 | + if args[1] == "ps" and verification_fails: |
| 243 | + raise subprocess.CalledProcessError(1, args) |
| 244 | + return subprocess.CompletedProcess(args, 0, "sie-model-loading-test" if args[1] == "ps" else "", "") |
| 245 | + |
| 246 | + monkeypatch.setattr(subprocess, "run", fake_run) |
| 247 | + with pytest.raises(ExceptionGroup) as caught: |
| 248 | + cleanup_model_loading_collector("sie-model-loading-test", original_error) |
| 249 | + assert caught.value.exceptions[0] is original_error |
| 250 | + assert isinstance(caught.value.exceptions[1], subprocess.CalledProcessError if verification_fails else RuntimeError) |
| 251 | + |
| 252 | + |
| 253 | +def test_model_loading_gauge_preserves_prometheus_name_and_remote_unit(tmp_path): |
| 254 | + config, image = render_collector_config(tmp_path) |
| 255 | + assert image == "otel/opentelemetry-collector-contrib:0.119.0" |
| 256 | + require_cached_collector_image(image) |
| 257 | + exporter = config["exporters"]["prometheus"] |
| 258 | + assert exporter["add_metric_suffixes"] is True |
| 259 | + exporter["endpoint"] = "0.0.0.0:9464" |
| 260 | + config["exporters"] = { |
| 261 | + "prometheus": exporter, |
| 262 | + "file/remote": {"path": "/out/remote.json", "flush_interval": "100ms"}, |
| 263 | + } |
| 264 | + pipelines = config["service"]["pipelines"] |
| 265 | + config["service"]["pipelines"] = { |
| 266 | + key: pipelines[key] for key in ["metrics/prometheus/gateway", "metrics/betterstack/gateway"] |
| 267 | + } |
| 268 | + pipelines["metrics/betterstack/gateway"]["exporters"] = ["file/remote"] |
| 269 | + config["receivers"]["otlp/gateway"]["protocols"]["http"] = {"endpoint": "0.0.0.0:4338"} |
| 270 | + config["processors"]["batch"]["timeout"] = "100ms" |
| 271 | + (tmp_path / "collector.yaml").write_text(yaml.safe_dump(config)) |
| 272 | + container = f"sie-model-loading-{uuid.uuid4().hex}" |
| 273 | + try: |
| 274 | + run( |
| 275 | + "docker", |
| 276 | + "run", |
| 277 | + "--detach", |
| 278 | + "--pull=never", |
| 279 | + "--name", |
| 280 | + container, |
| 281 | + "--cpus=1", |
| 282 | + "--memory=256m", |
| 283 | + "--pids-limit=64", |
| 284 | + "--cap-drop=ALL", |
| 285 | + "--security-opt=no-new-privileges", |
| 286 | + "--read-only", |
| 287 | + "--user=0", |
| 288 | + "-p", |
| 289 | + "127.0.0.1::4338", |
| 290 | + "-p", |
| 291 | + "127.0.0.1::9464", |
| 292 | + "-p", |
| 293 | + "127.0.0.1::13133", |
| 294 | + "-v", |
| 295 | + f"{tmp_path}:/out", |
| 296 | + image, |
| 297 | + "--config=/out/collector.yaml", |
| 298 | + ) |
| 299 | + ports = {port: run("docker", "port", container, f"{port}/tcp").split(":")[-1] for port in [4338, 9464, 13133]} |
| 300 | + |
| 301 | + def get(port, path=""): |
| 302 | + with urllib.request.urlopen(f"http://127.0.0.1:{ports[port]}{path}", timeout=2) as response: |
| 303 | + return response.read(1_048_577).decode() |
| 304 | + |
| 305 | + wait_for(lambda: get(13133)) |
| 306 | + request = urllib.request.Request( |
| 307 | + f"http://127.0.0.1:{ports[4338]}/v1/metrics", |
| 308 | + data=model_loading_payload(), |
| 309 | + headers={"Content-Type": "application/x-protobuf"}, |
| 310 | + ) |
| 311 | + with urllib.request.urlopen(request, timeout=5) as response: # noqa: S310 - fixed loopback HTTP |
| 312 | + assert response.status == 200 |
| 313 | + remote = wait_for(lambda: batches(tmp_path / "remote.json")) |
| 314 | + text = wait_for( |
| 315 | + lambda: value if "sie_gateway_model_loads_in_progress" in (value := get(9464, "/metrics")) else None |
| 316 | + ) |
| 317 | + assert len(text) <= 1_048_576 |
| 318 | + assert "sie_gateway_model_loads_in_progress_ratio" not in text |
| 319 | + samples = re.findall( |
| 320 | + r"^sie_gateway_model_loads_in_progress\{([^}]+)\} ([01])(?:\.0)?(?: \d+)?$", text, re.MULTILINE |
| 321 | + ) |
| 322 | + assert len(samples) == 3 |
| 323 | + observed = {} |
| 324 | + for labels, value in samples: |
| 325 | + attributes = dict(re.findall(r'(\w+)="([^"\\]*)"', labels)) |
| 326 | + assert attributes["pool"] == "default" |
| 327 | + assert attributes["producer_service"] == "sie-gateway" |
| 328 | + assert attributes["producer_instance"] == "model-loading-test" |
| 329 | + assert re.fullmatch(r"[0-9a-f-]{36}", attributes["collector_generation"]) |
| 330 | + observed[attributes["machine_profile"], attributes["bundle"]] = int(value) |
| 331 | + assert observed == {("cpu", "default"): 0, ("l4", "default"): 1, ("cpu", "candle"): 0} |
| 332 | + metrics = [ |
| 333 | + metric |
| 334 | + for batch in remote |
| 335 | + for resource in batch["resourceMetrics"] |
| 336 | + for scope in resource["scopeMetrics"] |
| 337 | + for metric in scope["metrics"] |
| 338 | + ] |
| 339 | + assert len(metrics) == 1 |
| 340 | + assert metrics[0]["name"] == "sie.gateway.model_loads_in_progress" |
| 341 | + assert metrics[0]["unit"] == "1" |
| 342 | + assert sorted(point["asDouble"] for point in metrics[0]["gauge"]["dataPoints"]) == [0, 0, 1] |
| 343 | + finally: |
| 344 | + cleanup_model_loading_collector(container, sys.exception()) |
| 345 | + |
| 346 | + |
137 | 347 | @pytest.mark.parametrize("receiver", ["gateway", "application"]) |
138 | 348 | def test_remote_metric_maps_have_one_scalar_per_retained_key(tmp_path, receiver): |
139 | 349 | config, image = rendered_config() |
|
0 commit comments