Skip to content

Commit 5301005

Browse files
committed
src/patchset.py: Implement Patchset service
Patchset service process patchset nodes: - Wait for parent checkout node to be available - Download checkout node tarball - Apply patches and calculate patchset hash - Upload new tarball Signed-off-by: Nikolay Yurin <[email protected]>
1 parent c1f4cb0 commit 5301005

File tree

6 files changed

+369
-21
lines changed

6 files changed

+369
-21
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
.env
22
.docker-env
33
data
4+
*.pyc
5+
*.venv

config/kernelci.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ kdir = "/home/kernelci/data/src/linux"
1313
output = "/home/kernelci/data/output"
1414
storage_config = "docker-host"
1515

16+
[patchset]
17+
kdir = "/home/kernelci/data/src/linux-patchset"
18+
output = "/home/kernelci/data/output"
19+
storage_config = "docker-host"
20+
1621
[scheduler]
1722
output = "/home/kernelci/data/output"
1823

docker-compose.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,3 +153,17 @@ services:
153153
- '--settings=${KCI_SETTINGS:-/home/kernelci/config/kernelci.toml}'
154154
- 'run'
155155
- '--mode=holdoff'
156+
157+
patchset:
158+
<<: *base-service
159+
container_name: 'kernelci-pipeline-patchset'
160+
command:
161+
- './pipeline/patchset.py'
162+
- '--settings=${KCI_SETTINGS:-/home/kernelci/config/kernelci.toml}'
163+
- 'run'
164+
volumes:
165+
- './src:/home/kernelci/pipeline'
166+
- './config:/home/kernelci/config'
167+
- './data/ssh:/home/kernelci/data/ssh'
168+
- './data/src:/home/kernelci/data/src'
169+
- './data/output:/home/kernelci/data/output'

src/monitor.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,10 @@ def _run(self, sub_id):
6060
event = self._api.receive_event(sub_id)
6161
obj = event.data
6262
dt = datetime.datetime.fromisoformat(event['time'])
63-
commit = (obj['data']['kernel_revision']['commit'][:12]
64-
if 'kernel_revision' in obj['data']
65-
else str(None))
63+
try:
64+
commit = obj['data']['kernel_revision']['commit'][:12]
65+
except (KeyError, TypeError):
66+
commit = str(None)
6667
result = result_map[obj['result']] if obj['result'] else str(None)
6768
print(self.LOG_FMT.format(
6869
time=dt.strftime('%Y-%m-%d %H:%M:%S.%f'),

src/patchset.py

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
#!/usr/bin/env python3
2+
#
3+
# SPDX-License-Identifier: LGPL-2.1-or-later
4+
#
5+
# Copyright (C) 2022 Collabora Limited
6+
# Author: Nikolay Yurin <[email protected]>
7+
8+
import os
9+
import sys
10+
import json
11+
import requests
12+
import time
13+
import tempfile
14+
import hashlib
15+
from datetime import datetime, timedelta
16+
from urllib.parse import urlparse
17+
from urllib.request import urlopen
18+
19+
import kernelci
20+
import kernelci.build
21+
import kernelci.config
22+
from kernelci.legacy.cli import Args, Command, parse_opts
23+
import kernelci.storage
24+
25+
from tarball import Tarball
26+
27+
# FIXME: make patchset service configuration option
28+
ALLOWED_DOMAINS = {"patchwork.kernel.org"}
29+
PATCHSET_SHORT_HASH_LEN = 13
30+
POLLING_DELAY_SECS = 30
31+
PATCH_TMP_FILE_PREFIX = "kernel-patch"
32+
33+
34+
class Patchset(Tarball):
35+
TAR_CREATE_CMD = """\
36+
set -e
37+
cd {target_dir}/
38+
tar --create --transform "s/^/{prefix}\\//" * | gzip > {tarball_path}
39+
"""
40+
41+
APPLY_PATCH_SHELL_CMD = """\
42+
set -e
43+
cd {kdir}
44+
patch -p1 < {patch_file}
45+
"""
46+
47+
# FIXME: I really don"t have a good idea what I"m doing here
48+
# This code probably needs rework and put into kernelci.patch
49+
def _hash_patch(self, patch_name, patch_file):
50+
allowed_prefixes = {
51+
b"old mode", # Old file permissions
52+
b"new mode", # New file permissions
53+
b"-", # This convers both removed lines and source file
54+
b"+", # This convers both added lines and target file
55+
# "@" I don"t know how we should handle hunks yet
56+
}
57+
hashable_patch_lines = []
58+
for line in patch_file.readlines():
59+
if not line:
60+
continue
61+
62+
for prefix in allowed_prefixes:
63+
if line.startswith(prefix):
64+
hashable_patch_lines.append(line)
65+
break
66+
67+
hashable_content = b"/n".join(hashable_patch_lines)
68+
self.log.debug(
69+
"Hashable content:\n" +
70+
hashable_content.decode("utf-8")
71+
)
72+
patch_hash_digest = hashlib.sha256(hashable_content).hexdigest()
73+
self.log.debug(f"Patch {patch_name} hash: {patch_hash_digest}")
74+
return patch_hash_digest
75+
76+
# FIXME: move into kernelci.patch
77+
def _apply_patch(self, kdir, patch_name, patch_url):
78+
self.log.info(
79+
f"Applying patch {patch_name}, url: {patch_url}",
80+
)
81+
try:
82+
encoding = urlopen(patch_url).headers.get_charsets()[0]
83+
except Exception as e:
84+
self.log.warn(
85+
"Failed to fetch encoding from patch "
86+
f"{patch_name} headers: {e}"
87+
)
88+
self.log.warn("Falling back to utf-8 encoding")
89+
encoding = "utf-8"
90+
91+
with tempfile.NamedTemporaryFile(
92+
prefix="{}-{}-".format(PATCH_TMP_FILE_PREFIX, patch_name),
93+
encoding=encoding
94+
) as tmp_f:
95+
if not kernelci.build._download_file(patch_url, tmp_f.name):
96+
raise FileNotFoundError(
97+
f"Error downloading patch from {patch_url}"
98+
)
99+
100+
kernelci.shell_cmd(self.APPLY_PATCH_SHELL_CMD.format(
101+
kdir=kdir,
102+
patch_file=tmp_f.name,
103+
))
104+
105+
return self._hash_patch(patch_name, tmp_f)
106+
107+
# FIXME: move into kernelci.patch
108+
def _apply_patches(self, kdir, patch_artifacts):
109+
patchset_hash = hashlib.sha256()
110+
for patch_name, patch_url in patch_artifacts.items():
111+
patch_hash = self._apply_patch(kdir, patch_name, patch_url)
112+
patchset_hash.update(patch_hash.encode("utf-8"))
113+
114+
patchset_hash_digest = patchset_hash.hexdigest()
115+
self.log.debug(f"Patchset hash: {patchset_hash_digest}")
116+
return patchset_hash_digest
117+
118+
def _download_checkout_archive(self, tarball_url, retries=3):
119+
self.log.info(f"Downloading checkout tarball, url: {tarball_url}")
120+
tar_filename = os.path.basename(urlparse(tarball_url).path)
121+
kernelci.build.pull_tarball(
122+
kdir=self._kdir,
123+
url=tarball_url,
124+
dest_filename=tar_filename,
125+
retries=retries,
126+
delete=True
127+
)
128+
129+
def _update_node(self, node, revision, tarball_url):
130+
updated_node = node.copy()
131+
updated_node.update({
132+
"data": {
133+
"kernel_revision": revision,
134+
},
135+
"state": "available",
136+
"artifacts": {
137+
"tarball": tarball_url,
138+
},
139+
"holdoff": str(datetime.utcnow() + timedelta(minutes=10))
140+
})
141+
try:
142+
self._api.node.update(updated_node)
143+
except requests.exceptions.HTTPError as err:
144+
err_msg = json.loads(err.response.content).get("detail", [])
145+
self.log.error(err_msg)
146+
147+
def _setup(self, *args):
148+
return self._api_helper.subscribe_filters({
149+
"op": "created",
150+
"name": "patchset",
151+
"state": "running",
152+
})
153+
154+
def _has_allowed_domain(self, url):
155+
domain = urlparse(url).hostname
156+
if domain not in ALLOWED_DOMAINS:
157+
raise RuntimeError(
158+
"Forbidden mbox domain %s, allowed domains: %s",
159+
domain,
160+
ALLOWED_DOMAINS
161+
)
162+
163+
def _validate_patch_artifacts(self, node_id, patch_artifacts):
164+
if not patch_artifacts:
165+
raise ValueError(
166+
"No patch artifacts available for node %s",
167+
node_id,
168+
)
169+
170+
for patch_mbox_url in patch_artifacts.values():
171+
self._has_allowed_domain(patch_mbox_url)
172+
173+
def _gen_checkout_name(self, checkout_node):
174+
revision = checkout_node["data"]["kernel_revision"]
175+
return "-".join([
176+
"linux",
177+
revision["tree"],
178+
revision["branch"],
179+
revision["describe"],
180+
])
181+
182+
def _process_patchset(self, checkout_node, patchset_node):
183+
patch_artifacts = patchset_node.get("artifacts")
184+
self._validate_patch_artifacts(patchset_node["id"], patch_artifacts)
185+
self._download_checkout_archive(checkout_node["artifacts"]["tarball"])
186+
187+
checkout_name = self._gen_checkout_name(checkout_node)
188+
checkout_path = os.path.join(self._kdir, checkout_name)
189+
190+
patchset_hash = self._apply_patches(checkout_path, patch_artifacts)
191+
patchset_hash_short = patchset_hash[:PATCHSET_SHORT_HASH_LEN]
192+
193+
tarball_path = self._make_tarball(
194+
target_dir=checkout_path,
195+
tarball_name=f"{checkout_name}-{patchset_hash_short}"
196+
)
197+
tarball_url = self._push_tarball(tarball_path)
198+
199+
patchset_revision = {
200+
**checkout_node["data"]["kernel_revision"],
201+
"patchset": patchset_hash,
202+
}
203+
self._update_node(
204+
node=patchset_node,
205+
revision=patchset_revision,
206+
tarball_url=tarball_url
207+
)
208+
209+
def _mark_failed(self, patchset_node):
210+
node = patchset_node.copy()
211+
node.update({
212+
"state": "done",
213+
"result": "fail",
214+
})
215+
try:
216+
self._api.node.update(node)
217+
except requests.exceptions.HTTPError as err:
218+
err_msg = json.loads(err.response.content).get("detail", [])
219+
self.log.error(err_msg)
220+
221+
def _mark_failed_if_no_parent(self, patchset_node):
222+
if not patchset_node["parent"]:
223+
self.log.error(
224+
f"Patchset node {patchset_node['id']} as has no parent"
225+
"checkout node , marking node as failed",
226+
)
227+
self._mark_failed(patchset_node)
228+
return True
229+
230+
return False
231+
232+
def _mark_failed_if_parent_failed(self, patchset_node, checkout_node):
233+
if (
234+
checkout_node["state"] == "done" and
235+
checkout_node["result"] == "fail"
236+
):
237+
self.log.error(
238+
f"Parent checkout node {checkout_node['id']} failed, "
239+
f"marking patchset node {patchset_node['id']} as failed",
240+
)
241+
self._mark_failed(patchset_node)
242+
return True
243+
244+
return False
245+
246+
def _run(self, sub_id):
247+
self.log.info("Listening for new trigger events")
248+
self.log.info("Press Ctrl-C to stop.")
249+
250+
while True:
251+
patchset_nodes = self._api.node.find({
252+
"name": "patchset",
253+
"state": "running",
254+
})
255+
256+
if patchset_nodes:
257+
self.log.debug(f"Found patchset nodes: {patchset_nodes}")
258+
259+
for patchset_node in patchset_nodes:
260+
if self._mark_failed_if_no_parent(patchset_node):
261+
continue
262+
263+
checkout_node = self._api.node.get(patchset_node["parent"])
264+
265+
if self._mark_failed_if_parent_failed(
266+
patchset_node,
267+
checkout_node
268+
):
269+
continue
270+
271+
if checkout_node["state"] == "running":
272+
self.log.info(
273+
f"Patchset node {patchset_node['id']} is waiting "
274+
f"for checkout node {checkout_node['id']} to complete",
275+
)
276+
continue
277+
278+
try:
279+
self.log.info(
280+
f"Processing patchset node: {patchset_node['id']}",
281+
)
282+
self._process_patchset(checkout_node, patchset_node)
283+
except Exception as e:
284+
self.log.error(
285+
f"Patchset node {patchset_node['id']} "
286+
f"processing failed: {e}",
287+
)
288+
self.log.traceback()
289+
self._mark_failed(patchset_node)
290+
291+
self.log.info(
292+
f"Waiting {POLLING_DELAY_SECS} seconds for a new nodes...",
293+
)
294+
time.sleep(POLLING_DELAY_SECS)
295+
296+
297+
class cmd_run(Command):
298+
help = (
299+
"Wait for a checkout node to be available "
300+
"and push a source+patchset tarball"
301+
)
302+
args = [
303+
Args.kdir, Args.output, Args.api_config, Args.storage_config,
304+
]
305+
opt_args = [
306+
Args.verbose, Args.storage_cred,
307+
]
308+
309+
def __call__(self, configs, args):
310+
return Patchset(configs, args).run(args)
311+
312+
313+
if __name__ == "__main__":
314+
opts = parse_opts("patchset", globals())
315+
configs = kernelci.config.load("config/pipeline.yaml")
316+
status = opts.command(configs, opts)
317+
sys.exit(0 if status is True else 1)

0 commit comments

Comments
 (0)