|
1 | 1 | # SPDX-License-Identifier: LGPL-2.1-or-later
|
2 | 2 | #
|
3 |
| -# Copyright (C) 2023 Collabora Limited |
| 3 | +# Copyright (C) 2024 Collabora Limited |
4 | 4 | # Author: Guillaume Tucker <[email protected]>
|
| 5 | +# Author: Denys Fedoryshchenko <[email protected]> |
5 | 6 |
|
6 | 7 | import os
|
7 | 8 | import tempfile
|
|
12 | 13 | import toml
|
13 | 14 | import threading
|
14 | 15 | 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 |
17 | 21 | import kernelci.api.helper
|
18 | 22 | import kernelci.config
|
19 | 23 | import kernelci.runtime.lava
|
20 | 24 | import kernelci.storage
|
| 25 | +import kernelci.config |
21 | 26 | from concurrent.futures import ThreadPoolExecutor
|
22 | 27 |
|
23 | 28 |
|
|
26 | 31 | SETTINGS.get('DEFAULT', {}).get('yaml_config', 'config')
|
27 | 32 | )
|
28 | 33 | SETTINGS_PREFIX = 'runtime'
|
| 34 | +YAMLCFG = kernelci.config.load_yaml('config') |
29 | 35 |
|
30 | 36 | app = FastAPI()
|
31 | 37 | executor = ThreadPoolExecutor(max_workers=16)
|
32 | 38 |
|
33 | 39 |
|
| 40 | +class ManualCheckout(BaseModel): |
| 41 | + nodeid: str |
| 42 | + commit: str |
| 43 | + |
| 44 | + |
| 45 | +class JobRetry(BaseModel): |
| 46 | + nodeid: str |
| 47 | + |
| 48 | + |
34 | 49 | def _get_api_helper(api_config_name, api_token):
|
35 | 50 | api_config = CONFIGS['api'][api_config_name]
|
36 | 51 | api = kernelci.api.get_api(api_config, api_token)
|
@@ -112,7 +127,7 @@ def async_job_submit(api_helper, node_id, job_callback):
|
112 | 127 | results = job_callback.get_results()
|
113 | 128 | job_node = api_helper.api.node.get(node_id)
|
114 | 129 | if not job_node:
|
115 |
| - print(f'Node {node_id} not found') |
| 130 | + logging.error(f'Node {node_id} not found') |
116 | 131 | return
|
117 | 132 | # TODO: Verify lab_name matches job node lab name
|
118 | 133 | # Also extract job_id and compare with node job_id (future)
|
@@ -187,9 +202,194 @@ async def callback(node_id: str, request: Request):
|
187 | 202 | return 'OK', 202
|
188 | 203 |
|
189 | 204 |
|
| 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 | + |
190 | 384 | # Default built-in development server, not suitable for production
|
191 | 385 | if __name__ == '__main__':
|
192 | 386 | tokens = SETTINGS.get(SETTINGS_PREFIX)
|
193 | 387 | if not tokens:
|
194 | 388 | 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') |
195 | 395 | uvicorn.run(app, host='0.0.0.0', port=8000)
|
0 commit comments