174 lines
8.3 KiB
Python
174 lines
8.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Verify local code, tests, databases and Docker without calling an external LLM."""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ast
|
|
import ipaddress
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shlex
|
|
import socket
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.request
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def local_test_worker() -> int:
|
|
"""Allow live local API/Neo4j tests, forbid outbound model calls."""
|
|
os.environ.pop('OPENWEBUI_API_KEY', None)
|
|
for line in (ROOT / '.env').read_text().splitlines():
|
|
key, separator, value = line.partition('=')
|
|
if separator and key in {'NEO4J_URI', 'NEO4J_USER', 'NEO4J_PASSWORD', 'NEO4J_DATABASE'}:
|
|
values = shlex.split(value)
|
|
if values:
|
|
os.environ[key] = values[0]
|
|
for key in ['RUN_NEO4J_TESTS', 'RUN_LIVE_TESTS', 'RUN_LIVE_RAG_E2E', 'HF_HUB_OFFLINE', 'TRANSFORMERS_OFFLINE']:
|
|
os.environ[key] = '1'
|
|
original_connect, original_connect_ex = socket.socket.connect, socket.socket.connect_ex
|
|
original_dns = socket.getaddrinfo
|
|
|
|
def permitted(address):
|
|
try:
|
|
return address[1] in {7687, 8000} and ipaddress.ip_address(address[0]).is_loopback
|
|
except (ValueError, TypeError, IndexError):
|
|
return False
|
|
|
|
def guarded_connect(self, address):
|
|
if self.family in {socket.AF_INET, socket.AF_INET6} and not permitted(address):
|
|
raise RuntimeError('External network disabled during local verification')
|
|
return original_connect(self, address)
|
|
|
|
def guarded_connect_ex(self, address):
|
|
if self.family in {socket.AF_INET, socket.AF_INET6} and not permitted(address):
|
|
raise RuntimeError('External network disabled during local verification')
|
|
return original_connect_ex(self, address)
|
|
|
|
def guarded_dns(host, port, *args, **kwargs):
|
|
if host not in {'localhost', '127.0.0.1', '::1'} or int(port) not in {7687, 8000}:
|
|
raise RuntimeError('External DNS disabled during local verification')
|
|
return original_dns(host, port, *args, **kwargs)
|
|
|
|
socket.socket.connect, socket.socket.connect_ex = guarded_connect, guarded_connect_ex
|
|
socket.getaddrinfo = guarded_dns
|
|
import pytest
|
|
return pytest.main(['-q', 'test', 'tools/rag/tests', 'tools/graphrag/tests'])
|
|
|
|
|
|
def verify_code() -> dict:
|
|
files = [path for name in ['app', 'tools', 'test', 'scripts'] for path in (ROOT / name).rglob('*.py')]
|
|
for path in files:
|
|
text = path.read_text()
|
|
compile(text, str(path), 'exec')
|
|
for node in ast.walk(ast.parse(text)):
|
|
modules = ([node.module] if isinstance(node, ast.ImportFrom) and node.module else
|
|
[item.name for item in node.names] if isinstance(node, ast.Import) else [])
|
|
for module in modules:
|
|
if module.split('.')[0] in {'tools', 'app'}:
|
|
target = ROOT.joinpath(*module.split('.'))
|
|
if not target.with_suffix('.py').is_file() and not (target / '__init__.py').is_file():
|
|
raise RuntimeError(f'Unresolved internal module {module} in {path.relative_to(ROOT)}')
|
|
shells = [path for name in ['scripts', 'tools'] for path in (ROOT / name).rglob('*.sh')]
|
|
for path in shells:
|
|
subprocess.run(['bash', '-n', str(path)], check=True)
|
|
return {'compiled_python_files': len(files), 'checked_shell_scripts': len(shells)}
|
|
|
|
|
|
def verify_database() -> dict:
|
|
path = ROOT / 'data/zp_index.sqlite'
|
|
with sqlite3.connect(path.resolve().as_uri() + '?mode=ro', uri=True) as conn:
|
|
assert conn.execute('PRAGMA quick_check').fetchone()[0] == 'ok', 'SQLite integrity failed'
|
|
assert not conn.execute('PRAGMA foreign_key_check').fetchall(), 'SQLite foreign keys failed'
|
|
counts = {table: conn.execute(f'SELECT count(*) FROM {table}').fetchone()[0]
|
|
for table in ['documents', 'chunks', 'chunks_fts', 'chunk_embeddings']}
|
|
assert counts['chunks'] == counts['chunks_fts'] and counts['chunks'] > 0, 'FTS count mismatch'
|
|
return counts
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--docker-context', default='default')
|
|
parser.add_argument('--git-network', action='store_true', help='Read-only probe of wiki origin over its configured SSH connection')
|
|
parser.add_argument('--output', type=Path, default=ROOT / 'data/project_verification.json')
|
|
parser.add_argument('--test-worker', action='store_true', help=argparse.SUPPRESS)
|
|
args = parser.parse_args()
|
|
os.chdir(ROOT)
|
|
sys.path.insert(0, str(ROOT))
|
|
if args.test_worker:
|
|
return local_test_worker()
|
|
report = {'checks': {}, 'external_llm_calls': False}
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
def check(name, action):
|
|
started = time.monotonic()
|
|
try:
|
|
result = action()
|
|
record = {'passed': True, 'details': result}
|
|
except Exception as error:
|
|
record = {'passed': False, 'error_type': type(error).__name__}
|
|
record['seconds'] = round(time.monotonic() - started, 2)
|
|
report['checks'][name] = record
|
|
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n')
|
|
print(f'{name}: {"OK" if record["passed"] else "FAILED"}', flush=True)
|
|
|
|
def command(*argv):
|
|
result = subprocess.run(argv, text=True, capture_output=True, timeout=90)
|
|
if result.returncode:
|
|
raise RuntimeError('Verification command failed; inspect the service locally')
|
|
return result.stdout.strip()
|
|
|
|
docker = ['docker', '--context', args.docker_context]
|
|
compose = [*docker, 'compose', '--project-directory', str(ROOT)]
|
|
check('python_and_shell', verify_code)
|
|
check('git_diff', lambda: command('git', 'diff', '--check'))
|
|
check('git_integrity', lambda: command('git', 'fsck', '--connectivity-only'))
|
|
check('compose_configuration', lambda: command(*compose, 'config', '--quiet'))
|
|
|
|
def health():
|
|
with urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=15) as response:
|
|
value = json.load(response)
|
|
assert value['ready'] and all(tool['ready'] for tool in value['tools'].values())
|
|
assert value['tools']['rag']['index_version'] == value['tools']['graphrag']['index_version']
|
|
return {'ready': True, 'index_version': value['index_version'],
|
|
'tools': {name: item['ready'] for name, item in value['tools'].items()}}
|
|
|
|
check('api_and_both_indexes', health)
|
|
check('sqlite_integrity', verify_database)
|
|
check('container_readiness', lambda: command(*compose, 'exec', '-T', 'zp-agent-api',
|
|
'python', '-m', 'tools.runtime.runtime_health'))
|
|
if args.git_network:
|
|
probe = ("import os,subprocess; from tools.sync.wiki_git import expected_branch; "
|
|
"p=subprocess.run(['git','-C',os.environ['ZPWIKI_ROOT'],'ls-remote','--exit-code',"
|
|
"'origin','refs/heads/'+expected_branch()],capture_output=True,text=True,timeout=60); "
|
|
"assert p.returncode==0,'Wiki origin unavailable'; print(p.stdout.strip())")
|
|
check('wiki_git_ssh', lambda: command(*compose, 'exec', '-T', 'zp-agent-api', 'python', '-c', probe))
|
|
|
|
def tests():
|
|
log = args.output.with_suffix('.pytest.log')
|
|
environment = {**os.environ}
|
|
environment.pop('OPENWEBUI_API_KEY', None)
|
|
with log.open('w') as output:
|
|
result = subprocess.run([sys.executable, str(Path(__file__).resolve()), '--test-worker'],
|
|
env=environment, stdout=output, stderr=subprocess.STDOUT, timeout=900)
|
|
assert result.returncode == 0, 'Regression failed; see local pytest log'
|
|
text = log.read_text()
|
|
counts = {name: int(value) for value, name in re.findall(r'(\d+) (passed|failed|skipped)', text)}
|
|
assert counts.get('passed', 0) > 0 and counts.get('skipped', 0) == 0
|
|
return {**counts, 'log': str(log), 'external_network_blocked': True}
|
|
|
|
check('full_local_regression', tests)
|
|
report['passed'] = all(item['passed'] for item in report['checks'].values())
|
|
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n')
|
|
print('Report: ' + str(args.output))
|
|
return 0 if report['passed'] else 1
|
|
|
|
|
|
if __name__ == '__main__':
|
|
raise SystemExit(main())
|