Skip to content

Commit a0fb373

Browse files
committed
koji build: a command to build packages on koji
imported from https://github.com/xcp-ng/xcp/ Signed-off-by: Gaëtan Lehmann <[email protected]>
1 parent 60834e2 commit a0fb373

File tree

7 files changed

+296
-1
lines changed

7 files changed

+296
-1
lines changed

.github/workflows/main.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ jobs:
1717
with:
1818
persist-credentials: false
1919
- uses: ./.github/actions/uv-setup/
20+
- run: sudo apt update && sudo apt install -y --no-install-recommends python3-rpm
2021
- name: Test
2122
# use script to provide a tty (workaround of systematic "docker -t"?)
2223
shell: 'script -q -e -c "bash {0}"'

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ license-files = ["LICENSE"]
88
dynamic = ["version"]
99
dependencies = [
1010
"argcomplete",
11+
"specfile",
1112
]
1213

1314
[dependency-groups]

requirements/base.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
# generated with update_requirements.py, do not edit manually
22
argcomplete
3+
specfile

src/xcp_ng_dev/cli.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
import argcomplete
1818

19+
from xcp_ng_dev.koji import koji_init_parser
20+
1921
CONTAINER_PREFIX = "ghcr.io/xcp-ng/xcp-ng-build-env"
2022

2123
DEFAULT_ULIMIT_NOFILE = 2048
@@ -82,6 +84,8 @@ def buildparser():
8284
required=True, title="Development environments",
8385
help="Available environments")
8486

87+
koji_init_parser(subparsers_env)
88+
8589
# container-based workflow
8690
parser_container = subparsers_env.add_parser('container', help="Use a local container to build a package")
8791
parser_container.set_defaults(func=container)

src/xcp_ng_dev/koji/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from xcp_ng_dev.koji.build import koji_build, koji_build_init_parser
2+
3+
# from icecream import ic
4+
5+
def koji_init_parser(subparsers_env):
6+
parser_koji = subparsers_env.add_parser('koji', help="Koji related commands")
7+
parser_koji.set_defaults(func=koji)
8+
subparsers_koji = parser_koji.add_subparsers(
9+
dest='command', required=True,
10+
help="Koji sub-commands")
11+
koji_build_init_parser(subparsers_koji)
12+
13+
def koji(args):
14+
match args.command:
15+
case 'build':
16+
koji_build(args)

src/xcp_ng_dev/koji/build.py

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
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)

uv.lock

Lines changed: 26 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)