Skip to content

Commit 7c7dc8a

Browse files
committed
lava_callback: Add API call to pipeline
Add call to create custom checkout node (manually specified commit). Signed-off-by: Denys Fedoryshchenko <[email protected]>
1 parent 2e296ab commit 7c7dc8a

File tree

2 files changed

+205
-4
lines changed

2 files changed

+205
-4
lines changed

docker/lava-callback/requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
uwsgi==2.0.22
22
uvicorn==0.30.1
33
fastapi==0.111.0
4+
pyjwt==2.8.0

src/lava_callback.py

+204-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# SPDX-License-Identifier: LGPL-2.1-or-later
22
#
3-
# Copyright (C) 2023 Collabora Limited
3+
# Copyright (C) 2024 Collabora Limited
44
# Author: Guillaume Tucker <[email protected]>
5+
# Author: Denys Fedoryshchenko <[email protected]>
56

67
import os
78
import tempfile
@@ -12,12 +13,16 @@
1213
import toml
1314
import threading
1415
import uvicorn
15-
from fastapi import FastAPI, HTTPException, Request
16-
16+
import jwt
17+
import logging
18+
from datetime import datetime, timedelta
19+
from fastapi import FastAPI, HTTPException, Request, Header
20+
from pydantic import BaseModel
1721
import kernelci.api.helper
1822
import kernelci.config
1923
import kernelci.runtime.lava
2024
import kernelci.storage
25+
import kernelci.config
2126
from concurrent.futures import ThreadPoolExecutor
2227

2328

@@ -26,11 +31,21 @@
2631
SETTINGS.get('DEFAULT', {}).get('yaml_config', 'config')
2732
)
2833
SETTINGS_PREFIX = 'runtime'
34+
YAMLCFG = kernelci.config.load_yaml('config')
2935

3036
app = FastAPI()
3137
executor = ThreadPoolExecutor(max_workers=16)
3238

3339

40+
class ManualCheckout(BaseModel):
41+
nodeid: str
42+
commit: str
43+
44+
45+
class JobRetry(BaseModel):
46+
nodeid: str
47+
48+
3449
def _get_api_helper(api_config_name, api_token):
3550
api_config = CONFIGS['api'][api_config_name]
3651
api = kernelci.api.get_api(api_config, api_token)
@@ -112,7 +127,7 @@ def async_job_submit(api_helper, node_id, job_callback):
112127
results = job_callback.get_results()
113128
job_node = api_helper.api.node.get(node_id)
114129
if not job_node:
115-
print(f'Node {node_id} not found')
130+
logging.error(f'Node {node_id} not found')
116131
return
117132
# TODO: Verify lab_name matches job node lab name
118133
# Also extract job_id and compare with node job_id (future)
@@ -187,9 +202,194 @@ async def callback(node_id: str, request: Request):
187202
return 'OK', 202
188203

189204

205+
def decode_jwt(jwtstr):
206+
'''
207+
JWT secret stored at SETTINGS['jwt']['secret']
208+
which means secret.toml file should have jwt section
209+
with parameter secret= "<secret>"
210+
'''
211+
secret = SETTINGS.get('jwt', {}).get('secret')
212+
if not secret:
213+
logging.error('No JWT secret configured')
214+
return None
215+
return jwt.decode(jwtstr, secret, algorithms=['HS256'])
216+
217+
218+
def validate_permissions(jwtoken, permission):
219+
if not jwtoken:
220+
return False
221+
try:
222+
decoded = decode_jwt(jwtoken)
223+
except Exception as e:
224+
logging.error(f'Error decoding JWT: {e}')
225+
return False
226+
if not decoded:
227+
logging.error('Invalid JWT')
228+
return False
229+
permissions = decoded.get('permissions')
230+
if not permissions:
231+
logging.error('No permissions in JWT')
232+
return False
233+
if permission not in permissions:
234+
logging.error(f'Permission {permission} not in JWT')
235+
return False
236+
return decoded
237+
238+
239+
def find_parent_kind(node, api_helper, kind):
240+
parent_id = node.get('parent')
241+
if not parent_id:
242+
return None
243+
parent_node = api_helper.api.node.get(parent_id)
244+
if not parent_node:
245+
return None
246+
if parent_node.get('kind') == kind:
247+
return parent_node
248+
return find_parent_kind(parent_node, api_helper, kind)
249+
250+
251+
@app.post('/api/jobretry')
252+
async def jobretry(data: JobRetry, request: Request,
253+
Authorization: str = Header(None)):
254+
'''
255+
API call to assist in regression bisecting by retrying a specific job
256+
retrieved from test results.
257+
'''
258+
# Validate JWT token from Authorization header
259+
jwtoken = Authorization
260+
decoded = validate_permissions(jwtoken, 'testretry')
261+
if not decoded:
262+
return 'Unauthorized', 401
263+
264+
email = decoded.get('email')
265+
logging.info(f"User {email} is retrying job {data.nodeid}")
266+
api_config_name = SETTINGS.get('DEFAULT', {}).get('api_config')
267+
if not api_config_name:
268+
return 'No default API name set', 500
269+
api_token = os.getenv('KCI_API_TOKEN')
270+
api_helper = _get_api_helper(api_config_name, api_token)
271+
node = api_helper.api.node.get(data.nodeid)
272+
if not node:
273+
return 'Node not found', 404
274+
if node['kind'] != 'job':
275+
return 'Node is not a job', 400
276+
277+
knode = find_parent_kind(node, api_helper, 'kbuild')
278+
if not knode:
279+
return 'Kernel build not found', 404
280+
281+
jobfilter = [knode['name'], node['name']]
282+
knode['jobfilter'] = jobfilter
283+
knode['op'] = 'updated'
284+
knode['data'].pop('artifacts', None)
285+
# state - done, result - pass
286+
if knode.get('state') != 'done':
287+
return 'Kernel build is not done', 400
288+
if knode.get('result') != 'pass':
289+
return 'Kernel build result is not pass', 400
290+
# remove created, updated, timeout, owner, submitter, usergroups
291+
knode.pop('created', None)
292+
knode.pop('updated', None)
293+
knode.pop('timeout', None)
294+
knode.pop('owner', None)
295+
knode.pop('submitter', None)
296+
knode.pop('usergroups', None)
297+
298+
evnode = {'data': knode}
299+
# Now we can submit custom kbuild node to the API(pub/sub)
300+
api_helper.api.send_event('node', evnode)
301+
logging.info(f"Job retry for node {data.nodeid} submitted")
302+
return 'OK', 200
303+
304+
305+
@app.post('/api/checkout')
306+
async def checkout(data: ManualCheckout, request: Request,
307+
Authorization: str = Header(None)):
308+
'''
309+
API call to assist in regression bisecting by manually checking out
310+
a specific commit on a specific branch of a specific tree, retrieved
311+
from test results.
312+
'''
313+
# Validate JWT token from Authorization header
314+
jwtoken = Authorization
315+
decoded = validate_permissions(jwtoken, 'checkout')
316+
if not decoded:
317+
return 'Unauthorized', 401
318+
319+
email = decoded.get('email')
320+
if not email:
321+
return 'Unauthorized', 401
322+
logging.info(f"User {email} is checking out {data.nodeid} at custom commit {data.commit}")
323+
324+
api_config_name = SETTINGS.get('DEFAULT', {}).get('api_config')
325+
if not api_config_name:
326+
return 'No default API name set', 500
327+
api_token = os.getenv('KCI_API_TOKEN')
328+
api_helper = _get_api_helper(api_config_name, api_token)
329+
node = api_helper.api.node.get(data.nodeid)
330+
if not node:
331+
return 'Node not found', 404
332+
try:
333+
treename = node['data']['kernel_revision']['tree']
334+
treeurl = node['data']['kernel_revision']['url']
335+
branch = node['data']['kernel_revision']['branch']
336+
commit = node['data']['kernel_revision']['commit']
337+
except KeyError:
338+
return 'Node does not have kernel revision data', 400
339+
340+
if node['kind'] != 'job':
341+
jobnode = find_parent_kind(node, api_helper, 'job')
342+
if not jobnode:
343+
return 'Job not found', 404
344+
else:
345+
jobnode = node
346+
347+
kbuildnode = find_parent_kind(node, api_helper, 'kbuild')
348+
if not kbuildnode:
349+
return 'Kernel build not found', 404
350+
351+
kbuildname = kbuildnode['name']
352+
testname = jobnode['name']
353+
354+
# Now we can submit custom checkout node to the API
355+
# Maybe add field who requested the checkout?
356+
timeout = 300
357+
checkout_timeout = datetime.utcnow() + timedelta(minutes=timeout)
358+
node = {
359+
"kind": "checkout",
360+
"name": "checkout",
361+
"path": ["checkout"],
362+
"data": {
363+
"kernel_revision": {
364+
"tree": treename,
365+
"branch": branch,
366+
"commit": commit,
367+
"url": treeurl
368+
}
369+
},
370+
"timeout": checkout_timeout.isoformat(),
371+
"jobfilter": [
372+
kbuildname,
373+
testname
374+
],
375+
}
376+
r = api_helper.api.node.add(node)
377+
if not r:
378+
return 'Failed to submit checkout node', 500
379+
else:
380+
logging.info(f"Checkout node {r['id']} submitted")
381+
return r, 200
382+
383+
190384
# Default built-in development server, not suitable for production
191385
if __name__ == '__main__':
192386
tokens = SETTINGS.get(SETTINGS_PREFIX)
193387
if not tokens:
194388
print('No tokens configured in toml file')
389+
jwt_secret = SETTINGS.get('jwt', {}).get('secret')
390+
if not jwt_secret:
391+
print('No JWT secret configured')
392+
api_token = os.getenv('KCI_API_TOKEN')
393+
if not api_token:
394+
print('No API token set')
195395
uvicorn.run(app, host='0.0.0.0', port=8000)

0 commit comments

Comments
 (0)