blob: a2be651405e66032919131fee311d5d62baa095c [file] [log] [blame]
Mohammed Naser2fd39612024-04-14 13:37:45 -04001# 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
15import asyncio
16import os
17import pathlib
18import textwrap
19from datetime import datetime, timezone
20
21import aiopath
22import aioshutil
23import platformdirs
24from asynctempfile import NamedTemporaryFile
25from gerrit import GerritClient
26from pydantic import BaseModel, HttpUrl, PrivateAttr
27from pydantic_yaml import parse_yaml_file_as, to_yaml_file
28
29
30class ChartRepository(BaseModel):
31 url: HttpUrl
32
33 @property
34 def name(self):
35 return self.url.host.replace(".", "-") + self.url.path.replace("/", "-")
36
37
38class ChartPatches(BaseModel):
39 gerrit: dict[str, list[int]] = {}
40
41
42class ChartDependency(BaseModel):
43 name: str
44 repository: HttpUrl
45 version: str
46
47
48class ChartRequirements(BaseModel):
49 dependencies: list[ChartDependency] = []
50
51
52class 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
63class Chart(BaseModel):
64 name: str
65 version: str
66 repository: ChartRepository
67 dependencies: list[ChartDependency] = []
68 patches: ChartPatches = ChartPatches()
69
70
71async 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
Mohammed Naser589b3612024-04-18 19:46:23 -040099 {path.name}/values_overrides/*
Mohammed Naser2fd39612024-04-14 13:37:45 -0400100 """
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
128class 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():
vexxhost-bot4101af32024-07-09 03:08:24 +0200251 patch_paths = sorted(
252 [patch_path async for patch_path in patches_path.glob("*.patch")]
253 )
254 for patch_path in patch_paths:
Mohammed Naser2fd39612024-04-14 13:37:45 -0400255 async with patch_path.open(mode="rb") as patch_file:
256 patch_data = await patch_file.read()
257 await patch(input=patch_data, path=chart_path)
258
259 async def fetch_charts(self):
260 await asyncio.gather(
261 *[
262 self._helm(["repo", "add", repo.name, str(repo.url)])
263 for repo in self.repositories
264 ]
265 )
266 await self._helm(["repo", "update"])
267
268 await asyncio.gather(*[self._fetch_chart(chart) for chart in self.charts])
269
270
271async def main():
272 config = parse_yaml_file_as(Config, ".charts.yml")
273 await config.fetch_charts()
274
275
276if __name__ == "__main__":
277 asyncio.run(main())