Mohammed Naser | e4436b4 | 2024-04-15 17:57:11 -0400 | [diff] [blame] | 1 | # Copyright (c) 2024 VEXXHOST, Inc. |
| 2 | # |
| 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may |
| 4 | # not use this file except in compliance with the License. You may obtain |
| 5 | # a copy of the License at |
| 6 | # |
| 7 | # http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | # |
| 9 | # Unless required by applicable law or agreed to in writing, software |
| 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| 12 | # License for the specific language governing permissions and limitations |
| 13 | # under the License. |
| 14 | |
| 15 | import asyncio |
| 16 | import os |
| 17 | import pathlib |
| 18 | import textwrap |
| 19 | from datetime import datetime, timezone |
| 20 | |
| 21 | import aiopath |
| 22 | import aioshutil |
| 23 | import platformdirs |
| 24 | from asynctempfile import NamedTemporaryFile |
| 25 | from gerrit import GerritClient |
| 26 | from pydantic import BaseModel, HttpUrl, PrivateAttr |
| 27 | from pydantic_yaml import parse_yaml_file_as, to_yaml_file |
| 28 | |
| 29 | |
| 30 | class ChartRepository(BaseModel): |
| 31 | url: HttpUrl |
| 32 | |
| 33 | @property |
| 34 | def name(self): |
| 35 | return self.url.host.replace(".", "-") + self.url.path.replace("/", "-") |
| 36 | |
| 37 | |
| 38 | class ChartPatches(BaseModel): |
| 39 | gerrit: dict[str, list[int]] = {} |
| 40 | |
| 41 | |
| 42 | class ChartDependency(BaseModel): |
| 43 | name: str |
| 44 | repository: HttpUrl |
| 45 | version: str |
| 46 | |
| 47 | |
| 48 | class ChartRequirements(BaseModel): |
| 49 | dependencies: list[ChartDependency] = [] |
| 50 | |
| 51 | |
| 52 | class ChartLock(BaseModel): |
| 53 | dependencies: list[ChartDependency] = [] |
| 54 | digest: str |
| 55 | generated: datetime |
| 56 | |
| 57 | class Config: |
| 58 | json_encoders = { |
| 59 | "generated": lambda dt: dt.isoformat(), |
| 60 | } |
| 61 | |
| 62 | |
| 63 | class Chart(BaseModel): |
| 64 | name: str |
| 65 | version: str |
| 66 | repository: ChartRepository |
| 67 | dependencies: list[ChartDependency] = [] |
| 68 | patches: ChartPatches = ChartPatches() |
| 69 | |
| 70 | |
| 71 | async def patch(input: bytes, path: aiopath.AsyncPath): |
| 72 | async with NamedTemporaryFile() as temp_file: |
| 73 | await temp_file.write( |
| 74 | textwrap.dedent( |
| 75 | f"""\ |
| 76 | {path.name}/* |
| 77 | """ |
| 78 | ) |
| 79 | .strip() |
| 80 | .encode() |
| 81 | ) |
| 82 | await temp_file.flush() |
| 83 | |
| 84 | proc = await asyncio.create_subprocess_shell( |
| 85 | f"filterdiff -p1 -I {temp_file.name}", |
| 86 | stdin=asyncio.subprocess.PIPE, |
| 87 | stdout=asyncio.subprocess.PIPE, |
| 88 | stderr=asyncio.subprocess.PIPE, |
| 89 | ) |
| 90 | stdout, stderr = await proc.communicate(input=input) |
| 91 | if proc.returncode != 0: |
| 92 | raise Exception(stderr) |
| 93 | |
| 94 | async with NamedTemporaryFile() as temp_file: |
| 95 | await temp_file.write( |
| 96 | textwrap.dedent( |
| 97 | f"""\ |
| 98 | {path.name}/Chart.yaml |
vexxhost-bot | c8b785a | 2024-04-18 23:36:25 -0400 | [diff] [blame^] | 99 | {path.name}/values_overrides/* |
Mohammed Naser | e4436b4 | 2024-04-15 17:57:11 -0400 | [diff] [blame] | 100 | """ |
| 101 | ) |
| 102 | .strip() |
| 103 | .encode() |
| 104 | ) |
| 105 | await temp_file.flush() |
| 106 | |
| 107 | proc = await asyncio.create_subprocess_shell( |
| 108 | f"filterdiff -p1 -X {temp_file.name}", |
| 109 | stdin=asyncio.subprocess.PIPE, |
| 110 | stdout=asyncio.subprocess.PIPE, |
| 111 | stderr=asyncio.subprocess.PIPE, |
| 112 | ) |
| 113 | stdout, stderr = await proc.communicate(input=stdout) |
| 114 | if proc.returncode != 0: |
| 115 | raise Exception(stderr) |
| 116 | |
| 117 | proc = await asyncio.create_subprocess_shell( |
| 118 | f"patch -p2 -d {path} -E", |
| 119 | stdin=asyncio.subprocess.PIPE, |
| 120 | stdout=asyncio.subprocess.PIPE, |
| 121 | stderr=asyncio.subprocess.PIPE, |
| 122 | ) |
| 123 | stdout, stderr = await proc.communicate(input=stdout) |
| 124 | if proc.returncode != 0: |
| 125 | raise Exception(stdout) |
| 126 | |
| 127 | |
| 128 | class Config(BaseModel): |
| 129 | charts: list[Chart] |
| 130 | |
| 131 | _workspace: pathlib.Path = PrivateAttr( |
| 132 | default=pathlib.Path( |
| 133 | platformdirs.user_cache_dir("atmosphere-sync-charts", "vexxhost") |
| 134 | ) |
| 135 | ) |
| 136 | |
| 137 | @property |
| 138 | def repositories(self): |
| 139 | repositories = [] |
| 140 | |
| 141 | for chart in self.charts: |
| 142 | if chart.repository in repositories: |
| 143 | continue |
| 144 | repositories.append(chart.repository) |
| 145 | |
| 146 | return repositories |
| 147 | |
| 148 | async def _helm(self, args: list[str]): |
| 149 | proc = await asyncio.create_subprocess_shell( |
| 150 | f"helm {' '.join(args)}", |
| 151 | env={**dict(os.environ), **{"HOME": str(self._workspace)}}, |
| 152 | ) |
| 153 | await proc.communicate() |
| 154 | if proc.returncode != 0: |
| 155 | raise Exception(f"helm {' '.join(args)} failed") |
| 156 | |
| 157 | async def _fetch_chart(self, chart: Chart, path="charts"): |
| 158 | charts_path: aiopath.AsyncPath = aiopath.AsyncPath(path) |
| 159 | chart_path = charts_path / chart.name |
| 160 | |
| 161 | try: |
| 162 | await aioshutil.rmtree(f"{path}/{chart.name}-{chart.version}") |
| 163 | except FileNotFoundError: |
| 164 | pass |
| 165 | |
| 166 | try: |
| 167 | try: |
| 168 | os.rename( |
| 169 | f"{path}/{chart.name}", f"{path}/{chart.name}-{chart.version}" |
| 170 | ) |
| 171 | except FileNotFoundError: |
| 172 | pass |
| 173 | |
| 174 | await self._helm( |
| 175 | [ |
| 176 | "fetch", |
| 177 | "--untar", |
| 178 | f"--destination={path}", |
| 179 | f"{chart.repository.name}/{chart.name}", |
| 180 | f"--version={chart.version}", |
| 181 | ] |
| 182 | ) |
| 183 | except Exception: |
| 184 | os.rename(f"{path}/{chart.name}-{chart.version}", f"{path}/{chart.name}") |
| 185 | raise |
| 186 | |
| 187 | try: |
| 188 | await aioshutil.rmtree(f"{path}/{chart.name}-{chart.version}") |
| 189 | except FileNotFoundError: |
| 190 | pass |
| 191 | |
| 192 | if chart.dependencies: |
| 193 | requirements = ChartRequirements(dependencies=chart.dependencies) |
| 194 | to_yaml_file(f"{path}/{chart.name}/requirements.yaml", requirements) |
| 195 | |
| 196 | await asyncio.gather( |
| 197 | *[ |
| 198 | aioshutil.rmtree(f"{path}/{chart.name}/charts/{req.name}") |
| 199 | for req in chart.dependencies |
| 200 | ] |
| 201 | ) |
| 202 | |
| 203 | await self._helm( |
| 204 | ["dependency", "update", "--skip-refresh", f"{path}/{chart.name}"] |
| 205 | ) |
| 206 | |
| 207 | await asyncio.gather( |
| 208 | *[ |
| 209 | aioshutil.unpack_archive( |
| 210 | f"{path}/{chart.name}/charts/{req.name}-{req.version}.tgz", |
| 211 | f"{path}/{chart.name}/charts", |
| 212 | ) |
| 213 | for req in chart.dependencies |
| 214 | ] |
| 215 | ) |
| 216 | |
| 217 | await asyncio.gather( |
| 218 | *[ |
| 219 | (chart_path / "charts" / f"{req.name}-{req.version}.tgz").unlink() |
| 220 | for req in chart.dependencies |
| 221 | ] |
| 222 | ) |
| 223 | |
| 224 | for req in chart.dependencies: |
| 225 | lock = parse_yaml_file_as( |
| 226 | ChartLock, |
| 227 | f"{path}/{chart.name}/charts/{req.name}/requirements.lock", |
| 228 | ) |
| 229 | lock.generated = datetime.min.replace(tzinfo=timezone.utc) |
| 230 | to_yaml_file( |
| 231 | f"{path}/{chart.name}/charts/{req.name}/requirements.lock", lock |
| 232 | ) |
| 233 | |
| 234 | # Reset the generated time in the lock file to make things reproducible |
| 235 | lock = parse_yaml_file_as( |
| 236 | ChartLock, f"{path}/{chart.name}/requirements.lock" |
| 237 | ) |
| 238 | lock.generated = datetime.min.replace(tzinfo=timezone.utc) |
| 239 | to_yaml_file(f"{path}/{chart.name}/requirements.lock", lock) |
| 240 | |
| 241 | for gerrit, changes in chart.patches.gerrit.items(): |
| 242 | client = GerritClient(base_url=f"https://{gerrit}") |
| 243 | |
| 244 | for change_id in changes: |
| 245 | change = client.changes.get(change_id) |
| 246 | gerrit_patch = change.get_revision().get_patch(decode=True) |
| 247 | await patch(input=gerrit_patch.encode(), path=chart_path) |
| 248 | |
| 249 | patches_path = charts_path / "patches" / chart.name |
| 250 | if await patches_path.exists(): |
| 251 | async for patch_path in patches_path.glob("*.patch"): |
| 252 | async with patch_path.open(mode="rb") as patch_file: |
| 253 | patch_data = await patch_file.read() |
| 254 | await patch(input=patch_data, path=chart_path) |
| 255 | |
| 256 | async def fetch_charts(self): |
| 257 | await asyncio.gather( |
| 258 | *[ |
| 259 | self._helm(["repo", "add", repo.name, str(repo.url)]) |
| 260 | for repo in self.repositories |
| 261 | ] |
| 262 | ) |
| 263 | await self._helm(["repo", "update"]) |
| 264 | |
| 265 | await asyncio.gather(*[self._fetch_chart(chart) for chart in self.charts]) |
| 266 | |
| 267 | |
| 268 | async def main(): |
| 269 | config = parse_yaml_file_as(Config, ".charts.yml") |
| 270 | await config.fetch_charts() |
| 271 | |
| 272 | |
| 273 | if __name__ == "__main__": |
| 274 | asyncio.run(main()) |