Build log endpoints
This commit is contained in:
parent
b80508eed9
commit
8a4797bf02
10 changed files with 77 additions and 18 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ classifiers = [
|
||||||
"License :: OSI Approved :: MIT License",
|
"License :: OSI Approved :: MIT License",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"ansi2html",
|
||||||
"fastapi",
|
"fastapi",
|
||||||
"gunicorn",
|
"gunicorn",
|
||||||
"httpx",
|
"httpx",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue