Skip to content

Commit

Permalink
Add Git Bisect Combinator
Browse files Browse the repository at this point in the history
Git bisect combinator can be used to loop the runbook
until the bisect operations suceeds.

The example provided is to bisect failure in kernel tree
  • Loading branch information
adityagesh committed Oct 26, 2023
1 parent 0c8cd24 commit f96d0ce
Show file tree
Hide file tree
Showing 8 changed files with 395 additions and 20 deletions.
25 changes: 25 additions & 0 deletions docs/run_test/runbook.rst
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ Runbook Reference
- `batch combinator <#batch-combinator>`__

- `items <#items-1>`__
- `bisect combinator <#bisect-combinator>`__

- `notifier <#notifier>`__

Expand Down Expand Up @@ -571,6 +572,30 @@ For example,
- image: CentOS
vm_size: Standard_DS3_v2
bisect combinator
^^^^^^^^^^^^^^^^^

Specify a git repo url, the good commit and bad commit. The combinator
performs bisect operations on VM specified under 'connection'.

The runbook will be iterated until the bisect operations completes.

For example,

.. code:: yaml
combinator:
type: git_bisect
repo: $(repo_url)
bad_commit: $(bad_commit)
good_commit: $(good_commit)
connection:
address: $(bisect_vm_address)
private_key_file: $(admin_private_key_file)
Refer `Sample runbook <https://github.com/microsoft/lisa/blob/main/examples/runbook/git_bisect.yml>`__

notifier
~~~~~~~~

Expand Down
191 changes: 191 additions & 0 deletions lisa/combinators/git_bisect_combinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import pathlib
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Type

from dataclasses_json import dataclass_json

from lisa import messages, notifier, schema
from lisa.combinator import Combinator
from lisa.messages import KernelBuildMessage, TestResultMessage, TestStatus
from lisa.node import Node, quick_connect
from lisa.tools.git import Git, GitBisect
from lisa.util import LisaException, constants, field_metadata

STOP_PATTERNS = ["first bad commit", "This means the bug has been fixed between"]


# Combinator requires a node to clone the source code.
@dataclass_json()
@dataclass
class GitBisectCombinatorSchema(schema.Combinator):
connection: Optional[schema.RemoteNode] = field(
default=None, metadata=field_metadata(required=True)
)
repo: str = field(
default="",
metadata=field_metadata(
required=True,
),
)
good_commit: str = field(
default="",
metadata=field_metadata(
required=True,
),
)
bad_commit: str = field(
default="",
metadata=field_metadata(
required=True,
),
)


# GitBisect Combinator is a loop that runs "expanded" phase
# of runbook until the bisect is complete.
# There can be any number of expanded phases, but the
# GitBisectTestResult notifier should have on boolean/None output per
# phase.


class GitBisectCombinator(Combinator):
def __init__(
self,
runbook: GitBisectCombinatorSchema,
**kwargs: Any,
) -> None:
super().__init__(runbook)
self._iteration = 0
self._result_notifier = GitBisectResult(schema.Notifier())
notifier.register_notifier(self._result_notifier)
self._source_path: pathlib.PurePath
self._node: Optional[Node] = None

def _initialize(self, *args: Any, **kwargs: Any) -> None:
self._clone_source()
if self._source_path:
self._start_bisect()
else:
raise LisaException(
"Source path is not set. Please check the source clone."
)

@classmethod
def type_name(cls) -> str:
return constants.COMBINATOR_GITBISECT

@classmethod
def type_schema(cls) -> Type[schema.TypedSchema]:
return GitBisectCombinatorSchema

def _next(self) -> Optional[Dict[str, Any]]:
_next: Optional[Dict[str, Any]] = None
self._process_result()
if not self._check_bisect_complete():
_next = {}
_next["ref"] = self._get_current_commit_hash()
else:
self._log.info("Bisect Complete")
self._result_notifier.result = None
self._iteration += 1
return _next

def _process_result(self) -> None:
if self._iteration == 0:
return
if self._result_notifier.result is not None:
results = self._result_notifier.result
if results:
self._bisect_good()
else:
self._bisect_bad()
else:
raise LisaException(
"Bisect combinator does not get result for next iteration. Please check"
" GitBisectResult notifier."
)

def _get_remote_node(self) -> Node:
if not self._node or not self._node.is_connected:
self._node = quick_connect(self.runbook.connection, "source_node")
return self._node

def _clone_source(self) -> None:
node = self._get_remote_node()
git = node.tools[Git]
self._source_path = git.clone(
url=self.runbook.repo, cwd=node.working_path, timeout=1200
)
node.close()

def _start_bisect(self) -> None:
node = self._get_remote_node()
git_bisect = node.tools[GitBisect]
git_bisect.start(cwd=self._source_path)
git_bisect.good(cwd=self._source_path, ref=self.runbook.good_commit)
git_bisect.bad(cwd=self._source_path, ref=self.runbook.bad_commit)
node.close()

def _bisect_bad(self) -> None:
node = self._get_remote_node()
git_bisect = node.tools[GitBisect]
git_bisect.bad(cwd=self._source_path)
node.close()

def _bisect_good(self) -> None:
node = self._get_remote_node()
git_bisect = node.tools[GitBisect]
git_bisect.good(cwd=self._source_path)
node.close()

def _check_bisect_complete(self) -> bool:
node = self._get_remote_node()
git_bisect = node.tools[GitBisect]
result = git_bisect.check_bisect_complete(cwd=self._source_path)
node.close()
return result

def _get_current_commit_hash(self) -> str:
node = self._get_remote_node()
git = node.tools[Git]
result = git.get_current_commit_hash(cwd=self._source_path)
node.close()
return result


class GitBisectResult(notifier.Notifier):
@classmethod
def type_name(cls) -> str:
return "git_bisect_result"

@classmethod
def type_schema(cls) -> Type[schema.TypedSchema]:
return schema.Notifier

def _initialize(self, *args: Any, **kwargs: Any) -> None:
self.result: Optional[bool] = None

def _received_message(self, message: messages.MessageBase) -> None:
if isinstance(message, messages.TestResultMessage):
self._update_test_result(message)
elif isinstance(message, messages.KernelBuildMessage):
self._update_result(message.is_success)
else:
raise LisaException(f"Received unsubscribed message type: {type(message)}")

def _update_test_result(self, message: messages.TestResultMessage) -> None:
if message.is_completed:
if message.status == TestStatus.FAILED:
self._update_result(False)
elif message.status == TestStatus.PASSED:
self._update_result(True)

def _update_result(self, result: bool) -> None:
current_result = self.result
if current_result is not None:
self.result = current_result and result
else:
self.result = result

def _subscribed_message_type(self) -> List[Type[messages.MessageBase]]:
return [TestResultMessage, KernelBuildMessage]
9 changes: 9 additions & 0 deletions lisa/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,15 @@ class ProvisionBootTimeMessage(MessageBase):
information: Dict[str, str] = field(default_factory=dict)


@dataclass
class KernelBuildMessage(MessageBase):
type: str = "KernelBuild"
old_kernel_version: str = ""
new_kernel_version: str = ""
is_success: bool = False
error_message: str = ""


def _is_completed_status(status: TestStatus) -> bool:
return status in [
TestStatus.FAILED,
Expand Down
1 change: 1 addition & 0 deletions lisa/mixin_modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import lisa.combinators.batch_combinator # noqa: F401
import lisa.combinators.csv_combinator # noqa: F401
import lisa.combinators.git_bisect_combinator # noqa: F401
import lisa.combinators.grid_combinator # noqa: F401
import lisa.notifiers.console # noqa: F401
import lisa.notifiers.env_stats # noqa: F401
Expand Down
92 changes: 72 additions & 20 deletions lisa/transformers/kernel_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Type, cast

from assertpy import assert_that
from assertpy.assertpy import assert_that
from dataclasses_json import dataclass_json

from lisa import schema
from lisa import notifier, schema
from lisa.messages import KernelBuildMessage
from lisa.node import Node, quick_connect
from lisa.operating_system import Posix, Ubuntu
from lisa.secret import PATTERN_HEADTAIL, add_secret
Expand Down Expand Up @@ -73,6 +74,7 @@ class KernelInstallerTransformerSchema(schema.Transformer):
installer: Optional[BaseInstallerSchema] = field(
default=None, metadata=field_metadata(required=True)
)
raise_exception: Optional[bool] = True


class BaseInstaller(subclasses.BaseClassWithRunbookMixin):
Expand Down Expand Up @@ -101,6 +103,8 @@ def install(self) -> str:

class KernelInstallerTransformer(Transformer):
_information_output_name = "information"
_is_success_output_name = "is_success"

_information: Dict[str, Any] = dict()

@classmethod
Expand All @@ -120,6 +124,10 @@ def _internal_run(self) -> Dict[str, Any]:
assert runbook.connection, "connection must be defined."
assert runbook.installer, "installer must be defined."

message = KernelBuildMessage()
build_sucess: bool = False
boot_success: bool = False

node = quick_connect(runbook.connection, "installer_node")

uname = node.tools[Uname]
Expand All @@ -133,24 +141,68 @@ def _internal_run(self) -> Dict[str, Any]:
)

installer.validate()
installed_kernel_version = installer.install()
self._information = installer.information
self._log.info(f"installed kernel version: {installed_kernel_version}")

# for ubuntu cvm kernel, there is no menuentry added into grub file
if hasattr(installer.runbook, "source"):
if installer.runbook.source != "linux-image-azure-fde":
posix = cast(Posix, node.os)
posix.replace_boot_kernel(installed_kernel_version)

self._log.info("rebooting")
node.reboot()
kernel_version_after_install = uname.get_linux_information(force_run=True)
self._log.info(f"kernel version after install: {kernel_version_after_install}")
assert_that(
kernel_version_after_install, "Kernel installation Failed"
).is_not_equal_to(kernel_version_before_install)
return {self._information_output_name: self._information}

try:
message.old_kernel_version = uname.get_linux_information(
force_run=True
).kernel_version_raw

installed_kernel_version = installer.install()
build_sucess = True
self._information = installer.information
self._log.info(f"installed kernel version: {installed_kernel_version}")

# for ubuntu cvm kernel, there is no menuentry added into grub file
if hasattr(installer.runbook, "source"):
if installer.runbook.source != "linux-image-azure-fde":
posix = cast(Posix, node.os)
posix.replace_boot_kernel(installed_kernel_version)
else:
efi_files = node.execute(
"ls -t /usr/lib/linux/efi/kernel.efi-*-azure-cvm",
sudo=True,
shell=True,
expected_exit_code=0,
expected_exit_code_failure_message=(
"fail to find kernel.efi file for kernel type "
" linux-image-azure-fde"
),
)
efi_file = efi_files.stdout.splitlines()[0]
node.execute(
(
"cp /boot/efi/EFI/ubuntu/grubx64.efi "
"/boot/efi/EFI/ubuntu/grubx64.efi.bak"
),
sudo=True,
)
node.execute(
f"cp {efi_file} /boot/efi/EFI/ubuntu/grubx64.efi",
sudo=True,
shell=True,
)

self._log.info("rebooting")
node.reboot()
boot_success = True
new_kernel_version = uname.get_linux_information(force_run=True)
message.new_kernel_version = new_kernel_version.kernel_version_raw
self._log.info(f"kernel version after install: " f"{new_kernel_version}")
assert_that(
new_kernel_version.kernel_version_raw, "Kernel installation Failed"
).is_not_equal_to(kernel_version_before_install.kernel_version_raw)
except Exception as e:
message.error_message = str(e)
if runbook.raise_exception:
raise e
self._log.info(f"Kernel build failed: {e}")
finally:
message.is_success = build_sucess and boot_success
notifier.notify(message)
return {
self._information_output_name: self._information,
self._is_success_output_name: build_sucess and boot_success,
}


class RepoInstaller(BaseInstaller):
Expand Down
1 change: 1 addition & 0 deletions lisa/util/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
COMBINATOR = "combinator"
COMBINATOR_GRID = "grid"
COMBINATOR_BATCH = "batch"
COMBINATOR_GITBISECT = "git_bisect"

ENVIRONMENT = "environment"
ENVIRONMENTS = "environments"
Expand Down
Loading

0 comments on commit f96d0ce

Please sign in to comment.