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

View file

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

View file

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

View file

@ -1,9 +1,11 @@
import datetime
from typing import Optional
from ansi2html import Ansi2HTMLConverter
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import StreamingResponse
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from starlette.status import HTTP_404_NOT_FOUND
from . import github, models
from .controller import controller
@ -120,22 +122,26 @@ async def build(name: str):
@router.get(
"/builds/{name}/init-log",
response_class=StreamingResponse,
responses={200: {"content": {"text/plain": {}}}},
response_class=HTMLResponse,
)
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(
"/builds/{name}/log",
response_class=StreamingResponse,
responses={200: {"content": {"text/plain": {}}}},
response_class=HTMLResponse,
)
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")

View file

@ -255,3 +255,34 @@ async def delete_job(build_name: str, job_kind: DeploymentMode) -> None:
"--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
spec:
template:
metadata:
labels:
runboat/job-kind: cleanup
spec:
containers:
- name: cleanup

View file

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

View file

@ -156,6 +156,12 @@ class Build(BaseModel):
def live_link(self) -> str:
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
async def deploy(
cls, repo: str, target_branch: str, pr: int | None, git_commit: str

View file

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

View file

@ -19,6 +19,12 @@
{% endif %}
<p>Commit: <a href="{{ build.repo_commit_link }}">{{ build.git_commit }}</a></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' %}
<p><a href="{{ build.deploy_link }}">=> live</a></p>
<button onclick="stop()">stop</button>