From 57f9c855440a43f3760ac92d441536eeb3e39051 Mon Sep 17 00:00:00 2001 From: Nils Hamerlinck Date: Mon, 17 Oct 2022 19:05:49 +0700 Subject: [PATCH] Add configurable kubefiles path --- .env.sample | 1 + .env.test | 2 +- Dockerfile | 1 + README.md | 16 ++++++++ src/runboat/k8s.py | 22 ++++++++--- src/runboat/models.py | 69 +++++++++++++++------------------- src/runboat/settings.py | 21 +++++++++++ tests/test_render_kubefiles.py | 8 ++-- tests/test_settings.py | 11 ++++++ 9 files changed, 103 insertions(+), 48 deletions(-) diff --git a/.env.sample b/.env.sample index 31d0089..ba7d832 100644 --- a/.env.sample +++ b/.env.sample @@ -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_SECRET_ENV={"PGPASSWORD": "..."} RUNBOAT_BUILD_TEMPLATE_VARS={"storageClassName": "my-storage-class"} +RUNBOAT_BUILD_DEFAULT_KUBEFILES_PATH= RUNBOAT_GITHUB_TOKEN= RUNBOAT_LOG_CONFIG=log-config.yaml RUNBOAT_BASE_URL=https://runboat.odoo-community.org diff --git a/.env.test b/.env.test index c65b3ea..087069e 100644 --- a/.env.test +++ b/.env.test @@ -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_PASSWD="admin" RUNBOAT_BUILD_NAMESPACE=runboat-builds diff --git a/Dockerfile b/Dockerfile index da2ae67..79c1870 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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_SECRET_ENV='{"PGPASSWORD": "..."}' ENV RUNBOAT_BUILD_TEMPLATE_VARS='{}' +ENV RUNBOAT_BUILD_DEFAULT_KUBEFILES_PATH= ENV RUNBOAT_GITHUB_TOKEN= ENV RUNBOAT_GITHUB_WEBHOOK_SECRET= ENV RUNBOAT_BASE_URL=https://runboat.example.com diff --git a/README.md b/README.md index 31c81f7..06e4b11 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,22 @@ resources: - it removes the deployment finalizers and deletes resources matching the `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 - setup environment variables (start from `.env.sample`, the meaning of the environment diff --git a/src/runboat/k8s.py b/src/runboat/k8s.py index 305ce0c..e21cc5f 100644 --- a/src/runboat/k8s.py +++ b/src/runboat/k8s.py @@ -179,11 +179,23 @@ def make_deployment_vars( @contextmanager -def _render_kubefiles(deployment_vars: DeploymentVars) -> Generator[Path, None, None]: - with resources.path( - __package__, "kubefiles" +def _get_kubefiles_path(kubefiles_path: Path | None) -> Generator[Path, None, None]: + if kubefiles_path: + 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: tmp_path = Path(tmp_dir) + _logger.debug("kubefiles path: %s", kubefiles_path) # TODO async copytree, or make this whole _render_kubefiles run_in_executor shutil.copytree(kubefiles_path, tmp_path, dirs_exist_ok=True) 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) -async def deploy(deployment_vars: DeploymentVars) -> None: - with _render_kubefiles(deployment_vars) as tmp_path: +async def deploy(kubefiles_path: Path | None, deployment_vars: DeploymentVars) -> None: + with _render_kubefiles(kubefiles_path, deployment_vars) as tmp_path: # 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 # existence of deployment is how the controller knows it has something to diff --git a/src/runboat/models.py b/src/runboat/models.py index cb5be25..f2f0656 100644 --- a/src/runboat/models.py +++ b/src/runboat/models.py @@ -178,22 +178,24 @@ class Build(BaseModel): return await k8s.log(self.name, job_kind=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}).") + async def _deploy( + cls, commit_info: CommitInfo, name: str, slug: str, job_kind: k8s.DeploymentMode + ) -> None: + """Internal method to prepare for and handle a k8s.deploy()""" build_settings = settings.get_build_settings( 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( - k8s.DeploymentMode.deployment, + job_kind, name, slug, commit_info, - build_settings[0], + build_settings, ) - await k8s.deploy(deployment_vars) + await k8s.deploy(kubefiles_path, deployment_vars) await github.notify_status( commit_info.repo, commit_info.git_commit, @@ -201,6 +203,16 @@ class Build(BaseModel): 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: """Start build if init succeeded, or reinitialize if failed.""" if self.status not in (BuildStatus.stopped, BuildStatus.stopping): @@ -229,41 +241,27 @@ class Build(BaseModel): _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.initialize) - deployment_vars = k8s.make_deployment_vars( - k8s.DeploymentMode.deployment, + await self._deploy( + self.commit_info, 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) - await github.notify_status( - self.commit_info.repo, - self.commit_info.git_commit, - GitHubStatusState.pending, - target_url=None, + job_kind=k8s.DeploymentMode.deployment, ) async def initialize(self) -> None: """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. _logger.info(f"Deploying initialize job for {self}.") - deployment_vars = k8s.make_deployment_vars( - k8s.DeploymentMode.initialize, + await self._deploy( + self.commit_info, self.name, self.slug, - self.commit_info, - settings.get_build_settings( - self.commit_info.repo, self.commit_info.target_branch - )[0], + job_kind=k8s.DeploymentMode.initialize, ) - await k8s.deploy(deployment_vars) 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 # the database being created by the initialization after the cleanup job has # completed. @@ -273,16 +271,9 @@ class Build(BaseModel): # Start cleanup job. on_cleanup_{started,succeeded,failed} callbacks will follow # from job events. _logger.info(f"Deploying cleanup job for {self}.") - deployment_vars = k8s.make_deployment_vars( - 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 self._deploy( + self.commit_info, self.name, self.slug, job_kind=k8s.DeploymentMode.cleanup ) - await k8s.deploy(deployment_vars) async def on_initialize_started(self) -> None: if self.init_status == BuildInitStatus.started: diff --git a/src/runboat/settings.py b/src/runboat/settings.py index d6059a1..fb92c8b 100644 --- a/src/runboat/settings.py +++ b/src/runboat/settings.py @@ -1,16 +1,31 @@ import re +from pathlib import Path from pydantic import BaseModel, BaseSettings, validator 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): image: str # container image:tag # These extend the respective global settings. env: dict[str, str] = {} secret_env: 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): @@ -51,6 +66,8 @@ class Settings(BaseSettings): # A dictionary of variables to be set in the jinja rendering context for the # kubefiles. 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, # and report build statuses). github_token: str | None @@ -66,6 +83,10 @@ class Settings(BaseSettings): # Disable posting of statuses to GitHub commits 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]: for repo_settings in self.repos: if not re.match(repo_settings.repo, repo, re.IGNORECASE): diff --git a/tests/test_render_kubefiles.py b/tests/test_render_kubefiles.py index 6afd4d4..f3642ae 100644 --- a/tests/test_render_kubefiles.py +++ b/tests/test_render_kubefiles.py @@ -1,6 +1,6 @@ from runboat.github import CommitInfo from runboat.k8s import DeploymentMode, _render_kubefiles, make_deployment_vars -from runboat.settings import BuildSettings +from runboat.settings import BuildSettings, settings EXPECTED = """\ resources: @@ -79,6 +79,8 @@ patches: 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( mode=DeploymentMode.deployment, build_name="build-name", @@ -89,9 +91,9 @@ def test_render_kubefiles() -> None: pr=None, 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 / "deployment.yaml").is_file() kustomization = (tmp_path / "kustomization.yaml").read_text() diff --git a/tests/test_settings.py b/tests/test_settings.py index dc3aef2..53bbf51 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,3 +1,5 @@ +from pathlib import Path + import pytest from runboat.exceptions import RepoOrBranchNotSupported @@ -12,3 +14,12 @@ def test_get_build_settings() -> None: settings.get_build_settings("acsone/mis-builder", "15.0") with pytest.raises(RepoOrBranchNotSupported): 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"), + ) + ]