|
| 1 | +import argparse |
| 2 | +import datetime |
| 3 | +from pathlib import Path |
| 4 | +import subprocess as sp |
| 5 | +import sys |
| 6 | +from typing import cast |
| 7 | +from dulwich.repo import Repo |
| 8 | +from dulwich.refs import Ref |
| 9 | +from dulwich.objects import Commit |
| 10 | +from dulwich.errors import NotGitRepository |
| 11 | +from dulwich.diff_tree import tree_changes |
| 12 | +from tenacity import retry, stop_after_attempt, wait_exponential |
| 13 | + |
| 14 | + |
| 15 | +def _get_commit(name: bytes) -> bytes: |
| 16 | + """Resolve a commit SHA or branch name to a commit SHA string.""" |
| 17 | + repo = Repo(".") |
| 18 | + if name in repo: |
| 19 | + return name |
| 20 | + for prefix in [b"refs/heads/", b"refs/remotes/origin/", b"refs/tags/"]: |
| 21 | + ref = cast(Ref, prefix + name) |
| 22 | + if ref in repo.refs: |
| 23 | + return repo.refs[ref] |
| 24 | + raise KeyError(f"Cannot resolve commit or branch: {name}") |
| 25 | + |
| 26 | + |
| 27 | +def changed_files_between( |
| 28 | + commit1: bytes, commit2: bytes, name1: str = "", name2: str = "" |
| 29 | +) -> list[Path]: |
| 30 | + """Get a unique list of changed files between 2 commits. |
| 31 | +
|
| 32 | + :param commit1: The first commit ID. |
| 33 | + :param commit2: The second commit ID. |
| 34 | + :param name1: Optional human-readable name for commit1 used in logging (defaults to the commit SHA). |
| 35 | + :param name2: Optional human-readable name for commit2 used in logging (defaults to the commit SHA). |
| 36 | + :return: A unique list of changed files. |
| 37 | + """ |
| 38 | + repo = Repo(".") |
| 39 | + c1 = cast(Commit, repo[commit1]) |
| 40 | + c2 = cast(Commit, repo[commit2]) |
| 41 | + changes = tree_changes(repo.object_store, c1.tree, c2.tree) |
| 42 | + files = set() |
| 43 | + for change in changes: |
| 44 | + if change.old and change.old.path: |
| 45 | + files.add(change.old.path.decode()) |
| 46 | + if change.new and change.new.path: |
| 47 | + files.add(change.new.path.decode()) |
| 48 | + paths = sorted(Path(file) for file in files) |
| 49 | + print( |
| 50 | + f"Changed files between {name1 or commit1.decode()[:7]} and { |
| 51 | + name2 or commit2.decode()[:7] |
| 52 | + }:" |
| 53 | + ) |
| 54 | + for p in paths: |
| 55 | + print(f" {p}") |
| 56 | + return paths |
| 57 | + |
| 58 | + |
| 59 | +def has_relevant_changes( |
| 60 | + commit1: str | bytes, |
| 61 | + commit2: str | bytes, |
| 62 | + image_dirs: list[str], |
| 63 | + name1: str = "", |
| 64 | + name2: str = "", |
| 65 | +) -> bool: |
| 66 | + if not commit1 or not commit2: |
| 67 | + return True |
| 68 | + if isinstance(commit1, str): |
| 69 | + commit1 = commit1.encode() |
| 70 | + if isinstance(commit2, str): |
| 71 | + commit2 = commit2.encode() |
| 72 | + dirs = [Path(d).resolve() for d in image_dirs] |
| 73 | + for p in changed_files_between(commit1, commit2, name1=name1, name2=name2): |
| 74 | + if any(p.resolve().is_relative_to(d) for d in dirs): |
| 75 | + return True |
| 76 | + return False |
| 77 | + |
| 78 | + |
| 79 | +def has_relevant_changes_main_dev(image_dirs: list[str]) -> bool: |
| 80 | + try: |
| 81 | + c_main = _get_commit(b"main") |
| 82 | + c_dev = _get_commit(b"dev") |
| 83 | + except (KeyError, NotGitRepository): |
| 84 | + return True |
| 85 | + return has_relevant_changes(c_main, c_dev, image_dirs, name1="main", name2="dev") |
| 86 | + |
| 87 | + |
| 88 | +def _tag_date(tag: str) -> str: |
| 89 | + """Suffix a tag with the current date as a 6-digit string. |
| 90 | +
|
| 91 | + :param tag: A tag of a Podman image. |
| 92 | + :return: A new tag. |
| 93 | + """ |
| 94 | + return tag + datetime.datetime.now(tz=datetime.timezone.utc).strftime("_%m%d%H") |
| 95 | + |
| 96 | + |
| 97 | +@retry( |
| 98 | + stop=stop_after_attempt(3), wait=wait_exponential(multiplier=60, min=60, max=300) |
| 99 | +) |
| 100 | +def _push_image(image: str, tool: str = "podman"): |
| 101 | + sp.run( |
| 102 | + [tool, "push", image], |
| 103 | + shell=False, |
| 104 | + check=True, |
| 105 | + ) |
| 106 | + |
| 107 | + |
| 108 | +def _build_image( |
| 109 | + image_dir: str, |
| 110 | + tags: str | list[str], |
| 111 | + tool: str = "podman", |
| 112 | + registry: str = "quay.io/legendu", |
| 113 | +): |
| 114 | + if isinstance(tags, str): |
| 115 | + tags = [tags] |
| 116 | + image = f"{registry}/{image_dir}" |
| 117 | + print(f"\n\nBuilding the {tool} image {image}...", flush=True) |
| 118 | + cmd = [tool, "build", image_dir] |
| 119 | + for tag in tags: |
| 120 | + cmd.append("-t") |
| 121 | + cmd.append(f"{image}:{tag}") |
| 122 | + sp.run(cmd, shell=False, check=True) |
| 123 | + for tag in tags: |
| 124 | + _push_image(f"{image}:{tag}", tool=tool) |
| 125 | + |
| 126 | + |
| 127 | +def build_images( |
| 128 | + commit1: str, |
| 129 | + commit2: str, |
| 130 | + image_dirs: list[str], |
| 131 | + tool: str = "podman", |
| 132 | + registry: str = "quay.io/legendu", |
| 133 | +): |
| 134 | + if not has_relevant_changes(commit1, commit2, image_dirs): |
| 135 | + print( |
| 136 | + f"Skip building {tool} images as there are no relevant changes between { |
| 137 | + commit1 |
| 138 | + } and {commit2}.\n" |
| 139 | + ) |
| 140 | + return |
| 141 | + tags = ["next"] |
| 142 | + if not has_relevant_changes_main_dev(image_dirs): |
| 143 | + tags.append("latest") |
| 144 | + tags.extend([_tag_date(tag) for tag in tags]) |
| 145 | + print(f"Building {tool} images using tags:", ", ".join(tags), "\n", flush=True) |
| 146 | + failures = [] |
| 147 | + for image_dir in image_dirs: |
| 148 | + try: |
| 149 | + _build_image(image_dir, tags=tags, tool=tool, registry=registry) |
| 150 | + except (sp.CalledProcessError, FileNotFoundError) as e: |
| 151 | + print(f"Error building {image_dir}: {e}", flush=True) |
| 152 | + failures.append(image_dir) |
| 153 | + if failures: |
| 154 | + sys.exit(f"\n\nError: failed to build images: {', '.join(failures)}\n") |
| 155 | + |
| 156 | + |
| 157 | +def parse_args(): |
| 158 | + """Parse command-line arguments. |
| 159 | +
|
| 160 | + :return: An object containing the parsed arguments. |
| 161 | + """ |
| 162 | + parser = argparse.ArgumentParser(description="Build container images.") |
| 163 | + parser.add_argument( |
| 164 | + "-c1", |
| 165 | + "--commit1", |
| 166 | + dest="commit1", |
| 167 | + default="", |
| 168 | + help="The first commit ID (empty by default).", |
| 169 | + ) |
| 170 | + parser.add_argument( |
| 171 | + "-c2", |
| 172 | + "--commit2", |
| 173 | + dest="commit2", |
| 174 | + default="", |
| 175 | + help="The second commit ID (empty by default).", |
| 176 | + ) |
| 177 | + parser.add_argument( |
| 178 | + "-r", |
| 179 | + "--registry", |
| 180 | + dest="registry", |
| 181 | + default="quay.io/legendu", |
| 182 | + help="Container registry prefix (default: quay.io/legendu).", |
| 183 | + ) |
| 184 | + parser.add_argument( |
| 185 | + "-t", |
| 186 | + "--tool", |
| 187 | + dest="tool", |
| 188 | + default="podman", |
| 189 | + choices=["podman", "docker"], |
| 190 | + help="Container tool to use for building and pushing images (default: podman).", |
| 191 | + ) |
| 192 | + group = parser.add_mutually_exclusive_group(required=True) |
| 193 | + group.add_argument( |
| 194 | + "-i", |
| 195 | + "--image-dirs", |
| 196 | + dest="image_dirs", |
| 197 | + nargs="+", |
| 198 | + default=None, |
| 199 | + metavar="IMAGE_DIR", |
| 200 | + help="Explicit list of image directories to build.", |
| 201 | + ) |
| 202 | + group.add_argument( |
| 203 | + "-f", |
| 204 | + "--file-image-dirs", |
| 205 | + dest="file_image_dirs", |
| 206 | + default=None, |
| 207 | + metavar="FILE", |
| 208 | + help="Path to a file listing image directories to build, one per line.", |
| 209 | + ) |
| 210 | + return parser.parse_args() |
| 211 | + |
| 212 | + |
| 213 | +def _resolve_image_dirs(args: argparse.Namespace) -> list[str]: |
| 214 | + if args.image_dirs: |
| 215 | + return args.image_dirs |
| 216 | + lines = Path(args.file_image_dirs).read_text().splitlines() |
| 217 | + return [line.strip() for line in lines if line.strip()] |
| 218 | + |
| 219 | + |
| 220 | +def main(): |
| 221 | + args = parse_args() |
| 222 | + build_images( |
| 223 | + args.commit1, |
| 224 | + args.commit2, |
| 225 | + _resolve_image_dirs(args), |
| 226 | + tool=args.tool, |
| 227 | + registry=args.registry, |
| 228 | + ) |
| 229 | + |
| 230 | + |
| 231 | +if __name__ == "__main__": |
| 232 | + main() |
0 commit comments