Look ma, no database
This commit is contained in:
parent
488d620f40
commit
6bd0c8e7cd
17 changed files with 781 additions and 385 deletions
44
README.md
44
README.md
|
|
@ -1,3 +1,47 @@
|
||||||
# runboat ☸️
|
# runboat ☸️
|
||||||
|
|
||||||
A simple runbot lookalike on kubernetes. Main goal is replacing the OCA runbot.
|
A simple runbot lookalike on kubernetes. Main goal is replacing the OCA runbot.
|
||||||
|
|
||||||
|
|
||||||
|
# Requirements
|
||||||
|
|
||||||
|
For running the builds:
|
||||||
|
|
||||||
|
- A namespace in a kubernetes cluster.
|
||||||
|
- A wildcard DNS domain that points to the kubernetes ingress.
|
||||||
|
- A postgres database, accessible from within the cluster namespace with a user with
|
||||||
|
permissions to create database.
|
||||||
|
|
||||||
|
For running the controller:
|
||||||
|
|
||||||
|
- Python 3.10
|
||||||
|
- `kubectl`
|
||||||
|
- A `KUBECONFIG` that provides access to the namespace where the builds are deployed,
|
||||||
|
with permissions to create and delete service, deployment, ingress, secret and
|
||||||
|
configmap resources.
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
Prototype:
|
||||||
|
|
||||||
|
- webhook
|
||||||
|
- plug it on a bunch of OCA and shopinvader repos to test load
|
||||||
|
- handle init failures, add failed status
|
||||||
|
- reaper
|
||||||
|
|
||||||
|
MVP:
|
||||||
|
|
||||||
|
- finish api
|
||||||
|
- log api endpoints
|
||||||
|
- report build status to github
|
||||||
|
- k8s init container timeout
|
||||||
|
- error handling in API
|
||||||
|
- basic tests
|
||||||
|
- look at other TODO in code
|
||||||
|
- build image
|
||||||
|
- deployment
|
||||||
|
- plug it on shopinvader and acsone
|
||||||
|
|
||||||
|
More:
|
||||||
|
|
||||||
|
- UI
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,9 @@ classifiers = [
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi",
|
"fastapi",
|
||||||
"jinja2",
|
"jinja2",
|
||||||
"kubernetes",
|
"kubernetes_asyncio",
|
||||||
"psycopg2",
|
|
||||||
"sqlalchemy",
|
|
||||||
"uvicorn",
|
"uvicorn",
|
||||||
|
"requests", # TODO for github, to replace by aiohttp or httpx
|
||||||
]
|
]
|
||||||
dynamic = ["version", "description"]
|
dynamic = ["version", "description"]
|
||||||
|
|
||||||
|
|
@ -26,8 +25,6 @@ test = [
|
||||||
]
|
]
|
||||||
mypy = [
|
mypy = [
|
||||||
"mypy",
|
"mypy",
|
||||||
"sqlalchemy[mypy]",
|
|
||||||
"types-requests",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|
@ -36,9 +33,4 @@ Home = "https://github.com/sbidoul/runboat"
|
||||||
[tool.isort]
|
[tool.isort]
|
||||||
profile = 'black'
|
profile = 'black'
|
||||||
|
|
||||||
[tool.mypy]
|
|
||||||
plugins = [
|
|
||||||
"sqlalchemy.ext.mypy.plugin",
|
|
||||||
]
|
|
||||||
|
|
||||||
# flake8 config is in .flake8
|
# flake8 config is in .flake8
|
||||||
|
|
|
||||||
|
|
@ -1,137 +1,165 @@
|
||||||
import datetime
|
from typing import List, Optional
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from starlette.status import HTTP_404_NOT_FOUND
|
|
||||||
|
|
||||||
from . import github, models
|
from . import controller, github
|
||||||
from .app import app
|
from .deps import authenticated
|
||||||
from .db import get_db
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class Status(BaseModel):
|
||||||
|
deployed: int
|
||||||
|
running: int
|
||||||
|
starting: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
||||||
|
read_with_orm_mode = True
|
||||||
|
|
||||||
|
|
||||||
class Repo(BaseModel):
|
class Repo(BaseModel):
|
||||||
id: str
|
name: str
|
||||||
created: datetime.datetime
|
link: str
|
||||||
display_name: str
|
|
||||||
display_url: str = Field(title="Link to view the repo")
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
orm_mode = True
|
orm_mode = True
|
||||||
|
read_with_orm_mode = True
|
||||||
|
|
||||||
@app.get(
|
|
||||||
"/repos",
|
|
||||||
response_model=List[Repo],
|
|
||||||
)
|
|
||||||
def repos(db: Session = Depends(get_db)):
|
|
||||||
return db.query(models.Repo).all()
|
|
||||||
|
|
||||||
|
|
||||||
class Branch(BaseModel):
|
|
||||||
id: str
|
|
||||||
created: datetime.datetime
|
|
||||||
display_name: str
|
|
||||||
display_url: str = Field(title="Link to view the branch or PR")
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
orm_mode = True
|
|
||||||
|
|
||||||
|
|
||||||
@app.get(
|
|
||||||
"/repos/{repo_id}/branches",
|
|
||||||
response_model=List[Branch],
|
|
||||||
)
|
|
||||||
def branches(repo_id: str, db: Session = Depends(get_db)):
|
|
||||||
repo = db.query(models.Repo).get(repo_id)
|
|
||||||
if not repo:
|
|
||||||
raise HTTPException(HTTP_404_NOT_FOUND)
|
|
||||||
return db.query(models.Branch).filter(models.Branch.repo == repo).all()
|
|
||||||
|
|
||||||
|
|
||||||
class Build(BaseModel):
|
class Build(BaseModel):
|
||||||
id: str
|
# created: datetime.datetime
|
||||||
created: datetime.datetime
|
repo: str
|
||||||
display_name: str
|
target_branch: str
|
||||||
display_url: str = Field(title="Link to open the build")
|
pr: Optional[int]
|
||||||
status: models.BuildStatus
|
commit: str
|
||||||
|
image: str
|
||||||
|
link: str
|
||||||
|
status: controller.BuildStatus
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
||||||
|
read_with_orm_mode = True
|
||||||
|
|
||||||
|
|
||||||
|
class BranchOrPull(BaseModel):
|
||||||
|
# created: datetime.datetime
|
||||||
|
repo: str
|
||||||
|
target_branch: str
|
||||||
|
pr: Optional[int]
|
||||||
|
link: str
|
||||||
|
builds: List[Build]
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
orm_mode = True
|
orm_mode = True
|
||||||
|
|
||||||
|
|
||||||
@app.get(
|
@router.get("/status", response_model=Status)
|
||||||
"/repos/{repo_id}/branches/{branch_id}/builds",
|
async def controller_status():
|
||||||
response_model=List[Build],
|
return controller.controller
|
||||||
)
|
|
||||||
def builds(repo_id: str, branch_id: str, db: Session = Depends(get_db)):
|
|
||||||
repo = db.query(models.Repo).get(repo_id)
|
|
||||||
if not repo:
|
|
||||||
raise HTTPException(HTTP_404_NOT_FOUND)
|
|
||||||
branch = (
|
|
||||||
db.query(models.Branch)
|
|
||||||
.filter(models.Branch.id == branch_id, models.Branch.repo == repo)
|
|
||||||
.one_or_none()
|
|
||||||
)
|
|
||||||
if not branch:
|
|
||||||
raise HTTPException(HTTP_404_NOT_FOUND)
|
|
||||||
return db.query(models.Build).filter(models.Build.branch == branch).all()
|
|
||||||
|
|
||||||
|
|
||||||
@app.get(
|
@router.get("/repos", response_model=List[Repo])
|
||||||
"/repos/{repo_id}/branches/{branch_id}/builds/{build_id}/init-log",
|
async def repos():
|
||||||
response_class=StreamingResponse,
|
# return models.Repo.all()
|
||||||
responses={200: {"content": {"text/plain": {}}}},
|
|
||||||
)
|
|
||||||
def init_log(repo_id: str, branch_id: str, build_id: str):
|
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
@app.get(
|
@router.get(
|
||||||
"/repos/{repo_id}/branches/{branch_id}/builds/{build_id}/log",
|
"/repos/{org}/{repo}/branches-and-pulls",
|
||||||
response_class=StreamingResponse,
|
response_model=List[BranchOrPull],
|
||||||
responses={200: {"content": {"text/plain": {}}}},
|
response_model_exclude_none=True,
|
||||||
)
|
)
|
||||||
def log(repo_id: str, branch_id: str, build_id: str):
|
async def branches_and_pulls(org: str, repo: str):
|
||||||
|
# return await models.Repo.by_org_repo(org, repo).branches_and_pulls()
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
@app.post(
|
@router.post(
|
||||||
"/repos/{repo_id}/branches/{branch_id}/builds/{build_id}/start",
|
"/repos/{org}/{repo}/branches/{branch}/trigger",
|
||||||
)
|
|
||||||
def start(repo_id: str, branch_id: str, build_id: str):
|
|
||||||
"""Start the deployment.
|
|
||||||
|
|
||||||
If already running, drop the db and restart.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
@app.post(
|
|
||||||
"/repos/{repo_id}/branches/{branch_id}/builds/{build_id}/stop",
|
|
||||||
)
|
|
||||||
def stop(repo_id: str, branch_id: str, build_id: str):
|
|
||||||
"""Stop the deployment, drop the database."""
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
@app.post(
|
|
||||||
"/trigger-branch",
|
|
||||||
response_model=Build,
|
response_model=Build,
|
||||||
|
dependencies=[Depends(authenticated)],
|
||||||
)
|
)
|
||||||
def trigger_branch(org: str, repo: str, branch: str, db: Session = Depends(get_db)):
|
async def trigger_branch(org: str, repo: str, branch: str):
|
||||||
|
"""Trigger build for a branch."""
|
||||||
|
# TODO async github call
|
||||||
branch_info = github.get_branch_info(org, repo, branch)
|
branch_info = github.get_branch_info(org, repo, branch)
|
||||||
_branch = models.Branch.for_github_branch(db, branch_info)
|
controller.Build.deploy(
|
||||||
return models.Build.for_branch(db, _branch, branch_info.head_sha)
|
repo=f"{branch_info.org}/{branch_info.repo}",
|
||||||
|
target_branch=branch_info.name,
|
||||||
|
pr=None,
|
||||||
@app.post(
|
commit=branch_info.head_sha,
|
||||||
"/trigger-pr",
|
|
||||||
response_model=Build,
|
|
||||||
)
|
)
|
||||||
def trigger_pr(org: str, repo: str, pr: int, db: Session = Depends(get_db)):
|
|
||||||
pr_info = github.get_pr_info(org, repo, pr)
|
|
||||||
_branch = models.Branch.for_github_pr(db, pr_info)
|
@router.post(
|
||||||
return models.Build.for_branch(db, _branch, pr_info.head_sha)
|
"/repos/{org}/{repo}/pulls/{pr}/trigger",
|
||||||
|
response_model=Build,
|
||||||
|
dependencies=[Depends(authenticated)],
|
||||||
|
)
|
||||||
|
async def trigger_pull(org: str, repo: str, pr: int):
|
||||||
|
"""Trigger build for a pull request."""
|
||||||
|
# TODO async github call
|
||||||
|
pull_info = github.get_pull_info(org, repo, pr)
|
||||||
|
await controller.Build.deploy(
|
||||||
|
repo=f"{pull_info.org}/{pull_info.repo}",
|
||||||
|
target_branch=pull_info.target_branch,
|
||||||
|
pr=pull_info.number,
|
||||||
|
commit=pull_info.head_sha,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_by_name(name: str) -> controller.Build:
|
||||||
|
try:
|
||||||
|
# TODO do not access controller internals
|
||||||
|
return controller.controller._builds_by_name[name]
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/builds/{name}", response_model=Build)
|
||||||
|
async def build(name: str):
|
||||||
|
return _build_by_name(name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/builds/{name}/init-log",
|
||||||
|
response_class=StreamingResponse,
|
||||||
|
responses={200: {"content": {"text/plain": {}}}},
|
||||||
|
)
|
||||||
|
async def init_log(name: str):
|
||||||
|
# build = _build_by_name(name)
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/builds/{name}/log",
|
||||||
|
response_class=StreamingResponse,
|
||||||
|
responses={200: {"content": {"text/plain": {}}}},
|
||||||
|
)
|
||||||
|
async def log(name: str):
|
||||||
|
# build = _build_by_name(name)
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/builds/{name}/start")
|
||||||
|
async def start(name: str):
|
||||||
|
"""Start the deployment."""
|
||||||
|
build = _build_by_name(name)
|
||||||
|
await build.delay_start()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/builds/{name}/stop")
|
||||||
|
async def stop(name: str):
|
||||||
|
"""Stop the deployment."""
|
||||||
|
build = _build_by_name(name)
|
||||||
|
await build.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/builds/{name}", dependencies=[Depends(authenticated)])
|
||||||
|
async def delete(name: str):
|
||||||
|
"""Delete the deployment and drop the database."""
|
||||||
|
build = _build_by_name(name)
|
||||||
|
await build.undeploy()
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from .db import create_tables
|
from . import api, controller, k8s
|
||||||
|
|
||||||
app = FastAPI(title="Runboat")
|
app = FastAPI(title="Runboat", description="Runbot on Kubernetes ☸️")
|
||||||
|
app.include_router(api.router)
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup() -> None:
|
async def startup() -> None:
|
||||||
create_tables()
|
await k8s.load_kube_config()
|
||||||
|
await controller.controller.start()
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
async def shutdown() -> None:
|
||||||
|
await controller.controller.stop()
|
||||||
|
|
|
||||||
338
src/runboat/controller.py
Normal file
338
src/runboat/controller.py
Normal file
|
|
@ -0,0 +1,338 @@
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from kubernetes_asyncio.client.models.v1_deployment import V1Deployment
|
||||||
|
|
||||||
|
from runboat.build_images import get_build_image
|
||||||
|
|
||||||
|
from . import k8s
|
||||||
|
from .settings import settings
|
||||||
|
from .utils import slugify
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BuildStatus(str, Enum):
|
||||||
|
stopped = "stopped"
|
||||||
|
starting = "starting"
|
||||||
|
started = "started"
|
||||||
|
|
||||||
|
|
||||||
|
class BuildTodo(str, Enum):
|
||||||
|
start = "start"
|
||||||
|
|
||||||
|
|
||||||
|
class Build:
|
||||||
|
def __init__(self, deployment: V1Deployment):
|
||||||
|
self._deployment = deployment
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return self._deployment.metadata.labels["runboat/build"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def repo(self) -> str:
|
||||||
|
return self._deployment.metadata.annotations["runboat/repo"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target_branch(self) -> str:
|
||||||
|
return self._deployment.metadata.annotations["runboat/target-branch"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pr(self) -> int | None:
|
||||||
|
return self._deployment.metadata.annotations["runboat/pr"] or None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def commit(self) -> str:
|
||||||
|
return self._deployment.metadata.annotations["runboat/commit"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def make_slug(
|
||||||
|
cls, repo: str, target_branch: str, pr: int | None, commit: str
|
||||||
|
) -> str:
|
||||||
|
slug = f"{slugify(repo)}-{slugify(target_branch)}"
|
||||||
|
if pr:
|
||||||
|
slug = f"{slug}-pr{slugify(pr)}"
|
||||||
|
slug = f"{slug}-{commit[:12]}"
|
||||||
|
return slug
|
||||||
|
|
||||||
|
@property
|
||||||
|
def slug(self) -> str:
|
||||||
|
return self.make_slug(self.repo, self.target_branch, self.pr, self.commit)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def link(self) -> str:
|
||||||
|
return f"https://{self.slug}.{settings.build_domain}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> BuildStatus:
|
||||||
|
replicas = self._deployment.status.replicas
|
||||||
|
if not replicas:
|
||||||
|
status = BuildStatus.stopped
|
||||||
|
else:
|
||||||
|
if self._deployment.status.ready_replicas == replicas:
|
||||||
|
status = BuildStatus.started
|
||||||
|
else:
|
||||||
|
status = BuildStatus.starting
|
||||||
|
# TODO detect stopping, deploying, undeploying ?
|
||||||
|
# TODO: failed status
|
||||||
|
return status
|
||||||
|
|
||||||
|
@property
|
||||||
|
def todo(self) -> BuildTodo | None:
|
||||||
|
return self._deployment.metadata.annotations["runboat/todo"] or None
|
||||||
|
|
||||||
|
async def delay_start(self) -> None:
|
||||||
|
"""Mark a build for startup.
|
||||||
|
|
||||||
|
This is done by setting the runboat/todo annotation to 'start'.
|
||||||
|
This will in turn let the starter process it when there is
|
||||||
|
available capacity.
|
||||||
|
"""
|
||||||
|
await k8s.patch_deployment(
|
||||||
|
self._deployment.metadata.name,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"op": "replace",
|
||||||
|
"path": "/metadata/annotations/runboat~1todo",
|
||||||
|
"value": "start",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""Start a build.
|
||||||
|
|
||||||
|
Set replicas to 1, and reset todo.
|
||||||
|
"""
|
||||||
|
_logger.info(f"Starting {self.slug} ({self.name})")
|
||||||
|
await k8s.patch_deployment(
|
||||||
|
self._deployment.metadata.name,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"op": "replace",
|
||||||
|
"path": "/metadata/annotations/runboat~1todo",
|
||||||
|
"value": "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"op": "replace",
|
||||||
|
"path": "/spec/replicas",
|
||||||
|
"value": 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Stop a build.
|
||||||
|
|
||||||
|
Set replicas to 0, and reset todo.
|
||||||
|
"""
|
||||||
|
_logger.info(f"Stopping {self.slug} ({self.name})")
|
||||||
|
await k8s.patch_deployment(
|
||||||
|
self._deployment.metadata.name,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"op": "replace",
|
||||||
|
"path": "/metadata/annotations/runboat~1todo",
|
||||||
|
"value": "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"op": "replace",
|
||||||
|
"path": "/spec/replicas",
|
||||||
|
"value": 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def undeploy(self) -> None:
|
||||||
|
"""Undeploy a build.
|
||||||
|
|
||||||
|
Delete all resources, and drop the database.
|
||||||
|
"""
|
||||||
|
_logger.info(f"Undeploying {self.slug} ({self.name})")
|
||||||
|
await k8s.undeploy(self.name)
|
||||||
|
await k8s.dropdb(self.name)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def deploy(
|
||||||
|
cls, repo: str, target_branch: str, pr: int | None, commit: str
|
||||||
|
) -> None:
|
||||||
|
"""Deploy a build, without starting it."""
|
||||||
|
name = str(uuid.uuid4())
|
||||||
|
slug = cls.make_slug(repo, target_branch, pr, commit)
|
||||||
|
_logger.info("Deploying {slug} ({name})")
|
||||||
|
image = get_build_image(target_branch)
|
||||||
|
deployment_vars = k8s.make_deployment_vars(
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
repo.lower(),
|
||||||
|
target_branch,
|
||||||
|
pr,
|
||||||
|
commit,
|
||||||
|
image,
|
||||||
|
)
|
||||||
|
await k8s.deploy(deployment_vars)
|
||||||
|
|
||||||
|
|
||||||
|
class Controller:
|
||||||
|
"""The controller monitors and manages the deployments.
|
||||||
|
|
||||||
|
It run several background tasks:
|
||||||
|
- The 'watcher' listens to kubernetes events on deployements and maintains an
|
||||||
|
in-memory data structure about existing deployments and their state. It wakes up
|
||||||
|
the starter and the reaper when necessary.
|
||||||
|
- The 'starter' starts deployment that have been flagged to start, while making sure
|
||||||
|
that the maximum number of deployment starting concurrently does not exceed the
|
||||||
|
limit.
|
||||||
|
- The 'reaper' stops old running deployments, and deletes old stopped deployments so
|
||||||
|
as to limit the maximum number of each.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_tasks: list[asyncio.Task]
|
||||||
|
_wakeup_event: asyncio.Event
|
||||||
|
_builds_by_name: dict[str, Build]
|
||||||
|
_starting: int
|
||||||
|
_started: int
|
||||||
|
_starter_queue: asyncio.Queue
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._tasks = []
|
||||||
|
self._wakeup_event = asyncio.Event()
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self._builds_by_name = {}
|
||||||
|
self._starting = 0
|
||||||
|
self._started = 0
|
||||||
|
self._starter_queue = asyncio.Queue()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def running(self) -> int:
|
||||||
|
return self._starting + self._started
|
||||||
|
|
||||||
|
@property
|
||||||
|
def starting(self) -> int:
|
||||||
|
return self._starting
|
||||||
|
|
||||||
|
@property
|
||||||
|
def deployed(self) -> int:
|
||||||
|
return len(self._builds_by_name)
|
||||||
|
|
||||||
|
def _add(self, build: Build) -> None:
|
||||||
|
self._remove(build.name)
|
||||||
|
if build.status == BuildStatus.starting:
|
||||||
|
self._starting += 1
|
||||||
|
elif build.status == BuildStatus.started:
|
||||||
|
self._started += 1
|
||||||
|
self._builds_by_name[build.name] = build
|
||||||
|
|
||||||
|
def _remove(self, build_name: str) -> None:
|
||||||
|
old_build = self._builds_by_name.get(build_name)
|
||||||
|
if old_build is None:
|
||||||
|
return
|
||||||
|
if old_build.status == BuildStatus.starting:
|
||||||
|
self._starting -= 1
|
||||||
|
elif old_build.status == BuildStatus.started:
|
||||||
|
self._started -= 1
|
||||||
|
del self._builds_by_name[build_name]
|
||||||
|
|
||||||
|
def _wakeup(self) -> None:
|
||||||
|
self._wakeup_event.set()
|
||||||
|
self._wakeup_event.clear()
|
||||||
|
|
||||||
|
def added(self, build_name: str, deployment: V1Deployment) -> None:
|
||||||
|
new_build = Build(deployment)
|
||||||
|
assert new_build.name == build_name
|
||||||
|
assert new_build.name not in self._builds_by_name
|
||||||
|
if new_build.todo == BuildTodo.start:
|
||||||
|
self._starter_queue.put_nowait(new_build.name)
|
||||||
|
self._add(new_build)
|
||||||
|
self._wakeup()
|
||||||
|
|
||||||
|
def modified(self, build_name: str, deployment: V1Deployment) -> None:
|
||||||
|
new_build = Build(deployment)
|
||||||
|
assert new_build.name == build_name
|
||||||
|
assert new_build.name in self._builds_by_name
|
||||||
|
old_build = self._builds_by_name[new_build.name]
|
||||||
|
if new_build.todo == BuildTodo.start and new_build.todo != old_build.todo:
|
||||||
|
self._starter_queue.put_nowait(new_build.name)
|
||||||
|
self._add(new_build)
|
||||||
|
self._wakeup()
|
||||||
|
|
||||||
|
def deleted(self, build_name: str) -> None:
|
||||||
|
self._remove(build_name)
|
||||||
|
self._wakeup()
|
||||||
|
|
||||||
|
async def watcher(self) -> None:
|
||||||
|
async for event_type, deployment in k8s.watch_deployments():
|
||||||
|
build_name = deployment.metadata.labels.get("runboat/build")
|
||||||
|
if not build_name:
|
||||||
|
continue
|
||||||
|
_logger.debug(f"{event_type} deployment {build_name}")
|
||||||
|
if event_type == "ADDED":
|
||||||
|
self.added(build_name, deployment)
|
||||||
|
elif event_type == "MODIFIED":
|
||||||
|
self.modified(build_name, deployment)
|
||||||
|
elif event_type == "DELETED":
|
||||||
|
self.deleted(build_name)
|
||||||
|
else:
|
||||||
|
_logger.error(f"Unexpected event type {event_type}.")
|
||||||
|
|
||||||
|
async def starter(self) -> None:
|
||||||
|
while True:
|
||||||
|
await self._wakeup_event.wait()
|
||||||
|
while not self._starter_queue.empty():
|
||||||
|
if self.starting >= settings.max_starting:
|
||||||
|
# Too many starting, back to sleep.
|
||||||
|
break
|
||||||
|
if self.running > settings.max_running:
|
||||||
|
# Too many started, back to sleep. If ==, we are going to start one
|
||||||
|
# more and let the reaper do it's job to get back to the maximum.
|
||||||
|
break
|
||||||
|
build_name = await self._starter_queue.get()
|
||||||
|
try:
|
||||||
|
build = self._builds_by_name.get(build_name)
|
||||||
|
if build is None:
|
||||||
|
continue
|
||||||
|
await build.start()
|
||||||
|
finally:
|
||||||
|
# TODO in case of exception, add back to starter queue ?
|
||||||
|
self._starter_queue.task_done()
|
||||||
|
|
||||||
|
async def reaper(self) -> None:
|
||||||
|
while True:
|
||||||
|
await self._wakeup_event.wait()
|
||||||
|
# TODO
|
||||||
|
# - stop old started
|
||||||
|
# - undeploy old deployed
|
||||||
|
# - keep sticky builds
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
_logger.info("Starting controller tasks.")
|
||||||
|
|
||||||
|
async def walking_dead(func):
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await func()
|
||||||
|
except Exception:
|
||||||
|
delay = 5
|
||||||
|
_logger.exception(
|
||||||
|
f"Unhandled exception in {func}, restarting in {delay} sec."
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
for f in (self.watcher, self.starter, self.reaper):
|
||||||
|
self._tasks.append(asyncio.create_task(walking_dead(f)))
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
_logger.info("Stopping controller tasks.")
|
||||||
|
for task in self._tasks:
|
||||||
|
task.cancel()
|
||||||
|
# Wait until all tasks are cancelled.
|
||||||
|
await asyncio.gather(*self._tasks, return_exceptions=True)
|
||||||
|
self._task = []
|
||||||
|
|
||||||
|
|
||||||
|
controller = Controller()
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
from .settings import settings
|
|
||||||
|
|
||||||
engine = create_engine(settings.database_url)
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
|
|
||||||
def create_tables() -> None:
|
|
||||||
Base.metadata.create_all(engine)
|
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
yield db
|
|
||||||
except Exception:
|
|
||||||
db.rollback()
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
db.commit()
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
25
src/runboat/deps.py
Normal file
25
src/runboat/deps.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||||
|
|
||||||
|
from .settings import settings
|
||||||
|
|
||||||
|
security = HTTPBasic()
|
||||||
|
|
||||||
|
|
||||||
|
def authenticated(credentials: HTTPBasicCredentials = Depends(security)) -> None:
|
||||||
|
correct_username = secrets.compare_digest(
|
||||||
|
credentials.username,
|
||||||
|
settings.admin_user,
|
||||||
|
)
|
||||||
|
correct_password = secrets.compare_digest(
|
||||||
|
credentials.password,
|
||||||
|
settings.admin_passwd,
|
||||||
|
)
|
||||||
|
if not (correct_username and correct_password):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Incorrect user name or password",
|
||||||
|
headers={"WWW-Authenticate": "Basic"},
|
||||||
|
)
|
||||||
|
|
@ -2,7 +2,7 @@ class ClientError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class RepoNotFound(ClientError):
|
class RepoNotSupported(ClientError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ def get_branch_info(org: str, repo: str, branch: str) -> BranchInfo:
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PullRequestInfo:
|
class PullInfo:
|
||||||
org: str
|
org: str
|
||||||
repo: str
|
repo: str
|
||||||
number: int
|
number: int
|
||||||
|
|
@ -45,9 +45,9 @@ class PullRequestInfo:
|
||||||
target_branch: str
|
target_branch: str
|
||||||
|
|
||||||
|
|
||||||
def get_pr_info(org: str, repo: str, pr: int) -> PullRequestInfo:
|
def get_pull_info(org: str, repo: str, pr: int) -> PullInfo:
|
||||||
pr_data = _github_get(f"/repos/{org}/{repo}/pulls/{pr}")
|
pr_data = _github_get(f"/repos/{org}/{repo}/pulls/{pr}")
|
||||||
return PullRequestInfo(
|
return PullInfo(
|
||||||
org=org,
|
org=org,
|
||||||
repo=repo,
|
repo=repo,
|
||||||
number=pr,
|
number=pr,
|
||||||
|
|
|
||||||
171
src/runboat/k8s.py
Normal file
171
src/runboat/k8s.py
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
import asyncio
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from importlib import resources
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, AsyncGenerator, ContextManager, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from jinja2 import Template
|
||||||
|
from kubernetes_asyncio import client, config, watch
|
||||||
|
from kubernetes_asyncio.client.api_client import ApiClient
|
||||||
|
from kubernetes_asyncio.client.models.v1_deployment import V1Deployment
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _split_image_name_tag(img: str) -> Tuple[str, str]:
|
||||||
|
if ":" in img:
|
||||||
|
return img.split(":", 2)
|
||||||
|
return (img, "latest")
|
||||||
|
|
||||||
|
|
||||||
|
async def load_kube_config() -> None:
|
||||||
|
await config.load_kube_config()
|
||||||
|
|
||||||
|
|
||||||
|
async def patch_deployment(deployment_name: str, ops: List[Dict["str", Any]]) -> None:
|
||||||
|
async with ApiClient() as api:
|
||||||
|
appsv1 = client.AppsV1Api(api)
|
||||||
|
await appsv1.patch_namespaced_deployment(
|
||||||
|
name=deployment_name,
|
||||||
|
namespace=settings.build_namespace,
|
||||||
|
body=ops,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def watch_deployments() -> AsyncGenerator[Tuple[str, V1Deployment], None]:
|
||||||
|
w = watch.Watch()
|
||||||
|
# use the context manager to close http sessions automatically
|
||||||
|
async with ApiClient() as api:
|
||||||
|
appsv1 = client.AppsV1Api(api)
|
||||||
|
async for event in w.stream(
|
||||||
|
appsv1.list_namespaced_deployment, namespace=settings.build_namespace
|
||||||
|
):
|
||||||
|
yield event["type"], event["object"]
|
||||||
|
|
||||||
|
|
||||||
|
class DeploymentVars(BaseModel):
|
||||||
|
namespace: str
|
||||||
|
build_name: str
|
||||||
|
repo: str
|
||||||
|
target_branch: str
|
||||||
|
pr: Optional[int]
|
||||||
|
commit: str
|
||||||
|
image_name: str
|
||||||
|
image_tag: str
|
||||||
|
pghost: str
|
||||||
|
pgport: str
|
||||||
|
pguser: str
|
||||||
|
pgpassword: str
|
||||||
|
pgdatabase: str
|
||||||
|
admin_passwd: str
|
||||||
|
hostname: str
|
||||||
|
|
||||||
|
|
||||||
|
def make_deployment_vars(
|
||||||
|
build_name: str,
|
||||||
|
slug: str,
|
||||||
|
repo: str,
|
||||||
|
target_branch: str,
|
||||||
|
pr: int | None,
|
||||||
|
commit: str,
|
||||||
|
image: str,
|
||||||
|
) -> DeploymentVars:
|
||||||
|
image_name, image_tag = _split_image_name_tag(image)
|
||||||
|
return DeploymentVars(
|
||||||
|
namespace=settings.build_namespace,
|
||||||
|
build_name=build_name,
|
||||||
|
repo=repo,
|
||||||
|
target_branch=target_branch,
|
||||||
|
pr=pr,
|
||||||
|
commit=commit,
|
||||||
|
image_name=image_name,
|
||||||
|
image_tag=image_tag,
|
||||||
|
pghost=settings.build_pghost,
|
||||||
|
pgport=settings.build_pgport,
|
||||||
|
pguser=settings.build_pguser,
|
||||||
|
pgpassword=settings.build_pgpassword,
|
||||||
|
pgdatabase=build_name,
|
||||||
|
admin_passwd=settings.build_admin_passwd,
|
||||||
|
hostname=f"{slug}.{settings.build_domain}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _render_kubefiles(deployment_vars: DeploymentVars) -> ContextManager[Path]:
|
||||||
|
with resources.path(
|
||||||
|
__package__, "kubefiles"
|
||||||
|
) as kubefiles_path, tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
tmp_path = Path(tmp_dir)
|
||||||
|
# TODO async copytree
|
||||||
|
shutil.copytree(kubefiles_path, tmp_path, dirs_exist_ok=True)
|
||||||
|
template = Template((tmp_path / "kustomization.yaml.jinja").read_text())
|
||||||
|
(tmp_path / "kustomization.yaml").write_text(
|
||||||
|
template.render(dict(deployment_vars))
|
||||||
|
)
|
||||||
|
yield tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
async def _kubectl(args: List[str]) -> None:
|
||||||
|
proc = await asyncio.create_subprocess_exec("kubectl", *args)
|
||||||
|
await proc.wait()
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise subprocess.CalledProcessError(proc.returncode, ["kubectl"] + args)
|
||||||
|
|
||||||
|
|
||||||
|
async def deploy(deployment_vars: DeploymentVars) -> None:
|
||||||
|
with _render_kubefiles(deployment_vars) as tmp_path:
|
||||||
|
await _kubectl(
|
||||||
|
[
|
||||||
|
"apply",
|
||||||
|
"-k",
|
||||||
|
str(tmp_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def dropdb(build_name: str) -> None:
|
||||||
|
await _kubectl(
|
||||||
|
[
|
||||||
|
"-n",
|
||||||
|
settings.build_namespace,
|
||||||
|
"run",
|
||||||
|
f"dropdb-{build_name}",
|
||||||
|
"--restart=Never",
|
||||||
|
"--rm",
|
||||||
|
"-i",
|
||||||
|
"--tty",
|
||||||
|
"--image",
|
||||||
|
"postgres",
|
||||||
|
"--env",
|
||||||
|
f"PGHOST={settings.build_pghost}",
|
||||||
|
"--env",
|
||||||
|
f"PGPORT={settings.build_pgport}",
|
||||||
|
"--env",
|
||||||
|
f"PGUSER={settings.build_pguser}",
|
||||||
|
"--env",
|
||||||
|
f"PGPASSWORD={settings.build_pgpassword}",
|
||||||
|
"--",
|
||||||
|
"dropdb",
|
||||||
|
"--if-exists",
|
||||||
|
"--force", # pg 13+
|
||||||
|
build_name,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def undeploy(build_name: str) -> None:
|
||||||
|
await _kubectl(
|
||||||
|
[
|
||||||
|
"-n",
|
||||||
|
settings.build_namespace,
|
||||||
|
"delete",
|
||||||
|
"service,deployment,ingress,secret,configmap",
|
||||||
|
"-l",
|
||||||
|
f"runboat/build={build_name}",
|
||||||
|
"--wait=false",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
@ -2,8 +2,10 @@ apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
name: odoo
|
name: odoo
|
||||||
|
annotations:
|
||||||
|
runboat/todo: "start" # ask controller to start when there is capacity
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
replicas: 0 # deploy idle (with 0 replica)
|
||||||
strategy:
|
strategy:
|
||||||
type: Recreate
|
type: Recreate
|
||||||
selector:
|
selector:
|
||||||
|
|
@ -17,6 +19,7 @@ spec:
|
||||||
initContainers:
|
initContainers:
|
||||||
- name: odoo-init
|
- name: odoo-init
|
||||||
image: odoo
|
image: odoo
|
||||||
|
restartPolicy: Never
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: runboat-scripts
|
- name: runboat-scripts
|
||||||
mountPath: /runboat
|
mountPath: /runboat
|
||||||
|
|
@ -26,6 +29,10 @@ spec:
|
||||||
- configMapRef:
|
- configMapRef:
|
||||||
name: odooenv
|
name: odooenv
|
||||||
args: ["bash", "/runboat/runboat-init.sh"]
|
args: ["bash", "/runboat/runboat-init.sh"]
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: 1000m
|
||||||
|
memory: 1Gi
|
||||||
containers:
|
containers:
|
||||||
- name: odoo
|
- name: odoo
|
||||||
image: odoo
|
image: odoo
|
||||||
|
|
@ -43,6 +50,11 @@ spec:
|
||||||
- name: longpolling
|
- name: longpolling
|
||||||
containerPort: 8072
|
containerPort: 8072
|
||||||
args: ["bash", "/runboat/runboat-start.sh"]
|
args: ["bash", "/runboat/runboat-start.sh"]
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: 1000m
|
||||||
|
memory: 1Gi
|
||||||
|
terminationGracePeriodSeconds: 5
|
||||||
volumes:
|
volumes:
|
||||||
- name: runboat-scripts
|
- name: runboat-scripts
|
||||||
configMap:
|
configMap:
|
||||||
|
|
|
||||||
|
|
@ -3,30 +3,41 @@ resources:
|
||||||
- service.yaml
|
- service.yaml
|
||||||
- ingress.yaml
|
- ingress.yaml
|
||||||
|
|
||||||
namePrefix: "{{ name_prefix }}-"
|
namespace: {{ namespace }}
|
||||||
|
|
||||||
|
nameSuffix: "-{{ build_name }}"
|
||||||
|
|
||||||
commonLabels:
|
commonLabels:
|
||||||
runboat-build: "{{ name_prefix}}"
|
runboat/build: "{{ build_name }}"
|
||||||
|
|
||||||
|
commonAnnotations:
|
||||||
|
runboat/repo: "{{ repo }}"
|
||||||
|
runboat/target-branch: "{{ target_branch }}"
|
||||||
|
runboat/pr: "{{ pr if pr else '' }}"
|
||||||
|
runboat/commit: "{{ commit }}"
|
||||||
|
|
||||||
images:
|
images:
|
||||||
- name: odoo
|
- name: odoo
|
||||||
newName: "{{ image_name }}"
|
newName: "{{ image_name }}"
|
||||||
newTag: latest
|
newTag: "{{ image_tag }}"
|
||||||
|
|
||||||
secretGenerator:
|
secretGenerator:
|
||||||
- name: odoosecretenv
|
- name: odoosecretenv
|
||||||
literals:
|
literals:
|
||||||
- DB_PASSWORD={{ db_passwd }}
|
- PGPASSWORD={{ pgpassword }}
|
||||||
- ADMIN_PASSWD={{ admin_passwd }}
|
- ADMIN_PASSWD={{ admin_passwd }}
|
||||||
|
|
||||||
configMapGenerator:
|
configMapGenerator:
|
||||||
- name: odooenv
|
- name: odooenv
|
||||||
literals:
|
literals:
|
||||||
- DB_HOST={{ db_host }}
|
- PGHOST={{ pghost }}
|
||||||
- DB_PORT={{ db_port }}
|
- PGPORT={{ pgport }}
|
||||||
- DB_USER={{ db_user }}
|
- PGUSER={{ pguser }}
|
||||||
- DB_NAME={{ db_name }}
|
- PGNAME={{ pgname }}
|
||||||
- LIST_DB=False
|
- PGDATABASE={{ pgdatabase }}
|
||||||
|
- ADDONS_DIR=/build
|
||||||
|
- RUNBOAT_GIT_REPO=https://github.com/{{ repo }}
|
||||||
|
- RUNBOAT_GIT_REF={{ commit }}
|
||||||
- name: runboat-scripts
|
- name: runboat-scripts
|
||||||
files:
|
files:
|
||||||
- runboat-clone-and-install.sh
|
- runboat-clone-and-install.sh
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,12 @@ set -ex
|
||||||
# Run oca_install_addons and oca_init_db on it.
|
# Run oca_install_addons and oca_init_db on it.
|
||||||
#
|
#
|
||||||
|
|
||||||
git clone --filter=blob:none $REPO $ADDONS_DIR
|
git clone --filter=blob:none $RUNBOAT_GIT_REPO $ADDONS_DIR
|
||||||
cd $ADDONS_DIR
|
cd $ADDONS_DIR
|
||||||
git fetch origin $REF:build
|
git fetch origin $RUNBOAT_GIT_REF:build
|
||||||
git checkout build
|
git checkout build
|
||||||
|
|
||||||
oca_install_addons
|
oca_install_addons
|
||||||
|
|
||||||
|
# disable the database manager
|
||||||
|
echo "list_db = False" >> $ODOO_RC
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,7 @@ bash /runboat/runboat-clone-and-install.sh
|
||||||
|
|
||||||
oca_wait_for_postgres
|
oca_wait_for_postgres
|
||||||
|
|
||||||
# TODO: rather, do nothing if db exists, so we can start instantly ?
|
# TODO: do nothing if db exists and all addons are installed, so we can start instantly
|
||||||
dropdb --if-exists $PGDATABASE
|
|
||||||
|
|
||||||
ADDONS=$(addons --addons-dir ${ADDONS_DIR} --include "${INCLUDE}" --exclude "${EXCLUDE}" list)
|
ADDONS=$(addons --addons-dir ${ADDONS_DIR} --include "${INCLUDE}" --exclude "${EXCLUDE}" list)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,216 +0,0 @@
|
||||||
from enum import Enum
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, func
|
|
||||||
from sqlalchemy.ext.compiler import compiles
|
|
||||||
from sqlalchemy.orm import Session, relationship
|
|
||||||
from sqlalchemy.sql import expression
|
|
||||||
|
|
||||||
from .build_images import check_branch_supported, get_build_image
|
|
||||||
from .db import Base
|
|
||||||
from .exceptions import RepoNotFound
|
|
||||||
from .github import BranchInfo, PullRequestInfo
|
|
||||||
from .settings import settings
|
|
||||||
from .utils import slugify
|
|
||||||
|
|
||||||
|
|
||||||
class utcnow(expression.FunctionElement):
|
|
||||||
type = DateTime()
|
|
||||||
|
|
||||||
|
|
||||||
@compiles(utcnow, "postgresql")
|
|
||||||
def pg_utcnow(element, compiler, **kw):
|
|
||||||
return "TIMEZONE('utc', CURRENT_TIMESTAMP)"
|
|
||||||
|
|
||||||
|
|
||||||
class Repo(Base):
|
|
||||||
__tablename__ = "repo"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
created = Column(DateTime, nullable=False, server_default=utcnow())
|
|
||||||
org = Column(String, nullable=False)
|
|
||||||
name = Column(String, nullable=False)
|
|
||||||
|
|
||||||
branches: List["Branch"] = relationship("Branch", back_populates="repo")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def display_name(self) -> str:
|
|
||||||
return f"{self.org}/{self.name}"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def display_url(self) -> str:
|
|
||||||
return f"https://github.com/{self.org}/{self.name}"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def slug(self) -> str:
|
|
||||||
return f"{self.org}-{self.name}"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_repo(cls, db: Session, org: str, name: str) -> "Repo":
|
|
||||||
repo = (
|
|
||||||
db.query(Repo)
|
|
||||||
.filter(
|
|
||||||
func.lower(Repo.org) == func.lower(org),
|
|
||||||
func.lower(Repo.name) == func.lower(name),
|
|
||||||
)
|
|
||||||
.one_or_none()
|
|
||||||
)
|
|
||||||
if repo is None:
|
|
||||||
raise RepoNotFound(f"Repo {org}/{name} not supported by this runboat.")
|
|
||||||
return repo
|
|
||||||
|
|
||||||
|
|
||||||
class Branch(Base):
|
|
||||||
__tablename__ = "branch"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
created = Column(DateTime, nullable=False, server_default=utcnow())
|
|
||||||
target_branch: str = Column(String, nullable=False)
|
|
||||||
pr = Column(Integer, nullable=True)
|
|
||||||
|
|
||||||
repo_id = Column(Integer, ForeignKey("repo.id"), nullable=False, index=True)
|
|
||||||
|
|
||||||
repo: Repo = relationship(Repo, back_populates="branches")
|
|
||||||
builds: List["Build"] = relationship("Build", back_populates="branch")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def display_name(self) -> str:
|
|
||||||
name = f"{self.target_branch}"
|
|
||||||
if self.pr:
|
|
||||||
name += f" #{self.pr}"
|
|
||||||
return name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def display_url(self) -> str:
|
|
||||||
url = f"https://github.com/{self.repo.org}/{self.repo.name}"
|
|
||||||
if self.pr:
|
|
||||||
return f"{url}/pull/{self.pr}"
|
|
||||||
return f"{url}/tree/{self.target_branch}"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def slug(self) -> str:
|
|
||||||
slug = f"{self.repo.slug}-{slugify(self.target_branch)}"
|
|
||||||
if self.pr:
|
|
||||||
return f"{slug}-pr{self.pr}"
|
|
||||||
return slug
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def for_github_branch(cls, db: Session, branch_info: BranchInfo) -> "Branch":
|
|
||||||
repo = Repo.get_repo(db, branch_info.org, branch_info.repo)
|
|
||||||
branch = (
|
|
||||||
db.query(Branch)
|
|
||||||
.filter(
|
|
||||||
Branch.repo == repo,
|
|
||||||
Branch.target_branch == branch_info.name,
|
|
||||||
Branch.pr is None,
|
|
||||||
)
|
|
||||||
.one_or_none()
|
|
||||||
)
|
|
||||||
if branch is None:
|
|
||||||
check_branch_supported(branch_info.name)
|
|
||||||
branch = Branch(
|
|
||||||
repo=repo,
|
|
||||||
target_branch=branch_info.name,
|
|
||||||
pr=None,
|
|
||||||
)
|
|
||||||
db.add(branch)
|
|
||||||
db.flush()
|
|
||||||
return branch
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def for_github_pr(cls, db: Session, pr_info: PullRequestInfo) -> "Branch":
|
|
||||||
repo = Repo.get_repo(db, pr_info.org, pr_info.repo)
|
|
||||||
branch = (
|
|
||||||
db.query(Branch)
|
|
||||||
.filter(
|
|
||||||
Branch.repo == repo,
|
|
||||||
Branch.target_branch == pr_info.target_branch,
|
|
||||||
Branch.pr == pr_info.number,
|
|
||||||
)
|
|
||||||
.one_or_none()
|
|
||||||
)
|
|
||||||
if branch is None:
|
|
||||||
check_branch_supported(pr_info.target_branch)
|
|
||||||
branch = Branch(
|
|
||||||
repo=repo,
|
|
||||||
target_branch=pr_info.target_branch,
|
|
||||||
pr=pr_info.number,
|
|
||||||
)
|
|
||||||
db.add(branch)
|
|
||||||
db.flush()
|
|
||||||
return branch
|
|
||||||
|
|
||||||
|
|
||||||
class BuildStatus(str, Enum):
|
|
||||||
stopped = "stopped"
|
|
||||||
running = "running"
|
|
||||||
deploying = "deploying"
|
|
||||||
not_deployed = "not_deployed"
|
|
||||||
|
|
||||||
|
|
||||||
class Build(Base):
|
|
||||||
__tablename__ = "build"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
created = Column(DateTime, nullable=False, server_default=utcnow())
|
|
||||||
|
|
||||||
branch_id = Column(Integer, ForeignKey("branch.id"), nullable=False, index=True)
|
|
||||||
branch: Branch = relationship(Branch, back_populates="builds")
|
|
||||||
|
|
||||||
build_image: str = Column(String, nullable=False)
|
|
||||||
git_sha: str = Column(String, nullable=False)
|
|
||||||
status: BuildStatus = Column(String, nullable=False)
|
|
||||||
# ressource_label = Column(String, nullable=False, unique=True, index=True)
|
|
||||||
|
|
||||||
# TODO: add unique constraint on branch_id + git_sha
|
|
||||||
|
|
||||||
@property
|
|
||||||
def display_name(self) -> str:
|
|
||||||
return f"{self.created} {self.git_sha[:6]}"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def display_url(self) -> str:
|
|
||||||
return f"http://{self.slug}.{settings.build_domain}"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def slug(self) -> str:
|
|
||||||
return f"{self.branch.slug}-{self.id}"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def for_branch(cls, db: Session, branch: Branch, git_sha: str) -> "Build":
|
|
||||||
build = (
|
|
||||||
db.query(Build)
|
|
||||||
.filter(
|
|
||||||
Build.branch == branch,
|
|
||||||
Build.git_sha == git_sha,
|
|
||||||
)
|
|
||||||
.one_or_none()
|
|
||||||
)
|
|
||||||
if build is None:
|
|
||||||
build_image = get_build_image(branch.target_branch)
|
|
||||||
build = Build(
|
|
||||||
branch=branch,
|
|
||||||
git_sha=git_sha,
|
|
||||||
build_image=build_image,
|
|
||||||
status=BuildStatus.not_deployed,
|
|
||||||
)
|
|
||||||
db.add(build)
|
|
||||||
db.flush()
|
|
||||||
return build
|
|
||||||
|
|
||||||
def delete(self) -> None:
|
|
||||||
# self.stop
|
|
||||||
# delete from db
|
|
||||||
...
|
|
||||||
|
|
||||||
def start(self) -> None:
|
|
||||||
...
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
...
|
|
||||||
|
|
||||||
def init_log(self) -> str:
|
|
||||||
...
|
|
||||||
|
|
||||||
def log(self, tail: int = 1000) -> str:
|
|
||||||
...
|
|
||||||
|
|
@ -1,15 +1,23 @@
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pydantic import BaseSettings
|
from pydantic import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
database_url: str
|
admin_user: str
|
||||||
build_pghost: Optional[str]
|
admin_passwd: str
|
||||||
build_pgport: Optional[str]
|
supported_repos: set[str]
|
||||||
build_pguser: Optional[str]
|
max_starting: int = 4
|
||||||
build_pgpassword: Optional[str]
|
max_running: int = 10
|
||||||
|
max_deployed: int = 20
|
||||||
|
build_namespace: str
|
||||||
|
build_pghost: str
|
||||||
|
build_pgport: str
|
||||||
|
build_pguser: str
|
||||||
|
build_pgpassword: str
|
||||||
|
build_admin_passwd: str
|
||||||
build_domain: str
|
build_domain: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_prefix = "RUNBOAT_"
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,4 @@ import re
|
||||||
|
|
||||||
|
|
||||||
def slugify(s: str) -> str:
|
def slugify(s: str) -> str:
|
||||||
return re.sub(r"[^a-z0-9]", "-", s.lower())
|
return re.sub(r"[^a-z0-9]", "-", str(s).lower())
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue