diff --git a/README.md b/README.md index 9a0d335..df5a532 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 3d15207..c1509c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ classifiers = [ "License :: OSI Approved :: MIT License", ] dependencies = [ + "ansi2html", "fastapi", "gunicorn", "httpx", diff --git a/requirements.txt b/requirements.txt index b7a1bfb..f8a7336 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/src/runboat/api.py b/src/runboat/api.py index e5a1a6b..1d254d4 100644 --- a/src/runboat/api.py +++ b/src/runboat/api.py @@ -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") diff --git a/src/runboat/k8s.py b/src/runboat/k8s.py index 5380e63..4cff7a9 100644 --- a/src/runboat/k8s.py +++ b/src/runboat/k8s.py @@ -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, + ) diff --git a/src/runboat/kubefiles/cleanup.yaml b/src/runboat/kubefiles/cleanup.yaml index b64b5ac..935018a 100644 --- a/src/runboat/kubefiles/cleanup.yaml +++ b/src/runboat/kubefiles/cleanup.yaml @@ -6,6 +6,9 @@ metadata: runboat/job-kind: cleanup spec: template: + metadata: + labels: + runboat/job-kind: cleanup spec: containers: - name: cleanup diff --git a/src/runboat/kubefiles/initialize.yaml b/src/runboat/kubefiles/initialize.yaml index ea0404a..a263f02 100644 --- a/src/runboat/kubefiles/initialize.yaml +++ b/src/runboat/kubefiles/initialize.yaml @@ -6,6 +6,9 @@ metadata: runboat/job-kind: initialize spec: template: + metadata: + labels: + runboat/job-kind: initialize spec: containers: - name: initalize diff --git a/src/runboat/models.py b/src/runboat/models.py index 3fb5e44..3166775 100644 --- a/src/runboat/models.py +++ b/src/runboat/models.py @@ -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 diff --git a/src/runboat/utils.py b/src/runboat/utils.py index 2d17fcb..38ebd31 100644 --- a/src/runboat/utils.py +++ b/src/runboat/utils.py @@ -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) diff --git a/src/runboat/webui/build.html b/src/runboat/webui/build.html index b79035f..6f63d09 100644 --- a/src/runboat/webui/build.html +++ b/src/runboat/webui/build.html @@ -19,6 +19,12 @@ {% endif %}
Commit: {{ build.git_commit }}
Status: {{ build.status }}
+ {% if build.status == 'started' %}