buildkite.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  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. """Buildkite utilities."""
  10. import os
  11. import subprocess
  12. from collections.abc import Callable
  13. from enum import Enum, auto
  14. from pathlib import Path
  15. from typing import Any, TypeVar
  16. import yaml
  17. from materialize import git, spawn, ui
  18. T = TypeVar("T")
  19. class BuildkiteEnvVar(Enum):
  20. # environment
  21. BUILDKITE_AGENT_META_DATA_AWS_INSTANCE_TYPE = auto()
  22. BUILDKITE_AGENT_META_DATA_INSTANCE_TYPE = auto()
  23. # build
  24. BUILDKITE_PULL_REQUEST = auto()
  25. BUILDKITE_BUILD_NUMBER = auto()
  26. BUILDKITE_BUILD_ID = auto()
  27. BUILDKITE_PIPELINE_DEFAULT_BRANCH = auto()
  28. BUILDKITE_PULL_REQUEST_BASE_BRANCH = auto()
  29. BUILDKITE_ORGANIZATION_SLUG = auto()
  30. BUILDKITE_PIPELINE_SLUG = auto()
  31. BUILDKITE_BRANCH = auto()
  32. BUILDKITE_COMMIT = auto()
  33. BUILDKITE_BUILD_URL = auto()
  34. # step
  35. BUILDKITE_PARALLEL_JOB = auto()
  36. BUILDKITE_PARALLEL_JOB_COUNT = auto()
  37. BUILDKITE_STEP_KEY = auto()
  38. # will be the same for sharded and retried build steps
  39. BUILDKITE_STEP_ID = auto()
  40. # assumed to be unique
  41. BUILDKITE_JOB_ID = auto()
  42. BUILDKITE_LABEL = auto()
  43. BUILDKITE_RETRY_COUNT = auto()
  44. def get_var(var: BuildkiteEnvVar, fallback_value: Any = None) -> Any:
  45. return os.getenv(var.name, fallback_value)
  46. def is_in_buildkite() -> bool:
  47. return ui.env_is_truthy("BUILDKITE")
  48. def is_in_pull_request() -> bool:
  49. """Note that this is a heuristic."""
  50. if not is_in_buildkite():
  51. return False
  52. if is_pull_request_marker_set():
  53. return True
  54. if is_on_default_branch():
  55. return False
  56. if git.is_on_release_version():
  57. return False
  58. if git.contains_commit("HEAD", "main", fetch=True):
  59. return False
  60. return True
  61. def is_pull_request_marker_set() -> bool:
  62. # If set, this variable will contain either the ID of the pull request or the string "false".
  63. return get_var(BuildkiteEnvVar.BUILDKITE_PULL_REQUEST, "false") != "false"
  64. def is_on_default_branch() -> bool:
  65. current_branch = get_var(BuildkiteEnvVar.BUILDKITE_BRANCH, "unknown")
  66. default_branch = get_var(BuildkiteEnvVar.BUILDKITE_PIPELINE_DEFAULT_BRANCH, "main")
  67. return current_branch == default_branch
  68. def get_pull_request_base_branch(fallback: str = "main"):
  69. return get_var(BuildkiteEnvVar.BUILDKITE_PULL_REQUEST_BASE_BRANCH, fallback)
  70. def get_pipeline_default_branch(fallback: str = "main"):
  71. return get_var(BuildkiteEnvVar.BUILDKITE_PIPELINE_DEFAULT_BRANCH, fallback)
  72. def get_merge_base(url: str = "https://github.com/MaterializeInc/materialize") -> str:
  73. base_branch = get_pull_request_base_branch() or get_pipeline_default_branch()
  74. merge_base = git.get_common_ancestor_commit(
  75. remote=git.get_remote(url), branch=base_branch, fetch_branch=True
  76. )
  77. return merge_base
  78. def inline_link(url: str, label: str | None = None) -> str:
  79. """See https://buildkite.com/docs/pipelines/links-and-images-in-log-output"""
  80. link = f"url='{url}'"
  81. if label:
  82. link = f"{link};content='{label}'"
  83. # These escape codes are not supported by terminals
  84. return f"\033]1339;{link}\a" if is_in_buildkite() else f"{label},{url}"
  85. def inline_image(url: str, alt: str) -> str:
  86. """See https://buildkite.com/docs/pipelines/links-and-images-in-log-output#images-syntax-for-inlining-images"""
  87. content = f"url='{url}';alt='{alt}'"
  88. # These escape codes are not supported by terminals
  89. return f"\033]1338;{content}\a" if is_in_buildkite() else f"{alt},{url}"
  90. def find_modified_lines() -> set[tuple[str, int]]:
  91. """
  92. Find each line that has been added or modified in the current pull request.
  93. """
  94. merge_base = get_merge_base()
  95. print(f"Merge base: {merge_base}")
  96. result = spawn.capture(["git", "diff", "-U0", merge_base])
  97. modified_lines: set[tuple[str, int]] = set()
  98. file_path = None
  99. for line in result.splitlines():
  100. # +++ b/src/adapter/src/coord/command_handler.rs
  101. if line.startswith("+++"):
  102. file_path = line.removeprefix("+++ b/")
  103. # @@ -641,7 +640,6 @@ impl Coordinator {
  104. elif line.startswith("@@ "):
  105. # We only care about the second value ("+640,6" in the example),
  106. # which contains the line number and length of the modified block
  107. # in new code state.
  108. parts = line.split(" ")[2]
  109. if "," in parts:
  110. start, length = map(int, parts.split(","))
  111. else:
  112. start = int(parts)
  113. length = 1
  114. for line_nr in range(start, start + length):
  115. assert file_path
  116. modified_lines.add((file_path, line_nr))
  117. return modified_lines
  118. def upload_artifact(path: Path | str, cwd: Path | None = None, quiet: bool = False):
  119. spawn.runv(
  120. [
  121. "buildkite-agent",
  122. "artifact",
  123. "upload",
  124. "--log-level",
  125. "fatal" if quiet else "notice",
  126. path,
  127. ],
  128. cwd=cwd,
  129. )
  130. def get_parallelism_index() -> int:
  131. _validate_parallelism_configuration()
  132. return int(get_var(BuildkiteEnvVar.BUILDKITE_PARALLEL_JOB, 0))
  133. def get_parallelism_count() -> int:
  134. _validate_parallelism_configuration()
  135. return int(get_var(BuildkiteEnvVar.BUILDKITE_PARALLEL_JOB_COUNT, 1))
  136. def _upload_shard_info_metadata(items: list[str]) -> None:
  137. label = get_var(BuildkiteEnvVar.BUILDKITE_LABEL) or get_var(
  138. BuildkiteEnvVar.BUILDKITE_STEP_KEY
  139. )
  140. spawn.runv(
  141. ["buildkite-agent", "meta-data", "set", f"Shard for {label}", ", ".join(items)]
  142. )
  143. def notify_qa_team_about_failure(failure: str) -> None:
  144. if not is_in_buildkite():
  145. return
  146. label = get_var(BuildkiteEnvVar.BUILDKITE_LABEL)
  147. message = f"{label}: {failure}"
  148. print(message)
  149. pipeline = {
  150. "notify": [
  151. {
  152. "slack": {
  153. "channels": ["#team-testing-bots"],
  154. "message": message,
  155. },
  156. "if": 'build.state == "passed" || build.state == "failed" || build.state == "canceled"',
  157. }
  158. ]
  159. }
  160. spawn.runv(
  161. ["buildkite-agent", "pipeline", "upload"], stdin=yaml.dump(pipeline).encode()
  162. )
  163. def shard_list(items: list[T], to_identifier: Callable[[T], str]) -> list[T]:
  164. if len(items) == 0:
  165. return []
  166. parallelism_index = get_parallelism_index()
  167. parallelism_count = get_parallelism_count()
  168. if parallelism_count == 1:
  169. return items
  170. accepted_items = [
  171. item
  172. for i, item in enumerate(items)
  173. if i % parallelism_count == parallelism_index
  174. ]
  175. if is_in_buildkite() and accepted_items:
  176. _upload_shard_info_metadata(list(map(to_identifier, accepted_items)))
  177. return accepted_items
  178. def _validate_parallelism_configuration() -> None:
  179. job_index = get_var(BuildkiteEnvVar.BUILDKITE_PARALLEL_JOB)
  180. job_count = get_var(BuildkiteEnvVar.BUILDKITE_PARALLEL_JOB_COUNT)
  181. if job_index is None and job_count is None:
  182. # OK
  183. return
  184. job_index_desc = f"${BuildkiteEnvVar.BUILDKITE_PARALLEL_JOB.name} (= '{job_index}')"
  185. job_count_desc = (
  186. f"${BuildkiteEnvVar.BUILDKITE_PARALLEL_JOB_COUNT.name} (= '{job_count}')"
  187. )
  188. assert (
  189. job_index is not None and job_count is not None
  190. ), f"{job_index_desc} and {job_count_desc} need to be either both specified or not specified"
  191. job_index = int(job_index)
  192. job_count = int(job_count)
  193. assert job_count > 0, f"{job_count_desc} not valid"
  194. assert (
  195. 0 <= job_index < job_count
  196. ), f"{job_index_desc} out of valid range with {job_count_desc}"
  197. def truncate_annotation_str(text: str, max_length: int = 900_000) -> str:
  198. # 400 Bad Request: The annotation body must be less than 1 MB
  199. return text if len(text) <= max_length else text[:max_length] + "..."
  200. def get_artifact_url(artifact: dict[str, Any]) -> str:
  201. org = get_var(BuildkiteEnvVar.BUILDKITE_ORGANIZATION_SLUG)
  202. pipeline = get_var(BuildkiteEnvVar.BUILDKITE_PIPELINE_SLUG)
  203. build = get_var(BuildkiteEnvVar.BUILDKITE_BUILD_NUMBER)
  204. return f"https://buildkite.com/organizations/{org}/pipelines/{pipeline}/builds/{build}/jobs/{artifact['job_id']}/artifacts/{artifact['id']}"
  205. def add_annotation_raw(style: str, markdown: str) -> None:
  206. """
  207. Note that this does not trim the data.
  208. :param markdown: must not exceed 1 MB
  209. """
  210. spawn.runv(
  211. [
  212. "buildkite-agent",
  213. "annotate",
  214. f"--style={style}",
  215. f"--context={os.environ['BUILDKITE_JOB_ID']}-{style}",
  216. ],
  217. stdin=markdown.encode(),
  218. )
  219. def add_annotation(style: str, title: str, content: str) -> None:
  220. if style == "info":
  221. markdown = f"""<details><summary>{title}</summary>
  222. {truncate_annotation_str(content)}
  223. </details>"""
  224. else:
  225. markdown = f"""{title}
  226. {truncate_annotation_str(content)}"""
  227. add_annotation_raw(style, markdown)
  228. def get_job_url_from_build_url(build_url: str, build_job_id: str) -> str:
  229. return f"{build_url}#{build_job_id}"
  230. def get_job_url_from_pipeline_and_build(
  231. pipeline: str, build_number: str | int, build_job_id: str
  232. ) -> str:
  233. build_url = f"https://buildkite.com/materialize/{pipeline}/builds/{build_number}"
  234. return get_job_url_from_build_url(build_url, build_job_id)
  235. def is_build_failed(build: str) -> bool:
  236. try:
  237. return (
  238. spawn.capture(
  239. ["buildkite-agent", "meta-data", "get", build],
  240. stderr=subprocess.DEVNULL,
  241. )
  242. == "failed"
  243. )
  244. except subprocess.CalledProcessError:
  245. return False