| #!/usr/bin/env python3 | |
| # | |
| # Licensed to the Apache Software Foundation (ASF) under one | |
| # or more contributor license agreements. See the NOTICE file | |
| # distributed with this work for additional information | |
| # regarding copyright ownership. The ASF licenses this file | |
| # to you under the Apache License, Version 2.0 (the | |
| # "License"); you may not use this file except in compliance | |
| # with the License. You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, | |
| # software distributed under the License is distributed on an | |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| # KIND, either express or implied. See the License for the | |
| # specific language governing permissions and limitations | |
| # under the License. | |
| import ast | |
| import logging | |
| import re | |
| from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError | |
| import docker | |
| import subprocess | |
| import time | |
| import xml.etree.ElementTree as ET | |
| from .logger_config import get_logger | |
| logger = get_logger(__name__) | |
| class DockerExecCommandError(Exception): | |
| pass | |
| class HBaseShellCommandError(DockerExecCommandError): | |
| pass | |
| class DockerExecCommandTimeoutError(DockerExecCommandError): | |
| pass | |
| class HBaseInitializationError(Exception): | |
| pass | |
| class HBaseDockerClient: | |
| def __init__(self, container_name: str, local_conf: str, hbase_ui_port: int = 16010, | |
| cluster_name: str = "HBase Cluster", max_retries: int = 12, sleep_time: int = 5) -> None: | |
| self._container_name = container_name | |
| self._local_conf = local_conf | |
| self._hbase_ui_port = hbase_ui_port | |
| self._cluster_name = cluster_name | |
| self._max_retries = max_retries | |
| self._sleep_time = sleep_time | |
| self._docker_client = docker.from_env() | |
| @property | |
| def name(self) -> str: | |
| return self._cluster_name | |
| def run_docker_exec_command(self, bash_cmd: str, timeout: int | None = None) -> str: | |
| """ | |
| Uses the Docker SDK to exec a Bash command in the object's Docker container. | |
| Equivalent to: docker exec <container> bash -c <bash_cmd> | |
| """ | |
| cmd = ["bash", "-c", bash_cmd] | |
| cmd_str = f"docker exec {self._container_name} bash -c {bash_cmd}" | |
| logger.debug(f"Running command on {self._cluster_name}: {cmd_str}") | |
| try: | |
| container = self._docker_client.containers.get(self._container_name) | |
| if timeout is not None: | |
| pool = ThreadPoolExecutor(max_workers=1) | |
| future = pool.submit(container.exec_run, cmd, demux=True) | |
| try: | |
| result = future.result(timeout=timeout) | |
| except FuturesTimeoutError: | |
| raise DockerExecCommandTimeoutError( | |
| f"Command timed out after {timeout}s on {self._cluster_name} " | |
| f"({self._container_name}): {bash_cmd}\n" | |
| f"The command used to run this was: {cmd_str}\n" | |
| ) | |
| finally: | |
| pool.shutdown(wait=False, cancel_futures=True) | |
| else: | |
| result = container.exec_run(cmd, demux=True) | |
| except docker.errors.DockerException as e: | |
| raise DockerExecCommandError( | |
| f"The following command failed on {self._cluster_name} ({self._container_name}): {bash_cmd}\n" | |
| f"The command used to run this was: {cmd_str}\n" | |
| f"Docker error: {e}\n" | |
| ) | |
| exit_code, (stdout, stderr) = result | |
| stdout_str = (stdout or b'').decode('utf-8') | |
| if exit_code != 0: | |
| raise DockerExecCommandError( | |
| f"The following command failed on {self._cluster_name} ({self._container_name}): {bash_cmd}\n" | |
| f"The command used to run this was: {cmd_str}\n" | |
| f"The command's STDERR was:\n{(stderr or b'').decode('utf-8')}\n" | |
| f"The command's STDOUT was:\n{stdout_str}\n" | |
| ) | |
| return stdout_str | |
| def run_hbase_shell_command(self, hbase_cmd: str, timeout: int | None = None) -> str: | |
| """ | |
| Uses 'docker exec' to run the provided HBase shell command in the object's Docker container. | |
| The command looks like: docker exec <container> bash -c hbase shell -n <<< "<hbase_cmd>" | |
| """ | |
| hbase_shell_cmd = f'''hbase shell -n <<< "{hbase_cmd}"''' | |
| try: | |
| return self.run_docker_exec_command(hbase_shell_cmd, timeout=timeout) | |
| except DockerExecCommandTimeoutError: | |
| # DockerExecCommandTimeoutError is a subclass of DockerExecCommandError, so we need to make sure | |
| # it's specifically caught and re-raised. Otherwise, it's swallowed when catching DockerExecCommandError | |
| raise | |
| except DockerExecCommandError as e: | |
| raise HBaseShellCommandError(e) | |
| def _get_pid_from_jps(self, process_name: str) -> int | None: | |
| """Runs jps inside the container and returns the PID of the named process, or None.""" | |
| try: | |
| output = self.run_docker_exec_command("jps") | |
| for line in output.strip().splitlines(): | |
| parts = line.split() | |
| if len(parts) == 2 and parts[1] == process_name: | |
| return int(parts[0]) | |
| except DockerExecCommandError: | |
| pass | |
| return None | |
| def wait_for_hbase_ui(self) -> bool: | |
| """ | |
| Checks for a 200 OK on the HBase Master UI inside the container using curl. | |
| """ | |
| check_cmd = f"curl -s -f --max-time 3 http://{self._container_name}:{self._hbase_ui_port} > /dev/null" | |
| logger.info(f"Waiting for HBase UI inside container: {self._cluster_name} ({self._container_name})") | |
| last_error = None | |
| for attempt in range(1, self._max_retries + 1): | |
| try: | |
| self.run_docker_exec_command(check_cmd, timeout=self._sleep_time) | |
| logger.info(f"SUCCESS: {self._cluster_name} HBase UI is up.") | |
| return True | |
| except DockerExecCommandError as e: | |
| last_error = e | |
| logger.info(f"Waiting {self._sleep_time} seconds before checking HBase UI inside container again") | |
| time.sleep(self._sleep_time) | |
| raise HBaseInitializationError( | |
| f"\nTIMEOUT: {self._cluster_name} UI failed to respond inside container after " | |
| f"{self._max_retries} attempts.\nLast error: {last_error}" | |
| ) | |
| def wait_for_master_initialization(self) -> bool: | |
| """Waits for the current HMaster process to log 'Master has completed initialization'.""" | |
| logger.info(f"Waiting for Master initialization: {self._cluster_name} ({self._container_name})") | |
| for attempt in range(1, self._max_retries + 1): | |
| pid = self._get_pid_from_jps("HMaster") | |
| if pid is not None: | |
| awk_cmd = ( | |
| f"awk '/env:JVM_PID={pid}/{{seen=1; found=0}} " | |
| f"seen && /Master has completed initialization/{{found=1}} " | |
| f"END{{exit !found}}' /opt/hbase/logs/hbase-*-master-*.log" | |
| ) | |
| try: | |
| self.run_docker_exec_command(awk_cmd) | |
| logger.info(f"SUCCESS: {self._cluster_name} Master has completed initialization.") | |
| return True | |
| except DockerExecCommandError: | |
| pass | |
| logging.info(f"Waiting {self._sleep_time} seconds before checking Master initialization again") | |
| time.sleep(self._sleep_time) | |
| raise HBaseInitializationError( | |
| f"\nTIMEOUT: {self._cluster_name} Master failed to initialize after " | |
| f"{self._max_retries} attempts." | |
| ) | |
| def wait_for_region_server_initialization(self) -> bool: | |
| """Waits for the current HRegionServer process to log 'Serving as' message.""" | |
| logger.info(f"Waiting for RegionServer initialization: {self._cluster_name} ({self._container_name})") | |
| for attempt in range(1, self._max_retries + 1): | |
| pid = self._get_pid_from_jps("HRegionServer") | |
| if pid is not None: | |
| awk_cmd = ( | |
| f"awk '/env:JVM_PID={pid}/{{seen=1; found=0}} " | |
| f"seen && /Serving as {self._container_name},/{{found=1}} " | |
| f"END{{exit !found}}' /opt/hbase/logs/hbase-*-regionserver-*.log" | |
| ) | |
| try: | |
| self.run_docker_exec_command(awk_cmd) | |
| logger.info(f"SUCCESS: {self._cluster_name} RegionServer is serving.") | |
| return True | |
| except DockerExecCommandError: | |
| pass | |
| logging.info(f"Waiting {self._sleep_time} seconds before checking RegionServer initialization again") | |
| time.sleep(self._sleep_time) | |
| raise HBaseInitializationError( | |
| f"\nTIMEOUT: {self._cluster_name} RegionServer failed to initialize after " | |
| f"{self._max_retries} attempts." | |
| ) | |
| def check_server_status(self, desired_status: dict | None = None) -> bool: | |
| """Runs 'status' inside the HBase shell and validates the output.""" | |
| if desired_status is None: | |
| desired_status = {'masters': '1', 'region_servers': '1', 'dead_servers': '0'} | |
| logger.info(f"Validating Cluster Status: {self._cluster_name} ({self._container_name})") | |
| for attempt in range(1, self._max_retries + 1): | |
| try: | |
| output = self.get_hbase_status() | |
| # The cluster's status should have 1 active master, 1 region server, | |
| # and no dead servers | |
| validations = { | |
| "Active Master": f"{desired_status['masters']} active master" in output, | |
| "Region Server": f"{desired_status['region_servers']} servers" in output, | |
| "No Dead Servers": f"{desired_status['dead_servers']} dead" in output | |
| } | |
| if all(validations.values()): | |
| for check, status in validations.items(): | |
| logger.info(f" [PASS] {check}") | |
| logger.info(f"SUCCESS: {self._cluster_name} is fully operational.") | |
| return True | |
| else: | |
| logger.warning(f"{self._cluster_name} is responding, but not all " | |
| f"components are ready...") | |
| logger.info(f"HBase 'status' command output:\n{output}") | |
| except HBaseShellCommandError: | |
| pass | |
| logging.info(f"Waiting {self._sleep_time} seconds before getting status on {self.name} again") | |
| time.sleep(self._sleep_time) | |
| raise HBaseInitializationError( | |
| f"\nTIMEOUT: {self._cluster_name} shell check failed after {self._max_retries} attempts." | |
| ) | |
| def get_hbase_status(self) -> str: | |
| logger.debug(f"Getting status of {self.name}") | |
| return self.run_hbase_shell_command("status") | |
| def wait_for_cluster_to_start(self) -> None: | |
| """curls the cluster's HBase UI to make sure it is up and then makes sure all desired servers are up""" | |
| self.wait_for_hbase_ui() | |
| self.wait_for_master_initialization() | |
| self.wait_for_region_server_initialization() | |
| self.check_server_status() | |
| def create_table(self, table_name: str, column_family: str) -> bool: | |
| logger.info(f"Creating table '{table_name}' on {self._cluster_name}") | |
| create_cmd = f"create '{table_name}', '{column_family}'" | |
| output = self.run_hbase_shell_command(create_cmd) | |
| if f"Created table {table_name}" not in output: | |
| logger.error(f"Could not create table '{table_name}' on {self._cluster_name}") | |
| return False | |
| return True | |
| def disable_table(self, table_name: str) -> None: | |
| logger.debug(f"Disabling table '{table_name}' on {self.name}") | |
| self.run_hbase_shell_command(f"disable '{table_name}'") | |
| def drop_table(self, table_name: str) -> None: | |
| logger.info(f"Dropping table '{table_name}' on {self.name}") | |
| self.run_hbase_shell_command(f"drop '{table_name}'") | |
| def list_tables(self) -> list: | |
| """Gets the list of HBase tables and returns it as a Python list""" | |
| logger.debug(f"Getting the list of tables in HBase on {self.name}") | |
| pattern = r'\[(.*?)\]' | |
| output = self.run_hbase_shell_command("list") | |
| output = output.replace('\n', ' ') | |
| match = re.search(pattern, output) | |
| return ast.literal_eval(match.group(0)) | |
| def list_regions(self, table_name: str) -> str: | |
| """Gets list of regions and their info for the provided table""" | |
| logger.info(f"Getting list of regions for table '{table_name}'") | |
| return self.run_hbase_shell_command(f"list_regions '{table_name}'") | |
| def put(self, table_name: str, row: str, column: str, data: str, spec_map: str | None = None) -> None: | |
| """ | |
| Performs an HBase put command. | |
| :param table_name: the table we are inserting data into | |
| :param row: row of the table we are inserting data into | |
| :param column: column of the table we are inserting data into | |
| :param data: the actual data we are inserting (as a string) | |
| :param spec_map: additional attributes input as a string | |
| (e.g. "{ATTRIBUTES=>{'my-key'=>'my-value'}}") | |
| """ | |
| logger.info(f"Adding data to table '{table_name}' on {self.name}") | |
| put_cmd = f"put '{table_name}', '{row}', '{column}', '{data}'" | |
| if spec_map: | |
| put_cmd += f", {spec_map}" | |
| self.run_hbase_shell_command(put_cmd) | |
| def get(self, table_name: str, row: str, column: str | None = None, spec_map: str | None = None) -> str: | |
| logger.info(f"Getting data from table '{table_name}' on {self.name}") | |
| get_cmd = f"get '{table_name}', '{row}'" | |
| if column: | |
| get_cmd += f", '{column}'" | |
| if spec_map: | |
| get_cmd += f", {spec_map}" | |
| output = self.run_hbase_shell_command(get_cmd) | |
| logger.debug(f"Got data:\n{output}") | |
| return output | |
| def delete(self, table_name: str, row: str, column: str, timestamp: int | None = None, | |
| spec_map: str | None = None) -> None: | |
| logger.info(f"Deleting data from table '{table_name}' on {self.name}") | |
| delete_cmd = f"delete '{table_name}', '{row}', '{column}'" | |
| if timestamp: | |
| delete_cmd += f", {timestamp}" | |
| if spec_map: | |
| delete_cmd += f", {spec_map}" | |
| self.run_hbase_shell_command(delete_cmd) | |
| def scan(self, table_name: str, spec_map: str | None = None) -> str: | |
| log_msg = f"Scanning table '{table_name}' on {self.name}" | |
| scan_cmd = f"scan '{table_name}'" | |
| if spec_map: | |
| scan_cmd += f", {spec_map}" | |
| log_msg += f" with spec_map {spec_map}" | |
| logging.info(log_msg) | |
| return self.run_hbase_shell_command(scan_cmd) | |
| def count(self, table_name: str, spec: str | None = None) -> str: | |
| logger.info(f"Counting rows for table '{table_name}' on {self.name}") | |
| count_cmd = f"count '{table_name}'" | |
| if spec: | |
| count_cmd += f"{spec}" | |
| return self.run_hbase_shell_command(count_cmd) | |
| def flush(self, table_name: str, timeout: int | None = None) -> None: | |
| logger.debug(f"Flushing table '{table_name}' on {self.name}") | |
| self.run_hbase_shell_command(f"flush '{table_name}'", timeout=timeout) | |
| def split(self, thing_to_split: str, split_key: str | None = None) -> None: | |
| log_msg = f"Splitting '{thing_to_split}'" | |
| split_cmd = f"split '{thing_to_split}'" | |
| if split_key: | |
| log_msg += f" on key '{split_key}'" | |
| split_cmd += f", '{split_key}'" | |
| log_msg += f" on {self.name}" | |
| logger.info(log_msg) | |
| self.run_hbase_shell_command(split_cmd) | |
| def flush_and_split(self, thing_to_split: str, split_key: str | None = None) -> None: | |
| """ | |
| Flushes the table and triggers an asynchronous region split. Split the entire table or pass a region to split an | |
| individual region. With the second parameter, you can specify an explicit split key for the region. | |
| thing_to_split - TABLENAME, REGIONNAME, or ENCODED_REGIONNAME | |
| split_key - where to have the region split | |
| """ | |
| self.flush(thing_to_split) | |
| self.split(thing_to_split, split_key) | |
| def major_compact(self, table_or_region: str, column_family: str | None = None, mob: str | None = None) -> None: | |
| log_msg = f"Running major_compact on '{table_or_region}'" | |
| command = f"major_compact '{table_or_region}'" | |
| if column_family: | |
| log_msg += f" for column family '{column_family}'" | |
| command += f", '{column_family}'" | |
| if mob and column_family: | |
| log_msg += " with MOB" | |
| command += f", 'MOB'" | |
| elif mob and not column_family: | |
| log_msg += " with MOB" | |
| command += ", nil, 'MOB'" | |
| self.run_hbase_shell_command(command) | |
| def major_compact_and_wait(self, table_or_region: str, column_family: str | None = None, mob: str | None = None, | |
| timeout: int = 30, sleep_time: int = 1) -> bool: | |
| """Triggers major compaction on a table and blocks until it completes.""" | |
| logger.info(f"Triggering major compaction on '{table_or_region}' on {self.name}...") | |
| self.major_compact(table_or_region, column_family, mob) | |
| start_time = time.time() | |
| while time.time() - start_time < timeout: | |
| output = self.run_hbase_shell_command(f"compaction_state '{table_or_region}'") | |
| # When all regions finish compacting, compaction_state returns NONE | |
| if "NONE" in output: | |
| logger.info(f"SUCCESS: Major compaction completed for '{table_or_region}'.") | |
| return True | |
| logger.debug(f"Compaction still in progress for '{table_or_region}'... waiting {sleep_time}s") | |
| time.sleep(sleep_time) | |
| raise RuntimeError( | |
| f"TIMEOUT: Major compaction on table '{table_or_region}' failed to complete within {timeout} seconds." | |
| ) | |
| def catalogjanitor_run(self) -> None: | |
| """Forces the CatalogJanitor to immediately clean up split parent regions in hbase:meta.""" | |
| logger.info(f"Running catalogjanitor_run on {self.name}") | |
| self.run_hbase_shell_command("catalogjanitor_run") | |
| def refresh_meta(self) -> None: | |
| logger.info(f"Refreshing meta on {self.name}") | |
| self.run_hbase_shell_command("refresh_meta") | |
| def refresh_hfiles(self) -> None: | |
| logger.info(f"Refreshing HFiles on {self.name}") | |
| self.run_hbase_shell_command("refresh_hfiles") | |
| def refresh_meta_and_hfiles(self) -> None: | |
| """Consecutively runs refresh_meta and refresh_hfiles in the HBase shell""" | |
| self.refresh_meta() | |
| self.refresh_hfiles() | |
| def enable_read_only_mode(self, run_update_all_config: bool = True) -> None: | |
| """ | |
| Sets hbase.global.readonly.enabled to 'true' in the local hbase-site.xml file and runs update_all_config | |
| to dynamically update the configuration. This method assumes the hbase-site.xml file is a mounted volume | |
| in the docker-compose file, which allows the config file within the docker container to be updated as well. | |
| """ | |
| self._set_read_only_mode(new_read_only_flag=True, run_update_all_config=run_update_all_config) | |
| def disable_read_only_mode(self, run_update_all_config: bool = True) -> None: | |
| """ | |
| Sets hbase.global.readonly.enabled to 'false' in the local hbase-site.xml file and runs update_all_config | |
| to dynamically update the configuration. This method assumes the hbase-site.xml file is a mounted volume | |
| in the docker-compose file, which allows the config file within the docker container to be updated as well. | |
| """ | |
| self._set_read_only_mode(new_read_only_flag=False, run_update_all_config=run_update_all_config) | |
| def _set_read_only_mode(self, new_read_only_flag: bool, run_update_all_config: bool = True) -> None: | |
| action = "Enabling" if new_read_only_flag else "Disabling" | |
| conjunction_adverb = "and then" if run_update_all_config else "but not" | |
| logger.info(f"{action} read-only mode in conf for {self.name} " | |
| f"{conjunction_adverb} running update_all_config after") | |
| new_read_only_flag = str(new_read_only_flag).lower() | |
| self.set_hbase_conf_property_value('hbase.global.readonly.enabled', new_read_only_flag) | |
| actual = self.get_hbase_conf_property_value('hbase.global.readonly.enabled') | |
| assert actual == new_read_only_flag, ( | |
| f"Expected hbase.global.readonly.enabled={new_read_only_flag} on {self.name}, but got '{actual}'" | |
| ) | |
| if run_update_all_config: | |
| self.update_all_config() | |
| def update_all_config(self) -> None: | |
| logger.debug(f"Running update_all_config on {self.name} to dynamically update the configuration") | |
| self.run_hbase_shell_command("update_all_config") | |
| def get_hbase_conf_property_value(self, conf_prop: str) -> str | None: | |
| tree = ET.parse(self._local_conf) | |
| root = tree.getroot() | |
| for prop in root.findall('property'): | |
| name_elem = prop.find('name') | |
| if name_elem is not None and name_elem.text == conf_prop: | |
| return prop.find('value').text | |
| def set_hbase_conf_property_value(self, conf_prop: str, value: str) -> None: | |
| """Sets hbase.global.readonly.enabled to a new value in a local hbase-site.xml file""" | |
| tree = ET.parse(self._local_conf) | |
| root = tree.getroot() | |
| for prop in root.findall('property'): | |
| name_elem = prop.find('name') | |
| if name_elem is not None and name_elem.text == conf_prop: | |
| value_elem = prop.find('value') | |
| if value_elem is not None: | |
| value_elem.text = str(value) | |
| break | |
| tree.write(self._local_conf, encoding='utf-8', xml_declaration=True) | |
| # The conf file is a Docker volume - wait for the updated version to sync | |
| time.sleep(1) | |
| def assert_read_only_error_occurs(self, cmd_type: str, table_name: str, column: str, | |
| row: str | None = None, data: str | None = None) -> None: | |
| """ | |
| Runs a command on read-only cluster and expects an error to occur as a result. | |
| """ | |
| logger.info(f"Verifying we cannot perform a '{cmd_type}' on {self.name} " | |
| f"since it is in read-only mode") | |
| try: | |
| # This should throw an exception | |
| match cmd_type.lower(): | |
| case 'create': | |
| self.create_table(table_name, column) | |
| case 'drop': | |
| self.drop_table(table_name) | |
| case 'put': | |
| self.put(table_name, row, column, data) | |
| case 'delete': | |
| self.delete(table_name, row, column) | |
| case _: | |
| raise RuntimeError(f"Unexpected command type: {cmd_type}") | |
| # If we get here, then the command succeeded on the read-replica cluster, which should | |
| # not have happened. | |
| raise RuntimeError(f"Expected {cmd_type} attempt on {self.name} " | |
| f"to result in an error") | |
| except HBaseShellCommandError as e: | |
| # Verify the command we ran on the read-replica cluster produced the expected exception | |
| expected_error = ("org.apache.hadoop.hbase.WriteAttemptedOnReadOnlyClusterException: " | |
| "Operation not allowed in Read-Only Mode") | |
| assert expected_error in str(e), (f"Expected exception to contain the following: " | |
| f"{expected_error}\n" | |
| f"The actual exception was:\n{e}") | |
| logger.info(f"{cmd_type.capitalize()} attempt on {self.name} failed as expected") | |
| def assert_table_does_not_exist(self, table_name: str) -> None: | |
| logger.info(f"Verifying '{table_name}' is not in the list of tables on {self.name}") | |
| assert table_name not in self.list_tables(), \ | |
| f"Expected table '{table_name}' to not exist on {self.name}" | |
| def assert_table_exists(self, table_name: str) -> None: | |
| logger.info(f"Verifying '{table_name}' is in the list of tables on {self.name}") | |
| assert table_name in self.list_tables(), \ | |
| f"Expected table '{table_name}' to exist on {self.name}" | |
| def assert_table_row_count(self, table_name: str, expected_row_count: int) -> None: | |
| logger.info(f"Verifying table '{table_name}' on {self.name} has {expected_row_count} row(s)") | |
| output = self.count(table_name) | |
| match = re.search(r'^(\d+) row\(s\)$', output, re.MULTILINE) | |
| actual_row_count = int(match.group(1)) if match else None | |
| assert actual_row_count == expected_row_count, \ | |
| (f"Expected table '{table_name}' on {self.name} to have {expected_row_count} row(s). " | |
| f"Instead got {actual_row_count}") | |
| def assert_get_output(self, table: str, row: str, cf: str, expected_data: str) -> str: | |
| output = self.get(table, row, cf) | |
| assert f"value={expected_data}" in output, \ | |
| f"Expected get command to retrieve a row with value={expected_data}. Output instead was:\n{output}" | |
| return output | |
| def assert_region_count_for_table(self, table_name: str, expected_region_count: int) -> None: | |
| logger.info(f"Verifying table '{table_name}' has {expected_region_count} region(s)") | |
| output = self.list_regions(table_name) | |
| match = re.search(r'^ (\d+) rows$', output, re.MULTILINE) | |
| actual_region_count = int(match.group(1)) if match else None | |
| assert actual_region_count == expected_region_count, \ | |
| (f"Expected table '{table_name}' on {self.name} to have {expected_region_count} region(s). " | |
| f"Instead got {actual_region_count}") | |
| @staticmethod | |
| def __run_subprocess_command(command: list | str, error_msg: str, | |
| shell: bool = False) -> subprocess.CompletedProcess: | |
| if shell: | |
| cmd_msg = command | |
| else: | |
| cmd_msg = f"{' '.join(command)}" | |
| logger.info(f"Running: {cmd_msg}") | |
| result = subprocess.run(command, capture_output=True, text=True, shell=shell) | |
| if result.returncode != 0: | |
| raise RuntimeError( | |
| f"Command failed: {cmd_msg}\n" | |
| f"{error_msg} (exit {result.returncode}):\n" | |
| f"STDOUT: {result.stdout}\nSTDERR: {result.stderr}" | |
| ) | |
| return result | |
| @staticmethod | |
| def wait_for_clusters_to_start(clusters: list) -> None: | |
| for cluster in clusters: | |
| cluster.wait_for_cluster_to_start() | |
| logger.info("=" * 40) | |
| logger.info("ALL CLUSTERS VERIFIED AND READY") | |
| logger.info("=" * 40) | |
| @staticmethod | |
| def are_containers_running(docker_compose_file: str | None = None) -> bool: | |
| logger.info("Checking if docker containers are running") | |
| command = ["docker", "compose"] | |
| if docker_compose_file: | |
| command += ["-f", docker_compose_file] | |
| command += ["ps", "--status", "running", "-q"] | |
| result = HBaseDockerClient.__run_subprocess_command(command, "Failed to get docker container status") | |
| return bool(result.stdout.strip()) | |
| @staticmethod | |
| def set_up_data_store_dir(data_store_root: str) -> None: | |
| command = ["mkdir", "-p", f"{data_store_root}/data-store/hbase", f"{data_store_root}/data-store/run", | |
| f"{data_store_root}/data-store/logs", f"{data_store_root}/data-store/zk"] | |
| HBaseDockerClient.__run_subprocess_command(command, | |
| error_msg=f"Failed to create {data_store_root} and its sub-dirs") | |
| command = ["chmod", "-R", "777", f"{data_store_root}"] | |
| HBaseDockerClient.__run_subprocess_command(command, | |
| error_msg=f"Failed to give {data_store_root} " | |
| f"and its sub-dirs full permissions") | |
| @staticmethod | |
| def remove_data_store_dir(data_store_root: str, sudo: bool = False) -> None: | |
| command = ["rm", "-rf", data_store_root] | |
| if sudo: | |
| command = ["sudo"] + command | |
| logger.info(f"Deleting HBase data root dir at: {data_store_root}") | |
| HBaseDockerClient.__run_subprocess_command(command, | |
| f"Could not delete data store root: {data_store_root}") | |
| @staticmethod | |
| def start_or_restart_containers(docker_compose_file: str | None = None, data_store_root: str | None = None) -> None: | |
| if data_store_root: | |
| HBaseDockerClient.set_up_data_store_dir(data_store_root) | |
| if HBaseDockerClient.are_containers_running(docker_compose_file): | |
| logger.info("Restarting docker containers") | |
| command = ["docker", "compose"] | |
| if docker_compose_file: | |
| command += ["-f", docker_compose_file] | |
| command += ["restart"] | |
| action = "restart" | |
| else: | |
| logger.info("Starting docker containers") | |
| command = ["docker", "compose"] | |
| if docker_compose_file: | |
| command += ["-f", docker_compose_file] | |
| command += ["up", "-d"] | |
| action = "start" | |
| HBaseDockerClient.__run_subprocess_command(command, f"docker compose {action} failed") | |
| logger.info(f"docker compose {action} completed successfully") | |
| @staticmethod | |
| def start_service(service_name: str, docker_compose_file: str | None = None) -> None: | |
| logger.info(f"Starting docker compose service: {service_name}") | |
| command = ["docker", "compose"] | |
| if docker_compose_file: | |
| command += ["-f", docker_compose_file] | |
| command += ["up", "-d", service_name] | |
| HBaseDockerClient.__run_subprocess_command(command, f"Failed to start service '{service_name}'") | |
| @staticmethod | |
| def stop_containers(docker_compose_file: str | None = None, data_store_root: str | None = None, | |
| sudo: bool = False) -> None: | |
| command = ["docker", "compose"] | |
| if docker_compose_file: | |
| command += ["-f", docker_compose_file] | |
| command += ["down"] | |
| logger.info("Stopping docker containers") | |
| HBaseDockerClient.__run_subprocess_command(command, "stop_containers failed") | |
| logger.info("Successfully stopped docker containers") | |
| if data_store_root: | |
| HBaseDockerClient.remove_data_store_dir(data_store_root, sudo) |