Skip to content

Commit

Permalink
refactor: boot_control: split reboot from post_update/post_rollback A…
Browse files Browse the repository at this point in the history
…PI (#427)

This PR splits the reboot from boot_control post_update/post_rollback APIs, instead creating new finalizing_update/finalizing_rollback APIs for reboot operation.
  • Loading branch information
Bodong-Yang authored Nov 21, 2024
1 parent 1a6fb3b commit 8335d04
Show file tree
Hide file tree
Showing 9 changed files with 100 additions and 46 deletions.
18 changes: 13 additions & 5 deletions src/otaclient/boot_control/_grub.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
from pathlib import Path
from pprint import pformat
from subprocess import CalledProcessError
from typing import ClassVar, Dict, Generator, List, Optional, Tuple
from typing import ClassVar, Dict, List, NoReturn, Optional, Tuple

from otaclient import errors as ota_errors
from otaclient._types import OTAStatus
Expand Down Expand Up @@ -895,7 +895,7 @@ def pre_update(self, version: str, *, standby_as_ref: bool, erase_standby=False)
_err_msg, module=__name__
) from e

def post_update(self) -> Generator[None, None, None]:
def post_update(self) -> None:
try:
logger.info("grub_boot: post-update setup...")
# ------ update fstab ------ #
Expand All @@ -916,11 +916,18 @@ def post_update(self) -> Generator[None, None, None]:
# ------ pre-reboot ------ #
self._mp_control.umount_all(ignore_error=True)
self._boot_control.grub_reboot_to_standby()
except Exception as e:
_err_msg = f"failed on post_update: {e!r}"
logger.error(_err_msg)
raise ota_errors.BootControlPostUpdateFailed(
_err_msg, module=__name__
) from e

yield # hand over control to otaclient
def finalizing_update(self) -> NoReturn:
try:
cmdhelper.reboot()
except Exception as e:
_err_msg = f"failed on post_update: {e!r}"
_err_msg = f"reboot failed: {e!r}"
logger.error(_err_msg)
raise ota_errors.BootControlPostUpdateFailed(
_err_msg, module=__name__
Expand All @@ -944,10 +951,11 @@ def post_rollback(self):
logger.info("grub_boot: post-rollback setup...")
self._boot_control.grub_reboot_to_standby()
self._mp_control.umount_all(ignore_error=True)
cmdhelper.reboot()
except Exception as e:
_err_msg = f"failed on pre_rollback: {e!r}"
logger.error(_err_msg)
raise ota_errors.BootControlPostRollbackFailed(
_err_msg, module=__name__
) from e

finalizing_rollback = finalizing_update
19 changes: 14 additions & 5 deletions src/otaclient/boot_control/_jetson_cboot.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import logging
import subprocess
from pathlib import Path
from typing import Generator, Optional
from typing import NoReturn, Optional

from otaclient import errors as ota_errors
from otaclient._types import OTAStatus
Expand Down Expand Up @@ -616,7 +616,7 @@ def pre_update(self, version: str, *, standby_as_ref: bool, erase_standby: bool)
_err_msg, module=__name__
) from e

def post_update(self) -> Generator[None, None, None]:
def post_update(self) -> None:
try:
logger.info("jetson-cboot: post-update ...")
# ------ update extlinux.conf ------ #
Expand Down Expand Up @@ -677,15 +677,23 @@ def post_update(self) -> Generator[None, None, None]:
self._mp_control.umount_all(ignore_error=True)
logger.info(f"[post-update]: \n{NVBootctrlJetsonCBOOT.dump_slots_info()}")
logger.info("post update finished, wait for reboot ...")
yield # hand over control back to otaclient
cmdhelper.reboot()
except Exception as e:
_err_msg = f"failed on post_update: {e!r}"
logger.error(_err_msg)
raise ota_errors.BootControlPostUpdateFailed(
_err_msg, module=__name__
) from e

def finalizing_update(self) -> NoReturn:
try:
cmdhelper.reboot()
except Exception as e:
_err_msg = f"reboot failed: {e!r}"
logger.error(_err_msg)
raise ota_errors.BootControlPostUpdateFailed(
_err_msg, module=__name__
) from e

def pre_rollback(self):
try:
logger.info("jetson-cboot: pre-rollback setup ...")
Expand All @@ -704,14 +712,15 @@ def post_rollback(self):
logger.info("jetson-cboot: post-rollback setup...")
self._mp_control.umount_all(ignore_error=True)
self._cboot_control.switch_boot_to_standby()
cmdhelper.reboot()
except Exception as e:
_err_msg = f"failed on post_rollback: {e!r}"
logger.error(_err_msg)
raise ota_errors.BootControlPostRollbackFailed(
_err_msg, module=__name__
) from e

finalizing_rollback = finalizing_update

def on_operation_failure(self):
"""Failure registering and cleanup at failure."""
logger.warning("on failure try to unmounting standby slot...")
Expand Down
19 changes: 14 additions & 5 deletions src/otaclient/boot_control/_jetson_uefi.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
import shutil
import subprocess
from pathlib import Path
from typing import Any, ClassVar, Generator, Literal
from typing import Any, ClassVar, Generator, Literal, NoReturn

from pydantic import BaseModel
from typing_extensions import Self
Expand Down Expand Up @@ -982,7 +982,7 @@ def pre_update(self, version: str, *, standby_as_ref: bool, erase_standby: bool)
_err_msg, module=__name__
) from e

def post_update(self) -> Generator[None, None, None]:
def post_update(self) -> None:
try:
logger.info("jetson-uefi: post-update ...")
# ------ update extlinux.conf ------ #
Expand Down Expand Up @@ -1063,15 +1063,23 @@ def post_update(self) -> Generator[None, None, None]:
# ------ prepare to reboot ------ #
self._mp_control.umount_all(ignore_error=True)
logger.info("post update finished, wait for reboot ...")
yield # hand over control back to otaclient
cmdhelper.reboot()
except Exception as e:
_err_msg = f"jetson-uefi: failed on post_update: {e!r}"
logger.error(_err_msg)
raise ota_errors.BootControlPostUpdateFailed(
_err_msg, module=__name__
) from e

def finalizing_update(self) -> NoReturn:
try:
cmdhelper.reboot()
except Exception as e:
_err_msg = f"reboot failed: {e!r}"
logger.error(_err_msg)
raise ota_errors.BootControlPostUpdateFailed(
_err_msg, module=__name__
) from e

def pre_rollback(self):
try:
logger.info("jetson-uefi: pre-rollback setup ...")
Expand All @@ -1090,14 +1098,15 @@ def post_rollback(self):
logger.info("jetson-uefi: post-rollback setup...")
self._mp_control.umount_all(ignore_error=True)
self._uefi_control.switch_boot_to_standby()
cmdhelper.reboot()
except Exception as e:
_err_msg = f"jetson-uefi: failed on post_rollback: {e!r}"
logger.error(_err_msg)
raise ota_errors.BootControlPostRollbackFailed(
_err_msg, module=__name__
) from e

finalizing_rollback = finalizing_update

def on_operation_failure(self):
"""Failure registering and cleanup at failure."""
logger.warning("on failure try to unmounting standby slot...")
Expand Down
19 changes: 14 additions & 5 deletions src/otaclient/boot_control/_rpi_boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import subprocess
from pathlib import Path
from string import Template
from typing import Any, Generator, Literal
from typing import Any, Generator, Literal, NoReturn

from typing_extensions import Self

Expand Down Expand Up @@ -523,15 +523,14 @@ def post_rollback(self):
logger.info("rpi_boot: post-rollback setup...")
self._rpiboot_control.prepare_tryboot_txt()
self._mp_control.umount_all(ignore_error=True)
self._rpiboot_control.reboot_tryboot()
except Exception as e:
_err_msg = f"failed on post_rollback: {e!r}"
logger.error(_err_msg)
raise ota_errors.BootControlPostRollbackFailed(
_err_msg, module=__name__
) from e

def post_update(self) -> Generator[None, None, None]:
def post_update(self) -> None:
try:
logger.info("rpi_boot: post-update setup...")
self._mp_control.preserve_ota_folder_to_standby()
Expand All @@ -542,15 +541,25 @@ def post_update(self) -> Generator[None, None, None]:
)
self._rpiboot_control.prepare_tryboot_txt()
self._mp_control.umount_all(ignore_error=True)
yield # hand over control back to otaclient
self._rpiboot_control.reboot_tryboot()
except Exception as e:
_err_msg = f"failed on post_update: {e!r}"
logger.error(_err_msg)
raise ota_errors.BootControlPostUpdateFailed(
_err_msg, module=__name__
) from e

def finalizing_update(self) -> NoReturn:
try:
self._rpiboot_control.reboot_tryboot()
except Exception as e:
_err_msg = f"reboot failed: {e!r}"
logger.error(_err_msg)
raise ota_errors.BootControlPostUpdateFailed(
_err_msg, module=__name__
) from e

finalizing_rollback = finalizing_update

def on_operation_failure(self):
"""Failure registering and cleanup at failure."""
logger.warning("on failure try to unmounting standby slot...")
Expand Down
31 changes: 24 additions & 7 deletions src/otaclient/boot_control/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

from abc import abstractmethod
from pathlib import Path
from typing import Generator, Protocol
from typing import Protocol

from typing_extensions import deprecated

Expand Down Expand Up @@ -55,23 +55,40 @@ def get_standby_boot_dir(self) -> Path:
them to actual boot dir.
"""

@abstractmethod
def load_version(self) -> str:
"""Read the version info from the current slot."""

@abstractmethod
def on_operation_failure(self) -> None:
"""Cleanup by boot_control implementation when OTA failed."""

#
# ------ update ------ #
#

@abstractmethod
def pre_update(
self, version: str, *, standby_as_ref: bool, erase_standby: bool
): ...

@abstractmethod
def pre_rollback(self): ...
def post_update(self) -> None: ...

@abstractmethod
def post_update(self) -> Generator[None, None, None]: ...
def finalizing_update(self) -> None:
"""Normally this method only reboots the device."""

#
# ------ rollback ------ #
#

@abstractmethod
def post_rollback(self): ...
def pre_rollback(self) -> None: ...

@abstractmethod
def load_version(self) -> str:
"""Read the version info from the current slot."""
def post_rollback(self): ...

@abstractmethod
def on_operation_failure(self): ...
def finalizing_rollback(self) -> None:
"""Normally this method only reboots the device."""
7 changes: 3 additions & 4 deletions src/otaclient/ota_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -548,9 +548,7 @@ def _execute_update(self):
)
# NOTE(20240219): move persist file handling here
self._process_persistents(otameta)

# boot controller postupdate
next(_postupdate_gen := self._boot_controller.post_update())
self._boot_controller.post_update()

# ------ finalizing update ------ #
logger.info("local update finished, wait on all subecs...")
Expand All @@ -571,7 +569,7 @@ def _execute_update(self):

logger.info(f"device will reboot in {WAIT_BEFORE_REBOOT} seconds!")
time.sleep(WAIT_BEFORE_REBOOT)
next(_postupdate_gen, None) # reboot
self._boot_controller.finalizing_update()

# API

Expand Down Expand Up @@ -600,6 +598,7 @@ def execute(self):
try:
self._boot_controller.pre_rollback()
self._boot_controller.post_rollback()
self._boot_controller.finalizing_rollback()
except ota_errors.OTAError as e:
logger.error(f"rollback failed: {e!r}")
self._boot_controller.on_operation_failure()
Expand Down
13 changes: 6 additions & 7 deletions src/otaclient_common/cmdhelper.py
Original file line number Diff line number Diff line change
Expand Up @@ -443,16 +443,15 @@ def reboot(args: list[str] | None = None) -> NoReturn: # pragma: no cover
Args:
args (Optional[list[str]], optional): args passed to reboot command.
Defaults to None, not passing any args.
Raises:
CalledProcessError for the reboot call, or SystemExit on sys.exit(0).
"""
cmd = ["reboot"]
if args:
logger.info(f"will reboot with argument: {args=}")
cmd.extend(args)

try:
logger.warning("system will reboot now!")
subprocess_call(cmd, raise_exception=True)
sys.exit(0)
except CalledProcessError:
logger.exception("failed to reboot")
raise
logger.warning("system will reboot now!")
subprocess_call(cmd, raise_exception=True)
sys.exit(0)
9 changes: 6 additions & 3 deletions tests/test_otaclient/test_boot_control/test_grub.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import shutil
import typing
from pathlib import Path
from typing import Any

import pytest
import pytest_mock
Expand Down Expand Up @@ -368,10 +369,12 @@ def test_grub_normal_update(
shutil.copy(slot_a_ota_partition_dir / _initrd, slot_b / "boot")

logger.info("pre-update completed, entering post-update...")

# test post-update
_post_updater = grub_controller.post_update()
next(_post_updater)
next(_post_updater, None)
grub_controller: Any # for typing
grub_controller.post_update()
grub_controller.finalizing_update()

assert (
slot_b / Path(cfg.FSTAB_FILE).relative_to("/")
).read_text().strip() == self.FSTAB_UPDATED.strip()
Expand Down
11 changes: 6 additions & 5 deletions tests/test_otaclient/test_boot_control/test_rpi_boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import typing
from pathlib import Path
from string import Template
from typing import Any

import pytest
import pytest_mock
Expand Down Expand Up @@ -262,10 +263,10 @@ def test_rpi_boot_normal_update(self, mocker: pytest_mock.MockerFixture):
mocker.patch(f"{MODULE}.cfg", _mock_otaclient_cfg)

# ------ boot_controller_inst1.stage1: init ------ #
rpi_boot_controller1 = RPIBootController()
rpi_boot_controller = RPIBootController()

# ------ boot_controller_inst1.stage2: pre_update ------ #
rpi_boot_controller1.pre_update(
rpi_boot_controller.pre_update(
version=VERSION,
standby_as_ref=False,
erase_standby=False,
Expand Down Expand Up @@ -299,9 +300,9 @@ def test_rpi_boot_normal_update(self, mocker: pytest_mock.MockerFixture):
shutil.copy(os.path.realpath(_initrd_img), self.slot_b_boot_dir)

# ------ boot_controller_inst1.stage3: post_update, reboot switch boot ------ #
_post_updater = rpi_boot_controller1.post_update()
next(_post_updater)
next(_post_updater, None) # actual reboot here
rpi_boot_controller: Any # for typing only
rpi_boot_controller.post_update()
rpi_boot_controller.finalizing_update()

# --- assertion --- #
self.reboot_tryboot_mock.assert_called_once()
Expand Down

1 comment on commit 8335d04

@github-actions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage

Coverage Report
FileStmtsMissCoverMissing
src/ota_metadata/legacy
   __init__.py10100% 
   parser.py3264386%100, 156, 161, 197–198, 208–209, 212, 224, 277–279, 283–286, 320–323, 392, 395, 403–405, 418, 427–428, 431–432, 597–599, 649–650, 653, 681–683, 737, 740–742
   types.py841384%37, 40–42, 112–116, 122–125
src/ota_metadata/utils
   cert_store.py420100% 
src/ota_proxy
   __init__.py361072%59, 61, 63, 72, 81–82, 102, 104–106
   __main__.py770%16–18, 20, 22–23, 25
   _consts.py150100% 
   cache_control_header.py68494%71, 91, 113, 121
   cache_streaming.py1441390%211, 225, 229–230, 265–266, 268, 280, 349, 367–370
   config.py170100% 
   db.py731875%109, 115, 153, 159–160, 163, 169, 171, 192–199, 201–202
   errors.py50100% 
   lru_cache_helper.py47295%84–85
   ota_cache.py2155972%70–71, 140, 151–152, 184–185, 202, 239–243, 247–249, 251, 253–260, 262–264, 267–268, 272–273, 277, 324, 332–334, 413–416, 430, 433–434, 448–449, 451–453, 457–458, 464–465, 496, 502, 529, 581–583
   server_app.py1393971%76, 79, 85, 101, 103, 162, 171, 213–214, 216–218, 221, 226–228, 231–232, 235, 238, 241, 244, 257–258, 261–262, 264, 267, 293–296, 299, 313–315, 321–323
   utils.py140100% 
src/otaclient
   __init__.py5260%17, 19
   __main__.py110%16
   _logging.py513335%43–44, 46–47, 49–54, 56–57, 59–60, 62–65, 67, 77, 80–82, 84–86, 89–90, 92–96
   _status_monitor.py1611193%46, 48–49, 157, 160, 177, 180, 196–197, 204, 207
   _types.py750100% 
   errors.py120199%97
   main.py492940%30, 32–39, 41–42, 44–45, 47–48, 52, 54, 59, 61, 67–69, 72–73, 77–80, 82
   ota_core.py3009070%97, 124, 126–127, 129, 133, 137–138, 143–144, 150, 152, 191–194, 200, 204, 210, 349, 381–382, 384, 393, 396, 401–402, 405, 411, 413–417, 453–456, 459–466, 502–505, 583–590, 595, 598–605, 638–639, 645, 649–650, 656, 681–683, 685, 760, 788–789, 791–792, 800, 802–808
   utils.py37294%73–74
src/otaclient/boot_control
   __init__.py40100% 
   _firmware_package.py932276%82, 86, 136, 180, 186, 209–210, 213–218, 220–221, 224–229, 231
   _grub.py41812769%214, 262–265, 271–275, 312–313, 320–325, 328–334, 337, 340–341, 346, 348–350, 359–365, 367–368, 370–372, 381–383, 385–387, 466–467, 471–472, 524, 530, 556, 578, 582–583, 598–600, 624–627, 639, 643–645, 647–649, 708–711, 736–739, 762–765, 777–778, 781–782, 817, 823, 843–844, 846, 871–873, 891–894, 919–922, 929–932, 937–945, 950–957
   _jetson_cboot.py2612610%20, 22–25, 27–29, 35–40, 42, 58–60, 62, 64–65, 71, 75, 134, 137, 139–140, 143, 150–151, 159–160, 163, 169–170, 178, 187–191, 193, 199, 202–203, 209, 212–213, 218–219, 221, 227–228, 231–232, 235–237, 239, 245, 250–252, 254–256, 261, 263–266, 268–269, 278–279, 282–283, 288–289, 292–296, 299–300, 305–306, 309, 312–316, 321–324, 327, 330–331, 334, 337–338, 341, 345–350, 354–355, 359, 362–363, 366, 369–372, 374, 377–378, 382, 385, 388–391, 393, 400, 404–405, 408–409, 415–416, 422, 424–425, 429, 431, 433–435, 438, 442, 445, 448–449, 451, 454, 462–463, 470, 480, 483, 491–492, 497–500, 502, 509, 511–513, 519–520, 524–525, 528, 532, 535, 537, 544–548, 550, 562–565, 568, 571, 573, 580, 590–592, 594, 596, 599, 602, 605, 607–608, 611–615, 619–621, 623, 631–635, 637, 640, 644, 647, 658–659, 664, 674, 677–683, 687–693, 697–706, 710–718, 722, 724, 726–728
   _jetson_common.py1724573%132, 140, 288–291, 294, 311, 319, 354, 359–364, 382, 408–409, 411–413, 417–420, 422–423, 425–429, 431, 438–439, 442–443, 453, 456–457, 460, 462, 506–507
   _jetson_uefi.py40427432%124–126, 131–132, 151–153, 158–161, 328, 446, 448–451, 455, 459–460, 462–470, 472, 484–485, 488–489, 492–493, 496–498, 502–503, 508–510, 514, 518–519, 522–523, 526–527, 531, 534–535, 537, 542–543, 547, 550–551, 556, 560–561, 564, 568–570, 572, 576–579, 581–582, 604–605, 609–610, 612, 616, 620–621, 624–625, 632, 635–637, 640, 642–643, 648–649, 652–655, 657–658, 663, 665–666, 674, 677–680, 682–683, 685, 689–690, 694, 702–706, 709–710, 712, 715–719, 722, 725–729, 733–734, 737–742, 745–746, 749–752, 754–755, 762–763, 773–776, 779, 782–785, 788–792, 795–796, 799, 802–805, 808, 810, 815–816, 819, 822–825, 827, 833, 838–839, 858–859, 862, 870–871, 878, 888, 891, 898–899, 904–907, 915–918, 926–927, 939–942, 944, 947, 950, 958, 969–971, 973–975, 977–981, 986–987, 989, 1002, 1006, 1009, 1019, 1024, 1032–1033, 1036, 1040, 1042–1044, 1050–1051, 1056, 1064–1069, 1074–1079, 1084–1092, 1097–1104, 1112–1114
   _ota_status_control.py1021189%117, 122, 127, 240, 244–245, 248, 255, 257–258, 273
   _rpi_boot.py28713353%53, 56, 120–121, 125, 133–136, 150–153, 158–159, 161–162, 167–168, 171–172, 181–182, 222, 228–232, 235, 253–255, 259–261, 266–268, 272–274, 284–285, 288, 291, 293–294, 296–297, 299–301, 307, 310–311, 321–324, 332–336, 338, 340–341, 346–347, 354, 357–362, 393, 395–398, 408–411, 415–416, 418–422, 450–453, 472–475, 501–504, 509–517, 522–529, 544–547, 554–557, 565–567
   _slot_mnt_helper.py120100% 
   configs.py510100% 
   protocol.py60100% 
   selecter.py412929%44–46, 49–50, 54–55, 58–60, 63, 65, 69, 77–79, 81–82, 84–85, 89, 91, 93–94, 96, 98–99, 101, 103
src/otaclient/configs
   __init__.py170100% 
   _cfg_configurable.py490100% 
   _cfg_consts.py47197%97
   _common.py80100% 
   _ecu_info.py56394%59, 64–65
   _proxy_info.py51492%85, 87–88, 90
   cfg.py190100% 
src/otaclient/create_standby
   __init__.py13192%36
   common.py2264480%59, 62–63, 67–69, 71, 75–76, 78, 126, 174–176, 178–180, 182, 185–188, 192, 203, 279–280, 282–287, 299, 339, 367, 370–372, 388–389, 403, 407, 429–430
   interface.py70100% 
   rebuild_mode.py1151091%98–100, 119, 150–155
src/otaclient/grpc
   _otaproxy_ctx.py1175156%68–70, 72–73, 81–84, 87–89, 93, 98–99, 101–102, 105, 107–108, 111–113, 116–117, 120–122, 127–132, 136, 139–143, 145–146, 154–156, 159, 196–198, 203, 239
src/otaclient/grpc/api_v2
   ecu_status.py1531093%75, 78, 81, 147, 171, 173, 300, 370–371, 410
   ecu_tracker.py341944%40–42, 48, 52–54, 61–63, 66, 70–74, 77–79
   servicer.py1273671%85, 174–176, 183, 194–195, 236–237, 240–244, 253–262, 269, 275, 278–281, 287–288, 295, 298, 304, 307
   types.py46295%78–79
src/otaclient_api/v2
   api_caller.py39684%45–47, 83–85
   types.py2562391%86, 89–92, 131, 209–210, 212, 259, 262–263, 506–508, 512–513, 515, 518–519, 522–523, 586
src/otaclient_common
   __init__.py341555%42–44, 61, 63, 68–77
   _io.py64198%41
   cmdhelper.py90100% 
   common.py1061189%44, 148, 151–153, 168, 175–177, 271, 275
   downloader.py1991094%107–108, 126, 153, 369, 424, 428, 516–517, 526
   linux.py611575%51–53, 59, 69, 74, 76, 108–109, 133–134, 190, 195–196, 198
   logging.py29196%55
   persist_file_handling.py1181884%113, 118, 150–152, 163, 192–193, 228–232, 242–244, 246–247
   proto_streamer.py42880%33, 48, 66–67, 72, 81–82, 100
   proto_wrapper.py3984688%87, 165, 172, 184–186, 205, 210, 221, 257, 263, 268, 299, 303, 307, 402, 462, 469, 472, 492, 499, 501, 526, 532, 535, 537, 562, 568, 571, 573, 605, 609, 611, 625, 642, 669, 672, 676, 707, 713, 762–763, 765, 803–805
   retry_task_map.py105595%158–159, 161, 181–182
   typing.py31487%48, 97–98, 100
TOTAL6459162374% 

Tests Skipped Failures Errors Time
235 0 💤 0 ❌ 0 🔥 11m 53s ⏱️

Please sign in to comment.