upgrade-platform.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. """Strict known-baseline, documentation-only upgrade of the original workshop Agent."""
  2. import argparse
  3. import importlib.util
  4. import json
  5. import os
  6. from pathlib import Path
  7. import shutil
  8. import sys
  9. HERE = Path(__file__).resolve().parent
  10. def core_at(package):
  11. spec = importlib.util.spec_from_file_location('workshop_installer_core', package / 'install-core.py')
  12. module = importlib.util.module_from_spec(spec)
  13. spec.loader.exec_module(module)
  14. return module
  15. def snapshot(core, target):
  16. values = {}
  17. for path in sorted(target.rglob('*')):
  18. core.plain_path(path)
  19. if path.is_file():
  20. values[path.relative_to(target).as_posix()] = {
  21. 'sha256': core.digest(path.read_bytes()), 'mode': path.stat().st_mode & 0o777}
  22. else:
  23. core.require(path.is_dir(), 'SPECIAL_FILE_NOT_SUPPORTED')
  24. return values
  25. def write(core, path, value):
  26. path.write_bytes(core.encode(value))
  27. path.chmod(0o600)
  28. def load(core, package):
  29. package = core.plain_path(package)
  30. for line in (package / 'FILES.sha256').read_text('utf-8').splitlines():
  31. h, name = line.split(' ', 1)
  32. core.require(core.safe_name(name), 'INVALID_PACKAGE_PATH')
  33. p = core.plain_path(package / name)
  34. core.require(p.is_file() and core.digest(p.read_bytes()) == h, 'PACKAGE_HASH_MISMATCH')
  35. meta = json.loads((package / 'release.json').read_bytes())
  36. core.require(meta['schema'] == 'ksf.workshop-platform-upgrade.v1', 'INVALID_RELEASE')
  37. candidate = meta['candidate']
  38. core.require(len(candidate) == 43 and candidate['SKILL.md'] ==
  39. '8757e3b58b461deb70201469f2e3579a7f03e5e6204c94aa327e143aa9b44bb3', 'UNKNOWN_TARGET')
  40. content = {}
  41. for name, expected in candidate.items():
  42. core.require(core.safe_name(name), 'INVALID_PACKAGE_PATH')
  43. data = core.plain_path(package / 'skill' / name).read_bytes()
  44. core.require(core.digest(data) == expected, 'CANDIDATE_HASH_MISMATCH')
  45. content[name] = data
  46. protected = {n for n in candidate if n.startswith('scripts/') or n == 'runtime-lock.json'}
  47. core.require(len(protected) == 7 and set(meta['protectedFiles']) == protected, 'PROTECTED_FILE_LAYOUT')
  48. for baseline in meta['baselines'].values():
  49. core.require(all(baseline.get(n) == candidate[n] for n in protected), 'PROTECTED_RUNTIME_CHANGED')
  50. return meta, content
  51. def run(command, target, output, package=HERE):
  52. package = Path(package).resolve()
  53. core = core_at(package)
  54. core.require(Path(target).is_absolute() and Path(output).is_absolute(), 'ABSOLUTE_PATH_REQUIRED')
  55. target, output = core.plain_path(target), core.plain_path(output)
  56. core.require(target.is_dir() and target.name == 'ksf-workshop-runtime', 'EXISTING_WORKSHOP_TARGET_REQUIRED')
  57. core.require(not output.is_relative_to(target.parent) and not package.is_relative_to(target)
  58. and not output.is_relative_to(package), 'RESULT_TARGET_OVERLAP')
  59. core.plain_path(target / 'credential-binding.json')
  60. core.require((target / 'credential-binding.json').is_file(), 'EXISTING_BINDING_REQUIRED')
  61. if os.name != 'nt':
  62. core.require(os.geteuid() == target.stat().st_uid, 'TARGET_OWNER_REQUIRED')
  63. core.require((target / 'credential-binding.json').stat().st_mode & 0o777 == 0o600,
  64. 'PRIVATE_BINDING_PERMISSIONS')
  65. meta, content = load(core, package)
  66. files = {'ksf-workshop-runtime': content}
  67. if command == 'inventory':
  68. public = {n: core.digest(core.plain_path(target / n).read_bytes())
  69. if core.plain_path(target / n).is_file() else None for n in content}
  70. return {'ok': True, 'readOnly': True, 'publicFilesOnly': True,
  71. 'files': public, 'bindingIncluded': False, 'businessWrites': 0}
  72. if command == 'plan':
  73. core.require(not output.exists(), 'NEW_RESULT_DIRECTORY_REQUIRED')
  74. actions = core.plan(target.parent, files, meta['baselines'])
  75. output.mkdir(parents=True, mode=0o700)
  76. output.chmod(0o700)
  77. record = {'schema': 'ksf.workshop-platform-upgrade-plan.v1', 'releaseId': meta['releaseId'],
  78. 'target': str(target), 'candidate': meta['candidate'], 'actions': actions,
  79. 'before': snapshot(core, target)}
  80. write(core, output / 'plan.json', record)
  81. action = actions[0]
  82. baseline = action.get('before', meta['candidate'])
  83. changed = [n for n in content if baseline.get(n) != meta['candidate'][n]]
  84. result = {'ok': True, 'readOnly': True, 'action': action['action'],
  85. 'baselineId': action.get('fromVersion', 'r10'), 'changes': len(changed),
  86. 'creates': len(set(content) - set(baseline)), 'changedFiles': changed,
  87. 'protectedFilesModified': 0, 'vocModified': False, 'businessWrites': 0}
  88. write(core, output / 'plan-summary.json', result)
  89. return result
  90. record = json.loads((output / 'plan.json').read_bytes())
  91. core.require(record['target'] == str(target) and record['candidate'] == meta['candidate'], 'PLAN_MISMATCH')
  92. def verify():
  93. core.verify_component(target, content)
  94. after = snapshot(core, target)
  95. for name, old in record['before'].items():
  96. if name not in content or name in meta['protectedFiles']:
  97. core.require(after.get(name) == old, 'PRESERVED_FILE_CHANGED')
  98. return {'ok': True, 'workshopVersion': 'r10', 'publicFilesVerified': 43,
  99. 'protectedFilesModified': 0, 'localFilesPreserved': True,
  100. 'agentLoaded': False, 'vocModified': False, 'businessWrites': 0}
  101. if command == 'verify':
  102. result = verify()
  103. write(core, output / 'verify.json', result)
  104. return result
  105. core.require(command == 'apply', 'UNKNOWN_COMMAND')
  106. if (output / 'apply.json').exists():
  107. old = json.loads((output / 'apply.json').read_bytes())
  108. core.require(old.get('ok') is True, 'PRIOR_APPLY_REQUIRES_REVIEW')
  109. return {**verify(), 'alreadyApplied': True}
  110. core.require(snapshot(core, target) == record['before'], 'TARGET_CHANGED_SINCE_PLAN')
  111. actions = core.plan(target.parent, files, meta['baselines'])
  112. core.require(actions == record['actions'], 'PLAN_CHANGED')
  113. if actions[0]['action'] == 'verify-only':
  114. return {**verify(), 'alreadyApplied': True}
  115. backup = output / 'before-full'
  116. core.require(not backup.exists(), 'PRIOR_BACKUP_REQUIRES_REVIEW')
  117. shutil.copytree(target, backup, copy_function=shutil.copy2)
  118. backup.chmod(0o700)
  119. core.require(snapshot(core, backup) == record['before'], 'BACKUP_VERIFY_FAILED')
  120. core.require(snapshot(core, target) == record['before'], 'TARGET_CHANGED_SINCE_PLAN')
  121. try:
  122. core.install_files(target.parent, files, actions, output / 'before-changed')
  123. result = {**verify(), 'applied': True, 'fullBackupVerified': True}
  124. write(core, output / 'apply.json', result)
  125. return result
  126. except Exception:
  127. restored = snapshot(core, target) == record['before']
  128. write(core, output / 'apply.json', {'ok': False, 'originalRestored': restored})
  129. raise
  130. if __name__ == '__main__':
  131. p = argparse.ArgumentParser(description=__doc__)
  132. p.add_argument('command', choices=['plan', 'apply', 'verify', 'inventory'])
  133. p.add_argument('--target-dir', required=True)
  134. p.add_argument('--out', required=True)
  135. args = p.parse_args()
  136. try:
  137. print(json.dumps(run(args.command, args.target_dir, args.out), ensure_ascii=False, indent=2))
  138. except Exception as exc:
  139. allowed = str(exc)
  140. code = allowed if allowed and all(c.isupper() or c.isdigit() or c == '_' for c in allowed) else 'PLATFORM_UPGRADE_STOPPED'
  141. print(json.dumps({'ok': False, 'errorCode': code}), file=sys.stderr)
  142. sys.exit(1)