diff --git a/node_cli/cli/node.py b/node_cli/cli/node.py index 95437b6b..ff781249 100644 --- a/node_cli/cli/node.py +++ b/node_cli/cli/node.py @@ -109,10 +109,17 @@ def init_node(env_file): expose_value=False, prompt='Are you sure you want to update SKALE node software?') @click.option('--pull-config', 'pull_config_for_schain', hidden=True, type=str) +@click.option( + '--unsafe', + 'unsafe_ok', + help='Allow unsafe update', + hidden=True, + is_flag=True +) @click.argument('env_file') @streamed_cmd -def update_node(env_file, pull_config_for_schain): - update(env_file, pull_config_for_schain) +def update_node(env_file, pull_config_for_schain, unsafe_ok): + update(env_file, pull_config_for_schain, unsafe_ok) @node.command('signature', help='Get node signature for given validator id') @@ -173,9 +180,16 @@ def remove_node_from_maintenance(): @click.option('--yes', is_flag=True, callback=abort_if_false, expose_value=False, prompt='Are you sure you want to turn off the node?') +@click.option( + '--unsafe', + 'unsafe_ok', + help='Allow unsafe turn-off', + hidden=True, + is_flag=True +) @streamed_cmd -def _turn_off(maintenance_on): - turn_off(maintenance_on) +def _turn_off(maintenance_on, unsafe_ok): + turn_off(maintenance_on, unsafe_ok) @node.command('turn-on', help='Turn on the node') diff --git a/node_cli/cli/sync_node.py b/node_cli/cli/sync_node.py index bca887b6..a8ad1324 100644 --- a/node_cli/cli/sync_node.py +++ b/node_cli/cli/sync_node.py @@ -74,7 +74,14 @@ def _init_sync(env_file, archive, catchup, historic_state): @click.option('--yes', is_flag=True, callback=abort_if_false, expose_value=False, prompt='Are you sure you want to update SKALE node software?') +@click.option( + '--unsafe', + 'unsafe_ok', + help='Allow unsafe update', + hidden=True, + is_flag=True +) @click.argument('env_file') @streamed_cmd -def _update_sync(env_file): +def _update_sync(env_file, unsafe_ok): update_sync(env_file) diff --git a/node_cli/configs/routes.py b/node_cli/configs/routes.py index 87fac8f5..285e56bd 100644 --- a/node_cli/configs/routes.py +++ b/node_cli/configs/routes.py @@ -25,12 +25,22 @@ ROUTES = { 'v1': { - 'node': ['info', 'register', 'maintenance-on', 'maintenance-off', 'signature', - 'send-tg-notification', 'exit/start', 'exit/status', 'set-domain-name'], + 'node': [ + 'info', + 'register', + 'maintenance-on', + 'maintenance-off', + 'signature', + 'send-tg-notification', + 'exit/start', + 'exit/status', + 'set-domain-name', + 'update-safe', + ], 'health': ['containers', 'schains', 'sgx'], 'schains': ['config', 'list', 'dkg-statuses', 'firewall-rules', 'repair', 'get'], 'ssl': ['status', 'upload'], - 'wallet': ['info', 'send-eth'] + 'wallet': ['info', 'send-eth'], } } @@ -40,8 +50,11 @@ class RouteNotFoundException(Exception): def route_exists(blueprint, method, api_version): - return ROUTES.get(api_version) and ROUTES[api_version].get(blueprint) and \ - method in ROUTES[api_version][blueprint] + return ( + ROUTES.get(api_version) + and ROUTES[api_version].get(blueprint) + and method in ROUTES[api_version][blueprint] + ) def get_route(blueprint, method, api_version=CURRENT_API_VERSION, check=True): @@ -53,5 +66,8 @@ def get_route(blueprint, method, api_version=CURRENT_API_VERSION, check=True): def get_all_available_routes(api_version=CURRENT_API_VERSION): routes = ROUTES[api_version] - return [get_route(blueprint, method, api_version) for blueprint in routes - for method in routes[blueprint]] + return [ + get_route(blueprint, method, api_version) + for blueprint in routes + for method in routes[blueprint] + ] diff --git a/node_cli/core/node.py b/node_cli/core/node.py index 9d5c83f7..e8895d2a 100644 --- a/node_cli/core/node.py +++ b/node_cli/core/node.py @@ -92,6 +92,16 @@ class NodeStatuses(Enum): NOT_CREATED = 5 +def is_update_safe() -> bool: + status, payload = get_request(BLUEPRINT_NAME, 'update-safe') + if status == 'error': + return False + safe = payload['update_safe'] + if not safe: + logger.info('Locked schains: %s', payload['unsafe_chains']) + return safe + + @check_inited @check_user def register_node(name, p2p_ip, @@ -206,7 +216,10 @@ def init_sync( @check_inited @check_user -def update_sync(env_filepath): +def update_sync(env_filepath: str, unsafe_ok: bool = False) -> None: + if not unsafe_ok and not is_update_safe(): + error_msg = 'Cannot update safely' + error_exit(error_msg, exit_code=CLIExitCodes.UNSAFE_UPDATE) logger.info('Node update started') configure_firewall_rules() env = get_node_env(env_filepath, sync_node=True) @@ -259,7 +272,11 @@ def get_node_env( @check_inited @check_user -def update(env_filepath, pull_config_for_schain): +def update(env_filepath: str, pull_config_for_schain: str, unsafe_ok: bool = False) -> None: + if not unsafe_ok and not is_update_safe(): + error_msg = 'Cannot update safely' + error_exit(error_msg, exit_code=CLIExitCodes.UNSAFE_UPDATE) + logger.info('Node update started') configure_firewall_rules() env = get_node_env( @@ -388,7 +405,10 @@ def set_maintenance_mode_off(): @check_inited @check_user -def turn_off(maintenance_on): +def turn_off(maintenance_on: bool = False, unsafe_ok: bool = False) -> None: + if not unsafe_ok and not is_update_safe(): + error_msg = 'Cannot turn off safely' + error_exit(error_msg, exit_code=CLIExitCodes.UNSAFE_UPDATE) if maintenance_on: set_maintenance_mode_on() turn_off_op() diff --git a/node_cli/utils/exit_codes.py b/node_cli/utils/exit_codes.py index 1173aad0..85656fb1 100644 --- a/node_cli/utils/exit_codes.py +++ b/node_cli/utils/exit_codes.py @@ -30,3 +30,4 @@ class CLIExitCodes(IntEnum): REVERT_ERROR = 6 BAD_USER_ERROR = 7 NODE_STATE_ERROR = 8 + UNSAFE_UPDATE = 9 diff --git a/node_cli/utils/helper.py b/node_cli/utils/helper.py index cba65ddd..71e559cf 100644 --- a/node_cli/utils/helper.py +++ b/node_cli/utils/helper.py @@ -24,6 +24,7 @@ import sys import uuid from urllib.parse import urlparse +from typing import Optional import yaml import shutil @@ -230,7 +231,7 @@ def post_request(blueprint, method, json=None, files=None): return status, payload -def get_request(blueprint, method, params=None): +def get_request(blueprint: str, method: str, params: Optional[dict] = None) -> tuple[str, str]: route = get_route(blueprint, method) url = construct_url(route) try: diff --git a/tests/cli/node_test.py b/tests/cli/node_test.py index 319137ed..9c86057c 100644 --- a/tests/cli/node_test.py +++ b/tests/cli/node_test.py @@ -36,15 +36,17 @@ version, _turn_off, _turn_on, - _set_domain_name + _set_domain_name, ) +from node_cli.utils.exit_codes import CLIExitCodes from node_cli.utils.helper import init_default_logger from tests.helper import ( response_mock, run_command, run_command_mock, - subprocess_run_mock + safe_update_api_response, + subprocess_run_mock, ) from tests.resources_test import BIG_DISK_SIZE @@ -53,18 +55,19 @@ def test_register_node(resource_alloc, mocked_g_config): - resp_mock = response_mock( - requests.codes.ok, - {'status': 'ok', 'payload': None} - ) + resp_mock = response_mock(requests.codes.ok, {'status': 'ok', 'payload': None}) with mock.patch('node_cli.utils.decorators.is_node_inited', return_value=True): result = run_command_mock( 'node_cli.utils.helper.requests.post', resp_mock, register_node, - ['--name', 'test-node', '--ip', '0.0.0.0', '--port', '8080', '-d', 'skale.test']) + ['--name', 'test-node', '--ip', '0.0.0.0', '--port', '8080', '-d', 'skale.test'], + ) assert result.exit_code == 0 - assert result.output == 'Node registered in SKALE manager.\nFor more info run < skale node info >\n' # noqa + assert ( + result.output + == 'Node registered in SKALE manager.\nFor more info run < skale node info >\n' + ) # noqa def test_register_node_with_error(resource_alloc, mocked_g_config): @@ -77,75 +80,74 @@ def test_register_node_with_error(resource_alloc, mocked_g_config): 'node_cli.utils.helper.requests.post', resp_mock, register_node, - ['--name', 'test-node2', '--ip', '0.0.0.0', '--port', '80', '-d', 'skale.test']) + ['--name', 'test-node2', '--ip', '0.0.0.0', '--port', '80', '-d', 'skale.test'], + ) assert result.exit_code == 3 - assert result.output == f'Command failed with following errors:\n--------------------------------------------------\nStrange error\n--------------------------------------------------\nYou can find more info in {G_CONF_HOME}.skale/.skale-cli-log/debug-node-cli.log\n' # noqa + assert ( + result.output == f'Command failed with following errors:\n--------------------------------------------------\nStrange error\n--------------------------------------------------\nYou can find more info in {G_CONF_HOME}.skale/.skale-cli-log/debug-node-cli.log\n') # noqa def test_register_node_with_prompted_ip(resource_alloc, mocked_g_config): - resp_mock = response_mock( - requests.codes.ok, - {'status': 'ok', 'payload': None} - ) + resp_mock = response_mock(requests.codes.ok, {'status': 'ok', 'payload': None}) with mock.patch('node_cli.utils.decorators.is_node_inited', return_value=True): result = run_command_mock( 'node_cli.utils.helper.requests.post', resp_mock, register_node, - ['--name', 'test-node', '--port', '8080', '-d', 'skale.test'], input='0.0.0.0\n') + ['--name', 'test-node', '--port', '8080', '-d', 'skale.test'], + input='0.0.0.0\n', + ) assert result.exit_code == 0 assert result.output == 'Enter node public IP: 0.0.0.0\nNode registered in SKALE manager.\nFor more info run < skale node info >\n' # noqa def test_register_node_with_default_port(resource_alloc, mocked_g_config): - resp_mock = response_mock( - requests.codes.ok, - {'status': 'ok', 'payload': None} - ) + resp_mock = response_mock(requests.codes.ok, {'status': 'ok', 'payload': None}) with mock.patch('node_cli.utils.decorators.is_node_inited', return_value=True): result = run_command_mock( 'node_cli.utils.helper.requests.post', resp_mock, register_node, - ['--name', 'test-node', '-d', 'skale.test'], input='0.0.0.0\n') + ['--name', 'test-node', '-d', 'skale.test'], + input='0.0.0.0\n', + ) assert result.exit_code == 0 assert result.output == 'Enter node public IP: 0.0.0.0\nNode registered in SKALE manager.\nFor more info run < skale node info >\n' # noqa def test_register_with_no_alloc(mocked_g_config): - resp_mock = response_mock( - requests.codes.ok, - {'status': 'ok', 'payload': None} - ) + resp_mock = response_mock(requests.codes.ok, {'status': 'ok', 'payload': None}) result = run_command_mock( 'node_cli.utils.helper.requests.post', resp_mock, register_node, - ['--name', 'test-node', '-d', 'skale.test'], input='0.0.0.0\n') + ['--name', 'test-node', '-d', 'skale.test'], + input='0.0.0.0\n', + ) assert result.exit_code == 8 - print(repr(result.output)) - assert result.output == f'Enter node public IP: 0.0.0.0\nCommand failed with following errors:\n--------------------------------------------------\nNode hasn\'t been inited before.\nYou should run < skale node init >\n--------------------------------------------------\nYou can find more info in {G_CONF_HOME}.skale/.skale-cli-log/debug-node-cli.log\n' # noqa + assert result.output == f"Enter node public IP: 0.0.0.0\nCommand failed with following errors:\n--------------------------------------------------\nNode hasn't been inited before.\nYou should run < skale node init >\n--------------------------------------------------\nYou can find more info in {G_CONF_HOME}.skale/.skale-cli-log/debug-node-cli.log\n" # noqa def test_node_info_node_info(): payload = { 'node_info': { - 'name': 'test', 'ip': '0.0.0.0', + 'name': 'test', + 'ip': '0.0.0.0', 'publicIP': '1.1.1.1', 'port': 10001, 'publicKey': '0x7', 'start_date': 1570114466, 'leaving_date': 0, - 'last_reward_date': 1570628924, 'second_address': 0, - 'status': 0, 'id': 32, 'owner': '0x23', - 'domain_name': 'skale.test' + 'last_reward_date': 1570628924, + 'second_address': 0, + 'status': 0, + 'id': 32, + 'owner': '0x23', + 'domain_name': 'skale.test', } } - resp_mock = response_mock( - requests.codes.ok, - json_data={'payload': payload, 'status': 'ok'} - ) + resp_mock = response_mock(requests.codes.ok, json_data={'payload': payload, 'status': 'ok'}) result = run_command_mock('node_cli.utils.helper.requests.get', resp_mock, node_info) assert result.exit_code == 0 assert result.output == '--------------------------------------------------\nNode info\nName: test\nID: 32\nIP: 0.0.0.0\nPublic IP: 1.1.1.1\nPort: 10001\nDomain name: skale.test\nStatus: Active\n--------------------------------------------------\n' # noqa @@ -154,22 +156,23 @@ def test_node_info_node_info(): def test_node_info_node_info_not_created(): payload = { 'node_info': { - 'name': 'test', 'ip': '0.0.0.0', + 'name': 'test', + 'ip': '0.0.0.0', 'publicIP': '1.1.1.1', 'port': 10001, 'publicKey': '0x7', 'start_date': 1570114466, 'leaving_date': 0, - 'last_reward_date': 1570628924, 'second_address': 0, - 'status': 5, 'id': 32, 'owner': '0x23', - 'domain_name': 'skale.test' + 'last_reward_date': 1570628924, + 'second_address': 0, + 'status': 5, + 'id': 32, + 'owner': '0x23', + 'domain_name': 'skale.test', } } - resp_mock = response_mock( - requests.codes.ok, - json_data={'payload': payload, 'status': 'ok'} - ) + resp_mock = response_mock(requests.codes.ok, json_data={'payload': payload, 'status': 'ok'}) result = run_command_mock('node_cli.utils.helper.requests.get', resp_mock, node_info) assert result.exit_code == 0 assert result.output == 'This SKALE node is not registered on SKALE Manager yet\n' @@ -178,22 +181,23 @@ def test_node_info_node_info_not_created(): def test_node_info_node_info_frozen(): payload = { 'node_info': { - 'name': 'test', 'ip': '0.0.0.0', + 'name': 'test', + 'ip': '0.0.0.0', 'publicIP': '1.1.1.1', 'port': 10001, 'publicKey': '0x7', 'start_date': 1570114466, 'leaving_date': 0, - 'last_reward_date': 1570628924, 'second_address': 0, - 'status': 2, 'id': 32, 'owner': '0x23', - 'domain_name': 'skale.test' + 'last_reward_date': 1570628924, + 'second_address': 0, + 'status': 2, + 'id': 32, + 'owner': '0x23', + 'domain_name': 'skale.test', } } - resp_mock = response_mock( - requests.codes.ok, - json_data={'payload': payload, 'status': 'ok'} - ) + resp_mock = response_mock(requests.codes.ok, json_data={'payload': payload, 'status': 'ok'}) result = run_command_mock('node_cli.utils.helper.requests.get', resp_mock, node_info) assert result.exit_code == 0 assert result.output == '--------------------------------------------------\nNode info\nName: test\nID: 32\nIP: 0.0.0.0\nPublic IP: 1.1.1.1\nPort: 10001\nDomain name: skale.test\nStatus: Frozen\n--------------------------------------------------\n' # noqa @@ -202,22 +206,23 @@ def test_node_info_node_info_frozen(): def test_node_info_node_info_left(): payload = { 'node_info': { - 'name': 'test', 'ip': '0.0.0.0', + 'name': 'test', + 'ip': '0.0.0.0', 'publicIP': '1.1.1.1', 'port': 10001, 'publicKey': '0x7', 'start_date': 1570114466, 'leaving_date': 0, - 'last_reward_date': 1570628924, 'second_address': 0, - 'status': 4, 'id': 32, 'owner': '0x23', - 'domain_name': 'skale.test' + 'last_reward_date': 1570628924, + 'second_address': 0, + 'status': 4, + 'id': 32, + 'owner': '0x23', + 'domain_name': 'skale.test', } } - resp_mock = response_mock( - requests.codes.ok, - json_data={'payload': payload, 'status': 'ok'} - ) + resp_mock = response_mock(requests.codes.ok, json_data={'payload': payload, 'status': 'ok'}) result = run_command_mock('node_cli.utils.helper.requests.get', resp_mock, node_info) assert result.exit_code == 0 assert result.output == '--------------------------------------------------\nNode info\nName: test\nID: 32\nIP: 0.0.0.0\nPublic IP: 1.1.1.1\nPort: 10001\nDomain name: skale.test\nStatus: Left\n--------------------------------------------------\n' # noqa @@ -226,22 +231,23 @@ def test_node_info_node_info_left(): def test_node_info_node_info_leaving(): payload = { 'node_info': { - 'name': 'test', 'ip': '0.0.0.0', + 'name': 'test', + 'ip': '0.0.0.0', 'publicIP': '1.1.1.1', 'port': 10001, 'publicKey': '0x7', 'start_date': 1570114466, 'leaving_date': 0, - 'last_reward_date': 1570628924, 'second_address': 0, - 'status': 1, 'id': 32, 'owner': '0x23', - 'domain_name': 'skale.test' + 'last_reward_date': 1570628924, + 'second_address': 0, + 'status': 1, + 'id': 32, + 'owner': '0x23', + 'domain_name': 'skale.test', } } - resp_mock = response_mock( - requests.codes.ok, - json_data={'payload': payload, 'status': 'ok'} - ) + resp_mock = response_mock(requests.codes.ok, json_data={'payload': payload, 'status': 'ok'}) result = run_command_mock('node_cli.utils.helper.requests.get', resp_mock, node_info) assert result.exit_code == 0 assert result.output == '--------------------------------------------------\nNode info\nName: test\nID: 32\nIP: 0.0.0.0\nPublic IP: 1.1.1.1\nPort: 10001\nDomain name: skale.test\nStatus: Leaving\n--------------------------------------------------\n' # noqa @@ -250,22 +256,23 @@ def test_node_info_node_info_leaving(): def test_node_info_node_info_in_maintenance(): payload = { 'node_info': { - 'name': 'test', 'ip': '0.0.0.0', + 'name': 'test', + 'ip': '0.0.0.0', 'publicIP': '1.1.1.1', 'port': 10001, 'publicKey': '0x7', 'start_date': 1570114466, 'leaving_date': 0, - 'last_reward_date': 1570628924, 'second_address': 0, - 'status': 3, 'id': 32, 'owner': '0x23', - 'domain_name': 'skale.test' + 'last_reward_date': 1570628924, + 'second_address': 0, + 'status': 3, + 'id': 32, + 'owner': '0x23', + 'domain_name': 'skale.test', } } - resp_mock = response_mock( - requests.codes.ok, - json_data={'payload': payload, 'status': 'ok'} - ) + resp_mock = response_mock(requests.codes.ok, json_data={'payload': payload, 'status': 'ok'}) result = run_command_mock('node_cli.utils.helper.requests.get', resp_mock, node_info) assert result.exit_code == 0 assert result.output == '--------------------------------------------------\nNode info\nName: test\nID: 32\nIP: 0.0.0.0\nPublic IP: 1.1.1.1\nPort: 10001\nDomain name: skale.test\nStatus: In Maintenance\n--------------------------------------------------\n' # noqa @@ -273,23 +280,16 @@ def test_node_info_node_info_in_maintenance(): def test_node_signature(): signature_sample = '0x1231231231' - response_data = { - 'status': 'ok', - 'payload': {'signature': signature_sample} - } + response_data = {'status': 'ok', 'payload': {'signature': signature_sample}} resp_mock = response_mock(requests.codes.ok, json_data=response_data) - result = run_command_mock('node_cli.utils.helper.requests.get', - resp_mock, signature, ['1']) + result = run_command_mock('node_cli.utils.helper.requests.get', resp_mock, signature, ['1']) assert result.exit_code == 0 assert result.output == f'Signature: {signature_sample}\n' def test_backup(): pathlib.Path(SKALE_DIR).mkdir(parents=True, exist_ok=True) - result = run_command( - backup_node, - ['/tmp'] - ) + result = run_command(backup_node, ['/tmp']) assert result.exit_code == 0 print(result.output) assert 'Backup archive succesfully created ' in result.output @@ -297,21 +297,17 @@ def test_backup(): def test_restore(mocked_g_config): pathlib.Path(SKALE_DIR).mkdir(parents=True, exist_ok=True) - result = run_command( - backup_node, - ['/tmp'] + result = run_command(backup_node, ['/tmp']) + backup_path = result.output.replace('Backup archive successfully created: ', '').replace( + '\n', '' ) - backup_path = result.output.replace( - 'Backup archive successfully created: ', '').replace('\n', '') - - with patch('node_cli.core.node.restore_op', MagicMock()) as mock_restore_op, \ - patch('subprocess.run', new=subprocess_run_mock), \ - patch('node_cli.core.resources.get_disk_size', return_value=BIG_DISK_SIZE), \ - patch('node_cli.utils.decorators.is_node_inited', return_value=False): - result = run_command( - restore_node, - [backup_path, './tests/test-env'] - ) + + with patch('node_cli.core.node.restore_op', MagicMock()) as mock_restore_op, patch( + 'subprocess.run', new=subprocess_run_mock + ), patch('node_cli.core.resources.get_disk_size', return_value=BIG_DISK_SIZE), patch( + 'node_cli.utils.decorators.is_node_inited', return_value=False + ): + result = run_command(restore_node, [backup_path, './tests/test-env']) assert result.exit_code == 0 assert 'Node is restored from backup\n' in result.output # noqa @@ -320,21 +316,17 @@ def test_restore(mocked_g_config): def test_restore_no_snapshot(mocked_g_config): pathlib.Path(SKALE_DIR).mkdir(parents=True, exist_ok=True) - result = run_command( - backup_node, - ['/tmp'] + result = run_command(backup_node, ['/tmp']) + backup_path = result.output.replace('Backup archive successfully created: ', '').replace( + '\n', '' ) - backup_path = result.output.replace( - 'Backup archive successfully created: ', '').replace('\n', '') - - with patch('node_cli.core.node.restore_op', MagicMock()) as mock_restore_op, \ - patch('subprocess.run', new=subprocess_run_mock), \ - patch('node_cli.core.resources.get_disk_size', return_value=BIG_DISK_SIZE), \ - patch('node_cli.utils.decorators.is_node_inited', return_value=False): - result = run_command( - restore_node, - [backup_path, './tests/test-env', '--no-snapshot'] - ) + + with patch('node_cli.core.node.restore_op', MagicMock()) as mock_restore_op, patch( + 'subprocess.run', new=subprocess_run_mock + ), patch('node_cli.core.resources.get_disk_size', return_value=BIG_DISK_SIZE), patch( + 'node_cli.utils.decorators.is_node_inited', return_value=False + ): + result = run_command(restore_node, [backup_path, './tests/test-env', '--no-snapshot']) assert result.exit_code == 0 assert 'Node is restored from backup\n' in result.output # noqa @@ -342,90 +334,93 @@ def test_restore_no_snapshot(mocked_g_config): def test_maintenance_on(): - resp_mock = response_mock( - requests.codes.ok, - {'status': 'ok', 'payload': None} - ) + resp_mock = response_mock(requests.codes.ok, {'status': 'ok', 'payload': None}) result = run_command_mock( - 'node_cli.utils.helper.requests.post', - resp_mock, - set_node_in_maintenance, - ['--yes']) + 'node_cli.utils.helper.requests.post', resp_mock, set_node_in_maintenance, ['--yes'] + ) assert result.exit_code == 0 - assert result.output == 'Setting maintenance mode on...\nNode is successfully set in maintenance mode\n' # noqa + assert ( + result.output + == 'Setting maintenance mode on...\nNode is successfully set in maintenance mode\n' + ) # noqa def test_maintenance_off(mocked_g_config): - resp_mock = response_mock( - requests.codes.ok, - {'status': 'ok', 'payload': None} - ) + resp_mock = response_mock(requests.codes.ok, {'status': 'ok', 'payload': None}) result = run_command_mock( - 'node_cli.utils.helper.requests.post', - resp_mock, - remove_node_from_maintenance) + 'node_cli.utils.helper.requests.post', resp_mock, remove_node_from_maintenance + ) assert result.exit_code == 0 - assert result.output == 'Setting maintenance mode off...\nNode is successfully removed from maintenance mode\n' # noqa + assert ( + result.output + == 'Setting maintenance mode off...\nNode is successfully removed from maintenance mode\n' + ) # noqa def test_turn_off_maintenance_on(mocked_g_config): - resp_mock = response_mock( - requests.codes.ok, - {'status': 'ok', 'payload': None} - ) - with mock.patch('subprocess.run', new=subprocess_run_mock), \ - mock.patch('node_cli.core.node.turn_off_op'), \ - mock.patch('node_cli.core.node.is_node_inited', return_value=True): + resp_mock = response_mock(requests.codes.ok, {'status': 'ok', 'payload': None}) + with mock.patch('subprocess.run', new=subprocess_run_mock), mock.patch( + 'node_cli.core.node.turn_off_op' + ), mock.patch('node_cli.utils.decorators.is_node_inited', return_value=True): + with mock.patch( + 'node_cli.utils.helper.requests.get', return_value=safe_update_api_response() + ): + result = run_command_mock( + 'node_cli.utils.helper.requests.post', + resp_mock, + _turn_off, + ['--maintenance-on', '--yes'], + ) + assert ( + result.output + == 'Setting maintenance mode on...\nNode is successfully set in maintenance mode\n' + ) # noqa + assert result.exit_code == 0 result = run_command_mock( 'node_cli.utils.helper.requests.post', resp_mock, _turn_off, - [ - '--maintenance-on', - '--yes' - ]) - assert result.exit_code == 0 - assert result.output == 'Setting maintenance mode on...\nNode is successfully set in maintenance mode\n' # noqa + ['--maintenance-on', '--yes'], + ) + assert 'Cannot turn off safely' in result.output + assert result.exit_code == CLIExitCodes.UNSAFE_UPDATE def test_turn_on_maintenance_off(mocked_g_config): - resp_mock = response_mock( - requests.codes.ok, - {'status': 'ok', 'payload': None} - ) - with mock.patch('subprocess.run', new=subprocess_run_mock), \ - mock.patch('node_cli.core.node.get_flask_secret_key'), \ - mock.patch('node_cli.core.node.turn_on_op'), \ - mock.patch('node_cli.core.node.is_base_containers_alive'), \ - mock.patch('node_cli.core.node.is_node_inited', return_value=True): + resp_mock = response_mock(requests.codes.ok, {'status': 'ok', 'payload': None}) + with mock.patch('subprocess.run', new=subprocess_run_mock), mock.patch( + 'node_cli.core.node.get_flask_secret_key' + ), mock.patch('node_cli.core.node.turn_on_op'), mock.patch( + 'node_cli.core.node.is_base_containers_alive' + ), mock.patch('node_cli.core.node.is_node_inited', return_value=True): result = run_command_mock( 'node_cli.utils.helper.requests.post', resp_mock, _turn_on, - [ - './tests/test-env', - '--maintenance-off', - '--sync-schains', - '--yes' - ]) + ['./tests/test-env', '--maintenance-off', '--sync-schains', '--yes'], + ) assert result.exit_code == 0 - assert result.output == 'Setting maintenance mode off...\nNode is successfully removed from maintenance mode\n' # noqa, tmp fix + assert ( + result.output + == 'Setting maintenance mode off...\nNode is successfully removed from maintenance mode\n' + ) # noqa, tmp fix def test_set_domain_name(): - resp_mock = response_mock( - requests.codes.ok, - {'status': 'ok', 'payload': None} - ) + resp_mock = response_mock(requests.codes.ok, {'status': 'ok', 'payload': None}) with mock.patch('node_cli.utils.decorators.is_node_inited', return_value=True): result = run_command_mock( 'node_cli.utils.helper.requests.post', resp_mock, - _set_domain_name, ['-d', 'skale.test', '--yes']) + _set_domain_name, + ['-d', 'skale.test', '--yes'], + ) assert result.exit_code == 0 - assert result.output == 'Setting new domain name: skale.test\nDomain name successfully changed\n' # noqa + assert ( + result.output == 'Setting new domain name: skale.test\nDomain name successfully changed\n' + ) # noqa def test_node_version(meta_file_v2): @@ -436,4 +431,7 @@ def test_node_version(meta_file_v2): result = run_command(version, ['--json']) print(repr(result.output)) assert result.exit_code == 0 - assert result.output == "{'version': '0.1.1', 'config_stream': 'develop', 'docker_lvmpy_stream': '1.1.2'}\n" # noqa + assert ( + result.output + == "{'version': '0.1.1', 'config_stream': 'develop', 'docker_lvmpy_stream': '1.1.2'}\n" + ) # noqa diff --git a/tests/cli/sync_node_test.py b/tests/cli/sync_node_test.py index 3966d3c8..84b4a423 100644 --- a/tests/cli/sync_node_test.py +++ b/tests/cli/sync_node_test.py @@ -23,13 +23,12 @@ import logging from node_cli.configs import SKALE_DIR, NODE_DATA_PATH +from node_cli.core.node_options import NodeOptions from node_cli.cli.sync_node import _init_sync, _update_sync +from node_cli.utils.exit_codes import CLIExitCodes from node_cli.utils.helper import init_default_logger -from node_cli.core.node_options import NodeOptions -from tests.helper import ( - run_command, subprocess_run_mock -) +from tests.helper import run_command, safe_update_api_response, subprocess_run_mock from tests.resources_test import BIG_DISK_SIZE logger = logging.getLogger(__name__) @@ -38,43 +37,41 @@ def test_init_sync(mocked_g_config): pathlib.Path(SKALE_DIR).mkdir(parents=True, exist_ok=True) - with mock.patch('subprocess.run', new=subprocess_run_mock), \ - mock.patch('node_cli.core.node.init_sync_op'), \ - mock.patch('node_cli.core.node.is_base_containers_alive', return_value=True), \ - mock.patch('node_cli.core.resources.get_disk_size', return_value=BIG_DISK_SIZE), \ - mock.patch('node_cli.core.node.configure_firewall_rules'), \ - mock.patch('node_cli.utils.decorators.is_node_inited', return_value=False): - result = run_command( - _init_sync, - ['./tests/test-env'] - ) + with mock.patch('subprocess.run', new=subprocess_run_mock), mock.patch( + 'node_cli.core.node.init_sync_op' + ), mock.patch('node_cli.core.node.is_base_containers_alive', return_value=True), mock.patch( + 'node_cli.core.resources.get_disk_size', return_value=BIG_DISK_SIZE + ), mock.patch('node_cli.core.node.configure_firewall_rules'), mock.patch( + 'node_cli.utils.decorators.is_node_inited', return_value=False + ): + result = run_command(_init_sync, ['./tests/test-env']) assert result.exit_code == 0 def test_init_sync_archive_catchup(mocked_g_config, clean_node_options): pathlib.Path(NODE_DATA_PATH).mkdir(parents=True, exist_ok=True) -# with mock.patch('subprocess.run', new=subprocess_run_mock), \ - with mock.patch('node_cli.core.node.is_base_containers_alive', return_value=True), \ - mock.patch('node_cli.operations.base.cleanup_volume_artifacts'), \ - mock.patch('node_cli.operations.base.download_skale_node'), \ - mock.patch('node_cli.operations.base.sync_skale_node'), \ - mock.patch('node_cli.operations.base.configure_docker'), \ - mock.patch('node_cli.operations.base.prepare_host'), \ - mock.patch('node_cli.operations.base.ensure_filestorage_mapping'), \ - mock.patch('node_cli.operations.base.link_env_file'), \ - mock.patch('node_cli.operations.base.download_contracts'), \ - mock.patch('node_cli.operations.base.generate_nginx_config'), \ - mock.patch('node_cli.operations.base.prepare_block_device'), \ - mock.patch('node_cli.operations.base.update_meta'), \ - mock.patch('node_cli.operations.base.update_resource_allocation'), \ - mock.patch('node_cli.operations.base.update_images'), \ - mock.patch('node_cli.operations.base.compose_up'), \ - mock.patch('node_cli.core.resources.get_disk_size', return_value=BIG_DISK_SIZE), \ - mock.patch('node_cli.core.node.configure_firewall_rules'), \ - mock.patch('node_cli.utils.decorators.is_node_inited', return_value=False): + # with mock.patch('subprocess.run', new=subprocess_run_mock), \ + with mock.patch('node_cli.core.node.is_base_containers_alive', return_value=True), mock.patch( + 'node_cli.operations.base.cleanup_volume_artifacts' + ), mock.patch('node_cli.operations.base.download_skale_node'), mock.patch( + 'node_cli.operations.base.sync_skale_node' + ), mock.patch('node_cli.operations.base.configure_docker'), mock.patch( + 'node_cli.operations.base.prepare_host' + ), mock.patch('node_cli.operations.base.ensure_filestorage_mapping'), mock.patch( + 'node_cli.operations.base.link_env_file' + ), mock.patch('node_cli.operations.base.download_contracts'), mock.patch( + 'node_cli.operations.base.generate_nginx_config' + ), mock.patch('node_cli.operations.base.prepare_block_device'), mock.patch( + 'node_cli.operations.base.update_meta' + ), mock.patch('node_cli.operations.base.update_resource_allocation'), mock.patch( + 'node_cli.operations.base.update_images' + ), mock.patch('node_cli.operations.base.compose_up'), mock.patch( + 'node_cli.core.resources.get_disk_size', return_value=BIG_DISK_SIZE + ), mock.patch('node_cli.core.node.configure_firewall_rules'), mock.patch( + 'node_cli.utils.decorators.is_node_inited', return_value=False + ): result = run_command( - _init_sync, - ['./tests/test-env', '--archive', '--catchup', '--historic-state'] + _init_sync, ['./tests/test-env', '--archive', '--catchup', '--historic-state'] ) node_options = NodeOptions() @@ -87,30 +84,41 @@ def test_init_sync_archive_catchup(mocked_g_config, clean_node_options): def test_init_sync_historic_state_fail(mocked_g_config, clean_node_options): pathlib.Path(SKALE_DIR).mkdir(parents=True, exist_ok=True) - with mock.patch('subprocess.run', new=subprocess_run_mock), \ - mock.patch('node_cli.core.node.init_sync_op'), \ - mock.patch('node_cli.core.node.is_base_containers_alive', return_value=True), \ - mock.patch('node_cli.core.resources.get_disk_size', return_value=BIG_DISK_SIZE), \ - mock.patch('node_cli.core.node.configure_firewall_rules'), \ - mock.patch('node_cli.utils.decorators.is_node_inited', return_value=False): - result = run_command( - _init_sync, - ['./tests/test-env', '--historic-state'] - ) + with mock.patch('subprocess.run', new=subprocess_run_mock), mock.patch( + 'node_cli.core.node.init_sync_op' + ), mock.patch('node_cli.core.node.is_base_containers_alive', return_value=True), mock.patch( + 'node_cli.core.resources.get_disk_size', return_value=BIG_DISK_SIZE + ), mock.patch('node_cli.core.node.configure_firewall_rules'), mock.patch( + 'node_cli.utils.decorators.is_node_inited', return_value=False + ): + result = run_command(_init_sync, ['./tests/test-env', '--historic-state']) assert result.exit_code == 1 assert '--historic-state can be used only' in result.output def test_update_sync(mocked_g_config): pathlib.Path(SKALE_DIR).mkdir(parents=True, exist_ok=True) - with mock.patch('subprocess.run', new=subprocess_run_mock), \ - mock.patch('node_cli.core.node.update_sync_op'), \ - mock.patch('node_cli.core.node.is_base_containers_alive', return_value=True), \ - mock.patch('node_cli.core.resources.get_disk_size', return_value=BIG_DISK_SIZE), \ - mock.patch('node_cli.core.node.configure_firewall_rules'), \ - mock.patch('node_cli.utils.decorators.is_node_inited', return_value=True): - result = run_command( - _update_sync, - ['./tests/test-env', '--yes'] - ) - assert result.exit_code == 0 + + with mock.patch('subprocess.run', new=subprocess_run_mock), mock.patch( + 'node_cli.core.node.update_sync_op' + ), mock.patch('node_cli.core.node.is_base_containers_alive', return_value=True), mock.patch( + 'node_cli.core.resources.get_disk_size', return_value=BIG_DISK_SIZE + ), mock.patch('node_cli.core.node.configure_firewall_rules'), mock.patch( + 'node_cli.utils.decorators.is_node_inited', return_value=True + ): + result = run_command(_update_sync, ['./tests/test-env', '--yes']) + assert result.exit_code == CLIExitCodes.UNSAFE_UPDATE + assert 'Cannot update safely' in result.output + + with mock.patch( + 'node_cli.utils.helper.requests.get', return_value=safe_update_api_response() + ): + result = run_command(_update_sync, ['./tests/test-env', '--yes']) + assert result.exit_code == 0 + + with mock.patch( + 'node_cli.utils.helper.requests.get', return_value=safe_update_api_response(False) + ): + result = run_command(_update_sync, ['./tests/test-env', '--yes']) + assert result.exit_code == CLIExitCodes.UNSAFE_UPDATE + assert 'Cannot update safely' in result.output diff --git a/tests/core/core_node_test.py b/tests/core/core_node_test.py index 2ee12036..c3c3e11d 100644 --- a/tests/core/core_node_test.py +++ b/tests/core/core_node_test.py @@ -12,12 +12,9 @@ from node_cli.configs import NODE_DATA_PATH from node_cli.configs.resource_allocation import RESOURCE_ALLOCATION_FILEPATH from node_cli.core.node import BASE_CONTAINERS_AMOUNT, is_base_containers_alive -from node_cli.core.node import init, pack_dir, update +from node_cli.core.node import init, pack_dir, update, is_update_safe -from tests.helper import ( - response_mock, - subprocess_run_mock -) +from tests.helper import response_mock, safe_update_api_response, subprocess_run_mock from tests.resources_test import BIG_DISK_SIZE dclient = docker.from_env() @@ -30,8 +27,7 @@ @pytest.fixture def skale_base_containers(): containers = [ - dclient.containers.run(ALPINE_IMAGE_NAME, detach=True, - name=f'skale_test{i}', command=CMD) + dclient.containers.run(ALPINE_IMAGE_NAME, detach=True, name=f'skale_test{i}', command=CMD) for i in range(BASE_CONTAINERS_AMOUNT) ] yield containers @@ -42,8 +38,7 @@ def skale_base_containers(): @pytest.fixture def skale_base_containers_without_one(): containers = [ - dclient.containers.run(ALPINE_IMAGE_NAME, detach=True, - name=f'skale_test{i}', command=CMD) + dclient.containers.run(ALPINE_IMAGE_NAME, detach=True, name=f'skale_test{i}', command=CMD) for i in range(BASE_CONTAINERS_AMOUNT - 1) ] yield containers @@ -54,8 +49,7 @@ def skale_base_containers_without_one(): @pytest.fixture def skale_base_containers_exited(): containers = [ - dclient.containers.run(HELLO_WORLD_IMAGE_NAME, detach=True, - name=f'skale_test{i}') + dclient.containers.run(HELLO_WORLD_IMAGE_NAME, detach=True, name=f'skale_test{i}') for i in range(BASE_CONTAINERS_AMOUNT) ] time.sleep(10) @@ -92,18 +86,14 @@ def test_pack_dir(tmp_dir): print(tar.getnames()) assert Path(a_data).relative_to(tmp_dir).as_posix() in tar.getnames() assert Path(b_data).relative_to(tmp_dir).as_posix() in tar.getnames() - assert Path(trash_data).relative_to(tmp_dir).as_posix() in \ - tar.getnames() + assert Path(trash_data).relative_to(tmp_dir).as_posix() in tar.getnames() - cleaned_archive_path = os.path.abspath( - os.path.join(tmp_dir, 'cleaned-archive.tar.gz') - ) + cleaned_archive_path = os.path.abspath(os.path.join(tmp_dir, 'cleaned-archive.tar.gz')) pack_dir(backup_dir, cleaned_archive_path, exclude=(trash_dir,)) with tarfile.open(cleaned_archive_path) as tar: assert Path(a_data).relative_to(tmp_dir).as_posix() in tar.getnames() assert Path(b_data).relative_to(tmp_dir).as_posix() in tar.getnames() - assert Path(trash_data).relative_to(tmp_dir).as_posix() not in \ - tar.getnames() + assert Path(trash_data).relative_to(tmp_dir).as_posix() not in tar.getnames() # Not absolute or unrelated path in exclude raises ValueError with pytest.raises(ValueError): @@ -116,9 +106,7 @@ def test_is_base_containers_alive(skale_base_containers): assert is_base_containers_alive() -def test_is_base_containers_alive_one_failed( - skale_base_containers_without_one -): +def test_is_base_containers_alive_one_failed(skale_base_containers_without_one): assert not is_base_containers_alive() @@ -153,17 +141,15 @@ def test_init_node(no_resource_file): # todo: write new init node test resp_mock = response_mock(requests.codes.created) assert not os.path.isfile(RESOURCE_ALLOCATION_FILEPATH) env_filepath = './tests/test-env' - with mock.patch('subprocess.run', new=subprocess_run_mock), \ - mock.patch('node_cli.core.resources.get_disk_size', - return_value=BIG_DISK_SIZE), \ - mock.patch('node_cli.core.host.prepare_host'), \ - mock.patch('node_cli.core.host.init_data_dir'), \ - mock.patch('node_cli.core.node.configure_firewall_rules'), \ - mock.patch('node_cli.core.node.init_op'), \ - mock.patch('node_cli.core.node.is_base_containers_alive', - return_value=True), \ - mock.patch('node_cli.utils.helper.post_request', - resp_mock): + with mock.patch('subprocess.run', new=subprocess_run_mock), mock.patch( + 'node_cli.core.resources.get_disk_size', return_value=BIG_DISK_SIZE + ), mock.patch('node_cli.core.host.prepare_host'), mock.patch( + 'node_cli.core.host.init_data_dir' + ), mock.patch('node_cli.core.node.configure_firewall_rules'), mock.patch( + 'node_cli.core.node.init_op' + ), mock.patch('node_cli.core.node.is_base_containers_alive', return_value=True), mock.patch( + 'node_cli.utils.helper.post_request', resp_mock + ): init(env_filepath) assert os.path.isfile(RESOURCE_ALLOCATION_FILEPATH) @@ -172,17 +158,28 @@ def test_update_node(mocked_g_config, resource_file): env_filepath = './tests/test-env' resp_mock = response_mock(requests.codes.created) os.makedirs(NODE_DATA_PATH, exist_ok=True) - with mock.patch('subprocess.run', new=subprocess_run_mock), \ - mock.patch('node_cli.core.node.update_op'), \ - mock.patch('node_cli.core.node.get_flask_secret_key'), \ - mock.patch('node_cli.core.node.save_env_params'), \ - mock.patch('node_cli.core.node.configure_firewall_rules'), \ - mock.patch('node_cli.core.host.prepare_host'), \ - mock.patch('node_cli.core.node.is_base_containers_alive', - return_value=True), \ - mock.patch('node_cli.utils.helper.post_request', - resp_mock), \ - mock.patch('node_cli.core.resources.get_disk_size', - return_value=BIG_DISK_SIZE), \ - mock.patch('node_cli.core.host.init_data_dir'): - update(env_filepath, pull_config_for_schain=None) + with mock.patch('subprocess.run', new=subprocess_run_mock), mock.patch( + 'node_cli.core.node.update_op' + ), mock.patch('node_cli.core.node.get_flask_secret_key'), mock.patch( + 'node_cli.core.node.save_env_params' + ), mock.patch('node_cli.core.node.configure_firewall_rules'), mock.patch( + 'node_cli.core.host.prepare_host' + ), mock.patch('node_cli.core.node.is_base_containers_alive', return_value=True), mock.patch( + 'node_cli.utils.helper.post_request', resp_mock + ), mock.patch('node_cli.core.resources.get_disk_size', return_value=BIG_DISK_SIZE), mock.patch( + 'node_cli.core.host.init_data_dir' + ): + with mock.patch('node_cli.utils.helper.requests.get', return_value=safe_update_api_response()): # noqa + result = update(env_filepath, pull_config_for_schain=None) + assert result is None + + +def test_is_update_safe(): + assert not is_update_safe() + with mock.patch('node_cli.utils.helper.requests.get', return_value=safe_update_api_response()): + assert is_update_safe() + + with mock.patch( + 'node_cli.utils.helper.requests.get', return_value=safe_update_api_response(safe=False) + ): + assert not is_update_safe() diff --git a/tests/helper.py b/tests/helper.py index 832ac577..c753e176 100644 --- a/tests/helper.py +++ b/tests/helper.py @@ -20,6 +20,8 @@ import mock import os + +import requests from click.testing import CliRunner from mock import Mock, MagicMock @@ -84,3 +86,16 @@ def subprocess_run_mock(*args, returncode=0, **kwargs): result.stdout = MagicMock() result.stderr = MagicMock() return result + + +def safe_update_api_response(safe: bool = True) -> dict: + if safe: + return response_mock( + requests.codes.ok, + {'status': 'ok', 'payload': {'update_safe': True, 'unsafe_chains': []}}, + ) + else: + return response_mock( + requests.codes.ok, + {'status': 'ok', 'payload': {'update_safe': False, 'unsafe_chains': ['test_chain']}}, + ) diff --git a/tests/routes_test.py b/tests/routes_test.py index 3cd2416e..9c00b8f1 100644 --- a/tests/routes_test.py +++ b/tests/routes_test.py @@ -13,6 +13,7 @@ '/api/v1/node/exit/start', '/api/v1/node/exit/status', '/api/v1/node/set-domain-name', + '/api/v1/node/update-safe', '/api/v1/health/containers', '/api/v1/health/schains',