Look ma, no database

This commit is contained in:
Stéphane Bidoul 2021-10-20 13:43:55 +02:00
parent 488d620f40
commit 6bd0c8e7cd
No known key found for this signature in database
GPG key ID: BCAB2555446B5B92
17 changed files with 781 additions and 385 deletions

View file

@ -1,3 +1,47 @@
# runboat ☸️
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

View file

@ -12,10 +12,9 @@ classifiers = [
dependencies = [
"fastapi",
"jinja2",
"kubernetes",
"psycopg2",
"sqlalchemy",
"kubernetes_asyncio",
"uvicorn",
"requests", # TODO for github, to replace by aiohttp or httpx
]
dynamic = ["version", "description"]
@ -26,8 +25,6 @@ test = [
]
mypy = [
"mypy",
"sqlalchemy[mypy]",
"types-requests",
]
[project.urls]
@ -36,9 +33,4 @@ Home = "https://github.com/sbidoul/runboat"
[tool.isort]
profile = 'black'
[tool.mypy]
plugins = [
"sqlalchemy.ext.mypy.plugin",
]
# flake8 config is in .flake8

View file

@ -1,137 +1,165 @@
import datetime
from typing import List
from typing import List, Optional
from fastapi import Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from starlette.status import HTTP_404_NOT_FOUND
from pydantic import BaseModel
from . import github, models
from .app import app
from .db import get_db
from . import controller, github
from .deps import authenticated
router = APIRouter()
class Status(BaseModel):
deployed: int
running: int
starting: int
class Config:
orm_mode = True
read_with_orm_mode = True
class Repo(BaseModel):
id: str
created: datetime.datetime
display_name: str
display_url: str = Field(title="Link to view the repo")
name: str
link: str
class Config:
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()
read_with_orm_mode = True
class Build(BaseModel):
id: str
created: datetime.datetime
display_name: str
display_url: str = Field(title="Link to open the build")
status: models.BuildStatus
# created: datetime.datetime
repo: str
target_branch: str
pr: Optional[int]
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:
orm_mode = True
@app.get(
"/repos/{repo_id}/branches/{branch_id}/builds",
response_model=List[Build],
)
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()
@router.get("/status", response_model=Status)
async def controller_status():
return controller.controller
@app.get(
"/repos/{repo_id}/branches/{branch_id}/builds/{build_id}/init-log",
response_class=StreamingResponse,
responses={200: {"content": {"text/plain": {}}}},
)
def init_log(repo_id: str, branch_id: str, build_id: str):
@router.get("/repos", response_model=List[Repo])
async def repos():
# return models.Repo.all()
...
@app.get(
"/repos/{repo_id}/branches/{branch_id}/builds/{build_id}/log",
response_class=StreamingResponse,
responses={200: {"content": {"text/plain": {}}}},
@router.get(
"/repos/{org}/{repo}/branches-and-pulls",
response_model=List[BranchOrPull],
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(
"/repos/{repo_id}/branches/{branch_id}/builds/{build_id}/start",
)
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",
@router.post(
"/repos/{org}/{repo}/branches/{branch}/trigger",
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 = models.Branch.for_github_branch(db, branch_info)
return models.Build.for_branch(db, _branch, branch_info.head_sha)
controller.Build.deploy(
repo=f"{branch_info.org}/{branch_info.repo}",
target_branch=branch_info.name,
pr=None,
commit=branch_info.head_sha,
)
@app.post(
"/trigger-pr",
@router.post(
"/repos/{org}/{repo}/pulls/{pr}/trigger",
response_model=Build,
dependencies=[Depends(authenticated)],
)
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)
return models.Build.for_branch(db, _branch, pr_info.head_sha)
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()

View file

@ -1,10 +1,17 @@
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")
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
View 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()

View file

@ -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
View 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"},
)

View file

@ -2,7 +2,7 @@ class ClientError(Exception):
pass
class RepoNotFound(ClientError):
class RepoNotSupported(ClientError):
pass

View file

@ -37,7 +37,7 @@ def get_branch_info(org: str, repo: str, branch: str) -> BranchInfo:
@dataclass
class PullRequestInfo:
class PullInfo:
org: str
repo: str
number: int
@ -45,9 +45,9 @@ class PullRequestInfo:
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}")
return PullRequestInfo(
return PullInfo(
org=org,
repo=repo,
number=pr,

171
src/runboat/k8s.py Normal file
View 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",
]
)

View file

@ -2,8 +2,10 @@ apiVersion: apps/v1
kind: Deployment
metadata:
name: odoo
annotations:
runboat/todo: "start" # ask controller to start when there is capacity
spec:
replicas: 1
replicas: 0 # deploy idle (with 0 replica)
strategy:
type: Recreate
selector:
@ -17,6 +19,7 @@ spec:
initContainers:
- name: odoo-init
image: odoo
restartPolicy: Never
volumeMounts:
- name: runboat-scripts
mountPath: /runboat
@ -26,6 +29,10 @@ spec:
- configMapRef:
name: odooenv
args: ["bash", "/runboat/runboat-init.sh"]
resources:
limits:
cpu: 1000m
memory: 1Gi
containers:
- name: odoo
image: odoo
@ -43,6 +50,11 @@ spec:
- name: longpolling
containerPort: 8072
args: ["bash", "/runboat/runboat-start.sh"]
resources:
limits:
cpu: 1000m
memory: 1Gi
terminationGracePeriodSeconds: 5
volumes:
- name: runboat-scripts
configMap:

View file

@ -3,30 +3,41 @@ resources:
- service.yaml
- ingress.yaml
namePrefix: "{{ name_prefix }}-"
namespace: {{ namespace }}
nameSuffix: "-{{ build_name }}"
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:
- name: odoo
newName: "{{ image_name }}"
newTag: latest
newTag: "{{ image_tag }}"
secretGenerator:
- name: odoosecretenv
literals:
- DB_PASSWORD={{ db_passwd }}
- PGPASSWORD={{ pgpassword }}
- ADMIN_PASSWD={{ admin_passwd }}
configMapGenerator:
- name: odooenv
literals:
- DB_HOST={{ db_host }}
- DB_PORT={{ db_port }}
- DB_USER={{ db_user }}
- DB_NAME={{ db_name }}
- LIST_DB=False
- PGHOST={{ pghost }}
- PGPORT={{ pgport }}
- PGUSER={{ pguser }}
- PGNAME={{ pgname }}
- PGDATABASE={{ pgdatabase }}
- ADDONS_DIR=/build
- RUNBOAT_GIT_REPO=https://github.com/{{ repo }}
- RUNBOAT_GIT_REF={{ commit }}
- name: runboat-scripts
files:
- runboat-clone-and-install.sh

View file

@ -7,9 +7,12 @@ set -ex
# 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
git fetch origin $REF:build
git fetch origin $RUNBOAT_GIT_REF:build
git checkout build
oca_install_addons
# disable the database manager
echo "list_db = False" >> $ODOO_RC

View file

@ -10,8 +10,7 @@ bash /runboat/runboat-clone-and-install.sh
oca_wait_for_postgres
# TODO: rather, do nothing if db exists, so we can start instantly ?
dropdb --if-exists $PGDATABASE
# TODO: do nothing if db exists and all addons are installed, so we can start instantly
ADDONS=$(addons --addons-dir ${ADDONS_DIR} --include "${INCLUDE}" --exclude "${EXCLUDE}" list)

View file

@ -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:
...

View file

@ -1,15 +1,23 @@
from typing import Optional
from pydantic import BaseSettings
class Settings(BaseSettings):
database_url: str
build_pghost: Optional[str]
build_pgport: Optional[str]
build_pguser: Optional[str]
build_pgpassword: Optional[str]
admin_user: str
admin_passwd: str
supported_repos: set[str]
max_starting: int = 4
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
class Config:
env_prefix = "RUNBOAT_"
settings = Settings()

View file

@ -2,4 +2,4 @@ import re
def slugify(s: str) -> str:
return re.sub(r"[^a-z0-9]", "-", s.lower())
return re.sub(r"[^a-z0-9]", "-", str(s).lower())