blob: ce32da0725d06b89dae3b1399736c04bed92930c [file] [log] [blame]
Mohammed Nasere4436b42024-04-15 17:57:11 -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
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
127class 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
267async def main():
268 config = parse_yaml_file_as(Config, ".charts.yml")
269 await config.fetch_charts()
270
271
272if __name__ == "__main__":
273 asyncio.run(main())