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