Files
StarPilot/openpilot/starpilot/schema_cache_normalize.py
firestar5683 978105ad9b Project identity and attribution
Document StarPilot setup and third-party attribution. Keep cloud authentication scoped to the selected provider.

Co-authored-by: Daniel Koepping <elkoled@gmail.com>
Co-authored-by: Danny <dannyirwin31@yahoo.com>
Co-authored-by: firestarsdog <229254897+firestarsdog@users.noreply.github.com>
Co-authored-by: Adeeb Shihadeh <adeebshihadeh@gmail.com>
2026-10-14 19:56:17 -05:00

145 lines
6.9 KiB
Python

"""Explicit offline normalization of caches from this exact compiled schema."""
from datetime import UTC, datetime
import hashlib
import json
from pathlib import Path
import struct
import capnp
from openpilot.starpilot import schema_cache as cache
from openpilot.starpilot.state_migration import (
_atomic_write, _outside_params, _params_lock, _read_value, _save_snapshot, canonical_json, load_snapshot,
)
EVENT_KEYS = tuple(sorted(key for key, contract in cache.CONTRACTS.items() if contract.service is not None))
class NormalizationError(RuntimeError):
"""No incompatible cache is eligible; an I/O failure may leave a reported partial batch."""
class _EnvelopeSink:
def put(self, key, value, *, block):
self.raw = value
def _header(raw):
start = len(cache.MAGIC) + 4
size = struct.unpack_from('>I', raw, len(cache.MAGIC))[0]
return json.loads(raw[start:start + size])
def _values(value):
if isinstance(value, float):
return struct.pack('>d', value) # Preserve signed zero and nonfinite values during comparison.
if isinstance(value, dict):
return {key: _values(item) for key, item in value.items()}
if isinstance(value, list):
return [_values(item) for item in value]
return value
def _prepare(key, raw):
inspected = cache.inspect_cache(key, raw)
if inspected.status != 'valid' or inspected.payload is None:
raise NormalizationError(f'{key}: {inspected.status}: {inspected.reason}')
contract = cache.CONTRACTS[key]
if contract.service is None:
raise NormalizationError(f'{key}: only Event caches can be normalized')
try:
with contract.root.from_bytes(inspected.payload, traversal_limit_in_words=cache.MAX_PAYLOAD_BYTES // 8) as message:
# Event.to_dict suppresses union-member decode errors; decode the selected
# service explicitly before constructing a fresh typed message.
service_values = getattr(message, contract.service).to_dict()
decoded = message.to_dict()
if contract.service not in decoded or _values(decoded[contract.service]) != _values(service_values):
raise NormalizationError(f'{key}: selected service did not decode completely')
original_values = _values(decoded)
if _header(raw)['version'] == 2:
return raw
sink = _EnvelopeSink()
cache.put_cache(sink, key, contract.root.new_message(**decoded), block=True)
converted = cache.inspect_cache(key, sink.raw)
if converted.status != 'valid' or converted.payload is None or _header(sink.raw)['version'] != 2:
raise NormalizationError(f'{key}: current writer did not produce a valid v2 envelope')
with contract.root.from_bytes(converted.payload, traversal_limit_in_words=cache.MAX_PAYLOAD_BYTES // 8) as message:
if _values(message.to_dict()) != original_values:
raise NormalizationError(f'{key}: typed values changed during normalization')
except (ValueError, TypeError, capnp.KjException) as error:
raise NormalizationError(f'{key}: typed cache validation failed: {type(error).__name__}') from error
return sink.raw
def _read(path):
try:
return _read_value(path)
except FileNotFoundError:
return None
def _identity(raw):
return None if raw is None else {'sha256': hashlib.sha256(raw).hexdigest(), 'size': len(raw),
'version': _header(raw)['version'], 'schema_sha256': _header(raw)['schema_sha256']}
def normalize_current_event_caches(params, backup_dir, *, producers_stopped: bool = False):
"""Validate every present Event cache before backing up and writing any cache.
Run with cache producers stopped, before changing compiled schemas. This cannot
convert an incompatible v1 cache. It never changes CarParams or read-side APIs.
The Params lock covers comparisons and atomic replacements; each prepared
envelope comes from put_cache with a fully decoded current typed message.
"""
if producers_stopped is not True:
raise NormalizationError('Stop cache producers before explicitly normalizing caches')
namespace = Path(params.get_param_path(EVENT_KEYS[0])).parent.absolute()
storage = Path(backup_dir).absolute()
_outside_params(namespace, storage)
with _params_lock(namespace):
actual = namespace.resolve(strict=True)
originals = {key: _read(actual / key) for key in EVENT_KEYS}
prepared = {key: _prepare(key, raw) for key, raw in originals.items() if raw is not None}
changed = [key for key, raw in prepared.items() if raw != originals[key]]
report = {
'format': 'starpilot-current-event-cache-normalization', 'version': 1,
'recorded_utc': datetime.now(UTC).isoformat(), 'namespace': str(namespace),
'codec': {'path': cache.__file__, 'sha256': hashlib.sha256(Path(cache.__file__).read_bytes()).hexdigest()},
'normalizer': {'path': __file__, 'sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest()},
'status': 'unchanged', 'backup': None, 'attempted': [], 'written': [],
'caches': {key: {'before': _identity(raw), 'after': _identity(prepared.get(key)),
'action': 'normalize' if key in changed else 'missing' if raw is None else 'unchanged'}
for key, raw in originals.items()},
}
if not changed:
return report
backup = _save_snapshot({key: raw for key, raw in originals.items() if raw is not None}, storage)
if load_snapshot(backup) != {key: raw for key, raw in originals.items() if raw is not None}:
raise NormalizationError('Backup readback differs from original caches')
report.update(status='prepared', backup=str(backup))
receipt = backup / 'normalization.json'
_atomic_write(receipt, canonical_json(report))
try:
if namespace.resolve(strict=True) != actual or any(_read(actual / key) != raw for key, raw in originals.items()):
raise NormalizationError('Cache source changed after validation; no caches were written')
for key in changed:
if namespace.resolve(strict=True) != actual or _read(actual / key) != originals[key]:
raise NormalizationError(f'{key}: source changed before replacement')
report['attempted'].append(key)
_atomic_write(receipt, canonical_json(report))
_atomic_write(actual / key, prepared[key])
report['written'].append(key)
if _read(actual / key) != prepared[key] or cache.inspect_cache(key, prepared[key]).status != 'valid':
raise NormalizationError(f'{key}: exact cache readback failed')
if any(_read(actual / key) != prepared.get(key) for key in EVENT_KEYS):
raise NormalizationError('Final cache readback differs from the prepared batch')
report['status'] = 'normalized'
_atomic_write(receipt, canonical_json(report))
except Exception as error:
report.update(status='failed', error=f'{type(error).__name__}: {error}')
_atomic_write(receipt, canonical_json(report))
raise NormalizationError(f'Normalization stopped; originals and partial-write receipt: {backup}') from error
return report