offline_release.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. """Build and validate a single offline transfer archive, including its private dotenv."""
  2. import argparse
  3. import hashlib
  4. import json
  5. import os
  6. from pathlib import Path, PurePosixPath
  7. import subprocess
  8. import tempfile
  9. import zipfile
  10. ROOT = Path(__file__).resolve().parents[1]
  11. RELEASE_MANIFEST = '.offline-release.json'
  12. SNAPSHOT_TARGET = 'output/knowledge-snapshot.zip'
  13. TOKENIZER_TARGET = 'models/deepseek-tokenizer/tokenizer.json'
  14. def checksum(stream):
  15. digest = hashlib.sha256()
  16. length = 0
  17. while block := stream.read(1024 * 1024):
  18. digest.update(block)
  19. length += len(block)
  20. return digest.hexdigest(), length
  21. def safe_name(name):
  22. path = PurePosixPath(name)
  23. return bool(name and not name.startswith('/') and '\\' not in name and
  24. all(part not in ('', '.', '..') for part in path.parts) and
  25. path.as_posix() == name)
  26. def code_files(root):
  27. output = subprocess.run(['git', 'ls-files', '-z', '--cached', '--others', '--exclude-standard'],
  28. cwd=root, check=True, capture_output=True).stdout
  29. paths = []
  30. for raw in output.split(b'\0'):
  31. if not raw:
  32. continue
  33. name = os.fsdecode(raw).replace('\\', '/')
  34. if not safe_name(name) or name == RELEASE_MANIFEST:
  35. raise ValueError('invalid_project_path')
  36. if any(part == '.env' or part.startswith('.env.') for part in PurePosixPath(name).parts):
  37. continue
  38. path = root / name
  39. if path.is_file() and not path.is_symlink():
  40. paths.append((name, path))
  41. return dict(paths)
  42. def release_files(root, snapshot):
  43. root = Path(root).resolve()
  44. snapshot = Path(snapshot).resolve()
  45. knowledge_manifest = json.loads((root / 'knowledge/prepared/ingestion/manifest.json').read_text(encoding='utf-8'))
  46. if knowledge_manifest.get('complete') is not True:
  47. raise ValueError('knowledge_not_published')
  48. files = code_files(root)
  49. files['__main__.py'] = root / 'scripts/offline_release_bootstrap.py'
  50. files['.env'] = root / '.env'
  51. files['config/dms_schema.json'] = root / 'config/dms_schema.json'
  52. files[SNAPSHOT_TARGET] = snapshot
  53. files[TOKENIZER_TARGET] = root / TOKENIZER_TARGET
  54. for relative in knowledge_manifest['model_files']:
  55. normalized = relative.replace('\\', '/')
  56. name = 'models/bge-large-zh-v1.5/' + normalized
  57. if not safe_name(name):
  58. raise ValueError('invalid_model_path')
  59. files[name] = root / name
  60. if any(not path.is_file() for path in files.values()):
  61. raise ValueError('missing_release_file')
  62. return files
  63. def pack(root, snapshot, bundle):
  64. root, bundle = Path(root).resolve(), Path(bundle)
  65. if bundle.exists():
  66. raise ValueError('release_already_exists')
  67. files = release_files(root, snapshot)
  68. metadata = {}
  69. for name, path in files.items():
  70. with path.open('rb') as stream:
  71. digest, length = checksum(stream)
  72. metadata[name] = {'sha256': digest, 'bytes': length}
  73. bundle.parent.mkdir(parents=True, exist_ok=True)
  74. partial = bundle.with_name(bundle.name + '.partial')
  75. if partial.exists():
  76. raise ValueError('partial_release_exists')
  77. try:
  78. with zipfile.ZipFile(partial, 'w', allowZip64=True) as archive:
  79. for name, path in sorted(files.items()):
  80. compression = zipfile.ZIP_STORED if path.suffix in ('.bin', '.zip', '.whl') else zipfile.ZIP_DEFLATED
  81. archive.write(path, name, compress_type=compression)
  82. archive.writestr(RELEASE_MANIFEST, json.dumps({'schema': 1, 'files': metadata}, sort_keys=True))
  83. verify(partial)
  84. partial.replace(bundle)
  85. finally:
  86. partial.unlink(missing_ok=True)
  87. return {'files': len(files), 'bytes': sum(row['bytes'] for row in metadata.values())}
  88. def verify(bundle):
  89. with zipfile.ZipFile(bundle) as archive:
  90. manifest_info = archive.getinfo(RELEASE_MANIFEST)
  91. if manifest_info.file_size > 32_000_000:
  92. raise ValueError('release_manifest_too_large')
  93. manifest = json.loads(archive.read(RELEASE_MANIFEST))
  94. files = manifest.get('files')
  95. if manifest.get('schema') != 1 or not isinstance(files, dict) or not files:
  96. raise ValueError('invalid_release_manifest')
  97. if (len(archive.infolist()) != len(files) + 1 or
  98. set(archive.namelist()) != set(files) | {RELEASE_MANIFEST} or
  99. any(not safe_name(name) or (name != '.env' and any(
  100. part == '.env' or part.startswith('.env.') for part in PurePosixPath(name).parts))
  101. for name in files)):
  102. raise ValueError('unexpected_release_members')
  103. for name, expected in files.items():
  104. info = archive.getinfo(name)
  105. if info.is_dir() or info.file_size != expected['bytes']:
  106. raise ValueError('release_size_mismatch')
  107. with archive.open(name) as stream:
  108. digest, length = checksum(stream)
  109. if (digest, length) != (expected['sha256'], expected['bytes']):
  110. raise ValueError('release_hash_mismatch')
  111. return {'files': len(files), 'bytes': sum(row['bytes'] for row in files.values())}
  112. def extract(bundle, target):
  113. report = verify(bundle)
  114. target = Path(target).resolve()
  115. target.mkdir(parents=True, exist_ok=True)
  116. with zipfile.ZipFile(bundle) as archive:
  117. names = [name for name in archive.namelist() if name != RELEASE_MANIFEST]
  118. if any((target / name).resolve().is_relative_to(target) is False for name in names):
  119. raise ValueError('unsafe_release_target')
  120. if any((target / name).exists() for name in names):
  121. raise ValueError('release_target_not_empty')
  122. for name in names:
  123. destination = target / name
  124. destination.parent.mkdir(parents=True, exist_ok=True)
  125. fd, temporary = tempfile.mkstemp(prefix='.release-', dir=destination.parent)
  126. try:
  127. with os.fdopen(fd, 'wb') as output, archive.open(name) as source:
  128. while block := source.read(1024 * 1024):
  129. output.write(block)
  130. Path(temporary).replace(destination)
  131. finally:
  132. Path(temporary).unlink(missing_ok=True)
  133. return report
  134. def main():
  135. parser = argparse.ArgumentParser(description=__doc__)
  136. actions = parser.add_subparsers(dest='action', required=True)
  137. create = actions.add_parser('pack')
  138. create.add_argument('snapshot', type=Path)
  139. create.add_argument('bundle', type=Path)
  140. check = actions.add_parser('verify')
  141. check.add_argument('bundle', type=Path)
  142. unpack = actions.add_parser('extract')
  143. unpack.add_argument('bundle', type=Path)
  144. unpack.add_argument('target', type=Path)
  145. args = parser.parse_args()
  146. try:
  147. if args.action == 'pack':
  148. report = pack(ROOT, args.snapshot, args.bundle)
  149. elif args.action == 'verify':
  150. report = verify(args.bundle)
  151. else:
  152. report = extract(args.bundle, args.target)
  153. print(json.dumps({'ok': True, 'action': args.action, **report}))
  154. return 0
  155. except (OSError, ValueError, KeyError, TypeError, subprocess.CalledProcessError, zipfile.BadZipFile):
  156. print(json.dumps({'ok': False, 'action': args.action, 'code': 'offline_release_invalid'}))
  157. return 1
  158. if __name__ == '__main__':
  159. raise SystemExit(main())