Add configurable kubefiles path
This commit is contained in:
parent
9a70138dc5
commit
57f9c85544
9 changed files with 103 additions and 48 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
16
README.md
16
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue