mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-03 02:26:43 +09:00
A new collector template receives OTLP; Attach → Tracing gives an app the standard OTEL_EXPORTER_OTLP_* variables, and a Jaeger attached to the collector as its trace store receives the spans. ServerKit does not instrument apps; it only wires the path. A docker leg sends a span from an app and finds it in Jaeger.
142 lines
6.3 KiB
Python
142 lines
6.3 KiB
Python
"""App → OpenTelemetry Collector → Jaeger, through REAL containers (plan 86 §A4).
|
|
|
|
The bundled collector and Jaeger templates are rendered with the panel's rules.
|
|
The collector gets Jaeger attached (traces), an app gets the collector attached
|
|
(tracing); the app posts one OTLP/HTTP span to the endpoint it was given, and
|
|
Jaeger's API then knows the app's service name.
|
|
|
|
SERVERKIT_DOCKER_BUILDS=1 pytest tests -m docker_builds
|
|
"""
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
import urllib.request
|
|
import uuid
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from app import db
|
|
from app.services.app_attachment_service import AppAttachmentService
|
|
from app.services.compose_env_service import ComposeEnvService
|
|
from app.services.template_service import TemplateService
|
|
from tests.factories import make_application
|
|
|
|
|
|
def _docker_ready() -> bool:
|
|
if shutil.which('docker') is None:
|
|
return False
|
|
try:
|
|
return subprocess.run(['docker', 'info'], capture_output=True,
|
|
timeout=20).returncode == 0
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return False
|
|
|
|
|
|
pytestmark = [
|
|
pytest.mark.docker_builds,
|
|
pytest.mark.skipif(os.environ.get('SERVERKIT_DOCKER_BUILDS') != '1',
|
|
reason='docker-builds leg; opt in with SERVERKIT_DOCKER_BUILDS=1'),
|
|
pytest.mark.skipif(not _docker_ready(), reason='docker daemon not reachable'),
|
|
]
|
|
|
|
SEND = r'''
|
|
import json, os, time, urllib.request
|
|
now = time.time_ns()
|
|
span = {"resourceSpans": [{"resource": {"attributes": [
|
|
{"key": "service.name", "value": {"stringValue": os.environ["SERVICE"]}}]},
|
|
"scopeSpans": [{"spans": [{"traceId": "5b8efff798038103d269b633813fc60c",
|
|
"spanId": "eee19b7ec3c1b174", "name": "probe", "kind": 1,
|
|
"startTimeUnixNano": str(now - 1000000), "endTimeUnixNano": str(now)}]}]}]}
|
|
req = urllib.request.Request(os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] + "/v1/traces",
|
|
data=json.dumps(span).encode(),
|
|
headers={"Content-Type": "application/json"})
|
|
for _ in range(30):
|
|
try:
|
|
print("SENT", urllib.request.urlopen(req, timeout=5).status, flush=True)
|
|
break
|
|
except OSError:
|
|
time.sleep(2)
|
|
'''
|
|
|
|
|
|
def _install(tmp_path, installed, template_id, name, variables):
|
|
template = TemplateService.get_template(template_id)['template']
|
|
variables = dict(variables, APP_NAME=name)
|
|
for var in template.get('variables', []):
|
|
variables.setdefault(var['name'], TemplateService.generate_value(var))
|
|
root = tmp_path / name
|
|
root.mkdir()
|
|
rendered = TemplateService._render_compose_and_files(template, variables, str(root))
|
|
compose = yaml.safe_load(rendered['compose_content'])
|
|
for svc in compose['services'].values():
|
|
svc.pop('ports', None) # the shared network is the only path in
|
|
(root / 'docker-compose.yml').write_text(yaml.safe_dump(compose))
|
|
for f in rendered['files']:
|
|
with open(f['path'], 'w', newline='\n') as fh:
|
|
fh.write(f['content'])
|
|
(root / '.serverkit-template.json').write_text(json.dumps(
|
|
{'template_id': template_id, 'variables': variables}))
|
|
row = make_application(db, name=name, root_path=str(root))
|
|
installed[str(row.id)] = {'template_id': template_id}
|
|
return row
|
|
|
|
|
|
def _files(row):
|
|
files = ['-f', os.path.join(row.root_path, 'docker-compose.yml')]
|
|
override = os.path.join(row.root_path, ComposeEnvService.OVERRIDE_NAME)
|
|
return files + (['-f', override] if os.path.exists(override) else [])
|
|
|
|
|
|
def test_a_span_reaches_jaeger_through_the_collector(app, tmp_path, monkeypatch):
|
|
suffix = uuid.uuid4().hex[:8]
|
|
installed = {}
|
|
monkeypatch.setattr(TemplateService, 'get_config', classmethod(
|
|
lambda cls: {'repos': [], 'installed': installed}))
|
|
jaeger = _install(tmp_path, installed, 'jaeger', f'skc-jaeger-{suffix}', {})
|
|
otel = _install(tmp_path, installed, 'otel-collector', f'skc-otel-{suffix}', {})
|
|
web_root = tmp_path / f'skc-web-{suffix}'
|
|
web_root.mkdir()
|
|
(web_root / 'send.py').write_text(SEND)
|
|
service = f'probe-{suffix}'
|
|
(web_root / 'docker-compose.yml').write_text(yaml.safe_dump({'services': {'app': {
|
|
'image': 'python:3.12-alpine', 'volumes': ['./send.py:/send.py:ro'],
|
|
'environment': {'SERVICE': service}, 'command': ['python', '/send.py']}}}))
|
|
web = make_application(db, name=f'skc-web-{suffix}', root_path=str(web_root))
|
|
|
|
AppAttachmentService.attach(otel, jaeger, 'traces')
|
|
AppAttachmentService.attach(web, otel, 'tracing')
|
|
rows = [jaeger, otel, web]
|
|
try:
|
|
for row in (jaeger, otel):
|
|
ComposeEnvService.refresh_for_project(row.root_path)
|
|
subprocess.run(['docker', 'compose', '-p', row.name, *_files(row), 'up', '-d'],
|
|
check=True, capture_output=True, timeout=300)
|
|
ComposeEnvService.refresh_for_project(web.root_path)
|
|
sent = subprocess.run(['docker', 'compose', '-p', web.name, *_files(web), 'run', '--rm', 'app'],
|
|
capture_output=True, text=True, timeout=300)
|
|
assert 'SENT 200' in sent.stdout, sent.stdout + sent.stderr
|
|
|
|
# Jaeger's query API, from inside the network (its UI port is not published).
|
|
probe = ('import json,urllib.request,time\n'
|
|
'for _ in range(45):\n'
|
|
' try:\n'
|
|
f' d=json.load(urllib.request.urlopen("http://{jaeger.name}:16686/api/services"))\n'
|
|
f' if "{service}" in (d.get("data") or []): print("FOUND"); break\n'
|
|
' except OSError: pass\n'
|
|
' time.sleep(2)\n')
|
|
found = subprocess.run(['docker', 'run', '--rm', '--network', 'serverkit-services',
|
|
'python:3.12-alpine', 'python', '-c', probe],
|
|
capture_output=True, text=True, timeout=300)
|
|
if 'FOUND' not in found.stdout:
|
|
logs = {name: subprocess.run(['docker', 'logs', '--tail', '40', name],
|
|
capture_output=True, text=True).stderr[-3000:]
|
|
for name in (otel.name, jaeger.name)}
|
|
pytest.fail(f'span never reached jaeger\n{found.stdout}{found.stderr}\n{logs}')
|
|
finally:
|
|
for row in reversed(rows):
|
|
subprocess.run(['docker', 'compose', '-p', row.name, *_files(row), 'down', '-v'],
|
|
capture_output=True, timeout=180)
|