sync_repo_from_koji.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. #!/bin/env python
  2. from __future__ import print_function
  3. import argparse
  4. import re
  5. import os
  6. import sys
  7. import subprocess
  8. import glob
  9. import shutil
  10. import tempfile
  11. import atexit
  12. from datetime import datetime
  13. USER_REPO_HTTPS = "https://koji.xcp-ng.org/repos/user/"
  14. RELEASE_VERSIONS = [
  15. '7.6',
  16. '8.0',
  17. '8.1',
  18. '8.2',
  19. '8.3',
  20. ]
  21. DEV_VERSIONS = [
  22. ]
  23. VERSIONS = DEV_VERSIONS + RELEASE_VERSIONS
  24. # Not used, just here as memory and in the unlikely case we might need to update their repos again
  25. DEAD_TAGS = [
  26. 'v7.6-base',
  27. 'v7.6-updates',
  28. 'v7.6-testing',
  29. 'v8.0-base',
  30. 'v8.0-updates',
  31. 'v8.0-testing',
  32. 'v8.1-base',
  33. 'v8.1-updates',
  34. 'v8.1-testing',
  35. ]
  36. TAGS = [
  37. 'v8.2-base',
  38. 'v8.2-updates',
  39. 'v8.2-candidates',
  40. 'v8.2-testing',
  41. 'v8.2-ci',
  42. 'v8.2-incoming',
  43. 'v8.2-lab',
  44. 'v8.3-base',
  45. 'v8.3-updates',
  46. 'v8.3-candidates',
  47. 'v8.3-testing',
  48. 'v8.3-ci',
  49. 'v8.3-incoming',
  50. 'v8.3-lab',
  51. ]
  52. # tags in which we only keep the latest build for each package
  53. RELEASE_TAGS = [
  54. 'v7.6-base',
  55. 'v8.0-base',
  56. 'v8.1-base',
  57. 'v8.2-base',
  58. # 'v8.3-base', # special case: we have a history of pre-release builds that users might need for troubleshooting
  59. ]
  60. # tags for which we want to export a stripped repo for offline updates
  61. OFFLINE_TAGS = [
  62. 'v8.2-updates',
  63. 'v8.2-v-linstor',
  64. 'v8.3-updates',
  65. 'v8.3-v-linstor',
  66. ]
  67. # Additional "user" tags. For them, repos are generated at a different place.
  68. # Initialized empty: user tags are autodetected based on their name
  69. U_TAGS = []
  70. # Additional V-tags (V stands for "vates" or for "vendor"). For them, repos also are generated at a different place.
  71. # Initialized empty: V-tags are autodetected based on their name
  72. V_TAGS = []
  73. KOJI_ROOT_DIR = '/mnt/koji'
  74. KEY_ID = "3fd3ac9e"
  75. DEVNULL = open(os.devnull, 'w')
  76. def version_from_tag(tag):
  77. matches = re.match(r'v(\d+\.\d+)', tag)
  78. return matches.group(1)
  79. def repo_name_from_tag(tag):
  80. version = version_from_tag(tag)
  81. name = tag[len("v%s-" % version):]
  82. if name.startswith('u-') or name.startswith('v-'):
  83. name = name[2:]
  84. return name
  85. def build_path_to_version(parent_dir, tag):
  86. version = version_from_tag(tag)
  87. major = version.split('.')[0]
  88. return os.path.join(parent_dir, major, version)
  89. def build_path_to_repo(parent_dir, tag):
  90. return os.path.join(build_path_to_version(parent_dir, tag), repo_name_from_tag(tag))
  91. def sign_rpm(rpm):
  92. # create temporary work directory
  93. tmpdir = tempfile.mkdtemp(prefix=rpm)
  94. current_dir = os.getcwd()
  95. try:
  96. os.chdir(tmpdir)
  97. # download from koji
  98. subprocess.check_call(['koji', 'download-build', '--debuginfo', '--noprogress', '--rpm', rpm])
  99. # sign: requires a sign-rpm executable or alias in the PATH
  100. subprocess.check_call(['sign-rpm', rpm], stdout=DEVNULL)
  101. # import signature
  102. subprocess.check_call(['koji', 'import-sig', rpm])
  103. finally:
  104. # clean up
  105. os.chdir(current_dir)
  106. shutil.rmtree(tmpdir)
  107. def write_repo(tag, dest_dir, tmp_root_dir, offline=False):
  108. version = version_from_tag(tag)
  109. repo_name = repo_name_from_tag(tag)
  110. # Hack for 7.6 because koji only handles its updates and updates_testing repos:
  111. if version == '7.6':
  112. if repo_name == 'testing':
  113. repo_name = 'updates_testing'
  114. elif repo_name != 'updates':
  115. raise Exception("Fatal: koji should not have any changes outside testing and updates for 7.6!")
  116. path_to_repo = build_path_to_repo(dest_dir, tag)
  117. path_to_tmp_repo = build_path_to_repo(tmp_root_dir, tag)
  118. # remove temporary repo if exists
  119. if os.path.isdir(path_to_tmp_repo):
  120. shutil.rmtree(path_to_tmp_repo)
  121. # create empty structure
  122. print("\n-- Copy the RPMs from %s to %s" % (KOJI_ROOT_DIR, path_to_tmp_repo))
  123. os.makedirs(os.path.join(path_to_tmp_repo, 'x86_64/Packages'))
  124. if not offline:
  125. os.makedirs(os.path.join(path_to_tmp_repo, 'Source/SPackages'))
  126. print("Link to latest dist-repo: %s" % os.readlink('%s/repos-dist/%s/latest' % (KOJI_ROOT_DIR, tag)))
  127. # copy RPMs from koji
  128. for f in glob.glob('%s/repos-dist/%s/latest/x86_64/Packages/*/*.rpm' % (KOJI_ROOT_DIR, tag)):
  129. shutil.copy(f, os.path.join(path_to_tmp_repo, 'x86_64', 'Packages'))
  130. if not offline:
  131. # and source RPMs
  132. for f in glob.glob('%s/repos-dist/%s/latest/src/Packages/*/*.rpm' % (KOJI_ROOT_DIR, tag)):
  133. shutil.copy(f, os.path.join(path_to_tmp_repo, 'Source', 'SPackages'))
  134. if offline:
  135. # For offline update repos, in order to reduce the size, let's remove debuginfo packages
  136. # and other big useless packages.
  137. delete_patterns = [
  138. '*-debuginfo-*.rpm',
  139. 'xs-opam-repo-*.rpm', # big and only used for builds
  140. 'java-1.8.0-*.rpm', # old java, used to be pulled by linstor
  141. ]
  142. for delete_pattern in delete_patterns:
  143. subprocess.check_call([
  144. 'find', os.path.join(path_to_tmp_repo, 'x86_64', 'Packages'),
  145. '-name', delete_pattern,
  146. '-delete',
  147. ])
  148. # generate repodata and sign
  149. paths = [os.path.join(path_to_tmp_repo, 'x86_64')]
  150. if not offline:
  151. paths.append(os.path.join(path_to_tmp_repo, 'Source'))
  152. for path in paths:
  153. print("\n-- Generate repodata for %s" % path)
  154. subprocess.check_call(['createrepo_c', path], stdout=DEVNULL)
  155. subprocess.check_call(['sign-file', os.path.join(path, 'repodata', 'repomd.xml')], stdout=DEVNULL)
  156. # Synchronize to our final repository:
  157. # - add new RPMs
  158. # - remove RPMs that are not present anymore (for tags in RELEASE_TAGS)
  159. # - do NOT change the creation nor modification stamps for existing RPMs that have not been modified
  160. # (and usually there's no reason why they would have been modified without changing names)
  161. # => using -c and omitting -t
  162. # - sync updated repodata
  163. print("\n-- Syncing to final repository %s" % path_to_repo)
  164. if not os.path.exists(path_to_repo):
  165. os.makedirs(path_to_repo)
  166. subprocess.check_call(['rsync', '-crlpi', '--delete-after', path_to_tmp_repo + '/', path_to_repo])
  167. print()
  168. shutil.rmtree(path_to_tmp_repo)
  169. def sign_unsigned_rpms(tag):
  170. # get list of RPMs not signed by us by comparing the list that is signed with the full list
  171. # all RPMs for the tag
  172. output = subprocess.check_output(['koji', 'list-tagged', tag, '--rpms'])
  173. rpms = set(output.strip().splitlines())
  174. # only signed RPMs
  175. # koji list-tagged v7.6-base --sigs | grep "^3fd3ac9e" | cut -c 10-
  176. signed_rpms = set()
  177. output = subprocess.check_output(['koji', 'list-tagged', tag, '--sigs'])
  178. for line in output.strip().splitlines():
  179. try:
  180. key, rpm = line.split(' ')
  181. except:
  182. # couldn't unpack values... no signature.
  183. continue
  184. if key == KEY_ID:
  185. signed_rpms.add(rpm)
  186. # diff and sort
  187. unsigned_rpms = sorted(list(rpms.difference(signed_rpms)))
  188. if unsigned_rpms:
  189. print("\nSigning unsigned RPMs first\n")
  190. for rpm in unsigned_rpms:
  191. sign_rpm(rpm + '.rpm')
  192. for rpm in unsigned_rpms:
  193. if rpm.endswith('.src'):
  194. nvr = rpm[:-4]
  195. # write signed file to koji's own repositories
  196. subprocess.check_call(['koji', 'write-signed-rpm', KEY_ID, nvr])
  197. def atexit_remove_lock(lock_file):
  198. os.unlink(lock_file)
  199. def main():
  200. parser = argparse.ArgumentParser(description='Detect package changes in koji and update repository')
  201. parser.add_argument('dest_dir', help='root directory of the destination repository')
  202. parser.add_argument('u_dest_dir', help='root directory of the destination repository for user tags')
  203. parser.add_argument('v_dest_dir', help='root directory of the destination repository for V-tags')
  204. parser.add_argument('data_dir', help='directory where the script will write or read data from')
  205. parser.add_argument('--quiet', action='store_true',
  206. help='do not output anything unless there are changes to be considered')
  207. parser.add_argument('--modify-stable-base', action='store_true',
  208. help='allow modifying the base repository of a stable release')
  209. args = parser.parse_args()
  210. dest_dir = args.dest_dir
  211. u_dest_dir = args.u_dest_dir
  212. v_dest_dir = args.v_dest_dir
  213. data_dir = args.data_dir
  214. tmp_root_dir = os.path.join(data_dir, 'tmproot')
  215. quiet = args.quiet
  216. lock_file = os.path.join(data_dir, 'lock')
  217. if os.path.exists(lock_file):
  218. print("Lock file %s already exists. Aborting." % lock_file)
  219. return
  220. else:
  221. open(lock_file, 'w').close()
  222. atexit.register(atexit_remove_lock, lock_file)
  223. global U_TAGS, V_TAGS
  224. U_TAGS += subprocess.check_output(['koji', 'list-tags', 'v*.*-u-*']).strip().splitlines()
  225. V_TAGS += subprocess.check_output(['koji', 'list-tags', 'v*.*-v-*']).strip().splitlines()
  226. def dest_dir_for_tag(tag):
  227. if tag in U_TAGS:
  228. return u_dest_dir
  229. if tag in V_TAGS:
  230. return v_dest_dir
  231. return dest_dir
  232. def offline_repo_dir():
  233. return os.path.join(v_dest_dir, 'offline')
  234. for version in VERSIONS:
  235. for tag in TAGS + U_TAGS + V_TAGS:
  236. if version_from_tag(tag) != version:
  237. continue
  238. needs_update = False
  239. # get current list of packages from koji for this tag
  240. tag_builds_koji = subprocess.check_output(['koji', 'list-tagged', '--quiet', tag])
  241. # read latest known list of builds in the tag if exists
  242. tag_builds_filepath = os.path.join(data_dir, "%s-builds.txt" % tag)
  243. if os.path.exists(tag_builds_filepath):
  244. with open(tag_builds_filepath, 'r') as f:
  245. tag_builds_txt = f.read()
  246. if tag_builds_koji != tag_builds_txt:
  247. needs_update = True
  248. else:
  249. needs_update = True
  250. msgs = ["\n*** %s" % tag]
  251. if needs_update:
  252. msgs.append("Repository update needed")
  253. if tag in RELEASE_TAGS and version not in DEV_VERSIONS:
  254. if args.modify_stable_base:
  255. msgs.append("Modification of base repository for stable release %s " % version
  256. + "allowed through the --modify-stable-base switch.")
  257. else:
  258. if not quiet:
  259. msgs.append("Not modifying base repository for stable release %s..." % version)
  260. print('\n'.join(msgs))
  261. continue
  262. print('\n'.join(msgs))
  263. # sign RPMs in the tag if needed
  264. sign_unsigned_rpms(tag)
  265. # export the RPMs from koji
  266. print("\n-- Make koji write the repository for tag %s" % tag)
  267. with_non_latest = [] if tag in RELEASE_TAGS else ['--non-latest']
  268. sys.stdout.flush()
  269. subprocess.check_call(['koji', 'dist-repo', tag, '3fd3ac9e', '--with-src', '--noinherit'] + with_non_latest)
  270. # write repository to the appropriate destination directory for the tag
  271. write_repo(tag, dest_dir_for_tag(tag), tmp_root_dir)
  272. if tag in OFFLINE_TAGS:
  273. print("\n-- Make koji write the offline repository for tag %s" % tag)
  274. # Also generate a stripped repo for offline updates
  275. sys.stdout.flush()
  276. subprocess.check_call(['koji', 'dist-repo', tag, '3fd3ac9e', '--noinherit'])
  277. write_repo(tag, offline_repo_dir(), tmp_root_dir, offline=True)
  278. # Wrap it up in a tarball
  279. offline_repo_path = build_path_to_repo(offline_repo_dir(), tag)
  280. offline_repo_path_parent = os.path.dirname(offline_repo_path)
  281. offline_tarball_path_prefix = os.path.join(
  282. offline_repo_path_parent,
  283. "xcpng-%s-offline-%s" % (version.replace('.', '_'), repo_name_from_tag(tag))
  284. )
  285. offline_tarball = "%s-%s.tar" % (offline_tarball_path_prefix, datetime.now().strftime("%Y%m%d"))
  286. print("\n-- Generate offline update tarball: %s" % offline_tarball)
  287. subprocess.check_call(['rm', '-f', offline_tarball])
  288. subprocess.check_call([
  289. 'tar',
  290. '-cf', offline_tarball,
  291. '-C', offline_repo_path_parent,
  292. os.path.basename(offline_repo_path)
  293. ])
  294. # Point the "latest" symlink at the tarball
  295. latest_symlink = "%s-latest.tar" % offline_tarball_path_prefix
  296. if os.path.exists(latest_symlink):
  297. os.unlink(latest_symlink)
  298. # relative symlink
  299. os.symlink(os.path.basename(offline_tarball), latest_symlink)
  300. # And remove older tarballs
  301. tarballs = glob.glob("%s-*.tar" % offline_tarball_path_prefix)
  302. tarballs.remove(latest_symlink)
  303. tarballs_sorted_by_mtime = sorted(tarballs, key=os.path.getmtime, reverse=True)
  304. # Remove all but the latest three tarballs
  305. for old_tarball in tarballs_sorted_by_mtime[3:]:
  306. print("Removing old tarball: %s" % old_tarball)
  307. os.remove(old_tarball)
  308. # Update SHA256SUMs
  309. subprocess.check_call(
  310. 'sha256sum *.tar > SHA256SUMS',
  311. shell=True,
  312. cwd=offline_repo_path_parent
  313. )
  314. # And sign them
  315. subprocess.check_call(
  316. ['sign-file', 'SHA256SUMS'],
  317. cwd=offline_repo_path_parent,
  318. stdout=DEVNULL
  319. )
  320. # update data
  321. with open(tag_builds_filepath, 'w') as f:
  322. f.write(tag_builds_koji)
  323. elif not quiet:
  324. print('\n'.join(msgs))
  325. print("Already up to date")
  326. # Write repo files for U_TAGS
  327. for version in VERSIONS:
  328. contents = "# User repositories from XCP-ng developers. Meant for testing and troubleshooting purposes.\n"
  329. last_tag = None
  330. for tag in U_TAGS:
  331. if version_from_tag(tag) != version:
  332. continue
  333. last_tag = tag
  334. repo_name = repo_name_from_tag(tag)
  335. repo_path_https = build_path_to_repo(USER_REPO_HTTPS, tag)
  336. contents += """[xcp-ng-{repo_name}]
  337. name=xcp-ng-{repo_name}
  338. baseurl={repo_path_https}/x86_64/
  339. enabled=0
  340. gpgcheck=1
  341. repo_gpgcheck=1
  342. metadata_expire=0
  343. gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-xcpng
  344. """.format(repo_name=repo_name, repo_path_https=repo_path_https)
  345. if last_tag is not None:
  346. repo_filename = os.path.join(
  347. build_path_to_version(dest_dir_for_tag(last_tag), last_tag),
  348. 'xcpng-users.repo'
  349. )
  350. with open(repo_filename, 'w') as f:
  351. f.write(contents)
  352. if __name__ == "__main__":
  353. main()