Files
LangBot/tools/regression_ci.py
T

240 lines
9.2 KiB
Python

#!/usr/bin/env python3
"""Prepare a disposable Linux CI stack from this checkout, then run a regression suite."""
import argparse
import json
import os
from pathlib import Path
import secrets
import shutil
import signal
import subprocess
import sys
import time
import yaml
from regression_gate import supervise
from regression_suite import Suite
from regression_traffic import owned_box_containers, read_endpoint_snapshot
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from tests.e2e.utils.config_factory import create_minimal_config, create_test_directories # noqa: E402
def ready(url, processes, timeout=120):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if any(p.poll() is not None for p in processes):
raise RuntimeError('A regression dependency exited; inspect service logs')
try:
read_endpoint_snapshot(url, timeout_seconds=1)
return
except (RuntimeError, OSError):
time.sleep(0.2)
raise RuntimeError(f'Service did not become ready: {url}')
def run(args):
if subprocess.check_output(
['docker', 'ps', '-a', '--filter', 'label=langbot.box=true', '--format', '{{.ID}}'], text=True, timeout=10
).strip():
raise RuntimeError('CI needs a dedicated Docker daemon with no existing Box sandboxes')
root = args.output.resolve()
root.mkdir(parents=True, exist_ok=False, mode=0o700)
private = root / '.private'
private.mkdir(mode=0o700)
processes, logs = [], []
key, runtime_token, box_token = (secrets.token_urlsafe(32) for _ in range(3))
env = dict(
os.environ,
LANGBOT_QA_API_KEY=key,
LANGBOT_DATA_ROOT=str(private / 'data'),
PYTHONPATH=str(ROOT / 'src'),
LITELLM_LOCAL_MODEL_COST_MAP='True',
PYTHONUNBUFFERED='1',
LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=runtime_token,
LANGBOT_BOX_CONTROL_TOKEN=box_token,
)
def start(name, command):
log = (root / f'{name}.log').open('w')
logs.append(log)
process = subprocess.Popen(
command, cwd=private, env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True
)
processes.append(process)
return process.pid
try:
config_path = create_minimal_config(private, port=15301)
create_test_directories(private)
config = yaml.safe_load(config_path.read_text())
config['api'].update(global_api_key=key, host='127.0.0.1')
config['plugin'].update(enable=True, runtime_ws_url='ws://127.0.0.1:15340/control/ws')
config['box'].update(enabled=True, backend='local', runtime={'endpoint': 'ws://127.0.0.1:15310/control/ws'})
config['box']['local'].update(
host_root=str(private / 'box'), allowed_mount_roots=[str(private / 'box')], image='python:3.12-alpine'
)
config_path.write_text(yaml.safe_dump(config))
profile_path = ROOT / f'tools/regression_profiles/{args.suite}.json'
profile = json.loads(profile_path.read_text())
cli_config = private / 'lbctl.yaml'
cli_config.write_text(
yaml.safe_dump(
{
'version': 1,
'current_context': 'smoke',
'contexts': {
'smoke': {
'endpoint': 'http://127.0.0.1:15301',
'credential': {'type': 'env', 'name': 'LANGBOT_QA_API_KEY'},
}
},
}
)
)
profile['cli'] = {'executable': str(args.lbctl.resolve()), 'config': str(cli_config)}
profile['vars'].update(
mock_base_url='http://127.0.0.1:18080/v1',
embedding_base_url='http://127.0.0.1:18080/v1',
core_base_url='http://127.0.0.1:15301',
core_health='http://127.0.0.1:15301/healthz',
core_db=str(private / 'data/langbot.db'),
mock_records=str(root / 'mock-requests.jsonl'),
)
profile['plugins'] = []
if args.suite == 'smoke':
# Two turns retain the history/isolation oracle within the 60-second budget.
profile['smoke']['group_turns'] = 2
for resource in profile['resources']:
if resource['kind'] == 'model' and resource.get('type') == 'embedding':
resource['body']['name'] = 'mock-embedding'
for document in resource.get('documents', []):
document['file'] = str((profile_path.parent / document['file']).resolve())
pids = {}
pids['mock'] = start(
'mock',
[
sys.executable,
str(ROOT / 'tools/mock_llm.py'),
'--port',
'18080',
'--record',
str(root / 'mock-requests.jsonl'),
],
)
pids['runtime'] = start(
'runtime',
[
sys.executable,
'-m',
'langbot_plugin.cli.__init__',
'rt',
'--ws-control-port',
'15340',
'--ws-debug-port',
'15341',
],
)
pids['box'] = start(
'box',
[
sys.executable,
'-m',
'langbot_plugin.cli.__init__',
'box',
'--host',
'127.0.0.1',
'--ws-control-port',
'15310',
],
)
pids['core'] = start(
'core', [sys.executable, str(ROOT / 'main.py'), '--standalone-runtime', '--standalone-box']
)
profile['observe'] = {
'health': {
'core': 'http://127.0.0.1:15301/healthz',
'box': 'http://127.0.0.1:15310/readyz',
'mock': 'http://127.0.0.1:18080/healthz',
},
'pids': pids,
}
if args.suite == 'endurance':
profile['observe']['box_data_root'] = str(private / 'box')
for url in profile['observe']['health'].values():
ready(url, processes)
preparation = root / 'preparation'
preparation.mkdir()
suite = Suite(profile, profile_path, preparation, env)
identity = suite.call(['whoami'])
# Bind the CLI to the newly bootstrapped Workspace before installing plugins.
cli_data = yaml.safe_load(cli_config.read_text())
cli_data['contexts']['smoke']['expected_workspace_uuid'] = identity['workspace_uuid']
cli_config.write_text(yaml.safe_dump(cli_data))
for directory in ('Runner/LocalAgent', 'KnowledgeEngine/LangRAG', 'misc/GeneralParsers'):
source = args.plugins / directory
package_source = private / source.name
shutil.copytree(source, package_source, ignore=shutil.ignore_patterns('.git', '.venv*', '__pycache__'))
archive = shutil.make_archive(str(private / source.name), 'zip', root_dir=package_source)
suite.call(['plugin', 'install', 'local', '--file', archive, '--wait'])
generated = private / f'{args.suite}.json'
generated.write_text(json.dumps(profile, indent=2))
return supervise(
[
sys.executable,
str(ROOT / 'tools/regression_suite.py'),
'run',
'--profile',
str(generated),
'--output',
str(root / 'gate/suite'),
],
root / 'gate',
60 if args.suite == 'smoke' else 2100,
pids,
env,
)
finally:
for process in reversed(processes):
if process.poll() is None:
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.wait()
for log in logs:
log.close()
# This is a disposable CI daemon; still delete only this run's mounted sandboxes.
for container in owned_box_containers(private / 'box'):
subprocess.run(['docker', 'rm', '-f', container], check=True, capture_output=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--lbctl', type=Path, required=True)
parser.add_argument('--plugins', type=Path, required=True)
parser.add_argument('--suite', choices=('smoke', 'endurance'), default='smoke')
args = parser.parse_args()
if args.output.exists():
parser.error('--output must be a new directory')
try:
result = run(args)
except Exception as exc:
verdict = args.output / 'gate' / 'gate.json'
result = json.loads(verdict.read_text()) if verdict.exists() else {}
result.update(passed=False, infrastructure_error=f'{type(exc).__name__}: {exc}')
verdict.parent.mkdir(parents=True, exist_ok=True)
verdict.write_text(json.dumps(result, indent=2) + '\n')
raise
print(json.dumps(result, indent=2))
raise SystemExit(0 if result['passed'] else 1)
if __name__ == '__main__':
main()