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
|
- name: Install project
|
||||||
run: |
|
run: |
|
||||||
pip install -U "pip>=21.3.1"
|
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
|
- name: Run tests
|
||||||
run: |
|
run: pytest -v --cov --cov-report=xml ./tests
|
||||||
pytest -v --cov --cov-report=xml ./tests
|
- name: Run mypy
|
||||||
|
run: mypy ./src/runboat ./tests
|
||||||
- uses: codecov/codecov-action@v1
|
- uses: codecov/codecov-action@v1
|
||||||
build-image:
|
build-image:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
- test
|
- test
|
||||||
|
if: ${{ github.repository_owner == 'sbidoul' && github.ref == 'refs/heads/main' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v1
|
||||||
|
|
|
||||||
|
|
@ -39,4 +39,4 @@ repos:
|
||||||
rev: v2.29.0
|
rev: v2.29.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyupgrade
|
- id: pyupgrade
|
||||||
args: ["--py38-plus"]
|
args: ["--py39-plus"]
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ test = [
|
||||||
"pytest-cov",
|
"pytest-cov",
|
||||||
"pytest-dotenv",
|
"pytest-dotenv",
|
||||||
]
|
]
|
||||||
|
mypy = [
|
||||||
|
"mypy",
|
||||||
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Home = "https://github.com/sbidoul/runboat"
|
Home = "https://github.com/sbidoul/runboat"
|
||||||
|
|
@ -39,3 +42,23 @@ env_override_existing_values = 1
|
||||||
env_files = [".env.test"]
|
env_files = [".env.test"]
|
||||||
|
|
||||||
# flake8 config is in .flake8
|
# 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
|
import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from ansi2html import Ansi2HTMLConverter # type: ignore
|
from ansi2html import Ansi2HTMLConverter
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from starlette.status import HTTP_404_NOT_FOUND
|
from starlette.status import HTTP_404_NOT_FOUND
|
||||||
|
|
||||||
from . import github, models
|
from . import github, models
|
||||||
from .controller import controller
|
from .controller import Controller, controller
|
||||||
from .deps import authenticated
|
from .deps import authenticated
|
||||||
from .settings import settings
|
from .settings import settings
|
||||||
|
|
||||||
|
|
@ -60,12 +60,12 @@ class Build(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status", response_model=Status)
|
@router.get("/status", response_model=Status)
|
||||||
async def controller_status():
|
async def controller_status() -> Controller:
|
||||||
return controller
|
return controller
|
||||||
|
|
||||||
|
|
||||||
@router.get("/repos", response_model=list[Repo])
|
@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]
|
return [models.Repo(name=name) for name in settings.supported_repos]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -74,7 +74,7 @@ async def repos():
|
||||||
response_model=list[Build],
|
response_model=list[Build],
|
||||||
response_model_exclude_none=True,
|
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)
|
return controller.db.search(repo)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -82,7 +82,7 @@ async def builds(repo: Optional[str] = None):
|
||||||
"/builds/trigger/branch",
|
"/builds/trigger/branch",
|
||||||
dependencies=[Depends(authenticated)],
|
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."""
|
"""Trigger build for a branch."""
|
||||||
branch_info = await github.get_branch_info(repo, branch)
|
branch_info = await github.get_branch_info(repo, branch)
|
||||||
await controller.deploy_or_start(
|
await controller.deploy_or_start(
|
||||||
|
|
@ -97,7 +97,7 @@ async def trigger_branch(repo: str, branch: str):
|
||||||
"/builds/trigger/pr",
|
"/builds/trigger/pr",
|
||||||
dependencies=[Depends(authenticated)],
|
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."""
|
"""Trigger build for a pull request."""
|
||||||
pull_info = await github.get_pull_info(repo, pr)
|
pull_info = await github.get_pull_info(repo, pr)
|
||||||
await controller.deploy_or_start(
|
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)
|
@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)
|
return await _build_by_name(name)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -124,42 +124,42 @@ async def build(name: str):
|
||||||
"/builds/{name}/init-log",
|
"/builds/{name}/init-log",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
)
|
)
|
||||||
async def init_log(name: str):
|
async def init_log(name: str) -> str:
|
||||||
build = await _build_by_name(name)
|
build = await _build_by_name(name)
|
||||||
log = await build.init_log()
|
log = await build.init_log()
|
||||||
if not log:
|
if not log:
|
||||||
raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail="No log found.")
|
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(
|
@router.get(
|
||||||
"/builds/{name}/log",
|
"/builds/{name}/log",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
)
|
)
|
||||||
async def log(name: str):
|
async def log(name: str) -> str:
|
||||||
build = await _build_by_name(name)
|
build = await _build_by_name(name)
|
||||||
log = await build.log()
|
log = await build.log()
|
||||||
if not log:
|
if not log:
|
||||||
raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail="No log found.")
|
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")
|
@router.post("/builds/{name}/start")
|
||||||
async def start(name: str):
|
async def start(name: str) -> None:
|
||||||
"""Start the deployment."""
|
"""Start the deployment."""
|
||||||
build = await _build_by_name(name)
|
build = await _build_by_name(name)
|
||||||
await build.start()
|
await build.start()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/builds/{name}/stop")
|
@router.post("/builds/{name}/stop")
|
||||||
async def stop(name: str):
|
async def stop(name: str) -> None:
|
||||||
"""Stop the deployment."""
|
"""Stop the deployment."""
|
||||||
build = await _build_by_name(name)
|
build = await _build_by_name(name)
|
||||||
await build.stop()
|
await build.stop()
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/builds/{name}", dependencies=[Depends(authenticated)])
|
@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."""
|
"""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()
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ class Controller:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
db: BuildsDb
|
db: BuildsDb
|
||||||
_tasks: list[asyncio.Task]
|
_tasks: list[asyncio.Task[None]]
|
||||||
_wakeup_initializer: asyncio.Event
|
_wakeup_initializer: asyncio.Event
|
||||||
_wakeup_stopper: asyncio.Event
|
_wakeup_stopper: asyncio.Event
|
||||||
_wakeup_undeployer: asyncio.Event
|
_wakeup_undeployer: asyncio.Event
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,12 @@ from importlib import resources
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Generator, Optional, TypedDict, cast
|
from typing import Any, Callable, Generator, Optional, TypedDict, cast
|
||||||
|
|
||||||
import urllib3 # type: ignore
|
import urllib3
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
from kubernetes import client, config, watch # type: ignore
|
from kubernetes import client, config, watch
|
||||||
from kubernetes.client.exceptions import ApiException # type: ignore
|
from kubernetes.client.exceptions import ApiException
|
||||||
from kubernetes.client.models.v1_deployment import V1Deployment # type: ignore
|
from kubernetes.client.models.v1_deployment import V1Deployment
|
||||||
from kubernetes.client.models.v1_job import V1Job # type: ignore
|
from kubernetes.client.models.v1_job import V1Job
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from .settings import settings
|
from .settings import settings
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import uuid
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional
|
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 pydantic import BaseModel
|
||||||
|
|
||||||
from . import github, k8s
|
from . import github, k8s
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from uvicorn.workers import UvicornWorker # type: ignore
|
from uvicorn.workers import UvicornWorker
|
||||||
|
|
||||||
from .settings import settings
|
from .settings import settings
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
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.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
|
@ -13,12 +13,12 @@ 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):
|
async def build(request: Request, 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 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>
|
<p>Branch: <a href="{{ build.repo_link }}">{{ build.target_branch }}</a></p>
|
||||||
{% 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.value }}</p>
|
||||||
<p>
|
<p>
|
||||||
Logs:
|
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>
|
</p>
|
||||||
{% if build.status == 'started' %}
|
{% if build.status == 'started' %}
|
||||||
<p><a href="{{ build.deploy_link }}">=> live</a></p>
|
|
||||||
<button onclick="stop()">stop</button>
|
<button onclick="stop()">stop</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
<button onclick="start()">start</button>
|
<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
|
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") == "15.0"
|
||||||
assert get_main_branch("15.0-ocabot-merge") == "15.0"
|
assert get_main_branch("15.0-ocabot-merge") == "15.0"
|
||||||
with pytest.raises(BranchNotSupported):
|
with pytest.raises(BranchNotSupported):
|
||||||
get_main_branch("8.0")
|
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")
|
||||||
assert is_branch_supported("15.0-ocabot-merge")
|
assert is_branch_supported("15.0-ocabot-merge")
|
||||||
assert not is_branch_supported("8.0")
|
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") == "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"
|
assert get_build_image("15.0-zzz") == "ghcr.io/oca/oca-ci/py3.8-odoo15.0:latest"
|
||||||
with pytest.raises(BranchNotSupported):
|
with pytest.raises(BranchNotSupported):
|
||||||
|
|
|
||||||
|
|
@ -29,14 +29,14 @@ def _make_build(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_add():
|
def test_add() -> None:
|
||||||
db = BuildsDb()
|
db = BuildsDb()
|
||||||
assert db.add(_make_build()) # new
|
assert db.add(_make_build()) # new
|
||||||
assert not db.add(_make_build()) # no change
|
assert not db.add(_make_build()) # no change
|
||||||
assert db.add(_make_build(status=BuildStatus.failed))
|
assert db.add(_make_build(status=BuildStatus.failed))
|
||||||
|
|
||||||
|
|
||||||
def test_remove():
|
def test_remove() -> None:
|
||||||
db = BuildsDb()
|
db = BuildsDb()
|
||||||
assert not db.remove("not-a-build")
|
assert not db.remove("not-a-build")
|
||||||
build = _make_build()
|
build = _make_build()
|
||||||
|
|
@ -44,7 +44,7 @@ def test_remove():
|
||||||
assert db.remove(build.name)
|
assert db.remove(build.name)
|
||||||
|
|
||||||
|
|
||||||
def test_get_for_commit():
|
def test_get_for_commit() -> None:
|
||||||
db = BuildsDb()
|
db = BuildsDb()
|
||||||
build = _make_build()
|
build = _make_build()
|
||||||
db.add(build)
|
db.add(build)
|
||||||
|
|
@ -62,7 +62,7 @@ def test_get_for_commit():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_search():
|
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"))
|
||||||
|
|
@ -70,7 +70,7 @@ def test_search():
|
||||||
assert db.search("oca/repo1") == [build1]
|
assert db.search("oca/repo1") == [build1]
|
||||||
|
|
||||||
|
|
||||||
def test_count_by_status():
|
def test_count_by_status() -> None:
|
||||||
db = BuildsDb()
|
db = BuildsDb()
|
||||||
db.add(_make_build(name="b1", status=BuildStatus.started))
|
db.add(_make_build(name="b1", status=BuildStatus.started))
|
||||||
db.add(_make_build(name="b2", status=BuildStatus.stopped))
|
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
|
assert db.count_by_status(BuildStatus.failed) == 0
|
||||||
|
|
||||||
|
|
||||||
def test_count_by_init_status():
|
def test_count_by_init_status() -> None:
|
||||||
db = BuildsDb()
|
db = BuildsDb()
|
||||||
db.add(_make_build(name="b1", init_status=BuildInitStatus.started))
|
db.add(_make_build(name="b1", init_status=BuildInitStatus.started))
|
||||||
db.add(_make_build(name="b2", init_status=BuildInitStatus.todo))
|
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
|
assert db.count_by_init_status(BuildInitStatus.failed) == 0
|
||||||
|
|
||||||
|
|
||||||
def test_count_all():
|
def test_count_all() -> None:
|
||||||
db = BuildsDb()
|
db = BuildsDb()
|
||||||
assert db.count_all() == 0
|
assert db.count_all() == 0
|
||||||
db.add(_make_build(name="b1"))
|
db.add(_make_build(name="b1"))
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,5 @@ from runboat.k8s import _split_image_name_tag
|
||||||
("postgres:12", ("postgres", "12")),
|
("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
|
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(
|
deployment_vars = make_deployment_vars(
|
||||||
mode=DeploymentMode.deployment,
|
mode=DeploymentMode.deployment,
|
||||||
build_name="build-name",
|
build_name="build-name",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue