Add configurable kubefiles path

This commit is contained in:
Nils Hamerlinck 2022-10-17 19:05:49 +07:00
parent 9a70138dc5
commit 57f9c85544
9 changed files with 103 additions and 48 deletions

View file

@ -6,6 +6,7 @@ RUNBOAT_BUILD_DOMAIN=runboat-builds.odoo-community.org
RUNBOAT_BUILD_ENV={"PGHOST": "postgres14.runboat-builds-db", "PGPORT": "5432", "PGUSER": "runboat-build"} RUNBOAT_BUILD_ENV={"PGHOST": "postgres14.runboat-builds-db", "PGPORT": "5432", "PGUSER": "runboat-build"}
RUNBOAT_BUILD_SECRET_ENV={"PGPASSWORD": "..."} RUNBOAT_BUILD_SECRET_ENV={"PGPASSWORD": "..."}
RUNBOAT_BUILD_TEMPLATE_VARS={"storageClassName": "my-storage-class"} RUNBOAT_BUILD_TEMPLATE_VARS={"storageClassName": "my-storage-class"}
RUNBOAT_BUILD_DEFAULT_KUBEFILES_PATH=
RUNBOAT_GITHUB_TOKEN= RUNBOAT_GITHUB_TOKEN=
RUNBOAT_LOG_CONFIG=log-config.yaml RUNBOAT_LOG_CONFIG=log-config.yaml
RUNBOAT_BASE_URL=https://runboat.odoo-community.org RUNBOAT_BASE_URL=https://runboat.odoo-community.org

View file

@ -1,4 +1,4 @@
RUNBOAT_REPOS=[{"repo": "^oca/.*", "branch": "^15.0$", "builds": [{"image": "ghcr.io/oca/oca-ci/py3.8-odoo15.0:latest"}]}] RUNBOAT_REPOS=[{"repo": "^oca/.*", "branch": "^15.0$", "builds": [{"image": "ghcr.io/oca/oca-ci/py3.8-odoo15.0:latest"}]}, {"repo": "^oca/.*", "branch": "^16.0$", "builds": [{"image": "ghcr.io/oca/oca-ci/py3.10-odoo16.0:latest", "kubefiles_path": "/tmp"}]}]
RUNBOAT_API_ADMIN_USER="admin" RUNBOAT_API_ADMIN_USER="admin"
RUNBOAT_API_ADMIN_PASSWD="admin" RUNBOAT_API_ADMIN_PASSWD="admin"
RUNBOAT_BUILD_NAMESPACE=runboat-builds RUNBOAT_BUILD_NAMESPACE=runboat-builds

View file

@ -19,6 +19,7 @@ ENV RUNBOAT_BUILD_DOMAIN=runboat-builds.example.com
ENV RUNBOAT_BUILD_ENV='{"PGHOST": "postgres14.runboat-builds-db", "PGPORT": "5432", "PGUSER": "runboat-build"}' ENV RUNBOAT_BUILD_ENV='{"PGHOST": "postgres14.runboat-builds-db", "PGPORT": "5432", "PGUSER": "runboat-build"}'
ENV RUNBOAT_BUILD_SECRET_ENV='{"PGPASSWORD": "..."}' ENV RUNBOAT_BUILD_SECRET_ENV='{"PGPASSWORD": "..."}'
ENV RUNBOAT_BUILD_TEMPLATE_VARS='{}' ENV RUNBOAT_BUILD_TEMPLATE_VARS='{}'
ENV RUNBOAT_BUILD_DEFAULT_KUBEFILES_PATH=
ENV RUNBOAT_GITHUB_TOKEN= ENV RUNBOAT_GITHUB_TOKEN=
ENV RUNBOAT_GITHUB_WEBHOOK_SECRET= ENV RUNBOAT_GITHUB_WEBHOOK_SECRET=
ENV RUNBOAT_BASE_URL=https://runboat.example.com ENV RUNBOAT_BASE_URL=https://runboat.example.com

View file

@ -141,6 +141,22 @@ resources:
- it removes the deployment finalizers and deletes resources matching the - it removes the deployment finalizers and deletes resources matching the
`runboat/build` label after the cleanup job succeeded. `runboat/build` label after the cleanup job succeeded.
### Alternative Kubefiles
By default, Runboat relies on its bundled Kubefiles:
[src/runboat/kubefiles](./src/runboat/kubefiles)
But you can define:
- a different default path through environment variable
`RUNBOAT_BUILD_DEFAULT_KUBEFILES_PATH`;
- a different path for a specific repo,
by defining the `kubefiles_path` key in `RUNBOAT_REPOS`, e.g.:
```
RUNBOAT_REPOS=[{"repo": "^oca/.*", "branch": "^15.0$", "builds": [{"image": "ghcr.io/oca/oca-ci/py3.8-odoo15.0:latest", "kubefiles_path": "/tmp"}]}]
```
## Developing ## Developing
- setup environment variables (start from `.env.sample`, the meaning of the environment - setup environment variables (start from `.env.sample`, the meaning of the environment

View file

@ -179,11 +179,23 @@ def make_deployment_vars(
@contextmanager @contextmanager
def _render_kubefiles(deployment_vars: DeploymentVars) -> Generator[Path, None, None]: def _get_kubefiles_path(kubefiles_path: Path | None) -> Generator[Path, None, None]:
with resources.path( if kubefiles_path:
__package__, "kubefiles" yield kubefiles_path
else:
with resources.path(__package__, "kubefiles") as default_kubefiles_path:
yield default_kubefiles_path
@contextmanager
def _render_kubefiles(
kubefiles_path: Path | None, deployment_vars: DeploymentVars
) -> Generator[Path, None, None]:
with _get_kubefiles_path(
kubefiles_path
) as kubefiles_path, tempfile.TemporaryDirectory() as tmp_dir: ) as kubefiles_path, tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = Path(tmp_dir) tmp_path = Path(tmp_dir)
_logger.debug("kubefiles path: %s", kubefiles_path)
# TODO async copytree, or make this whole _render_kubefiles run_in_executor # TODO async copytree, or make this whole _render_kubefiles run_in_executor
shutil.copytree(kubefiles_path, tmp_path, dirs_exist_ok=True) shutil.copytree(kubefiles_path, tmp_path, dirs_exist_ok=True)
template = Template((tmp_path / "kustomization.yaml.jinja").read_text()) template = Template((tmp_path / "kustomization.yaml.jinja").read_text())
@ -203,8 +215,8 @@ async def _kubectl(args: list[str]) -> None:
raise subprocess.CalledProcessError(return_code, ["kubectl"] + args) raise subprocess.CalledProcessError(return_code, ["kubectl"] + args)
async def deploy(deployment_vars: DeploymentVars) -> None: async def deploy(kubefiles_path: Path | None, deployment_vars: DeploymentVars) -> None:
with _render_kubefiles(deployment_vars) as tmp_path: with _render_kubefiles(kubefiles_path, deployment_vars) as tmp_path:
# Dry-run first to avoid creating some resources when the creation of the # Dry-run first to avoid creating some resources when the creation of the
# deployment itself fails. In such cases, we would have resource leak as the # deployment itself fails. In such cases, we would have resource leak as the
# existence of deployment is how the controller knows it has something to # existence of deployment is how the controller knows it has something to

View file

@ -178,22 +178,24 @@ class Build(BaseModel):
return await k8s.log(self.name, job_kind=None) return await k8s.log(self.name, job_kind=None)
@classmethod @classmethod
async def deploy(cls, commit_info: CommitInfo) -> None: async def _deploy(
"""Deploy a build, without starting it.""" cls, commit_info: CommitInfo, name: str, slug: str, job_kind: k8s.DeploymentMode
name = f"b{uuid.uuid4()}" ) -> None:
slug = cls.make_slug(commit_info) """Internal method to prepare for and handle a k8s.deploy()"""
_logger.info(f"Deploying {slug} ({name}).")
build_settings = settings.get_build_settings( build_settings = settings.get_build_settings(
commit_info.repo, commit_info.target_branch commit_info.repo, commit_info.target_branch
)[0]
kubefiles_path = (
build_settings.kubefiles_path or settings.build_default_kubefiles_path
) )
deployment_vars = k8s.make_deployment_vars( deployment_vars = k8s.make_deployment_vars(
k8s.DeploymentMode.deployment, job_kind,
name, name,
slug, slug,
commit_info, commit_info,
build_settings[0], build_settings,
) )
await k8s.deploy(deployment_vars) await k8s.deploy(kubefiles_path, deployment_vars)
await github.notify_status( await github.notify_status(
commit_info.repo, commit_info.repo,
commit_info.git_commit, commit_info.git_commit,
@ -201,6 +203,16 @@ class Build(BaseModel):
target_url=None, target_url=None,
) )
@classmethod
async def deploy(cls, commit_info: CommitInfo) -> None:
"""Deploy a build, without starting it."""
name = f"b{uuid.uuid4()}"
slug = cls.make_slug(commit_info)
_logger.info(f"Deploying {slug} ({name}).")
await cls._deploy(
commit_info, name, slug, job_kind=k8s.DeploymentMode.deployment
)
async def start(self) -> None: async def start(self) -> None:
"""Start build if init succeeded, or reinitialize if failed.""" """Start build if init succeeded, or reinitialize if failed."""
if self.status not in (BuildStatus.stopped, BuildStatus.stopping): if self.status not in (BuildStatus.stopped, BuildStatus.stopping):
@ -229,41 +241,27 @@ class Build(BaseModel):
_logger.info(f"Redeploying {self}.") _logger.info(f"Redeploying {self}.")
await k8s.kill_job(self.name, job_kind=k8s.DeploymentMode.cleanup) await k8s.kill_job(self.name, job_kind=k8s.DeploymentMode.cleanup)
await k8s.kill_job(self.name, job_kind=k8s.DeploymentMode.initialize) await k8s.kill_job(self.name, job_kind=k8s.DeploymentMode.initialize)
deployment_vars = k8s.make_deployment_vars( await self._deploy(
k8s.DeploymentMode.deployment, self.commit_info,
self.name, self.name,
self.slug, self.slug,
self.commit_info, job_kind=k8s.DeploymentMode.deployment,
settings.get_build_settings(
self.commit_info.repo, self.commit_info.target_branch
)[0],
)
await k8s.deploy(deployment_vars)
await github.notify_status(
self.commit_info.repo,
self.commit_info.git_commit,
GitHubStatusState.pending,
target_url=None,
) )
async def initialize(self) -> None: async def initialize(self) -> None:
"""Launch the initialization job.""" """Launch the initialization job."""
# Start initizalization job. on_initialize_{started,succeeded,failed} callbacks # Start initialization job. on_initialize_{started,succeeded,failed} callbacks
# will follow from job events. # will follow from job events.
_logger.info(f"Deploying initialize job for {self}.") _logger.info(f"Deploying initialize job for {self}.")
deployment_vars = k8s.make_deployment_vars( await self._deploy(
k8s.DeploymentMode.initialize, self.commit_info,
self.name, self.name,
self.slug, self.slug,
self.commit_info, job_kind=k8s.DeploymentMode.initialize,
settings.get_build_settings(
self.commit_info.repo, self.commit_info.target_branch
)[0],
) )
await k8s.deploy(deployment_vars)
async def cleanup(self) -> None: async def cleanup(self) -> None:
"""Launch the clenaup job.""" """Launch the cleanup job."""
# Kill the initialization job to reduce conflict with the cleanup job, such as # Kill the initialization job to reduce conflict with the cleanup job, such as
# the database being created by the initialization after the cleanup job has # the database being created by the initialization after the cleanup job has
# completed. # completed.
@ -273,16 +271,9 @@ class Build(BaseModel):
# Start cleanup job. on_cleanup_{started,succeeded,failed} callbacks will follow # Start cleanup job. on_cleanup_{started,succeeded,failed} callbacks will follow
# from job events. # from job events.
_logger.info(f"Deploying cleanup job for {self}.") _logger.info(f"Deploying cleanup job for {self}.")
deployment_vars = k8s.make_deployment_vars( await self._deploy(
k8s.DeploymentMode.cleanup, self.commit_info, self.name, self.slug, job_kind=k8s.DeploymentMode.cleanup
self.name,
self.slug,
self.commit_info,
settings.get_build_settings(
self.commit_info.repo, self.commit_info.target_branch
)[0],
) )
await k8s.deploy(deployment_vars)
async def on_initialize_started(self) -> None: async def on_initialize_started(self) -> None:
if self.init_status == BuildInitStatus.started: if self.init_status == BuildInitStatus.started:

View file

@ -1,16 +1,31 @@
import re import re
from pathlib import Path
from pydantic import BaseModel, BaseSettings, validator from pydantic import BaseModel, BaseSettings, validator
from .exceptions import RepoOrBranchNotSupported from .exceptions import RepoOrBranchNotSupported
def validate_path(v: str | None) -> Path | None:
if not v:
return None
p = Path(v)
if not p.is_dir():
raise ValueError(f"Invalid path: {p}")
return p
class BuildSettings(BaseModel): class BuildSettings(BaseModel):
image: str # container image:tag image: str # container image:tag
# These extend the respective global settings. # These extend the respective global settings.
env: dict[str, str] = {} env: dict[str, str] = {}
secret_env: dict[str, str] = {} secret_env: dict[str, str] = {}
template_vars: dict[str, str] = {} template_vars: dict[str, str] = {}
kubefiles_path: Path | None
validate_kubefiles_path = validator("kubefiles_path", allow_reuse=True)(
validate_path
)
class RepoSettings(BaseModel): class RepoSettings(BaseModel):
@ -51,6 +66,8 @@ class Settings(BaseSettings):
# A dictionary of variables to be set in the jinja rendering context for the # A dictionary of variables to be set in the jinja rendering context for the
# kubefiles. # kubefiles.
build_template_vars: dict[str, str] = {} build_template_vars: dict[str, str] = {}
# The path of the default kubefiles to be used.
build_default_kubefiles_path: Path | None
# The token to use for the GitHub api calls (to query branches and pull requests, # The token to use for the GitHub api calls (to query branches and pull requests,
# and report build statuses). # and report build statuses).
github_token: str | None github_token: str | None
@ -66,6 +83,10 @@ class Settings(BaseSettings):
# Disable posting of statuses to GitHub commits # Disable posting of statuses to GitHub commits
disable_commit_statuses: bool = False disable_commit_statuses: bool = False
validate_build_default_kubefiles_path = validator(
"build_default_kubefiles_path", allow_reuse=True
)(validate_path)
def get_build_settings(self, repo: str, target_branch: str) -> list[BuildSettings]: def get_build_settings(self, repo: str, target_branch: str) -> list[BuildSettings]:
for repo_settings in self.repos: for repo_settings in self.repos:
if not re.match(repo_settings.repo, repo, re.IGNORECASE): if not re.match(repo_settings.repo, repo, re.IGNORECASE):

View file

@ -1,6 +1,6 @@
from runboat.github import CommitInfo from runboat.github import CommitInfo
from runboat.k8s import DeploymentMode, _render_kubefiles, make_deployment_vars from runboat.k8s import DeploymentMode, _render_kubefiles, make_deployment_vars
from runboat.settings import BuildSettings from runboat.settings import BuildSettings, settings
EXPECTED = """\ EXPECTED = """\
resources: resources:
@ -79,6 +79,8 @@ patches:
def test_render_kubefiles() -> None: def test_render_kubefiles() -> None:
build_settings = BuildSettings(image="ghcr.io/oca/oca-ci:py3.8-odoo15.0")
kubefiles_path = settings.build_default_kubefiles_path
deployment_vars = make_deployment_vars( deployment_vars = make_deployment_vars(
mode=DeploymentMode.deployment, mode=DeploymentMode.deployment,
build_name="build-name", build_name="build-name",
@ -89,9 +91,9 @@ def test_render_kubefiles() -> None:
pr=None, pr=None,
git_commit="abcdef123456789", git_commit="abcdef123456789",
), ),
build_settings=BuildSettings(image="ghcr.io/oca/oca-ci:py3.8-odoo15.0"), build_settings=build_settings,
) )
with _render_kubefiles(deployment_vars) as tmp_path: with _render_kubefiles(kubefiles_path, deployment_vars) as tmp_path:
assert (tmp_path / "kustomization.yaml").is_file() assert (tmp_path / "kustomization.yaml").is_file()
assert (tmp_path / "deployment.yaml").is_file() assert (tmp_path / "deployment.yaml").is_file()
kustomization = (tmp_path / "kustomization.yaml").read_text() kustomization = (tmp_path / "kustomization.yaml").read_text()

View file

@ -1,3 +1,5 @@
from pathlib import Path
import pytest import pytest
from runboat.exceptions import RepoOrBranchNotSupported from runboat.exceptions import RepoOrBranchNotSupported
@ -12,3 +14,12 @@ def test_get_build_settings() -> None:
settings.get_build_settings("acsone/mis-builder", "15.0") settings.get_build_settings("acsone/mis-builder", "15.0")
with pytest.raises(RepoOrBranchNotSupported): with pytest.raises(RepoOrBranchNotSupported):
assert not settings.get_build_settings("OCA/mis-builder", "15.0-stuff") assert not settings.get_build_settings("OCA/mis-builder", "15.0-stuff")
assert settings.get_build_settings("OCA/mis-builder", "15.0") == [
BuildSettings(image="ghcr.io/oca/oca-ci/py3.8-odoo15.0:latest")
]
assert settings.get_build_settings("OCA/mis-builder", "16.0") == [
BuildSettings(
image="ghcr.io/oca/oca-ci/py3.10-odoo16.0:latest",
kubefiles_path=Path("/tmp"),
)
]