commit
020c6609b1
15 changed files with 78 additions and 46 deletions
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
|
|
@ -16,15 +16,17 @@ jobs:
|
|||
- name: Install project
|
||||
run: |
|
||||
pip install -U "pip>=21.3.1"
|
||||
pip install -e .[test] -c requirements.txt -c requirements-test.txt
|
||||
pip install -e .[test,mypy] -c requirements.txt -c requirements-test.txt -c requirements-mypy.txt
|
||||
- name: Run tests
|
||||
run: |
|
||||
pytest -v --cov --cov-report=xml ./tests
|
||||
run: pytest -v --cov --cov-report=xml ./tests
|
||||
- name: Run mypy
|
||||
run: mypy ./src/runboat ./tests
|
||||
- uses: codecov/codecov-action@v1
|
||||
build-image:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- test
|
||||
if: ${{ github.repository_owner == 'sbidoul' && github.ref == 'refs/heads/main' }}
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
|
|
|||
|
|
@ -39,4 +39,4 @@ repos:
|
|||
rev: v2.29.0
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: ["--py38-plus"]
|
||||
args: ["--py39-plus"]
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ test = [
|
|||
"pytest-cov",
|
||||
"pytest-dotenv",
|
||||
]
|
||||
mypy = [
|
||||
"mypy",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Home = "https://github.com/sbidoul/runboat"
|
||||
|
|
@ -39,3 +42,23 @@ env_override_existing_values = 1
|
|||
env_files = [".env.test"]
|
||||
|
||||
# flake8 config is in .flake8
|
||||
|
||||
[tool.mypy]
|
||||
strict = true
|
||||
show_error_codes = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "uvicorn.*"
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "urllib3.*"
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "kubernetes.*"
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "ansi2html"
|
||||
ignore_missing_imports = true
|
||||
|
|
|
|||
4
requirements-mypy.txt
Normal file
4
requirements-mypy.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# frozen requirements generated by pip-deepfreeze
|
||||
mypy==0.910
|
||||
mypy-extensions==0.4.3
|
||||
toml==0.10.2
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
import datetime
|
||||
from typing import Optional
|
||||
|
||||
from ansi2html import Ansi2HTMLConverter # type: ignore
|
||||
from ansi2html import Ansi2HTMLConverter
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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
|
||||
from .controller import Controller, controller
|
||||
from .deps import authenticated
|
||||
from .settings import settings
|
||||
|
||||
|
|
@ -60,12 +60,12 @@ class Build(BaseModel):
|
|||
|
||||
|
||||
@router.get("/status", response_model=Status)
|
||||
async def controller_status():
|
||||
async def controller_status() -> Controller:
|
||||
return controller
|
||||
|
||||
|
||||
@router.get("/repos", response_model=list[Repo])
|
||||
async def repos():
|
||||
async def repos() -> list[models.Repo]:
|
||||
return [models.Repo(name=name) for name in settings.supported_repos]
|
||||
|
||||
|
||||
|
|
@ -74,7 +74,7 @@ async def repos():
|
|||
response_model=list[Build],
|
||||
response_model_exclude_none=True,
|
||||
)
|
||||
async def builds(repo: Optional[str] = None):
|
||||
async def builds(repo: Optional[str] = None) -> list[models.Build]:
|
||||
return controller.db.search(repo)
|
||||
|
||||
|
||||
|
|
@ -82,7 +82,7 @@ async def builds(repo: Optional[str] = None):
|
|||
"/builds/trigger/branch",
|
||||
dependencies=[Depends(authenticated)],
|
||||
)
|
||||
async def trigger_branch(repo: str, branch: str):
|
||||
async def trigger_branch(repo: str, branch: str) -> None:
|
||||
"""Trigger build for a branch."""
|
||||
branch_info = await github.get_branch_info(repo, branch)
|
||||
await controller.deploy_or_start(
|
||||
|
|
@ -97,7 +97,7 @@ async def trigger_branch(repo: str, branch: str):
|
|||
"/builds/trigger/pr",
|
||||
dependencies=[Depends(authenticated)],
|
||||
)
|
||||
async def trigger_pull(repo: str, pr: int):
|
||||
async def trigger_pull(repo: str, pr: int) -> None:
|
||||
"""Trigger build for a pull request."""
|
||||
pull_info = await github.get_pull_info(repo, pr)
|
||||
await controller.deploy_or_start(
|
||||
|
|
@ -116,7 +116,7 @@ async def _build_by_name(name: str) -> models.Build:
|
|||
|
||||
|
||||
@router.get("/builds/{name}", response_model=Build)
|
||||
async def build(name: str):
|
||||
async def build(name: str) -> models.Build:
|
||||
return await _build_by_name(name)
|
||||
|
||||
|
||||
|
|
@ -124,42 +124,42 @@ async def build(name: str):
|
|||
"/builds/{name}/init-log",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def init_log(name: str):
|
||||
async def init_log(name: str) -> str:
|
||||
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)
|
||||
return Ansi2HTMLConverter().convert(log) # type: ignore [no-any-return]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/builds/{name}/log",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def log(name: str):
|
||||
async def log(name: str) -> str:
|
||||
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)
|
||||
return Ansi2HTMLConverter().convert(log) # type: ignore [no-any-return]
|
||||
|
||||
|
||||
@router.post("/builds/{name}/start")
|
||||
async def start(name: str):
|
||||
async def start(name: str) -> None:
|
||||
"""Start the deployment."""
|
||||
build = await _build_by_name(name)
|
||||
await build.start()
|
||||
|
||||
|
||||
@router.post("/builds/{name}/stop")
|
||||
async def stop(name: str):
|
||||
async def stop(name: str) -> None:
|
||||
"""Stop the deployment."""
|
||||
build = await _build_by_name(name)
|
||||
await build.stop()
|
||||
|
||||
|
||||
@router.delete("/builds/{name}", dependencies=[Depends(authenticated)])
|
||||
async def delete(name: str):
|
||||
async def delete(name: str) -> None:
|
||||
"""Delete the deployment and drop the database."""
|
||||
build = await _build_by_name(name)
|
||||
await build.undeploy()
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ class Controller:
|
|||
"""
|
||||
|
||||
db: BuildsDb
|
||||
_tasks: list[asyncio.Task]
|
||||
_tasks: list[asyncio.Task[None]]
|
||||
_wakeup_initializer: asyncio.Event
|
||||
_wakeup_stopper: asyncio.Event
|
||||
_wakeup_undeployer: asyncio.Event
|
||||
|
|
|
|||
|
|
@ -11,12 +11,12 @@ from importlib import resources
|
|||
from pathlib import Path
|
||||
from typing import Any, Callable, Generator, Optional, TypedDict, cast
|
||||
|
||||
import urllib3 # type: ignore
|
||||
import urllib3
|
||||
from jinja2 import Template
|
||||
from kubernetes import client, config, watch # type: ignore
|
||||
from kubernetes.client.exceptions import ApiException # type: ignore
|
||||
from kubernetes.client.models.v1_deployment import V1Deployment # type: ignore
|
||||
from kubernetes.client.models.v1_job import V1Job # type: ignore
|
||||
from kubernetes import client, config, watch
|
||||
from kubernetes.client.exceptions import ApiException
|
||||
from kubernetes.client.models.v1_deployment import V1Deployment
|
||||
from kubernetes.client.models.v1_job import V1Job
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .settings import settings
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import uuid
|
|||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from kubernetes.client.models.v1_deployment import V1Deployment # type: ignore
|
||||
from kubernetes.client.models.v1_deployment import V1Deployment
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import github, k8s
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from uvicorn.workers import UvicornWorker # type: ignore
|
||||
from uvicorn.workers import UvicornWorker
|
||||
|
||||
from .settings import settings
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
|
|
@ -13,12 +13,12 @@ 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):
|
||||
async def build(request: Request, 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", {"request": request, "build": build}
|
||||
"build.html.jinja", {"request": request, "build": build}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,15 +18,18 @@
|
|||
<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 }}</p>
|
||||
<p>Status: {{ build.status.value }}</p>
|
||||
<p>
|
||||
Logs:
|
||||
<a href="/api/v1/builds/{{ build.name }}/init-log" target="_blank">build log</a>
|
||||
<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">run log</a>
|
||||
<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' %}
|
||||
<p><a href="{{ build.deploy_link }}">=> live</a></p>
|
||||
<button onclick="stop()">stop</button>
|
||||
{% else %}
|
||||
<button onclick="start()">start</button>
|
||||
|
|
@ -4,20 +4,20 @@ from runboat.build_images import get_build_image, get_main_branch, is_branch_sup
|
|||
from runboat.exceptions import BranchNotSupported
|
||||
|
||||
|
||||
def test_get_main_branch():
|
||||
def test_get_main_branch() -> None:
|
||||
assert get_main_branch("15.0") == "15.0"
|
||||
assert get_main_branch("15.0-ocabot-merge") == "15.0"
|
||||
with pytest.raises(BranchNotSupported):
|
||||
get_main_branch("8.0")
|
||||
|
||||
|
||||
def test_is_branch_supported():
|
||||
def test_is_branch_supported() -> None:
|
||||
assert is_branch_supported("15.0")
|
||||
assert is_branch_supported("15.0-ocabot-merge")
|
||||
assert not is_branch_supported("8.0")
|
||||
|
||||
|
||||
def test_get_build_image():
|
||||
def test_get_build_image() -> None:
|
||||
assert get_build_image("15.0") == "ghcr.io/oca/oca-ci/py3.8-odoo15.0:latest"
|
||||
assert get_build_image("15.0-zzz") == "ghcr.io/oca/oca-ci/py3.8-odoo15.0:latest"
|
||||
with pytest.raises(BranchNotSupported):
|
||||
|
|
|
|||
|
|
@ -29,14 +29,14 @@ def _make_build(
|
|||
)
|
||||
|
||||
|
||||
def test_add():
|
||||
def test_add() -> None:
|
||||
db = BuildsDb()
|
||||
assert db.add(_make_build()) # new
|
||||
assert not db.add(_make_build()) # no change
|
||||
assert db.add(_make_build(status=BuildStatus.failed))
|
||||
|
||||
|
||||
def test_remove():
|
||||
def test_remove() -> None:
|
||||
db = BuildsDb()
|
||||
assert not db.remove("not-a-build")
|
||||
build = _make_build()
|
||||
|
|
@ -44,7 +44,7 @@ def test_remove():
|
|||
assert db.remove(build.name)
|
||||
|
||||
|
||||
def test_get_for_commit():
|
||||
def test_get_for_commit() -> None:
|
||||
db = BuildsDb()
|
||||
build = _make_build()
|
||||
db.add(build)
|
||||
|
|
@ -62,7 +62,7 @@ def test_get_for_commit():
|
|||
)
|
||||
|
||||
|
||||
def test_search():
|
||||
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"))
|
||||
|
|
@ -70,7 +70,7 @@ def test_search():
|
|||
assert db.search("oca/repo1") == [build1]
|
||||
|
||||
|
||||
def test_count_by_status():
|
||||
def test_count_by_status() -> None:
|
||||
db = BuildsDb()
|
||||
db.add(_make_build(name="b1", status=BuildStatus.started))
|
||||
db.add(_make_build(name="b2", status=BuildStatus.stopped))
|
||||
|
|
@ -79,7 +79,7 @@ def test_count_by_status():
|
|||
assert db.count_by_status(BuildStatus.failed) == 0
|
||||
|
||||
|
||||
def test_count_by_init_status():
|
||||
def test_count_by_init_status() -> None:
|
||||
db = BuildsDb()
|
||||
db.add(_make_build(name="b1", init_status=BuildInitStatus.started))
|
||||
db.add(_make_build(name="b2", init_status=BuildInitStatus.todo))
|
||||
|
|
@ -88,7 +88,7 @@ def test_count_by_init_status():
|
|||
assert db.count_by_init_status(BuildInitStatus.failed) == 0
|
||||
|
||||
|
||||
def test_count_all():
|
||||
def test_count_all() -> None:
|
||||
db = BuildsDb()
|
||||
assert db.count_all() == 0
|
||||
db.add(_make_build(name="b1"))
|
||||
|
|
|
|||
|
|
@ -10,5 +10,5 @@ from runboat.k8s import _split_image_name_tag
|
|||
("postgres:12", ("postgres", "12")),
|
||||
],
|
||||
)
|
||||
def test_split_image_name_tag(image, expected):
|
||||
def test_split_image_name_tag(image: str, expected: tuple[str, str]) -> None:
|
||||
assert _split_image_name_tag(image) == expected
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ patches:
|
|||
"""
|
||||
|
||||
|
||||
def test_render_kubefiles():
|
||||
def test_render_kubefiles() -> None:
|
||||
deployment_vars = make_deployment_vars(
|
||||
mode=DeploymentMode.deployment,
|
||||
build_name="build-name",
|
||||
|
|
|
|||
Loading…
Reference in a new issue