version_list.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556
  1. # Copyright Materialize, Inc. and contributors. All rights reserved.
  2. #
  3. # Use of this software is governed by the Business Source License
  4. # included in the LICENSE file at the root of this repository.
  5. #
  6. # As of the Change Date specified in that file, in accordance with
  7. # the Business Source License, use of this software will be governed
  8. # by the Apache License, Version 2.0.
  9. from __future__ import annotations
  10. import os
  11. from collections.abc import Callable
  12. from pathlib import Path
  13. import frontmatter
  14. import requests
  15. import yaml
  16. from materialize import build_context, buildkite, docker, git
  17. from materialize.docker import (
  18. commit_to_image_tag,
  19. image_of_commit_exists,
  20. release_version_to_image_tag,
  21. )
  22. from materialize.git import get_version_tags
  23. from materialize.mz_version import MzVersion
  24. MZ_ROOT = Path(os.environ["MZ_ROOT"])
  25. def get_self_managed_versions() -> list[MzVersion]:
  26. prefixes = set()
  27. result = set()
  28. for entry in yaml.safe_load(
  29. requests.get("https://materializeinc.github.io/materialize/index.yaml").text
  30. )["entries"]["materialize-operator"]:
  31. version = MzVersion.parse_mz(entry["appVersion"])
  32. prefix = (version.major, version.minor)
  33. if not version.prerelease and prefix not in prefixes:
  34. result.add(version)
  35. prefixes.add(prefix)
  36. return sorted(result)
  37. # not released on Docker
  38. INVALID_VERSIONS = {
  39. MzVersion.parse_mz("v0.52.1"),
  40. MzVersion.parse_mz("v0.55.1"),
  41. MzVersion.parse_mz("v0.55.2"),
  42. MzVersion.parse_mz("v0.55.3"),
  43. MzVersion.parse_mz("v0.55.4"),
  44. MzVersion.parse_mz("v0.55.5"),
  45. MzVersion.parse_mz("v0.55.6"),
  46. MzVersion.parse_mz("v0.56.0"),
  47. MzVersion.parse_mz("v0.57.1"),
  48. MzVersion.parse_mz("v0.57.2"),
  49. MzVersion.parse_mz("v0.57.5"),
  50. MzVersion.parse_mz("v0.57.6"),
  51. MzVersion.parse_mz("v0.81.0"), # incompatible for upgrades
  52. MzVersion.parse_mz("v0.81.1"), # incompatible for upgrades
  53. MzVersion.parse_mz("v0.81.2"), # incompatible for upgrades
  54. MzVersion.parse_mz("v0.89.7"),
  55. MzVersion.parse_mz("v0.92.0"), # incompatible for upgrades
  56. MzVersion.parse_mz("v0.93.0"), # accidental release
  57. MzVersion.parse_mz("v0.99.1"), # incompatible for upgrades
  58. MzVersion.parse_mz("v0.113.1"), # incompatible for upgrades
  59. }
  60. _SKIP_IMAGE_CHECK_BELOW_THIS_VERSION = MzVersion.parse_mz("v0.77.0")
  61. def resolve_ancestor_image_tag(ancestor_overrides: dict[str, MzVersion]) -> str:
  62. """
  63. Resolve the ancestor image tag.
  64. :param ancestor_overrides: one of #ANCESTOR_OVERRIDES_FOR_PERFORMANCE_REGRESSIONS, #ANCESTOR_OVERRIDES_FOR_SCALABILITY_REGRESSIONS, #ANCESTOR_OVERRIDES_FOR_CORRECTNESS_REGRESSIONS
  65. :return: image of the ancestor
  66. """
  67. manual_ancestor_override = os.getenv("COMMON_ANCESTOR_OVERRIDE")
  68. if manual_ancestor_override is not None:
  69. image_tag = _manual_ancestor_specification_to_image_tag(
  70. manual_ancestor_override
  71. )
  72. print(
  73. f"Using specified {image_tag} as image tag for ancestor (context: specified in $COMMON_ANCESTOR_OVERRIDE)"
  74. )
  75. return image_tag
  76. ancestor_image_resolution = _create_ancestor_image_resolution(ancestor_overrides)
  77. image_tag, context = ancestor_image_resolution.resolve_image_tag()
  78. print(f"Using {image_tag} as image tag for ancestor (context: {context})")
  79. return image_tag
  80. def _create_ancestor_image_resolution(
  81. ancestor_overrides: dict[str, MzVersion]
  82. ) -> AncestorImageResolutionBase:
  83. if buildkite.is_in_buildkite():
  84. return AncestorImageResolutionInBuildkite(ancestor_overrides)
  85. else:
  86. return AncestorImageResolutionLocal(ancestor_overrides)
  87. def _manual_ancestor_specification_to_image_tag(ancestor_spec: str) -> str:
  88. if MzVersion.is_valid_version_string(ancestor_spec):
  89. return release_version_to_image_tag(MzVersion.parse_mz(ancestor_spec))
  90. else:
  91. return commit_to_image_tag(ancestor_spec)
  92. class AncestorImageResolutionBase:
  93. def __init__(self, ancestor_overrides: dict[str, MzVersion]):
  94. self.ancestor_overrides = ancestor_overrides
  95. def resolve_image_tag(self) -> tuple[str, str]:
  96. raise NotImplementedError
  97. def _get_override_commit_instead_of_version(
  98. self,
  99. version: MzVersion,
  100. ) -> str | None:
  101. """
  102. If a commit specifies a mz version as prerequisite (to avoid regressions) that is newer than the provided
  103. version (i.e., prerequisite not satisfied by the latest version), then return that commit's hash if the commit
  104. contained in the current state.
  105. Otherwise, return none.
  106. """
  107. for (
  108. commit_hash,
  109. min_required_mz_version,
  110. ) in self.ancestor_overrides.items():
  111. if version >= min_required_mz_version:
  112. continue
  113. if git.contains_commit(commit_hash):
  114. # commit would require at least min_required_mz_version
  115. return commit_hash
  116. return None
  117. def _resolve_image_tag_of_previous_release(
  118. self, context_prefix: str, previous_minor: bool
  119. ) -> tuple[str, str]:
  120. tagged_release_version = git.get_tagged_release_version(version_type=MzVersion)
  121. assert tagged_release_version is not None
  122. previous_release_version = get_previous_published_version(
  123. tagged_release_version, previous_minor=previous_minor
  124. )
  125. override_commit = self._get_override_commit_instead_of_version(
  126. previous_release_version
  127. )
  128. if override_commit is not None:
  129. # use the commit instead of the previous release
  130. return (
  131. commit_to_image_tag(override_commit),
  132. f"commit override instead of previous release ({previous_release_version})",
  133. )
  134. return (
  135. release_version_to_image_tag(previous_release_version),
  136. f"{context_prefix} {tagged_release_version}",
  137. )
  138. def _resolve_image_tag_of_previous_release_from_current(
  139. self, context: str
  140. ) -> tuple[str, str]:
  141. # Even though we are on main we might be in an older state, pick the
  142. # latest release that was before our current version.
  143. current_version = MzVersion.parse_cargo()
  144. previous_published_version = get_previous_published_version(
  145. current_version, previous_minor=True
  146. )
  147. override_commit = self._get_override_commit_instead_of_version(
  148. previous_published_version
  149. )
  150. if override_commit is not None:
  151. # use the commit instead of the latest release
  152. return (
  153. commit_to_image_tag(override_commit),
  154. f"commit override instead of latest release ({previous_published_version})",
  155. )
  156. return (
  157. release_version_to_image_tag(previous_published_version),
  158. context,
  159. )
  160. def _resolve_image_tag_of_merge_base(
  161. self,
  162. context_when_image_of_commit_exists: str,
  163. context_when_falling_back_to_latest: str,
  164. ) -> tuple[str, str]:
  165. # If the current PR has a known and accepted regression, don't compare
  166. # against merge base of it
  167. override_commit = self._get_override_commit_instead_of_version(
  168. MzVersion.parse_cargo()
  169. )
  170. common_ancestor_commit = buildkite.get_merge_base()
  171. if override_commit is not None:
  172. return (
  173. commit_to_image_tag(override_commit),
  174. f"commit override instead of merge base ({common_ancestor_commit})",
  175. )
  176. if image_of_commit_exists(common_ancestor_commit):
  177. return (
  178. commit_to_image_tag(common_ancestor_commit),
  179. context_when_image_of_commit_exists,
  180. )
  181. else:
  182. return (
  183. release_version_to_image_tag(get_latest_published_version()),
  184. context_when_falling_back_to_latest,
  185. )
  186. class AncestorImageResolutionLocal(AncestorImageResolutionBase):
  187. def resolve_image_tag(self) -> tuple[str, str]:
  188. if build_context.is_on_release_version():
  189. return self._resolve_image_tag_of_previous_release(
  190. "previous minor release because on local release branch",
  191. previous_minor=True,
  192. )
  193. elif build_context.is_on_main_branch():
  194. return self._resolve_image_tag_of_previous_release_from_current(
  195. "previous release from current because on local main branch"
  196. )
  197. else:
  198. return self._resolve_image_tag_of_merge_base(
  199. "merge base of local non-main branch",
  200. "latest release because image of merge base of local non-main branch not available",
  201. )
  202. class AncestorImageResolutionInBuildkite(AncestorImageResolutionBase):
  203. def resolve_image_tag(self) -> tuple[str, str]:
  204. if buildkite.is_in_pull_request():
  205. return self._resolve_image_tag_of_merge_base(
  206. "merge base of pull request",
  207. "latest release because image of merge base of pull request not available",
  208. )
  209. elif build_context.is_on_release_version():
  210. return self._resolve_image_tag_of_previous_release(
  211. "previous minor release because on release branch", previous_minor=True
  212. )
  213. else:
  214. return self._resolve_image_tag_of_previous_release_from_current(
  215. "previous release from current because not in a pull request and not on a release branch",
  216. )
  217. def get_latest_published_version() -> MzVersion:
  218. """Get the latest mz version, older than current state, for which an image is published."""
  219. excluded_versions = set()
  220. current_version = MzVersion.parse_cargo()
  221. while True:
  222. latest_published_version = git.get_latest_version(
  223. version_type=MzVersion,
  224. excluded_versions=excluded_versions,
  225. current_version=current_version,
  226. )
  227. if is_valid_release_image(latest_published_version):
  228. return latest_published_version
  229. else:
  230. print(
  231. f"Skipping version {latest_published_version} (image not found), trying earlier version"
  232. )
  233. excluded_versions.add(latest_published_version)
  234. def get_previous_published_version(
  235. release_version: MzVersion, previous_minor: bool
  236. ) -> MzVersion:
  237. """Get the highest preceding mz version to the specified version for which an image is published."""
  238. excluded_versions = set()
  239. while True:
  240. previous_published_version = get_previous_mz_version(
  241. release_version,
  242. previous_minor=previous_minor,
  243. excluded_versions=excluded_versions,
  244. )
  245. if is_valid_release_image(previous_published_version):
  246. return previous_published_version
  247. else:
  248. print(f"Skipping version {previous_published_version} (image not found)")
  249. excluded_versions.add(previous_published_version)
  250. def get_published_minor_mz_versions(
  251. newest_first: bool = True,
  252. limit: int | None = None,
  253. include_filter: Callable[[MzVersion], bool] | None = None,
  254. exclude_current_minor_version: bool = False,
  255. ) -> list[MzVersion]:
  256. """
  257. Get the latest patch version for every minor version.
  258. Use this version if it is NOT important whether a tag was introduced before or after creating this branch.
  259. See also: #get_minor_mz_versions_listed_in_docs()
  260. """
  261. # sorted in descending order
  262. all_versions = get_all_mz_versions(newest_first=True)
  263. minor_versions: dict[str, MzVersion] = {}
  264. version = MzVersion.parse_cargo()
  265. current_version = f"{version.major}.{version.minor}"
  266. # Note that this method must not apply limit_to_published_versions to a created list
  267. # because in that case minor versions may get lost.
  268. for version in all_versions:
  269. if include_filter is not None and not include_filter(version):
  270. # this version shall not be included
  271. continue
  272. minor_version = f"{version.major}.{version.minor}"
  273. if exclude_current_minor_version and minor_version == current_version:
  274. continue
  275. if minor_version in minor_versions.keys():
  276. # we already have a more recent version for this minor version
  277. continue
  278. if not is_valid_release_image(version):
  279. # this version is not considered valid
  280. continue
  281. minor_versions[minor_version] = version
  282. if limit is not None and len(minor_versions.keys()) == limit:
  283. # collected enough versions
  284. break
  285. assert len(minor_versions) > 0
  286. return sorted(minor_versions.values(), reverse=newest_first)
  287. def get_minor_mz_versions_listed_in_docs(respect_released_tag: bool) -> list[MzVersion]:
  288. """
  289. Get the latest patch version for every minor version in ascending order.
  290. Use this version if it is important whether a tag was introduced before or after creating this branch.
  291. See also: #get_published_minor_mz_versions()
  292. """
  293. return VersionsFromDocs(respect_released_tag).minor_versions()
  294. def get_all_mz_versions(
  295. newest_first: bool = True,
  296. ) -> list[MzVersion]:
  297. """
  298. Get all mz versions based on git tags. Versions known to be invalid are excluded.
  299. See also: #get_all_mz_versions_listed_in_docs
  300. """
  301. return [
  302. version
  303. for version in get_version_tags(
  304. version_type=MzVersion, newest_first=newest_first
  305. )
  306. if version not in INVALID_VERSIONS
  307. ]
  308. def get_all_mz_versions_listed_in_docs(
  309. respect_released_tag: bool,
  310. ) -> list[MzVersion]:
  311. """
  312. Get all mz versions based on docs. Versions known to be invalid are excluded.
  313. See also: #get_all_mz_versions()
  314. """
  315. return VersionsFromDocs(respect_released_tag).all_versions()
  316. def get_all_published_mz_versions(
  317. newest_first: bool = True, limit: int | None = None
  318. ) -> list[MzVersion]:
  319. """Get all mz versions based on git tags. This method ensures that images of the versions exist."""
  320. return limit_to_published_versions(
  321. get_all_mz_versions(newest_first=newest_first), limit
  322. )
  323. def limit_to_published_versions(
  324. all_versions: list[MzVersion], limit: int | None = None
  325. ) -> list[MzVersion]:
  326. """Remove versions for which no image is published."""
  327. versions = []
  328. for v in all_versions:
  329. if is_valid_release_image(v):
  330. versions.append(v)
  331. if limit is not None and len(versions) == limit:
  332. break
  333. return versions
  334. def get_previous_mz_version(
  335. version: MzVersion,
  336. previous_minor: bool,
  337. excluded_versions: set[MzVersion] | None = None,
  338. ) -> MzVersion:
  339. """Get the predecessor of the specified version based on git tags."""
  340. if excluded_versions is None:
  341. excluded_versions = set()
  342. if previous_minor:
  343. version = MzVersion.create(version.major, version.minor, 0)
  344. if version.prerelease is not None and len(version.prerelease) > 0:
  345. # simply drop the prerelease, do not try to find a decremented version
  346. found_version = MzVersion.create(version.major, version.minor, version.patch)
  347. if found_version not in excluded_versions:
  348. return found_version
  349. else:
  350. # start searching with this version
  351. version = found_version
  352. all_versions: list[MzVersion] = get_version_tags(version_type=type(version))
  353. all_suitable_previous_versions = [
  354. v
  355. for v in all_versions
  356. if v < version
  357. and (v.prerelease is None or len(v.prerelease) == 0)
  358. and v not in INVALID_VERSIONS
  359. and v not in excluded_versions
  360. ]
  361. return max(all_suitable_previous_versions)
  362. def is_valid_release_image(version: MzVersion) -> bool:
  363. """
  364. Checks if a version is not known as an invalid version and has a published image.
  365. Note that this method may take shortcuts on older versions.
  366. """
  367. if version in INVALID_VERSIONS:
  368. return False
  369. if version < _SKIP_IMAGE_CHECK_BELOW_THIS_VERSION:
  370. # optimization: assume that all versions older than this one are either valid or listed in INVALID_VERSIONS
  371. return True
  372. # This is a potentially expensive operation which pulls an image if it hasn't been pulled yet.
  373. return docker.image_of_release_version_exists(version)
  374. def get_commits_of_accepted_regressions_between_versions(
  375. ancestor_overrides: dict[str, MzVersion],
  376. since_version_exclusive: MzVersion,
  377. to_version_inclusive: MzVersion,
  378. ) -> list[str]:
  379. """
  380. Get commits of accepted regressions between both versions.
  381. :param ancestor_overrides: one of #ANCESTOR_OVERRIDES_FOR_PERFORMANCE_REGRESSIONS, #ANCESTOR_OVERRIDES_FOR_SCALABILITY_REGRESSIONS, #ANCESTOR_OVERRIDES_FOR_CORRECTNESS_REGRESSIONS
  382. :return: commits
  383. """
  384. assert since_version_exclusive <= to_version_inclusive
  385. commits = []
  386. for (
  387. regression_introducing_commit,
  388. first_version_with_regression,
  389. ) in ancestor_overrides.items():
  390. if (
  391. since_version_exclusive
  392. < first_version_with_regression
  393. <= to_version_inclusive
  394. ):
  395. commits.append(regression_introducing_commit)
  396. return commits
  397. class VersionsFromDocs:
  398. """Materialize versions as listed in doc/user/content/releases
  399. Only versions that declare `versiond: true` in their
  400. frontmatter are considered.
  401. >>> len(VersionsFromDocs(respect_released_tag=True).all_versions()) > 0
  402. True
  403. >>> len(VersionsFromDocs(respect_released_tag=True).minor_versions()) > 0
  404. True
  405. >>> len(VersionsFromDocs(respect_released_tag=True).patch_versions(minor_version=MzVersion.parse_mz("v0.52.0")))
  406. 4
  407. >>> min(VersionsFromDocs(respect_released_tag=True).all_versions())
  408. MzVersion(major=0, minor=27, patch=0, prerelease=None, build=None)
  409. """
  410. def __init__(self, respect_released_tag: bool) -> None:
  411. files = Path(MZ_ROOT / "doc" / "user" / "content" / "releases").glob("v*.md")
  412. self.versions = []
  413. current_version = MzVersion.parse_cargo()
  414. for f in files:
  415. base = f.stem
  416. metadata = frontmatter.load(f)
  417. if respect_released_tag and not metadata.get("released", False):
  418. continue
  419. current_patch = metadata.get("patch", 0)
  420. for patch in range(current_patch + 1):
  421. version = MzVersion.parse_mz(f"{base}.{patch}")
  422. if not respect_released_tag and version >= current_version:
  423. continue
  424. if version not in INVALID_VERSIONS:
  425. self.versions.append(version)
  426. assert len(self.versions) > 0
  427. self.versions.sort()
  428. def all_versions(self) -> list[MzVersion]:
  429. return self.versions
  430. def minor_versions(self) -> list[MzVersion]:
  431. """Return the latest patch version for every minor version."""
  432. minor_versions = {}
  433. for version in self.versions:
  434. minor_versions[f"{version.major}.{version.minor}"] = version
  435. assert len(minor_versions) > 0
  436. return sorted(minor_versions.values())
  437. def patch_versions(self, minor_version: MzVersion) -> list[MzVersion]:
  438. """Return all patch versions within the given minor version."""
  439. patch_versions = []
  440. for version in self.versions:
  441. if (
  442. version.major == minor_version.major
  443. and version.minor == minor_version.minor
  444. ):
  445. patch_versions.append(version)
  446. assert len(patch_versions) > 0
  447. return sorted(patch_versions)