install-query-skills.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. """Install the two query skills from a verified package; default is a read-only plan.
  2. Only checksummed skill files are managed. Credentials, employee state, databases,
  3. gateway configuration and services are never installed or modified here.
  4. """
  5. import argparse
  6. import hashlib
  7. import json
  8. import os
  9. from pathlib import Path
  10. import shutil
  11. import tempfile
  12. NAMES = ('ksf-workshop-runtime', 'ksf-voc-library')
  13. def digest(path):
  14. with path.open('rb') as stream:
  15. return hashlib.file_digest(stream, 'sha256').hexdigest()
  16. def plain(path):
  17. path = Path(path).absolute()
  18. for ancestor in (path, *path.parents):
  19. if ancestor.exists() or ancestor.is_symlink():
  20. if ancestor.is_symlink() or ancestor.resolve() != ancestor:
  21. raise ValueError('REDIRECTED_PATH')
  22. return path
  23. def snapshot(folder):
  24. if not folder.exists():
  25. return {}
  26. result = {}
  27. for path in folder.rglob('*'):
  28. plain(path)
  29. if path.is_file():
  30. result[path.relative_to(folder).as_posix()] = digest(path)
  31. return result
  32. def contents(folder):
  33. plain(folder)
  34. entries = {}
  35. for line in (folder / 'FILES.sha256').read_text(encoding='utf-8').splitlines():
  36. sha, rel = line.split(' ', 1)
  37. parts = Path(rel).parts
  38. if len(sha) != 64 or any(ch not in '0123456789abcdef' for ch in sha) or not parts or Path(rel).is_absolute() or '..' in parts or rel in entries or rel in ('FILES.sha256', 'credential-binding.json'):
  39. raise ValueError('INVALID_SKILL_MANIFEST')
  40. if digest(plain(folder / rel)) != sha:
  41. raise ValueError('PACKAGE_HASH_MISMATCH')
  42. entries[rel] = sha
  43. entries['FILES.sha256'] = digest(folder / 'FILES.sha256')
  44. if snapshot(folder) != entries:
  45. raise ValueError('UNEXPECTED_SKILL_FILES')
  46. return entries
  47. def install(package, data_root, apply=False):
  48. package, data_root = plain(package), plain(data_root)
  49. if package == data_root or package in data_root.parents:
  50. raise ValueError('NESTED_PACKAGE_AND_DATA_ROOT')
  51. baseline = json.loads((package / 'baselines.json').read_text(encoding='utf-8'))
  52. entries, changes, targets = {}, [], {}
  53. for name in NAMES:
  54. source = package / 'skills' / name
  55. target = plain(data_root / 'skills' / name)
  56. if package == target or package in target.parents or target in package.parents:
  57. raise ValueError('NESTED_PACKAGE_AND_SKILL')
  58. wanted = contents(source)
  59. entries[name], targets[name] = wanted, target
  60. if target.exists() and not target.is_dir():
  61. raise ValueError('TARGET_NOT_DIRECTORY')
  62. actual = {rel: digest(plain(target / rel)) for rel in wanted if (target / rel).is_file()}
  63. if target.exists() and actual != wanted and actual not in baseline[name]:
  64. raise ValueError('UNKNOWN_SKILL_BASELINE:' + name)
  65. if not target.exists() and name == 'ksf-workshop-runtime':
  66. raise ValueError('WORKSHOP_INSTANCE_NOT_PROVISIONED')
  67. for rel, sha in wanted.items():
  68. if actual.get(rel) != sha:
  69. changes.append((name, rel, actual.get(rel), sha))
  70. state = data_root / 'ksf-workshop-state'
  71. binding = targets['ksf-workshop-runtime'] / 'credential-binding.json'
  72. before_state = snapshot(state)
  73. before_binding = digest(plain(binding)) if binding.is_file() else None
  74. result = dict(ok=True, readOnly=not apply, applied=False,
  75. changed=[name + '/' + rel for name, rel, _, _ in changes],
  76. filesChecked=sum(len(files) for files in entries.values()),
  77. databaseWrites=0, databaseChecked=False, networkRequests=0,
  78. credentialBindingPreserved=True, stateDirectoryPreserved=True,
  79. gatewayModified=False, discoveryVerified=False, realMessageVerified=False)
  80. if not apply or not changes:
  81. return result
  82. data_root.mkdir(parents=True, exist_ok=True)
  83. backup = Path(tempfile.mkdtemp(prefix='ksf-query-skills-backup-', dir=data_root))
  84. os.chmod(backup, 0o700)
  85. installed = []
  86. try:
  87. for name, rel, before, after in changes:
  88. source, target = package / 'skills' / name / rel, targets[name] / rel
  89. if (digest(plain(target)) if target.is_file() else None) != before:
  90. raise ValueError('CONCURRENT_SKILL_CHANGE')
  91. mode = target.stat().st_mode & 0o777 if target.is_file() else 0o600
  92. previous = backup / name / rel
  93. if before:
  94. previous.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
  95. shutil.copy2(target, previous)
  96. target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
  97. fd, temporary = tempfile.mkstemp(prefix='.query-skill-', dir=target.parent)
  98. temporary = Path(temporary)
  99. try:
  100. with os.fdopen(fd, 'wb') as stream:
  101. stream.write(source.read_bytes())
  102. stream.flush()
  103. os.fsync(stream.fileno())
  104. os.chmod(temporary, mode)
  105. if digest(temporary) != after:
  106. raise ValueError('STAGED_HASH_MISMATCH')
  107. if (digest(plain(target)) if target.is_file() else None) != before:
  108. raise ValueError('CONCURRENT_SKILL_CHANGE')
  109. os.replace(temporary, target)
  110. installed.append((target, previous, before, after))
  111. finally:
  112. temporary.unlink(missing_ok=True)
  113. for name, wanted in entries.items():
  114. if any(digest(plain(targets[name] / rel)) != sha for rel, sha in wanted.items()):
  115. raise ValueError('INSTALLED_HASH_MISMATCH')
  116. if snapshot(state) != before_state or (digest(plain(binding)) if binding.is_file() else None) != before_binding:
  117. raise ValueError('STATE_OR_BINDING_CHANGED')
  118. except Exception:
  119. for target, previous, before, after in reversed(installed):
  120. # Never overwrite a concurrent edit while rolling back our files.
  121. if target.is_file() and digest(target) == after:
  122. if before:
  123. shutil.copy2(previous, target)
  124. else:
  125. target.unlink()
  126. raise
  127. result.update(applied=True, backup=str(backup), filesystemVerified=True)
  128. return result
  129. if __name__ == '__main__':
  130. parser = argparse.ArgumentParser(description=__doc__)
  131. parser.add_argument('--package', required=True)
  132. parser.add_argument('--data-root', default='/opt/data')
  133. mode = parser.add_mutually_exclusive_group()
  134. mode.add_argument('--check', action='store_true')
  135. mode.add_argument('--apply', action='store_true')
  136. args = parser.parse_args()
  137. try:
  138. print(json.dumps(install(args.package, args.data_root, args.apply), ensure_ascii=False, indent=2))
  139. except (ValueError, OSError, KeyError) as error:
  140. print(json.dumps(dict(ok=False, error=str(error)), ensure_ascii=False))
  141. raise SystemExit(1)