mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-06 20:43:46 +08:00
312 lines
17 KiB
Python
312 lines
17 KiB
Python
"""Partition lifecycle workloads and prove complete shard outcomes."""
|
|
import argparse
|
|
import hashlib
|
|
from collections import Counter
|
|
import json
|
|
import math
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
|
|
def digest(data):
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def file_record(path):
|
|
if path.is_symlink():
|
|
return {'sha256': digest(os.fsencode(os.readlink(path))), 'mode': 0o120000}
|
|
if not path.is_file():
|
|
return None
|
|
return {'sha256': digest(path.read_bytes()), 'mode': path.stat().st_mode & 0o777}
|
|
|
|
|
|
def build_identity(root):
|
|
"""Pin the complete committed tree plus actual changes; separate only named build outputs."""
|
|
root = Path(root)
|
|
def git(*args):
|
|
return subprocess.check_output(['git', '--no-optional-locks', '-c', 'gc.auto=0', '-c', 'maintenance.auto=false', *args], cwd=root)
|
|
inventory_path = root / 'tools/ci/lifecycle_build_outputs.json'
|
|
inventory_bytes = inventory_path.read_bytes()
|
|
inventory = json.loads(inventory_bytes)
|
|
outputs = {}
|
|
for name, declaration in inventory.items():
|
|
path = Path(name)
|
|
if path.is_absolute() or '..' in path.parts or set(declaration) != {'recipe', 'required_tracked'}:
|
|
raise ValueError('Invalid declared build output')
|
|
outputs[name] = file_record(root / name)
|
|
if declaration['required_tracked'] and outputs[name] is None:
|
|
raise ValueError(f'Missing declared tracked artifact: {name}')
|
|
if not (root / declaration['recipe']).is_file():
|
|
raise ValueError(f'Missing build recipe for {name}')
|
|
changed = git('diff', '--name-only', '--no-renames', '-z', 'HEAD', '--').decode().split('\0')
|
|
changes = {name: file_record(root / name) for name in changed if name and name not in inventory}
|
|
untracked = git('ls-files', '--others', '--exclude-standard', '-z', '--', 'openpilot', 'opendbc_repo', 'panda',
|
|
'msgq_repo', 'rednose_repo', 'tinygrad_repo', 'tinygrad_model_runtime', 'tools', 'scripts').decode().split('\0')
|
|
additions = {name: file_record(root / name) for name in untracked if name and name not in inventory}
|
|
recipes = {name: file_record(root / name) for name in sorted({'SConstruct', *[row['recipe'] for row in inventory.values()]})}
|
|
tracked = git('ls-files', '--cached', '-z').decode().split('\0')
|
|
source_files = {name: file_record(root / name) for name in tracked if name and name not in inventory}
|
|
if any(metadata is None for metadata in source_files.values()):
|
|
raise ValueError('Missing source input from the tracked build closure')
|
|
inputs = {'revision': git('rev-parse', 'HEAD').decode().strip(), 'tree': git('rev-parse', 'HEAD^{tree}').decode().strip(),
|
|
'tracked_inputs': source_files, 'changes': changes, 'untracked': additions, 'recipes': recipes,
|
|
'output_inventory_sha256': digest(inventory_bytes)}
|
|
return {'inputs': inputs, 'declared_outputs': outputs}
|
|
|
|
|
|
def same_build_inputs(first, second):
|
|
inventory = json.loads((Path(__file__).with_name('lifecycle_build_outputs.json')).read_text())
|
|
expected = digest(Path(__file__).with_name('lifecycle_build_outputs.json').read_bytes())
|
|
for record in (first, second):
|
|
if set(record) != {'inputs', 'declared_outputs'} or set(record['declared_outputs']) != set(inventory):
|
|
raise ValueError('Missing or forged build provenance')
|
|
if set(record['inputs']) != {'revision', 'tree', 'tracked_inputs', 'changes', 'untracked', 'recipes', 'output_inventory_sha256'}:
|
|
raise ValueError('Missing source input closure')
|
|
if not record['inputs']['tracked_inputs'] or not record['inputs']['recipes']:
|
|
raise ValueError('Empty source input closure')
|
|
if record['inputs']['output_inventory_sha256'] != expected:
|
|
raise ValueError('Build output inventory changed')
|
|
for name, metadata in record['declared_outputs'].items():
|
|
if metadata is None and inventory[name]['required_tracked']:
|
|
raise ValueError('Missing native artifact provenance')
|
|
if metadata is not None and (set(metadata) != {'sha256', 'mode'} or len(metadata['sha256']) != 64):
|
|
raise ValueError('Malformed native artifact provenance')
|
|
return first['inputs'] == second['inputs']
|
|
|
|
|
|
def matches(identity, target):
|
|
"""A whole module, or one declared heavy module's whole pytest function."""
|
|
module, *functions = target.split('::', 1)
|
|
prefix = module.removesuffix('.py').replace('/', '.') + '.'
|
|
if not functions:
|
|
return identity.startswith(prefix)
|
|
function = functions[0].split('[', 1)[0]
|
|
if not function.isidentifier() or not function.startswith('test_'):
|
|
raise ValueError('Invalid lifecycle function selector')
|
|
selected = prefix + functions[0]
|
|
return identity == selected if '[' in functions[0] else identity == selected or identity.startswith(selected + '[')
|
|
|
|
|
|
def workload_policy():
|
|
policy = json.loads(Path(__file__).with_name('lifecycle_workloads.json').read_text())
|
|
if policy['version'] != 1 or set(policy['function_groups']) != {
|
|
'openpilot/starpilot/car/toyota/tests/test_legacy_advert_callers.py',
|
|
'openpilot/starpilot/tests/test_ford_configuration_parity.py',
|
|
'openpilot/starpilot/aol/tests/test_stock_location_recovery.py',
|
|
'openpilot/starpilot/car/toyota/tests/test_camera_2aa_callers.py',
|
|
'openpilot/starpilot/car/toyota/tests/test_prius_sdsu_callers.py',
|
|
'openpilot/starpilot/car/toyota/tests/test_prius_advert_callers.py'}:
|
|
raise ValueError('Unreviewed lifecycle function grouping policy')
|
|
if type(policy['independent_function_max_cases']) is not int or not 1 <= policy['independent_function_max_cases'] <= 60:
|
|
raise ValueError('Invalid independent function group size')
|
|
if any(row.get('independent_parameters') is not True for row in policy['function_groups'].values()):
|
|
raise ValueError('Function parameter independence was not reviewed')
|
|
values = [policy['independent_function_max_seconds'], policy['unknown_seconds_per_case']]
|
|
values.extend(row['estimated_seconds_per_case'] for row in policy['function_groups'].values())
|
|
if any(not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0 for value in values):
|
|
raise ValueError('Invalid lifecycle workload estimate')
|
|
for row in policy['modules'].values():
|
|
if type(row['cases']) is not int or row['cases'] < 1 or not math.isfinite(row['seconds']) or row['seconds'] <= 0:
|
|
raise ValueError('Invalid measured lifecycle workload')
|
|
return policy
|
|
|
|
|
|
def partition(targets, identities, count):
|
|
if (not 1 <= count <= 16 or len(set(targets)) != len(targets) or len(set(identities)) != len(identities) or
|
|
any(not target.endswith('.py') or '::' in target or Path(target).is_absolute() or '..' in Path(target).parts for target in targets)):
|
|
raise ValueError('Invalid lifecycle catalog')
|
|
owners = {}
|
|
for identity in identities:
|
|
modules = [target for target in targets if matches(identity, target)]
|
|
if len(modules) != 1:
|
|
raise ValueError(f'Lifecycle identity needs exactly one module: {identity}')
|
|
owners[identity] = modules[0]
|
|
if set(owners.values()) != set(targets):
|
|
raise ValueError('Lifecycle module collected no cases')
|
|
policy = workload_policy()
|
|
units = {}
|
|
for identity, target in owners.items():
|
|
unit = target
|
|
if target in policy['function_groups']:
|
|
function = identity[len(target.removesuffix('.py').replace('/', '.') + '.'):].split('[', 1)[0]
|
|
if not function.isidentifier() or not function.startswith('test_'):
|
|
raise ValueError('Heavy lifecycle grouping requires top-level pytest functions')
|
|
unit += '::' + function
|
|
units.setdefault(unit, []).append(identity)
|
|
bounded = {}
|
|
for unit, selected in units.items():
|
|
target = unit.split('::', 1)[0]
|
|
if (target in policy['function_groups'] and
|
|
(len(selected) > policy['independent_function_max_cases'] or
|
|
len(selected) * policy['function_groups'][target]['estimated_seconds_per_case'] > policy['independent_function_max_seconds'])):
|
|
prefix = target.removesuffix('.py').replace('/', '.') + '.'
|
|
# Only reviewed independent parameter cases become exact leaf selectors.
|
|
# Actual collected node IDs are retained verbatim, including parameter punctuation.
|
|
for identity in selected:
|
|
bounded[target + '::' + identity[len(prefix):]] = [identity]
|
|
else:
|
|
bounded[unit] = selected
|
|
units = bounded
|
|
weights = {}
|
|
for unit, selected in units.items():
|
|
target = unit.split('::', 1)[0]
|
|
if target in policy['function_groups']:
|
|
per_case = policy['function_groups'][target]['estimated_seconds_per_case']
|
|
elif target in policy['modules']:
|
|
measured = policy['modules'][target]
|
|
per_case = measured['seconds'] / measured['cases']
|
|
else:
|
|
per_case = policy['unknown_seconds_per_case']
|
|
weights[unit] = per_case * len(selected)
|
|
shards, loads = [[] for _ in range(count)], [0.0] * count
|
|
for unit in sorted(units, key=lambda unit: (-weights[unit], unit)):
|
|
index = min(range(count), key=lambda index: (loads[index], index))
|
|
shards[index].append(unit)
|
|
loads[index] += weights[unit]
|
|
return shards
|
|
|
|
|
|
def release_result_allowed(record):
|
|
# Preserve the unsharded RELEASE runner's original unittest/pytest skip
|
|
# semantics. Skipped cases stay collected, source-bound and counted as
|
|
# skipped, never passed. Failures, errors and xfails remain unsuccessful.
|
|
return record['status'] in ('passed', 'skipped')
|
|
|
|
|
|
def release_matches(identity, target):
|
|
if identity.startswith('opendbc.'):
|
|
identity = 'opendbc_repo.' + identity
|
|
module, *selectors = target.split('::')
|
|
if not module.endswith('.py') or module.startswith('/') or '..' in module.split('/'):
|
|
raise ValueError('Invalid RELEASE target')
|
|
prefix = module.removesuffix('.py').replace('/', '.')
|
|
if selectors:
|
|
prefix += '.' + '.'.join(selectors)
|
|
return identity == prefix or identity.startswith((prefix + '.', prefix + '['))
|
|
return identity.startswith(prefix + '.')
|
|
|
|
|
|
def release_partition(targets, identities, count):
|
|
"""Keep every declared module/class/function intact; balance by actual case count."""
|
|
if type(count) is not int or not 1 <= count <= 16 or len(set(targets)) != len(targets) or len(set(identities)) != len(identities):
|
|
raise ValueError('Invalid RELEASE catalog')
|
|
groups = {target: [] for target in targets}
|
|
for identity in identities:
|
|
owners = [target for target in targets if release_matches(identity, target)]
|
|
if len(owners) != 1:
|
|
raise ValueError('RELEASE case needs exactly one declared owner')
|
|
groups[owners[0]].append(identity)
|
|
if not groups or any(not cases for cases in groups.values()):
|
|
raise ValueError('RELEASE target collected no cases')
|
|
shards, loads = [[] for _ in range(count)], [0] * count
|
|
for target in sorted(groups, key=lambda target: (-len(groups[target]), target)):
|
|
index = min(range(count), key=lambda i: (loads[i], i))
|
|
shards[index].append(target)
|
|
loads[index] += len(groups[target])
|
|
return shards
|
|
|
|
|
|
def aggregate(directory, count, suite='aol-lifecycle'):
|
|
if suite not in ('aol-lifecycle', 'safety-release'):
|
|
raise ValueError('Invalid aggregate suite')
|
|
partitioner = release_partition if suite == 'safety-release' else partition
|
|
matcher = release_matches if suite == 'safety-release' else matches
|
|
native_kinds = ('safety',) if suite == 'safety-release' else ('safety', 'aol_protocol')
|
|
documents = [json.loads(path.read_text()) for path in sorted(Path(directory).rglob('coverage.json'))]
|
|
if len(documents) != count:
|
|
raise ValueError('Missing or duplicate lifecycle shard artifacts')
|
|
first = documents[0]
|
|
full = first['full_catalog_test_ids']
|
|
targets = first['full_catalog_targets']
|
|
expected = partitioner(targets, full, count)
|
|
seen, indices = [], set()
|
|
passed_count = skipped_count = 0
|
|
for doc in documents:
|
|
index = doc['shard']['index']
|
|
if index in indices or not 0 <= index < count or doc['shard']['count'] != count:
|
|
raise ValueError('Invalid lifecycle shard index')
|
|
indices.add(index)
|
|
if doc['suite'] != suite or doc['status'] != 'completed' or doc['exit_code'] != 0:
|
|
raise ValueError('Lifecycle shard failed')
|
|
if doc['collection_errors'] or doc['gate_errors']:
|
|
raise ValueError('Lifecycle shard errors')
|
|
if doc['full_catalog_test_ids'] != full or doc['full_catalog_targets'] != targets or doc['targets'] != expected[index]:
|
|
raise ValueError('Lifecycle catalog or partition changed')
|
|
for key in ('revision', 'manifest_sha256', 'dependencies', 'runner_sha256', 'suite_runner_sha256', 'pytest_runner_sha256', 'shard_runner_sha256'):
|
|
if doc['source'][key] != first['source'][key]:
|
|
raise ValueError(f'Lifecycle source disagreement: {key}')
|
|
if not same_build_inputs(doc['source']['build_identity'], first['source']['build_identity']):
|
|
raise ValueError('Lifecycle source or native build inputs differ across shards')
|
|
if doc['source']['build_identity'] != doc['source_after']['build_identity']:
|
|
raise ValueError('Lifecycle source or declared native outputs changed during execution')
|
|
if doc['source']['inputs'] != doc['source_after']['inputs']:
|
|
raise ValueError('Lifecycle source changed during execution')
|
|
can_paths = {'opendbc_repo/opendbc/can/packer.py', 'opendbc_repo/opendbc/can/parser.py'}
|
|
can_sources = doc['native'].get('can_sources', {})
|
|
if set(can_sources) != can_paths or can_sources != first['native'].get('can_sources'):
|
|
raise ValueError('Tracked CAN import origins changed')
|
|
for name, checksum in can_sources.items():
|
|
if doc['source']['build_identity']['inputs']['tracked_inputs'][name]['sha256'] != checksum:
|
|
raise ValueError('Imported CAN implementation differs from source inputs')
|
|
capnp = doc['native'].get('pycapnp', {})
|
|
if capnp.get('sha256') != first['native'].get('pycapnp', {}).get('sha256') or len(capnp.get('sha256', '')) != 64:
|
|
raise ValueError('Native pycapnp import provenance changed')
|
|
if suite == 'safety-release' and (doc['native']['safety'].get('variant') != 'release' or
|
|
doc['native']['safety'].get('all_output_hook_available') is not False):
|
|
raise ValueError('RELEASE shard must use the actual release registry')
|
|
for key in native_kinds:
|
|
if not doc['native'][key]['sha256']:
|
|
raise ValueError('Missing genuine native build evidence')
|
|
ids = doc['selected_test_ids']
|
|
coverage_path = next(path for path in Path(directory).rglob('coverage.json')
|
|
if json.loads(path.read_text())['shard']['index'] == index)
|
|
build_input_digest = digest(json.dumps(doc['source']['build_identity']['inputs'], sort_keys=True).encode())
|
|
for kind in native_kinds:
|
|
native = doc['native'][kind]
|
|
if native['build_inputs_sha256'] != build_input_digest:
|
|
raise ValueError('Native build input source provenance changed')
|
|
artifact = coverage_path.with_name(Path(native['path']).name)
|
|
if not artifact.is_file() or digest(artifact.read_bytes()) != native['sha256']:
|
|
raise ValueError('Missing or modified genuine native artifact')
|
|
command = native.get('commands') if kind == 'safety' else native.get('command')
|
|
if not command:
|
|
raise ValueError('Missing actual native builder command')
|
|
result_path = coverage_path.with_name('results.json')
|
|
if doc['result_sha256'] != hashlib.sha256(result_path.read_bytes()).hexdigest():
|
|
raise ValueError('Lifecycle outcome artifact changed')
|
|
result = json.loads(result_path.read_text())
|
|
records = result['results']
|
|
if result['exit_code'] != 0 or result['collection_errors'] or result['gate_errors']:
|
|
raise ValueError('Lifecycle outcome report failed')
|
|
allowed = release_result_allowed if suite == 'safety-release' else lambda row: row['status'] == 'passed'
|
|
if result['selected_test_ids'] != ids or Counter(row['id'] for row in records) != Counter(ids) or any(not allowed(row) for row in records):
|
|
raise ValueError('Lifecycle missing, duplicate or unsuccessful outcomes')
|
|
if ids != [identity for identity in full if any(matcher(identity, target) for target in expected[index])]:
|
|
raise ValueError('Lifecycle shard cases differ from the full catalog')
|
|
passed_count += sum(row['status'] == 'passed' for row in records)
|
|
skipped_count += sum(row['status'] == 'skipped' for row in records)
|
|
seen.extend(ids)
|
|
if Counter(seen) != Counter(full) or len(set(full)) != len(full):
|
|
raise ValueError('Lifecycle aggregate does not cover every case exactly once')
|
|
return {'status': 'completed', 'shards': count, 'cases_passed': passed_count, 'cases_skipped': skipped_count,
|
|
'cases_total': len(full), 'targets': len(targets),
|
|
'source_revision': first['source']['revision']}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('directory', type=Path)
|
|
parser.add_argument('--count', type=int, default=4)
|
|
parser.add_argument('--suite', choices=('aol-lifecycle', 'safety-release'), default='aol-lifecycle')
|
|
parser.add_argument('--output', type=Path, required=True)
|
|
args = parser.parse_args()
|
|
result = aggregate(args.directory, args.count, args.suite)
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_text(json.dumps(result, indent=2) + '\n')
|
|
print(json.dumps(result))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|