gogs_setup.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. """Create the enterprise organization/config repository as its designated administrator."""
  2. import argparse
  3. import base64
  4. import json
  5. import os
  6. from pathlib import Path
  7. import subprocess
  8. import sys
  9. import tempfile
  10. import urllib.error
  11. import urllib.request
  12. sys.dont_write_bytecode = True
  13. from bootstrap import HERE, Stopped, need, plain, private_json, sealed, sha
  14. ORIGIN = 'https://git.fmode.cn'
  15. class Gogs:
  16. def __init__(self, token):
  17. self.token = token
  18. def call(self, method, route, body=None, missing=False):
  19. need(route.startswith('/api/v1/') and '..' not in route, 'INVALID_GOGS_ROUTE')
  20. request = urllib.request.Request(ORIGIN + route, method=method,
  21. headers={'Authorization': 'token ' + self.token,
  22. 'Content-Type': 'application/json'},
  23. data=None if body is None else json.dumps(body).encode())
  24. # Credentials may only go to this origin; Gogs API calls do not need redirects.
  25. class NoRedirect(urllib.request.HTTPRedirectHandler):
  26. def redirect_request(self, *args, **kwargs):
  27. return None
  28. try:
  29. with urllib.request.build_opener(NoRedirect()).open(request, timeout=30) as response:
  30. raw = response.read(2 * 1024 * 1024)
  31. return json.loads(raw) if raw else None
  32. except urllib.error.HTTPError as error:
  33. if error.code == 404 and missing:
  34. return None
  35. raise Stopped('GOGS_HTTP_' + str(error.code)) from None
  36. def lookup_organization(api, name):
  37. org = api.call('GET', '/api/v1/orgs/' + name, missing=True)
  38. if org:
  39. return org
  40. # A missing route is not evidence that an organization does not exist.
  41. mine = api.call('GET', '/api/v1/user/orgs')
  42. need(isinstance(mine, list), 'GOGS_ORGANIZATION_LIST_INVALID')
  43. matches = [row for row in mine if (row.get('username') or row.get('login')) == name]
  44. need(len(matches) <= 1, 'GOGS_ORGANIZATION_LIST_INVALID')
  45. if matches:
  46. return matches[0]
  47. # This also detects a same-name organization outside the administrator's memberships.
  48. account = api.call('GET', '/api/v1/users/' + name, missing=True)
  49. need(account is None, 'EXISTING_ORGANIZATION_MEMBERSHIP_UNVERIFIED')
  50. return None
  51. def inspect(api, cfg):
  52. who = api.call('GET', '/api/v1/user')
  53. need((who.get('username') or who.get('login')) == cfg['administrator'], 'GOGS_ADMINISTRATOR_MISMATCH')
  54. org = lookup_organization(api, cfg['organization'])
  55. owner_verified = False
  56. if org:
  57. teams = api.call('GET', '/api/v1/orgs/' + cfg['organization'] + '/teams', missing=True)
  58. if teams is not None:
  59. owners = [t for t in teams if t['name'] == 'Owners']
  60. need(len(owners) == 1, 'GOGS_OWNER_TEAM_MISSING')
  61. members = api.call('GET', '/api/v1/teams/' + str(owners[0]['id']) + '/members', missing=True)
  62. if members is None:
  63. members = api.call('GET', '/api/v1/admin/teams/' + str(owners[0]['id']) + '/members')
  64. need(any((m.get('username') or m.get('login')) == cfg['administrator'] for m in members),
  65. 'EXISTING_ORGANIZATION_OWNER_MISMATCH')
  66. owner_verified = True
  67. repo = api.call('GET', '/api/v1/repos/' + cfg['organization'] + '/' + cfg['repository'], missing=True)
  68. if repo:
  69. need(repo.get('private') is True and repo.get('owner', {}).get('username',
  70. repo.get('owner', {}).get('login')) == cfg['organization'], 'EXISTING_REPOSITORY_MISMATCH')
  71. return {'organizationExists': bool(org), 'repositoryExists': bool(repo),
  72. 'organizationOwnerVerified': owner_verified,
  73. 'ownerVerificationUnavailable': bool(org) and not owner_verified,
  74. 'administrator': cfg['administrator'], 'organization': cfg['organization'],
  75. 'repository': cfg['repository']}
  76. def git(args, cwd, env):
  77. result = subprocess.run(['git', *args], cwd=cwd, env=env, capture_output=True, timeout=120)
  78. need(result.returncode == 0, 'CONFIGURATION_GIT_OPERATION_FAILED')
  79. return result.stdout
  80. def repository_state(existing, root, files):
  81. expected = set(files) | {'FILES.sha256'}
  82. if set(existing) == expected and all(existing[n] == (root / n).read_bytes() for n in expected):
  83. return 'current'
  84. prior = json.loads((root / 'previous-release.json').read_bytes())['files']
  85. need(set(existing) == set(prior) and set(prior) <= expected
  86. and all(sha(existing[n]) == digest for n, digest in prior.items()),
  87. 'EXISTING_REPOSITORY_CONTENT_CHANGED')
  88. return 'known-r1'
  89. def create_organization(api, cfg):
  90. org = cfg['organization']
  91. description = '企业配置来源;companyObjectId=' + cfg['companyObjectId']
  92. try:
  93. return api.call('POST', '/api/v1/orgs',
  94. {'username': org, 'full_name': cfg['companyName'], 'description': description})
  95. except Stopped as error:
  96. if str(error) != 'GOGS_HTTP_404':
  97. raise
  98. # Gogs 0.13 exposes organization creation on the admin user route, with form field names.
  99. # An uncertain response or authorization failure never triggers a second write attempt.
  100. existing = lookup_organization(api, org)
  101. if existing is not None:
  102. return existing
  103. return api.call('POST', '/api/v1/admin/users/' + cfg['administrator'] + '/orgs',
  104. {'UserName': org, 'FullName': cfg['companyName'], 'Description': description})
  105. def setup(api, root, apply=False):
  106. root = plain(root)
  107. cfg, files = sealed(root)
  108. status = inspect(api, cfg)
  109. if not apply:
  110. return {'ok': True, 'planOnly': True, **status, 'productionConfigurationChanged': False}
  111. org, name = cfg['organization'], cfg['repository']
  112. if not status['organizationExists']:
  113. create_organization(api, cfg)
  114. # Revalidate ownership before creating a repository in an existing/new organization.
  115. status = inspect(api, cfg)
  116. need(status['organizationExists'] and status['organizationOwnerVerified'], 'GOGS_OWNER_VERIFICATION_UNAVAILABLE')
  117. if not status['repositoryExists']:
  118. api.call('POST', '/api/v1/org/' + org + '/repos',
  119. {'name': name, 'description': '康师傅工作坊配置与初始化组件;不存放凭据',
  120. 'private': True, 'auto_init': False})
  121. repo = api.call('GET', '/api/v1/repos/' + org + '/' + name)
  122. need(repo.get('private') is True, 'REPOSITORY_NOT_PRIVATE')
  123. env = dict(os.environ)
  124. env.update(GIT_TERMINAL_PROMPT='0', GIT_CONFIG_COUNT='2',
  125. GIT_CONFIG_KEY_0='http.' + ORIGIN + '/.extraheader',
  126. GIT_CONFIG_VALUE_0='Authorization: Basic ' + base64.b64encode(
  127. (cfg['administrator'] + ':' + api.token).encode()).decode(),
  128. GIT_CONFIG_KEY_1='http.followRedirects', GIT_CONFIG_VALUE_1='false')
  129. remote = ORIGIN + '/' + org + '/' + name + '.git'
  130. with tempfile.TemporaryDirectory(prefix='ksf-enterprise-git-') as temp:
  131. staging = Path(temp)
  132. git(['init', '--initial-branch=main'], staging, env)
  133. git(['config', 'core.autocrlf', 'false'], staging, env)
  134. git(['remote', 'add', 'origin', remote], staging, env)
  135. refs = git(['ls-remote', 'origin'], staging, env).decode().splitlines()
  136. had_content = bool(refs)
  137. heads = [x for x in refs if x.endswith('\trefs/heads/main')]
  138. commit = None
  139. if refs:
  140. need(len(heads) == 1, 'EXISTING_REPOSITORY_BASELINE_UNKNOWN')
  141. git(['fetch', '--depth=1', 'origin', 'main'], staging, env)
  142. tracked = git(['ls-tree', '-r', '--name-only', 'FETCH_HEAD'], staging, env).decode().splitlines()
  143. existing = {n: git(['show', 'FETCH_HEAD:' + n], staging, env) for n in tracked}
  144. if repository_state(existing, root, files) == 'current':
  145. commit = git(['rev-parse', 'FETCH_HEAD'], staging, env).decode().strip()
  146. else:
  147. git(['checkout', '-B', 'main', 'FETCH_HEAD'], staging, env)
  148. changed = commit is None
  149. if changed:
  150. for relative in [*files, 'FILES.sha256']:
  151. dest = staging / relative
  152. dest.parent.mkdir(parents=True, exist_ok=True)
  153. dest.write_bytes((root / relative).read_bytes())
  154. git(['config', 'user.name', 'Kangshifu configuration release'], staging, env)
  155. git(['config', 'user.email', 'ksf-config@fmode.cn'], staging, env)
  156. git(['add', '--', *sorted(files), 'FILES.sha256'], staging, env)
  157. git(['commit', '-m', 'Configure enterprise workshop and required VOC runtime'], staging, env)
  158. commit = git(['rev-parse', 'HEAD'], staging, env).decode().strip()
  159. git(['push', 'origin', 'HEAD:refs/heads/main'], staging, env)
  160. refs = git(['ls-remote', 'origin', 'refs/heads/main'], staging, env).decode()
  161. need(refs.split()[0] == commit, 'GOGS_PUSH_NOT_CONFIRMED')
  162. # Read every remote file back, including the private configuration manifest.
  163. git(['fetch', '--depth=1', 'origin', commit], staging, env)
  164. for relative in [*files, 'FILES.sha256']:
  165. need(git(['show', 'FETCH_HEAD:' + relative], staging, env) == (root / relative).read_bytes(),
  166. 'GOGS_READBACK_MISMATCH')
  167. return {'ok': True, 'planOnly': False, **inspect(api, cfg), 'repositoryURL': remote,
  168. 'commit': commit, 'repositoryContentCreated': changed and not had_content,
  169. 'repositoryContentUpdated': changed and had_content, 'private': True,
  170. 'filesReadBack': len(files) + 1, 'credentialsInRepository': False,
  171. 'enterprisePlatformBindingInstalled': False, 'businessWrites': 0}
  172. def main():
  173. parser = argparse.ArgumentParser(description=__doc__)
  174. parser.add_argument('--token-file', required=True, type=Path)
  175. parser.add_argument('--apply', action='store_true')
  176. args = parser.parse_args()
  177. # The supplied private JSON contains only an existing Gogs access token, never a password reset.
  178. token = private_json(args.token_file).get('token')
  179. need(isinstance(token, str) and len(token) >= 20 and '\n' not in token, 'GOGS_TOKEN_MISSING')
  180. print(json.dumps(setup(Gogs(token), HERE, args.apply), ensure_ascii=False, indent=2))
  181. if __name__ == '__main__':
  182. try:
  183. main()
  184. except (Stopped, OSError, ValueError, KeyError) as error:
  185. print(json.dumps({'ok': False, 'error': {'code': str(error) if isinstance(error, Stopped)
  186. else 'GOGS_SETUP_STOPPED'}}), file=sys.stderr)
  187. sys.exit(1)