Build log endpoints

This commit is contained in:
Stéphane Bidoul 2021-11-11 19:30:39 +01:00
parent b80508eed9
commit 8a4797bf02
No known key found for this signature in database
GPG key ID: BCAB2555446B5B92
10 changed files with 77 additions and 18 deletions

View file

@ -106,8 +106,8 @@ actually deploy. It expects the following to hold true:
value; value;
- a deployment starts with 0 replicas and is created with a - a deployment starts with 0 replicas and is created with a
`runboat/init-status=todo` label, as well as a `runboat/cleanup` finalizer; `runboat/init-status=todo` label, as well as a `runboat/cleanup` finalizer;
- the intialization job has a `runboat/job-kind=initialize` label; - the intialization job and pods have a `runboat/job-kind=initialize` label;
- the cleanup job has a `runboat/job-kind=cleanup` label. - the cleanup job and pods have a `runboat/job-kind=cleanup` label.
- the following annotations are set on deployments: - the following annotations are set on deployments:
- `runboat/repo`: the repository in owner/repo format; - `runboat/repo`: the repository in owner/repo format;
@ -136,7 +136,6 @@ Advanced prototype (min required to open the project):
MVP: MVP:
- build/log and build/init-log api endpoints
- better error handling in API (return 400 on user errors) - better error handling in API (return 400 on user errors)
- more tests - more tests
- look at other TODO in code to see if anything important remains - look at other TODO in code to see if anything important remains
@ -147,6 +146,7 @@ MVP:
More: More:
- streaming build/log and build/init-log api endpoints
- shiny UI - shiny UI
- websocket stream of build changes, for a dynamic UI - websocket stream of build changes, for a dynamic UI
- handle PR close (delete all builds for PR) - handle PR close (delete all builds for PR)

View file

@ -10,6 +10,7 @@ classifiers = [
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: MIT License",
] ]
dependencies = [ dependencies = [
"ansi2html",
"fastapi", "fastapi",
"gunicorn", "gunicorn",
"httpx", "httpx",

View file

@ -1,4 +1,5 @@
# frozen requirements generated by pip-deepfreeze # frozen requirements generated by pip-deepfreeze
ansi2html==1.6.0
anyio==3.3.4 anyio==3.3.4
asgiref==3.4.1 asgiref==3.4.1
cachetools==4.2.4 cachetools==4.2.4

View file

@ -1,9 +1,11 @@
import datetime import datetime
from typing import Optional from typing import Optional
from ansi2html import Ansi2HTMLConverter
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import StreamingResponse from fastapi.responses import HTMLResponse
from pydantic import BaseModel from pydantic import BaseModel
from starlette.status import HTTP_404_NOT_FOUND
from . import github, models from . import github, models
from .controller import controller from .controller import controller
@ -120,22 +122,26 @@ async def build(name: str):
@router.get( @router.get(
"/builds/{name}/init-log", "/builds/{name}/init-log",
response_class=StreamingResponse, response_class=HTMLResponse,
responses={200: {"content": {"text/plain": {}}}},
) )
async def init_log(name: str): async def init_log(name: str):
# build = await _build_by_name(name) build = await _build_by_name(name)
... log = await build.init_log()
if not log:
raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail="No log found.")
return Ansi2HTMLConverter().convert(log)
@router.get( @router.get(
"/builds/{name}/log", "/builds/{name}/log",
response_class=StreamingResponse, response_class=HTMLResponse,
responses={200: {"content": {"text/plain": {}}}},
) )
async def log(name: str): async def log(name: str):
# build = _build_by_name(name) build = await _build_by_name(name)
... log = await build.log()
if not log:
raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail="No log found.")
return Ansi2HTMLConverter().convert(log)
@router.post("/builds/{name}/start") @router.post("/builds/{name}/start")

View file

@ -255,3 +255,34 @@ async def delete_job(build_name: str, job_kind: DeploymentMode) -> None:
"--wait=false", "--wait=false",
] ]
) )
@sync_to_async
def log(build_name: str, job_kind: DeploymentMode | None) -> str:
"""Return the buil log.
The pod for which the log is returned is the first that matches the
build_name (via its runboat/build label) and job_kind (via its
runboat/job-kind label).
"""
corev1 = client.CoreV1Api()
pods = corev1.list_namespaced_pod(
namespace=settings.build_namespace, label_selector=f"runboat/build={build_name}"
).items
pod = None
for pod in pods:
if job_kind is None:
if "runboat/job-kind" not in pod.metadata.labels:
break
else:
if pod.metadata.labels.get("runboat/job-kind") == job_kind:
break
else:
# no matching pod found
return
return corev1.read_namespaced_pod_log(
pod.metadata.name,
namespace=settings.build_namespace,
tail_lines=None if job_kind else None,
follow=False,
)

View file

@ -6,6 +6,9 @@ metadata:
runboat/job-kind: cleanup runboat/job-kind: cleanup
spec: spec:
template: template:
metadata:
labels:
runboat/job-kind: cleanup
spec: spec:
containers: containers:
- name: cleanup - name: cleanup

View file

@ -6,6 +6,9 @@ metadata:
runboat/job-kind: initialize runboat/job-kind: initialize
spec: spec:
template: template:
metadata:
labels:
runboat/job-kind: initialize
spec: spec:
containers: containers:
- name: initalize - name: initalize

View file

@ -156,6 +156,12 @@ class Build(BaseModel):
def live_link(self) -> str: def live_link(self) -> str:
return f"{self.webui_link}?live" return f"{self.webui_link}?live"
async def init_log(self) -> str:
return await k8s.log(self.name, job_kind=k8s.DeploymentMode.initialize)
async def log(self) -> str:
return await k8s.log(self.name, job_kind=None)
@classmethod @classmethod
async def deploy( async def deploy(
cls, repo: str, target_branch: str, pr: int | None, git_commit: str cls, repo: str, target_branch: str, pr: int | None, git_commit: str

View file

@ -1,4 +1,5 @@
import asyncio import asyncio
import functools
import re import re
from concurrent.futures.thread import ThreadPoolExecutor from concurrent.futures.thread import ThreadPoolExecutor
from functools import wraps from functools import wraps
@ -12,8 +13,9 @@ def slugify(s: str | int) -> str:
def sync_to_async(func): def sync_to_async(func):
@wraps(func) @wraps(func)
async def inner(*args): async def inner(*args, **kwargs):
return await asyncio.get_running_loop().run_in_executor(_pool, func, *args) f = functools.partial(func, *args, **kwargs)
return await asyncio.get_running_loop().run_in_executor(_pool, f)
return inner return inner
@ -27,12 +29,12 @@ def sync_to_async_iterator(iterator_func):
raise StopAsyncIteration() raise StopAsyncIteration()
@sync_to_async @sync_to_async
def async_iterator_func(*args): def async_iterator_func(*args, **kwargs):
return iterator_func(*args) return iterator_func(*args, **kwargs)
@wraps(iterator_func) @wraps(iterator_func)
async def inner(*args): async def inner(*args, **kwargs):
iterator = await async_iterator_func(*args) iterator = await async_iterator_func(*args, **kwargs)
while True: while True:
try: try:
item = await async_next(iterator) item = await async_next(iterator)

View file

@ -19,6 +19,12 @@
{% endif %} {% endif %}
<p>Commit: <a href="{{ build.repo_commit_link }}">{{ build.git_commit }}</a></p> <p>Commit: <a href="{{ build.repo_commit_link }}">{{ build.git_commit }}</a></p>
<p>Status: {{ build.status }}</p> <p>Status: {{ build.status }}</p>
<p>
Logs:
<a href="/api/v1/builds/{{ build.name }}/init-log" target="_blank">build log</a>
|
<a href="/api/v1/builds/{{ build.name }}/log" target="_blank">run log</a>
</p>
{% if build.status == 'started' %} {% if build.status == 'started' %}
<p><a href="{{ build.deploy_link }}">=> live</a></p> <p><a href="{{ build.deploy_link }}">=> live</a></p>
<button onclick="stop()">stop</button> <button onclick="stop()">stop</button>