5056 lines
175 KiB
Diff
5056 lines
175 KiB
Diff
From 7cf137380bc80653c50747a1d4d70783d593fcb5 Mon Sep 17 00:00:00 2001
|
|
From: Miroslav Lisik <mlisik@redhat.com>
|
|
Date: Fri, 29 Nov 2019 12:16:11 +0100
|
|
Subject: [PATCH 1/3] squash bz1676431 Display status of disaster recovery site
|
|
|
|
support DR config in node add, node remove, cluster destroy
|
|
|
|
dr: add command for setting recovery site
|
|
|
|
improve typing
|
|
|
|
move tests
|
|
|
|
dr: add a command for displaying clusters' status
|
|
|
|
dr: add a command for displaying dr config
|
|
|
|
dr: add 'destroy' sub-command
|
|
|
|
dr: review based fixes
|
|
|
|
update capabilities, changelog
|
|
---
|
|
CHANGELOG.md | 9 +
|
|
pcs/app.py | 2 +
|
|
pcs/cli/common/console_report.py | 16 +-
|
|
pcs/cli/common/lib_wrapper.py | 13 +
|
|
pcs/cli/dr.py | 138 ++++
|
|
pcs/cli/routing/dr.py | 15 +
|
|
pcs/cluster.py | 1 +
|
|
pcs/common/dr.py | 109 +++
|
|
pcs/common/file_type_codes.py | 27 +-
|
|
pcs/common/report_codes.py | 3 +
|
|
pcs/lib/commands/cluster.py | 18 +-
|
|
pcs/lib/commands/dr.py | 316 ++++++++
|
|
pcs/lib/communication/corosync.py | 28 +
|
|
pcs/lib/communication/status.py | 97 +++
|
|
pcs/lib/dr/__init__.py | 0
|
|
pcs/lib/dr/config/__init__.py | 0
|
|
pcs/lib/dr/config/facade.py | 49 ++
|
|
pcs/lib/dr/env.py | 28 +
|
|
pcs/lib/env.py | 17 +
|
|
pcs/lib/file/instance.py | 21 +-
|
|
pcs/lib/file/metadata.py | 8 +
|
|
pcs/lib/file/toolbox.py | 80 +-
|
|
pcs/lib/node.py | 5 +-
|
|
pcs/lib/node_communication_format.py | 16 +
|
|
pcs/lib/reports.py | 31 +
|
|
pcs/pcs.8 | 18 +-
|
|
pcs/pcs_internal.py | 1 +
|
|
pcs/settings_default.py | 1 +
|
|
pcs/usage.py | 32 +-
|
|
.../tier0/cli/common/test_console_report.py | 24 +
|
|
pcs_test/tier0/cli/test_dr.py | 293 +++++++
|
|
pcs_test/tier0/common/test_dr.py | 167 ++++
|
|
.../lib/commands/cluster/test_add_nodes.py | 143 +++-
|
|
pcs_test/tier0/lib/commands/dr/__init__.py | 0
|
|
.../tier0/lib/commands/dr/test_destroy.py | 342 ++++++++
|
|
.../tier0/lib/commands/dr/test_get_config.py | 134 ++++
|
|
.../lib/commands/dr/test_set_recovery_site.py | 702 ++++++++++++++++
|
|
pcs_test/tier0/lib/commands/dr/test_status.py | 756 ++++++++++++++++++
|
|
.../tier0/lib/communication/test_status.py | 7 +
|
|
pcs_test/tier0/lib/dr/__init__.py | 0
|
|
pcs_test/tier0/lib/dr/test_facade.py | 138 ++++
|
|
pcs_test/tier0/lib/test_env.py | 42 +-
|
|
.../tools/command_env/config_corosync_conf.py | 9 +-
|
|
pcs_test/tools/command_env/config_http.py | 3 +
|
|
.../tools/command_env/config_http_corosync.py | 24 +
|
|
.../tools/command_env/config_http_files.py | 28 +-
|
|
.../tools/command_env/config_http_status.py | 52 ++
|
|
.../mock_get_local_corosync_conf.py | 12 +-
|
|
pcsd/capabilities.xml | 12 +
|
|
pcsd/pcsd_file.rb | 15 +
|
|
pcsd/pcsd_remove_file.rb | 7 +
|
|
pcsd/remote.rb | 19 +-
|
|
pcsd/settings.rb | 1 +
|
|
pcsd/settings.rb.debian | 1 +
|
|
pylintrc | 2 +-
|
|
55 files changed, 3964 insertions(+), 68 deletions(-)
|
|
create mode 100644 pcs/cli/dr.py
|
|
create mode 100644 pcs/cli/routing/dr.py
|
|
create mode 100644 pcs/common/dr.py
|
|
create mode 100644 pcs/lib/commands/dr.py
|
|
create mode 100644 pcs/lib/communication/status.py
|
|
create mode 100644 pcs/lib/dr/__init__.py
|
|
create mode 100644 pcs/lib/dr/config/__init__.py
|
|
create mode 100644 pcs/lib/dr/config/facade.py
|
|
create mode 100644 pcs/lib/dr/env.py
|
|
create mode 100644 pcs_test/tier0/cli/test_dr.py
|
|
create mode 100644 pcs_test/tier0/common/test_dr.py
|
|
create mode 100644 pcs_test/tier0/lib/commands/dr/__init__.py
|
|
create mode 100644 pcs_test/tier0/lib/commands/dr/test_destroy.py
|
|
create mode 100644 pcs_test/tier0/lib/commands/dr/test_get_config.py
|
|
create mode 100644 pcs_test/tier0/lib/commands/dr/test_set_recovery_site.py
|
|
create mode 100644 pcs_test/tier0/lib/commands/dr/test_status.py
|
|
create mode 100644 pcs_test/tier0/lib/communication/test_status.py
|
|
create mode 100644 pcs_test/tier0/lib/dr/__init__.py
|
|
create mode 100644 pcs_test/tier0/lib/dr/test_facade.py
|
|
create mode 100644 pcs_test/tools/command_env/config_http_status.py
|
|
|
|
diff --git a/CHANGELOG.md b/CHANGELOG.md
|
|
index 69e6da44..889436c3 100644
|
|
--- a/CHANGELOG.md
|
|
+++ b/CHANGELOG.md
|
|
@@ -1,5 +1,14 @@
|
|
# Change Log
|
|
|
|
+## [Unreleased]
|
|
+
|
|
+### Added
|
|
+- It is possible to configure a disaster-recovery site and display its status
|
|
+ ([rhbz#1676431])
|
|
+
|
|
+[rhbz#1676431]: https://bugzilla.redhat.com/show_bug.cgi?id=1676431
|
|
+
|
|
+
|
|
## [0.10.4] - 2019-11-28
|
|
|
|
### Added
|
|
diff --git a/pcs/app.py b/pcs/app.py
|
|
index 8df07c1d..defc4055 100644
|
|
--- a/pcs/app.py
|
|
+++ b/pcs/app.py
|
|
@@ -25,6 +25,7 @@ from pcs.cli.routing import (
|
|
cluster,
|
|
config,
|
|
constraint,
|
|
+ dr,
|
|
host,
|
|
node,
|
|
pcsd,
|
|
@@ -245,6 +246,7 @@ def main(argv=None):
|
|
"booth": booth.booth_cmd,
|
|
"host": host.host_cmd,
|
|
"client": client.client_cmd,
|
|
+ "dr": dr.dr_cmd,
|
|
"help": lambda lib, argv, modifiers: usage.main(),
|
|
}
|
|
try:
|
|
diff --git a/pcs/cli/common/console_report.py b/pcs/cli/common/console_report.py
|
|
index 0a730cfa..d349c823 100644
|
|
--- a/pcs/cli/common/console_report.py
|
|
+++ b/pcs/cli/common/console_report.py
|
|
@@ -2,6 +2,7 @@
|
|
from collections import defaultdict
|
|
from collections.abc import Iterable
|
|
from functools import partial
|
|
+from typing import Mapping
|
|
import sys
|
|
|
|
from pcs.common import (
|
|
@@ -46,6 +47,7 @@ _file_role_translation = {
|
|
file_type_codes.BOOTH_CONFIG: "Booth configuration",
|
|
file_type_codes.BOOTH_KEY: "Booth key",
|
|
file_type_codes.COROSYNC_AUTHKEY: "Corosync authkey",
|
|
+ file_type_codes.PCS_DR_CONFIG: "disaster-recovery configuration",
|
|
file_type_codes.PACEMAKER_AUTHKEY: "Pacemaker authkey",
|
|
file_type_codes.PCSD_ENVIRONMENT_CONFIG: "pcsd configuration",
|
|
file_type_codes.PCSD_SSL_CERT: "pcsd SSL certificate",
|
|
@@ -53,7 +55,7 @@ _file_role_translation = {
|
|
file_type_codes.PCS_KNOWN_HOSTS: "known-hosts",
|
|
file_type_codes.PCS_SETTINGS_CONF: "pcs configuration",
|
|
}
|
|
-_file_role_to_option_translation = {
|
|
+_file_role_to_option_translation: Mapping[str, str] = {
|
|
file_type_codes.BOOTH_CONFIG: "--booth-conf",
|
|
file_type_codes.BOOTH_KEY: "--booth-key",
|
|
file_type_codes.CIB: "-f",
|
|
@@ -2284,4 +2286,16 @@ CODE_TO_MESSAGE_BUILDER_MAP = {
|
|
"resources\n\n{crm_simulate_plaintext_output}"
|
|
).format(**info)
|
|
,
|
|
+
|
|
+ codes.DR_CONFIG_ALREADY_EXIST: lambda info: (
|
|
+ "Disaster-recovery already configured"
|
|
+ ).format(**info),
|
|
+
|
|
+ codes.DR_CONFIG_DOES_NOT_EXIST: lambda info: (
|
|
+ "Disaster-recovery is not configured"
|
|
+ ).format(**info),
|
|
+
|
|
+ codes.NODE_IN_LOCAL_CLUSTER: lambda info: (
|
|
+ "Node '{node}' is part of local cluster"
|
|
+ ).format(**info),
|
|
}
|
|
diff --git a/pcs/cli/common/lib_wrapper.py b/pcs/cli/common/lib_wrapper.py
|
|
index 27b7d8b1..4ef6bf2f 100644
|
|
--- a/pcs/cli/common/lib_wrapper.py
|
|
+++ b/pcs/cli/common/lib_wrapper.py
|
|
@@ -9,6 +9,7 @@ from pcs.lib.commands import (
|
|
booth,
|
|
cib_options,
|
|
cluster,
|
|
+ dr,
|
|
fencing_topology,
|
|
node,
|
|
pcsd,
|
|
@@ -183,6 +184,18 @@ def load_module(env, middleware_factory, name):
|
|
}
|
|
)
|
|
|
|
+ if name == "dr":
|
|
+ return bind_all(
|
|
+ env,
|
|
+ middleware.build(middleware_factory.corosync_conf_existing),
|
|
+ {
|
|
+ "get_config": dr.get_config,
|
|
+ "destroy": dr.destroy,
|
|
+ "set_recovery_site": dr.set_recovery_site,
|
|
+ "status_all_sites_plaintext": dr.status_all_sites_plaintext,
|
|
+ }
|
|
+ )
|
|
+
|
|
if name == "remote_node":
|
|
return bind_all(
|
|
env,
|
|
diff --git a/pcs/cli/dr.py b/pcs/cli/dr.py
|
|
new file mode 100644
|
|
index 00000000..c6830aa0
|
|
--- /dev/null
|
|
+++ b/pcs/cli/dr.py
|
|
@@ -0,0 +1,138 @@
|
|
+from typing import (
|
|
+ Any,
|
|
+ List,
|
|
+ Sequence,
|
|
+)
|
|
+
|
|
+from pcs.cli.common.console_report import error
|
|
+from pcs.cli.common.errors import CmdLineInputError
|
|
+from pcs.cli.common.parse_args import InputModifiers
|
|
+from pcs.common import report_codes
|
|
+from pcs.common.dr import (
|
|
+ DrConfigDto,
|
|
+ DrConfigSiteDto,
|
|
+ DrSiteStatusDto,
|
|
+)
|
|
+from pcs.common.tools import indent
|
|
+
|
|
+def config(
|
|
+ lib: Any,
|
|
+ argv: Sequence[str],
|
|
+ modifiers: InputModifiers,
|
|
+) -> None:
|
|
+ """
|
|
+ Options: None
|
|
+ """
|
|
+ modifiers.ensure_only_supported()
|
|
+ if argv:
|
|
+ raise CmdLineInputError()
|
|
+ config_raw = lib.dr.get_config()
|
|
+ try:
|
|
+ config_dto = DrConfigDto.from_dict(config_raw)
|
|
+ except (KeyError, TypeError, ValueError):
|
|
+ raise error(
|
|
+ "Unable to communicate with pcsd, received response:\n"
|
|
+ f"{config_raw}"
|
|
+ )
|
|
+
|
|
+ lines = ["Local site:"]
|
|
+ lines.extend(indent(_config_site_lines(config_dto.local_site)))
|
|
+ for site_dto in config_dto.remote_site_list:
|
|
+ lines.append("Remote site:")
|
|
+ lines.extend(indent(_config_site_lines(site_dto)))
|
|
+ print("\n".join(lines))
|
|
+
|
|
+def _config_site_lines(site_dto: DrConfigSiteDto) -> List[str]:
|
|
+ lines = [f"Role: {site_dto.site_role.capitalize()}"]
|
|
+ if site_dto.node_list:
|
|
+ lines.append("Nodes:")
|
|
+ lines.extend(indent(sorted([node.name for node in site_dto.node_list])))
|
|
+ return lines
|
|
+
|
|
+
|
|
+def set_recovery_site(
|
|
+ lib: Any,
|
|
+ argv: Sequence[str],
|
|
+ modifiers: InputModifiers,
|
|
+) -> None:
|
|
+ """
|
|
+ Options:
|
|
+ * --request-timeout - HTTP timeout for node authorization check
|
|
+ """
|
|
+ modifiers.ensure_only_supported("--request-timeout")
|
|
+ if len(argv) != 1:
|
|
+ raise CmdLineInputError()
|
|
+ lib.dr.set_recovery_site(argv[0])
|
|
+
|
|
+def status(
|
|
+ lib: Any,
|
|
+ argv: Sequence[str],
|
|
+ modifiers: InputModifiers,
|
|
+) -> None:
|
|
+ """
|
|
+ Options:
|
|
+ * --full - show full details, node attributes and failcount
|
|
+ * --hide-inactive - hide inactive resources
|
|
+ * --request-timeout - HTTP timeout for node authorization check
|
|
+ """
|
|
+ modifiers.ensure_only_supported(
|
|
+ "--full", "--hide-inactive", "--request-timeout",
|
|
+ )
|
|
+ if argv:
|
|
+ raise CmdLineInputError()
|
|
+
|
|
+ status_list_raw = lib.dr.status_all_sites_plaintext(
|
|
+ hide_inactive_resources=modifiers.get("--hide-inactive"),
|
|
+ verbose=modifiers.get("--full"),
|
|
+ )
|
|
+ try:
|
|
+ status_list = [
|
|
+ DrSiteStatusDto.from_dict(status_raw)
|
|
+ for status_raw in status_list_raw
|
|
+ ]
|
|
+ except (KeyError, TypeError, ValueError):
|
|
+ raise error(
|
|
+ "Unable to communicate with pcsd, received response:\n"
|
|
+ f"{status_list_raw}"
|
|
+ )
|
|
+
|
|
+ has_errors = False
|
|
+ plaintext_parts = []
|
|
+ for site_status in status_list:
|
|
+ plaintext_parts.append(
|
|
+ "--- {local_remote} cluster - {role} site ---".format(
|
|
+ local_remote=("Local" if site_status.local_site else "Remote"),
|
|
+ role=site_status.site_role.capitalize()
|
|
+ )
|
|
+ )
|
|
+ if site_status.status_successfully_obtained:
|
|
+ plaintext_parts.append(site_status.status_plaintext.strip())
|
|
+ plaintext_parts.extend(["", ""])
|
|
+ else:
|
|
+ has_errors = True
|
|
+ plaintext_parts.extend([
|
|
+ "Error: Unable to get status of the cluster from any node",
|
|
+ ""
|
|
+ ])
|
|
+ print("\n".join(plaintext_parts).strip())
|
|
+ if has_errors:
|
|
+ raise error("Unable to get status of all sites")
|
|
+
|
|
+
|
|
+def destroy(
|
|
+ lib: Any,
|
|
+ argv: Sequence[str],
|
|
+ modifiers: InputModifiers,
|
|
+) -> None:
|
|
+ """
|
|
+ Options:
|
|
+ * --skip-offline - skip unreachable nodes (including missing auth token)
|
|
+ * --request-timeout - HTTP timeout for node authorization check
|
|
+ """
|
|
+ modifiers.ensure_only_supported("--skip-offline", "--request-timeout")
|
|
+ if argv:
|
|
+ raise CmdLineInputError()
|
|
+ force_flags = []
|
|
+ if modifiers.get("--skip-offline"):
|
|
+ force_flags.append(report_codes.SKIP_OFFLINE_NODES)
|
|
+ lib.dr.destroy(force_flags=force_flags)
|
|
diff --git a/pcs/cli/routing/dr.py b/pcs/cli/routing/dr.py
|
|
new file mode 100644
|
|
index 00000000..dbf44c1c
|
|
--- /dev/null
|
|
+++ b/pcs/cli/routing/dr.py
|
|
@@ -0,0 +1,15 @@
|
|
+from pcs import usage
|
|
+from pcs.cli import dr
|
|
+from pcs.cli.common.routing import create_router
|
|
+
|
|
+dr_cmd = create_router(
|
|
+ {
|
|
+ "help": lambda lib, argv, modifiers: usage.dr(argv),
|
|
+ "config": dr.config,
|
|
+ "destroy": dr.destroy,
|
|
+ "set-recovery-site": dr.set_recovery_site,
|
|
+ "status": dr.status,
|
|
+ },
|
|
+ ["dr"],
|
|
+ default_cmd="help",
|
|
+)
|
|
diff --git a/pcs/cluster.py b/pcs/cluster.py
|
|
index 3a931b60..9473675f 100644
|
|
--- a/pcs/cluster.py
|
|
+++ b/pcs/cluster.py
|
|
@@ -1209,6 +1209,7 @@ def cluster_destroy(lib, argv, modifiers):
|
|
settings.corosync_conf_file,
|
|
settings.corosync_authkey_file,
|
|
settings.pacemaker_authkey_file,
|
|
+ settings.pcsd_dr_config_location,
|
|
])
|
|
state_files = [
|
|
"cib-*",
|
|
diff --git a/pcs/common/dr.py b/pcs/common/dr.py
|
|
new file mode 100644
|
|
index 00000000..1648d93d
|
|
--- /dev/null
|
|
+++ b/pcs/common/dr.py
|
|
@@ -0,0 +1,109 @@
|
|
+from enum import auto
|
|
+from typing import (
|
|
+ Any,
|
|
+ Iterable,
|
|
+ Mapping,
|
|
+)
|
|
+
|
|
+from pcs.common.interface.dto import DataTransferObject
|
|
+from pcs.common.tools import AutoNameEnum
|
|
+
|
|
+
|
|
+class DrRole(AutoNameEnum):
|
|
+ PRIMARY = auto()
|
|
+ RECOVERY = auto()
|
|
+
|
|
+
|
|
+class DrConfigNodeDto(DataTransferObject):
|
|
+ def __init__(self, name: str):
|
|
+ self.name = name
|
|
+
|
|
+ def to_dict(self) -> Mapping[str, Any]:
|
|
+ return dict(name=self.name)
|
|
+
|
|
+ @classmethod
|
|
+ def from_dict(cls, payload: Mapping[str, Any]) -> "DrConfigNodeDto":
|
|
+ return cls(payload["name"])
|
|
+
|
|
+
|
|
+class DrConfigSiteDto(DataTransferObject):
|
|
+ def __init__(
|
|
+ self,
|
|
+ site_role: DrRole,
|
|
+ node_list: Iterable[DrConfigNodeDto]
|
|
+ ):
|
|
+ self.site_role = site_role
|
|
+ self.node_list = node_list
|
|
+
|
|
+ def to_dict(self) -> Mapping[str, Any]:
|
|
+ return dict(
|
|
+ site_role=self.site_role.value,
|
|
+ node_list=[node.to_dict() for node in self.node_list]
|
|
+ )
|
|
+
|
|
+ @classmethod
|
|
+ def from_dict(cls, payload: Mapping[str, Any]) -> "DrConfigSiteDto":
|
|
+ return cls(
|
|
+ DrRole(payload["site_role"]),
|
|
+ [
|
|
+ DrConfigNodeDto.from_dict(payload_node)
|
|
+ for payload_node in payload["node_list"]
|
|
+ ],
|
|
+ )
|
|
+
|
|
+
|
|
+class DrConfigDto(DataTransferObject):
|
|
+ def __init__(
|
|
+ self,
|
|
+ local_site: DrConfigSiteDto,
|
|
+ remote_site_list: Iterable[DrConfigSiteDto]
|
|
+ ):
|
|
+ self.local_site = local_site
|
|
+ self.remote_site_list = remote_site_list
|
|
+
|
|
+ def to_dict(self) -> Mapping[str, Any]:
|
|
+ return dict(
|
|
+ local_site=self.local_site.to_dict(),
|
|
+ remote_site_list=[site.to_dict() for site in self.remote_site_list],
|
|
+ )
|
|
+
|
|
+ @classmethod
|
|
+ def from_dict(cls, payload: Mapping[str, Any]) -> "DrConfigDto":
|
|
+ return cls(
|
|
+ DrConfigSiteDto.from_dict(payload["local_site"]),
|
|
+ [
|
|
+ DrConfigSiteDto.from_dict(payload_site)
|
|
+ for payload_site in payload["remote_site_list"]
|
|
+ ],
|
|
+ )
|
|
+
|
|
+
|
|
+class DrSiteStatusDto(DataTransferObject):
|
|
+ def __init__(
|
|
+ self,
|
|
+ local_site: bool,
|
|
+ site_role: DrRole,
|
|
+ status_plaintext: str,
|
|
+ status_successfully_obtained: bool
|
|
+ ):
|
|
+ self.local_site = local_site
|
|
+ self.site_role = site_role
|
|
+ self.status_plaintext = status_plaintext
|
|
+ self.status_successfully_obtained = status_successfully_obtained
|
|
+
|
|
+ def to_dict(self) -> Mapping[str, Any]:
|
|
+ return dict(
|
|
+ local_site=self.local_site,
|
|
+ site_role=self.site_role.value,
|
|
+ status_plaintext=self.status_plaintext,
|
|
+ status_successfully_obtained=self.status_successfully_obtained,
|
|
+ )
|
|
+
|
|
+ @classmethod
|
|
+ def from_dict(cls, payload: Mapping[str, Any]) -> "DrSiteStatusDto":
|
|
+ return cls(
|
|
+ payload["local_site"],
|
|
+ DrRole(payload["site_role"]),
|
|
+ payload["status_plaintext"],
|
|
+ payload["status_successfully_obtained"],
|
|
+ )
|
|
diff --git a/pcs/common/file_type_codes.py b/pcs/common/file_type_codes.py
|
|
index 9c801180..967aa76b 100644
|
|
--- a/pcs/common/file_type_codes.py
|
|
+++ b/pcs/common/file_type_codes.py
|
|
@@ -1,11 +1,16 @@
|
|
-BOOTH_CONFIG = "BOOTH_CONFIG"
|
|
-BOOTH_KEY = "BOOTH_KEY"
|
|
-CIB = "CIB"
|
|
-COROSYNC_AUTHKEY = "COROSYNC_AUTHKEY"
|
|
-COROSYNC_CONF = "COROSYNC_CONF"
|
|
-PACEMAKER_AUTHKEY = "PACEMAKER_AUTHKEY"
|
|
-PCSD_ENVIRONMENT_CONFIG = "PCSD_ENVIRONMENT_CONFIG"
|
|
-PCSD_SSL_CERT = "PCSD_SSL_CERT"
|
|
-PCSD_SSL_KEY = "PCSD_SSL_KEY"
|
|
-PCS_KNOWN_HOSTS = "PCS_KNOWN_HOSTS"
|
|
-PCS_SETTINGS_CONF = "PCS_SETTINGS_CONF"
|
|
+from typing import NewType
|
|
+
|
|
+FileTypeCode = NewType("FileTypeCode", str)
|
|
+
|
|
+BOOTH_CONFIG = FileTypeCode("BOOTH_CONFIG")
|
|
+BOOTH_KEY = FileTypeCode("BOOTH_KEY")
|
|
+CIB = FileTypeCode("CIB")
|
|
+COROSYNC_AUTHKEY = FileTypeCode("COROSYNC_AUTHKEY")
|
|
+COROSYNC_CONF = FileTypeCode("COROSYNC_CONF")
|
|
+PACEMAKER_AUTHKEY = FileTypeCode("PACEMAKER_AUTHKEY")
|
|
+PCSD_ENVIRONMENT_CONFIG = FileTypeCode("PCSD_ENVIRONMENT_CONFIG")
|
|
+PCSD_SSL_CERT = FileTypeCode("PCSD_SSL_CERT")
|
|
+PCSD_SSL_KEY = FileTypeCode("PCSD_SSL_KEY")
|
|
+PCS_KNOWN_HOSTS = FileTypeCode("PCS_KNOWN_HOSTS")
|
|
+PCS_SETTINGS_CONF = FileTypeCode("PCS_SETTINGS_CONF")
|
|
+PCS_DR_CONFIG = FileTypeCode("PCS_DR_CONFIG")
|
|
diff --git a/pcs/common/report_codes.py b/pcs/common/report_codes.py
|
|
index 4e3433a8..514ac079 100644
|
|
--- a/pcs/common/report_codes.py
|
|
+++ b/pcs/common/report_codes.py
|
|
@@ -141,6 +141,8 @@ COROSYNC_TRANSPORT_UNSUPPORTED_OPTIONS = "COROSYNC_TRANSPORT_UNSUPPORTED_OPTIONS
|
|
CRM_MON_ERROR = "CRM_MON_ERROR"
|
|
DEFAULTS_CAN_BE_OVERRIDEN = "DEFAULTS_CAN_BE_OVERRIDEN"
|
|
DEPRECATED_OPTION = "DEPRECATED_OPTION"
|
|
+DR_CONFIG_ALREADY_EXIST = "DR_CONFIG_ALREADY_EXIST"
|
|
+DR_CONFIG_DOES_NOT_EXIST = "DR_CONFIG_DOES_NOT_EXIST"
|
|
DUPLICATE_CONSTRAINTS_EXIST = "DUPLICATE_CONSTRAINTS_EXIST"
|
|
EMPTY_RESOURCE_SET_LIST = "EMPTY_RESOURCE_SET_LIST"
|
|
EMPTY_ID = "EMPTY_ID"
|
|
@@ -203,6 +205,7 @@ NONE_HOST_FOUND = "NONE_HOST_FOUND"
|
|
NODE_USED_AS_TIE_BREAKER = "NODE_USED_AS_TIE_BREAKER"
|
|
NODES_TO_REMOVE_UNREACHABLE = "NODES_TO_REMOVE_UNREACHABLE"
|
|
NODE_TO_CLEAR_IS_STILL_IN_CLUSTER = "NODE_TO_CLEAR_IS_STILL_IN_CLUSTER"
|
|
+NODE_IN_LOCAL_CLUSTER = "NODE_IN_LOCAL_CLUSTER"
|
|
OMITTING_NODE = "OMITTING_NODE"
|
|
OBJECT_WITH_ID_IN_UNEXPECTED_CONTEXT = "OBJECT_WITH_ID_IN_UNEXPECTED_CONTEXT"
|
|
PACEMAKER_LOCAL_NODE_NAME_NOT_FOUND = "PACEMAKER_LOCAL_NODE_NAME_NOT_FOUND"
|
|
diff --git a/pcs/lib/commands/cluster.py b/pcs/lib/commands/cluster.py
|
|
index 64015864..f30dcb25 100644
|
|
--- a/pcs/lib/commands/cluster.py
|
|
+++ b/pcs/lib/commands/cluster.py
|
|
@@ -777,7 +777,7 @@ def add_nodes(
|
|
skip_wrong_config=force,
|
|
)
|
|
|
|
- # distribute corosync and pacemaker authkeys
|
|
+ # distribute corosync and pacemaker authkeys and other config files
|
|
files_action = {}
|
|
forceable_io_error_creator = reports.get_problem_creator(
|
|
report_codes.SKIP_FILE_DISTRIBUTION_ERRORS, force
|
|
@@ -814,6 +814,22 @@ def add_nodes(
|
|
file_path=settings.pacemaker_authkey_file,
|
|
))
|
|
|
|
+ if os.path.isfile(settings.pcsd_dr_config_location):
|
|
+ try:
|
|
+ files_action.update(
|
|
+ node_communication_format.pcs_dr_config_file(
|
|
+ open(settings.pcsd_dr_config_location, "rb").read()
|
|
+ )
|
|
+ )
|
|
+ except EnvironmentError as e:
|
|
+ report_processor.report(forceable_io_error_creator(
|
|
+ reports.file_io_error,
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ RawFileError.ACTION_READ,
|
|
+ format_environment_error(e),
|
|
+ file_path=settings.pcsd_dr_config_location,
|
|
+ ))
|
|
+
|
|
# pcs_settings.conf was previously synced using pcsdcli send_local_configs.
|
|
# This has been changed temporarily until new system for distribution and
|
|
# syncronization of configs will be introduced.
|
|
diff --git a/pcs/lib/commands/dr.py b/pcs/lib/commands/dr.py
|
|
new file mode 100644
|
|
index 00000000..41ddb5cb
|
|
--- /dev/null
|
|
+++ b/pcs/lib/commands/dr.py
|
|
@@ -0,0 +1,316 @@
|
|
+from typing import (
|
|
+ Any,
|
|
+ Container,
|
|
+ Iterable,
|
|
+ List,
|
|
+ Mapping,
|
|
+ Tuple,
|
|
+)
|
|
+
|
|
+from pcs.common import file_type_codes, report_codes
|
|
+from pcs.common.dr import (
|
|
+ DrConfigDto,
|
|
+ DrConfigNodeDto,
|
|
+ DrConfigSiteDto,
|
|
+ DrSiteStatusDto,
|
|
+)
|
|
+from pcs.common.file import RawFileError
|
|
+from pcs.common.node_communicator import RequestTarget
|
|
+from pcs.common.reports import SimpleReportProcessor
|
|
+
|
|
+from pcs.lib import node_communication_format, reports
|
|
+from pcs.lib.communication.corosync import GetCorosyncConf
|
|
+from pcs.lib.communication.nodes import (
|
|
+ DistributeFilesWithoutForces,
|
|
+ RemoveFilesWithoutForces,
|
|
+)
|
|
+from pcs.lib.communication.status import GetFullClusterStatusPlaintext
|
|
+from pcs.lib.communication.tools import (
|
|
+ run as run_com_cmd,
|
|
+ run_and_raise,
|
|
+)
|
|
+from pcs.lib.corosync.config_facade import ConfigFacade as CorosyncConfigFacade
|
|
+from pcs.lib.dr.config.facade import (
|
|
+ DrRole,
|
|
+ Facade as DrConfigFacade,
|
|
+)
|
|
+from pcs.lib.env import LibraryEnvironment
|
|
+from pcs.lib.errors import LibraryError, ReportItemList
|
|
+from pcs.lib.file.instance import FileInstance
|
|
+from pcs.lib.file.raw_file import raw_file_error_report
|
|
+from pcs.lib.file.toolbox import for_file_type as get_file_toolbox
|
|
+from pcs.lib.interface.config import ParserErrorException
|
|
+from pcs.lib.node import get_existing_nodes_names
|
|
+
|
|
+
|
|
+def get_config(env: LibraryEnvironment) -> Mapping[str, Any]:
|
|
+ """
|
|
+ Return local disaster recovery config
|
|
+
|
|
+ env -- LibraryEnvironment
|
|
+ """
|
|
+ report_processor = SimpleReportProcessor(env.report_processor)
|
|
+ report_list, dr_config = _load_dr_config(env.get_dr_env().config)
|
|
+ report_processor.report_list(report_list)
|
|
+ if report_processor.has_errors:
|
|
+ raise LibraryError()
|
|
+
|
|
+ return DrConfigDto(
|
|
+ DrConfigSiteDto(
|
|
+ dr_config.local_role,
|
|
+ []
|
|
+ ),
|
|
+ [
|
|
+ DrConfigSiteDto(
|
|
+ site.role,
|
|
+ [DrConfigNodeDto(name) for name in site.node_name_list]
|
|
+ )
|
|
+ for site in dr_config.get_remote_site_list()
|
|
+ ]
|
|
+ ).to_dict()
|
|
+
|
|
+
|
|
+def set_recovery_site(env: LibraryEnvironment, node_name: str) -> None:
|
|
+ """
|
|
+ Set up disaster recovery with the local cluster being the primary site
|
|
+
|
|
+ env
|
|
+ node_name -- a known host from the recovery site
|
|
+ """
|
|
+ if env.ghost_file_codes:
|
|
+ raise LibraryError(
|
|
+ reports.live_environment_required(env.ghost_file_codes)
|
|
+ )
|
|
+ report_processor = SimpleReportProcessor(env.report_processor)
|
|
+ dr_env = env.get_dr_env()
|
|
+ if dr_env.config.raw_file.exists():
|
|
+ report_processor.report(reports.dr_config_already_exist())
|
|
+ target_factory = env.get_node_target_factory()
|
|
+
|
|
+ local_nodes, report_list = get_existing_nodes_names(
|
|
+ env.get_corosync_conf(),
|
|
+ error_on_missing_name=True
|
|
+ )
|
|
+ report_processor.report_list(report_list)
|
|
+
|
|
+ if node_name in local_nodes:
|
|
+ report_processor.report(reports.node_in_local_cluster(node_name))
|
|
+
|
|
+ report_list, local_targets = target_factory.get_target_list_with_reports(
|
|
+ local_nodes, allow_skip=False, report_none_host_found=False
|
|
+ )
|
|
+ report_processor.report_list(report_list)
|
|
+
|
|
+ report_list, remote_targets = (
|
|
+ target_factory.get_target_list_with_reports(
|
|
+ [node_name], allow_skip=False, report_none_host_found=False
|
|
+ )
|
|
+ )
|
|
+ report_processor.report_list(report_list)
|
|
+
|
|
+ if report_processor.has_errors:
|
|
+ raise LibraryError()
|
|
+
|
|
+ com_cmd = GetCorosyncConf(env.report_processor)
|
|
+ com_cmd.set_targets(remote_targets)
|
|
+ remote_cluster_nodes, report_list = get_existing_nodes_names(
|
|
+ CorosyncConfigFacade.from_string(
|
|
+ run_and_raise(env.get_node_communicator(), com_cmd)
|
|
+ ),
|
|
+ error_on_missing_name=True
|
|
+ )
|
|
+ if report_processor.report_list(report_list):
|
|
+ raise LibraryError()
|
|
+
|
|
+ # ensure we have tokens for all nodes of remote cluster
|
|
+ report_list, remote_targets = target_factory.get_target_list_with_reports(
|
|
+ remote_cluster_nodes, allow_skip=False, report_none_host_found=False
|
|
+ )
|
|
+ if report_processor.report_list(report_list):
|
|
+ raise LibraryError()
|
|
+ dr_config_exporter = (
|
|
+ get_file_toolbox(file_type_codes.PCS_DR_CONFIG).exporter
|
|
+ )
|
|
+ # create dr config for remote cluster
|
|
+ remote_dr_cfg = dr_env.create_facade(DrRole.RECOVERY)
|
|
+ remote_dr_cfg.add_site(DrRole.PRIMARY, local_nodes)
|
|
+ # send config to all node of remote cluster
|
|
+ distribute_file_cmd = DistributeFilesWithoutForces(
|
|
+ env.report_processor,
|
|
+ node_communication_format.pcs_dr_config_file(
|
|
+ dr_config_exporter.export(remote_dr_cfg.config)
|
|
+ )
|
|
+ )
|
|
+ distribute_file_cmd.set_targets(remote_targets)
|
|
+ run_and_raise(env.get_node_communicator(), distribute_file_cmd)
|
|
+ # create new dr config, with local cluster as primary site
|
|
+ local_dr_cfg = dr_env.create_facade(DrRole.PRIMARY)
|
|
+ local_dr_cfg.add_site(DrRole.RECOVERY, remote_cluster_nodes)
|
|
+ distribute_file_cmd = DistributeFilesWithoutForces(
|
|
+ env.report_processor,
|
|
+ node_communication_format.pcs_dr_config_file(
|
|
+ dr_config_exporter.export(local_dr_cfg.config)
|
|
+ )
|
|
+ )
|
|
+ distribute_file_cmd.set_targets(local_targets)
|
|
+ run_and_raise(env.get_node_communicator(), distribute_file_cmd)
|
|
+ # Note: No token sync across multiple clusters. Most probably they are in
|
|
+ # different subnetworks.
|
|
+
|
|
+
|
|
+def status_all_sites_plaintext(
|
|
+ env: LibraryEnvironment,
|
|
+ hide_inactive_resources: bool = False,
|
|
+ verbose: bool = False,
|
|
+) -> List[Mapping[str, Any]]:
|
|
+ """
|
|
+ Return local site's and all remote sites' status as plaintext
|
|
+
|
|
+ env -- LibraryEnvironment
|
|
+ hide_inactive_resources -- if True, do not display non-running resources
|
|
+ verbose -- if True, display more info
|
|
+ """
|
|
+ # The command does not provide an option to skip offline / unreacheable /
|
|
+ # misbehaving nodes.
|
|
+ # The point of such skipping is to stop a command if it is unable to make
|
|
+ # changes on all nodes. The user can then decide to proceed anyway and
|
|
+ # make changes on the skipped nodes later manually.
|
|
+ # This command only reads from nodes so it automatically asks other nodes
|
|
+ # if one is offline / misbehaving.
|
|
+ class SiteData():
|
|
+ local: bool
|
|
+ role: DrRole
|
|
+ target_list: Iterable[RequestTarget]
|
|
+ status_loaded: bool
|
|
+ status_plaintext: str
|
|
+
|
|
+ def __init__(self, local, role, target_list):
|
|
+ self.local = local
|
|
+ self.role = role
|
|
+ self.target_list = target_list
|
|
+ self.status_loaded = False
|
|
+ self.status_plaintext = ""
|
|
+
|
|
+
|
|
+ if env.ghost_file_codes:
|
|
+ raise LibraryError(
|
|
+ reports.live_environment_required(env.ghost_file_codes)
|
|
+ )
|
|
+
|
|
+ report_processor = SimpleReportProcessor(env.report_processor)
|
|
+ report_list, dr_config = _load_dr_config(env.get_dr_env().config)
|
|
+ report_processor.report_list(report_list)
|
|
+ if report_processor.has_errors:
|
|
+ raise LibraryError()
|
|
+
|
|
+ site_data_list = []
|
|
+ target_factory = env.get_node_target_factory()
|
|
+
|
|
+ # get local nodes
|
|
+ local_nodes, report_list = get_existing_nodes_names(env.get_corosync_conf())
|
|
+ report_processor.report_list(report_list)
|
|
+ report_list, local_targets = target_factory.get_target_list_with_reports(
|
|
+ local_nodes,
|
|
+ skip_non_existing=True,
|
|
+ )
|
|
+ report_processor.report_list(report_list)
|
|
+ site_data_list.append(SiteData(True, dr_config.local_role, local_targets))
|
|
+
|
|
+ # get remote sites' nodes
|
|
+ for conf_remote_site in dr_config.get_remote_site_list():
|
|
+ report_list, remote_targets = (
|
|
+ target_factory.get_target_list_with_reports(
|
|
+ conf_remote_site.node_name_list,
|
|
+ skip_non_existing=True,
|
|
+ )
|
|
+ )
|
|
+ report_processor.report_list(report_list)
|
|
+ site_data_list.append(
|
|
+ SiteData(False, conf_remote_site.role, remote_targets)
|
|
+ )
|
|
+ if report_processor.has_errors:
|
|
+ raise LibraryError()
|
|
+
|
|
+ # get all statuses
|
|
+ for site_data in site_data_list:
|
|
+ com_cmd = GetFullClusterStatusPlaintext(
|
|
+ report_processor,
|
|
+ hide_inactive_resources=hide_inactive_resources,
|
|
+ verbose=verbose,
|
|
+ )
|
|
+ com_cmd.set_targets(site_data.target_list)
|
|
+ site_data.status_loaded, site_data.status_plaintext = run_com_cmd(
|
|
+ env.get_node_communicator(), com_cmd
|
|
+ )
|
|
+
|
|
+ return [
|
|
+ DrSiteStatusDto(
|
|
+ site_data.local,
|
|
+ site_data.role,
|
|
+ site_data.status_plaintext,
|
|
+ site_data.status_loaded,
|
|
+ ).to_dict()
|
|
+ for site_data in site_data_list
|
|
+ ]
|
|
+
|
|
+def _load_dr_config(
|
|
+ config_file: FileInstance,
|
|
+) -> Tuple[ReportItemList, DrConfigFacade]:
|
|
+ if not config_file.raw_file.exists():
|
|
+ return [reports.dr_config_does_not_exist()], DrConfigFacade.empty()
|
|
+ try:
|
|
+ return [], config_file.read_to_facade()
|
|
+ except RawFileError as e:
|
|
+ return [raw_file_error_report(e)], DrConfigFacade.empty()
|
|
+ except ParserErrorException as e:
|
|
+ return (
|
|
+ config_file.parser_exception_to_report_list(e),
|
|
+ DrConfigFacade.empty()
|
|
+ )
|
|
+
|
|
+
|
|
+def destroy(env: LibraryEnvironment, force_flags: Container[str] = ()) -> None:
|
|
+ """
|
|
+ Destroy disaster-recovery configuration on all sites
|
|
+ """
|
|
+ if env.ghost_file_codes:
|
|
+ raise LibraryError(
|
|
+ reports.live_environment_required(env.ghost_file_codes)
|
|
+ )
|
|
+
|
|
+ report_processor = SimpleReportProcessor(env.report_processor)
|
|
+ skip_offline = report_codes.SKIP_OFFLINE_NODES in force_flags
|
|
+
|
|
+ report_list, dr_config = _load_dr_config(env.get_dr_env().config)
|
|
+ report_processor.report_list(report_list)
|
|
+
|
|
+ if report_processor.has_errors:
|
|
+ raise LibraryError()
|
|
+
|
|
+ local_nodes, report_list = get_existing_nodes_names(env.get_corosync_conf())
|
|
+ report_processor.report_list(report_list)
|
|
+
|
|
+ if report_processor.has_errors:
|
|
+ raise LibraryError()
|
|
+
|
|
+ remote_nodes: List[str] = []
|
|
+ for conf_remote_site in dr_config.get_remote_site_list():
|
|
+ remote_nodes.extend(conf_remote_site.node_name_list)
|
|
+
|
|
+ target_factory = env.get_node_target_factory()
|
|
+ report_list, targets = target_factory.get_target_list_with_reports(
|
|
+ remote_nodes + local_nodes, skip_non_existing=skip_offline,
|
|
+ )
|
|
+ report_processor.report_list(report_list)
|
|
+ if report_processor.has_errors:
|
|
+ raise LibraryError()
|
|
+
|
|
+ com_cmd = RemoveFilesWithoutForces(
|
|
+ env.report_processor, {
|
|
+ "pcs disaster-recovery config": {
|
|
+ "type": "pcs_disaster_recovery_conf",
|
|
+ },
|
|
+ },
|
|
+ )
|
|
+ com_cmd.set_targets(targets)
|
|
+ run_and_raise(env.get_node_communicator(), com_cmd)
|
|
diff --git a/pcs/lib/communication/corosync.py b/pcs/lib/communication/corosync.py
|
|
index 0f3c3787..1a78e0de 100644
|
|
--- a/pcs/lib/communication/corosync.py
|
|
+++ b/pcs/lib/communication/corosync.py
|
|
@@ -138,3 +138,31 @@ class ReloadCorosyncConf(
|
|
def on_complete(self):
|
|
if not self.__was_successful and self.__has_failures:
|
|
self._report(reports.unable_to_perform_operation_on_any_node())
|
|
+
|
|
+
|
|
+class GetCorosyncConf(
|
|
+ AllSameDataMixin, OneByOneStrategyMixin, RunRemotelyBase
|
|
+):
|
|
+ __was_successful = False
|
|
+ __has_failures = False
|
|
+ __corosync_conf = None
|
|
+
|
|
+ def _get_request_data(self):
|
|
+ return RequestData("remote/get_corosync_conf")
|
|
+
|
|
+ def _process_response(self, response):
|
|
+ report = response_to_report_item(
|
|
+ response, severity=ReportItemSeverity.WARNING
|
|
+ )
|
|
+ if report is not None:
|
|
+ self.__has_failures = True
|
|
+ self._report(report)
|
|
+ return self._get_next_list()
|
|
+ self.__corosync_conf = response.data
|
|
+ self.__was_successful = True
|
|
+ return []
|
|
+
|
|
+ def on_complete(self):
|
|
+ if not self.__was_successful and self.__has_failures:
|
|
+ self._report(reports.unable_to_perform_operation_on_any_node())
|
|
+ return self.__corosync_conf
|
|
diff --git a/pcs/lib/communication/status.py b/pcs/lib/communication/status.py
|
|
new file mode 100644
|
|
index 00000000..3470415a
|
|
--- /dev/null
|
|
+++ b/pcs/lib/communication/status.py
|
|
@@ -0,0 +1,97 @@
|
|
+import json
|
|
+from typing import Tuple
|
|
+
|
|
+from pcs.common.node_communicator import RequestData
|
|
+from pcs.lib import reports
|
|
+from pcs.lib.communication.tools import (
|
|
+ AllSameDataMixin,
|
|
+ OneByOneStrategyMixin,
|
|
+ RunRemotelyBase,
|
|
+)
|
|
+from pcs.lib.errors import ReportItemSeverity
|
|
+from pcs.lib.node_communication import response_to_report_item
|
|
+
|
|
+
|
|
+class GetFullClusterStatusPlaintext(
|
|
+ AllSameDataMixin, OneByOneStrategyMixin, RunRemotelyBase
|
|
+):
|
|
+ def __init__(
|
|
+ self, report_processor, hide_inactive_resources=False, verbose=False
|
|
+ ):
|
|
+ super().__init__(report_processor)
|
|
+ self._hide_inactive_resources = hide_inactive_resources
|
|
+ self._verbose = verbose
|
|
+ self._cluster_status = ""
|
|
+ self._was_successful = False
|
|
+
|
|
+ def _get_request_data(self):
|
|
+ return RequestData(
|
|
+ "remote/cluster_status_plaintext",
|
|
+ [
|
|
+ (
|
|
+ "data_json",
|
|
+ json.dumps(dict(
|
|
+ hide_inactive_resources=self._hide_inactive_resources,
|
|
+ verbose=self._verbose,
|
|
+ ))
|
|
+ )
|
|
+ ],
|
|
+ )
|
|
+
|
|
+ def _process_response(self, response):
|
|
+ report = response_to_report_item(
|
|
+ response, severity=ReportItemSeverity.WARNING
|
|
+ )
|
|
+ if report is not None:
|
|
+ self._report(report)
|
|
+ return self._get_next_list()
|
|
+
|
|
+ node = response.request.target.label
|
|
+ try:
|
|
+ output = json.loads(response.data)
|
|
+ if output["status"] == "success":
|
|
+ self._was_successful = True
|
|
+ self._cluster_status = output["data"]
|
|
+ return []
|
|
+ if output["status_msg"]:
|
|
+ self._report(
|
|
+ reports.node_communication_command_unsuccessful(
|
|
+ node,
|
|
+ response.request.action,
|
|
+ output["status_msg"]
|
|
+ )
|
|
+ )
|
|
+ # TODO Node name should be added to each received report item and
|
|
+ # those modified report itemss should be reported. That, however,
|
|
+ # requires reports overhaul which would add posibility to add a
|
|
+ # node name to any report item. Also, infos and warnings should not
|
|
+ # be ignored.
|
|
+ if output["report_list"]:
|
|
+ for report_data in output["report_list"]:
|
|
+ if (
|
|
+ report_data["severity"] == ReportItemSeverity.ERROR
|
|
+ and
|
|
+ report_data["report_text"]
|
|
+ ):
|
|
+ self._report(
|
|
+ reports.node_communication_command_unsuccessful(
|
|
+ node,
|
|
+ response.request.action,
|
|
+ report_data["report_text"]
|
|
+ )
|
|
+ )
|
|
+ except (ValueError, LookupError, TypeError):
|
|
+ self._report(reports.invalid_response_format(
|
|
+ node,
|
|
+ severity=ReportItemSeverity.WARNING,
|
|
+ ))
|
|
+
|
|
+ return self._get_next_list()
|
|
+
|
|
+ def on_complete(self) -> Tuple[bool, str]:
|
|
+ # Usually, reports.unable_to_perform_operation_on_any_node is reported
|
|
+ # when the operation was unsuccessful and failed on at least one node.
|
|
+ # The only use case this communication command is used does not need
|
|
+ # that report and on top of that the report causes confusing ouptut for
|
|
+ # the user. The report may be added in a future if needed.
|
|
+ return self._was_successful, self._cluster_status
|
|
diff --git a/pcs/lib/dr/__init__.py b/pcs/lib/dr/__init__.py
|
|
new file mode 100644
|
|
index 00000000..e69de29b
|
|
diff --git a/pcs/lib/dr/config/__init__.py b/pcs/lib/dr/config/__init__.py
|
|
new file mode 100644
|
|
index 00000000..e69de29b
|
|
diff --git a/pcs/lib/dr/config/facade.py b/pcs/lib/dr/config/facade.py
|
|
new file mode 100644
|
|
index 00000000..f3187ba5
|
|
--- /dev/null
|
|
+++ b/pcs/lib/dr/config/facade.py
|
|
@@ -0,0 +1,49 @@
|
|
+from typing import (
|
|
+ Iterable,
|
|
+ List,
|
|
+ NamedTuple,
|
|
+)
|
|
+
|
|
+from pcs.common.dr import DrRole
|
|
+from pcs.lib.interface.config import FacadeInterface
|
|
+
|
|
+
|
|
+class DrSite(NamedTuple):
|
|
+ role: DrRole
|
|
+ node_name_list: List[str]
|
|
+
|
|
+
|
|
+class Facade(FacadeInterface):
|
|
+ @classmethod
|
|
+ def create(cls, local_role: DrRole) -> "Facade":
|
|
+ return cls(dict(
|
|
+ local=dict(
|
|
+ role=local_role.value,
|
|
+ ),
|
|
+ remote_sites=[],
|
|
+ ))
|
|
+
|
|
+ @classmethod
|
|
+ def empty(cls) -> "Facade":
|
|
+ return cls(dict())
|
|
+
|
|
+ @property
|
|
+ def local_role(self) -> DrRole:
|
|
+ return DrRole(self._config["local"]["role"])
|
|
+
|
|
+ def add_site(self, role: DrRole, node_list: Iterable[str]) -> None:
|
|
+ self._config["remote_sites"].append(
|
|
+ dict(
|
|
+ role=role.value,
|
|
+ nodes=[dict(name=node) for node in node_list],
|
|
+ )
|
|
+ )
|
|
+
|
|
+ def get_remote_site_list(self) -> List[DrSite]:
|
|
+ return [
|
|
+ DrSite(
|
|
+ DrRole(conf_site["role"]),
|
|
+ [node["name"] for node in conf_site["nodes"]]
|
|
+ )
|
|
+ for conf_site in self._config.get("remote_sites", [])
|
|
+ ]
|
|
diff --git a/pcs/lib/dr/env.py b/pcs/lib/dr/env.py
|
|
new file mode 100644
|
|
index 00000000..c73ee622
|
|
--- /dev/null
|
|
+++ b/pcs/lib/dr/env.py
|
|
@@ -0,0 +1,28 @@
|
|
+from pcs.common import file_type_codes
|
|
+
|
|
+from pcs.lib.file.instance import FileInstance
|
|
+from pcs.lib.file.toolbox import (
|
|
+ for_file_type as get_file_toolbox,
|
|
+ FileToolbox,
|
|
+)
|
|
+
|
|
+from .config.facade import (
|
|
+ DrRole,
|
|
+ Facade,
|
|
+)
|
|
+
|
|
+class DrEnv:
|
|
+ def __init__(self):
|
|
+ self._config_file = FileInstance.for_dr_config()
|
|
+
|
|
+ @staticmethod
|
|
+ def create_facade(role: DrRole) -> Facade:
|
|
+ return Facade.create(role)
|
|
+
|
|
+ @property
|
|
+ def config(self) -> FileInstance:
|
|
+ return self._config_file
|
|
+
|
|
+ @staticmethod
|
|
+ def get_config_toolbox() -> FileToolbox:
|
|
+ return get_file_toolbox(file_type_codes.PCS_DR_CONFIG)
|
|
diff --git a/pcs/lib/env.py b/pcs/lib/env.py
|
|
index 66f7b1a4..0b12103e 100644
|
|
--- a/pcs/lib/env.py
|
|
+++ b/pcs/lib/env.py
|
|
@@ -3,11 +3,13 @@ from typing import (
|
|
)
|
|
from xml.etree.ElementTree import Element
|
|
|
|
+from pcs.common import file_type_codes
|
|
from pcs.common.node_communicator import Communicator, NodeCommunicatorFactory
|
|
from pcs.common.tools import Version
|
|
from pcs.lib import reports
|
|
from pcs.lib.booth.env import BoothEnv
|
|
from pcs.lib.cib.tools import get_cib_crm_feature_set
|
|
+from pcs.lib.dr.env import DrEnv
|
|
from pcs.lib.node import get_existing_nodes_names
|
|
from pcs.lib.communication import qdevice
|
|
from pcs.lib.communication.corosync import (
|
|
@@ -89,6 +91,7 @@ class LibraryEnvironment:
|
|
self._request_timeout
|
|
)
|
|
self.__loaded_booth_env = None
|
|
+ self.__loaded_dr_env = None
|
|
|
|
self.__timeout_cache = {}
|
|
|
|
@@ -108,6 +111,15 @@ class LibraryEnvironment:
|
|
def user_groups(self):
|
|
return self._user_groups
|
|
|
|
+ @property
|
|
+ def ghost_file_codes(self):
|
|
+ codes = set()
|
|
+ if not self.is_cib_live:
|
|
+ codes.add(file_type_codes.CIB)
|
|
+ if not self.is_corosync_conf_live:
|
|
+ codes.add(file_type_codes.COROSYNC_CONF)
|
|
+ return codes
|
|
+
|
|
def get_cib(self, minimal_version: Optional[Version] = None) -> Element:
|
|
if self.__loaded_cib_diff_source is not None:
|
|
raise AssertionError("CIB has already been loaded")
|
|
@@ -412,3 +424,8 @@ class LibraryEnvironment:
|
|
if self.__loaded_booth_env is None:
|
|
self.__loaded_booth_env = BoothEnv(name, self._booth_files_data)
|
|
return self.__loaded_booth_env
|
|
+
|
|
+ def get_dr_env(self) -> DrEnv:
|
|
+ if self.__loaded_dr_env is None:
|
|
+ self.__loaded_dr_env = DrEnv()
|
|
+ return self.__loaded_dr_env
|
|
diff --git a/pcs/lib/file/instance.py b/pcs/lib/file/instance.py
|
|
index da6b760c..f0812c2d 100644
|
|
--- a/pcs/lib/file/instance.py
|
|
+++ b/pcs/lib/file/instance.py
|
|
@@ -51,18 +51,27 @@ class FileInstance():
|
|
"""
|
|
Factory for known-hosts file
|
|
"""
|
|
- file_type_code = file_type_codes.PCS_KNOWN_HOSTS
|
|
- return cls(
|
|
- raw_file.RealFile(metadata.for_file_type(file_type_code)),
|
|
- toolbox.for_file_type(file_type_code)
|
|
- )
|
|
+ return cls._for_common(file_type_codes.PCS_KNOWN_HOSTS)
|
|
|
|
@classmethod
|
|
def for_pacemaker_key(cls):
|
|
"""
|
|
Factory for pacemaker key file
|
|
"""
|
|
- file_type_code = file_type_codes.PACEMAKER_AUTHKEY
|
|
+ return cls._for_common(file_type_codes.PACEMAKER_AUTHKEY)
|
|
+
|
|
+ @classmethod
|
|
+ def for_dr_config(cls) -> "FileInstance":
|
|
+ """
|
|
+ Factory for disaster-recovery config file
|
|
+ """
|
|
+ return cls._for_common(file_type_codes.PCS_DR_CONFIG)
|
|
+
|
|
+ @classmethod
|
|
+ def _for_common(
|
|
+ cls,
|
|
+ file_type_code: file_type_codes.FileTypeCode,
|
|
+ ) -> "FileInstance":
|
|
return cls(
|
|
raw_file.RealFile(metadata.for_file_type(file_type_code)),
|
|
toolbox.for_file_type(file_type_code)
|
|
diff --git a/pcs/lib/file/metadata.py b/pcs/lib/file/metadata.py
|
|
index 175e5ac1..72701aed 100644
|
|
--- a/pcs/lib/file/metadata.py
|
|
+++ b/pcs/lib/file/metadata.py
|
|
@@ -50,6 +50,14 @@ _metadata = {
|
|
permissions=0o600,
|
|
is_binary=False,
|
|
),
|
|
+ code.PCS_DR_CONFIG: lambda: FileMetadata(
|
|
+ file_type_code=code.PCS_DR_CONFIG,
|
|
+ path=settings.pcsd_dr_config_location,
|
|
+ owner_user_name="root",
|
|
+ owner_group_name="root",
|
|
+ permissions=0o600,
|
|
+ is_binary=False,
|
|
+ )
|
|
}
|
|
|
|
def for_file_type(file_type_code, *args, **kwargs):
|
|
diff --git a/pcs/lib/file/toolbox.py b/pcs/lib/file/toolbox.py
|
|
index 5d827887..db852617 100644
|
|
--- a/pcs/lib/file/toolbox.py
|
|
+++ b/pcs/lib/file/toolbox.py
|
|
@@ -1,4 +1,9 @@
|
|
-from collections import namedtuple
|
|
+from typing import (
|
|
+ Any,
|
|
+ Dict,
|
|
+ NamedTuple,
|
|
+ Type,
|
|
+)
|
|
import json
|
|
|
|
from pcs.common import file_type_codes as code
|
|
@@ -8,6 +13,8 @@ from pcs.lib.booth.config_parser import (
|
|
Exporter as BoothConfigExporter,
|
|
Parser as BoothConfigParser,
|
|
)
|
|
+from pcs.lib.dr.config.facade import Facade as DrConfigFacade
|
|
+from pcs.lib.errors import ReportItemList
|
|
from pcs.lib.interface.config import (
|
|
ExporterInterface,
|
|
FacadeInterface,
|
|
@@ -16,27 +23,23 @@ from pcs.lib.interface.config import (
|
|
)
|
|
|
|
|
|
-FileToolbox = namedtuple(
|
|
- "FileToolbox",
|
|
- [
|
|
- # File type code the toolbox belongs to
|
|
- "file_type_code",
|
|
- # Provides an easy access for reading and modifying data
|
|
- "facade",
|
|
- # Turns raw data into a structure which the facade is able to process
|
|
- "parser",
|
|
- # Turns a structure produced by the parser and the facade to raw data
|
|
- "exporter",
|
|
- # Checks that the structure is valid
|
|
- "validator",
|
|
- # Provides means for file syncing based on the file's version
|
|
- "version_controller",
|
|
- ]
|
|
-)
|
|
+class FileToolbox(NamedTuple):
|
|
+ # File type code the toolbox belongs to
|
|
+ file_type_code: code.FileTypeCode
|
|
+ # Provides an easy access for reading and modifying data
|
|
+ facade: Type[FacadeInterface]
|
|
+ # Turns raw data into a structure which the facade is able to process
|
|
+ parser: Type[ParserInterface]
|
|
+ # Turns a structure produced by the parser and the facade to raw data
|
|
+ exporter: Type[ExporterInterface]
|
|
+ # Checks that the structure is valid
|
|
+ validator: None # TBI
|
|
+ # Provides means for file syncing based on the file's version
|
|
+ version_controller: None # TBI
|
|
|
|
|
|
class JsonParserException(ParserErrorException):
|
|
- def __init__(self, json_exception):
|
|
+ def __init__(self, json_exception: json.JSONDecodeError):
|
|
super().__init__()
|
|
self.json_exception = json_exception
|
|
|
|
@@ -45,7 +48,7 @@ class JsonParser(ParserInterface):
|
|
Adapts standard json parser to our interfaces
|
|
"""
|
|
@staticmethod
|
|
- def parse(raw_file_data):
|
|
+ def parse(raw_file_data: bytes) -> Dict[str, Any]:
|
|
try:
|
|
# json.loads handles bytes, it expects utf-8, 16 or 32 encoding
|
|
return json.loads(raw_file_data)
|
|
@@ -54,8 +57,12 @@ class JsonParser(ParserInterface):
|
|
|
|
@staticmethod
|
|
def exception_to_report_list(
|
|
- exception, file_type_code, file_path, force_code, is_forced_or_warning
|
|
- ):
|
|
+ exception: JsonParserException,
|
|
+ file_type_code: code.FileTypeCode,
|
|
+ file_path: str,
|
|
+ force_code: str, # TODO: fix
|
|
+ is_forced_or_warning: bool
|
|
+ ) -> ReportItemList:
|
|
report_creator = reports.get_problem_creator(
|
|
force_code=force_code, is_forced=is_forced_or_warning
|
|
)
|
|
@@ -80,7 +87,7 @@ class JsonExporter(ExporterInterface):
|
|
Adapts standard json exporter to our interfaces
|
|
"""
|
|
@staticmethod
|
|
- def export(config_structure):
|
|
+ def export(config_structure: Dict[str, Any])-> bytes:
|
|
return json.dumps(
|
|
config_structure, indent=4, sort_keys=True,
|
|
).encode("utf-8")
|
|
@@ -88,23 +95,27 @@ class JsonExporter(ExporterInterface):
|
|
|
|
class NoopParser(ParserInterface):
|
|
@staticmethod
|
|
- def parse(raw_file_data):
|
|
+ def parse(raw_file_data: bytes) -> bytes:
|
|
return raw_file_data
|
|
|
|
@staticmethod
|
|
def exception_to_report_list(
|
|
- exception, file_type_code, file_path, force_code, is_forced_or_warning
|
|
- ):
|
|
+ exception: ParserErrorException,
|
|
+ file_type_code: code.FileTypeCode,
|
|
+ file_path: str,
|
|
+ force_code: str, # TODO: fix
|
|
+ is_forced_or_warning: bool
|
|
+ ) -> ReportItemList:
|
|
return []
|
|
|
|
class NoopExporter(ExporterInterface):
|
|
@staticmethod
|
|
- def export(config_structure):
|
|
+ def export(config_structure: bytes) -> bytes:
|
|
return config_structure
|
|
|
|
class NoopFacade(FacadeInterface):
|
|
@classmethod
|
|
- def create(cls):
|
|
+ def create(cls) -> "NoopFacade":
|
|
return cls(bytes())
|
|
|
|
|
|
@@ -135,7 +146,16 @@ _toolboxes = {
|
|
),
|
|
code.PCS_KNOWN_HOSTS: FileToolbox(
|
|
file_type_code=code.PCS_KNOWN_HOSTS,
|
|
- facade=None, # TODO needed for 'auth' and 'deauth' commands
|
|
+ # TODO needed for 'auth' and 'deauth' commands
|
|
+ facade=None, # type: ignore
|
|
+ parser=JsonParser,
|
|
+ exporter=JsonExporter,
|
|
+ validator=None, # TODO needed for files syncing
|
|
+ version_controller=None, # TODO needed for files syncing
|
|
+ ),
|
|
+ code.PCS_DR_CONFIG: FileToolbox(
|
|
+ file_type_code=code.PCS_DR_CONFIG,
|
|
+ facade=DrConfigFacade,
|
|
parser=JsonParser,
|
|
exporter=JsonExporter,
|
|
validator=None, # TODO needed for files syncing
|
|
@@ -143,5 +163,5 @@ _toolboxes = {
|
|
),
|
|
}
|
|
|
|
-def for_file_type(file_type_code):
|
|
+def for_file_type(file_type_code: code.FileTypeCode) -> FileToolbox:
|
|
return _toolboxes[file_type_code]
|
|
diff --git a/pcs/lib/node.py b/pcs/lib/node.py
|
|
index 1930ffa8..09543c8e 100644
|
|
--- a/pcs/lib/node.py
|
|
+++ b/pcs/lib/node.py
|
|
@@ -1,5 +1,6 @@
|
|
from typing import (
|
|
Iterable,
|
|
+ List,
|
|
Optional,
|
|
Tuple,
|
|
)
|
|
@@ -18,7 +19,7 @@ def get_existing_nodes_names(
|
|
corosync_conf: Optional[CorosyncConfigFacade] = None,
|
|
cib: Optional[Element] = None,
|
|
error_on_missing_name: bool = False
|
|
-) -> Tuple[Iterable[str], ReportItemList]:
|
|
+) -> Tuple[List[str], ReportItemList]:
|
|
return __get_nodes_names(
|
|
*__get_nodes(corosync_conf, cib),
|
|
error_on_missing_name
|
|
@@ -56,7 +57,7 @@ def __get_nodes_names(
|
|
corosync_nodes: Iterable[CorosyncNode],
|
|
remote_and_guest_nodes: Iterable[PacemakerNode],
|
|
error_on_missing_name: bool = False
|
|
-) -> Tuple[Iterable[str], ReportItemList]:
|
|
+) -> Tuple[List[str], ReportItemList]:
|
|
report_list = []
|
|
corosync_names = []
|
|
name_missing_in_corosync = False
|
|
diff --git a/pcs/lib/node_communication_format.py b/pcs/lib/node_communication_format.py
|
|
index 6134c66d..1cef35b4 100644
|
|
--- a/pcs/lib/node_communication_format.py
|
|
+++ b/pcs/lib/node_communication_format.py
|
|
@@ -1,5 +1,9 @@
|
|
import base64
|
|
from collections import namedtuple
|
|
+from typing import (
|
|
+ Any,
|
|
+ Dict,
|
|
+)
|
|
|
|
from pcs.lib import reports
|
|
from pcs.lib.errors import LibraryError
|
|
@@ -55,6 +59,18 @@ def corosync_conf_file(corosync_conf_content):
|
|
"corosync.conf": corosync_conf_format(corosync_conf_content)
|
|
}
|
|
|
|
+def pcs_dr_config_format(dr_conf_content: bytes) -> Dict[str, Any]:
|
|
+ return {
|
|
+ "type": "pcs_disaster_recovery_conf",
|
|
+ "data": base64.b64encode(dr_conf_content).decode("utf-8"),
|
|
+ "rewrite_existing": True,
|
|
+ }
|
|
+
|
|
+def pcs_dr_config_file(dr_conf_content: bytes) -> Dict[str, Any]:
|
|
+ return {
|
|
+ "disaster-recovery config": pcs_dr_config_format(dr_conf_content)
|
|
+ }
|
|
+
|
|
def pcs_settings_conf_format(content):
|
|
return {
|
|
"data": content,
|
|
diff --git a/pcs/lib/reports.py b/pcs/lib/reports.py
|
|
index e83737b0..1f081007 100644
|
|
--- a/pcs/lib/reports.py
|
|
+++ b/pcs/lib/reports.py
|
|
@@ -4221,3 +4221,34 @@ def resource_disable_affects_other_resources(
|
|
"crm_simulate_plaintext_output": crm_simulate_plaintext_output,
|
|
}
|
|
)
|
|
+
|
|
+
|
|
+def dr_config_already_exist():
|
|
+ """
|
|
+ Disaster recovery config exists when the opposite was expected
|
|
+ """
|
|
+ return ReportItem.error(
|
|
+ report_codes.DR_CONFIG_ALREADY_EXIST,
|
|
+ )
|
|
+
|
|
+def dr_config_does_not_exist():
|
|
+ """
|
|
+ Disaster recovery config does not exist when the opposite was expected
|
|
+ """
|
|
+ return ReportItem.error(
|
|
+ report_codes.DR_CONFIG_DOES_NOT_EXIST,
|
|
+ )
|
|
+
|
|
+def node_in_local_cluster(node):
|
|
+ """
|
|
+ Node is part of local cluster and it cannot be used for example to set up
|
|
+ disaster-recovery site
|
|
+
|
|
+ node -- node which is part of local cluster
|
|
+ """
|
|
+ return ReportItem.error(
|
|
+ report_codes.NODE_IN_LOCAL_CLUSTER,
|
|
+ info=dict(
|
|
+ node=node,
|
|
+ ),
|
|
+ )
|
|
diff --git a/pcs/pcs.8 b/pcs/pcs.8
|
|
index 5765c6b5..651fda83 100644
|
|
--- a/pcs/pcs.8
|
|
+++ b/pcs/pcs.8
|
|
@@ -75,6 +75,9 @@ alert
|
|
.TP
|
|
client
|
|
Manage pcsd client configuration.
|
|
+.TP
|
|
+dr
|
|
+ Manage disaster recovery configuration.
|
|
.SS "resource"
|
|
.TP
|
|
[status [\fB\-\-hide\-inactive\fR]]
|
|
@@ -887,7 +890,7 @@ stop
|
|
Stop booth arbitrator service.
|
|
.SS "status"
|
|
.TP
|
|
-[status] [\fB\-\-full\fR | \fB\-\-hide\-inactive\fR]
|
|
+[status] [\fB\-\-full\fR] [\fB\-\-hide\-inactive\fR]
|
|
View all information about the cluster and resources (\fB\-\-full\fR provides more details, \fB\-\-hide\-inactive\fR hides inactive resources).
|
|
.TP
|
|
resources [\fB\-\-hide\-inactive\fR]
|
|
@@ -1015,6 +1018,19 @@ Remove specified recipients.
|
|
.TP
|
|
local-auth [<pcsd\-port>] [\-u <username>] [\-p <password>]
|
|
Authenticate current user to local pcsd. This is required to run some pcs commands which may require permissions of root user such as 'pcs cluster start'.
|
|
+.SS "dr"
|
|
+.TP
|
|
+config
|
|
+Display disaster-recovery configuration from the local node.
|
|
+.TP
|
|
+status [\fB\-\-full\fR] [\fB\-\-hide\-inactive\fR]
|
|
+Display status of the local and the remote site cluster (\fB\-\-full\fR provides more details, \fB\-\-hide\-inactive\fR hides inactive resources).
|
|
+.TP
|
|
+set\-recovery\-site <recovery site node>
|
|
+Set up disaster\-recovery with the local cluster being the primary site. The recovery site is defined by a name of one of its nodes.
|
|
+.TP
|
|
+destroy
|
|
+Permanently destroy disaster-recovery configuration on all sites.
|
|
.SH EXAMPLES
|
|
.TP
|
|
Show all resources
|
|
diff --git a/pcs/pcs_internal.py b/pcs/pcs_internal.py
|
|
index fecdc8d5..d956d71e 100644
|
|
--- a/pcs/pcs_internal.py
|
|
+++ b/pcs/pcs_internal.py
|
|
@@ -22,6 +22,7 @@ SUPPORTED_COMMANDS = {
|
|
"cluster.setup",
|
|
"cluster.add_nodes",
|
|
"cluster.remove_nodes",
|
|
+ "status.full_cluster_status_plaintext",
|
|
}
|
|
|
|
|
|
diff --git a/pcs/settings_default.py b/pcs/settings_default.py
|
|
index ab61b20b..6d8f33ac 100644
|
|
--- a/pcs/settings_default.py
|
|
+++ b/pcs/settings_default.py
|
|
@@ -50,6 +50,7 @@ pcsd_users_conf_location = os.path.join(pcsd_var_location, "pcs_users.conf")
|
|
pcsd_settings_conf_location = os.path.join(
|
|
pcsd_var_location, "pcs_settings.conf"
|
|
)
|
|
+pcsd_dr_config_location = os.path.join(pcsd_var_location, "disaster-recovery")
|
|
pcsd_exec_location = "/usr/lib/pcsd/"
|
|
pcsd_log_location = "/var/log/pcsd/pcsd.log"
|
|
pcsd_default_port = 2224
|
|
diff --git a/pcs/usage.py b/pcs/usage.py
|
|
index 0b16289e..e4f5af32 100644
|
|
--- a/pcs/usage.py
|
|
+++ b/pcs/usage.py
|
|
@@ -22,6 +22,7 @@ def full_usage():
|
|
out += strip_extras(host([], False))
|
|
out += strip_extras(alert([], False))
|
|
out += strip_extras(client([], False))
|
|
+ out += strip_extras(dr([], False))
|
|
print(out.strip())
|
|
print("Examples:\n" + examples.replace(r" \ ", ""))
|
|
|
|
@@ -124,6 +125,7 @@ def generate_completion_tree_from_usage():
|
|
tree["alert"] = generate_tree(alert([], False))
|
|
tree["booth"] = generate_tree(booth([], False))
|
|
tree["client"] = generate_tree(client([], False))
|
|
+ tree["dr"] = generate_tree(dr([], False))
|
|
return tree
|
|
|
|
def generate_tree(usage_txt):
|
|
@@ -194,6 +196,7 @@ Commands:
|
|
node Manage cluster nodes.
|
|
alert Manage pacemaker alerts.
|
|
client Manage pcsd client configuration.
|
|
+ dr Manage disaster recovery configuration.
|
|
"""
|
|
# Advanced usage to possibly add later
|
|
# --corosync_conf=<corosync file> Specify alternative corosync.conf file
|
|
@@ -1517,7 +1520,7 @@ def status(args=(), pout=True):
|
|
Usage: pcs status [commands]...
|
|
View current cluster and resource status
|
|
Commands:
|
|
- [status] [--full | --hide-inactive]
|
|
+ [status] [--full] [--hide-inactive]
|
|
View all information about the cluster and resources (--full provides
|
|
more details, --hide-inactive hides inactive resources).
|
|
|
|
@@ -2019,6 +2022,32 @@ Commands:
|
|
return output
|
|
|
|
|
|
+def dr(args=(), pout=True):
|
|
+ output = """
|
|
+Usage: pcs dr <command>
|
|
+Manage disaster recovery configuration.
|
|
+
|
|
+Commands:
|
|
+ config
|
|
+ Display disaster-recovery configuration from the local node.
|
|
+
|
|
+ status [--full] [--hide-inactive]
|
|
+ Display status of the local and the remote site cluster (--full
|
|
+ provides more details, --hide-inactive hides inactive resources).
|
|
+
|
|
+ set-recovery-site <recovery site node>
|
|
+ Set up disaster-recovery with the local cluster being the primary site.
|
|
+ The recovery site is defined by a name of one of its nodes.
|
|
+
|
|
+ destroy
|
|
+ Permanently destroy disaster-recovery configuration on all sites.
|
|
+"""
|
|
+ if pout:
|
|
+ print(sub_usage(args, output))
|
|
+ return None
|
|
+ return output
|
|
+
|
|
+
|
|
def show(main_usage_name, rest_usage_names):
|
|
usage_map = {
|
|
"acl": acl,
|
|
@@ -2028,6 +2057,7 @@ def show(main_usage_name, rest_usage_names):
|
|
"cluster": cluster,
|
|
"config": config,
|
|
"constraint": constraint,
|
|
+ "dr": dr,
|
|
"host": host,
|
|
"node": node,
|
|
"pcsd": pcsd,
|
|
diff --git a/pcs_test/tier0/cli/common/test_console_report.py b/pcs_test/tier0/cli/common/test_console_report.py
|
|
index 2deb896d..0d0c2457 100644
|
|
--- a/pcs_test/tier0/cli/common/test_console_report.py
|
|
+++ b/pcs_test/tier0/cli/common/test_console_report.py
|
|
@@ -4489,3 +4489,27 @@ class ResourceDisableAffectsOtherResources(NameBuildTest):
|
|
"crm_simulate output",
|
|
)
|
|
)
|
|
+
|
|
+
|
|
+class DrConfigAlreadyExist(NameBuildTest):
|
|
+ def test_success(self):
|
|
+ self.assert_message_from_report(
|
|
+ "Disaster-recovery already configured",
|
|
+ reports.dr_config_already_exist()
|
|
+ )
|
|
+
|
|
+
|
|
+class DrConfigDoesNotExist(NameBuildTest):
|
|
+ def test_success(self):
|
|
+ self.assert_message_from_report(
|
|
+ "Disaster-recovery is not configured",
|
|
+ reports.dr_config_does_not_exist()
|
|
+ )
|
|
+
|
|
+
|
|
+class NodeInLocalCluster(NameBuildTest):
|
|
+ def test_success(self):
|
|
+ self.assert_message_from_report(
|
|
+ "Node 'node-name' is part of local cluster",
|
|
+ reports.node_in_local_cluster("node-name")
|
|
+ )
|
|
diff --git a/pcs_test/tier0/cli/test_dr.py b/pcs_test/tier0/cli/test_dr.py
|
|
new file mode 100644
|
|
index 00000000..4422cdc4
|
|
--- /dev/null
|
|
+++ b/pcs_test/tier0/cli/test_dr.py
|
|
@@ -0,0 +1,293 @@
|
|
+from textwrap import dedent
|
|
+from unittest import mock, TestCase
|
|
+
|
|
+from pcs_test.tools.misc import dict_to_modifiers
|
|
+
|
|
+from pcs.common import report_codes
|
|
+
|
|
+from pcs.cli import dr
|
|
+from pcs.cli.common.errors import CmdLineInputError
|
|
+
|
|
+
|
|
+@mock.patch("pcs.cli.dr.print")
|
|
+class Config(TestCase):
|
|
+ def setUp(self):
|
|
+ self.lib = mock.Mock(spec_set=["dr"])
|
|
+ self.lib.dr = mock.Mock(spec_set=["get_config"])
|
|
+
|
|
+ def _call_cmd(self, argv=None):
|
|
+ dr.config(self.lib, argv or [], dict_to_modifiers({}))
|
|
+
|
|
+ def test_argv(self, mock_print):
|
|
+ with self.assertRaises(CmdLineInputError) as cm:
|
|
+ self._call_cmd(["x"])
|
|
+ self.assertIsNone(cm.exception.message)
|
|
+ mock_print.assert_not_called()
|
|
+
|
|
+ def test_success(self, mock_print):
|
|
+ self.lib.dr.get_config.return_value = {
|
|
+ "local_site": {
|
|
+ "node_list": [],
|
|
+ "site_role": "RECOVERY",
|
|
+ },
|
|
+ "remote_site_list": [
|
|
+ {
|
|
+ "node_list": [
|
|
+ {"name": "nodeA2"},
|
|
+ {"name": "nodeA1"},
|
|
+ ],
|
|
+ "site_role": "PRIMARY",
|
|
+ },
|
|
+ {
|
|
+ "node_list": [
|
|
+ {"name": "nodeB1"},
|
|
+ ],
|
|
+ "site_role": "RECOVERY",
|
|
+ }
|
|
+ ],
|
|
+ }
|
|
+ self._call_cmd([])
|
|
+ self.lib.dr.get_config.assert_called_once_with()
|
|
+ mock_print.assert_called_once_with(dedent("""\
|
|
+ Local site:
|
|
+ Role: Recovery
|
|
+ Remote site:
|
|
+ Role: Primary
|
|
+ Nodes:
|
|
+ nodeA1
|
|
+ nodeA2
|
|
+ Remote site:
|
|
+ Role: Recovery
|
|
+ Nodes:
|
|
+ nodeB1"""))
|
|
+
|
|
+ @mock.patch("pcs.cli.common.console_report.sys.stderr.write")
|
|
+ def test_invalid_response(self, mock_stderr, mock_print):
|
|
+ self.lib.dr.get_config.return_value = [
|
|
+ "wrong response",
|
|
+ {"x": "y"},
|
|
+ ]
|
|
+ with self.assertRaises(SystemExit) as cm:
|
|
+ self._call_cmd([])
|
|
+ self.assertEqual(cm.exception.code, 1)
|
|
+ self.lib.dr.get_config.assert_called_once_with()
|
|
+ mock_print.assert_not_called()
|
|
+ mock_stderr.assert_called_once_with(
|
|
+ "Error: Unable to communicate with pcsd, received response:\n"
|
|
+ "['wrong response', {'x': 'y'}]\n"
|
|
+ )
|
|
+
|
|
+
|
|
+class SetRecoverySite(TestCase):
|
|
+ def setUp(self):
|
|
+ self.lib = mock.Mock(spec_set=["dr"])
|
|
+ self.dr = mock.Mock(spec_set=["set_recovery_site"])
|
|
+ self.lib.dr = self.dr
|
|
+
|
|
+ def call_cmd(self, argv):
|
|
+ dr.set_recovery_site(self.lib, argv, dict_to_modifiers({}))
|
|
+
|
|
+ def test_no_node(self):
|
|
+ with self.assertRaises(CmdLineInputError) as cm:
|
|
+ self.call_cmd([])
|
|
+ self.assertIsNone(cm.exception.message)
|
|
+
|
|
+ def test_multiple_nodes(self):
|
|
+ with self.assertRaises(CmdLineInputError) as cm:
|
|
+ self.call_cmd(["node1", "node2"])
|
|
+ self.assertIsNone(cm.exception.message)
|
|
+
|
|
+ def test_success(self):
|
|
+ node = "node"
|
|
+ self.call_cmd([node])
|
|
+ self.dr.set_recovery_site.assert_called_once_with(node)
|
|
+
|
|
+
|
|
+@mock.patch("pcs.cli.dr.print")
|
|
+class Status(TestCase):
|
|
+ def setUp(self):
|
|
+ self.lib = mock.Mock(spec_set=["dr"])
|
|
+ self.lib.dr = mock.Mock(spec_set=["status_all_sites_plaintext"])
|
|
+
|
|
+ def _call_cmd(self, argv, modifiers=None):
|
|
+ dr.status(self.lib, argv, dict_to_modifiers(modifiers or {}))
|
|
+
|
|
+ def _fixture_response(self, local_success=True, remote_success=True):
|
|
+ self.lib.dr.status_all_sites_plaintext.return_value = [
|
|
+ {
|
|
+ "local_site": True,
|
|
+ "site_role": "PRIMARY",
|
|
+ "status_plaintext": (
|
|
+ "local cluster\nstatus" if local_success
|
|
+ else "this should never be displayed"
|
|
+ ),
|
|
+ "status_successfully_obtained": local_success,
|
|
+ },
|
|
+ {
|
|
+ "local_site": False,
|
|
+ "site_role": "RECOVERY",
|
|
+ "status_plaintext": (
|
|
+ "remote cluster\nstatus" if remote_success
|
|
+ else "this should never be displayed"
|
|
+ ),
|
|
+ "status_successfully_obtained": remote_success,
|
|
+ },
|
|
+ ]
|
|
+
|
|
+ @staticmethod
|
|
+ def _fixture_print():
|
|
+ return dedent("""\
|
|
+ --- Local cluster - Primary site ---
|
|
+ local cluster
|
|
+ status
|
|
+
|
|
+
|
|
+ --- Remote cluster - Recovery site ---
|
|
+ remote cluster
|
|
+ status"""
|
|
+ )
|
|
+
|
|
+ def test_argv(self, mock_print):
|
|
+ with self.assertRaises(CmdLineInputError) as cm:
|
|
+ self._call_cmd(["x"])
|
|
+ self.assertIsNone(cm.exception.message)
|
|
+ mock_print.assert_not_called()
|
|
+
|
|
+ def test_success(self, mock_print):
|
|
+ self._fixture_response()
|
|
+ self._call_cmd([])
|
|
+ self.lib.dr.status_all_sites_plaintext.assert_called_once_with(
|
|
+ hide_inactive_resources=False, verbose=False
|
|
+ )
|
|
+ mock_print.assert_called_once_with(self._fixture_print())
|
|
+
|
|
+ def test_success_full(self, mock_print):
|
|
+ self._fixture_response()
|
|
+ self._call_cmd([], {"full": True})
|
|
+ self.lib.dr.status_all_sites_plaintext.assert_called_once_with(
|
|
+ hide_inactive_resources=False, verbose=True
|
|
+ )
|
|
+ mock_print.assert_called_once_with(self._fixture_print())
|
|
+
|
|
+ def test_success_hide_inactive(self, mock_print):
|
|
+ self._fixture_response()
|
|
+ self._call_cmd([], {"hide-inactive": True})
|
|
+ self.lib.dr.status_all_sites_plaintext.assert_called_once_with(
|
|
+ hide_inactive_resources=True, verbose=False
|
|
+ )
|
|
+ mock_print.assert_called_once_with(self._fixture_print())
|
|
+
|
|
+ def test_success_all_flags(self, mock_print):
|
|
+ self._fixture_response()
|
|
+ self._call_cmd([], {"full": True, "hide-inactive": True})
|
|
+ self.lib.dr.status_all_sites_plaintext.assert_called_once_with(
|
|
+ hide_inactive_resources=True, verbose=True
|
|
+ )
|
|
+ mock_print.assert_called_once_with(self._fixture_print())
|
|
+
|
|
+ @mock.patch("pcs.cli.common.console_report.sys.stderr.write")
|
|
+ def test_error_local(self, mock_stderr, mock_print):
|
|
+ self._fixture_response(local_success=False)
|
|
+ with self.assertRaises(SystemExit) as cm:
|
|
+ self._call_cmd([])
|
|
+ self.assertEqual(cm.exception.code, 1)
|
|
+ self.lib.dr.status_all_sites_plaintext.assert_called_once_with(
|
|
+ hide_inactive_resources=False, verbose=False
|
|
+ )
|
|
+ mock_print.assert_called_once_with(dedent("""\
|
|
+ --- Local cluster - Primary site ---
|
|
+ Error: Unable to get status of the cluster from any node
|
|
+
|
|
+ --- Remote cluster - Recovery site ---
|
|
+ remote cluster
|
|
+ status"""
|
|
+ ))
|
|
+ mock_stderr.assert_called_once_with(
|
|
+ "Error: Unable to get status of all sites\n"
|
|
+ )
|
|
+
|
|
+ @mock.patch("pcs.cli.common.console_report.sys.stderr.write")
|
|
+ def test_error_remote(self, mock_stderr, mock_print):
|
|
+ self._fixture_response(remote_success=False)
|
|
+ with self.assertRaises(SystemExit) as cm:
|
|
+ self._call_cmd([])
|
|
+ self.assertEqual(cm.exception.code, 1)
|
|
+ self.lib.dr.status_all_sites_plaintext.assert_called_once_with(
|
|
+ hide_inactive_resources=False, verbose=False
|
|
+ )
|
|
+ mock_print.assert_called_once_with(dedent("""\
|
|
+ --- Local cluster - Primary site ---
|
|
+ local cluster
|
|
+ status
|
|
+
|
|
+
|
|
+ --- Remote cluster - Recovery site ---
|
|
+ Error: Unable to get status of the cluster from any node"""
|
|
+ ))
|
|
+ mock_stderr.assert_called_once_with(
|
|
+ "Error: Unable to get status of all sites\n"
|
|
+ )
|
|
+
|
|
+ @mock.patch("pcs.cli.common.console_report.sys.stderr.write")
|
|
+ def test_error_both(self, mock_stderr, mock_print):
|
|
+ self._fixture_response(local_success=False, remote_success=False)
|
|
+ with self.assertRaises(SystemExit) as cm:
|
|
+ self._call_cmd([])
|
|
+ self.assertEqual(cm.exception.code, 1)
|
|
+ self.lib.dr.status_all_sites_plaintext.assert_called_once_with(
|
|
+ hide_inactive_resources=False, verbose=False
|
|
+ )
|
|
+ mock_print.assert_called_once_with(dedent("""\
|
|
+ --- Local cluster - Primary site ---
|
|
+ Error: Unable to get status of the cluster from any node
|
|
+
|
|
+ --- Remote cluster - Recovery site ---
|
|
+ Error: Unable to get status of the cluster from any node"""
|
|
+ ))
|
|
+ mock_stderr.assert_called_once_with(
|
|
+ "Error: Unable to get status of all sites\n"
|
|
+ )
|
|
+
|
|
+ @mock.patch("pcs.cli.common.console_report.sys.stderr.write")
|
|
+ def test_invalid_response(self, mock_stderr, mock_print):
|
|
+ self.lib.dr.status_all_sites_plaintext.return_value = [
|
|
+ "wrong response",
|
|
+ {"x": "y"},
|
|
+ ]
|
|
+ with self.assertRaises(SystemExit) as cm:
|
|
+ self._call_cmd([])
|
|
+ self.assertEqual(cm.exception.code, 1)
|
|
+ self.lib.dr.status_all_sites_plaintext.assert_called_once_with(
|
|
+ hide_inactive_resources=False, verbose=False
|
|
+ )
|
|
+ mock_print.assert_not_called()
|
|
+ mock_stderr.assert_called_once_with(
|
|
+ "Error: Unable to communicate with pcsd, received response:\n"
|
|
+ "['wrong response', {'x': 'y'}]\n"
|
|
+ )
|
|
+
|
|
+
|
|
+class Destroy(TestCase):
|
|
+ def setUp(self):
|
|
+ self.lib = mock.Mock(spec_set=["dr"])
|
|
+ self.dr = mock.Mock(spec_set=["destroy"])
|
|
+ self.lib.dr = self.dr
|
|
+
|
|
+ def call_cmd(self, argv, modifiers=None):
|
|
+ modifiers = modifiers or {}
|
|
+ dr.destroy(self.lib, argv, dict_to_modifiers(modifiers))
|
|
+
|
|
+ def test_some_args(self):
|
|
+ with self.assertRaises(CmdLineInputError) as cm:
|
|
+ self.call_cmd(["arg"])
|
|
+ self.assertIsNone(cm.exception.message)
|
|
+
|
|
+ def test_success(self):
|
|
+ self.call_cmd([])
|
|
+ self.dr.destroy.assert_called_once_with(force_flags=[])
|
|
+
|
|
+ def test_skip_offline(self):
|
|
+ self.call_cmd([], modifiers={"skip-offline": True})
|
|
+ self.dr.destroy.assert_called_once_with(
|
|
+ force_flags=[report_codes.SKIP_OFFLINE_NODES]
|
|
+ )
|
|
diff --git a/pcs_test/tier0/common/test_dr.py b/pcs_test/tier0/common/test_dr.py
|
|
new file mode 100644
|
|
index 00000000..2ef12855
|
|
--- /dev/null
|
|
+++ b/pcs_test/tier0/common/test_dr.py
|
|
@@ -0,0 +1,167 @@
|
|
+from unittest import TestCase
|
|
+
|
|
+from pcs.common import dr
|
|
+
|
|
+
|
|
+class DrConfigNodeDto(TestCase):
|
|
+ def setUp(self):
|
|
+ self.name = "node-name"
|
|
+
|
|
+ def _fixture_dto(self):
|
|
+ return dr.DrConfigNodeDto(self.name)
|
|
+
|
|
+ def _fixture_dict(self):
|
|
+ return dict(name=self.name)
|
|
+
|
|
+ def test_to_dict(self):
|
|
+ self.assertEqual(
|
|
+ self._fixture_dict(),
|
|
+ self._fixture_dto().to_dict()
|
|
+ )
|
|
+
|
|
+ def test_from_dict(self):
|
|
+ dto = dr.DrConfigNodeDto.from_dict(self._fixture_dict())
|
|
+ self.assertEqual(dto.name, self.name)
|
|
+
|
|
+
|
|
+class DrConfigSiteDto(TestCase):
|
|
+ def setUp(self):
|
|
+ self.role = dr.DrRole.PRIMARY
|
|
+ self.node_name_list = ["node1", "node2"]
|
|
+
|
|
+ def _fixture_dto(self):
|
|
+ return dr.DrConfigSiteDto(
|
|
+ self.role,
|
|
+ [dr.DrConfigNodeDto(name) for name in self.node_name_list]
|
|
+ )
|
|
+
|
|
+ def _fixture_dict(self):
|
|
+ return dict(
|
|
+ site_role=self.role,
|
|
+ node_list=[dict(name=name) for name in self.node_name_list]
|
|
+ )
|
|
+
|
|
+ def test_to_dict(self):
|
|
+ self.assertEqual(
|
|
+ self._fixture_dict(),
|
|
+ self._fixture_dto().to_dict()
|
|
+ )
|
|
+
|
|
+ def test_from_dict(self):
|
|
+ dto = dr.DrConfigSiteDto.from_dict(self._fixture_dict())
|
|
+ self.assertEqual(dto.site_role, self.role)
|
|
+ self.assertEqual(len(dto.node_list), len(self.node_name_list))
|
|
+ for i, dto_node in enumerate(dto.node_list):
|
|
+ self.assertEqual(
|
|
+ dto_node.name,
|
|
+ self.node_name_list[i],
|
|
+ f"index: {i}"
|
|
+ )
|
|
+
|
|
+
|
|
+class DrConfig(TestCase):
|
|
+ @staticmethod
|
|
+ def _fixture_site_dto(role, node_name_list):
|
|
+ return dr.DrConfigSiteDto(
|
|
+ role,
|
|
+ [dr.DrConfigNodeDto(name) for name in node_name_list]
|
|
+ )
|
|
+
|
|
+ @staticmethod
|
|
+ def _fixture_dict():
|
|
+ return {
|
|
+ "local_site": {
|
|
+ "node_list": [],
|
|
+ "site_role": "RECOVERY",
|
|
+ },
|
|
+ "remote_site_list": [
|
|
+ {
|
|
+ "node_list": [
|
|
+ {"name": "nodeA1"},
|
|
+ {"name": "nodeA2"},
|
|
+ ],
|
|
+ "site_role": "PRIMARY",
|
|
+ },
|
|
+ {
|
|
+ "node_list": [
|
|
+ {"name": "nodeB1"},
|
|
+ ],
|
|
+ "site_role": "RECOVERY",
|
|
+ }
|
|
+ ],
|
|
+ }
|
|
+
|
|
+ def test_to_dict(self):
|
|
+ self.assertEqual(
|
|
+ self._fixture_dict(),
|
|
+ dr.DrConfigDto(
|
|
+ self._fixture_site_dto(dr.DrRole.RECOVERY, []),
|
|
+ [
|
|
+ self._fixture_site_dto(
|
|
+ dr.DrRole.PRIMARY,
|
|
+ ["nodeA1", "nodeA2"]
|
|
+ ),
|
|
+ self._fixture_site_dto(
|
|
+ dr.DrRole.RECOVERY,
|
|
+ ["nodeB1"]
|
|
+ ),
|
|
+ ]
|
|
+ ).to_dict()
|
|
+ )
|
|
+
|
|
+ def test_from_dict(self):
|
|
+ dto = dr.DrConfigDto.from_dict(self._fixture_dict())
|
|
+ self.assertEqual(
|
|
+ dto.local_site.to_dict(),
|
|
+ self._fixture_site_dto(dr.DrRole.RECOVERY, []).to_dict()
|
|
+ )
|
|
+ self.assertEqual(len(dto.remote_site_list), 2)
|
|
+ self.assertEqual(
|
|
+ dto.remote_site_list[0].to_dict(),
|
|
+ self._fixture_site_dto(
|
|
+ dr.DrRole.PRIMARY, ["nodeA1", "nodeA2"]
|
|
+ ).to_dict()
|
|
+ )
|
|
+ self.assertEqual(
|
|
+ dto.remote_site_list[1].to_dict(),
|
|
+ self._fixture_site_dto(dr.DrRole.RECOVERY, ["nodeB1"]).to_dict()
|
|
+ )
|
|
+
|
|
+class DrSiteStatusDto(TestCase):
|
|
+ def setUp(self):
|
|
+ self.local = False
|
|
+ self.role = dr.DrRole.PRIMARY
|
|
+ self.status_plaintext = "plaintext status"
|
|
+ self.status_successfully_obtained = True
|
|
+
|
|
+ def dto_fixture(self):
|
|
+ return dr.DrSiteStatusDto(
|
|
+ self.local,
|
|
+ self.role,
|
|
+ self.status_plaintext,
|
|
+ self.status_successfully_obtained,
|
|
+ )
|
|
+
|
|
+ def dict_fixture(self):
|
|
+ return dict(
|
|
+ local_site=self.local,
|
|
+ site_role=self.role.value,
|
|
+ status_plaintext=self.status_plaintext,
|
|
+ status_successfully_obtained=self.status_successfully_obtained,
|
|
+ )
|
|
+
|
|
+ def test_to_dict(self):
|
|
+ self.assertEqual(
|
|
+ self.dict_fixture(),
|
|
+ self.dto_fixture().to_dict()
|
|
+ )
|
|
+
|
|
+ def test_from_dict(self):
|
|
+ dto = dr.DrSiteStatusDto.from_dict(self.dict_fixture())
|
|
+ self.assertEqual(dto.local_site, self.local)
|
|
+ self.assertEqual(dto.site_role, self.role)
|
|
+ self.assertEqual(dto.status_plaintext, self.status_plaintext)
|
|
+ self.assertEqual(
|
|
+ dto.status_successfully_obtained,
|
|
+ self.status_successfully_obtained
|
|
+ )
|
|
diff --git a/pcs_test/tier0/lib/commands/cluster/test_add_nodes.py b/pcs_test/tier0/lib/commands/cluster/test_add_nodes.py
|
|
index a570d67e..295c1e6a 100644
|
|
--- a/pcs_test/tier0/lib/commands/cluster/test_add_nodes.py
|
|
+++ b/pcs_test/tier0/lib/commands/cluster/test_add_nodes.py
|
|
@@ -470,6 +470,11 @@ class LocalConfig():
|
|
return_value=False,
|
|
name=f"{local_prefix}fs.isfile.pacemaker_authkey"
|
|
)
|
|
+ .fs.isfile(
|
|
+ settings.pcsd_dr_config_location,
|
|
+ return_value=False,
|
|
+ name=f"{local_prefix}fs.isfile.pcsd_disaster_recovery"
|
|
+ )
|
|
.fs.isfile(
|
|
settings.pcsd_settings_conf_location,
|
|
return_value=False,
|
|
@@ -480,10 +485,12 @@ class LocalConfig():
|
|
def files_sync(self, node_labels):
|
|
corosync_authkey_content = b"corosync authfile"
|
|
pcmk_authkey_content = b"pcmk authfile"
|
|
- pcs_settings_content = "pcs_settigns.conf data"
|
|
+ pcs_disaster_recovery_content = b"disaster recovery config data"
|
|
+ pcs_settings_content = "pcs_settings.conf data"
|
|
file_list = [
|
|
"corosync authkey",
|
|
"pacemaker authkey",
|
|
+ "disaster-recovery config",
|
|
"pcs_settings.conf",
|
|
]
|
|
local_prefix = "local.files_sync."
|
|
@@ -512,6 +519,19 @@ class LocalConfig():
|
|
mode="rb",
|
|
name=f"{local_prefix}fs.open.pcmk_authkey_read",
|
|
)
|
|
+ .fs.isfile(
|
|
+ settings.pcsd_dr_config_location,
|
|
+ return_value=True,
|
|
+ name=f"{local_prefix}fs.isfile.pcsd_disaster_recovery"
|
|
+ )
|
|
+ .fs.open(
|
|
+ settings.pcsd_dr_config_location,
|
|
+ return_value=(
|
|
+ mock.mock_open(read_data=pcs_disaster_recovery_content)()
|
|
+ ),
|
|
+ mode="rb",
|
|
+ name=f"{local_prefix}fs.open.pcsd_disaster_recovery_read",
|
|
+ )
|
|
.fs.isfile(
|
|
settings.pcsd_settings_conf_location,
|
|
return_value=True,
|
|
@@ -526,6 +546,7 @@ class LocalConfig():
|
|
node_labels=node_labels,
|
|
pcmk_authkey=pcmk_authkey_content,
|
|
corosync_authkey=corosync_authkey_content,
|
|
+ pcs_disaster_recovery_conf=pcs_disaster_recovery_content,
|
|
pcs_settings_conf=pcs_settings_content,
|
|
name=f"{local_prefix}http.files.put_files",
|
|
)
|
|
@@ -2105,13 +2126,16 @@ class FailureFilesDistribution(TestCase):
|
|
self.expected_reports = []
|
|
self.pcmk_authkey_content = b"pcmk authkey content"
|
|
self.corosync_authkey_content = b"corosync authkey content"
|
|
+ self.pcsd_dr_config_content = b"disaster recovery config data"
|
|
self.pcmk_authkey_file_id = "pacemaker_remote authkey"
|
|
self.corosync_authkey_file_id = "corosync authkey"
|
|
+ self.pcsd_dr_config_file_id = "disaster-recovery config"
|
|
self.unsuccessful_nodes = self.new_nodes[:1]
|
|
self.successful_nodes = self.new_nodes[1:]
|
|
self.err_msg = "an error message"
|
|
self.corosync_key_open_before_position = "fs.isfile.pacemaker_authkey"
|
|
- self.pacemaker_key_open_before_position = "fs.isfile.pcsd_settings"
|
|
+ self.pacemaker_key_open_before_position = "fs.isfile.pcsd_dr_config"
|
|
+ self.pcsd_dr_config_open_before_position = "fs.isfile.pcsd_settings"
|
|
patch_getaddrinfo(self, self.new_nodes)
|
|
self.existing_corosync_nodes = [
|
|
node_fixture(node, node_id)
|
|
@@ -2149,9 +2173,14 @@ class FailureFilesDistribution(TestCase):
|
|
)
|
|
# open will be inserted here
|
|
.fs.isfile(
|
|
- settings.pcsd_settings_conf_location, return_value=False,
|
|
+ settings.pcsd_dr_config_location, return_value=True,
|
|
name=self.pacemaker_key_open_before_position
|
|
)
|
|
+ # open will be inserted here
|
|
+ .fs.isfile(
|
|
+ settings.pcsd_settings_conf_location, return_value=False,
|
|
+ name=self.pcsd_dr_config_open_before_position
|
|
+ )
|
|
)
|
|
self.expected_reports.extend(
|
|
[
|
|
@@ -2165,7 +2194,11 @@ class FailureFilesDistribution(TestCase):
|
|
self.distribution_started_reports = [
|
|
fixture.info(
|
|
report_codes.FILES_DISTRIBUTION_STARTED,
|
|
- file_list=["corosync authkey", "pacemaker authkey"],
|
|
+ file_list=[
|
|
+ self.corosync_authkey_file_id,
|
|
+ "pacemaker authkey",
|
|
+ self.pcsd_dr_config_file_id,
|
|
+ ],
|
|
node_list=self.new_nodes,
|
|
)
|
|
]
|
|
@@ -2181,6 +2214,12 @@ class FailureFilesDistribution(TestCase):
|
|
node=node,
|
|
file_description="pacemaker authkey",
|
|
) for node in self.successful_nodes
|
|
+ ] + [
|
|
+ fixture.info(
|
|
+ report_codes.FILE_DISTRIBUTION_SUCCESS,
|
|
+ node=node,
|
|
+ file_description=self.pcsd_dr_config_file_id,
|
|
+ ) for node in self.successful_nodes
|
|
]
|
|
|
|
def _add_nodes_with_lib_error(self):
|
|
@@ -2210,6 +2249,15 @@ class FailureFilesDistribution(TestCase):
|
|
name="fs.open.pacemaker_authkey",
|
|
before=self.pacemaker_key_open_before_position,
|
|
)
|
|
+ self.config.fs.open(
|
|
+ settings.pcsd_dr_config_location,
|
|
+ mode="rb",
|
|
+ side_effect=EnvironmentError(
|
|
+ 1, self.err_msg, settings.pcsd_dr_config_location
|
|
+ ),
|
|
+ name="fs.open.pcsd_dr_config",
|
|
+ before=self.pcsd_dr_config_open_before_position,
|
|
+ )
|
|
|
|
self._add_nodes_with_lib_error()
|
|
|
|
@@ -2236,7 +2284,17 @@ class FailureFilesDistribution(TestCase):
|
|
f"{self.err_msg}: '{settings.pacemaker_authkey_file}'"
|
|
),
|
|
operation=RawFileError.ACTION_READ,
|
|
- )
|
|
+ ),
|
|
+ fixture.error(
|
|
+ report_codes.FILE_IO_ERROR,
|
|
+ force_code=report_codes.SKIP_FILE_DISTRIBUTION_ERRORS,
|
|
+ file_type_code=file_type_codes.PCS_DR_CONFIG,
|
|
+ file_path=settings.pcsd_dr_config_location,
|
|
+ reason=(
|
|
+ f"{self.err_msg}: '{settings.pcsd_dr_config_location}'"
|
|
+ ),
|
|
+ operation=RawFileError.ACTION_READ,
|
|
+ ),
|
|
]
|
|
)
|
|
|
|
@@ -2260,6 +2318,15 @@ class FailureFilesDistribution(TestCase):
|
|
name="fs.open.pacemaker_authkey",
|
|
before=self.pacemaker_key_open_before_position,
|
|
)
|
|
+ .fs.open(
|
|
+ settings.pcsd_dr_config_location,
|
|
+ mode="rb",
|
|
+ side_effect=EnvironmentError(
|
|
+ 1, self.err_msg, settings.pcsd_dr_config_location
|
|
+ ),
|
|
+ name="fs.open.pcsd_dr_config",
|
|
+ before=self.pcsd_dr_config_open_before_position,
|
|
+ )
|
|
.local.distribute_and_reload_corosync_conf(
|
|
corosync_conf_fixture(
|
|
self.existing_corosync_nodes + [
|
|
@@ -2301,7 +2368,16 @@ class FailureFilesDistribution(TestCase):
|
|
f"{self.err_msg}: '{settings.pacemaker_authkey_file}'"
|
|
),
|
|
operation=RawFileError.ACTION_READ,
|
|
- )
|
|
+ ),
|
|
+ fixture.warn(
|
|
+ report_codes.FILE_IO_ERROR,
|
|
+ file_type_code=file_type_codes.PCS_DR_CONFIG,
|
|
+ file_path=settings.pcsd_dr_config_location,
|
|
+ reason=(
|
|
+ f"{self.err_msg}: '{settings.pcsd_dr_config_location}'"
|
|
+ ),
|
|
+ operation=RawFileError.ACTION_READ,
|
|
+ ),
|
|
]
|
|
)
|
|
|
|
@@ -2325,9 +2401,19 @@ class FailureFilesDistribution(TestCase):
|
|
name="fs.open.pacemaker_authkey",
|
|
before=self.pacemaker_key_open_before_position,
|
|
)
|
|
+ .fs.open(
|
|
+ settings.pcsd_dr_config_location,
|
|
+ return_value=mock.mock_open(
|
|
+ read_data=self.pcsd_dr_config_content
|
|
+ )(),
|
|
+ mode="rb",
|
|
+ name="fs.open.pcsd_dr_config",
|
|
+ before=self.pcsd_dr_config_open_before_position,
|
|
+ )
|
|
.http.files.put_files(
|
|
pcmk_authkey=self.pcmk_authkey_content,
|
|
corosync_authkey=self.corosync_authkey_content,
|
|
+ pcs_disaster_recovery_conf=self.pcsd_dr_config_content,
|
|
communication_list=[
|
|
dict(
|
|
label=node,
|
|
@@ -2339,7 +2425,11 @@ class FailureFilesDistribution(TestCase):
|
|
self.pcmk_authkey_file_id: dict(
|
|
code="unexpected",
|
|
message=self.err_msg
|
|
- )
|
|
+ ),
|
|
+ self.pcsd_dr_config_file_id: dict(
|
|
+ code="unexpected",
|
|
+ message=self.err_msg
|
|
+ ),
|
|
}))
|
|
) for node in self.unsuccessful_nodes
|
|
] + [
|
|
@@ -2374,6 +2464,15 @@ class FailureFilesDistribution(TestCase):
|
|
reason=self.err_msg,
|
|
) for node in self.unsuccessful_nodes
|
|
]
|
|
+ +
|
|
+ [
|
|
+ fixture.error(
|
|
+ report_codes.FILE_DISTRIBUTION_ERROR,
|
|
+ node=node,
|
|
+ file_description=self.pcsd_dr_config_file_id,
|
|
+ reason=self.err_msg,
|
|
+ ) for node in self.unsuccessful_nodes
|
|
+ ]
|
|
)
|
|
|
|
def test_communication_failure(self):
|
|
@@ -2396,9 +2495,19 @@ class FailureFilesDistribution(TestCase):
|
|
name="fs.open.pacemaker_authkey",
|
|
before=self.pacemaker_key_open_before_position,
|
|
)
|
|
+ .fs.open(
|
|
+ settings.pcsd_dr_config_location,
|
|
+ return_value=mock.mock_open(
|
|
+ read_data=self.pcsd_dr_config_content
|
|
+ )(),
|
|
+ mode="rb",
|
|
+ name="fs.open.pcsd_dr_config",
|
|
+ before=self.pcsd_dr_config_open_before_position,
|
|
+ )
|
|
.http.files.put_files(
|
|
pcmk_authkey=self.pcmk_authkey_content,
|
|
corosync_authkey=self.corosync_authkey_content,
|
|
+ pcs_disaster_recovery_conf=self.pcsd_dr_config_content,
|
|
communication_list=[
|
|
dict(
|
|
label=node,
|
|
@@ -2450,9 +2559,19 @@ class FailureFilesDistribution(TestCase):
|
|
name="fs.open.pacemaker_authkey",
|
|
before=self.pacemaker_key_open_before_position,
|
|
)
|
|
+ .fs.open(
|
|
+ settings.pcsd_dr_config_location,
|
|
+ return_value=mock.mock_open(
|
|
+ read_data=self.pcsd_dr_config_content
|
|
+ )(),
|
|
+ mode="rb",
|
|
+ name="fs.open.pcsd_dr_config",
|
|
+ before=self.pcsd_dr_config_open_before_position,
|
|
+ )
|
|
.http.files.put_files(
|
|
pcmk_authkey=self.pcmk_authkey_content,
|
|
corosync_authkey=self.corosync_authkey_content,
|
|
+ pcs_disaster_recovery_conf=self.pcsd_dr_config_content,
|
|
communication_list=[
|
|
dict(
|
|
label=node,
|
|
@@ -2501,9 +2620,19 @@ class FailureFilesDistribution(TestCase):
|
|
name="fs.open.pacemaker_authkey",
|
|
before=self.pacemaker_key_open_before_position,
|
|
)
|
|
+ .fs.open(
|
|
+ settings.pcsd_dr_config_location,
|
|
+ return_value=mock.mock_open(
|
|
+ read_data=self.pcsd_dr_config_content
|
|
+ )(),
|
|
+ mode="rb",
|
|
+ name="fs.open.pcsd_dr_config",
|
|
+ before=self.pcsd_dr_config_open_before_position,
|
|
+ )
|
|
.http.files.put_files(
|
|
pcmk_authkey=self.pcmk_authkey_content,
|
|
corosync_authkey=self.corosync_authkey_content,
|
|
+ pcs_disaster_recovery_conf=self.pcsd_dr_config_content,
|
|
communication_list=[
|
|
dict(
|
|
label=node,
|
|
diff --git a/pcs_test/tier0/lib/commands/dr/__init__.py b/pcs_test/tier0/lib/commands/dr/__init__.py
|
|
new file mode 100644
|
|
index 00000000..e69de29b
|
|
diff --git a/pcs_test/tier0/lib/commands/dr/test_destroy.py b/pcs_test/tier0/lib/commands/dr/test_destroy.py
|
|
new file mode 100644
|
|
index 00000000..de50b21c
|
|
--- /dev/null
|
|
+++ b/pcs_test/tier0/lib/commands/dr/test_destroy.py
|
|
@@ -0,0 +1,342 @@
|
|
+import json
|
|
+from unittest import TestCase
|
|
+
|
|
+from pcs_test.tools import fixture
|
|
+from pcs_test.tools.command_env import get_env_tools
|
|
+
|
|
+from pcs import settings
|
|
+from pcs.common import (
|
|
+ file_type_codes,
|
|
+ report_codes,
|
|
+)
|
|
+from pcs.common.file import RawFileError
|
|
+from pcs.lib.commands import dr
|
|
+
|
|
+
|
|
+DR_CONF = "pcs disaster-recovery config"
|
|
+REASON = "error msg"
|
|
+
|
|
+
|
|
+def generate_nodes(nodes_num, prefix=""):
|
|
+ return [f"{prefix}node{i}" for i in range(1, nodes_num + 1)]
|
|
+
|
|
+
|
|
+class CheckLive(TestCase):
|
|
+ def setUp(self):
|
|
+ self.env_assist, self.config = get_env_tools(self)
|
|
+
|
|
+ def assert_live_required(self, forbidden_options):
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.destroy(self.env_assist.get_env()),
|
|
+ [
|
|
+ fixture.error(
|
|
+ report_codes.LIVE_ENVIRONMENT_REQUIRED,
|
|
+ forbidden_options=forbidden_options
|
|
+ )
|
|
+ ],
|
|
+ expected_in_processor=False
|
|
+ )
|
|
+
|
|
+ def test_mock_corosync(self):
|
|
+ self.config.env.set_corosync_conf_data("corosync conf data")
|
|
+ self.assert_live_required([file_type_codes.COROSYNC_CONF])
|
|
+
|
|
+ def test_mock_cib(self):
|
|
+ self.config.env.set_cib_data("<cib />")
|
|
+ self.assert_live_required([file_type_codes.CIB])
|
|
+
|
|
+ def test_mock(self):
|
|
+ self.config.env.set_corosync_conf_data("corosync conf data")
|
|
+ self.config.env.set_cib_data("<cib />")
|
|
+ self.assert_live_required([
|
|
+ file_type_codes.CIB,
|
|
+ file_type_codes.COROSYNC_CONF,
|
|
+ ])
|
|
+
|
|
+
|
|
+class FixtureMixin:
|
|
+ def _fixture_load_configs(self):
|
|
+ self.config.raw_file.exists(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ )
|
|
+ self.config.raw_file.read(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ content="""
|
|
+ {{
|
|
+ "local": {{
|
|
+ "role": "PRIMARY"
|
|
+ }},
|
|
+ "remote_sites": [
|
|
+ {{
|
|
+ "nodes": [{nodes}],
|
|
+ "role": "RECOVERY"
|
|
+ }}
|
|
+ ]
|
|
+ }}
|
|
+ """.format(
|
|
+ nodes=", ".join([
|
|
+ json.dumps(dict(name=node))
|
|
+ for node in self.remote_nodes
|
|
+ ])
|
|
+ )
|
|
+ )
|
|
+ self.config.corosync_conf.load(node_name_list=self.local_nodes)
|
|
+
|
|
+ def _success_reports(self):
|
|
+ return [
|
|
+ fixture.info(
|
|
+ report_codes.FILES_REMOVE_FROM_NODES_STARTED,
|
|
+ file_list=[DR_CONF],
|
|
+ node_list=self.remote_nodes + self.local_nodes,
|
|
+ )
|
|
+ ] + [
|
|
+ fixture.info(
|
|
+ report_codes.FILE_REMOVE_FROM_NODE_SUCCESS,
|
|
+ file_description=DR_CONF,
|
|
+ node=node,
|
|
+ ) for node in (self.remote_nodes + self.local_nodes)
|
|
+ ]
|
|
+
|
|
+
|
|
+class Success(FixtureMixin, TestCase):
|
|
+ def setUp(self):
|
|
+ self.env_assist, self.config = get_env_tools(self)
|
|
+ self.local_nodes = generate_nodes(5)
|
|
+ self.remote_nodes = generate_nodes(3, prefix="remote-")
|
|
+ self.config.env.set_known_nodes(self.local_nodes + self.remote_nodes)
|
|
+
|
|
+ def test_minimal(self):
|
|
+ self._fixture_load_configs()
|
|
+ self.config.http.files.remove_files(
|
|
+ node_labels=self.remote_nodes + self.local_nodes,
|
|
+ pcs_disaster_recovery_conf=True,
|
|
+ )
|
|
+ dr.destroy(self.env_assist.get_env())
|
|
+ self.env_assist.assert_reports(self._success_reports())
|
|
+
|
|
+
|
|
+class FatalConfigIssue(FixtureMixin, TestCase):
|
|
+ def setUp(self):
|
|
+ self.env_assist, self.config = get_env_tools(self)
|
|
+ self.local_nodes = generate_nodes(5)
|
|
+ self.remote_nodes = generate_nodes(3, prefix="remote-")
|
|
+
|
|
+ def test_config_missing(self):
|
|
+ self.config.raw_file.exists(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ exists=False,
|
|
+ )
|
|
+
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.destroy(self.env_assist.get_env()),
|
|
+ )
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.error(
|
|
+ report_codes.DR_CONFIG_DOES_NOT_EXIST,
|
|
+ ),
|
|
+ ])
|
|
+
|
|
+ def test_config_read_error(self):
|
|
+ self.config.raw_file.exists(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ )
|
|
+ self.config.raw_file.read(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ exception_msg=REASON,
|
|
+ )
|
|
+
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.destroy(self.env_assist.get_env()),
|
|
+ )
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.error(
|
|
+ report_codes.FILE_IO_ERROR,
|
|
+ file_type_code=file_type_codes.PCS_DR_CONFIG,
|
|
+ file_path=settings.pcsd_dr_config_location,
|
|
+ operation=RawFileError.ACTION_READ,
|
|
+ reason=REASON,
|
|
+ ),
|
|
+ ])
|
|
+
|
|
+ def test_config_parse_error(self):
|
|
+ self.config.raw_file.exists(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ )
|
|
+ self.config.raw_file.read(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ content="bad content",
|
|
+ )
|
|
+
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.destroy(self.env_assist.get_env()),
|
|
+ )
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.error(
|
|
+ report_codes.PARSE_ERROR_JSON_FILE,
|
|
+ file_type_code=file_type_codes.PCS_DR_CONFIG,
|
|
+ file_path=settings.pcsd_dr_config_location,
|
|
+ line_number=1,
|
|
+ column_number=1,
|
|
+ position=0,
|
|
+ reason="Expecting value",
|
|
+ full_msg="Expecting value: line 1 column 1 (char 0)",
|
|
+ ),
|
|
+ ])
|
|
+
|
|
+ def test_corosync_conf_read_error(self):
|
|
+ self._fixture_load_configs()
|
|
+ self.config.corosync_conf.load_content(
|
|
+ "", exception_msg=REASON, instead="corosync_conf.load"
|
|
+ )
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.destroy(self.env_assist.get_env()),
|
|
+ [
|
|
+ fixture.error(
|
|
+ report_codes.UNABLE_TO_READ_COROSYNC_CONFIG,
|
|
+ path=settings.corosync_conf_file,
|
|
+ reason=REASON,
|
|
+ ),
|
|
+ ],
|
|
+ expected_in_processor=False
|
|
+ )
|
|
+
|
|
+ def test_corosync_conf_parse_error(self):
|
|
+ self._fixture_load_configs()
|
|
+ self.config.corosync_conf.load_content(
|
|
+ "wrong {\n corosync", instead="corosync_conf.load"
|
|
+ )
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.destroy(self.env_assist.get_env()),
|
|
+ [
|
|
+ fixture.error(
|
|
+ report_codes
|
|
+ .PARSE_ERROR_COROSYNC_CONF_LINE_IS_NOT_SECTION_NOR_KEY_VALUE
|
|
+ ),
|
|
+ ],
|
|
+ expected_in_processor=False
|
|
+ )
|
|
+
|
|
+
|
|
+class CommunicationIssue(FixtureMixin, TestCase):
|
|
+ def setUp(self):
|
|
+ self.env_assist, self.config = get_env_tools(self)
|
|
+ self.local_nodes = generate_nodes(5)
|
|
+ self.remote_nodes = generate_nodes(3, prefix="remote-")
|
|
+
|
|
+ def test_unknown_node(self):
|
|
+ self.config.env.set_known_nodes(
|
|
+ self.local_nodes[1:] + self.remote_nodes[1:]
|
|
+ )
|
|
+ self._fixture_load_configs()
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.destroy(self.env_assist.get_env())
|
|
+ )
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.error(
|
|
+ report_codes.HOST_NOT_FOUND,
|
|
+ host_list=self.local_nodes[:1] + self.remote_nodes[:1],
|
|
+ force_code=report_codes.SKIP_OFFLINE_NODES,
|
|
+ ),
|
|
+ ])
|
|
+
|
|
+ def test_unknown_node_force(self):
|
|
+ existing_nodes = self.remote_nodes[1:] + self.local_nodes[1:]
|
|
+ self.config.env.set_known_nodes(existing_nodes)
|
|
+ self._fixture_load_configs()
|
|
+ self.config.http.files.remove_files(
|
|
+ node_labels=existing_nodes,
|
|
+ pcs_disaster_recovery_conf=True,
|
|
+ )
|
|
+ dr.destroy(
|
|
+ self.env_assist.get_env(),
|
|
+ force_flags=[report_codes.SKIP_OFFLINE_NODES],
|
|
+ )
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.warn(
|
|
+ report_codes.HOST_NOT_FOUND,
|
|
+ host_list=self.local_nodes[:1] + self.remote_nodes[:1],
|
|
+ ),
|
|
+ ] + [
|
|
+ fixture.info(
|
|
+ report_codes.FILES_REMOVE_FROM_NODES_STARTED,
|
|
+ file_list=[DR_CONF],
|
|
+ node_list=existing_nodes,
|
|
+ )
|
|
+ ] + [
|
|
+ fixture.info(
|
|
+ report_codes.FILE_REMOVE_FROM_NODE_SUCCESS,
|
|
+ file_description=DR_CONF,
|
|
+ node=node,
|
|
+ ) for node in existing_nodes
|
|
+ ])
|
|
+
|
|
+ def test_node_issues(self):
|
|
+ self.config.env.set_known_nodes(self.local_nodes + self.remote_nodes)
|
|
+ self._fixture_load_configs()
|
|
+ self.config.http.files.remove_files(
|
|
+ pcs_disaster_recovery_conf=True,
|
|
+ communication_list=[
|
|
+ dict(label=node) for node in self.remote_nodes
|
|
+ ] + [
|
|
+ dict(
|
|
+ label=self.local_nodes[0],
|
|
+ was_connected=False,
|
|
+ error_msg=REASON,
|
|
+ ),
|
|
+ dict(
|
|
+ label=self.local_nodes[1],
|
|
+ output="invalid data",
|
|
+ ),
|
|
+ dict(
|
|
+ label=self.local_nodes[2],
|
|
+ output=json.dumps(dict(files={
|
|
+ DR_CONF: dict(
|
|
+ code="unexpected",
|
|
+ message=REASON,
|
|
+ ),
|
|
+ })),
|
|
+ ),
|
|
+ ] + [
|
|
+ dict(label=node) for node in self.local_nodes[3:]
|
|
+ ]
|
|
+ )
|
|
+
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.destroy(self.env_assist.get_env())
|
|
+ )
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.info(
|
|
+ report_codes.FILES_REMOVE_FROM_NODES_STARTED,
|
|
+ file_list=[DR_CONF],
|
|
+ node_list=self.remote_nodes + self.local_nodes,
|
|
+ ),
|
|
+ fixture.error(
|
|
+ report_codes.NODE_COMMUNICATION_ERROR_UNABLE_TO_CONNECT,
|
|
+ command="remote/remove_file",
|
|
+ node=self.local_nodes[0],
|
|
+ reason=REASON,
|
|
+ ),
|
|
+ fixture.error(
|
|
+ report_codes.INVALID_RESPONSE_FORMAT,
|
|
+ node=self.local_nodes[1],
|
|
+ ),
|
|
+ fixture.error(
|
|
+ report_codes.FILE_REMOVE_FROM_NODE_ERROR,
|
|
+ file_description=DR_CONF,
|
|
+ reason=REASON,
|
|
+ node=self.local_nodes[2],
|
|
+ ),
|
|
+ ] + [
|
|
+ fixture.info(
|
|
+ report_codes.FILE_REMOVE_FROM_NODE_SUCCESS,
|
|
+ file_description=DR_CONF,
|
|
+ node=node,
|
|
+ ) for node in self.local_nodes[3:] + self.remote_nodes
|
|
+ ])
|
|
diff --git a/pcs_test/tier0/lib/commands/dr/test_get_config.py b/pcs_test/tier0/lib/commands/dr/test_get_config.py
|
|
new file mode 100644
|
|
index 00000000..b2297c8a
|
|
--- /dev/null
|
|
+++ b/pcs_test/tier0/lib/commands/dr/test_get_config.py
|
|
@@ -0,0 +1,134 @@
|
|
+from unittest import TestCase
|
|
+
|
|
+from pcs import settings
|
|
+from pcs.common import (
|
|
+ file_type_codes,
|
|
+ report_codes,
|
|
+)
|
|
+from pcs.common.file import RawFileError
|
|
+from pcs.lib.commands import dr
|
|
+
|
|
+from pcs_test.tools.command_env import get_env_tools
|
|
+from pcs_test.tools import fixture
|
|
+
|
|
+REASON = "error msg"
|
|
+
|
|
+class Config(TestCase):
|
|
+ def setUp(self):
|
|
+ self.env_assist, self.config = get_env_tools(self)
|
|
+
|
|
+ def test_success(self):
|
|
+ (self.config
|
|
+ .raw_file.exists(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ )
|
|
+ .raw_file.read(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ content="""
|
|
+ {
|
|
+ "local": {
|
|
+ "role": "PRIMARY"
|
|
+ },
|
|
+ "remote_sites": [
|
|
+ {
|
|
+ "nodes": [
|
|
+ {
|
|
+ "name": "recovery-node"
|
|
+ }
|
|
+ ],
|
|
+ "role": "RECOVERY"
|
|
+ }
|
|
+ ]
|
|
+ }
|
|
+ """,
|
|
+ )
|
|
+ )
|
|
+ self.assertEqual(
|
|
+ dr.get_config(self.env_assist.get_env()),
|
|
+ {
|
|
+ "local_site": {
|
|
+ "node_list": [],
|
|
+ "site_role": "PRIMARY",
|
|
+ },
|
|
+ "remote_site_list": [
|
|
+ {
|
|
+ "node_list": [
|
|
+ {"name": "recovery-node"},
|
|
+ ],
|
|
+ "site_role": "RECOVERY",
|
|
+ },
|
|
+ ],
|
|
+ }
|
|
+ )
|
|
+
|
|
+ def test_config_missing(self):
|
|
+ (self.config
|
|
+ .raw_file.exists(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ exists=False,
|
|
+ )
|
|
+ )
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.get_config(self.env_assist.get_env()),
|
|
+ )
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.error(
|
|
+ report_codes.DR_CONFIG_DOES_NOT_EXIST,
|
|
+ ),
|
|
+ ])
|
|
+
|
|
+ def test_config_read_error(self):
|
|
+ (self.config
|
|
+ .raw_file.exists(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ )
|
|
+ .raw_file.read(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ exception_msg=REASON,
|
|
+ )
|
|
+ )
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.get_config(self.env_assist.get_env()),
|
|
+ )
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.error(
|
|
+ report_codes.FILE_IO_ERROR,
|
|
+ file_type_code=file_type_codes.PCS_DR_CONFIG,
|
|
+ file_path=settings.pcsd_dr_config_location,
|
|
+ operation=RawFileError.ACTION_READ,
|
|
+ reason=REASON,
|
|
+ ),
|
|
+ ])
|
|
+
|
|
+ def test_config_parse_error(self):
|
|
+ (self.config
|
|
+ .raw_file.exists(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ )
|
|
+ .raw_file.read(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ content="bad content",
|
|
+ )
|
|
+ )
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.get_config(self.env_assist.get_env()),
|
|
+ )
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.error(
|
|
+ report_codes.PARSE_ERROR_JSON_FILE,
|
|
+ file_type_code=file_type_codes.PCS_DR_CONFIG,
|
|
+ file_path=settings.pcsd_dr_config_location,
|
|
+ line_number=1,
|
|
+ column_number=1,
|
|
+ position=0,
|
|
+ reason="Expecting value",
|
|
+ full_msg="Expecting value: line 1 column 1 (char 0)",
|
|
+ ),
|
|
+ ])
|
|
diff --git a/pcs_test/tier0/lib/commands/dr/test_set_recovery_site.py b/pcs_test/tier0/lib/commands/dr/test_set_recovery_site.py
|
|
new file mode 100644
|
|
index 00000000..06d80df1
|
|
--- /dev/null
|
|
+++ b/pcs_test/tier0/lib/commands/dr/test_set_recovery_site.py
|
|
@@ -0,0 +1,702 @@
|
|
+import json
|
|
+from unittest import TestCase
|
|
+
|
|
+from pcs_test.tools import fixture
|
|
+from pcs_test.tools.command_env import get_env_tools
|
|
+
|
|
+from pcs import settings
|
|
+from pcs.common import (
|
|
+ file_type_codes,
|
|
+ report_codes,
|
|
+)
|
|
+from pcs.lib.dr.config.facade import DrRole
|
|
+from pcs.lib.commands import dr
|
|
+
|
|
+DR_CFG_DESC = "disaster-recovery config"
|
|
+
|
|
+COROSYNC_CONF_TEMPLATE = """\
|
|
+totem {{
|
|
+ version: 2
|
|
+ cluster_name: cluster_name
|
|
+}}
|
|
+
|
|
+nodelist {{
|
|
+{node_list}}}
|
|
+"""
|
|
+
|
|
+NODE_TEMPLATE_NO_NAME = """\
|
|
+ node {{
|
|
+ ring0_addr: {node}
|
|
+ nodeid: {id}
|
|
+ }}
|
|
+"""
|
|
+
|
|
+NODE_TEMPLATE = """\
|
|
+ node {{
|
|
+ ring0_addr: {node}
|
|
+ name: {node}
|
|
+ nodeid: {id}
|
|
+ }}
|
|
+"""
|
|
+
|
|
+
|
|
+def export_cfg(cfg_struct):
|
|
+ return json.dumps(cfg_struct, indent=4, sort_keys=True).encode("utf-8")
|
|
+
|
|
+def dr_cfg_fixture(local_role, remote_role, nodes):
|
|
+ return export_cfg(dict(
|
|
+ local=dict(
|
|
+ role=local_role.value,
|
|
+ ),
|
|
+ remote_sites=[
|
|
+ dict(
|
|
+ role=remote_role.value,
|
|
+ nodes=[dict(name=node) for node in nodes],
|
|
+ ),
|
|
+ ]
|
|
+ ))
|
|
+
|
|
+def corosync_conf_fixture(node_list):
|
|
+ return COROSYNC_CONF_TEMPLATE.format(
|
|
+ node_list="\n".join(node_list_fixture(node_list)),
|
|
+ )
|
|
+
|
|
+def node_list_fixture(node_list):
|
|
+ return [
|
|
+ NODE_TEMPLATE.format(node=node, id=i)
|
|
+ for i, node in enumerate(node_list, start=1)
|
|
+ ]
|
|
+
|
|
+
|
|
+def generate_nodes(nodes_num, prefix=""):
|
|
+ return [f"{prefix}node{i}" for i in range(1, nodes_num + 1)]
|
|
+
|
|
+
|
|
+class CheckLive(TestCase):
|
|
+ def setUp(self):
|
|
+ self.env_assist, self.config = get_env_tools(self)
|
|
+
|
|
+ def assert_live_required(self, forbidden_options):
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.set_recovery_site(self.env_assist.get_env(), "node"),
|
|
+ [
|
|
+ fixture.error(
|
|
+ report_codes.LIVE_ENVIRONMENT_REQUIRED,
|
|
+ forbidden_options=forbidden_options
|
|
+ )
|
|
+ ],
|
|
+ expected_in_processor=False
|
|
+ )
|
|
+
|
|
+ def test_mock_corosync(self):
|
|
+ self.config.env.set_corosync_conf_data(
|
|
+ corosync_conf_fixture(generate_nodes(3))
|
|
+ )
|
|
+ self.assert_live_required([file_type_codes.COROSYNC_CONF])
|
|
+
|
|
+ def test_mock_cib(self):
|
|
+ self.config.env.set_cib_data("<cib />")
|
|
+ self.assert_live_required([file_type_codes.CIB])
|
|
+
|
|
+ def test_mock(self):
|
|
+ self.config.env.set_corosync_conf_data(
|
|
+ corosync_conf_fixture(generate_nodes(3))
|
|
+ )
|
|
+ self.config.env.set_cib_data("<cib />")
|
|
+ self.assert_live_required([
|
|
+ file_type_codes.CIB,
|
|
+ file_type_codes.COROSYNC_CONF,
|
|
+ ])
|
|
+
|
|
+
|
|
+class SetRecoverySiteSuccess(TestCase):
|
|
+ def setUp(self):
|
|
+ self.env_assist, self.config = get_env_tools(self)
|
|
+
|
|
+ def _test_minimal(self, local_cluster_size, recovery_cluster_size):
|
|
+ local_nodes = generate_nodes(local_cluster_size)
|
|
+ remote_nodes = generate_nodes(recovery_cluster_size, prefix="recovery-")
|
|
+ orig_node = remote_nodes[-1]
|
|
+ cfg = self.config
|
|
+ cfg.env.set_known_nodes(local_nodes + remote_nodes)
|
|
+ cfg.raw_file.exists(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ exists=False,
|
|
+ )
|
|
+ cfg.corosync_conf.load_content(corosync_conf_fixture(local_nodes))
|
|
+ cfg.http.corosync.get_corosync_conf(
|
|
+ corosync_conf_fixture(remote_nodes), node_labels=[orig_node]
|
|
+ )
|
|
+ cfg.http.files.put_files(
|
|
+ node_labels=remote_nodes,
|
|
+ pcs_disaster_recovery_conf=dr_cfg_fixture(
|
|
+ DrRole.RECOVERY, DrRole.PRIMARY, local_nodes
|
|
+ ),
|
|
+ name="distribute_remote",
|
|
+ )
|
|
+ cfg.http.files.put_files(
|
|
+ node_labels=local_nodes,
|
|
+ pcs_disaster_recovery_conf=dr_cfg_fixture(
|
|
+ DrRole.PRIMARY, DrRole.RECOVERY, remote_nodes
|
|
+ ),
|
|
+ name="distribute_local",
|
|
+ )
|
|
+ dr.set_recovery_site(self.env_assist.get_env(), orig_node)
|
|
+ self.env_assist.assert_reports(
|
|
+ [
|
|
+ fixture.info(
|
|
+ report_codes.FILES_DISTRIBUTION_STARTED,
|
|
+ file_list=[DR_CFG_DESC],
|
|
+ node_list=remote_nodes,
|
|
+ )
|
|
+ ] + [
|
|
+ fixture.info(
|
|
+ report_codes.FILE_DISTRIBUTION_SUCCESS,
|
|
+ file_description=DR_CFG_DESC,
|
|
+ node=node,
|
|
+ ) for node in remote_nodes
|
|
+ ] + [
|
|
+ fixture.info(
|
|
+ report_codes.FILES_DISTRIBUTION_STARTED,
|
|
+ file_list=[DR_CFG_DESC],
|
|
+ node_list=local_nodes,
|
|
+ )
|
|
+ ] + [
|
|
+ fixture.info(
|
|
+ report_codes.FILE_DISTRIBUTION_SUCCESS,
|
|
+ file_description=DR_CFG_DESC,
|
|
+ node=node,
|
|
+ ) for node in local_nodes
|
|
+ ]
|
|
+ )
|
|
+
|
|
+ def test_minimal_local_1_remote_1(self):
|
|
+ self._test_minimal(1, 1)
|
|
+
|
|
+ def test_minimal_local_1_remote_2(self):
|
|
+ self._test_minimal(1, 2)
|
|
+
|
|
+ def test_minimal_local_1_remote_3(self):
|
|
+ self._test_minimal(1, 3)
|
|
+
|
|
+ def test_minimal_local_2_remote_1(self):
|
|
+ self._test_minimal(2, 1)
|
|
+
|
|
+ def test_minimal_local_2_remote_2(self):
|
|
+ self._test_minimal(2, 2)
|
|
+
|
|
+ def test_minimal_local_2_remote_3(self):
|
|
+ self._test_minimal(2, 3)
|
|
+
|
|
+ def test_minimal_local_3_remote_1(self):
|
|
+ self._test_minimal(3, 1)
|
|
+
|
|
+ def test_minimal_local_3_remote_2(self):
|
|
+ self._test_minimal(3, 2)
|
|
+
|
|
+ def test_minimal_local_3_remote_3(self):
|
|
+ self._test_minimal(3, 3)
|
|
+
|
|
+
|
|
+class FailureValidations(TestCase):
|
|
+ def setUp(self):
|
|
+ self.env_assist, self.config = get_env_tools(self)
|
|
+ self.local_nodes = generate_nodes(4)
|
|
+
|
|
+ def test_dr_cfg_exist(self):
|
|
+ orig_node = "node"
|
|
+ cfg = self.config
|
|
+ cfg.env.set_known_nodes(self.local_nodes + [orig_node])
|
|
+ cfg.raw_file.exists(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ exists=True,
|
|
+ )
|
|
+ cfg.corosync_conf.load_content(corosync_conf_fixture(self.local_nodes))
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.set_recovery_site(self.env_assist.get_env(), orig_node),
|
|
+ )
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.error(
|
|
+ report_codes.DR_CONFIG_ALREADY_EXIST,
|
|
+ )
|
|
+ ])
|
|
+
|
|
+ def test_local_nodes_name_missing(self):
|
|
+ orig_node = "node"
|
|
+ cfg = self.config
|
|
+ cfg.env.set_known_nodes(self.local_nodes + [orig_node])
|
|
+ cfg.raw_file.exists(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ exists=False,
|
|
+ )
|
|
+ cfg.corosync_conf.load_content(
|
|
+ COROSYNC_CONF_TEMPLATE.format(
|
|
+ node_list="\n".join(
|
|
+ [
|
|
+ NODE_TEMPLATE_NO_NAME.format(
|
|
+ node=self.local_nodes[0], id=len(self.local_nodes)
|
|
+ )
|
|
+ ] + node_list_fixture(self.local_nodes[1:])
|
|
+ )
|
|
+ )
|
|
+ )
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.set_recovery_site(self.env_assist.get_env(), orig_node),
|
|
+ )
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.error(
|
|
+ report_codes.COROSYNC_CONFIG_MISSING_NAMES_OF_NODES,
|
|
+ fatal=True,
|
|
+ )
|
|
+ ])
|
|
+
|
|
+ def test_node_part_of_local_cluster(self):
|
|
+ orig_node = self.local_nodes[-1]
|
|
+ cfg = self.config
|
|
+ cfg.env.set_known_nodes(self.local_nodes + [orig_node])
|
|
+ cfg.raw_file.exists(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ exists=False,
|
|
+ )
|
|
+ cfg.corosync_conf.load_content(corosync_conf_fixture(self.local_nodes))
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.set_recovery_site(self.env_assist.get_env(), orig_node),
|
|
+ )
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.error(
|
|
+ report_codes.NODE_IN_LOCAL_CLUSTER,
|
|
+ node=orig_node,
|
|
+ )
|
|
+ ])
|
|
+
|
|
+ def test_tokens_missing_for_local_nodes(self):
|
|
+ orig_node = "node"
|
|
+ cfg = self.config
|
|
+ cfg.env.set_known_nodes(self.local_nodes[:-1] + [orig_node])
|
|
+ cfg.raw_file.exists(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ exists=False,
|
|
+ )
|
|
+ cfg.corosync_conf.load_content(corosync_conf_fixture(self.local_nodes))
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.set_recovery_site(self.env_assist.get_env(), orig_node),
|
|
+ )
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.error(
|
|
+ report_codes.HOST_NOT_FOUND,
|
|
+ host_list=self.local_nodes[-1:],
|
|
+ )
|
|
+ ])
|
|
+
|
|
+ def test_token_missing_for_node(self):
|
|
+ orig_node = "node"
|
|
+ cfg = self.config
|
|
+ cfg.env.set_known_nodes(self.local_nodes)
|
|
+ cfg.raw_file.exists(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ exists=False,
|
|
+ )
|
|
+ cfg.corosync_conf.load_content(corosync_conf_fixture(self.local_nodes))
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.set_recovery_site(self.env_assist.get_env(), orig_node),
|
|
+ )
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.error(
|
|
+ report_codes.HOST_NOT_FOUND,
|
|
+ host_list=[orig_node],
|
|
+ )
|
|
+ ])
|
|
+
|
|
+ def test_tokens_missing_for_remote_cluster(self):
|
|
+ remote_nodes = generate_nodes(3, prefix="recovery-")
|
|
+ orig_node = remote_nodes[0]
|
|
+ cfg = self.config
|
|
+ cfg.env.set_known_nodes(self.local_nodes + remote_nodes[:-1])
|
|
+ cfg.raw_file.exists(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ exists=False,
|
|
+ )
|
|
+ cfg.corosync_conf.load_content(corosync_conf_fixture(self.local_nodes))
|
|
+ cfg.http.corosync.get_corosync_conf(
|
|
+ corosync_conf_fixture(remote_nodes), node_labels=[orig_node]
|
|
+ )
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.set_recovery_site(self.env_assist.get_env(), orig_node),
|
|
+ )
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.error(
|
|
+ report_codes.HOST_NOT_FOUND,
|
|
+ host_list=remote_nodes[-1:],
|
|
+ )
|
|
+ ])
|
|
+
|
|
+
|
|
+REASON = "error msg"
|
|
+
|
|
+
|
|
+class FailureRemoteCorocyncConf(TestCase):
|
|
+ def setUp(self):
|
|
+ self.env_assist, self.config = get_env_tools(self)
|
|
+ self.local_nodes = generate_nodes(4)
|
|
+ self.remote_nodes = generate_nodes(3, prefix="recovery-")
|
|
+ self.node = self.remote_nodes[0]
|
|
+
|
|
+ self.config.env.set_known_nodes(self.local_nodes + self.remote_nodes)
|
|
+ self.config.raw_file.exists(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ exists=False,
|
|
+ )
|
|
+ self.config.corosync_conf.load_content(
|
|
+ corosync_conf_fixture(self.local_nodes)
|
|
+ )
|
|
+
|
|
+ def test_network_issue(self):
|
|
+ self.config.http.corosync.get_corosync_conf(
|
|
+ communication_list=[
|
|
+ dict(
|
|
+ label=self.node,
|
|
+ was_connected=False,
|
|
+ error_msg=REASON,
|
|
+ )
|
|
+ ]
|
|
+ )
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.set_recovery_site(self.env_assist.get_env(), self.node),
|
|
+ )
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.warn(
|
|
+ report_codes.NODE_COMMUNICATION_ERROR_UNABLE_TO_CONNECT,
|
|
+ node=self.node,
|
|
+ command="remote/get_corosync_conf",
|
|
+ reason=REASON,
|
|
+
|
|
+ ),
|
|
+ fixture.error(report_codes.UNABLE_TO_PERFORM_OPERATION_ON_ANY_NODE)
|
|
+ ])
|
|
+
|
|
+ def test_file_does_not_exist(self):
|
|
+ self.config.http.corosync.get_corosync_conf(
|
|
+ communication_list=[
|
|
+ dict(
|
|
+ label=self.node,
|
|
+ response_code=400,
|
|
+ output=REASON,
|
|
+ )
|
|
+ ]
|
|
+ )
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.set_recovery_site(self.env_assist.get_env(), self.node),
|
|
+ )
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.warn(
|
|
+ report_codes.NODE_COMMUNICATION_COMMAND_UNSUCCESSFUL,
|
|
+ node=self.node,
|
|
+ command="remote/get_corosync_conf",
|
|
+ reason=REASON,
|
|
+
|
|
+ ),
|
|
+ fixture.error(report_codes.UNABLE_TO_PERFORM_OPERATION_ON_ANY_NODE)
|
|
+ ])
|
|
+
|
|
+ def test_node_names_missing(self):
|
|
+ self.config.http.corosync.get_corosync_conf(
|
|
+ COROSYNC_CONF_TEMPLATE.format(
|
|
+ node_list="\n".join(
|
|
+ [
|
|
+ NODE_TEMPLATE_NO_NAME.format(
|
|
+ node=self.remote_nodes[-1],
|
|
+ id=len(self.remote_nodes),
|
|
+ )
|
|
+ ] + node_list_fixture(self.remote_nodes[:-1])
|
|
+ )
|
|
+ ),
|
|
+ node_labels=[self.node],
|
|
+ )
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.set_recovery_site(self.env_assist.get_env(), self.node),
|
|
+ )
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.error(
|
|
+ report_codes.COROSYNC_CONFIG_MISSING_NAMES_OF_NODES,
|
|
+ fatal=True,
|
|
+ )
|
|
+ ])
|
|
+
|
|
+
|
|
+class FailureRemoteDrCfgDistribution(TestCase):
|
|
+ # pylint: disable=too-many-instance-attributes
|
|
+ def setUp(self):
|
|
+ self.env_assist, self.config = get_env_tools(self)
|
|
+ self.local_nodes = generate_nodes(4)
|
|
+ self.remote_nodes = generate_nodes(3, prefix="recovery-")
|
|
+ self.node = self.remote_nodes[0]
|
|
+ self.failed_nodes = self.remote_nodes[-1:]
|
|
+ successful_nodes = self.remote_nodes[:-1]
|
|
+
|
|
+ self.config.env.set_known_nodes(self.local_nodes + self.remote_nodes)
|
|
+ self.config.raw_file.exists(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ exists=False,
|
|
+ )
|
|
+ self.config.corosync_conf.load_content(
|
|
+ corosync_conf_fixture(self.local_nodes)
|
|
+ )
|
|
+ self.config.http.corosync.get_corosync_conf(
|
|
+ corosync_conf_fixture(self.remote_nodes), node_labels=[self.node]
|
|
+ )
|
|
+
|
|
+ self.success_communication = [
|
|
+ dict(label=node) for node in successful_nodes
|
|
+ ]
|
|
+ self.expected_reports = [
|
|
+ fixture.info(
|
|
+ report_codes.FILES_DISTRIBUTION_STARTED,
|
|
+ file_list=[DR_CFG_DESC],
|
|
+ node_list=self.remote_nodes,
|
|
+ )
|
|
+ ] + [
|
|
+ fixture.info(
|
|
+ report_codes.FILE_DISTRIBUTION_SUCCESS,
|
|
+ file_description=DR_CFG_DESC,
|
|
+ node=node,
|
|
+ ) for node in successful_nodes
|
|
+ ]
|
|
+
|
|
+ def test_write_failure(self):
|
|
+ self.config.http.files.put_files(
|
|
+ communication_list=self.success_communication + [
|
|
+ dict(
|
|
+ label=node,
|
|
+ output=json.dumps(dict(files={
|
|
+ DR_CFG_DESC: dict(
|
|
+ code="unexpected",
|
|
+ message=REASON
|
|
+ ),
|
|
+ }))
|
|
+ ) for node in self.failed_nodes
|
|
+ ],
|
|
+ pcs_disaster_recovery_conf=dr_cfg_fixture(
|
|
+ DrRole.RECOVERY, DrRole.PRIMARY, self.local_nodes
|
|
+ ),
|
|
+ )
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.set_recovery_site(self.env_assist.get_env(), self.node),
|
|
+ )
|
|
+ self.env_assist.assert_reports(
|
|
+ self.expected_reports + [
|
|
+ fixture.error(
|
|
+ report_codes.FILE_DISTRIBUTION_ERROR,
|
|
+ file_description=DR_CFG_DESC,
|
|
+ reason=REASON,
|
|
+ node=node,
|
|
+ ) for node in self.failed_nodes
|
|
+ ]
|
|
+ )
|
|
+
|
|
+ def test_network_failure(self):
|
|
+ self.config.http.files.put_files(
|
|
+ communication_list=self.success_communication + [
|
|
+ dict(
|
|
+ label=node,
|
|
+ was_connected=False,
|
|
+ error_msg=REASON,
|
|
+ ) for node in self.failed_nodes
|
|
+ ],
|
|
+ pcs_disaster_recovery_conf=dr_cfg_fixture(
|
|
+ DrRole.RECOVERY, DrRole.PRIMARY, self.local_nodes
|
|
+ ),
|
|
+ )
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.set_recovery_site(self.env_assist.get_env(), self.node),
|
|
+ )
|
|
+ self.env_assist.assert_reports(
|
|
+ self.expected_reports + [
|
|
+ fixture.error(
|
|
+ report_codes.NODE_COMMUNICATION_ERROR_UNABLE_TO_CONNECT,
|
|
+ command="remote/put_file",
|
|
+ reason=REASON,
|
|
+ node=node,
|
|
+ ) for node in self.failed_nodes
|
|
+ ]
|
|
+ )
|
|
+
|
|
+ def test_communication_error(self):
|
|
+ self.config.http.files.put_files(
|
|
+ communication_list=self.success_communication + [
|
|
+ dict(
|
|
+ label=node,
|
|
+ response_code=400,
|
|
+ output=REASON,
|
|
+ ) for node in self.failed_nodes
|
|
+ ],
|
|
+ pcs_disaster_recovery_conf=dr_cfg_fixture(
|
|
+ DrRole.RECOVERY, DrRole.PRIMARY, self.local_nodes
|
|
+ ),
|
|
+ )
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.set_recovery_site(self.env_assist.get_env(), self.node),
|
|
+ )
|
|
+ self.env_assist.assert_reports(
|
|
+ self.expected_reports + [
|
|
+ fixture.error(
|
|
+ report_codes.NODE_COMMUNICATION_COMMAND_UNSUCCESSFUL,
|
|
+ command="remote/put_file",
|
|
+ reason=REASON,
|
|
+ node=node,
|
|
+ ) for node in self.failed_nodes
|
|
+ ]
|
|
+ )
|
|
+
|
|
+
|
|
+class FailureLocalDrCfgDistribution(TestCase):
|
|
+ # pylint: disable=too-many-instance-attributes
|
|
+ def setUp(self):
|
|
+ self.env_assist, self.config = get_env_tools(self)
|
|
+ local_nodes = generate_nodes(4)
|
|
+ self.remote_nodes = generate_nodes(3, prefix="recovery-")
|
|
+ self.node = self.remote_nodes[0]
|
|
+ self.failed_nodes = local_nodes[-1:]
|
|
+ successful_nodes = local_nodes[:-1]
|
|
+
|
|
+ self.config.env.set_known_nodes(local_nodes + self.remote_nodes)
|
|
+ self.config.raw_file.exists(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ exists=False,
|
|
+ )
|
|
+ self.config.corosync_conf.load_content(
|
|
+ corosync_conf_fixture(local_nodes)
|
|
+ )
|
|
+ self.config.http.corosync.get_corosync_conf(
|
|
+ corosync_conf_fixture(self.remote_nodes), node_labels=[self.node]
|
|
+ )
|
|
+ self.config.http.files.put_files(
|
|
+ node_labels=self.remote_nodes,
|
|
+ pcs_disaster_recovery_conf=dr_cfg_fixture(
|
|
+ DrRole.RECOVERY, DrRole.PRIMARY, local_nodes
|
|
+ ),
|
|
+ name="distribute_remote",
|
|
+ )
|
|
+
|
|
+ self.success_communication = [
|
|
+ dict(label=node) for node in successful_nodes
|
|
+ ]
|
|
+ self.expected_reports = [
|
|
+ fixture.info(
|
|
+ report_codes.FILES_DISTRIBUTION_STARTED,
|
|
+ file_list=[DR_CFG_DESC],
|
|
+ node_list=self.remote_nodes,
|
|
+ )
|
|
+ ] + [
|
|
+ fixture.info(
|
|
+ report_codes.FILE_DISTRIBUTION_SUCCESS,
|
|
+ file_description=DR_CFG_DESC,
|
|
+ node=node,
|
|
+ ) for node in self.remote_nodes
|
|
+ ] + [
|
|
+ fixture.info(
|
|
+ report_codes.FILES_DISTRIBUTION_STARTED,
|
|
+ file_list=[DR_CFG_DESC],
|
|
+ node_list=local_nodes,
|
|
+ )
|
|
+ ] + [
|
|
+ fixture.info(
|
|
+ report_codes.FILE_DISTRIBUTION_SUCCESS,
|
|
+ file_description=DR_CFG_DESC,
|
|
+ node=node,
|
|
+ ) for node in successful_nodes
|
|
+ ]
|
|
+
|
|
+ def test_write_failure(self):
|
|
+ self.config.http.files.put_files(
|
|
+ communication_list=self.success_communication + [
|
|
+ dict(
|
|
+ label=node,
|
|
+ output=json.dumps(dict(files={
|
|
+ DR_CFG_DESC: dict(
|
|
+ code="unexpected",
|
|
+ message=REASON
|
|
+ ),
|
|
+ }))
|
|
+ ) for node in self.failed_nodes
|
|
+ ],
|
|
+ pcs_disaster_recovery_conf=dr_cfg_fixture(
|
|
+ DrRole.PRIMARY, DrRole.RECOVERY, self.remote_nodes
|
|
+ ),
|
|
+ )
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.set_recovery_site(self.env_assist.get_env(), self.node),
|
|
+ )
|
|
+ self.env_assist.assert_reports(
|
|
+ self.expected_reports + [
|
|
+ fixture.error(
|
|
+ report_codes.FILE_DISTRIBUTION_ERROR,
|
|
+ file_description=DR_CFG_DESC,
|
|
+ reason=REASON,
|
|
+ node=node,
|
|
+ ) for node in self.failed_nodes
|
|
+ ]
|
|
+ )
|
|
+
|
|
+ def test_network_failure(self):
|
|
+ self.config.http.files.put_files(
|
|
+ communication_list=self.success_communication + [
|
|
+ dict(
|
|
+ label=node,
|
|
+ was_connected=False,
|
|
+ error_msg=REASON,
|
|
+ ) for node in self.failed_nodes
|
|
+ ],
|
|
+ pcs_disaster_recovery_conf=dr_cfg_fixture(
|
|
+ DrRole.PRIMARY, DrRole.RECOVERY, self.remote_nodes
|
|
+ ),
|
|
+ )
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.set_recovery_site(self.env_assist.get_env(), self.node),
|
|
+ )
|
|
+ self.env_assist.assert_reports(
|
|
+ self.expected_reports + [
|
|
+ fixture.error(
|
|
+ report_codes.NODE_COMMUNICATION_ERROR_UNABLE_TO_CONNECT,
|
|
+ command="remote/put_file",
|
|
+ reason=REASON,
|
|
+ node=node,
|
|
+ ) for node in self.failed_nodes
|
|
+ ]
|
|
+ )
|
|
+
|
|
+ def test_communication_error(self):
|
|
+ self.config.http.files.put_files(
|
|
+ communication_list=self.success_communication + [
|
|
+ dict(
|
|
+ label=node,
|
|
+ response_code=400,
|
|
+ output=REASON,
|
|
+ ) for node in self.failed_nodes
|
|
+ ],
|
|
+ pcs_disaster_recovery_conf=dr_cfg_fixture(
|
|
+ DrRole.PRIMARY, DrRole.RECOVERY, self.remote_nodes
|
|
+ ),
|
|
+ )
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.set_recovery_site(self.env_assist.get_env(), self.node),
|
|
+ )
|
|
+ self.env_assist.assert_reports(
|
|
+ self.expected_reports + [
|
|
+ fixture.error(
|
|
+ report_codes.NODE_COMMUNICATION_COMMAND_UNSUCCESSFUL,
|
|
+ command="remote/put_file",
|
|
+ reason=REASON,
|
|
+ node=node,
|
|
+ ) for node in self.failed_nodes
|
|
+ ]
|
|
+ )
|
|
diff --git a/pcs_test/tier0/lib/commands/dr/test_status.py b/pcs_test/tier0/lib/commands/dr/test_status.py
|
|
new file mode 100644
|
|
index 00000000..b46eb757
|
|
--- /dev/null
|
|
+++ b/pcs_test/tier0/lib/commands/dr/test_status.py
|
|
@@ -0,0 +1,756 @@
|
|
+import json
|
|
+import re
|
|
+from unittest import TestCase
|
|
+
|
|
+from pcs import settings
|
|
+from pcs.common import (
|
|
+ file_type_codes,
|
|
+ report_codes,
|
|
+)
|
|
+from pcs.common.dr import DrRole
|
|
+from pcs.common.file import RawFileError
|
|
+from pcs.lib.commands import dr
|
|
+
|
|
+from pcs_test.tools.command_env import get_env_tools
|
|
+from pcs_test.tools import fixture
|
|
+
|
|
+
|
|
+REASON = "error msg"
|
|
+
|
|
+class CheckLive(TestCase):
|
|
+ def setUp(self):
|
|
+ self.env_assist, self.config = get_env_tools(self)
|
|
+
|
|
+ def assert_live_required(self, forbidden_options):
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.status_all_sites_plaintext(self.env_assist.get_env()),
|
|
+ [
|
|
+ fixture.error(
|
|
+ report_codes.LIVE_ENVIRONMENT_REQUIRED,
|
|
+ forbidden_options=forbidden_options
|
|
+ )
|
|
+ ],
|
|
+ expected_in_processor=False
|
|
+ )
|
|
+
|
|
+ def test_mock_corosync(self):
|
|
+ self.config.env.set_corosync_conf_data("corosync conf")
|
|
+ self.assert_live_required([file_type_codes.COROSYNC_CONF])
|
|
+
|
|
+ def test_mock_cib(self):
|
|
+ self.config.env.set_cib_data("<cib />")
|
|
+ self.assert_live_required([file_type_codes.CIB])
|
|
+
|
|
+ def test_mock(self):
|
|
+ self.config.env.set_corosync_conf_data("corosync conf")
|
|
+ self.config.env.set_cib_data("<cib />")
|
|
+ self.assert_live_required([
|
|
+ file_type_codes.CIB,
|
|
+ file_type_codes.COROSYNC_CONF,
|
|
+ ])
|
|
+
|
|
+class FixtureMixin():
|
|
+ def _set_up(self, local_node_count=2):
|
|
+ self.local_node_name_list = [
|
|
+ f"node{i}" for i in range(1, local_node_count + 1)
|
|
+ ]
|
|
+ self.remote_node_name_list = ["recovery-node"]
|
|
+ self.config.env.set_known_nodes(
|
|
+ self.local_node_name_list + self.remote_node_name_list
|
|
+ )
|
|
+ self.local_status = "local cluster\nstatus\n"
|
|
+ self.remote_status = "remote cluster\nstatus\n"
|
|
+
|
|
+ def _fixture_load_configs(self):
|
|
+ (self.config
|
|
+ .raw_file.exists(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ )
|
|
+ .raw_file.read(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ content="""
|
|
+ {
|
|
+ "local": {
|
|
+ "role": "PRIMARY"
|
|
+ },
|
|
+ "remote_sites": [
|
|
+ {
|
|
+ "nodes": [
|
|
+ {
|
|
+ "name": "recovery-node"
|
|
+ }
|
|
+ ],
|
|
+ "role": "RECOVERY"
|
|
+ }
|
|
+ ]
|
|
+ }
|
|
+ """,
|
|
+ )
|
|
+ .corosync_conf.load(node_name_list=self.local_node_name_list)
|
|
+ )
|
|
+
|
|
+ def _fixture_result(self, local_success=True, remote_success=True):
|
|
+ return [
|
|
+ {
|
|
+ "local_site": True,
|
|
+ "site_role": DrRole.PRIMARY,
|
|
+ "status_plaintext": self.local_status if local_success else "",
|
|
+ "status_successfully_obtained": local_success,
|
|
+ },
|
|
+ {
|
|
+ "local_site": False,
|
|
+ "site_role": DrRole.RECOVERY,
|
|
+ "status_plaintext": (
|
|
+ self.remote_status if remote_success else ""
|
|
+ ),
|
|
+ "status_successfully_obtained": remote_success,
|
|
+ }
|
|
+ ]
|
|
+
|
|
+class Success(FixtureMixin, TestCase):
|
|
+ def setUp(self):
|
|
+ self.env_assist, self.config = get_env_tools(self)
|
|
+ self._set_up()
|
|
+
|
|
+ def _assert_success(self, hide_inactive_resources, verbose):
|
|
+ self._fixture_load_configs()
|
|
+ (self.config
|
|
+ .http.status.get_full_cluster_status_plaintext(
|
|
+ name="http.status.get_full_cluster_status_plaintext.local",
|
|
+ node_labels=self.local_node_name_list[:1],
|
|
+ hide_inactive_resources=hide_inactive_resources,
|
|
+ verbose=verbose,
|
|
+ cluster_status_plaintext=self.local_status,
|
|
+ )
|
|
+ .http.status.get_full_cluster_status_plaintext(
|
|
+ name="http.status.get_full_cluster_status_plaintext.remote",
|
|
+ node_labels=self.remote_node_name_list[:1],
|
|
+ hide_inactive_resources=hide_inactive_resources,
|
|
+ verbose=verbose,
|
|
+ cluster_status_plaintext=self.remote_status,
|
|
+ )
|
|
+ )
|
|
+ result = dr.status_all_sites_plaintext(
|
|
+ self.env_assist.get_env(),
|
|
+ hide_inactive_resources=hide_inactive_resources,
|
|
+ verbose=verbose,
|
|
+ )
|
|
+ self.assertEqual(result, self._fixture_result())
|
|
+
|
|
+ def test_success_minimal(self):
|
|
+ self._assert_success(False, False)
|
|
+
|
|
+ def test_success_full(self):
|
|
+ self._assert_success(False, True)
|
|
+
|
|
+ def test_success_hide_inactive(self):
|
|
+ self._assert_success(True, False)
|
|
+
|
|
+ def test_success_all_flags(self):
|
|
+ self._assert_success(True, True)
|
|
+
|
|
+ def test_local_not_running_first_node(self):
|
|
+ self._fixture_load_configs()
|
|
+ (self.config
|
|
+ .http.status.get_full_cluster_status_plaintext(
|
|
+ name="http.status.get_full_cluster_status_plaintext.local",
|
|
+ cluster_status_plaintext=self.local_status,
|
|
+ communication_list=[
|
|
+ [dict(
|
|
+ label=self.local_node_name_list[0],
|
|
+ output=json.dumps(dict(
|
|
+ status="error",
|
|
+ status_msg="",
|
|
+ data=None,
|
|
+ report_list=[
|
|
+ {
|
|
+ "severity": "ERROR",
|
|
+ "code": "CRM_MON_ERROR",
|
|
+ "info": {
|
|
+ "reason": REASON,
|
|
+ },
|
|
+ "forceable": None,
|
|
+ "report_text": "translated report",
|
|
+ }
|
|
+ ]
|
|
+ )),
|
|
+ )],
|
|
+ [dict(
|
|
+ label=self.local_node_name_list[1],
|
|
+ )],
|
|
+ ]
|
|
+ )
|
|
+ .http.status.get_full_cluster_status_plaintext(
|
|
+ name="http.status.get_full_cluster_status_plaintext.remote",
|
|
+ node_labels=self.remote_node_name_list[:1],
|
|
+ cluster_status_plaintext=self.remote_status,
|
|
+ )
|
|
+ )
|
|
+ result = dr.status_all_sites_plaintext(self.env_assist.get_env())
|
|
+ self.assertEqual(result, self._fixture_result())
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.error(
|
|
+ report_codes.NODE_COMMUNICATION_COMMAND_UNSUCCESSFUL,
|
|
+ node=self.local_node_name_list[0],
|
|
+ command="remote/cluster_status_plaintext",
|
|
+ reason="translated report",
|
|
+ ),
|
|
+ ])
|
|
+
|
|
+ def test_local_not_running(self):
|
|
+ self._fixture_load_configs()
|
|
+ (self.config
|
|
+ .http.status.get_full_cluster_status_plaintext(
|
|
+ name="http.status.get_full_cluster_status_plaintext.local",
|
|
+ cmd_status="error",
|
|
+ cmd_status_msg="",
|
|
+ cluster_status_plaintext="",
|
|
+ report_list=[
|
|
+ {
|
|
+ "severity": "ERROR",
|
|
+ "code": "CRM_MON_ERROR",
|
|
+ "info": {
|
|
+ "reason": REASON,
|
|
+ },
|
|
+ "forceable": None,
|
|
+ "report_text": "translated report",
|
|
+ }
|
|
+ ],
|
|
+ communication_list=[
|
|
+ [dict(
|
|
+ label=self.local_node_name_list[0],
|
|
+ )],
|
|
+ [dict(
|
|
+ label=self.local_node_name_list[1],
|
|
+ )],
|
|
+ ]
|
|
+ )
|
|
+ .http.status.get_full_cluster_status_plaintext(
|
|
+ name="http.status.get_full_cluster_status_plaintext.remote",
|
|
+ node_labels=self.remote_node_name_list[:1],
|
|
+ cluster_status_plaintext=self.remote_status,
|
|
+ )
|
|
+ )
|
|
+ result = dr.status_all_sites_plaintext(self.env_assist.get_env())
|
|
+ self.assertEqual(result, self._fixture_result(local_success=False))
|
|
+ self.env_assist.assert_reports(
|
|
+ [
|
|
+ fixture.error(
|
|
+ report_codes.NODE_COMMUNICATION_COMMAND_UNSUCCESSFUL,
|
|
+ node=node,
|
|
+ command="remote/cluster_status_plaintext",
|
|
+ reason="translated report",
|
|
+ )
|
|
+ for node in self.local_node_name_list
|
|
+ ]
|
|
+ )
|
|
+
|
|
+ def test_remote_not_running(self):
|
|
+ self._fixture_load_configs()
|
|
+ (self.config
|
|
+ .http.status.get_full_cluster_status_plaintext(
|
|
+ name="http.status.get_full_cluster_status_plaintext.local",
|
|
+ node_labels=self.local_node_name_list[:1],
|
|
+ cluster_status_plaintext=self.local_status,
|
|
+ )
|
|
+ .http.status.get_full_cluster_status_plaintext(
|
|
+ name="http.status.get_full_cluster_status_plaintext.remote",
|
|
+ node_labels=self.remote_node_name_list[:1],
|
|
+ cmd_status="error",
|
|
+ cmd_status_msg="",
|
|
+ cluster_status_plaintext="",
|
|
+ report_list=[
|
|
+ {
|
|
+ "severity": "ERROR",
|
|
+ "code": "CRM_MON_ERROR",
|
|
+ "info": {
|
|
+ "reason": REASON,
|
|
+ },
|
|
+ "forceable": None,
|
|
+ "report_text": "translated report",
|
|
+ }
|
|
+ ],
|
|
+ )
|
|
+ )
|
|
+ result = dr.status_all_sites_plaintext(self.env_assist.get_env())
|
|
+ self.assertEqual(result, self._fixture_result(remote_success=False))
|
|
+ self.env_assist.assert_reports(
|
|
+ [
|
|
+ fixture.error(
|
|
+ report_codes.NODE_COMMUNICATION_COMMAND_UNSUCCESSFUL,
|
|
+ node=node,
|
|
+ command="remote/cluster_status_plaintext",
|
|
+ reason="translated report",
|
|
+ )
|
|
+ for node in self.remote_node_name_list
|
|
+ ]
|
|
+ )
|
|
+
|
|
+ def test_both_not_running(self):
|
|
+ self._fixture_load_configs()
|
|
+ (self.config
|
|
+ .http.status.get_full_cluster_status_plaintext(
|
|
+ name="http.status.get_full_cluster_status_plaintext.local",
|
|
+ cmd_status="error",
|
|
+ cmd_status_msg="",
|
|
+ cluster_status_plaintext="",
|
|
+ report_list=[
|
|
+ {
|
|
+ "severity": "ERROR",
|
|
+ "code": "CRM_MON_ERROR",
|
|
+ "info": {
|
|
+ "reason": REASON,
|
|
+ },
|
|
+ "forceable": None,
|
|
+ "report_text": "translated report",
|
|
+ }
|
|
+ ],
|
|
+ communication_list=[
|
|
+ [dict(
|
|
+ label=self.local_node_name_list[0],
|
|
+ )],
|
|
+ [dict(
|
|
+ label=self.local_node_name_list[1],
|
|
+ )],
|
|
+ ]
|
|
+ )
|
|
+ .http.status.get_full_cluster_status_plaintext(
|
|
+ name="http.status.get_full_cluster_status_plaintext.remote",
|
|
+ node_labels=self.remote_node_name_list[:1],
|
|
+ cmd_status="error",
|
|
+ cmd_status_msg="",
|
|
+ cluster_status_plaintext="",
|
|
+ report_list=[
|
|
+ {
|
|
+ "severity": "ERROR",
|
|
+ "code": "CRM_MON_ERROR",
|
|
+ "info": {
|
|
+ "reason": REASON,
|
|
+ },
|
|
+ "forceable": None,
|
|
+ "report_text": "translated report",
|
|
+ }
|
|
+ ],
|
|
+ )
|
|
+ )
|
|
+ result = dr.status_all_sites_plaintext(self.env_assist.get_env())
|
|
+ self.assertEqual(result, self._fixture_result(
|
|
+ local_success=False, remote_success=False
|
|
+ ))
|
|
+ self.env_assist.assert_reports(
|
|
+ [
|
|
+ fixture.error(
|
|
+ report_codes.NODE_COMMUNICATION_COMMAND_UNSUCCESSFUL,
|
|
+ node=node,
|
|
+ command="remote/cluster_status_plaintext",
|
|
+ reason="translated report",
|
|
+ )
|
|
+ for node in (
|
|
+ self.local_node_name_list + self.remote_node_name_list
|
|
+ )
|
|
+ ]
|
|
+ )
|
|
+
|
|
+
|
|
+class CommunicationIssue(FixtureMixin, TestCase):
|
|
+ def setUp(self):
|
|
+ self.env_assist, self.config = get_env_tools(self)
|
|
+ self._set_up()
|
|
+
|
|
+ def test_unknown_node(self):
|
|
+ self.config.env.set_known_nodes(
|
|
+ self.local_node_name_list[1:] + self.remote_node_name_list
|
|
+ )
|
|
+ self._fixture_load_configs()
|
|
+ (self.config
|
|
+ .http.status.get_full_cluster_status_plaintext(
|
|
+ name="http.status.get_full_cluster_status_plaintext.local",
|
|
+ node_labels=self.local_node_name_list[1:],
|
|
+ cluster_status_plaintext=self.local_status,
|
|
+ )
|
|
+ .http.status.get_full_cluster_status_plaintext(
|
|
+ name="http.status.get_full_cluster_status_plaintext.remote",
|
|
+ node_labels=self.remote_node_name_list[:1],
|
|
+ cluster_status_plaintext=self.remote_status,
|
|
+ )
|
|
+ )
|
|
+ result = dr.status_all_sites_plaintext(self.env_assist.get_env())
|
|
+ self.assertEqual(result, self._fixture_result())
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.warn(
|
|
+ report_codes.HOST_NOT_FOUND,
|
|
+ host_list=["node1"],
|
|
+ ),
|
|
+ ])
|
|
+
|
|
+ def test_unknown_all_nodes_in_site(self):
|
|
+ self.config.env.set_known_nodes(
|
|
+ self.local_node_name_list
|
|
+ )
|
|
+ self._fixture_load_configs()
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.status_all_sites_plaintext(self.env_assist.get_env()),
|
|
+ )
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.warn(
|
|
+ report_codes.HOST_NOT_FOUND,
|
|
+ host_list=self.remote_node_name_list,
|
|
+ ),
|
|
+ fixture.error(
|
|
+ report_codes.NONE_HOST_FOUND,
|
|
+ ),
|
|
+ ])
|
|
+
|
|
+ def test_missing_node_names(self):
|
|
+ self._fixture_load_configs()
|
|
+ coro_call = self.config.calls.get("corosync_conf.load")
|
|
+ (self.config
|
|
+ .http.status.get_full_cluster_status_plaintext(
|
|
+ name="http.status.get_full_cluster_status_plaintext.local",
|
|
+ node_labels=[],
|
|
+ )
|
|
+ .http.status.get_full_cluster_status_plaintext(
|
|
+ name="http.status.get_full_cluster_status_plaintext.remote",
|
|
+ node_labels=self.remote_node_name_list[:1],
|
|
+ cluster_status_plaintext=self.remote_status,
|
|
+ )
|
|
+ )
|
|
+ coro_call.content = re.sub(r"name: node\d", "", coro_call.content)
|
|
+ result = dr.status_all_sites_plaintext(self.env_assist.get_env())
|
|
+ self.assertEqual(result, self._fixture_result(local_success=False))
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.warn(
|
|
+ report_codes.COROSYNC_CONFIG_MISSING_NAMES_OF_NODES,
|
|
+ fatal=False,
|
|
+ ),
|
|
+ ])
|
|
+
|
|
+ def test_node_issues(self):
|
|
+ self._set_up(local_node_count=7)
|
|
+ self._fixture_load_configs()
|
|
+ (self.config
|
|
+ .http.status.get_full_cluster_status_plaintext(
|
|
+ name="http.status.get_full_cluster_status_plaintext.local",
|
|
+ cluster_status_plaintext=self.local_status,
|
|
+ communication_list=[
|
|
+ [dict(
|
|
+ label=self.local_node_name_list[0],
|
|
+ was_connected=False,
|
|
+ )],
|
|
+ [dict(
|
|
+ label=self.local_node_name_list[1],
|
|
+ response_code=401,
|
|
+ )],
|
|
+ [dict(
|
|
+ label=self.local_node_name_list[2],
|
|
+ response_code=500,
|
|
+ )],
|
|
+ [dict(
|
|
+ label=self.local_node_name_list[3],
|
|
+ response_code=404,
|
|
+ )],
|
|
+ [dict(
|
|
+ label=self.local_node_name_list[4],
|
|
+ output="invalid data",
|
|
+ )],
|
|
+ [dict(
|
|
+ label=self.local_node_name_list[5],
|
|
+ output=json.dumps(dict(status="success"))
|
|
+ )],
|
|
+ [dict(
|
|
+ label=self.local_node_name_list[6],
|
|
+ )],
|
|
+ ]
|
|
+ )
|
|
+ .http.status.get_full_cluster_status_plaintext(
|
|
+ name="http.status.get_full_cluster_status_plaintext.remote",
|
|
+ node_labels=self.remote_node_name_list[:1],
|
|
+ cluster_status_plaintext=self.remote_status,
|
|
+ )
|
|
+ )
|
|
+ result = dr.status_all_sites_plaintext(self.env_assist.get_env())
|
|
+ self.assertEqual(result, self._fixture_result())
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.warn(
|
|
+ report_codes.NODE_COMMUNICATION_ERROR_UNABLE_TO_CONNECT,
|
|
+ command="remote/cluster_status_plaintext",
|
|
+ node="node1",
|
|
+ reason=None,
|
|
+ ),
|
|
+ fixture.warn(
|
|
+ report_codes.NODE_COMMUNICATION_ERROR_NOT_AUTHORIZED,
|
|
+ command="remote/cluster_status_plaintext",
|
|
+ node="node2",
|
|
+ reason="HTTP error: 401",
|
|
+ ),
|
|
+ fixture.warn(
|
|
+ report_codes.NODE_COMMUNICATION_ERROR,
|
|
+ command="remote/cluster_status_plaintext",
|
|
+ node="node3",
|
|
+ reason="HTTP error: 500",
|
|
+ ),
|
|
+ fixture.warn(
|
|
+ report_codes.NODE_COMMUNICATION_ERROR_UNSUPPORTED_COMMAND,
|
|
+ command="remote/cluster_status_plaintext",
|
|
+ node="node4",
|
|
+ reason="HTTP error: 404",
|
|
+ ),
|
|
+ fixture.warn(
|
|
+ report_codes.INVALID_RESPONSE_FORMAT,
|
|
+ node="node5",
|
|
+ ),
|
|
+ fixture.warn(
|
|
+ report_codes.INVALID_RESPONSE_FORMAT,
|
|
+ node="node6",
|
|
+ ),
|
|
+ ])
|
|
+
|
|
+ def test_local_site_down(self):
|
|
+ self._fixture_load_configs()
|
|
+ (self.config
|
|
+ .http.status.get_full_cluster_status_plaintext(
|
|
+ name="http.status.get_full_cluster_status_plaintext.local",
|
|
+ cluster_status_plaintext=self.local_status,
|
|
+ communication_list=[
|
|
+ [dict(
|
|
+ label=self.local_node_name_list[0],
|
|
+ was_connected=False,
|
|
+ )],
|
|
+ [dict(
|
|
+ label=self.local_node_name_list[1],
|
|
+ was_connected=False,
|
|
+ )],
|
|
+ ]
|
|
+ )
|
|
+ .http.status.get_full_cluster_status_plaintext(
|
|
+ name="http.status.get_full_cluster_status_plaintext.remote",
|
|
+ node_labels=self.remote_node_name_list[:1],
|
|
+ cluster_status_plaintext=self.remote_status,
|
|
+ )
|
|
+ )
|
|
+ result = dr.status_all_sites_plaintext(self.env_assist.get_env())
|
|
+ self.assertEqual(result, self._fixture_result(local_success=False))
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.warn(
|
|
+ report_codes.NODE_COMMUNICATION_ERROR_UNABLE_TO_CONNECT,
|
|
+ command="remote/cluster_status_plaintext",
|
|
+ node="node1",
|
|
+ reason=None,
|
|
+ ),
|
|
+ fixture.warn(
|
|
+ report_codes.NODE_COMMUNICATION_ERROR_UNABLE_TO_CONNECT,
|
|
+ command="remote/cluster_status_plaintext",
|
|
+ node="node2",
|
|
+ reason=None,
|
|
+ ),
|
|
+ ])
|
|
+
|
|
+ def test_remote_site_down(self):
|
|
+ self._fixture_load_configs()
|
|
+ (self.config
|
|
+ .http.status.get_full_cluster_status_plaintext(
|
|
+ name="http.status.get_full_cluster_status_plaintext.local",
|
|
+ node_labels=self.local_node_name_list[:1],
|
|
+ cluster_status_plaintext=self.local_status,
|
|
+ )
|
|
+ .http.status.get_full_cluster_status_plaintext(
|
|
+ name="http.status.get_full_cluster_status_plaintext.remote",
|
|
+ cluster_status_plaintext=self.remote_status,
|
|
+ communication_list=[
|
|
+ [dict(
|
|
+ label=self.remote_node_name_list[0],
|
|
+ was_connected=False,
|
|
+ )],
|
|
+ ]
|
|
+ )
|
|
+ )
|
|
+ result = dr.status_all_sites_plaintext(self.env_assist.get_env())
|
|
+ self.assertEqual(result, self._fixture_result(remote_success=False))
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.warn(
|
|
+ report_codes.NODE_COMMUNICATION_ERROR_UNABLE_TO_CONNECT,
|
|
+ command="remote/cluster_status_plaintext",
|
|
+ node="recovery-node",
|
|
+ reason=None,
|
|
+ ),
|
|
+ ])
|
|
+
|
|
+ def test_both_sites_down(self):
|
|
+ self._fixture_load_configs()
|
|
+ (self.config
|
|
+ .http.status.get_full_cluster_status_plaintext(
|
|
+ name="http.status.get_full_cluster_status_plaintext.local",
|
|
+ cluster_status_plaintext=self.local_status,
|
|
+ communication_list=[
|
|
+ [dict(
|
|
+ label=self.local_node_name_list[0],
|
|
+ was_connected=False,
|
|
+ )],
|
|
+ [dict(
|
|
+ label=self.local_node_name_list[1],
|
|
+ was_connected=False,
|
|
+ )],
|
|
+ ]
|
|
+ )
|
|
+ .http.status.get_full_cluster_status_plaintext(
|
|
+ name="http.status.get_full_cluster_status_plaintext.remote",
|
|
+ cluster_status_plaintext=self.remote_status,
|
|
+ communication_list=[
|
|
+ [dict(
|
|
+ label=self.remote_node_name_list[0],
|
|
+ was_connected=False,
|
|
+ )],
|
|
+ ]
|
|
+ )
|
|
+ )
|
|
+ result = dr.status_all_sites_plaintext(self.env_assist.get_env())
|
|
+ self.assertEqual(
|
|
+ result,
|
|
+ self._fixture_result(local_success=False, remote_success=False)
|
|
+ )
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.warn(
|
|
+ report_codes.NODE_COMMUNICATION_ERROR_UNABLE_TO_CONNECT,
|
|
+ command="remote/cluster_status_plaintext",
|
|
+ node="node1",
|
|
+ reason=None,
|
|
+ ),
|
|
+ fixture.warn(
|
|
+ report_codes.NODE_COMMUNICATION_ERROR_UNABLE_TO_CONNECT,
|
|
+ command="remote/cluster_status_plaintext",
|
|
+ node="node2",
|
|
+ reason=None,
|
|
+ ),
|
|
+ fixture.warn(
|
|
+ report_codes.NODE_COMMUNICATION_ERROR_UNABLE_TO_CONNECT,
|
|
+ command="remote/cluster_status_plaintext",
|
|
+ node="recovery-node",
|
|
+ reason=None,
|
|
+ ),
|
|
+ ])
|
|
+
|
|
+
|
|
+class FatalConfigIssue(TestCase):
|
|
+ def setUp(self):
|
|
+ self.env_assist, self.config = get_env_tools(self)
|
|
+
|
|
+ def test_config_missing(self):
|
|
+ (self.config
|
|
+ .raw_file.exists(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ exists=False,
|
|
+ )
|
|
+ )
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.status_all_sites_plaintext(self.env_assist.get_env()),
|
|
+ )
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.error(
|
|
+ report_codes.DR_CONFIG_DOES_NOT_EXIST,
|
|
+ ),
|
|
+ ])
|
|
+
|
|
+ def test_config_read_error(self):
|
|
+ (self.config
|
|
+ .raw_file.exists(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ )
|
|
+ .raw_file.read(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ exception_msg=REASON,
|
|
+ )
|
|
+ )
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.status_all_sites_plaintext(self.env_assist.get_env()),
|
|
+ )
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.error(
|
|
+ report_codes.FILE_IO_ERROR,
|
|
+ file_type_code=file_type_codes.PCS_DR_CONFIG,
|
|
+ file_path=settings.pcsd_dr_config_location,
|
|
+ operation=RawFileError.ACTION_READ,
|
|
+ reason=REASON,
|
|
+ ),
|
|
+ ])
|
|
+
|
|
+ def test_config_parse_error(self):
|
|
+ (self.config
|
|
+ .raw_file.exists(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ )
|
|
+ .raw_file.read(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ content="bad content",
|
|
+ )
|
|
+ )
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.status_all_sites_plaintext(self.env_assist.get_env()),
|
|
+ )
|
|
+ self.env_assist.assert_reports([
|
|
+ fixture.error(
|
|
+ report_codes.PARSE_ERROR_JSON_FILE,
|
|
+ file_type_code=file_type_codes.PCS_DR_CONFIG,
|
|
+ file_path=settings.pcsd_dr_config_location,
|
|
+ line_number=1,
|
|
+ column_number=1,
|
|
+ position=0,
|
|
+ reason="Expecting value",
|
|
+ full_msg="Expecting value: line 1 column 1 (char 0)",
|
|
+ ),
|
|
+ ])
|
|
+
|
|
+ def test_corosync_conf_read_error(self):
|
|
+ (self.config
|
|
+ .raw_file.exists(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ )
|
|
+ .raw_file.read(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ content="{}",
|
|
+ )
|
|
+ .corosync_conf.load_content("", exception_msg=REASON)
|
|
+ )
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.status_all_sites_plaintext(self.env_assist.get_env()),
|
|
+ [
|
|
+ fixture.error(
|
|
+ report_codes.UNABLE_TO_READ_COROSYNC_CONFIG,
|
|
+ path=settings.corosync_conf_file,
|
|
+ reason=REASON,
|
|
+ ),
|
|
+ ],
|
|
+ expected_in_processor=False
|
|
+ )
|
|
+
|
|
+ def test_corosync_conf_parse_error(self):
|
|
+ (self.config
|
|
+ .raw_file.exists(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ )
|
|
+ .raw_file.read(
|
|
+ file_type_codes.PCS_DR_CONFIG,
|
|
+ settings.pcsd_dr_config_location,
|
|
+ content="{}",
|
|
+ )
|
|
+ .corosync_conf.load_content("wrong {\n corosync")
|
|
+ )
|
|
+ self.env_assist.assert_raise_library_error(
|
|
+ lambda: dr.status_all_sites_plaintext(self.env_assist.get_env()),
|
|
+ [
|
|
+ fixture.error(
|
|
+ report_codes
|
|
+ .PARSE_ERROR_COROSYNC_CONF_LINE_IS_NOT_SECTION_NOR_KEY_VALUE
|
|
+ ),
|
|
+ ],
|
|
+ expected_in_processor=False
|
|
+ )
|
|
diff --git a/pcs_test/tier0/lib/communication/test_status.py b/pcs_test/tier0/lib/communication/test_status.py
|
|
new file mode 100644
|
|
index 00000000..b8db7a73
|
|
--- /dev/null
|
|
+++ b/pcs_test/tier0/lib/communication/test_status.py
|
|
@@ -0,0 +1,7 @@
|
|
+from unittest import TestCase
|
|
+
|
|
+class GetFullClusterStatusPlaintext(TestCase):
|
|
+ """
|
|
+ tested in:
|
|
+ pcs_test.tier0.lib.commands.dr.test_status
|
|
+ """
|
|
diff --git a/pcs_test/tier0/lib/dr/__init__.py b/pcs_test/tier0/lib/dr/__init__.py
|
|
new file mode 100644
|
|
index 00000000..e69de29b
|
|
diff --git a/pcs_test/tier0/lib/dr/test_facade.py b/pcs_test/tier0/lib/dr/test_facade.py
|
|
new file mode 100644
|
|
index 00000000..baa17b1e
|
|
--- /dev/null
|
|
+++ b/pcs_test/tier0/lib/dr/test_facade.py
|
|
@@ -0,0 +1,138 @@
|
|
+from unittest import TestCase
|
|
+
|
|
+from pcs.common.dr import DrRole
|
|
+from pcs.lib.dr.config import facade
|
|
+
|
|
+
|
|
+class Facade(TestCase):
|
|
+ def test_create(self):
|
|
+ for role in DrRole:
|
|
+ with self.subTest(local_role=role.value):
|
|
+ self.assertEqual(
|
|
+ dict(
|
|
+ local=dict(
|
|
+ role=role.value,
|
|
+ ),
|
|
+ remote_sites=[],
|
|
+ ),
|
|
+ facade.Facade.create(role).config,
|
|
+ )
|
|
+
|
|
+ def test_local_role(self):
|
|
+ for role in DrRole:
|
|
+ with self.subTest(local_role=role.value):
|
|
+ cfg = facade.Facade({
|
|
+ "local": {
|
|
+ "role": role.value,
|
|
+ },
|
|
+ "remote_sites": [
|
|
+ ],
|
|
+ })
|
|
+ self.assertEqual(cfg.local_role, role)
|
|
+
|
|
+ def test_add_site(self):
|
|
+ node_list = [f"node{i}" for i in range(4)]
|
|
+ cfg = facade.Facade.create(DrRole.PRIMARY)
|
|
+ cfg.add_site(DrRole.RECOVERY, node_list)
|
|
+ self.assertEqual(
|
|
+ dict(
|
|
+ local=dict(
|
|
+ role=DrRole.PRIMARY.value,
|
|
+ ),
|
|
+ remote_sites=[
|
|
+ dict(
|
|
+ role=DrRole.RECOVERY.value,
|
|
+ nodes=[dict(name=node) for node in node_list],
|
|
+ ),
|
|
+ ]
|
|
+ ),
|
|
+ cfg.config
|
|
+ )
|
|
+
|
|
+class GetRemoteSiteList(TestCase):
|
|
+ def test_no_sites(self):
|
|
+ cfg = facade.Facade({
|
|
+ "local": {
|
|
+ "role": DrRole.PRIMARY.value,
|
|
+ },
|
|
+ "remote_sites": [
|
|
+ ],
|
|
+ })
|
|
+ self.assertEqual(
|
|
+ cfg.get_remote_site_list(),
|
|
+ []
|
|
+ )
|
|
+
|
|
+ def test_one_site(self):
|
|
+ cfg = facade.Facade({
|
|
+ "local": {
|
|
+ "role": DrRole.PRIMARY.value,
|
|
+ },
|
|
+ "remote_sites": [
|
|
+ {
|
|
+ "role": DrRole.RECOVERY.value,
|
|
+ "nodes": [
|
|
+ {"name": "node1"},
|
|
+ ],
|
|
+ },
|
|
+ ],
|
|
+ })
|
|
+ self.assertEqual(
|
|
+ cfg.get_remote_site_list(),
|
|
+ [
|
|
+ facade.DrSite(role=DrRole.RECOVERY, node_name_list=["node1"]),
|
|
+ ]
|
|
+ )
|
|
+
|
|
+ def test_more_sites(self):
|
|
+ cfg = facade.Facade({
|
|
+ "local": {
|
|
+ "role": DrRole.RECOVERY.value,
|
|
+ },
|
|
+ "remote_sites": [
|
|
+ {
|
|
+ "role": DrRole.PRIMARY.value,
|
|
+ "nodes": [
|
|
+ {"name": "nodeA1"},
|
|
+ {"name": "nodeA2"},
|
|
+ ],
|
|
+ },
|
|
+ {
|
|
+ "role": DrRole.RECOVERY.value,
|
|
+ "nodes": [
|
|
+ {"name": "nodeB1"},
|
|
+ {"name": "nodeB2"},
|
|
+ ],
|
|
+ },
|
|
+ ],
|
|
+ })
|
|
+ self.assertEqual(
|
|
+ cfg.get_remote_site_list(),
|
|
+ [
|
|
+ facade.DrSite(
|
|
+ role=DrRole.PRIMARY, node_name_list=["nodeA1", "nodeA2"]
|
|
+ ),
|
|
+ facade.DrSite(
|
|
+ role=DrRole.RECOVERY, node_name_list=["nodeB1", "nodeB2"]
|
|
+ ),
|
|
+ ]
|
|
+ )
|
|
+
|
|
+ def test_no_nodes(self):
|
|
+ cfg = facade.Facade({
|
|
+ "local": {
|
|
+ "role": DrRole.PRIMARY.value,
|
|
+ },
|
|
+ "remote_sites": [
|
|
+ {
|
|
+ "role": DrRole.RECOVERY.value,
|
|
+ "nodes": [],
|
|
+ },
|
|
+ ],
|
|
+ })
|
|
+ self.assertEqual(
|
|
+ cfg.get_remote_site_list(),
|
|
+ [
|
|
+ facade.DrSite(role=DrRole.RECOVERY, node_name_list=[]),
|
|
+ ]
|
|
+ )
|
|
diff --git a/pcs_test/tier0/lib/test_env.py b/pcs_test/tier0/lib/test_env.py
|
|
index edab9dc6..5c1c6a39 100644
|
|
--- a/pcs_test/tier0/lib/test_env.py
|
|
+++ b/pcs_test/tier0/lib/test_env.py
|
|
@@ -9,7 +9,7 @@ from pcs_test.tools.misc import (
|
|
get_test_resource as rc,
|
|
)
|
|
|
|
-from pcs.common import report_codes
|
|
+from pcs.common import file_type_codes, report_codes
|
|
from pcs.lib.env import LibraryEnvironment
|
|
from pcs.lib.errors import ReportItemSeverity as severity
|
|
|
|
@@ -57,6 +57,46 @@ class LibraryEnvironmentTest(TestCase):
|
|
env = LibraryEnvironment(self.mock_logger, self.mock_reporter)
|
|
self.assertEqual([], env.user_groups)
|
|
|
|
+class GhostFileCodes(TestCase):
|
|
+ def setUp(self):
|
|
+ self.mock_logger = mock.MagicMock(logging.Logger)
|
|
+ self.mock_reporter = MockLibraryReportProcessor()
|
|
+
|
|
+ def _fixture_get_env(self, cib_data=None, corosync_conf_data=None):
|
|
+ return LibraryEnvironment(
|
|
+ self.mock_logger,
|
|
+ self.mock_reporter,
|
|
+ cib_data=cib_data,
|
|
+ corosync_conf_data=corosync_conf_data
|
|
+ )
|
|
+
|
|
+ def test_nothing(self):
|
|
+ self.assertEqual(
|
|
+ self._fixture_get_env().ghost_file_codes,
|
|
+ set()
|
|
+ )
|
|
+
|
|
+ def test_corosync(self):
|
|
+ self.assertEqual(
|
|
+ self._fixture_get_env(corosync_conf_data="x").ghost_file_codes,
|
|
+ set([file_type_codes.COROSYNC_CONF])
|
|
+ )
|
|
+
|
|
+ def test_cib(self):
|
|
+ self.assertEqual(
|
|
+ self._fixture_get_env(cib_data="x").ghost_file_codes,
|
|
+ set([file_type_codes.CIB])
|
|
+ )
|
|
+
|
|
+ def test_all(self):
|
|
+ self.assertEqual(
|
|
+ self._fixture_get_env(
|
|
+ cib_data="x",
|
|
+ corosync_conf_data="x",
|
|
+ ).ghost_file_codes,
|
|
+ set([file_type_codes.COROSYNC_CONF, file_type_codes.CIB])
|
|
+ )
|
|
+
|
|
@patch_env("CommandRunner")
|
|
class CmdRunner(TestCase):
|
|
def setUp(self):
|
|
diff --git a/pcs_test/tools/command_env/config_corosync_conf.py b/pcs_test/tools/command_env/config_corosync_conf.py
|
|
index 3db57cee..a0bd9f33 100644
|
|
--- a/pcs_test/tools/command_env/config_corosync_conf.py
|
|
+++ b/pcs_test/tools/command_env/config_corosync_conf.py
|
|
@@ -9,9 +9,14 @@ class CorosyncConf:
|
|
self.__calls = call_collection
|
|
|
|
def load_content(
|
|
- self, content, name="corosync_conf.load_content", instead=None
|
|
+ self, content, name="corosync_conf.load_content", instead=None,
|
|
+ exception_msg=None
|
|
):
|
|
- self.__calls.place(name, Call(content), instead=instead)
|
|
+ self.__calls.place(
|
|
+ name,
|
|
+ Call(content, exception_msg=exception_msg),
|
|
+ instead=instead
|
|
+ )
|
|
|
|
def load(
|
|
self, node_name_list=None, name="corosync_conf.load",
|
|
diff --git a/pcs_test/tools/command_env/config_http.py b/pcs_test/tools/command_env/config_http.py
|
|
index 6827c2b1..911a82df 100644
|
|
--- a/pcs_test/tools/command_env/config_http.py
|
|
+++ b/pcs_test/tools/command_env/config_http.py
|
|
@@ -7,6 +7,7 @@ from pcs_test.tools.command_env.config_http_files import FilesShortcuts
|
|
from pcs_test.tools.command_env.config_http_host import HostShortcuts
|
|
from pcs_test.tools.command_env.config_http_pcmk import PcmkShortcuts
|
|
from pcs_test.tools.command_env.config_http_sbd import SbdShortcuts
|
|
+from pcs_test.tools.command_env.config_http_status import StatusShortcuts
|
|
from pcs_test.tools.command_env.mock_node_communicator import(
|
|
place_communication,
|
|
place_requests,
|
|
@@ -34,6 +35,7 @@ def _mutual_exclusive(param_names, **kwargs):
|
|
|
|
|
|
class HttpConfig:
|
|
+ # pylint: disable=too-many-instance-attributes
|
|
def __init__(self, call_collection, wrap_helper):
|
|
self.__calls = call_collection
|
|
|
|
@@ -43,6 +45,7 @@ class HttpConfig:
|
|
self.host = wrap_helper(HostShortcuts(self.__calls))
|
|
self.pcmk = wrap_helper(PcmkShortcuts(self.__calls))
|
|
self.sbd = wrap_helper(SbdShortcuts(self.__calls))
|
|
+ self.status = wrap_helper(StatusShortcuts(self.__calls))
|
|
|
|
def add_communication(self, name, communication_list, **kwargs):
|
|
"""
|
|
diff --git a/pcs_test/tools/command_env/config_http_corosync.py b/pcs_test/tools/command_env/config_http_corosync.py
|
|
index f7df73c1..3d89e649 100644
|
|
--- a/pcs_test/tools/command_env/config_http_corosync.py
|
|
+++ b/pcs_test/tools/command_env/config_http_corosync.py
|
|
@@ -29,6 +29,30 @@ class CorosyncShortcuts:
|
|
output='{"corosync":false}'
|
|
)
|
|
|
|
+ def get_corosync_conf(
|
|
+ self,
|
|
+ corosync_conf="",
|
|
+ node_labels=None,
|
|
+ communication_list=None,
|
|
+ name="http.corosync.get_corosync_conf",
|
|
+ ):
|
|
+ """
|
|
+ Create a call for loading corosync.conf text from remote nodes
|
|
+
|
|
+ string corosync_conf -- corosync.conf text to be loaded
|
|
+ list node_labels -- create success responses from these nodes
|
|
+ list communication_list -- create custom responses
|
|
+ string name -- the key of this call
|
|
+ """
|
|
+ place_multinode_call(
|
|
+ self.__calls,
|
|
+ name,
|
|
+ node_labels,
|
|
+ communication_list,
|
|
+ action="remote/get_corosync_conf",
|
|
+ output=corosync_conf,
|
|
+ )
|
|
+
|
|
def set_corosync_conf(
|
|
self, corosync_conf, node_labels=None, communication_list=None,
|
|
name="http.corosync.set_corosync_conf"
|
|
diff --git a/pcs_test/tools/command_env/config_http_files.py b/pcs_test/tools/command_env/config_http_files.py
|
|
index 8cc9b878..b4e93d64 100644
|
|
--- a/pcs_test/tools/command_env/config_http_files.py
|
|
+++ b/pcs_test/tools/command_env/config_http_files.py
|
|
@@ -11,9 +11,11 @@ class FilesShortcuts:
|
|
|
|
def put_files(
|
|
self, node_labels=None, pcmk_authkey=None, corosync_authkey=None,
|
|
- corosync_conf=None, pcs_settings_conf=None, communication_list=None,
|
|
+ corosync_conf=None, pcs_disaster_recovery_conf=None,
|
|
+ pcs_settings_conf=None, communication_list=None,
|
|
name="http.files.put_files",
|
|
):
|
|
+ # pylint: disable=too-many-arguments
|
|
"""
|
|
Create a call for the files distribution to the nodes.
|
|
|
|
@@ -21,6 +23,7 @@ class FilesShortcuts:
|
|
pcmk_authkey bytes -- content of pacemaker authkey file
|
|
corosync_authkey bytes -- content of corosync authkey file
|
|
corosync_conf string -- content of corosync.conf
|
|
+ pcs_disaster_recovery_conf string -- content of pcs DR config
|
|
pcs_settings_conf string -- content of pcs_settings.conf
|
|
communication_list list -- create custom responses
|
|
name string -- the key of this call
|
|
@@ -58,6 +61,17 @@ class FilesShortcuts:
|
|
)
|
|
output_data[file_id] = written_output_dict
|
|
|
|
+ if pcs_disaster_recovery_conf:
|
|
+ file_id = "disaster-recovery config"
|
|
+ input_data[file_id] = dict(
|
|
+ data=base64.b64encode(
|
|
+ pcs_disaster_recovery_conf
|
|
+ ).decode("utf-8"),
|
|
+ type="pcs_disaster_recovery_conf",
|
|
+ rewrite_existing=True,
|
|
+ )
|
|
+ output_data[file_id] = written_output_dict
|
|
+
|
|
if pcs_settings_conf:
|
|
file_id = "pcs_settings.conf"
|
|
input_data[file_id] = dict(
|
|
@@ -78,7 +92,8 @@ class FilesShortcuts:
|
|
)
|
|
|
|
def remove_files(
|
|
- self, node_labels=None, pcsd_settings=False, communication_list=None,
|
|
+ self, node_labels=None, pcsd_settings=False,
|
|
+ pcs_disaster_recovery_conf=False, communication_list=None,
|
|
name="http.files.remove_files"
|
|
):
|
|
"""
|
|
@@ -86,6 +101,7 @@ class FilesShortcuts:
|
|
|
|
node_labels list -- create success responses from these nodes
|
|
pcsd_settings bool -- if True, remove file pcsd_settings
|
|
+ pcs_disaster_recovery_conf bool -- if True, remove pcs DR config
|
|
communication_list list -- create custom responses
|
|
name string -- the key of this call
|
|
"""
|
|
@@ -100,6 +116,14 @@ class FilesShortcuts:
|
|
message="",
|
|
)
|
|
|
|
+ if pcs_disaster_recovery_conf:
|
|
+ file_id = "pcs disaster-recovery config"
|
|
+ input_data[file_id] = dict(type="pcs_disaster_recovery_conf")
|
|
+ output_data[file_id] = dict(
|
|
+ code="deleted",
|
|
+ message="",
|
|
+ )
|
|
+
|
|
place_multinode_call(
|
|
self.__calls,
|
|
name,
|
|
diff --git a/pcs_test/tools/command_env/config_http_status.py b/pcs_test/tools/command_env/config_http_status.py
|
|
new file mode 100644
|
|
index 00000000..888b27bb
|
|
--- /dev/null
|
|
+++ b/pcs_test/tools/command_env/config_http_status.py
|
|
@@ -0,0 +1,52 @@
|
|
+import json
|
|
+
|
|
+from pcs_test.tools.command_env.mock_node_communicator import (
|
|
+ place_multinode_call,
|
|
+)
|
|
+
|
|
+class StatusShortcuts:
|
|
+ def __init__(self, calls):
|
|
+ self.__calls = calls
|
|
+
|
|
+ def get_full_cluster_status_plaintext(
|
|
+ self, node_labels=None, communication_list=None,
|
|
+ name="http.status.get_full_cluster_status_plaintext",
|
|
+ hide_inactive_resources=False, verbose=False,
|
|
+ cmd_status="success", cmd_status_msg="", report_list=None,
|
|
+ cluster_status_plaintext="",
|
|
+ ):
|
|
+ # pylint: disable=too-many-arguments
|
|
+ """
|
|
+ Create a call for getting cluster status in plaintext
|
|
+
|
|
+ node_labels list -- create success responses from these nodes
|
|
+ communication_list list -- create custom responses
|
|
+ name string -- the key of this call
|
|
+ bool hide_inactive_resources -- input flag
|
|
+ bool verbose -- input flag
|
|
+ string cmd_status -- did the command succeed?
|
|
+ string_cmd_status_msg -- details for cmd_status
|
|
+ iterable report_list -- reports from a remote node
|
|
+ string cluster_status_plaintext -- resulting cluster status
|
|
+ """
|
|
+ report_list = report_list or []
|
|
+ place_multinode_call(
|
|
+ self.__calls,
|
|
+ name,
|
|
+ node_labels,
|
|
+ communication_list,
|
|
+ action="remote/cluster_status_plaintext",
|
|
+ param_list=[(
|
|
+ "data_json",
|
|
+ json.dumps(dict(
|
|
+ hide_inactive_resources=hide_inactive_resources,
|
|
+ verbose=verbose,
|
|
+ ))
|
|
+ )],
|
|
+ output=json.dumps(dict(
|
|
+ status=cmd_status,
|
|
+ status_msg=cmd_status_msg,
|
|
+ data=cluster_status_plaintext,
|
|
+ report_list=report_list,
|
|
+ )),
|
|
+ )
|
|
diff --git a/pcs_test/tools/command_env/mock_get_local_corosync_conf.py b/pcs_test/tools/command_env/mock_get_local_corosync_conf.py
|
|
index 854cb8f0..01eca5f1 100644
|
|
--- a/pcs_test/tools/command_env/mock_get_local_corosync_conf.py
|
|
+++ b/pcs_test/tools/command_env/mock_get_local_corosync_conf.py
|
|
@@ -1,10 +1,15 @@
|
|
+from pcs import settings
|
|
+from pcs.lib import reports
|
|
+from pcs.lib.errors import LibraryError
|
|
+
|
|
CALL_TYPE_GET_LOCAL_COROSYNC_CONF = "CALL_TYPE_GET_LOCAL_COROSYNC_CONF"
|
|
|
|
class Call:
|
|
type = CALL_TYPE_GET_LOCAL_COROSYNC_CONF
|
|
|
|
- def __init__(self, content):
|
|
+ def __init__(self, content, exception_msg=None):
|
|
self.content = content
|
|
+ self.exception_msg = exception_msg
|
|
|
|
def __repr__(self):
|
|
return str("<GetLocalCorosyncConf>")
|
|
@@ -13,5 +18,10 @@ class Call:
|
|
def get_get_local_corosync_conf(call_queue):
|
|
def get_local_corosync_conf():
|
|
_, expected_call = call_queue.take(CALL_TYPE_GET_LOCAL_COROSYNC_CONF)
|
|
+ if expected_call.exception_msg:
|
|
+ raise LibraryError(reports.corosync_config_read_error(
|
|
+ settings.corosync_conf_file,
|
|
+ expected_call.exception_msg,
|
|
+ ))
|
|
return expected_call.content
|
|
return get_local_corosync_conf
|
|
diff --git a/pcsd/capabilities.xml b/pcsd/capabilities.xml
|
|
index f9a76a22..1adb57ce 100644
|
|
--- a/pcsd/capabilities.xml
|
|
+++ b/pcsd/capabilities.xml
|
|
@@ -1696,6 +1696,18 @@
|
|
|
|
|
|
|
|
+ <capability id="pcs.disaster-recovery.essentials" in-pcs="1" in-pcsd="0">
|
|
+ <description>
|
|
+ Configure disaster-recovery with the local cluster as the primary site
|
|
+ and one recovery site. Display local disaster-recovery config. Display
|
|
+ status of all sites. Remove disaster-recovery config.
|
|
+
|
|
+ pcs commands: dr config, dr destroy, dr set-recovery-site, dr status
|
|
+ </description>
|
|
+ </capability>
|
|
+
|
|
+
|
|
+
|
|
<capability id="resource-agents.describe" in-pcs="1" in-pcsd="1">
|
|
<description>
|
|
Describe a resource agent - present its metadata.
|
|
diff --git a/pcsd/pcsd_file.rb b/pcsd/pcsd_file.rb
|
|
index 486b764d..d82b55d2 100644
|
|
--- a/pcsd/pcsd_file.rb
|
|
+++ b/pcsd/pcsd_file.rb
|
|
@@ -198,6 +198,20 @@ module PcsdFile
|
|
end
|
|
end
|
|
|
|
+ class PutPcsDrConf < PutFile
|
|
+ def full_file_name
|
|
+ @full_file_name ||= PCSD_DR_CONFIG_LOCATION
|
|
+ end
|
|
+
|
|
+ def binary?()
|
|
+ return true
|
|
+ end
|
|
+
|
|
+ def permissions()
|
|
+ return 0600
|
|
+ end
|
|
+ end
|
|
+
|
|
TYPES = {
|
|
"booth_authfile" => PutFileBoothAuthfile,
|
|
"booth_config" => PutFileBoothConfig,
|
|
@@ -205,6 +219,7 @@ module PcsdFile
|
|
"corosync_authkey" => PutFileCorosyncAuthkey,
|
|
"corosync_conf" => PutFileCorosyncConf,
|
|
"pcs_settings_conf" => PutPcsSettingsConf,
|
|
+ "pcs_disaster_recovery_conf" => PutPcsDrConf,
|
|
}
|
|
end
|
|
|
|
diff --git a/pcsd/pcsd_remove_file.rb b/pcsd/pcsd_remove_file.rb
|
|
index 1038402d..ffaed8e3 100644
|
|
--- a/pcsd/pcsd_remove_file.rb
|
|
+++ b/pcsd/pcsd_remove_file.rb
|
|
@@ -41,8 +41,15 @@ module PcsdRemoveFile
|
|
end
|
|
end
|
|
|
|
+ class RemovePcsDrConf < RemoveFile
|
|
+ def full_file_name
|
|
+ @full_file_name ||= PCSD_DR_CONFIG_LOCATION
|
|
+ end
|
|
+ end
|
|
+
|
|
TYPES = {
|
|
"pcmk_remote_authkey" => RemovePcmkRemoteAuthkey,
|
|
"pcsd_settings" => RemovePcsdSettings,
|
|
+ "pcs_disaster_recovery_conf" => RemovePcsDrConf,
|
|
}
|
|
end
|
|
diff --git a/pcsd/remote.rb b/pcsd/remote.rb
|
|
index 6f454681..28b91382 100644
|
|
--- a/pcsd/remote.rb
|
|
+++ b/pcsd/remote.rb
|
|
@@ -27,6 +27,7 @@ def remote(params, request, auth_user)
|
|
:status => method(:node_status),
|
|
:status_all => method(:status_all),
|
|
:cluster_status => method(:cluster_status_remote),
|
|
+ :cluster_status_plaintext => method(:cluster_status_plaintext),
|
|
:auth => method(:auth),
|
|
:check_auth => method(:check_auth),
|
|
:cluster_setup => method(:cluster_setup),
|
|
@@ -219,6 +220,18 @@ def cluster_status_remote(params, request, auth_user)
|
|
return JSON.generate(status)
|
|
end
|
|
|
|
+# get cluster status in plaintext (over-the-network version of 'pcs status')
|
|
+def cluster_status_plaintext(params, request, auth_user)
|
|
+ if not allowed_for_local_cluster(auth_user, Permissions::READ)
|
|
+ return 403, 'Permission denied'
|
|
+ end
|
|
+ return pcs_internal_proxy(
|
|
+ auth_user,
|
|
+ params.fetch(:data_json, ""),
|
|
+ "status.full_cluster_status_plaintext"
|
|
+ )
|
|
+end
|
|
+
|
|
def cluster_start(params, request, auth_user)
|
|
if params[:name]
|
|
code, response = send_request_with_token(
|
|
@@ -444,7 +457,11 @@ def get_corosync_conf_remote(params, request, auth_user)
|
|
if not allowed_for_local_cluster(auth_user, Permissions::READ)
|
|
return 403, 'Permission denied'
|
|
end
|
|
- return get_corosync_conf()
|
|
+ begin
|
|
+ return get_corosync_conf()
|
|
+ rescue
|
|
+ return 400, 'Unable to read corosync.conf'
|
|
+ end
|
|
end
|
|
|
|
# deprecated, use /remote/put_file (note that put_file doesn't support backup
|
|
diff --git a/pcsd/settings.rb b/pcsd/settings.rb
|
|
index a6fd0a26..e8dc0c96 100644
|
|
--- a/pcsd/settings.rb
|
|
+++ b/pcsd/settings.rb
|
|
@@ -9,6 +9,7 @@ KEY_FILE = PCSD_VAR_LOCATION + 'pcsd.key'
|
|
KNOWN_HOSTS_FILE_NAME = 'known-hosts'
|
|
PCSD_SETTINGS_CONF_LOCATION = PCSD_VAR_LOCATION + "pcs_settings.conf"
|
|
PCSD_USERS_CONF_LOCATION = PCSD_VAR_LOCATION + "pcs_users.conf"
|
|
+PCSD_DR_CONFIG_LOCATION = PCSD_VAR_LOCATION + "disaster-recovery"
|
|
|
|
CRM_MON = "/usr/sbin/crm_mon"
|
|
CRM_NODE = "/usr/sbin/crm_node"
|
|
diff --git a/pcsd/settings.rb.debian b/pcsd/settings.rb.debian
|
|
index 5d830af9..daaae37b 100644
|
|
--- a/pcsd/settings.rb.debian
|
|
+++ b/pcsd/settings.rb.debian
|
|
@@ -9,6 +9,7 @@ KEY_FILE = PCSD_VAR_LOCATION + 'pcsd.key'
|
|
KNOWN_HOSTS_FILE_NAME = 'known-hosts'
|
|
PCSD_SETTINGS_CONF_LOCATION = PCSD_VAR_LOCATION + "pcs_settings.conf"
|
|
PCSD_USERS_CONF_LOCATION = PCSD_VAR_LOCATION + "pcs_users.conf"
|
|
+PCSD_DR_CONFIG_LOCATION = PCSD_VAR_LOCATION + "disaster-recovery"
|
|
|
|
CRM_MON = "/usr/sbin/crm_mon"
|
|
CRM_NODE = "/usr/sbin/crm_node"
|
|
diff --git a/pylintrc b/pylintrc
|
|
index 5fc4c200..9255a804 100644
|
|
--- a/pylintrc
|
|
+++ b/pylintrc
|
|
@@ -19,7 +19,7 @@ max-parents=10
|
|
min-public-methods=0
|
|
|
|
[BASIC]
|
|
-good-names=e, i, op, ip, el, maxDiff, cm, ok, T
|
|
+good-names=e, i, op, ip, el, maxDiff, cm, ok, T, dr
|
|
|
|
[VARIABLES]
|
|
# A regular expression matching the name of dummy variables (i.e. expectedly
|
|
--
|
|
2.21.0
|
|
|