diff --git a/README.md b/README.md index 36d8bb2..b7efcbb 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 991961e..fae698b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/src/runboat/api.py b/src/runboat/api.py index 3d3d6f4..63d829e 100644 --- a/src/runboat/api.py +++ b/src/runboat/api.py @@ -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() diff --git a/src/runboat/app.py b/src/runboat/app.py index 60a89cc..cd6f3f8 100644 --- a/src/runboat/app.py +++ b/src/runboat/app.py @@ -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() diff --git a/src/runboat/controller.py b/src/runboat/controller.py new file mode 100644 index 0000000..e452db9 --- /dev/null +++ b/src/runboat/controller.py @@ -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() diff --git a/src/runboat/db.py b/src/runboat/db.py deleted file mode 100644 index 3c0e9f5..0000000 --- a/src/runboat/db.py +++ /dev/null @@ -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() diff --git a/src/runboat/deps.py b/src/runboat/deps.py new file mode 100644 index 0000000..410076c --- /dev/null +++ b/src/runboat/deps.py @@ -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"}, + ) diff --git a/src/runboat/exceptions.py b/src/runboat/exceptions.py index 1d98b87..aeaa806 100644 --- a/src/runboat/exceptions.py +++ b/src/runboat/exceptions.py @@ -2,7 +2,7 @@ class ClientError(Exception): pass -class RepoNotFound(ClientError): +class RepoNotSupported(ClientError): pass diff --git a/src/runboat/github.py b/src/runboat/github.py index 1f70c74..56d8fc9 100644 --- a/src/runboat/github.py +++ b/src/runboat/github.py @@ -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, diff --git a/src/runboat/k8s.py b/src/runboat/k8s.py new file mode 100644 index 0000000..0202f41 --- /dev/null +++ b/src/runboat/k8s.py @@ -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", + ] + ) diff --git a/src/runboat/kubefiles/deployment.yaml b/src/runboat/kubefiles/deployment.yaml index 0535a71..0684621 100644 --- a/src/runboat/kubefiles/deployment.yaml +++ b/src/runboat/kubefiles/deployment.yaml @@ -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: diff --git a/src/runboat/kubefiles/kustomization.yaml.jinja b/src/runboat/kubefiles/kustomization.yaml.jinja index dd780e7..10f175f 100644 --- a/src/runboat/kubefiles/kustomization.yaml.jinja +++ b/src/runboat/kubefiles/kustomization.yaml.jinja @@ -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 diff --git a/src/runboat/kubefiles/runboat-clone-and-install.sh b/src/runboat/kubefiles/runboat-clone-and-install.sh index 59ad6ee..bce65f9 100755 --- a/src/runboat/kubefiles/runboat-clone-and-install.sh +++ b/src/runboat/kubefiles/runboat-clone-and-install.sh @@ -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 diff --git a/src/runboat/kubefiles/runboat-init.sh b/src/runboat/kubefiles/runboat-init.sh index 6d4bba1..83b9165 100755 --- a/src/runboat/kubefiles/runboat-init.sh +++ b/src/runboat/kubefiles/runboat-init.sh @@ -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) diff --git a/src/runboat/models.py b/src/runboat/models.py deleted file mode 100644 index 288c066..0000000 --- a/src/runboat/models.py +++ /dev/null @@ -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: - ... diff --git a/src/runboat/settings.py b/src/runboat/settings.py index c9477a7..0ffed37 100644 --- a/src/runboat/settings.py +++ b/src/runboat/settings.py @@ -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() diff --git a/src/runboat/utils.py b/src/runboat/utils.py index 308eaab..e10f070 100644 --- a/src/runboat/utils.py +++ b/src/runboat/utils.py @@ -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())