Compare commits
3 Commits
5f8db2fa47
...
e138f25f3e
Author | SHA1 | Date |
---|---|---|
|
e138f25f3e | |
|
34fbdc6e02 | |
|
8126e5de21 |
179
updatebot.py
179
updatebot.py
|
@ -5,12 +5,12 @@ import logging
|
|||
import functools
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import tomllib
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, Literal, Union, Optional
|
||||
from typing import ClassVar, Iterable, Literal, Union, Optional
|
||||
|
||||
import colorlog
|
||||
import pydantic
|
||||
|
@ -112,19 +112,33 @@ Source = Union[
|
|||
]
|
||||
|
||||
|
||||
class BaseProject(abc.ABC, pydantic.BaseModel):
|
||||
path: Optional[Path] = None
|
||||
source: Source
|
||||
class ImageDef(pydantic.BaseModel):
|
||||
name: str
|
||||
image: str
|
||||
source: Source
|
||||
tag_format: str = '{version}'
|
||||
|
||||
|
||||
class BaseProject(abc.ABC, pydantic.BaseModel):
|
||||
name: str
|
||||
path: Path
|
||||
|
||||
def __init__(self, **data) -> None:
|
||||
data.setdefault('path', data.get('name'))
|
||||
super().__init__(**data)
|
||||
|
||||
@abc.abstractmethod
|
||||
def apply_update(self, path: Path, version: str) -> None:
|
||||
def apply_updates(self, basedir: Path) -> Iterable[tuple[ImageDef, str]]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def resource_diff(self, basedir: Path) -> Optional[str]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class KustomizeProject(BaseProject):
|
||||
kind: Literal['kustomize']
|
||||
images: list[ImageDef]
|
||||
|
||||
kustomize_files: ClassVar[list[str]] = [
|
||||
'kustomization.yaml',
|
||||
|
@ -132,41 +146,60 @@ class KustomizeProject(BaseProject):
|
|||
'Kustomization',
|
||||
]
|
||||
|
||||
def apply_update(self, path: Path, version: str) -> None:
|
||||
def apply_updates(self, basedir: Path) -> Iterable[tuple[ImageDef, str]]:
|
||||
path = basedir / self.path
|
||||
for filename in self.kustomize_files:
|
||||
filepath = path / filename
|
||||
if filepath.is_file():
|
||||
break
|
||||
else:
|
||||
filepath = path / self.kustomize_files[0]
|
||||
raise ValueError(
|
||||
f'Could not find Kustomize config for {self.name}'
|
||||
)
|
||||
|
||||
for image in self.images:
|
||||
yaml = ruamel.yaml.YAML()
|
||||
with filepath.open('rb') as f:
|
||||
kustomization = yaml.load(f)
|
||||
images = kustomization.setdefault('images', [])
|
||||
new_tag = self.tag_format.format(version=version)
|
||||
for image in images:
|
||||
if image['name'] == self.image:
|
||||
image['newTag'] = new_tag
|
||||
version = image.source.get_latest_version()
|
||||
new_tag = image.tag_format.format(version=version)
|
||||
for i in images:
|
||||
if i['name'] == image.image:
|
||||
i['newTag'] = new_tag
|
||||
break
|
||||
else:
|
||||
images.append({'name': self.image, 'newTag': new_tag})
|
||||
images.append({'name': image.image, 'newTag': new_tag})
|
||||
with filepath.open('wb') as f:
|
||||
yaml.dump(kustomization, f)
|
||||
yield (image, version)
|
||||
|
||||
def resource_diff(self, basedir: Path) -> Optional[str]:
|
||||
path = basedir / self.path
|
||||
cmd = ['kubectl', 'diff', '-k', path]
|
||||
try:
|
||||
p = subprocess.run(
|
||||
cmd,
|
||||
stdin=subprocess.DEVNULL,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
encoding='utf-8',
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
log.warning('Cannot generate resource diff: %s', e)
|
||||
return None
|
||||
if p.returncode != 0 and not p.stdout:
|
||||
log.error('Failed to generate resource diff: %s', p.stderr)
|
||||
return None
|
||||
return p.stdout
|
||||
|
||||
|
||||
class DirectoryProject(BaseProject):
|
||||
kind: Literal['dir'] | Literal['directory']
|
||||
|
||||
|
||||
Project = Union[
|
||||
KustomizeProject,
|
||||
DirectoryProject,
|
||||
]
|
||||
Project = KustomizeProject
|
||||
|
||||
|
||||
class RepoConfig(pydantic.BaseModel):
|
||||
url: str
|
||||
token_file: Path
|
||||
token_file: Optional[Path] = None
|
||||
branch: str = 'master'
|
||||
|
||||
@functools.cached_property
|
||||
|
@ -176,16 +209,18 @@ class RepoConfig(pydantic.BaseModel):
|
|||
return urllib.parse.urlunsplit(urlparts)
|
||||
|
||||
@functools.cached_property
|
||||
def auth_token(self) -> str:
|
||||
def auth_token(self) -> Optional[str]:
|
||||
if self.token_file:
|
||||
return self.token_file.read_text().strip()
|
||||
|
||||
def get_git_url(self) -> str:
|
||||
session = _get_session()
|
||||
headers = {}
|
||||
if token := self.auth_token:
|
||||
headers['Authorization'] = f'Bearer {token}'
|
||||
r = session.get(
|
||||
self.repo_api_url,
|
||||
headers={
|
||||
'Authorization': f'token {self.auth_token}',
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
data = r.json()
|
||||
if ssh_url := data.get('ssh_url'):
|
||||
|
@ -193,7 +228,11 @@ class RepoConfig(pydantic.BaseModel):
|
|||
return data['clone_url']
|
||||
|
||||
def create_pr(
|
||||
self, title: str, source_branch: str, target_branch: str
|
||||
self,
|
||||
title: str,
|
||||
source_branch: str,
|
||||
target_branch: str,
|
||||
body: Optional[str] = None,
|
||||
) -> None:
|
||||
session = _get_session()
|
||||
r = session.post(
|
||||
|
@ -205,6 +244,7 @@ class RepoConfig(pydantic.BaseModel):
|
|||
'title': title,
|
||||
'base': target_branch,
|
||||
'head': source_branch,
|
||||
'body': body,
|
||||
},
|
||||
)
|
||||
log.log(TRACE, '%r', r.content)
|
||||
|
@ -223,7 +263,7 @@ class RepoConfig(pydantic.BaseModel):
|
|||
|
||||
class Config(pydantic.BaseModel):
|
||||
repo: RepoConfig
|
||||
projects: dict[str, Project]
|
||||
projects: list[Project]
|
||||
|
||||
|
||||
class Arguments:
|
||||
|
@ -234,24 +274,26 @@ class Arguments:
|
|||
|
||||
|
||||
def update_project(
|
||||
repo: git.Repo, name: str, project: Project
|
||||
) -> Optional[git.Commit]:
|
||||
repo: git.Repo, project: Project
|
||||
) -> tuple[Optional[str], Optional[str]]:
|
||||
basedir = Path(repo.working_dir)
|
||||
log.debug('Checking for latest version of %s', name)
|
||||
latest = project.source.get_latest_version()
|
||||
log.info('Found version %s for %s', latest, name)
|
||||
log.debug('Applying update for %s version %s', name, latest)
|
||||
path = basedir / (project.path or name)
|
||||
project.apply_update(path, latest)
|
||||
title = None
|
||||
for image, version in project.apply_updates(basedir):
|
||||
log.info('Updating %s to %s', image.name, version)
|
||||
if repo.index.diff(None):
|
||||
log.debug('Committing changes to %s', path)
|
||||
repo.index.add(str(path))
|
||||
c = repo.index.commit(f'{name}: Update to {latest}')
|
||||
log.debug('Committing changes to %s', project.path)
|
||||
repo.index.add(str(project.path))
|
||||
c = repo.index.commit(f'{image.name}: Update to {version}')
|
||||
log.info('Commited %s %s', str(c)[:7], c.summary)
|
||||
return c
|
||||
if not title:
|
||||
if not isinstance(c.summary, str):
|
||||
title = bytes(c.summary).decode('utf-8')
|
||||
else:
|
||||
title = c.summary
|
||||
else:
|
||||
log.info('No changes to commit')
|
||||
return None
|
||||
diff = project.resource_diff(basedir)
|
||||
return title, diff
|
||||
|
||||
|
||||
def parse_args() -> Arguments:
|
||||
|
@ -294,23 +336,12 @@ def setup_logging() -> None:
|
|||
def main() -> None:
|
||||
setup_logging()
|
||||
args = parse_args()
|
||||
yaml = ruamel.yaml.YAML()
|
||||
with args.config.open('rb') as f:
|
||||
data = tomllib.load(f)
|
||||
data = yaml.load(f)
|
||||
config = Config.model_validate(data)
|
||||
log.debug('Using configuration: %s', config)
|
||||
all_projects = list(config.projects.keys())
|
||||
projects = []
|
||||
if args.projects:
|
||||
for project in args.projects:
|
||||
if project not in all_projects:
|
||||
log.warning('Unknown project: %s', project)
|
||||
continue
|
||||
projects.append(project)
|
||||
else:
|
||||
projects = all_projects
|
||||
if not projects:
|
||||
log.error('No projects')
|
||||
raise SystemExit(1)
|
||||
projects = args.projects or [p.name for p in config.projects]
|
||||
if log.isEnabledFor(logging.INFO):
|
||||
log.info('Updating projects: %s', ', '.join(projects))
|
||||
with tempfile.TemporaryDirectory(prefix='updatebot.') as d:
|
||||
|
@ -319,29 +350,43 @@ def main() -> None:
|
|||
log.debug('Retreiving repository Git URL')
|
||||
repo_url = config.repo.get_git_url()
|
||||
repo = git.Repo.clone_from(repo_url, d, depth=1, b=config.repo.branch)
|
||||
for project in config.projects:
|
||||
log.debug('Checking out new branch: %s', args.branch_name)
|
||||
repo.heads[0].checkout(force=True, B=args.branch_name)
|
||||
title = None
|
||||
for project in projects:
|
||||
commit = update_project(repo, project, config.projects[project])
|
||||
if commit and not title:
|
||||
if not isinstance(commit.summary, str):
|
||||
title = bytes(commit.summary).decode(
|
||||
'utf-8', errors='replace'
|
||||
)
|
||||
else:
|
||||
title = commit.summary
|
||||
description = None
|
||||
if project.name not in projects:
|
||||
continue
|
||||
title, diff = update_project(repo, project)
|
||||
if not title:
|
||||
log.info('No changes made')
|
||||
return
|
||||
continue
|
||||
if diff:
|
||||
description = (
|
||||
'<details>\n<summary>Resource diff</summary>\n\n'
|
||||
f'```diff\n{diff}```\n'
|
||||
'</details>'
|
||||
)
|
||||
if not args.dry_run:
|
||||
repo.head.reference.set_tracking_branch(
|
||||
git.RemoteReference(
|
||||
repo, f'refs/remotes/origin/{args.branch_name}'
|
||||
)
|
||||
)
|
||||
if not args.dry_run:
|
||||
repo.remote().push(force=True)
|
||||
config.repo.create_pr(title, args.branch_name, config.repo.branch)
|
||||
config.repo.create_pr(
|
||||
title,
|
||||
args.branch_name,
|
||||
config.repo.branch,
|
||||
description,
|
||||
)
|
||||
else:
|
||||
print(
|
||||
'Would create PR',
|
||||
f'{args.branch_name} → {config.repo.branch}:',
|
||||
title,
|
||||
)
|
||||
print(description or '')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
Loading…
Reference in New Issue