-
Notifications
You must be signed in to change notification settings - Fork 72
/
Copy pathversions.py
288 lines (221 loc) · 9.97 KB
/
versions.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
import dataclasses
import datetime
import json
import logging
import re
from dataclasses import dataclass
import requests
from bs4 import BeautifulSoup
from semver.version import Version
from docker_python_nodejs.readme import format_supported_versions
from .docker_hub import DockerImageDict, DockerTagDict, fetch_tags
from .nodejs_versions import (
fetch_node_releases,
fetch_node_unofficial_releases,
fetch_nodejs_release_schedule,
)
from .settings import DEFAULT_DISTRO, DEFAULT_PLATFORMS, DISTROS, VERSIONS_PATH
todays_date = datetime.datetime.now(datetime.UTC).date().isoformat()
logger = logging.getLogger("dpn")
@dataclass
class SupportedVersion:
"""A supported version of either Python or Node.js with start and end of support dates"""
start: str
end: str
version: str
@dataclass
class LanguageVersion:
canonical_version: str
key: str
distro: str
image: str | None = None
@dataclass
class NodeJsVersion(LanguageVersion):
pass
@dataclass
class PythonVersion(LanguageVersion):
canonical_version: str
key: str
image: str
distro: str
@dataclass
class BuildVersion:
"""A docker image build for a specific combination of python and nodejs versions"""
key: str
python: str
python_canonical: str
python_image: str
nodejs: str
nodejs_canonical: str
distro: str
platforms: list[str]
def _is_platform_image(platform: str, image: DockerImageDict) -> bool:
os, arch = platform.split("/")
return os == image["os"] and arch == image["architecture"]
def _wanted_image_platforms(distro: str) -> list[str]:
"""
Returns the supported image platforms for a distro
FIXME: Enable linux/arm64 for alpine when:
- https://github.com/nodejs/node/pull/45756 is fixed
- https://github.com/nodejs/unofficial-builds adds builds for musl + arm64
"""
if distro == "alpine":
return ["linux/amd64"]
return DEFAULT_PLATFORMS
def _image_tag_has_platforms(tag: DockerTagDict, distro: str) -> bool:
for platform in _wanted_image_platforms(distro):
has_platform = any(_is_platform_image(platform, image) for image in tag["images"])
if not has_platform:
return False
return True
def _wanted_tag(tag: DockerTagDict, ver: str, distro: str) -> bool:
return tag["name"].startswith(ver) and tag["name"].endswith(f"-{distro}") and _image_tag_has_platforms(tag, distro)
def _latest_patch(tags: list[DockerTagDict], ver: str, distro: str) -> str | None:
tags = [tag for tag in tags if _wanted_tag(tag, ver, distro)]
return sorted(tags, key=lambda x: Version.parse(x["name"]), reverse=True)[0]["name"] if tags else None
def scrape_supported_python_versions() -> list[SupportedVersion]:
"""Scrape supported python versions (risky)."""
versions = []
version_table_row_selector = "#supported-versions tbody tr"
res = requests.get("https://devguide.python.org/versions/", timeout=10.0)
res.raise_for_status()
soup = BeautifulSoup(res.text, "html.parser")
version_table_rows = soup.select(version_table_row_selector)
for ver in version_table_rows:
branch, _, _, first_release, end_of_life, _ = (v.text for v in ver.find_all("td"))
if first_release <= todays_date <= end_of_life:
versions.append(SupportedVersion(version=branch, start=first_release, end=end_of_life))
return versions
def decide_python_versions(distros: list[str], supported_versions: list[SupportedVersion]) -> list[PythonVersion]:
python_patch_re = "|".join([rf"^(\d+\.\d+\.\d+-{distro})$" for distro in distros])
python_wanted_tag_pattern = re.compile(python_patch_re)
# FIXME: can we avoid enumerating all tags to speed up things?
logger.debug("Fetching tags for python")
tags = [tag for tag in fetch_tags("python") if python_wanted_tag_pattern.match(tag["name"])]
versions: list[PythonVersion] = []
for supported_version in supported_versions:
ver = supported_version.version
for distro in distros:
canonical_image = _latest_patch(tags, ver, distro)
platforms = _wanted_image_platforms(distro)
if not canonical_image:
logger.warning(
f"Not good. ver={ver} distro={distro} platforms={','.join(platforms)} not in tags, skipping...",
)
continue
versions.append(
PythonVersion(
canonical_version=canonical_image.replace(f"-{distro}", ""),
image=canonical_image,
key=ver,
distro=distro,
),
)
return sorted(versions, key=lambda v: Version.parse(v.canonical_version), reverse=True)
def fetch_supported_nodejs_versions() -> list[SupportedVersion]:
release_schedule = fetch_nodejs_release_schedule()
versions = []
for ver, detail in release_schedule.items():
if detail["start"] <= todays_date <= detail["end"]:
versions.append(SupportedVersion(version=ver, start=detail["start"], end=detail["end"]))
return versions
def supported_versions() -> tuple[list[SupportedVersion], list[SupportedVersion]]:
suported_python_versions = scrape_supported_python_versions()
suported_nodejs_versions = fetch_supported_nodejs_versions()
supported_versions = format_supported_versions(suported_python_versions, suported_nodejs_versions)
logger.debug(f"Found the following supported versions:\n{supported_versions}")
return suported_python_versions, suported_nodejs_versions
def _has_arch_files(files: list[str], distro: str) -> bool:
if distro == "alpine":
return {"linux-x64-musl"}.issubset(files)
return {"linux-arm64", "linux-x64"}.issubset(files)
def decide_nodejs_versions(distros: list[str], supported_versions: list[SupportedVersion]) -> list[NodeJsVersion]:
logger.debug("Fetching releases for node")
node_releases = fetch_node_releases()
node_unofficial_releases = fetch_node_unofficial_releases()
versions: list[NodeJsVersion] = []
for supported_version in supported_versions:
ver = supported_version.version[1:] # Remove v prefix
for distro in distros:
distro_releases = node_unofficial_releases if distro == "alpine" else node_releases
matching_releases = [
rel
for rel in distro_releases
if rel["version"][1:].startswith(ver) and _has_arch_files(rel["files"], distro)
]
latest_patch_version = (
sorted(matching_releases, key=lambda x: Version.parse(x["version"][1:]), reverse=True)[0]["version"][1:]
if matching_releases
else None
)
if not latest_patch_version:
logger.warning(f"Not good, ver={ver} distro={distro} not in node releases, skipping...")
continue
versions.append(NodeJsVersion(canonical_version=latest_patch_version, key=ver, distro=distro))
return sorted(versions, key=lambda v: Version.parse(v.canonical_version), reverse=True)
def version_combinations(
nodejs_versions: list[NodeJsVersion],
python_versions: list[PythonVersion],
) -> list[BuildVersion]:
versions: list[BuildVersion] = []
for p in python_versions:
for n in nodejs_versions:
if p.distro != n.distro:
continue
# Skip distro in key if it's the default
distro_key = f"-{p.distro}" if p.distro != DEFAULT_DISTRO else ""
key = f"python{p.key}-nodejs{n.key}{distro_key}"
versions.append(
BuildVersion(
key=key,
python=p.key,
python_canonical=p.canonical_version,
python_image=p.image,
nodejs=n.key,
nodejs_canonical=n.canonical_version,
distro=p.distro,
platforms=_wanted_image_platforms(p.distro),
),
)
versions = sorted(versions, key=lambda v: DISTROS.index(v.distro))
versions = sorted(versions, key=lambda v: Version.parse(v.nodejs_canonical), reverse=True)
return sorted(versions, key=lambda v: Version.parse(v.python_canonical), reverse=True)
def decide_version_combinations(
distros: list[str],
supported_python_versions: list[SupportedVersion],
supported_node_versions: list[SupportedVersion],
) -> list[BuildVersion]:
distros = list(set(distros))
# Use the latest patch version from each minor
python_versions = decide_python_versions(distros, supported_python_versions)
# Use the latest minor version from each major
nodejs_versions = decide_nodejs_versions(distros, supported_node_versions)
return version_combinations(nodejs_versions, python_versions)
def persist_versions(versions: list[BuildVersion], dry_run: bool = False) -> None:
if dry_run:
logger.debug(versions)
return
with VERSIONS_PATH.open("w+") as fp:
version_dicts = [dataclasses.asdict(version) for version in versions]
json.dump({"versions": version_dicts}, fp, indent=2)
def load_versions() -> list[BuildVersion]:
with VERSIONS_PATH.open() as fp:
version_dicts = json.load(fp)["versions"]
return [BuildVersion(**version) for version in version_dicts]
def find_new_or_updated(
versions: list[BuildVersion],
force: bool = False,
) -> list[BuildVersion]:
if force:
logger.warning("Generating full build matrix because --force is set")
current_versions = load_versions()
current_versions_dict = {ver.key: ver for ver in current_versions}
versions_dict = {ver.key: ver for ver in versions}
new_or_updated: list[BuildVersion] = []
for key, ver in versions_dict.items():
# does key exist and are version dicts equal?
updated = key in current_versions_dict and ver != current_versions_dict[key]
new = key not in current_versions_dict
if new or updated or force:
new_or_updated.append(ver)
return new_or_updated