SSE endpoint for build events

And dyamic build.html page.
This commit is contained in:
Stéphane Bidoul 2021-11-16 21:33:16 +01:00
parent 03e6fa4795
commit 31beee0a47
No known key found for this signature in database
GPG key ID: BCAB2555446B5B92
11 changed files with 202 additions and 72 deletions

View file

@ -17,6 +17,7 @@ dependencies = [
"jinja2", "jinja2",
"kubernetes", "kubernetes",
"rich", "rich",
"sse-starlette",
"uvicorn", "uvicorn",
] ]
dynamic = ["version", "description"] dynamic = ["version", "description"]

View file

@ -32,6 +32,7 @@ rich==10.13.0
rsa==4.7.2 rsa==4.7.2
six==1.16.0 six==1.16.0
sniffio==1.2.0 sniffio==1.2.0
sse-starlette==0.9.0
starlette==0.16.0 starlette==0.16.0
typing-extensions==3.10.0.2 typing-extensions==3.10.0.2
urllib3==1.26.7 urllib3==1.26.7

View file

@ -1,10 +1,12 @@
import asyncio
import datetime import datetime
from typing import Optional from typing import AsyncGenerator, Optional
from ansi2html import Ansi2HTMLConverter 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 fastapi.responses import HTMLResponse
from pydantic import BaseModel from pydantic import BaseModel
from sse_starlette.sse import EventSourceResponse
from starlette.status import HTTP_404_NOT_FOUND from starlette.status import HTTP_404_NOT_FOUND
from . import github, models from . import github, models
@ -59,6 +61,11 @@ class Build(BaseModel):
read_with_orm_mode = True read_with_orm_mode = True
class BuildEvent(BaseModel):
event: models.BuildEvent
build: Build
@router.get("/status", response_model=Status) @router.get("/status", response_model=Status)
async def controller_status() -> Controller: async def controller_status() -> Controller:
return controller return controller
@ -75,7 +82,7 @@ async def repos() -> list[models.Repo]:
response_model_exclude_none=True, response_model_exclude_none=True,
) )
async def builds(repo: Optional[str] = None) -> list[models.Build]: async def builds(repo: Optional[str] = None) -> list[models.Build]:
return controller.db.search(repo) return list(controller.db.search(repo))
@router.post( @router.post(
@ -163,3 +170,82 @@ async def delete(name: str) -> None:
"""Delete the deployment and drop the database.""" """Delete the deployment and drop the database."""
build = await _build_by_name(name) build = await _build_by_name(name)
await build.undeploy() 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 = """
<!DOCTYPE html>
<html>
<head>
<title>SSE Test</title>
</head>
<body>
<h1>SSE Test</h1>
<ul id='messages'>
</ul>
<script>
const evtSource = new EventSource("/api/v1/build-events");
evtSource.onmessage = function(event) {
var messages = document.getElementById('messages')
var message = document.createElement('li')
oEvent = JSON.parse(event.data);
var content = document.createTextNode(
`${oEvent.event} - ${oEvent.build.name} ${oEvent.build.status}`
)
message.appendChild(content)
messages.insertBefore(message, messages.firstChild)
};
</script>
</body>
</html>
"""
@router.get("/estest")
async def get() -> HTMLResponse:
return HTMLResponse(eshtml)

View file

@ -1,4 +1,7 @@
from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from . import __version__, api, controller, k8s, webhooks, webui 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(api.router, prefix="/api/v1", tags=["api"])
app.include_router(webhooks.router, tags=["webhooks"]) app.include_router(webhooks.router, tags=["webhooks"])
app.include_router(webui.router, tags=["webui"]) app.include_router(webui.router, tags=["webui"])
app.mount(
"/webui", StaticFiles(directory=Path(__file__).parent / "webui"), name="webui"
)
@app.on_event("startup") @app.on_event("startup")

View file

@ -3,8 +3,8 @@ import logging
from typing import Any, Awaitable, Callable from typing import Any, Awaitable, Callable
from . import k8s from . import k8s
from .db import BuildEvent, BuildsDb from .db import BuildsDb
from .models import Build, BuildInitStatus, BuildStatus from .models import Build, BuildEvent, BuildInitStatus, BuildStatus
from .settings import settings from .settings import settings
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@ -43,7 +43,7 @@ class Controller:
self.db = BuildsDb() self.db = BuildsDb()
self.db.register_listener(self) 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() self._wakeup()
@property @property

View file

@ -1,21 +1,15 @@
import logging import logging
import sqlite3 import sqlite3
from enum import Enum from typing import Iterator, Protocol, cast
from typing import Protocol, cast
from weakref import WeakSet from weakref import WeakSet
from .models import Build, BuildInitStatus, BuildStatus from .models import Build, BuildEvent, BuildInitStatus, BuildStatus
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
class BuildEvent(Enum):
modified = 0
removed = 1
class BuildListener(Protocol): 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,)) self._con.execute("DELETE FROM builds WHERE name=?", (name,))
_logger.info("Noticed removal of %s", name) _logger.info("Noticed removal of %s", name)
for listener in self._listeners: for listener in self._listeners:
listener.build_updated(build, BuildEvent.removed) listener.on_build_event(BuildEvent.removed, build)
def add(self, build: Build) -> None: def add(self, build: Build) -> None:
prev_build = self.get(build.name) prev_build = self.get(build.name)
@ -147,7 +141,7 @@ class BuildsDb:
build.last_scaled, build.last_scaled,
) )
for listener in self._listeners: 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: def count_by_status(self, status: BuildStatus) -> int:
count = self._con.execute( count = self._con.execute(
@ -190,15 +184,21 @@ class BuildsDb:
).fetchall() ).fetchall()
return [self._build_from_row(row) for row in rows] 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 " query = "SELECT * FROM builds "
where = [] where = []
params = [] params = []
if repo: if repo:
where.append("repo=?") where.append("repo=?")
params.append(repo.lower()) params.append(repo.lower())
if name:
where.append("name=?")
params.append(name)
if where: if where:
query += "WHERE " + " AND ".join(where) query += "WHERE " + " AND ".join(where)
query += "ORDER BY repo, target_branch, pr, created DESC" query += "ORDER BY repo, target_branch, pr, created DESC"
rows = self._con.execute(query, params).fetchall() 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)

View file

@ -16,6 +16,11 @@ from .utils import slugify
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
class BuildEvent(str, Enum):
modified = "upd"
removed = "del"
class BuildStatus(str, Enum): class BuildStatus(str, Enum):
stopped = "stopped" # initialization succeeded and 0 replicas stopped = "stopped" # initialization succeeded and 0 replicas
stopping = "stopping" # 0 desired replicas but some are still running stopping = "stopping" # 0 desired replicas but some are still running

View file

@ -1,24 +1,19 @@
from pathlib import Path
from typing import Optional 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.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from .controller import controller from .controller import controller
from .models import BuildStatus from .models import BuildStatus
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory=str(Path(__file__).parent / "webui"))
@router.get("/builds/{name}", response_class=HTMLResponse) @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) build = controller.db.get(name)
if not build: if not build:
raise HTTPException(status.HTTP_404_NOT_FOUND) raise HTTPException(status.HTTP_404_NOT_FOUND)
if live is not None and build.status == BuildStatus.started: if live is not None and build.status == BuildStatus.started:
return RedirectResponse(url=build.deploy_link) return RedirectResponse(url=build.deploy_link)
return templates.TemplateResponse( return RedirectResponse(url=f"/webui/build.html?name={name}")
"build.html.jinja", {"request": request, "build": build}
)

View file

@ -0,0 +1,74 @@
<html>
<head>
<title>Runboat build</title>
</head>
<body>
<runboat-build id="build"></runboat-build>
<script type="module">
import {LitElement, html} from 'https://unpkg.com/lit@2.0.2?module';
class RunboatBuild extends LitElement {
static get properties() {
return {
build: {}
}
}
constructor() {
super();
this.build = {};
}
render() {
return html`
<p>Build: ${this.build.name}
${this.build.status == "started"?
html`<a href="${this.build.deploy_link}">=&gt; live</a>`:""
}
</p>
<p>
Repo: ${this.build.repo}
${this.build.pr?
html`PR <a href="${this.build.repo_link}">${this.build.pr}</a> to`:""
}
<a href="${this.build.repo_link}">${this.build.target_branch}</a>
${this.build.git_commit?
html`(<a href="${this.build.repo_commit_link}">${this.build.git_commit.substring(0, 8)}</a>)`:""
}
</p>
<p>Status: ${this.build.status}</p>
<p>Logs:
<a href="/api/v1/builds/${this.build.name}/init-log">init log</a>
${this.build.status == "started"?
html`| <a href="/api/v1/builds/${this.build.name}/log">log</a>`:""
}
</p>
<p>
<button @click="${this.stopHandler}" ?disabled="${this.build.status != "started"}">stop</button>
<button @click="${this.startHandler}" ?disabled="${this.build.status != "stopped"}">start</button>
</p>
`;
}
startHandler(e) {
fetch(`/api/v1/builds/${this.build.name}/start`, {method: 'POST'});
}
stopHandler(e) {
fetch(`/api/v1/builds/${this.build.name}/stop`, {method: 'POST'});
}
}
customElements.define('runboat-build', RunboatBuild);
const buildName = new URLSearchParams(window.location.search).get("name");
const buildElement = document.getElementById("build");
const evtSource = new EventSource(`/api/v1/build-events?build_name=${buildName}`);
evtSource.onmessage = function(event) {
var oEvent = JSON.parse(event.data);
buildElement.build = oEvent.event == "upd" ? oEvent.build : {};
}
// TODO: evtSource error handling
</script>
</body>
</html>

View file

@ -1,38 +0,0 @@
<html>
<head>
<title>Runboat build {{ build.name }} for {{ build.repo }}</title>
<script>
build_name = "{{ build.name }}";
function start() {
fetch(`/api/v1/builds/${build_name}/start`, options={method: 'POST'})
}
function stop() {
fetch(`/api/v1/builds/${build_name}/stop`, options={method: 'POST'})
}
</script>
<body>
<p>Repo: {{ build.repo }}</p>
{% if build.pr %}
<p>PR: <a href="{{ build.repo_link }}">{{ build.pr }}</a> to {{ build.target_branch }}</p>
{% else %}
<p>Branch: <a href="{{ build.repo_link }}">{{ build.target_branch }}</a></p>
{% endif %}
<p>Commit: <a href="{{ build.repo_commit_link }}">{{ build.git_commit }}</a></p>
<p>Status: {{ build.status.value }}</p>
<p>
Logs:
<a href="/api/v1/builds/{{ build.name }}/init-log" target="_blank">init log</a>
{% if build.status == 'started' %}
|
<a href="/api/v1/builds/{{ build.name }}/log" target="_blank">log</a>
|
<a href="{{ build.deploy_link }}">=> live</a>
{% endif %}
</p>
{% if build.status == 'started' %}
<button onclick="stop()">stop</button>
{% else %}
<button onclick="start()">start</button>
{% endif %}
</body>
</html>

View file

@ -35,12 +35,12 @@ def test_add() -> None:
listener = MagicMock() listener = MagicMock()
db.register_listener(listener) db.register_listener(listener)
db.add(_make_build()) # new db.add(_make_build()) # new
listener.build_updated.assert_called() listener.on_build_event.assert_called()
listener.reset_mock() listener.reset_mock()
db.add(_make_build()) # no change 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)) db.add(_make_build(status=BuildStatus.failed))
listener.build_updated.assert_called() listener.on_build_event.assert_called()
def test_remove() -> None: def test_remove() -> None:
@ -48,11 +48,11 @@ def test_remove() -> None:
listener = MagicMock() listener = MagicMock()
db.register_listener(listener) db.register_listener(listener)
db.remove("not-a-build") db.remove("not-a-build")
listener.build_updated.assert_not_called() listener.on_build_event.assert_not_called()
build = _make_build() build = _make_build()
db.add(build) db.add(build)
db.remove(build.name) db.remove(build.name)
listener.build_updated.assert_called() listener.on_build_event.assert_called()
def test_get_for_commit() -> None: def test_get_for_commit() -> None:
@ -77,8 +77,8 @@ def test_search() -> None:
db = BuildsDb() db = BuildsDb()
db.add(build1 := _make_build(name="b1", repo="oca/repo1")) db.add(build1 := _make_build(name="b1", repo="oca/repo1"))
db.add(_make_build(name="b2", repo="oca/repo2")) db.add(_make_build(name="b2", repo="oca/repo2"))
assert len(db.search()) == 2 assert len(list(db.search())) == 2
assert db.search("oca/repo1") == [build1] assert list(db.search("oca/repo1")) == [build1]
def test_count_by_status() -> None: def test_count_by_status() -> None: