diff --git a/pcs/Makefile.am b/pcs/Makefile.am index 510c126fe..e88448561 100644 --- a/pcs/Makefile.am +++ b/pcs/Makefile.am @@ -125,6 +125,7 @@ EXTRA_DIST = \ cli/tag/output.py \ cluster.py \ common/auth.py \ + common/booth_dto.py \ common/cluster_dto.py \ common/corosync_conf.py \ common/const.py \ diff --git a/pcs/common/booth_dto.py b/pcs/common/booth_dto.py new file mode 100644 index 000000000..b277fdf9b --- /dev/null +++ b/pcs/common/booth_dto.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass +from typing import Optional + +from pcs.common.interface.dto import DataTransferObject + + +@dataclass(frozen=True) +class BoothConfigFileDto(DataTransferObject): + name: str + data: str + + +@dataclass(frozen=True) +class BoothConfigAndAuthfileDto(DataTransferObject): + config: BoothConfigFileDto + authfile: Optional[BoothConfigFileDto] diff --git a/pcs/daemon/app/api_v0.py b/pcs/daemon/app/api_v0.py index 09429ecd7..86a399d63 100644 --- a/pcs/daemon/app/api_v0.py +++ b/pcs/daemon/app/api_v0.py @@ -7,7 +7,9 @@ from pcs import settings from pcs.common import file_type_codes, reports from pcs.common.async_tasks.dto import CommandDto, CommandOptionsDto +from pcs.common.booth_dto import BoothConfigAndAuthfileDto from pcs.common.cluster_dto import ClusterDaemonsInfoDto +from pcs.common.interface.dto import to_dict from pcs.common.pcs_cfgsync_dto import SyncConfigsDto from pcs.common.str_tools import format_list from pcs.daemon import log @@ -296,6 +298,29 @@ async def _handle_request(self) -> None: raise self._error(reports_to_str(result.reports)) +class BoothGetConfigHandler(_BaseApiV0Handler): + async def _handle_request(self) -> None: + instance_name = self.get_argument("name", None) + result = await self._run_library_command( + "booth.get_config_and_authfile", + dict(instance_name=instance_name), + ) + if not result.success: + raise self._error(reports_to_str(result.reports)) + + # Here we convert warning to an error. The overall goal was to convert + # the ruby behaviour to python (ruby produced error) and at the same + # time we wanted to use function which had exactly the behaviour that + # we wanted (except it produced warning instead of error). + # This is the compromise. + if any( + rep.message.code == reports.codes.BOOTH_UNSUPPORTED_FILE_LOCATION + for rep in result.reports + ): + raise self._error(reports_to_str(result.reports)) + self.write(to_dict(cast(BoothConfigAndAuthfileDto, result.result))) + + class CheckHostHandler(_BaseApiV0Handler): async def _handle_request(self) -> None: result = await self._run_library_command( @@ -587,6 +612,8 @@ def r(url: str) -> str: QdeviceNetSignNodeCertificateHandler, params, ), + # booth + (r("booth_get_config"), BoothGetConfigHandler, params), # cfgsync (r("get_configs"), GetConfigsHandler, params), ( diff --git a/pcs/daemon/async_tasks/worker/command_mapping.py b/pcs/daemon/async_tasks/worker/command_mapping.py index 423b67cb9..911dac0d7 100644 --- a/pcs/daemon/async_tasks/worker/command_mapping.py +++ b/pcs/daemon/async_tasks/worker/command_mapping.py @@ -121,6 +121,10 @@ class _Cmd: cmd=auth.known_hosts_change, required_permission=p.FULL, ), + "booth.get_config_and_authfile": _Cmd( + cmd=booth.get_config_and_authfile, + required_permission=p.READ, + ), "booth.ticket_cleanup": _Cmd( cmd=booth.ticket_cleanup, required_permission=p.WRITE, @@ -525,6 +529,7 @@ class _Cmd: LEGACY_API_COMMANDS = ( "auth.known_hosts_change", + "booth.get_config", "cluster.set_permissions", "manage_clusters.add_cluster", "manage_clusters.remove_clusters", diff --git a/pcs/lib/commands/booth.py b/pcs/lib/commands/booth.py index 4ab6becc9..966375722 100644 --- a/pcs/lib/commands/booth.py +++ b/pcs/lib/commands/booth.py @@ -6,27 +6,19 @@ from lxml.etree import _Element from pcs import settings -from pcs.common import ( - file_type_codes, - reports, -) -from pcs.common.file import ( - FileAlreadyExists, - RawFileError, +from pcs.common import file_type_codes, reports +from pcs.common.booth_dto import ( + BoothConfigAndAuthfileDto, + BoothConfigFileDto, ) +from pcs.common.file import FileAlreadyExists, RawFileError from pcs.common.reports import ReportProcessor from pcs.common.reports import codes as report_codes -from pcs.common.reports.item import ( - ReportItem, - get_severity, -) +from pcs.common.reports.item import ReportItem, get_severity from pcs.common.services.errors import ManageServiceError from pcs.common.str_tools import join_multilines from pcs.common.types import StringSequence -from pcs.lib import ( - tools, - validate, -) +from pcs.lib import tools, validate from pcs.lib.booth import ( config_files, config_validators, @@ -44,35 +36,18 @@ ensure_resources_stopped, remove_specified_elements, ) -from pcs.lib.cib.resource import ( - group, - hierarchy, - primitive, -) -from pcs.lib.cib.tools import ( - IdProvider, - get_resources, -) -from pcs.lib.communication.booth import ( - BoothGetConfig, - BoothSendConfig, -) +from pcs.lib.cib.resource import group, hierarchy, primitive +from pcs.lib.cib.tools import IdProvider, get_resources +from pcs.lib.communication.booth import BoothGetConfig, BoothSendConfig from pcs.lib.communication.tools import run_and_raise from pcs.lib.env import LibraryEnvironment from pcs.lib.errors import LibraryError from pcs.lib.external import CommandRunner from pcs.lib.file.instance import FileInstance -from pcs.lib.file.raw_file import ( - GhostFile, - RealFile, - raw_file_error_report, -) +from pcs.lib.file.raw_file import GhostFile, RealFile, raw_file_error_report from pcs.lib.interface.config import ParserErrorException from pcs.lib.node import get_existing_nodes_names -from pcs.lib.pacemaker.live import ( - has_cib_xml, - resource_restart, -) +from pcs.lib.pacemaker.live import has_cib_xml, resource_restart from pcs.lib.pacemaker.live import ticket_cleanup as live_ticket_cleanup from pcs.lib.pacemaker.live import ticket_standby as live_ticket_standby from pcs.lib.pacemaker.live import ticket_unstandby as live_ticket_unstandby @@ -378,6 +353,58 @@ def config_text( ) from e +def get_config_and_authfile( + env: LibraryEnvironment, + instance_name: Optional[str] = None, +) -> BoothConfigAndAuthfileDto: + """ + Read booth config and its authfile and return their content. + + instance_name -- booth instance name + """ + report_processor = env.report_processor + booth_env = env.get_booth_env(instance_name) + _ensure_live_booth_env(booth_env) + + try: + config_raw_data = booth_env.config.read_raw() + booth_conf = booth_env.config.raw_to_facade(config_raw_data) + except RawFileError as e: + report_processor.report(raw_file_error_report(e)) + raise LibraryError() from e + except ParserErrorException as e: + report_processor.report_list( + booth_env.config.parser_exception_to_report_list(e) + ) + raise LibraryError() from e + + try: + ( + authfile_name, + authfile_data, + authfile_report_list, + ) = config_files.get_authfile_name_and_data(booth_conf) + except RawFileError as e: + report_processor.report(raw_file_error_report(e)) + raise LibraryError() from e + report_processor.report_list(authfile_report_list) + + authfile: Optional[BoothConfigFileDto] = None + if authfile_name and authfile_data is not None: + authfile = BoothConfigFileDto( + name=authfile_name, + data=base64.b64encode(authfile_data).decode("utf-8"), + ) + + return BoothConfigAndAuthfileDto( + config=BoothConfigFileDto( + name=f"{booth_env.instance_name}.conf", + data=config_raw_data.decode("utf-8"), + ), + authfile=authfile, + ) + + def config_ticket_add( env: LibraryEnvironment, ticket_name: str, diff --git a/pcs_test/tier0/daemon/app/test_api_v0.py b/pcs_test/tier0/daemon/app/test_api_v0.py index aaed792df..ea09dd867 100644 --- a/pcs_test/tier0/daemon/app/test_api_v0.py +++ b/pcs_test/tier0/daemon/app/test_api_v0.py @@ -10,6 +10,7 @@ from tornado.util import TimeoutError as TornadoTimeoutError from tornado.web import Application +from pcs import settings from pcs.common import file_type_codes, reports from pcs.common.async_tasks.dto import ( CommandDto, @@ -21,6 +22,10 @@ TaskKillReason, TaskState, ) +from pcs.common.booth_dto import ( + BoothConfigAndAuthfileDto, + BoothConfigFileDto, +) from pcs.common.cluster_dto import ( ClusterComponentVersionDto, ClusterDaemonsInfoDto, @@ -863,6 +868,93 @@ def test_failure(self): ) +class BoothGetConfigHandler(ApiV0HandlerTest): + url = "/remote/booth_get_config" + + def test_success(self): + result_dto = BoothConfigAndAuthfileDto( + config=BoothConfigFileDto(name="booth.conf", data="some config"), + authfile=BoothConfigFileDto(name="booth.key", data="base64data"), + ) + self.mock_run_library_command.return_value = self.result_success( + result_dto + ) + response = self.fetch(self.url) + self.assertEqual(response.code, 200) + self.assert_body( + response.body, + json.dumps( + { + "config": {"name": "booth.conf", "data": "some config"}, + "authfile": {"name": "booth.key", "data": "base64data"}, + } + ), + ) + self.mock_run_library_command.assert_called_once_with( + "booth.get_config_and_authfile", dict(instance_name=None) + ) + + def test_success_with_name(self): + result_dto = BoothConfigAndAuthfileDto( + config=BoothConfigFileDto(name="my_booth.conf", data="some config"), + authfile=None, + ) + self.mock_run_library_command.return_value = self.result_success( + result_dto + ) + response = self.fetch(self.url, body=urlencode({"name": "my_booth"})) + self.assertEqual(response.code, 200) + self.assert_body( + response.body, + json.dumps( + { + "config": { + "name": "my_booth.conf", + "data": "some config", + }, + "authfile": None, + } + ), + ) + self.mock_run_library_command.assert_called_once_with( + "booth.get_config_and_authfile", dict(instance_name="my_booth") + ) + + def test_failure(self): + self.assert_error_with_report(self.url) + self.mock_run_library_command.assert_called_once_with( + "booth.get_config_and_authfile", dict(instance_name=None) + ) + + def test_authfile_not_in_booth_dir(self): + result_dto = BoothConfigAndAuthfileDto( + config=BoothConfigFileDto(name="booth.conf", data="some config"), + authfile=None, + ) + self.mock_run_library_command.return_value = self.result_success( + result_dto, + reports=[ + reports.ReportItem.warning( + reports.messages.BoothUnsupportedFileLocation( + "/etc/my_booth.key", + settings.booth_config_dir, + file_type_codes.BOOTH_KEY, + ) + ).to_dto() + ], + ) + response = self.fetch(self.url) + self.assertEqual(response.code, 400) + self.assert_body( + response.body, + "Warning: Booth key '/etc/my_booth.key' is outside of supported " + f"booth config directory '{settings.booth_config_dir}', ignoring the file", + ) + self.mock_run_library_command.assert_called_once_with( + "booth.get_config_and_authfile", dict(instance_name=None) + ) + + class CheckHostHandler(ApiV0HandlerTest): url = "/remote/check_host" diff --git a/pcs_test/tier0/lib/commands/test_booth.py b/pcs_test/tier0/lib/commands/test_booth.py index 1b483e345..d41c64c9a 100644 --- a/pcs_test/tier0/lib/commands/test_booth.py +++ b/pcs_test/tier0/lib/commands/test_booth.py @@ -1,10 +1,15 @@ # pylint: disable=too-many-lines +import base64 import os from textwrap import dedent from unittest import TestCase, mock from pcs import settings from pcs.common import file_type_codes, reports +from pcs.common.booth_dto import ( + BoothConfigAndAuthfileDto, + BoothConfigFileDto, +) from pcs.common.file import RawFileError from pcs.lib.booth import constants from pcs.lib.commands import booth as commands @@ -1297,6 +1302,226 @@ def test_remote_connection_error(self): ) +class GetConfig(TestCase, FixtureMixin): + def setUp(self): + self.env_assist, self.config = get_env_tools(self) + + def test_invalid_instance(self): + instance_name = "/tmp/booth/booth" + self.env_assist.assert_raise_library_error( + lambda: commands.get_config_and_authfile( + self.env_assist.get_env(), instance_name=instance_name + ), + [ + fixture_report_invalid_name(instance_name), + ], + expected_in_processor=False, + ) + + def test_not_live(self): + self.config.env.set_booth( + { + "config_data": "some config".encode("utf-8"), + "key_data": "some key data".encode("utf-8"), + "key_path": "/tmp/pcs_test/booth.key", + } + ) + self.env_assist.assert_raise_library_error( + lambda: commands.get_config_and_authfile(self.env_assist.get_env()), + [ + fixture.error( + reports.codes.LIVE_ENVIRONMENT_REQUIRED, + forbidden_options=[ + file_type_codes.BOOTH_CONFIG, + file_type_codes.BOOTH_KEY, + ], + ), + ], + expected_in_processor=False, + ) + + def test_success_default_instance(self): + config_content = self.fixture_cfg_content() + self.config.raw_file.read( + file_type_codes.BOOTH_CONFIG, + self.fixture_cfg_path(), + content=config_content, + name="raw_file.read.conf", + ) + self.config.raw_file.read( + file_type_codes.BOOTH_KEY, + self.fixture_key_path(), + content=RANDOM_KEY, + name="raw_file.read.key", + ) + result = commands.get_config_and_authfile(self.env_assist.get_env()) + self.assertEqual( + result, + BoothConfigAndAuthfileDto( + config=BoothConfigFileDto( + name="booth.conf", + data=config_content.decode("utf-8"), + ), + authfile=BoothConfigFileDto( + name="booth.key", + data=base64.b64encode(RANDOM_KEY).decode("utf-8"), + ), + ), + ) + + def test_success_custom_instance(self): + instance_name = "my_booth" + config_content = self.fixture_cfg_content( + self.fixture_key_path(instance_name) + ) + self.config.raw_file.read( + file_type_codes.BOOTH_CONFIG, + self.fixture_cfg_path(instance_name), + content=config_content, + name="raw_file.read.conf", + ) + self.config.raw_file.read( + file_type_codes.BOOTH_KEY, + self.fixture_key_path(instance_name), + content=RANDOM_KEY, + name="raw_file.read.key", + ) + result = commands.get_config_and_authfile( + self.env_assist.get_env(), instance_name=instance_name + ) + self.assertEqual( + result, + BoothConfigAndAuthfileDto( + config=BoothConfigFileDto( + name=f"{instance_name}.conf", + data=config_content.decode("utf-8"), + ), + authfile=BoothConfigFileDto( + name=f"{instance_name}.key", + data=base64.b64encode(RANDOM_KEY).decode("utf-8"), + ), + ), + ) + + def test_success_no_authfile(self): + config_content = bytes() + self.config.raw_file.read( + file_type_codes.BOOTH_CONFIG, + self.fixture_cfg_path(), + content=config_content, + ) + result = commands.get_config_and_authfile(self.env_assist.get_env()) + self.assertEqual( + result, + BoothConfigAndAuthfileDto( + config=BoothConfigFileDto( + name="booth.conf", + data="", + ), + authfile=None, + ), + ) + + def test_cannot_read_config(self): + error = "an error" + self.config.raw_file.read( + file_type_codes.BOOTH_CONFIG, + self.fixture_cfg_path(), + exception_msg=error, + ) + self.env_assist.assert_raise_library_error( + lambda: commands.get_config_and_authfile(self.env_assist.get_env()), + ) + self.env_assist.assert_reports( + [ + fixture.error( + reports.codes.FILE_IO_ERROR, + file_type_code=file_type_codes.BOOTH_CONFIG, + file_path=self.fixture_cfg_path(), + reason=error, + operation=RawFileError.ACTION_READ, + ), + ] + ) + + def test_cannot_read_authfile(self): + error = "an error" + self.config.raw_file.read( + file_type_codes.BOOTH_CONFIG, + self.fixture_cfg_path(), + content=self.fixture_cfg_content(), + name="raw_file.read.conf", + ) + self.config.raw_file.read( + file_type_codes.BOOTH_KEY, + self.fixture_key_path(), + exception_msg=error, + name="raw_file.read.key", + ) + self.env_assist.assert_raise_library_error( + lambda: commands.get_config_and_authfile(self.env_assist.get_env()), + ) + self.env_assist.assert_reports( + [ + fixture.error( + reports.codes.FILE_IO_ERROR, + file_type_code=file_type_codes.BOOTH_KEY, + file_path=self.fixture_key_path(), + reason=error, + operation=RawFileError.ACTION_READ, + ), + ] + ) + + def test_authfile_not_in_booth_dir(self): + config_content = "authfile=/etc/my_booth.key" + self.config.raw_file.read( + file_type_codes.BOOTH_CONFIG, + self.fixture_cfg_path(), + content=config_content.encode("utf-8"), + ) + result = commands.get_config_and_authfile(self.env_assist.get_env()) + self.assertEqual( + result, + BoothConfigAndAuthfileDto( + config=BoothConfigFileDto( + name="booth.conf", + data=config_content, + ), + authfile=None, + ), + ) + self.env_assist.assert_reports( + [ + fixture.warn( + reports.codes.BOOTH_UNSUPPORTED_FILE_LOCATION, + file_type_code=file_type_codes.BOOTH_KEY, + file_path="/etc/my_booth.key", + expected_dir=settings.booth_config_dir, + ), + ] + ) + + def test_config_parse_error(self): + self.config.raw_file.read( + file_type_codes.BOOTH_CONFIG, + self.fixture_cfg_path(), + content="invalid config".encode("utf-8"), + ) + self.env_assist.assert_raise_library_error( + lambda: commands.get_config_and_authfile(self.env_assist.get_env()), + ) + self.env_assist.assert_reports( + [ + fixture.error( + reports.codes.BOOTH_CONFIG_UNEXPECTED_LINES, + line_list=["invalid config"], + file_path=self.fixture_cfg_path(), + ), + ] + ) + + class ConfigTicketAdd(TestCase, FixtureMixin): def setUp(self): self.env_assist, self.config = get_env_tools(self) diff --git a/pcsd/pcs.rb b/pcsd/pcs.rb index f9a38ad6a..e28be3fac 100644 --- a/pcsd/pcs.rb +++ b/pcsd/pcs.rb @@ -26,9 +26,6 @@ class NotImplementedException < NotImplementedError end -class InvalidFileNameException < NameError -end - def getAllSettings(auth_user, cib_dom=nil) unless cib_dom cib_dom = get_cib_dom(auth_user) @@ -1337,39 +1334,6 @@ def get_parsed_local_sbd_config() end end -def read_booth_config(config) - if config.include?('/') - raise InvalidFileNameException.new(config) - end - config_path = File.join(BOOTH_CONFIG_DIR, config) - unless File.file?(config_path) - return nil - end - return read_file_lock(config_path) -end - -def read_booth_authfile(filename) - if filename.include?('/') - raise InvalidFileNameException.new(filename) - end - return Base64.strict_encode64( - read_file_lock(File.join(BOOTH_CONFIG_DIR, filename), true) - ) -end - -def get_authfile_from_booth_config(config_data) - authfile_path = nil - config_data.split("\n").each {|line| - if line.include?('=') - parts = line.split('=', 2) - if parts[0].strip == 'authfile' - authfile_path = parts[1].strip - end - end - } - return authfile_path -end - def get_alerts(auth_user) out, _, retcode = run_cmd(auth_user, PCS, '--', 'alert', 'get_all_alerts') diff --git a/pcsd/remote.rb b/pcsd/remote.rb index df9129ce2..689cfe4d5 100644 --- a/pcsd/remote.rb +++ b/pcsd/remote.rb @@ -51,7 +51,6 @@ def remote(params, request, auth_user) :qdevice_client_stop => method(:qdevice_client_stop), :booth_set_config => method(:booth_set_config), :booth_save_files => method(:booth_save_files), - :booth_get_config => method(:booth_get_config), :put_file => method(:put_file), :remove_file => method(:remove_file), :reload_corosync_conf => method(:reload_corosync_conf), @@ -1526,51 +1525,6 @@ def booth_save_files(params, request, auth_user) end end -def booth_get_config(params, request, auth_user) - unless allowed_for_local_cluster(auth_user, Permissions::READ) - return 403, 'Permission denied' - end - name = params[:name] - if name - config_file_name = "#{name}.conf" - else - config_file_name = 'booth.conf' - end - if config_file_name.include?('/') - return [400, 'Invalid name of booth configuration'] - end - begin - config_data = read_booth_config(config_file_name) - unless config_data - return [400, "Config doesn't exist"] - end - authfile_name = nil - authfile_data = nil - authfile_path = get_authfile_from_booth_config(config_data) - if authfile_path - if File.dirname(authfile_path) != BOOTH_CONFIG_DIR - return [ - 400, "Authfile of specified config is not in '#{BOOTH_CONFIG_DIR}'" - ] - end - authfile_name = File.basename(authfile_path) - authfile_data = read_booth_authfile(authfile_name) - end - return [200, JSON.generate({ - :config => { - :name => config_file_name, - :data => config_data - }, - :authfile => { - :name => authfile_name, - :data => authfile_data - } - })] - rescue => e - return [400, "Unable to read booth config/key file: #{e.message}"] - end -end - def put_file(params, request, auth_user) begin check_permissions(auth_user, Permissions::WRITE)