|
| 1 | +#!/usr/bin/env python3 |
| 2 | +import logging |
| 3 | +import os |
| 4 | +import re |
| 5 | +import subprocess |
| 6 | +from contextlib import contextmanager |
| 7 | +from datetime import datetime, timedelta |
| 8 | +from pathlib import Path |
| 9 | + |
| 10 | +from specfile.specfile import Specfile |
| 11 | + |
| 12 | +TIME_FORMAT = '%Y-%m-%d-%H-%M-%S' |
| 13 | + |
| 14 | +# target -> required branch |
| 15 | +PROTECTED_TARGETS = { |
| 16 | + "v8.2-ci": "8.2", |
| 17 | + "v8.2-fasttrack": "8.2", |
| 18 | + "v8.2-incoming": "8.2", |
| 19 | + "v8.3-ci": "master", |
| 20 | + "v8.3-fasttrack": "master", |
| 21 | + "v8.3-incoming": "master", |
| 22 | +} |
| 23 | + |
| 24 | +@contextmanager |
| 25 | +def cd(dir): |
| 26 | + """Change to a directory temporarily. To be used in a with statement.""" |
| 27 | + prevdir = os.getcwd() |
| 28 | + os.chdir(dir) |
| 29 | + try: |
| 30 | + yield os.path.realpath(dir) |
| 31 | + finally: |
| 32 | + os.chdir(prevdir) |
| 33 | + |
| 34 | +def check_dir(dirpath): |
| 35 | + if not os.path.isdir(dirpath): |
| 36 | + raise Exception("Directory %s doesn't exist" % dirpath) |
| 37 | + return dirpath |
| 38 | + |
| 39 | +def check_git_repo(dirpath): |
| 40 | + """check that the working copy is a working directory and is clean.""" |
| 41 | + with cd(dirpath): |
| 42 | + return subprocess.run(['git', 'diff-index', '--quiet', 'HEAD', '--']).returncode == 0 |
| 43 | + |
| 44 | +def check_commit_is_available_remotely(dirpath, hash, target, warn): |
| 45 | + with cd(dirpath): |
| 46 | + if not subprocess.check_output(['git', 'branch', '-r', '--contains', hash]): |
| 47 | + raise Exception("The current commit is not available in the remote repository") |
| 48 | + if target is not None and re.match(r'v\d+\.\d+-u-.+', target): |
| 49 | + raise Exception("Building with a user target requires using --pre-build or --test-build.\n") |
| 50 | + try: |
| 51 | + expected_branch = PROTECTED_TARGETS.get(target) |
| 52 | + if ( |
| 53 | + expected_branch is not None |
| 54 | + and not is_remote_branch_commit(dirpath, hash, expected_branch) |
| 55 | + ): |
| 56 | + raise Exception(f"The current commit is not the last commit in the remote branch {expected_branch}.\n" |
| 57 | + f"This is required when using the protected target {target}.\n") |
| 58 | + except Exception as e: |
| 59 | + if warn: |
| 60 | + print(f"warning: {e}", flush=True) |
| 61 | + else: |
| 62 | + raise e |
| 63 | + |
| 64 | +def get_repo_and_commit_info(dirpath): |
| 65 | + with cd(dirpath): |
| 66 | + remote = subprocess.check_output(['git', 'config', '--get', 'remote.origin.url']).decode().strip() |
| 67 | + # We want the exact hash for accurate build history |
| 68 | + hash = subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode().strip() |
| 69 | + return remote, hash |
| 70 | + |
| 71 | +def koji_url(remote, hash): |
| 72 | + if remote.startswith('git@'): |
| 73 | + remote = re.sub(r'git@(.+):', r'git+https://\1/', remote) |
| 74 | + elif remote.startswith('https://'): |
| 75 | + remote = 'git+' + remote |
| 76 | + else: |
| 77 | + raise Exception("Unrecognized remote URL") |
| 78 | + return remote + "?#" + hash |
| 79 | + |
| 80 | +@contextmanager |
| 81 | +def local_branch(branch): |
| 82 | + prev_branch = subprocess.check_output(['git', 'branch', '--show-current']).strip() |
| 83 | + commit = subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode().strip() |
| 84 | + subprocess.check_call(['git', 'checkout', '--quiet', commit]) |
| 85 | + try: |
| 86 | + yield branch |
| 87 | + finally: |
| 88 | + # prev_branch is empty when the head was detached |
| 89 | + subprocess.check_call(['git', 'checkout', prev_branch or commit]) |
| 90 | + |
| 91 | +def is_old_branch(b): |
| 92 | + branch_time = datetime.strptime(b.split('/')[-1], TIME_FORMAT) |
| 93 | + return branch_time < datetime.now() - timedelta(hours=3) |
| 94 | + |
| 95 | +def clean_old_branches(git_repo): |
| 96 | + with cd(git_repo): |
| 97 | + remote_branches = [ |
| 98 | + line.split()[-1] for line in subprocess.check_output(['git', 'ls-remote']).decode().splitlines() |
| 99 | + ] |
| 100 | + remote_branches = [b for b in remote_branches if b.startswith('refs/heads/koji/test/')] |
| 101 | + old_branches = [b for b in remote_branches if is_old_branch(b)] |
| 102 | + if old_branches: |
| 103 | + print("removing outdated remote branch(es)", flush=True) |
| 104 | + subprocess.check_call(['git', 'push', '--delete', 'origin'] + old_branches) |
| 105 | + |
| 106 | +def xcpng_version(target): |
| 107 | + xcpng_version_match = re.match(r'^v(\d+\.\d+)-u-\S+$', target) |
| 108 | + if xcpng_version_match is None: |
| 109 | + raise Exception(f"Can't find XCP-ng version in {target}") |
| 110 | + return xcpng_version_match.group(1) |
| 111 | + |
| 112 | +def find_next_release(package, spec, target, test_build_id, pre_build_id): |
| 113 | + assert test_build_id is not None or pre_build_id is not None |
| 114 | + builds = subprocess.check_output(['koji', 'list-builds', '--quiet', '--package', package]).decode().splitlines() |
| 115 | + if test_build_id: |
| 116 | + base_nvr = f'{package}-{spec.version}-{spec.release}.0.{test_build_id}.' |
| 117 | + else: |
| 118 | + base_nvr = f'{package}-{spec.version}-{spec.release}~{pre_build_id}.' |
| 119 | + # use a regex to match %{macro} without actually expanding the macros |
| 120 | + base_nvr_re = ( |
| 121 | + re.escape(re.sub('%{.+}', "@@@", base_nvr)).replace('@@@', '.*') |
| 122 | + + r'(\d+)' |
| 123 | + + re.escape(f'.xcpng{xcpng_version(target)}') |
| 124 | + ) |
| 125 | + build_matches = [re.match(base_nvr_re, b) for b in builds] |
| 126 | + build_nbs = [int(m.group(1)) for m in build_matches if m] |
| 127 | + build_nb = sorted(build_nbs)[-1] + 1 if build_nbs else 1 |
| 128 | + if test_build_id: |
| 129 | + return f'{spec.release}.0.{test_build_id}.{build_nb}' |
| 130 | + else: |
| 131 | + return f'{spec.release}~{pre_build_id}.{build_nb}' |
| 132 | + |
| 133 | +def push_bumped_release(git_repo, target, test_build_id, pre_build_id): |
| 134 | + t = datetime.now().strftime(TIME_FORMAT) |
| 135 | + branch = f'koji/test/{test_build_id or pre_build_id}/{t}' |
| 136 | + with cd(git_repo), local_branch(branch): |
| 137 | + spec_paths = subprocess.check_output(['git', 'ls-files', 'SPECS/*.spec']).decode().splitlines() |
| 138 | + assert len(spec_paths) == 1 |
| 139 | + spec_path = spec_paths[0] |
| 140 | + with Specfile(spec_path) as spec: |
| 141 | + # find the next build number |
| 142 | + package = Path(spec_path).stem |
| 143 | + spec.release = find_next_release(package, spec, target, test_build_id, pre_build_id) |
| 144 | + subprocess.check_call(['git', 'commit', '--quiet', '-m', "bump release for test build", spec_path]) |
| 145 | + subprocess.check_call(['git', 'push', 'origin', f'HEAD:refs/heads/{branch}']) |
| 146 | + commit = subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode().strip() |
| 147 | + return commit |
| 148 | + |
| 149 | +def is_remote_branch_commit(git_repo, sha, branch): |
| 150 | + with cd(git_repo): |
| 151 | + remote_sha = ( |
| 152 | + subprocess.check_output(['git', 'ls-remote', 'origin', f'refs/heads/{branch}']).decode().strip().split()[0] |
| 153 | + ) |
| 154 | + return sha == remote_sha |
| 155 | + |
| 156 | +def build_id_of(name, candidate): |
| 157 | + if candidate is None: |
| 158 | + return None |
| 159 | + |
| 160 | + length = len(candidate) |
| 161 | + if length > 16: |
| 162 | + logging.error(f"The {name} build id must be at most 16 characters long, it's {length} characters long") |
| 163 | + exit(1) |
| 164 | + |
| 165 | + invalid_chars = any(re.match(r'[a-zA-Z0-9]', char) is None for char in candidate) |
| 166 | + |
| 167 | + if invalid_chars: |
| 168 | + pp_invalid = ''.join("^" if re.match(r'[a-zA-Z0-9]', char) is None else " " for char in candidate) |
| 169 | + logging.error(f"The {name} build id must only contain letters and digits:") |
| 170 | + logging.error(f" {candidate}") |
| 171 | + logging.error(f" {pp_invalid}") |
| 172 | + exit(1) |
| 173 | + |
| 174 | + return candidate |
| 175 | + |
| 176 | +def koji_build_init_parser(subparsers_container): |
| 177 | + parser = subparsers_container.add_parser( |
| 178 | + 'build', |
| 179 | + help='Build a package or chain-build several from local git repos for RPM sources') |
| 180 | + parser.add_argument('target', help='Koji target for the build') |
| 181 | + parser.add_argument('git_repos', nargs='+', |
| 182 | + help='local path to one or more git repositories. If several are provided, ' |
| 183 | + 'a chained build will be started in the order of the arguments') |
| 184 | + parser.add_argument('--scratch', action="store_true", help='Perform scratch build') |
| 185 | + parser.add_argument('--nowait', action="store_true", help='Do not wait for the build to end') |
| 186 | + parser.add_argument('--force', action="store_true", help='Bypass sanity checks') |
| 187 | + parser.add_argument( |
| 188 | + '--test-build', |
| 189 | + metavar="ID", |
| 190 | + help='Run a test build. The provided ID will be used to build a unique release tag.', |
| 191 | + ) |
| 192 | + parser.add_argument( |
| 193 | + '--pre-build', |
| 194 | + metavar="ID", |
| 195 | + help='Run a pre build. The provided ID will be used to build a unique release tag.', |
| 196 | + ) |
| 197 | + # args = parser.parse_args() |
| 198 | + |
| 199 | +def koji_build(args): |
| 200 | + target = args.target |
| 201 | + git_repos = [os.path.abspath(check_dir(d)) for d in args.git_repos] |
| 202 | + is_scratch = args.scratch |
| 203 | + is_nowait = args.nowait |
| 204 | + |
| 205 | + test_build = build_id_of("test", args.test_build) |
| 206 | + pre_build = build_id_of("pre", args.pre_build) |
| 207 | + |
| 208 | + if test_build and pre_build: |
| 209 | + logging.error("--pre-build and --test-build can't be used together") |
| 210 | + exit(1) |
| 211 | + |
| 212 | + # if len(git_repos) > 1 and is_scratch: |
| 213 | + # parser.error("--scratch is not compatible with chained builds.") |
| 214 | + |
| 215 | + # for d in git_repos: |
| 216 | + # if not check_git_repo(d): |
| 217 | + # parser.error("%s is not in a clean state (or is not a git repository)." % d) |
| 218 | + |
| 219 | + if len(git_repos) == 1: |
| 220 | + remote, hash = get_repo_and_commit_info(git_repos[0]) |
| 221 | + if test_build or pre_build: |
| 222 | + clean_old_branches(git_repos[0]) |
| 223 | + hash = push_bumped_release(git_repos[0], target, test_build, pre_build) |
| 224 | + else: |
| 225 | + check_commit_is_available_remotely(git_repos[0], hash, None if is_scratch else target, args.force) |
| 226 | + url = koji_url(remote, hash) |
| 227 | + command = ( |
| 228 | + ['koji', 'build'] |
| 229 | + + (['--scratch'] if is_scratch else []) |
| 230 | + + [target, url] |
| 231 | + + (['--nowait'] if is_nowait else []) |
| 232 | + ) |
| 233 | + print(' '.join(command), flush=True) |
| 234 | + subprocess.check_call(command) |
| 235 | + else: |
| 236 | + urls = [] |
| 237 | + for d in git_repos: |
| 238 | + remote, hash = get_repo_and_commit_info(d) |
| 239 | + if test_build or pre_build: |
| 240 | + clean_old_branches(d) |
| 241 | + hash = push_bumped_release(d, target, test_build, pre_build) |
| 242 | + else: |
| 243 | + check_commit_is_available_remotely(d, hash, None if is_scratch else target, args.force) |
| 244 | + urls.append(koji_url(remote, hash)) |
| 245 | + command = ['koji', 'chain-build', target] + (' : '.join(urls)).split(' ') + (['--nowait'] if is_nowait else []) |
| 246 | + print(' '.join(command), flush=True) |
| 247 | + subprocess.check_call(command) |
0 commit comments