host_bootstrap.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
  1. """Run the sealed installer in one enterprise-matched container after normal twin initialization."""
  2. import argparse
  3. import base64
  4. import json
  5. from pathlib import Path
  6. import re
  7. import subprocess
  8. import sys
  9. import uuid
  10. sys.dont_write_bytecode = True
  11. from bootstrap import HERE, Stopped, check_enterprise, need, plain, sealed
  12. def run(args, runner=subprocess.run, input_bytes=None):
  13. result = runner(args, input=input_bytes, capture_output=True, timeout=240)
  14. need(result.returncode == 0, 'HOST_COMMAND_FAILED')
  15. return result.stdout
  16. def initialize(checkout, commit, container, apply=False, runner=subprocess.run):
  17. checkout = plain(checkout)
  18. need(re.fullmatch('[a-f0-9]{40}', commit), 'PINNED_COMMIT_REQUIRED')
  19. need(re.fullmatch('[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}', container), 'INVALID_CONTAINER_NAME')
  20. cfg, files = sealed(checkout)
  21. actual = run(['git', '-C', str(checkout), 'rev-parse', 'HEAD'], runner).decode().strip()
  22. need(actual == commit, 'CONFIGURATION_COMMIT_MISMATCH')
  23. need(not run(['git', '-C', str(checkout), 'status', '--porcelain'], runner).strip(),
  24. 'CONFIGURATION_CHECKOUT_CHANGED')
  25. prefix = ['docker', 'exec', '--user', '10000:10000', container]
  26. need(run([*prefix, 'id', '-u'], runner).strip() == b'10000', 'CONTAINER_USER_MISMATCH')
  27. current = json.loads(run([*prefix, 'cat', '/opt/data/.fmode/config/enterprise/current.json'], runner))
  28. check_enterprise(current, cfg)
  29. # A different enterprise is rejected before docker cp or any installation writes.
  30. if not apply:
  31. return {'ok': True, 'planOnly': True, 'companyObjectId': cfg['companyObjectId'],
  32. 'configurationCommit': commit, 'container': container, 'businessWrites': 0}
  33. # Transfer only public files over stdin, written by hermes. No root extraction or .git copying.
  34. copy_code = '''import base64,json,os,pathlib,sys,tempfile
  35. raw=sys.stdin.buffer.read(16000001)
  36. assert len(raw)<=16000000
  37. files=json.loads(raw)
  38. base=pathlib.Path('/opt/data')
  39. assert all(not x.is_symlink() for x in (base,*base.parents))
  40. root=pathlib.Path(tempfile.mkdtemp(prefix='ksf-enterprise-package-',dir=base))
  41. for name,body in files.items():
  42. parts=pathlib.PurePosixPath(name).parts
  43. assert name and name=='/'.join(parts) and not name.startswith('/')
  44. assert all(p not in ('.','..') for p in parts) and '\\\\' not in name and ':' not in name
  45. file=root/name
  46. file.parent.mkdir(parents=True,exist_ok=True,mode=0o700)
  47. with file.open('xb') as out: out.write(base64.b64decode(body,validate=True))
  48. file.chmod(0o644)
  49. print(str(root))
  50. '''
  51. wire = json.dumps({n: base64.b64encode((checkout / n).read_bytes()).decode()
  52. for n in [*files, 'FILES.sha256']}).encode()
  53. target = run(['docker', 'exec', '--user', '10000:10000', '-i', container,
  54. 'python3', '-B', '-c', copy_code], runner, wire).decode().strip()
  55. need(re.fullmatch('/opt/data/ksf-enterprise-package-[a-zA-Z0-9_-]+', target), 'PACKAGE_TRANSFER_PATH_INVALID')
  56. out = '/opt/data/ksf-enterprise-results/' + uuid.uuid4().hex
  57. results = []
  58. for command in ('plan', 'apply', 'verify'):
  59. value = json.loads(run([*prefix, 'python3', '-B', target + '/bootstrap.py', command,
  60. '--data-dir', '/opt/data', '--out', out], runner))
  61. need(value.get('ok') is True, 'CONTAINER_INSTALL_FAILED')
  62. results.append(value)
  63. voc_results = []
  64. for command in ('plan', 'apply', 'verify'):
  65. value = json.loads(run([*prefix, 'python3', '-B', target + '/voc.py', command,
  66. '--data-dir', '/opt/data', '--out', out + '/voc'], runner))
  67. need(value.get('ok') is True, 'CONTAINER_VOC_INSTALL_FAILED')
  68. voc_results.append(value)
  69. return {'ok': True, 'container': container, 'companyObjectId': cfg['companyObjectId'],
  70. 'configurationCommit': commit, 'resultsDirectory': out, 'packageDirectory': target,
  71. 'installation': results[-1], 'voc': voc_results[-1],
  72. 'vocModified': voc_results[1].get('vocModified', False), 'authorizationProvisioned': False,
  73. 'automaticLifecycleHookInstalled': False, 'businessWrites': 0}
  74. def main():
  75. parser = argparse.ArgumentParser(description=__doc__)
  76. parser.add_argument('--checkout', required=True, type=Path)
  77. parser.add_argument('--commit', required=True)
  78. parser.add_argument('--container', required=True)
  79. parser.add_argument('--apply', action='store_true')
  80. args = parser.parse_args()
  81. need(args.checkout.is_absolute(), 'ABSOLUTE_PATH_REQUIRED')
  82. print(json.dumps(initialize(args.checkout, args.commit, args.container, args.apply),
  83. ensure_ascii=False, indent=2))
  84. if __name__ == '__main__':
  85. try:
  86. main()
  87. except (Stopped, OSError, ValueError, KeyError, subprocess.TimeoutExpired) as error:
  88. print(json.dumps({'ok': False, 'error': {'code': str(error) if isinstance(error, Stopped)
  89. else 'HOST_BOOTSTRAP_STOPPED'}}), file=sys.stderr)
  90. sys.exit(1)