Blob Blame History Raw
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