mirror of
https://gitlab.com/itsulu-odoo/runboat.git
synced 2026-05-30 23:41:27 +00:00
Initial commit
This commit is contained in:
commit
ac0bd848d4
25 changed files with 943 additions and 0 deletions
5
.flake8
Normal file
5
.flake8
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
[flake8]
|
||||
max-line-length = 88
|
||||
; E203 for black
|
||||
; B008 does not like fastapi Depends
|
||||
extend-ignore = E203,B008
|
||||
131
.gitignore
vendored
Normal file
131
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
tmp/
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
42
.pre-commit-config.yaml
Normal file
42
.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
default_language_version:
|
||||
python: python3
|
||||
repos:
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 21.9b0
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/myint/autoflake
|
||||
rev: v1.4
|
||||
hooks:
|
||||
- id: autoflake
|
||||
args:
|
||||
- --in-place
|
||||
- --ignore-init-module-imports
|
||||
- --remove-all-unused-imports
|
||||
- --remove-duplicate-keys
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.0.1
|
||||
hooks:
|
||||
- id: check-toml
|
||||
- id: check-yaml
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: "4.0.1"
|
||||
hooks:
|
||||
- id: flake8
|
||||
additional_dependencies: ["flake8-bugbear==21.4.3"]
|
||||
- repo: https://github.com/pre-commit/mirrors-isort
|
||||
rev: v5.9.3
|
||||
hooks:
|
||||
- id: isort
|
||||
- repo: https://github.com/myint/docformatter
|
||||
rev: v1.4
|
||||
hooks:
|
||||
- id: docformatter
|
||||
args: ["--in-place", "--wrap-summaries=88"]
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v2.29.0
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: ["--py38-plus"]
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2021 Stéphane Bidoul
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
3
README.md
Normal file
3
README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# runboat ☸️
|
||||
|
||||
A simple runbot lookalike on kubernetes. Main goal is replacing the OCA runbot.
|
||||
28
pyproject.toml
Normal file
28
pyproject.toml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
[build-system]
|
||||
requires = ["flit_core >=3.4,<4"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
|
||||
[project]
|
||||
name = "runboat"
|
||||
authors = [{name = "St\u00e9phane Bidoul", email = "stephane.bidoul@gmail.com"}]
|
||||
readme = "README.md"
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
]
|
||||
dependencies = [
|
||||
"fastapi",
|
||||
"jinja2",
|
||||
"kubernetes",
|
||||
"psycopg2",
|
||||
"sqlalchemy",
|
||||
"uvicorn",
|
||||
]
|
||||
dynamic = ["version", "description"]
|
||||
|
||||
[project.urls]
|
||||
Home = "https://github.com/sbidoul/runboat"
|
||||
|
||||
[tool.isort]
|
||||
profile = 'black'
|
||||
|
||||
# flake8 config is in .flake8
|
||||
33
requirements.txt
Normal file
33
requirements.txt
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# frozen requirements generated by pip-deepfreeze
|
||||
anyio==3.3.3
|
||||
asgiref==3.4.1
|
||||
cachetools==4.2.4
|
||||
certifi==2021.10.8
|
||||
charset-normalizer==2.0.7
|
||||
click==8.0.3
|
||||
fastapi==0.70.0
|
||||
google-auth==2.3.0
|
||||
greenlet==1.1.2
|
||||
h11==0.12.0
|
||||
idna==3.3
|
||||
Jinja2==3.0.2
|
||||
kubernetes==18.20.0
|
||||
MarkupSafe==2.0.1
|
||||
oauthlib==3.1.1
|
||||
psycopg2==2.9.1
|
||||
pyasn1==0.4.8
|
||||
pyasn1-modules==0.2.8
|
||||
pydantic==1.8.2
|
||||
python-dateutil==2.8.2
|
||||
PyYAML==6.0
|
||||
requests==2.26.0
|
||||
requests-oauthlib==1.3.0
|
||||
rsa==4.7.2
|
||||
six==1.16.0
|
||||
sniffio==1.2.0
|
||||
SQLAlchemy==1.4.25
|
||||
starlette==0.16.0
|
||||
typing-extensions==3.10.0.2
|
||||
urllib3==1.26.7
|
||||
uvicorn==0.15.0
|
||||
websocket-client==1.2.1
|
||||
2
src/runboat/__init__.py
Normal file
2
src/runboat/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
"""A simple runbot lookalike using kubernetes."""
|
||||
__version__ = "0.1"
|
||||
131
src/runboat/api.py
Normal file
131
src/runboat/api.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import datetime
|
||||
from enum import Enum
|
||||
from typing import List
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import github, models
|
||||
from .app import app
|
||||
from .db import get_db
|
||||
|
||||
|
||||
class Repo(BaseModel):
|
||||
id: str
|
||||
created: datetime.datetime
|
||||
display_name: str
|
||||
display_url: str = Field(title="Link to view the repo")
|
||||
|
||||
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)):
|
||||
return db.query(models.Branch).filter(models.Branch.repo_id == repo_id).all()
|
||||
|
||||
|
||||
class BuildStatus(str, Enum):
|
||||
stopped = "stopped"
|
||||
running = "running"
|
||||
deploying = "deploying"
|
||||
not_deployed = "not_deployed"
|
||||
|
||||
|
||||
class Build(BaseModel):
|
||||
id: str
|
||||
created: datetime.datetime
|
||||
display_name: str
|
||||
display_url: str = Field(title="Link to open the build")
|
||||
status: BuildStatus
|
||||
|
||||
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):
|
||||
...
|
||||
|
||||
|
||||
@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):
|
||||
...
|
||||
|
||||
|
||||
@app.get(
|
||||
"/repos/{repo_id}/branches/{branch_id}/builds/{build_id}/log",
|
||||
response_class=StreamingResponse,
|
||||
responses={200: {"content": {"text/plain": {}}}},
|
||||
)
|
||||
def log(repo_id: str, branch_id: str, build_id: str):
|
||||
...
|
||||
|
||||
|
||||
@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",
|
||||
response_model=Build,
|
||||
)
|
||||
def trigger_branch(org: str, repo: str, branch: str, db: Session = Depends(get_db)):
|
||||
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)
|
||||
|
||||
|
||||
@app.post(
|
||||
"/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)
|
||||
return models.Build.for_branch(db, branch, pr_info.head_sha)
|
||||
10
src/runboat/app.py
Normal file
10
src/runboat/app.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
from fastapi import FastAPI
|
||||
|
||||
from .db import create_tables
|
||||
|
||||
app = FastAPI(title="Runboat")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
create_tables()
|
||||
14
src/runboat/build_images.py
Normal file
14
src/runboat/build_images.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from typing import Optional
|
||||
|
||||
images = {
|
||||
"15.0": "ghcr.io/oca/oca-ci/py3.8-odoo15.0:latest",
|
||||
"14.0": "ghcr.io/oca/oca-ci/py3.6-odoo14.0:latest",
|
||||
"13.0": "ghcr.io/oca/oca-ci/py3.6-odoo13.0:latest",
|
||||
"12.0": "ghcr.io/oca/oca-ci/py3.6-odoo12.0:latest",
|
||||
"11.0": "ghcr.io/oca/oca-ci/py3.5-odoo11.0:latest",
|
||||
"10.0": "ghcr.io/oca/oca-ci/py2.7-odoo10.0:latest",
|
||||
}
|
||||
|
||||
|
||||
def get_build_image(target_branch: str) -> Optional[str]:
|
||||
return images.get(target_branch)
|
||||
21
src/runboat/db.py
Normal file
21
src/runboat/db.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import Session, 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():
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
|
||||
def get_db() -> Session:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
14
src/runboat/exceptions.py
Normal file
14
src/runboat/exceptions.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
class ClientError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RepoNotFound(ClientError):
|
||||
pass
|
||||
|
||||
|
||||
class BranchNotFound(ClientError):
|
||||
pass
|
||||
|
||||
|
||||
class NotFoundOnGithub(ClientError):
|
||||
pass
|
||||
56
src/runboat/github.py
Normal file
56
src/runboat/github.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from .exceptions import NotFoundOnGithub
|
||||
|
||||
|
||||
def _github_get(url: str) -> Any:
|
||||
full_url = f"https://api.github.com{url}"
|
||||
headers = {
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
}
|
||||
response = requests.get(full_url, headers)
|
||||
if response.status_code == 404:
|
||||
raise NotFoundOnGithub(full_url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
@dataclass
|
||||
class BranchInfo:
|
||||
org: str
|
||||
repo: str
|
||||
name: str
|
||||
head_sha: str
|
||||
|
||||
|
||||
def get_branch_info(org: str, repo: str, branch: str) -> BranchInfo:
|
||||
branch_data = _github_get(f"/repos/{org}/{repo}/git/ref/heads/{branch}")
|
||||
return BranchInfo(
|
||||
org=org,
|
||||
repo=repo,
|
||||
name=branch,
|
||||
head_sha=branch_data["object"]["sha"],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PullRequestInfo:
|
||||
org: str
|
||||
repo: str
|
||||
number: int
|
||||
head_sha: str
|
||||
target_branch: str
|
||||
|
||||
|
||||
def get_pr_info(org: str, repo: str, pr: int) -> PullRequestInfo:
|
||||
pr_data = _github_get(f"/repos/{org}/{repo}/pulls/{pr}")
|
||||
return PullRequestInfo(
|
||||
org=org,
|
||||
repo=repo,
|
||||
number=pr,
|
||||
head_sha=pr_data["head"]["sha"],
|
||||
target_branch=pr_data["base"]["ref"],
|
||||
)
|
||||
49
src/runboat/kubefiles/deployment.yaml
Normal file
49
src/runboat/kubefiles/deployment.yaml
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: odoo
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: odoo
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: odoo
|
||||
spec:
|
||||
initContainers:
|
||||
- name: odoo-init
|
||||
image: odoo
|
||||
volumeMounts:
|
||||
- name: runboat-scripts
|
||||
mountPath: /runboat
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: odoosecretenv
|
||||
- configMapRef:
|
||||
name: odooenv
|
||||
args: ["bash", "/runboat/runboat-init.sh"]
|
||||
containers:
|
||||
- name: odoo
|
||||
image: odoo
|
||||
volumeMounts:
|
||||
- name: runboat-scripts
|
||||
mountPath: /runboat
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: odoosecretenv
|
||||
- configMapRef:
|
||||
name: odooenv
|
||||
ports:
|
||||
- name: web
|
||||
containerPort: 8069
|
||||
- name: longpolling
|
||||
containerPort: 8072
|
||||
args: ["bash", "/runboat/runboat-start.sh"]
|
||||
volumes:
|
||||
- name: runboat-scripts
|
||||
configMap:
|
||||
name: runboat-scripts
|
||||
23
src/runboat/kubefiles/ingress.yaml
Normal file
23
src/runboat/kubefiles/ingress.yaml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: odoo
|
||||
spec:
|
||||
rules:
|
||||
- host: $(HOSTNAME)
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: odoo
|
||||
port:
|
||||
number: 8069
|
||||
- path: /longpolling
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: odoo
|
||||
port:
|
||||
number: 8072
|
||||
46
src/runboat/kubefiles/kustomization.yaml.jinja
Normal file
46
src/runboat/kubefiles/kustomization.yaml.jinja
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
resources:
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
- ingress.yaml
|
||||
|
||||
namePrefix: "{{ name_prefix }}-"
|
||||
|
||||
commonLabels:
|
||||
runboat-build: "{{ name_prefix}}"
|
||||
|
||||
images:
|
||||
- name: odoo
|
||||
newName: "{{ image_name }}"
|
||||
newTag: latest
|
||||
|
||||
secretGenerator:
|
||||
- name: odoosecretenv
|
||||
literals:
|
||||
- DB_PASSWORD={{ db_passwd }}
|
||||
- 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
|
||||
- name: runboat-scripts
|
||||
files:
|
||||
- runboat-clone-and-install.sh
|
||||
- runboat-init.sh
|
||||
- runboat-start.sh
|
||||
- name: vars
|
||||
literals:
|
||||
- HOSTNAME={{ hostname }}
|
||||
|
||||
vars:
|
||||
- name: HOSTNAME
|
||||
objref:
|
||||
name: vars
|
||||
kind: ConfigMap
|
||||
apiVersion: v1
|
||||
fieldref:
|
||||
fieldpath: data.HOSTNAME
|
||||
15
src/runboat/kubefiles/runboat-clone-and-install.sh
Executable file
15
src/runboat/kubefiles/runboat-clone-and-install.sh
Executable file
|
|
@ -0,0 +1,15 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -ex
|
||||
|
||||
#
|
||||
# Clone an addons repository at git reference in $ADDONS_DIR.
|
||||
# Run oca_install_addons and oca_init_db on it.
|
||||
#
|
||||
|
||||
git clone --filter=blob:none $REPO $ADDONS_DIR
|
||||
cd $ADDONS_DIR
|
||||
git fetch origin $REF:build
|
||||
git checkout build
|
||||
|
||||
oca_install_addons
|
||||
21
src/runboat/kubefiles/runboat-init.sh
Executable file
21
src/runboat/kubefiles/runboat-init.sh
Executable file
|
|
@ -0,0 +1,21 @@
|
|||
#!/bin/bash
|
||||
|
||||
#
|
||||
# Install all addons to test.
|
||||
#
|
||||
|
||||
set -ex
|
||||
|
||||
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
|
||||
|
||||
ADDONS=$(addons --addons-dir ${ADDONS_DIR} --include "${INCLUDE}" --exclude "${EXCLUDE}" list)
|
||||
|
||||
unbuffer $(which odoo || which openerp-server) \
|
||||
-d ${PGDATABASE} \
|
||||
-i ${ADDONS:-base} \
|
||||
--stop-after-init
|
||||
14
src/runboat/kubefiles/runboat-start.sh
Executable file
14
src/runboat/kubefiles/runboat-start.sh
Executable file
|
|
@ -0,0 +1,14 @@
|
|||
#!/bin/bash
|
||||
|
||||
#
|
||||
# Start Odoo
|
||||
#
|
||||
|
||||
set -ex
|
||||
|
||||
bash /runboat/runboat-clone-and-install.sh
|
||||
|
||||
oca_wait_for_postgres
|
||||
|
||||
unbuffer $(which odoo || which openerp-server) \
|
||||
-d ${PGDATABASE}
|
||||
17
src/runboat/kubefiles/service.yaml
Normal file
17
src/runboat/kubefiles/service.yaml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: odoo
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 8069
|
||||
targetPort: 8069
|
||||
protocol: TCP
|
||||
name: web
|
||||
- port: 8072
|
||||
targetPort: 8072
|
||||
protocol: TCP
|
||||
name: longpolling
|
||||
selector:
|
||||
app: odoo
|
||||
205
src/runboat/models.py
Normal file
205
src/runboat/models.py
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.ext.compiler import compiles
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import expression
|
||||
|
||||
from .build_images import get_build_image
|
||||
from .db import Base, Session
|
||||
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 = 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()
|
||||
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 = Column(String, nullable=False)
|
||||
pr = Column(Integer, nullable=True)
|
||||
|
||||
repo_id = Column(Integer, ForeignKey("repo.id"), nullable=False, index=True)
|
||||
|
||||
repo = relationship("Repo", back_populates="branches")
|
||||
builds = 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:
|
||||
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:
|
||||
branch = Branch(
|
||||
repo=repo,
|
||||
target_branch=pr_info.target_branch,
|
||||
pr=pr_info.number,
|
||||
)
|
||||
db.add(branch)
|
||||
db.flush()
|
||||
return branch
|
||||
|
||||
|
||||
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 = relationship("Branch", back_populates="builds")
|
||||
|
||||
build_image = Column(String, nullable=False)
|
||||
git_sha = Column(String, nullable=False)
|
||||
status = 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":
|
||||
print("*******", branch)
|
||||
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="not_deployed", # TODO use same enum as in API
|
||||
)
|
||||
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:
|
||||
...
|
||||
15
src/runboat/settings.py
Normal file
15
src/runboat/settings.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
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]
|
||||
build_domain: str
|
||||
|
||||
|
||||
settings = Settings()
|
||||
5
src/runboat/utils.py
Normal file
5
src/runboat/utils.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import re
|
||||
|
||||
|
||||
def slugify(s: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]", "-", s.lower())
|
||||
22
src/runboat/webhooks.py
Normal file
22
src/runboat/webhooks.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
def on_pr_open_or_update():
|
||||
# find Repo
|
||||
# find image from target branch (exit if not found)
|
||||
# find or create Branch
|
||||
# create Build
|
||||
# start build (enqueue)
|
||||
...
|
||||
|
||||
|
||||
def on_pr_close_or_merge():
|
||||
# find Repo, Branch
|
||||
# delete branch (enqueue)
|
||||
...
|
||||
|
||||
|
||||
def on_push():
|
||||
# find Repo, branch
|
||||
# find image from target branch (exit if not found)
|
||||
# find or create Branch
|
||||
# create Build
|
||||
# start build (enqueue)
|
||||
...
|
||||
Loading…
Reference in a new issue