mzcompose.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935
  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. #
  10. # mzcompose.py — runs Docker Compose with Materialize customizations.
  11. """Command-line driver for mzcompose.
  12. This module is the main user interface to mzcompose, which looks much like
  13. Docker Compose but adds several Materialize-specific features.
  14. NOTE(benesch): Much of the complexity here is very delicate argument parsing to
  15. ensure that the correct command line flags are forwarded to Docker Compose in
  16. just the right way. This stretches the limit of argparse, but that complexity
  17. has been carefully managed. If you are tempted to refactor the argument parsing
  18. code, please talk to me first!
  19. """
  20. from __future__ import annotations
  21. import argparse
  22. import inspect
  23. import json
  24. import os
  25. import shlex
  26. import subprocess
  27. import sys
  28. import webbrowser
  29. from collections.abc import Sequence
  30. from pathlib import Path
  31. from typing import IO, Any
  32. import junit_xml
  33. from humanize import naturalsize
  34. from semver.version import Version
  35. from materialize import MZ_ROOT, ci_util, mzbuild, spawn, ui
  36. from materialize.mzcompose.composition import (
  37. SECRETS,
  38. Composition,
  39. UnknownCompositionError,
  40. )
  41. from materialize.mzcompose.test_result import TestResult
  42. from materialize.ui import UIError
  43. RECOMMENDED_MIN_MEM = 7 * 1024**3 # 7GiB
  44. RECOMMENDED_MIN_CPUS = 2
  45. JUNIT_ERROR_DETAILS_SEPARATOR = "###---###"
  46. def main(argv: list[str]) -> None:
  47. parser = ArgumentParser(
  48. prog="mzcompose",
  49. formatter_class=argparse.RawDescriptionHelpFormatter,
  50. description="""
  51. mzcompose orchestrates services defined in mzcompose.py.
  52. It wraps Docker Compose to add some Materialize-specific features.""",
  53. epilog="""
  54. These are only the most common options. There are additional Docker Compose
  55. options that are also supported. Consult `docker compose help` for the full
  56. set.
  57. For help on a specific command, run `mzcompose COMMAND --help`.
  58. For additional details on mzcompose, consult doc/developer/mzbuild.md.""",
  59. )
  60. # Global arguments.
  61. parser.add_argument(
  62. "--mz-quiet",
  63. action="store_true",
  64. help="suppress Materialize-specific informational messages",
  65. )
  66. parser.add_argument(
  67. "--find",
  68. metavar="DIR",
  69. help="use the mzcompose.py file from DIR, rather than the current directory",
  70. )
  71. parser.add_argument(
  72. "--preserve-ports",
  73. action="store_true",
  74. help="bind container ports to the same host ports rather than choosing random host ports",
  75. )
  76. parser.add_argument(
  77. "--project-name",
  78. metavar="PROJECT_NAME",
  79. help="Use a different project name than the directory name",
  80. )
  81. parser.add_argument(
  82. "--sanity-restart-mz",
  83. action="store_true",
  84. default=os.getenv("BUILDKITE_TAG", "") != "",
  85. help="Whether to restart Materialized at the end of test cases and tests, enabled by default on release branches",
  86. )
  87. parser.add_argument("--ignore-docker-version", action="store_true")
  88. mzbuild.Repository.install_arguments(parser)
  89. # Docker Compose arguments that we explicitly ban. Since we don't support
  90. # these, we hide them from the help output.
  91. parser.add_argument("-f", "--file", nargs="?", help=argparse.SUPPRESS)
  92. parser.add_argument("--project-directory", nargs="?", help=argparse.SUPPRESS)
  93. parser.add_argument("-v", "--version", action="store_true", help=argparse.SUPPRESS)
  94. # Subcommands. We explicitly hardcode a list of known Docker Compose
  95. # commands for the sake of help text.
  96. subparsers = parser.add_subparsers(
  97. metavar="COMMAND", parser_class=ArgumentSubparser
  98. )
  99. BuildCommand.register(parser, subparsers)
  100. ConfigCommand.register(parser, subparsers)
  101. CpCommand.register(parser, subparsers)
  102. CreateCommand.register(parser, subparsers)
  103. DescribeCommand().register(parser, subparsers)
  104. DescriptionCommand().register(parser, subparsers)
  105. DownCommand().register(parser, subparsers)
  106. EventsCommand.register(parser, subparsers)
  107. ExecCommand.register(parser, subparsers)
  108. GenShortcutsCommand().register(parser, subparsers)
  109. help_command = HelpCommand()
  110. parser.set_defaults(command=help_command)
  111. help_command.register(parser, subparsers)
  112. ImagesCommand.register(parser, subparsers)
  113. KillCommand.register(parser, subparsers)
  114. ListCompositionsCommand().register(parser, subparsers)
  115. ListWorkflowsCommand().register(parser, subparsers)
  116. LogsCommand.register(parser, subparsers)
  117. PauseCommand.register(parser, subparsers)
  118. PortCommand.register(parser, subparsers)
  119. PsCommand.register(parser, subparsers)
  120. PullCommand.register(parser, subparsers)
  121. PushCommand.register(parser, subparsers)
  122. RestartCommand.register(parser, subparsers)
  123. RmCommand.register(parser, subparsers)
  124. RunCommand().register(parser, subparsers)
  125. ScaleCommand.register(parser, subparsers)
  126. StartCommand.register(parser, subparsers)
  127. StopCommand.register(parser, subparsers)
  128. SqlCommand().register(parser, subparsers)
  129. TopCommand.register(parser, subparsers)
  130. UnpauseCommand.register(parser, subparsers)
  131. UpCommand.register(parser, subparsers)
  132. WebCommand().register(parser, subparsers)
  133. args = parser.parse_args(argv)
  134. if args.file:
  135. parser.error("-f/--file option not supported")
  136. elif args.project_directory:
  137. parser.error("--project-directory option not supported")
  138. elif args.version:
  139. parser.error("-v/--version option not supported")
  140. ui.Verbosity.init_from_env(args.mz_quiet)
  141. args.command.invoke(args)
  142. def load_composition(args: argparse.Namespace) -> Composition:
  143. """Loads the composition specified by the command-line arguments."""
  144. if not args.ignore_docker_version:
  145. docker_local_version = Version.parse(
  146. spawn.capture(["docker", "--version"])
  147. .removeprefix("Docker version ")
  148. .split(", ")[0]
  149. )
  150. docker_ci_version = Version.parse("24.0.5")
  151. if docker_local_version < docker_ci_version:
  152. raise UIError(
  153. f"Your Docker version is {docker_local_version} while the version used in CI is {docker_ci_version}, please upgrade your local Docker version to prevent unexpected breakages.",
  154. hint="If you believe this is a mistake, contact the QA team. While not recommended, --ignore-docker-version can be used to ignore this version check.",
  155. )
  156. compose_local_version = Version.parse(
  157. spawn.capture(["docker", "compose", "version", "--short"])
  158. )
  159. compose_ci_version = Version.parse("2.15.1")
  160. if compose_local_version < compose_ci_version:
  161. raise UIError(
  162. f"Your Docker Compose version is {compose_local_version} while the version used in CI is {compose_ci_version}, please upgrade your local Docker Compose version to prevent unexpected breakages.",
  163. hint="If you believe this is a mistake, contact the QA team. While not recommended, --ignore-docker-version can be used to ignore this version check.",
  164. )
  165. sys.exit(1)
  166. repo = mzbuild.Repository.from_arguments(MZ_ROOT, args)
  167. try:
  168. return Composition(
  169. repo,
  170. name=args.find or Path.cwd().name,
  171. preserve_ports=args.preserve_ports,
  172. project_name=args.project_name,
  173. sanity_restart_mz=args.sanity_restart_mz,
  174. )
  175. except UnknownCompositionError as e:
  176. if args.find:
  177. hint = "available compositions:\n"
  178. for name in sorted(repo.compositions):
  179. hint += f" {name}\n"
  180. e.set_hint(hint)
  181. raise e
  182. else:
  183. hint = "enter one of the following directories and run ./mzcompose:\n"
  184. for path in repo.compositions.values():
  185. hint += f" {path.relative_to(Path.cwd())}\n"
  186. raise UIError(
  187. "directory does not contain mzcompose.py",
  188. hint,
  189. )
  190. class Command:
  191. """An mzcompose command."""
  192. name: str
  193. """The name of the command."""
  194. aliases: list[str] = []
  195. """Aliases to register for the command."""
  196. help: str
  197. """The help text displayed in top-level usage output."""
  198. add_help = True
  199. """Whether to add the `--help` argument to the subparser."""
  200. allow_unknown_arguments = False
  201. """Whether to error if unknown arguments are encountered before calling `run`."""
  202. def register(
  203. self,
  204. root_parser: argparse.ArgumentParser,
  205. subparsers: argparse._SubParsersAction,
  206. ) -> None:
  207. """Register this command as a subcommand of an argument parser."""
  208. self.root_parser = root_parser
  209. parser = subparsers.add_parser(
  210. self.name,
  211. aliases=self.aliases,
  212. help=self.help,
  213. description=self.help,
  214. add_help=self.add_help,
  215. )
  216. parser.set_defaults(command=self)
  217. self.configure(parser)
  218. def invoke(self, args: argparse.Namespace) -> None:
  219. """Invoke this command."""
  220. if not self.allow_unknown_arguments:
  221. unknown_args = [*args.unknown_args, *args.unknown_subargs]
  222. if unknown_args:
  223. self.root_parser.error(f"unknown argument {unknown_args[0]!r}")
  224. self.run(args)
  225. def configure(self, parser: argparse.ArgumentParser) -> None:
  226. """Override this in subclasses to add additional arguments."""
  227. pass
  228. def run(self, args: argparse.Namespace) -> None:
  229. """Override this in subclasses to specify the command's behavior."""
  230. pass
  231. class HelpCommand(Command):
  232. name = "help"
  233. help = "show this command"
  234. add_help = False
  235. allow_unknown_arguments = True
  236. def run(self, args: argparse.Namespace) -> None:
  237. self.root_parser.print_help()
  238. class GenShortcutsCommand(Command):
  239. name = "gen-shortcuts"
  240. help = "generate shortcut `mzcompose` shell scripts in mzcompose directories"
  241. def run(self, args: argparse.Namespace) -> None:
  242. repo = mzbuild.Repository.from_arguments(MZ_ROOT, args)
  243. template = """#!/usr/bin/env bash
  244. # Copyright Materialize, Inc. and contributors. All rights reserved.
  245. #
  246. # Use of this software is governed by the Business Source License
  247. # included in the LICENSE file at the root of this repository.
  248. #
  249. # As of the Change Date specified in that file, in accordance with
  250. # the Business Source License, use of this software will be governed
  251. # by the Apache License, Version 2.0.
  252. #
  253. # mzcompose — runs Docker Compose with Materialize customizations.
  254. exec "$(dirname "$0")"/{}/bin/pyactivate -m materialize.cli.mzcompose "$@"
  255. """
  256. for path in repo.compositions.values():
  257. mzcompose_path = path / "mzcompose"
  258. with open(mzcompose_path, "w") as f:
  259. f.write(template.format(os.path.relpath(repo.root, path)))
  260. mzbuild.chmod_x(mzcompose_path)
  261. class ListCompositionsCommand(Command):
  262. name = "list-compositions"
  263. help = "list the directories that contain compositions and their summaries"
  264. def run(self, args: argparse.Namespace) -> None:
  265. repo = mzbuild.Repository.from_arguments(MZ_ROOT, args)
  266. for name, path in sorted(repo.compositions.items(), key=lambda item: item[1]):
  267. print(os.path.relpath(path, repo.root))
  268. composition = Composition(repo, name, munge_services=False)
  269. if composition.description:
  270. # Emit the first paragraph of the description.
  271. for line in composition.description.split("\n"):
  272. if line.strip() == "":
  273. break
  274. print(f" {line}")
  275. class ListWorkflowsCommand(Command):
  276. name = "list-workflows"
  277. help = "list workflows in the composition"
  278. def run(self, args: argparse.Namespace) -> None:
  279. composition = load_composition(args)
  280. for name in sorted(composition.workflows):
  281. print(name)
  282. class DescribeCommand(Command):
  283. name = "describe"
  284. aliases = ["ls", "list"]
  285. help = "describe services and workflows in the composition"
  286. def run(self, args: argparse.Namespace) -> None:
  287. composition = load_composition(args)
  288. workflows = []
  289. for name, fn in composition.workflows.items():
  290. workflows.append((name, inspect.getdoc(fn) or ""))
  291. workflows.sort()
  292. name_width = min(max(len(name) for name, _ in workflows), 16)
  293. if composition.description:
  294. print("Description:")
  295. print(composition.description)
  296. print()
  297. print("Services:")
  298. for name in sorted(composition.compose["services"]):
  299. print(f" {name}")
  300. print()
  301. print("Workflows:")
  302. for name, description in workflows:
  303. if len(name) <= name_width or not description:
  304. print(f" {name: <{name_width}} {description}")
  305. else:
  306. print(f" {name}")
  307. print(f" {' ' * name_width} {description}")
  308. print()
  309. print(
  310. """For help on a specific workflow, run:
  311. $ ./mzcompose run WORKFLOW --help
  312. """
  313. )
  314. class DescriptionCommand(Command):
  315. name = "description"
  316. help = "fetch the Python code description from mzcompose.py"
  317. def run(self, args: argparse.Namespace) -> None:
  318. composition = load_composition(args)
  319. print(composition.description)
  320. class SqlCommand(Command):
  321. name = "sql"
  322. help = "connect a SQL shell to a running materialized service"
  323. def configure(self, parser: argparse.ArgumentParser) -> None:
  324. parser.add_argument(
  325. "--mz_system",
  326. action="store_true",
  327. )
  328. parser.add_argument(
  329. "service",
  330. metavar="SERVICE",
  331. nargs="?",
  332. default="materialized",
  333. help="the service to target",
  334. )
  335. def run(self, args: argparse.Namespace) -> None:
  336. composition = load_composition(args)
  337. service = composition.compose["services"].get(args.service)
  338. if not service:
  339. raise UIError(f"unknown service {args.service!r}")
  340. # Attempting to load the default port will produce a nice error message
  341. # if the service isn't running or isn't exposing a port.
  342. composition.default_port(args.service)
  343. image = service["image"].split(":")[0]
  344. if image == "materialize/materialized":
  345. deps = composition.repo.resolve_dependencies(
  346. [composition.repo.images["psql"]]
  347. )
  348. deps.acquire()
  349. if args.mz_system:
  350. deps["psql"].run(
  351. [
  352. "-h",
  353. service.get("hostname", args.service),
  354. "-p",
  355. "6877",
  356. "-U",
  357. "mz_system",
  358. "materialize",
  359. ],
  360. docker_args=[
  361. "--interactive",
  362. f"--network={composition.name}_default",
  363. ],
  364. env={"PGPASSWORD": "materialize", "PGCLIENTENCODING": "utf-8"},
  365. )
  366. else:
  367. deps["psql"].run(
  368. [
  369. "-h",
  370. service.get("hostname", args.service),
  371. "-p",
  372. "6875",
  373. "-U",
  374. "materialize",
  375. "materialize",
  376. ],
  377. docker_args=[
  378. "--interactive",
  379. f"--network={composition.name}_default",
  380. ],
  381. env={"PGCLIENTENCODING": "utf-8"},
  382. )
  383. elif image == "materialize/balancerd":
  384. assert not args.mz_system
  385. deps = composition.repo.resolve_dependencies(
  386. [composition.repo.images["psql"]]
  387. )
  388. deps.acquire()
  389. deps["psql"].run(
  390. [
  391. "-h",
  392. service.get("hostname", args.service),
  393. "-p",
  394. "6875",
  395. "-U",
  396. "materialize",
  397. "materialize",
  398. ],
  399. docker_args=["--interactive", f"--network={composition.name}_default"],
  400. env={"PGCLIENTENCODING": "utf-8"},
  401. )
  402. elif image == "materialize/postgres":
  403. assert not args.mz_system
  404. deps = composition.repo.resolve_dependencies(
  405. [composition.repo.images["psql"]]
  406. )
  407. deps.acquire()
  408. deps["psql"].run(
  409. [
  410. "-h",
  411. service.get("hostname", args.service),
  412. "-U",
  413. "postgres",
  414. "postgres",
  415. ],
  416. docker_args=["--interactive", f"--network={composition.name}_default"],
  417. env={"PGPASSWORD": "postgres", "PGCLIENTENCODING": "utf-8"},
  418. )
  419. elif image == "cockroachdb/cockroach" or args.service == "postgres-metadata":
  420. assert not args.mz_system
  421. deps = composition.repo.resolve_dependencies(
  422. [composition.repo.images["psql"]]
  423. )
  424. deps.acquire()
  425. deps["psql"].run(
  426. [
  427. "-h",
  428. service.get("hostname", args.service),
  429. "-p" "26257",
  430. "-U",
  431. "root",
  432. "root",
  433. ],
  434. docker_args=["--interactive", f"--network={composition.name}_default"],
  435. env={"PGCLIENTENCODING": "utf-8"},
  436. )
  437. elif image == "mysql":
  438. assert not args.mz_system
  439. deps = composition.repo.resolve_dependencies(
  440. [composition.repo.images["mysql-client"]]
  441. )
  442. deps.acquire()
  443. deps["mysql-client"].run(
  444. [
  445. "-h",
  446. service.get("hostname", args.service),
  447. "--port",
  448. "3306",
  449. "-u",
  450. "root",
  451. ],
  452. docker_args=[
  453. "--interactive",
  454. f"--network={composition.name}_default",
  455. "-e=MYSQL_PWD=p@ssw0rd",
  456. ],
  457. )
  458. else:
  459. raise UIError(
  460. f"cannot connect SQL shell to unhandled service {args.service!r}"
  461. )
  462. class WebCommand(Command):
  463. name = "web"
  464. help = "open a service's URL in a web browser"
  465. def configure(self, parser: argparse.ArgumentParser) -> None:
  466. parser.add_argument("service", metavar="SERVICE", help="the service to target")
  467. def run(self, args: argparse.Namespace) -> None:
  468. composition = load_composition(args)
  469. port = composition.default_port(args.service)
  470. url = f"http://localhost:{port}"
  471. print(f"Opening {url} in a web browser...")
  472. webbrowser.open(url)
  473. class DockerComposeCommand(Command):
  474. add_help = False
  475. allow_unknown_arguments = True
  476. def __init__(
  477. self,
  478. name: str,
  479. help: str,
  480. help_epilog: str | None = None,
  481. runs_containers: bool = False,
  482. ):
  483. self.name = name
  484. self.help = help
  485. self.help_epilog = help_epilog
  486. self.runs_containers = runs_containers
  487. def configure(self, parser: argparse.ArgumentParser) -> None:
  488. parser.add_argument("-h", "--help", action="store_true")
  489. def run(self, args: argparse.Namespace) -> None:
  490. if args.help:
  491. output = self.capture(
  492. ["docker", "compose", self.name, "--help"], stderr=subprocess.STDOUT
  493. )
  494. output = output.replace("docker compose", "./mzcompose")
  495. output += "\nThis command is a wrapper around Docker Compose."
  496. if self.help_epilog:
  497. output += "\n"
  498. output += self.help_epilog
  499. print(output, file=sys.stderr)
  500. return
  501. composition = load_composition(args)
  502. if not ui.env_is_truthy("CI") or ui.env_is_truthy("CI_ALLOW_LOCAL_BUILD"):
  503. ui.section("Collecting mzbuild images")
  504. for d in composition.dependencies:
  505. ui.say(d.spec())
  506. if self.runs_containers:
  507. if args.coverage:
  508. # If the user has requested coverage information, create the
  509. # coverage directory as the current user, so Docker doesn't create
  510. # it as root.
  511. (composition.path / "coverage").mkdir(exist_ok=True)
  512. # Need materialize user to be able to write to coverage
  513. os.chmod(composition.path / "coverage", 0o777)
  514. self.check_docker_resource_limits()
  515. composition.dependencies.acquire()
  516. if "services" in composition.compose:
  517. composition.pull_if_variable(composition.compose["services"].keys())
  518. self.handle_composition(args, composition)
  519. def handle_composition(
  520. self, args: argparse.Namespace, composition: Composition
  521. ) -> None:
  522. ui.header("Delegating to Docker Compose")
  523. composition.invoke(*args.unknown_args, self.name, *args.unknown_subargs)
  524. def check_docker_resource_limits(self) -> None:
  525. output = self.capture(
  526. ["docker", "system", "info", "--format", "{{.MemTotal}} {{.NCPU}}"]
  527. )
  528. [mem, ncpus] = [int(field) for field in output.split()]
  529. if mem < RECOMMENDED_MIN_MEM:
  530. ui.warn(
  531. f"Docker only has {naturalsize(mem, binary=True)} of memory available. "
  532. f"We recommend at least {naturalsize(RECOMMENDED_MIN_MEM, binary=True)} of memory."
  533. )
  534. if ncpus < RECOMMENDED_MIN_CPUS:
  535. ui.warn(
  536. f"Docker only has {ncpus} CPU available. "
  537. f"We recommend at least {RECOMMENDED_MIN_CPUS} CPUs."
  538. )
  539. def capture(self, args: list[str], stderr: None | int | IO[bytes] = None) -> str:
  540. try:
  541. return spawn.capture(args, stderr=stderr)
  542. except subprocess.CalledProcessError as e:
  543. # Print any captured output, since it probably hints at the problem.
  544. print(e.output, file=sys.stderr, end="")
  545. raise UIError(f"running `{args[0]}` failed (exit status {e.returncode})")
  546. except FileNotFoundError:
  547. raise UIError(
  548. f"unable to launch `{args[0]}`", hint=f"is {args[0]} installed?"
  549. )
  550. class RunCommand(DockerComposeCommand):
  551. def __init__(self) -> None:
  552. super().__init__(
  553. "run",
  554. "run a one-off command",
  555. runs_containers=True,
  556. help_epilog="""As an mzcompose extension, run also supports running a workflow, as in:
  557. $ ./mzcompose run WORKFLOW [workflow-options...]
  558. In this form, run does not accept any of the arguments listed above.
  559. To see the available workflows, run:
  560. $ ./mzcompose list
  561. """,
  562. )
  563. def configure(self, parser: argparse.ArgumentParser) -> None:
  564. pass
  565. def run(self, args: argparse.Namespace) -> Any:
  566. # This is a bit gross, but to determine the first position argument to
  567. # `run` we have no choice but to hardcode the list of `run` options that
  568. # take a value. E.g., in `run --entrypoint bash service`, we need to
  569. # return `service`, not `bash`. We also distinguish between `run --help
  570. # workflow` and `run workflow --help`: the former asks for help on the
  571. # `run` command while the latter asks for help on the named `workflow`.
  572. KNOWN_OPTIONS_WITH_ARGUMENT = [
  573. "-d",
  574. "--detach",
  575. "--name",
  576. "--entrypoint",
  577. "-e",
  578. "-l",
  579. "--label",
  580. "-p",
  581. "--publish",
  582. "-v",
  583. "--volume",
  584. "-w",
  585. "--workdir",
  586. ]
  587. setattr(args, "workflow", None)
  588. setattr(args, "help", False)
  589. arg_iter = iter(args.unknown_subargs)
  590. for arg in arg_iter:
  591. if arg in ["-h", "--help"]:
  592. setattr(args, "help", True)
  593. elif arg in KNOWN_OPTIONS_WITH_ARGUMENT:
  594. # This is an option that's known to take a value, so skip the
  595. # next argument too.
  596. next(arg_iter, None)
  597. elif arg.startswith("-"):
  598. # Flag option. Skip it.
  599. pass
  600. else:
  601. # Found a positional argument. Save it.
  602. setattr(args, "workflow", arg)
  603. break
  604. super().run(args)
  605. def handle_composition(
  606. self, args: argparse.Namespace, composition: Composition
  607. ) -> None:
  608. if args.workflow not in composition.workflows:
  609. # Restart any dependencies whose definitions have changed. This is
  610. # Docker Compose's default behavior for `up`, but not for `run`,
  611. # which is a constant irritation that we paper over here. The trick,
  612. # taken from Buildkite's Docker Compose plugin, is to run an `up`
  613. # command that requests zero instances of the requested service.
  614. if args.workflow:
  615. composition.invoke(
  616. "up",
  617. "-d",
  618. "--scale",
  619. f"{args.workflow}=0",
  620. args.workflow,
  621. )
  622. super().handle_composition(args, composition)
  623. else:
  624. # The user has specified a workflow rather than a service. Run the
  625. # workflow instead of Docker Compose.
  626. if args.unknown_args:
  627. bad_arg = args.unknown_args[0]
  628. elif args.unknown_subargs[0].startswith("-"):
  629. bad_arg = args.unknown_subargs[0]
  630. else:
  631. bad_arg = None
  632. if bad_arg:
  633. raise UIError(
  634. f"unknown option {bad_arg!r}",
  635. hint=f"if {bad_arg!r} is a valid Docker Compose option, "
  636. f"it can't be used when running {args.workflow!r}, because {args.workflow!r} "
  637. "is a custom mzcompose workflow, not a Docker Compose service",
  638. )
  639. # Run the workflow inside of a test case so that we get some basic
  640. # test analytics, even if the workflow doesn't define more granular
  641. # test cases.
  642. with composition.test_case(f"workflow-{args.workflow}"):
  643. ci_extra_args = json.loads(os.getenv("CI_EXTRA_ARGS", "{}"))
  644. buildkite_step_key = os.getenv("BUILDKITE_STEP_KEY")
  645. extra_args = (
  646. shlex.split(ci_extra_args[buildkite_step_key])
  647. if buildkite_step_key and buildkite_step_key in ci_extra_args
  648. else []
  649. )
  650. composition.workflow(
  651. args.workflow, *args.unknown_subargs[1:], *extra_args
  652. )
  653. if self.shall_generate_junit_report(args.find):
  654. junit_suite = self.generate_junit_suite(composition)
  655. self.write_junit_report_to_file(junit_suite)
  656. if any(
  657. not result.is_successful()
  658. for result in composition.test_results.values()
  659. ):
  660. raise UIError("at least one test case failed")
  661. def shall_generate_junit_report(self, composition: str | None) -> bool:
  662. return composition not in {
  663. # sqllogictest already generates a proper junit.xml file
  664. "sqllogictest",
  665. # testdrive already generates a proper junit.xml file
  666. "testdrive",
  667. # not a test, run as post-command, and should not overwrite an existing junit.xml from a previous test
  668. "get-cloud-hostname",
  669. }
  670. def generate_junit_suite(self, composition: Composition) -> junit_xml.TestSuite:
  671. buildkite_step_name = os.getenv("BUILDKITE_LABEL")
  672. test_suite_name = buildkite_step_name or composition.name
  673. test_class_name = test_suite_name
  674. # Upload test report to Buildkite Test Analytics.
  675. junit_suite = junit_xml.TestSuite(test_suite_name)
  676. for test_case_key, result in composition.test_results.items():
  677. self.append_to_junit_suite(
  678. junit_suite, test_class_name, test_case_key, result
  679. )
  680. return junit_suite
  681. def append_to_junit_suite(
  682. self,
  683. junit_suite: junit_xml.TestSuite,
  684. test_class_name: str,
  685. test_case_key: str,
  686. result: TestResult,
  687. ):
  688. if result.is_successful():
  689. test_case_name = test_case_key
  690. test_case = junit_xml.TestCase(
  691. test_case_name,
  692. test_class_name,
  693. result.duration,
  694. )
  695. junit_suite.test_cases.append(test_case)
  696. else:
  697. for error in result.errors:
  698. test_case_name = (
  699. error.test_case_name_override
  700. or error.location_as_file_name()
  701. or test_case_key
  702. )
  703. test_case = junit_xml.TestCase(
  704. test_case_name,
  705. error.test_class_name_override or test_class_name,
  706. # do not provide the duration when multiple errors are derived from a test execution
  707. elapsed_sec=None,
  708. )
  709. error_details_data = error.details
  710. if error.additional_details is not None:
  711. error_details_data = (error_details_data or "") + (
  712. f"{JUNIT_ERROR_DETAILS_SEPARATOR}{error.additional_details_header or 'Additional details'}"
  713. f"{JUNIT_ERROR_DETAILS_SEPARATOR}{error.additional_details}"
  714. )
  715. test_case.add_error_info(
  716. message=error.message, output=error_details_data
  717. )
  718. junit_suite.test_cases.append(test_case)
  719. def write_junit_report_to_file(self, junit_suite: junit_xml.TestSuite) -> Path:
  720. for test_case in junit_suite.test_cases:
  721. for obj in test_case.errors + test_case.failures + test_case.skipped:
  722. for typ in ("message", "output"):
  723. if obj[typ]:
  724. obj[typ] = " ".join(
  725. [
  726. (
  727. "[REDACTED]"
  728. if any(secret in word for secret in SECRETS)
  729. else word
  730. )
  731. for word in obj[typ].split(" ")
  732. ]
  733. )
  734. junit_report = ci_util.junit_report_filename("mzcompose")
  735. with junit_report.open("w") as f:
  736. junit_xml.to_xml_report_file(f, [junit_suite])
  737. return junit_report
  738. BuildCommand = DockerComposeCommand("build", "build or rebuild services")
  739. ConfigCommand = DockerComposeCommand("config", "validate and view the Compose file")
  740. CpCommand = DockerComposeCommand("cp", "copy files/folders", runs_containers=True)
  741. CreateCommand = DockerComposeCommand("create", "create services", runs_containers=True)
  742. class DownCommand(DockerComposeCommand):
  743. def __init__(self) -> None:
  744. super().__init__("down", "Stop and remove containers, networks")
  745. def run(self, args: argparse.Namespace) -> Any:
  746. args.unknown_subargs.append("--volumes")
  747. # --remove-orphans needs to be in effect at all times otherwise
  748. # services added to a composition after the fact will not be cleaned up
  749. args.unknown_subargs.append("--remove-orphans")
  750. super().run(args)
  751. EventsCommand = DockerComposeCommand(
  752. "events", "receive real time events from containers"
  753. )
  754. ExecCommand = DockerComposeCommand("exec", "execute a command in a running container")
  755. ImagesCommand = DockerComposeCommand("images", "list images")
  756. KillCommand = DockerComposeCommand("kill", "kill containers")
  757. LogsCommand = DockerComposeCommand("logs", "view output from containers")
  758. PauseCommand = DockerComposeCommand("pause", "pause services")
  759. PortCommand = DockerComposeCommand("port", "print the public port for a port binding")
  760. PsCommand = DockerComposeCommand("ps", "list containers")
  761. PullCommand = DockerComposeCommand("pull", "pull service images")
  762. PushCommand = DockerComposeCommand("push", "push service images")
  763. RestartCommand = DockerComposeCommand("restart", "restart services")
  764. RmCommand = DockerComposeCommand("rm", "remove stopped containers")
  765. ScaleCommand = DockerComposeCommand("scale", "set number of containers for a service")
  766. StartCommand = DockerComposeCommand("start", "start services", runs_containers=True)
  767. StopCommand = DockerComposeCommand("stop", "stop services")
  768. TopCommand = DockerComposeCommand("top", "display the running processes")
  769. UnpauseCommand = DockerComposeCommand("unpause", "unpause services")
  770. UpCommand = DockerComposeCommand(
  771. "up", "create and start containers", runs_containers=True
  772. )
  773. # The following commands are intentionally omitted:
  774. #
  775. # * `help`, because it is hard to integrate help messages for our custom
  776. # commands. Instead we focus on making `--help` work perfectly.
  777. #
  778. # * `version`, because mzcompose isn't versioned. If someone wants their
  779. # Docker Compose version, it's clearer to have them run
  780. # `docker compose version` explicitly.
  781. # The following `ArgumentParser` subclasses attach unknown arguments as
  782. # `unknown_args` and `unknown_subargs` to the returned arguments object. The
  783. # difference between unknown arguments that occur *before* the command vs. after
  784. # (consider `./mzcompose --before command --after) is important when forwarding
  785. # arguments to `docker compose`.
  786. #
  787. # `argparse.REMAINDER` seems like it'd be useful here, but it doesn't maintain
  788. # the above distinction, plus was deprecated in Python 3.9 due to unfixable
  789. # bugs: https://bugs.python.org/issue17050.
  790. class ArgumentParser(argparse.ArgumentParser):
  791. def parse_known_args(
  792. self,
  793. args: Sequence[str] | None = None,
  794. namespace: argparse.Namespace | None = None,
  795. ) -> tuple[argparse.Namespace, list[str]]:
  796. namespace, unknown_args = super().parse_known_args(args, namespace)
  797. setattr(namespace, "unknown_args", unknown_args)
  798. assert namespace is not None
  799. return namespace, []
  800. class ArgumentSubparser(argparse.ArgumentParser):
  801. def parse_known_args(
  802. self,
  803. args: Sequence[str] | None = None,
  804. namespace: argparse.Namespace | None = None,
  805. ) -> tuple[argparse.Namespace, list[str]]:
  806. new_namespace, unknown_args = super().parse_known_args(args, namespace)
  807. setattr(new_namespace, "unknown_subargs", unknown_args)
  808. assert new_namespace is not None
  809. return new_namespace, []
  810. if __name__ == "__main__":
  811. with ui.error_handler("mzcompose"):
  812. main(sys.argv[1:])