Mohammed Naser | 2fd3961 | 2024-04-14 13:37:45 -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 |
| 99 | """ |
| 100 | ) |
| 101 | .strip() |
| 102 | .encode() |
| 103 | ) |
| 104 | await temp_file.flush() |
| 105 | |
| 106 | proc = await asyncio.create_subprocess_shell( |
| 107 | f"filterdiff -p1 -X {temp_file.name}", |
| 108 | stdin=asyncio.subprocess.PIPE, |
| 109 | stdout=asyncio.subprocess.PIPE, |
| 110 | stderr=asyncio.subprocess.PIPE, |
| 111 | ) |
| 112 | stdout, stderr = await proc.communicate(input=stdout) |
| 113 | if proc.returncode != 0: |
| 114 | raise Exception(stderr) |
| 115 | |
| 116 | proc = await asyncio.create_subprocess_shell( |
| 117 | f"patch -p2 -d {path} -E", |
| 118 | stdin=asyncio.subprocess.PIPE, |
| 119 | stdout=asyncio.subprocess.PIPE, |
| 120 | stderr=asyncio.subprocess.PIPE, |
| 121 | ) |
| 122 | stdout, stderr = await proc.communicate(input=stdout) |
| 123 | if proc.returncode != 0: |
| 124 | raise Exception(stdout) |
| 125 | |
| 126 | |
| 127 | class Config(BaseModel): |
| 128 | charts: list[Chart] |
| 129 | |
| 130 | _workspace: pathlib.Path = PrivateAttr( |
| 131 | default=pathlib.Path( |
| 132 | platformdirs.user_cache_dir("atmosphere-sync-charts", "vexxhost") |
| 133 | ) |
| 134 | ) |
| 135 | |
| 136 | @property |
| 137 | def repositories(self): |
| 138 | repositories = [] |
| 139 | |
| 140 | for chart in self.charts: |
| 141 | if chart.repository in repositories: |
| 142 | continue |
| 143 | repositories.append(chart.repository) |
| 144 | |
| 145 | return repositories |
| 146 | |
| 147 | async def _helm(self, args: list[str]): |
| 148 | proc = await asyncio.create_subprocess_shell( |
| 149 | f"helm {' '.join(args)}", |
| 150 | env={**dict(os.environ), **{"HOME": str(self._workspace)}}, |
| 151 | ) |
| 152 | await proc.communicate() |
| 153 | if proc.returncode != 0: |
| 154 | raise Exception(f"helm {' '.join(args)} failed") |
| 155 | |
| 156 | async def _fetch_chart(self, chart: Chart, path="charts"): |
| 157 | charts_path: aiopath.AsyncPath = aiopath.AsyncPath(path) |
| 158 | chart_path = charts_path / chart.name |
| 159 | |
| 160 | try: |
| 161 | await aioshutil.rmtree(f"{path}/{chart.name}-{chart.version}") |
| 162 | except FileNotFoundError: |
| 163 | pass |
| 164 | |
| 165 | try: |
| 166 | try: |
| 167 | os.rename( |
| 168 | f"{path}/{chart.name}", f"{path}/{chart.name}-{chart.version}" |
| 169 | ) |
| 170 | except FileNotFoundError: |
| 171 | pass |
| 172 | |
| 173 | await self._helm( |
| 174 | [ |
| 175 | "fetch", |
| 176 | "--untar", |
| 177 | f"--destination={path}", |
| 178 | f"{chart.repository.name}/{chart.name}", |
| 179 | f"--version={chart.version}", |
| 180 | ] |
| 181 | ) |
| 182 | except Exception: |
| 183 | os.rename(f"{path}/{chart.name}-{chart.version}", f"{path}/{chart.name}") |
| 184 | raise |
| 185 | |
| 186 | try: |
| 187 | await aioshutil.rmtree(f"{path}/{chart.name}-{chart.version}") |
| 188 | except FileNotFoundError: |
| 189 | pass |
| 190 | |
| 191 | if chart.dependencies: |
| 192 | requirements = ChartRequirements(dependencies=chart.dependencies) |
| 193 | to_yaml_file(f"{path}/{chart.name}/requirements.yaml", requirements) |
| 194 | |
| 195 | await asyncio.gather( |
| 196 | *[ |
| 197 | aioshutil.rmtree(f"{path}/{chart.name}/charts/{req.name}") |
| 198 | for req in chart.dependencies |
| 199 | ] |
| 200 | ) |
| 201 | |
| 202 | await self._helm( |
| 203 | ["dependency", "update", "--skip-refresh", f"{path}/{chart.name}"] |
| 204 | ) |
| 205 | |
| 206 | await asyncio.gather( |
| 207 | *[ |
| 208 | aioshutil.unpack_archive( |
| 209 | f"{path}/{chart.name}/charts/{req.name}-{req.version}.tgz", |
| 210 | f"{path}/{chart.name}/charts", |
| 211 | ) |
| 212 | for req in chart.dependencies |
| 213 | ] |
| 214 | ) |
| 215 | |
| 216 | await asyncio.gather( |
| 217 | *[ |
| 218 | (chart_path / "charts" / f"{req.name}-{req.version}.tgz").unlink() |
| 219 | for req in chart.dependencies |
| 220 | ] |
| 221 | ) |
| 222 | |
| 223 | for req in chart.dependencies: |
| 224 | lock = parse_yaml_file_as( |
| 225 | ChartLock, |
| 226 | f"{path}/{chart.name}/charts/{req.name}/requirements.lock", |
| 227 | ) |
| 228 | lock.generated = datetime.min.replace(tzinfo=timezone.utc) |
| 229 | to_yaml_file( |
| 230 | f"{path}/{chart.name}/charts/{req.name}/requirements.lock", lock |
| 231 | ) |
| 232 | |
| 233 | # Reset the generated time in the lock file to make things reproducible |
| 234 | lock = parse_yaml_file_as( |
| 235 | ChartLock, f"{path}/{chart.name}/requirements.lock" |
| 236 | ) |
| 237 | lock.generated = datetime.min.replace(tzinfo=timezone.utc) |
| 238 | to_yaml_file(f"{path}/{chart.name}/requirements.lock", lock) |
| 239 | |
| 240 | for gerrit, changes in chart.patches.gerrit.items(): |
| 241 | client = GerritClient(base_url=f"https://{gerrit}") |
| 242 | |
| 243 | for change_id in changes: |
| 244 | change = client.changes.get(change_id) |
| 245 | gerrit_patch = change.get_revision().get_patch(decode=True) |
| 246 | await patch(input=gerrit_patch.encode(), path=chart_path) |
| 247 | |
| 248 | patches_path = charts_path / "patches" / chart.name |
| 249 | if await patches_path.exists(): |
| 250 | async for patch_path in patches_path.glob("*.patch"): |
| 251 | async with patch_path.open(mode="rb") as patch_file: |
| 252 | patch_data = await patch_file.read() |
| 253 | await patch(input=patch_data, path=chart_path) |
| 254 | |
| 255 | async def fetch_charts(self): |
| 256 | await asyncio.gather( |
| 257 | *[ |
| 258 | self._helm(["repo", "add", repo.name, str(repo.url)]) |
| 259 | for repo in self.repositories |
| 260 | ] |
| 261 | ) |
| 262 | await self._helm(["repo", "update"]) |
| 263 | |
| 264 | await asyncio.gather(*[self._fetch_chart(chart) for chart in self.charts]) |
| 265 | |
| 266 | |
| 267 | async def main(): |
| 268 | config = parse_yaml_file_as(Config, ".charts.yml") |
| 269 | await config.fetch_charts() |
| 270 | |
| 271 | |
| 272 | if __name__ == "__main__": |
| 273 | asyncio.run(main()) |