diff --git a/pyproject.toml b/pyproject.toml index ca069ad..1940528 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "jinja2", "kubernetes", "rich", + "sse-starlette", "uvicorn", ] dynamic = ["version", "description"] diff --git a/requirements.txt b/requirements.txt index e65b216..19a1355 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,6 +32,7 @@ rich==10.13.0 rsa==4.7.2 six==1.16.0 sniffio==1.2.0 +sse-starlette==0.9.0 starlette==0.16.0 typing-extensions==3.10.0.2 urllib3==1.26.7 diff --git a/src/runboat/api.py b/src/runboat/api.py index ca81e4c..fb994e6 100644 --- a/src/runboat/api.py +++ b/src/runboat/api.py @@ -1,10 +1,12 @@ +import asyncio import datetime -from typing import Optional +from typing import AsyncGenerator, Optional from ansi2html import Ansi2HTMLConverter -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import HTMLResponse from pydantic import BaseModel +from sse_starlette.sse import EventSourceResponse from starlette.status import HTTP_404_NOT_FOUND from . import github, models @@ -59,6 +61,11 @@ class Build(BaseModel): read_with_orm_mode = True +class BuildEvent(BaseModel): + event: models.BuildEvent + build: Build + + @router.get("/status", response_model=Status) async def controller_status() -> Controller: return controller @@ -75,7 +82,7 @@ async def repos() -> list[models.Repo]: response_model_exclude_none=True, ) async def builds(repo: Optional[str] = None) -> list[models.Build]: - return controller.db.search(repo) + return list(controller.db.search(repo)) @router.post( @@ -163,3 +170,82 @@ async def delete(name: str) -> None: """Delete the deployment and drop the database.""" build = await _build_by_name(name) await build.undeploy() + + +class BuildEventSource: + def __init__( + self, request: Request, repo: str | None = None, build_name: str | None = None + ): + self.queue: asyncio.Queue[str] = asyncio.Queue() + self.request = request + self.repo = repo + self.build_name = build_name + controller.db.register_listener(self) + + @classmethod + def _serialize(cls, event: models.BuildEvent, build: models.Build) -> str: + return BuildEvent(event=event, build=Build.from_orm(build)).json() + + def on_build_event(self, event: models.BuildEvent, build: models.Build) -> None: + if self.repo and build.repo != self.repo: + return + if self.build_name and build.name != self.build_name: + return + self.queue.put_nowait(self._serialize(event, build)) + + async def events(self) -> AsyncGenerator[str, None]: + for build in controller.db.search(self.repo, self.build_name): + yield self._serialize(models.BuildEvent.modified, build) + while True: + try: + event = await asyncio.wait_for(self.queue.get(), timeout=10) + except asyncio.TimeoutError: + pass + else: + yield event + # Check if the client is still there and wait for events again. + if await self.request.is_disconnected(): + break + + +@router.get("/build-events") +async def eventsource_endpoint( + request: Request, + repo: Optional[str] = None, + build_name: Optional[str] = None, +) -> EventSourceResponse: + event_source = BuildEventSource(request, repo, build_name) + return EventSourceResponse(event_source.events()) + + +eshtml = """ + + + + SSE Test + + +

SSE Test

+ + + + +""" + + +@router.get("/estest") +async def get() -> HTMLResponse: + return HTMLResponse(eshtml) diff --git a/src/runboat/app.py b/src/runboat/app.py index aaeeb46..103924d 100644 --- a/src/runboat/app.py +++ b/src/runboat/app.py @@ -1,4 +1,7 @@ +from pathlib import Path + from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles from . import __version__, api, controller, k8s, webhooks, webui @@ -8,6 +11,9 @@ app = FastAPI( app.include_router(api.router, prefix="/api/v1", tags=["api"]) app.include_router(webhooks.router, tags=["webhooks"]) app.include_router(webui.router, tags=["webui"]) +app.mount( + "/webui", StaticFiles(directory=Path(__file__).parent / "webui"), name="webui" +) @app.on_event("startup") diff --git a/src/runboat/controller.py b/src/runboat/controller.py index d0d5797..01cf222 100644 --- a/src/runboat/controller.py +++ b/src/runboat/controller.py @@ -3,8 +3,8 @@ import logging from typing import Any, Awaitable, Callable from . import k8s -from .db import BuildEvent, BuildsDb -from .models import Build, BuildInitStatus, BuildStatus +from .db import BuildsDb +from .models import Build, BuildEvent, BuildInitStatus, BuildStatus from .settings import settings _logger = logging.getLogger(__name__) @@ -43,7 +43,7 @@ class Controller: self.db = BuildsDb() self.db.register_listener(self) - def build_updated(self, build: Build, event: BuildEvent) -> None: + def on_build_event(self, event: BuildEvent, build: Build) -> None: self._wakeup() @property diff --git a/src/runboat/db.py b/src/runboat/db.py index 69569ea..249117f 100644 --- a/src/runboat/db.py +++ b/src/runboat/db.py @@ -1,21 +1,15 @@ import logging import sqlite3 -from enum import Enum -from typing import Protocol, cast +from typing import Iterator, Protocol, cast from weakref import WeakSet -from .models import Build, BuildInitStatus, BuildStatus +from .models import Build, BuildEvent, BuildInitStatus, BuildStatus _logger = logging.getLogger(__name__) -class BuildEvent(Enum): - modified = 0 - removed = 1 - - class BuildListener(Protocol): - def build_updated(self, build: Build, event: BuildEvent) -> None: + def on_build_event(self, event: BuildEvent, build: Build) -> None: ... @@ -94,7 +88,7 @@ class BuildsDb: self._con.execute("DELETE FROM builds WHERE name=?", (name,)) _logger.info("Noticed removal of %s", name) for listener in self._listeners: - listener.build_updated(build, BuildEvent.removed) + listener.on_build_event(BuildEvent.removed, build) def add(self, build: Build) -> None: prev_build = self.get(build.name) @@ -147,7 +141,7 @@ class BuildsDb: build.last_scaled, ) for listener in self._listeners: - listener.build_updated(build, BuildEvent.modified) + listener.on_build_event(BuildEvent.modified, build) def count_by_status(self, status: BuildStatus) -> int: count = self._con.execute( @@ -190,15 +184,21 @@ class BuildsDb: ).fetchall() return [self._build_from_row(row) for row in rows] - def search(self, repo: str | None = None) -> list[Build]: + def search( + self, repo: str | None = None, name: str | None = None + ) -> Iterator[Build]: query = "SELECT * FROM builds " where = [] params = [] if repo: where.append("repo=?") params.append(repo.lower()) + if name: + where.append("name=?") + params.append(name) if where: query += "WHERE " + " AND ".join(where) query += "ORDER BY repo, target_branch, pr, created DESC" rows = self._con.execute(query, params).fetchall() - return [self._build_from_row(row) for row in rows] + for row in rows: + yield self._build_from_row(row) diff --git a/src/runboat/models.py b/src/runboat/models.py index 7a2c483..5b0acb0 100644 --- a/src/runboat/models.py +++ b/src/runboat/models.py @@ -16,6 +16,11 @@ from .utils import slugify _logger = logging.getLogger(__name__) +class BuildEvent(str, Enum): + modified = "upd" + removed = "del" + + class BuildStatus(str, Enum): stopped = "stopped" # initialization succeeded and 0 replicas stopping = "stopping" # 0 desired replicas but some are still running diff --git a/src/runboat/webui.py b/src/runboat/webui.py index 9f4a99f..ea1dd3e 100644 --- a/src/runboat/webui.py +++ b/src/runboat/webui.py @@ -1,24 +1,19 @@ -from pathlib import Path from typing import Optional -from fastapi import APIRouter, HTTPException, Request, Response, status +from fastapi import APIRouter, HTTPException, Response, status from fastapi.responses import HTMLResponse, RedirectResponse -from fastapi.templating import Jinja2Templates from .controller import controller from .models import BuildStatus router = APIRouter() -templates = Jinja2Templates(directory=str(Path(__file__).parent / "webui")) @router.get("/builds/{name}", response_class=HTMLResponse) -async def build(request: Request, name: str, live: Optional[str] = None) -> Response: +async def build(name: str, live: Optional[str] = None) -> Response: build = controller.db.get(name) if not build: raise HTTPException(status.HTTP_404_NOT_FOUND) if live is not None and build.status == BuildStatus.started: return RedirectResponse(url=build.deploy_link) - return templates.TemplateResponse( - "build.html.jinja", {"request": request, "build": build} - ) + return RedirectResponse(url=f"/webui/build.html?name={name}") diff --git a/src/runboat/webui/build.html b/src/runboat/webui/build.html new file mode 100644 index 0000000..9fa05f1 --- /dev/null +++ b/src/runboat/webui/build.html @@ -0,0 +1,74 @@ + + + Runboat build + + + + + + diff --git a/src/runboat/webui/build.html.jinja b/src/runboat/webui/build.html.jinja deleted file mode 100644 index 39c53ba..0000000 --- a/src/runboat/webui/build.html.jinja +++ /dev/null @@ -1,38 +0,0 @@ - - - Runboat build {{ build.name }} for {{ build.repo }} - - -

Repo: {{ build.repo }}

- {% if build.pr %} -

PR: {{ build.pr }} to {{ build.target_branch }}

- {% else %} -

Branch: {{ build.target_branch }}

- {% endif %} -

Commit: {{ build.git_commit }}

-

Status: {{ build.status.value }}

-

- Logs: - init log - {% if build.status == 'started' %} - | - log - | - => live - {% endif %} -

- {% if build.status == 'started' %} - - {% else %} - - {% endif %} - - diff --git a/tests/test_db.py b/tests/test_db.py index c6b2f83..56251dc 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -35,12 +35,12 @@ def test_add() -> None: listener = MagicMock() db.register_listener(listener) db.add(_make_build()) # new - listener.build_updated.assert_called() + listener.on_build_event.assert_called() listener.reset_mock() db.add(_make_build()) # no change - listener.build_updated.assert_not_called() + listener.on_build_event.assert_not_called() db.add(_make_build(status=BuildStatus.failed)) - listener.build_updated.assert_called() + listener.on_build_event.assert_called() def test_remove() -> None: @@ -48,11 +48,11 @@ def test_remove() -> None: listener = MagicMock() db.register_listener(listener) db.remove("not-a-build") - listener.build_updated.assert_not_called() + listener.on_build_event.assert_not_called() build = _make_build() db.add(build) db.remove(build.name) - listener.build_updated.assert_called() + listener.on_build_event.assert_called() def test_get_for_commit() -> None: @@ -77,8 +77,8 @@ def test_search() -> None: db = BuildsDb() db.add(build1 := _make_build(name="b1", repo="oca/repo1")) db.add(_make_build(name="b2", repo="oca/repo2")) - assert len(db.search()) == 2 - assert db.search("oca/repo1") == [build1] + assert len(list(db.search())) == 2 + assert list(db.search("oca/repo1")) == [build1] def test_count_by_status() -> None: