Compare commits

..

3 Commits

Author SHA1 Message Date
Dustin e138f25f3e Include resourcresource diff in PR description
infra/updatebot/pipeline/head This commit looks good Details
Naturally, the PR will include the diff of the configuration changes the
update process makes, but that doesn't necessarily show what will
actually change in the cluster.  This is true of the `images` setting in
Kustomize configuration, and will become even more important when we
start updating remote manifest references.

To get a better idea of what will actually change when the update is
applied, we now try to run `kubectl diff` for each project after making
all changes.  The output is then included in the PR description.
2024-09-08 09:04:47 -05:00
Dustin 34fbdc6e02 Rework data model to group images into projects
A "project" now refers to an application deployed into Kubernetes, which
includes one or more "images."  This is really the grouping I wanted in
the beginning, which I tried to achieve using separate configuration
files.  Unfortunately, this made the original "projects" too
independent, making it difficult to produce the manifest diff I wanted
to add to the PR descriptions.  It was also cumbersome managing multiple
config files and therefore multiple CronJobs in Kubernetes.

The new data model is a lot deeper than the original one, making TOML a
lot less nice.  YAML definitely handles nested data structures better,
despite its shortcomings.  Having to repeat nested table names in TOML
is quite cumbersome.
2024-09-04 21:20:18 -05:00
Dustin 8126e5de21 Make Gitea auth token optional
For e.g. testing when running with `--dry-run`, the token may not be
necessary.
2024-08-27 20:19:17 -05:00
1 changed files with 135 additions and 90 deletions

View File

@ -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__':