1    	import contextlib
2    	import datetime
3    	import json
4    	import math
5    	import os
6    	import subprocess
7    	import sys
8    	import tempfile
9    	import time
10   	import xml.dom.minidom
11   	from collections.abc import Callable, Iterable, Mapping
12   	from typing import Any, cast
13   	from xml.parsers.expat import ExpatError
14   	
15   	import pcs.lib.pacemaker.live as lib_pacemaker
16   	from pcs import settings, utils
17   	from pcs.cli.common import parse_args
18   	from pcs.cli.common.errors import (
19   	    ERR_NODE_LIST_AND_ALL_MUTUALLY_EXCLUSIVE,
20   	    CmdLineInputError,
21   	)
22   	from pcs.cli.common.parse_args import (
23   	    OUTPUT_FORMAT_VALUE_CMD,
24   	    OUTPUT_FORMAT_VALUE_JSON,
25   	    Argv,
26   	    InputModifiers,
27   	    KeyValueParser,
28   	)
29   	from pcs.cli.common.tools import print_to_stderr
30   	from pcs.cli.file import metadata as file_metadata
31   	from pcs.cli.reports.messages import report_item_msg_from_dto
32   	from pcs.cli.reports.output import warn
33   	from pcs.cli.reports.processor import (
34   	    ReportProcessorToConsole,
35   	    report_and_exit_on_error,
36   	)
37   	from pcs.common import file as pcs_file
38   	from pcs.common import file_type_codes, reports
39   	from pcs.common.auth import HostAuthData
40   	from pcs.common.corosync_conf import CorosyncConfDto, CorosyncNodeDto
41   	from pcs.common.file import RawFileError
42   	from pcs.common.host import Destination
43   	from pcs.common.interface import dto
44   	from pcs.common.node_communicator import HostNotFound, Request, RequestData
45   	from pcs.common.str_tools import format_list, indent, join_multilines
46   	from pcs.common.tools import format_os_error
47   	from pcs.common.types import StringCollection, StringIterable
48   	from pcs.lib.commands.remote_node import _destroy_pcmk_remote_env
49   	from pcs.lib.communication.nodes import CheckAuth
50   	from pcs.lib.communication.pcs_cfgsync import SetConfigs
51   	from pcs.lib.communication.tools import RunRemotelyBase, run_and_raise
52   	from pcs.lib.communication.tools import run as run_com_cmd
53   	from pcs.lib.corosync import qdevice_net
54   	from pcs.lib.corosync.live import QuorumStatusException, QuorumStatusFacade
55   	from pcs.lib.errors import LibraryError
56   	from pcs.lib.file.instance import FileInstance
57   	from pcs.lib.file.raw_file import raw_file_error_report
58   	from pcs.lib.node import get_existing_nodes_names
59   	from pcs.lib.pcs_cfgsync.const import SYNCED_CONFIGS
60   	from pcs.utils import parallel_for_nodes
61   	
62   	
63   	def _corosync_conf_local_cmd_call(
64   	    corosync_conf_path: parse_args.ModifierValueType,
65   	    lib_cmd: Callable[[bytes], bytes],
66   	) -> None:
67   	    """
68   	    Call a library command that requires modifications of a corosync.conf file
69   	    supplied as an argument
70   	
71   	    The lib command needs to take the corosync.conf file content as its first
72   	    argument
73   	
74   	        lib_cmd -- the lib command to be called
75   	    """
76   	    corosync_conf_file = pcs_file.RawFile(
77   	        file_metadata.for_file_type(
78   	            file_type_codes.COROSYNC_CONF, corosync_conf_path
79   	        )
80   	    )
81   	
82   	    try:
83   	        corosync_conf_file.write(
84   	            lib_cmd(
85   	                corosync_conf_file.read(),
86   	            ),
87   	            can_overwrite=True,
88   	        )
89   	    except pcs_file.RawFileError as e:
90   	        raise CmdLineInputError(
91   	            reports.messages.FileIoError(
92   	                e.metadata.file_type_code,
93   	                e.action,
94   	                e.reason,
95   	                file_path=e.metadata.path,
96   	            ).message
97   	        ) from e
98   	
99   	
100  	def cluster_cib_upgrade_cmd(
101  	    lib: Any, argv: Argv, modifiers: InputModifiers
102  	) -> None:
103  	    """
104  	    Options:
105  	      * -f - CIB file
106  	    """
107  	    del lib
108  	    modifiers.ensure_only_supported("-f")
109  	    if argv:
110  	        raise CmdLineInputError()
111  	    utils.cluster_upgrade()
112  	
113  	
114  	def cluster_disable_cmd(
115  	    lib: Any, argv: Argv, modifiers: InputModifiers
116  	) -> None:
117  	    """
118  	    Options:
119  	      * --all - disable all cluster nodes
120  	      * --request-timeout - timeout for HTTP requests - effective only when at
121  	        least one node has been specified or --all has been used
122  	    """
123  	    modifiers.ensure_only_supported("--all", "--request-timeout")
124  	    if modifiers.get("--all"):
125  	        if argv:
126  	            utils.err(ERR_NODE_LIST_AND_ALL_MUTUALLY_EXCLUSIVE)
127  	        disable_cluster_all(lib.env.report_processor)
128  	    else:
129  	        disable_cluster(argv, lib.env.report_processor)
130  	
131  	
132  	def cluster_enable_cmd(lib: Any, argv: Argv, modifiers: InputModifiers) -> None:
133  	    """
134  	    Options:
135  	      * --all - enable all cluster nodes
136  	      * --request-timeout - timeout for HTTP requests - effective only when at
137  	        least one node has been specified or --all has been used
138  	    """
139  	    modifiers.ensure_only_supported("--all", "--request-timeout")
140  	    if modifiers.get("--all"):
141  	        if argv:
142  	            utils.err(ERR_NODE_LIST_AND_ALL_MUTUALLY_EXCLUSIVE)
143  	        enable_cluster_all(lib.env.report_processor)
144  	    else:
145  	        enable_cluster(argv, lib.env.report_processor)
146  	
147  	
148  	def cluster_stop_cmd(lib: Any, argv: Argv, modifiers: InputModifiers) -> None:
149  	    """
150  	    Options:
151  	      * --force - no error when possible quorum loss
152  	      * --request-timeout - timeout for HTTP requests - effective only when at
153  	        least one node has been specified
154  	      * --pacemaker - stop pacemaker, only effective when no node has been
155  	        specified
156  	      * --corosync - stop corosync, only effective when no node has been
157  	        specified
158  	      * --all - stop all cluster nodes
159  	    """
160  	    modifiers.ensure_only_supported(
161  	        "--wait",
162  	        "--request-timeout",
163  	        "--pacemaker",
164  	        "--corosync",
165  	        "--all",
166  	        "--force",
167  	    )
168  	    if modifiers.get("--all"):
169  	        if argv:
170  	            utils.err(ERR_NODE_LIST_AND_ALL_MUTUALLY_EXCLUSIVE)
171  	        stop_cluster_all(lib.env.report_processor)
172  	    else:
173  	        stop_cluster(argv, lib.env.report_processor)
174  	
175  	
176  	def cluster_start_cmd(lib: Any, argv: Argv, modifiers: InputModifiers) -> None:
177  	    """
178  	    Options:
179  	      * --wait
180  	      * --request-timeout - timeout for HTTP requests, have effect only if at
181  	        least one node have been specified
182  	      * --all - start all cluster nodes
183  	    """
184  	    modifiers.ensure_only_supported(
185  	        "--wait", "--request-timeout", "--all", "--corosync_conf"
186  	    )
187  	    if modifiers.get("--all"):
188  	        if argv:
189  	            utils.err(ERR_NODE_LIST_AND_ALL_MUTUALLY_EXCLUSIVE)
190  	        start_cluster_all(lib.env.report_processor)
191  	    else:
192  	        start_cluster(argv)
193  	
194  	
195  	def authkey_corosync(lib: Any, argv: Argv, modifiers: InputModifiers) -> None:
196  	    """
197  	    Options:
198  	      * --force - skip check for authkey length
199  	      * --request-timeout - timeout for HTTP requests
200  	      * --skip-offline - skip unreachable nodes
201  	    """
202  	    modifiers.ensure_only_supported(
203  	        "--force", "--skip-offline", "--request-timeout"
204  	    )
205  	    if len(argv) > 1:
206  	        raise CmdLineInputError()
207  	    force_flags = []
208  	    if modifiers.get("--force"):
209  	        force_flags.append(reports.codes.FORCE)
210  	    if modifiers.get("--skip-offline"):
211  	        force_flags.append(reports.codes.SKIP_OFFLINE_NODES)
212  	    corosync_authkey = None
213  	    if argv:
214  	        try:
215  	            with open(argv[0], "rb") as file:
216  	                corosync_authkey = file.read()
217  	        except OSError as e:
218  	            utils.err(f"Unable to read file '{argv[0]}': {format_os_error(e)}")
219  	    lib.cluster.corosync_authkey_change(
220  	        corosync_authkey=corosync_authkey,
221  	        force_flags=force_flags,
222  	    )
223  	
224  	
225  	def sync_nodes(lib: Any, argv: Argv, modifiers: InputModifiers) -> None:
226  	    """
227  	    Options:
228  	      * --request-timeout - timeout for HTTP requests
229  	    """
230  	    modifiers.ensure_only_supported("--request-timeout")
231  	    if argv:
232  	        raise CmdLineInputError()
233  	
234  	    config = utils.getCorosyncConf()
235  	    nodes, report_list = get_existing_nodes_names(
236  	        utils.get_corosync_conf_facade(conf_text=config)
237  	    )
238  	    if not nodes:
239  	        report_list.append(
240  	            reports.ReportItem.error(
241  	                reports.messages.CorosyncConfigNoNodesDefined()
242  	            )
243  	        )
244  	    if report_list:
245  	        report_and_exit_on_error(lib.env.report_processor, report_list)
246  	
247  	    for node in nodes:
248  	        utils.setCorosyncConfig(node, config)
249  	
250  	    warn(
251  	        "Corosync configuration has been synchronized, please reload corosync "
252  	        "daemon using 'pcs cluster reload corosync' command."
253  	    )
254  	
255  	
256  	def start_cluster(argv: Argv) -> None:
257  	    """
258  	    Commandline options:
259  	      * --wait
260  	      * --request-timeout - timeout for HTTP requests, have effect only if at
261  	        least one node have been specified
262  	    """
263  	    wait = False
264  	    wait_timeout = None
265  	    if "--wait" in utils.pcs_options:
266  	        wait_timeout = utils.validate_wait_get_timeout(False)
267  	        wait = True
268  	
269  	    if argv:
270  	        nodes = set(argv)  # unique
271  	        start_cluster_nodes(nodes)
272  	        if wait:
273  	            wait_for_nodes_started(nodes, wait_timeout)
274  	        return
275  	
276  	    if not utils.hasCorosyncConf():
277  	        utils.err("cluster is not currently configured on this node")
278  	
279  	    print_to_stderr("Starting Cluster...")
280  	    service_list = ["corosync"]
281  	    if utils.need_to_handle_qdevice_service():
282  	        service_list.append("corosync-qdevice")
283  	    service_list.append("pacemaker")
284  	    for service in service_list:
285  	        utils.start_service(service)
286  	    if wait:
287  	        wait_for_nodes_started([], wait_timeout)
288  	
289  	
290  	def start_cluster_all(report_processor: ReportProcessorToConsole) -> None:
291  	    """
292  	    Commandline options:
293  	      * --wait
294  	      * --request-timeout - timeout for HTTP requests
295  	    """
296  	    wait = False
297  	    wait_timeout = None
298  	    if "--wait" in utils.pcs_options:
299  	        wait_timeout = utils.validate_wait_get_timeout(False)
300  	        wait = True
301  	
302  	    all_nodes, report_list = get_existing_nodes_names(
303  	        utils.get_corosync_conf_facade()
304  	    )
305  	    if not all_nodes:
306  	        report_list.append(
307  	            reports.ReportItem.error(
308  	                reports.messages.CorosyncConfigNoNodesDefined()
309  	            )
310  	        )
311  	    if report_list:
312  	        report_and_exit_on_error(report_processor, report_list)
313  	
314  	    start_cluster_nodes(all_nodes)
315  	    if wait:
316  	        wait_for_nodes_started(all_nodes, wait_timeout)
317  	
318  	
319  	def start_cluster_nodes(nodes: StringCollection) -> None:
320  	    """
321  	    Commandline options:
322  	      * --request-timeout - timeout for HTTP requests
323  	    """
324  	    # Large clusters take longer time to start up. So we make the timeout longer
325  	    # for each 8 nodes:
326  	    #  1 -  8 nodes: 1 * timeout
327  	    #  9 - 16 nodes: 2 * timeout
328  	    # 17 - 24 nodes: 3 * timeout
329  	    # and so on
330  	    # Users can override this and set their own timeout by specifying
331  	    # the --request-timeout option (see utils.sendHTTPRequest).
332  	    timeout = int(
333  	        settings.default_request_timeout * math.ceil(len(nodes) / 8.0)
334  	    )
335  	    utils.read_known_hosts_file()  # cache known hosts
336  	    node_errors = parallel_for_nodes(
337  	        utils.startCluster, nodes, quiet=True, timeout=timeout
338  	    )
339  	    if node_errors:
340  	        utils.err(
341  	            "unable to start all nodes\n" + "\n".join(node_errors.values())
342  	        )
343  	
344  	
345  	def is_node_fully_started(node_status) -> bool:
346  	    """
347  	    Commandline options: no options
348  	    """
349  	    return (
350  	        "online" in node_status
351  	        and "pending" in node_status
352  	        and node_status["online"]
353  	        and not node_status["pending"]
354  	    )
355  	
356  	
357  	def wait_for_local_node_started(
358  	    stop_at: datetime.datetime, interval: float
359  	) -> tuple[int, str]:
360  	    """
361  	    Commandline options: no options
362  	    """
363  	    try:
364  	        while True:
365  	            time.sleep(interval)
366  	            node_status = lib_pacemaker.get_local_node_status(
367  	                utils.cmd_runner()
368  	            )
369  	            if is_node_fully_started(node_status):
370  	                return 0, "Started"
371  	            if datetime.datetime.now() > stop_at:
372  	                return 1, "Waiting timeout"
373  	    except LibraryError as e:
374  	        return (
375  	            1,
376  	            "Unable to get node status: {0}".format(
377  	                "\n".join(
378  	                    report_item_msg_from_dto(
379  	                        cast(reports.ReportItemDto, item).message
380  	                    ).message
381  	                    for item in e.args
382  	                )
383  	            ),
384  	        )
385  	
386  	
387  	def wait_for_remote_node_started(
388  	    node: str, stop_at: datetime.datetime, interval: float
389  	) -> tuple[int, str]:
390  	    """
391  	    Commandline options:
392  	      * --request-timeout - timeout for HTTP requests
393  	    """
394  	    while True:
395  	        time.sleep(interval)
396  	        code, output = utils.getPacemakerNodeStatus(node)
397  	        # HTTP error, permission denied or unable to auth
398  	        # there is no point in trying again as it won't get magically fixed
399  	        if code in [1, 3, 4]:
400  	            return 1, output
401  	        if code == 0:
402  	            try:
403  	                node_status = json.loads(output)
404  	                if is_node_fully_started(node_status):
405  	                    return 0, "Started"
406  	            except (ValueError, KeyError):
407  	                # this won't get fixed either
408  	                return 1, "Unable to get node status"
409  	        if datetime.datetime.now() > stop_at:
410  	            return 1, "Waiting timeout"
411  	
412  	
413  	def wait_for_nodes_started(
414  	    node_list: StringIterable, timeout: int | None = None
415  	) -> None:
416  	    """
417  	    Commandline options:
418  	      * --request-timeout - timeout for HTTP request, effective only if
419  	        node_list is not empty list
420  	    """
421  	    timeout = 60 * 15 if timeout is None else timeout
422  	    interval = 2
423  	    stop_at = datetime.datetime.now() + datetime.timedelta(seconds=timeout)
424  	    print_to_stderr("Waiting for node(s) to start...")
425  	    if not node_list:
426  	        code, output = wait_for_local_node_started(stop_at, interval)
427  	        if code != 0:
428  	            utils.err(output)
429  	        else:
430  	            print_to_stderr(output)
431  	    else:
432  	        utils.read_known_hosts_file()  # cache known hosts
433  	        node_errors = parallel_for_nodes(
434  	            wait_for_remote_node_started, node_list, stop_at, interval
435  	        )
436  	        if node_errors:
437  	            utils.err("unable to verify all nodes have started")
438  	
439  	
440  	def stop_cluster_all(report_processor: ReportProcessorToConsole) -> None:
441  	    """
442  	    Commandline options:
443  	      * --force - no error when possible quorum loss
444  	      * --request-timeout - timeout for HTTP requests
445  	    """
446  	    all_nodes, report_list = get_existing_nodes_names(
447  	        utils.get_corosync_conf_facade()
448  	    )
449  	    if not all_nodes:
450  	        report_list.append(
451  	            reports.ReportItem.error(
452  	                reports.messages.CorosyncConfigNoNodesDefined()
453  	            )
454  	        )
455  	    if report_list:
456  	        report_and_exit_on_error(report_processor, report_list)
457  	
458  	    stop_cluster_nodes(all_nodes, report_processor)
459  	
460  	
461  	def stop_cluster_nodes(  # noqa: PLR0912
462  	    nodes: StringCollection, report_processor: ReportProcessorToConsole
463  	) -> None:
464  	    """
465  	    Commandline options:
466  	      * --force - no error when possible quorum loss
467  	      * --request-timeout - timeout for HTTP requests
468  	    """
469  	    all_nodes, report_list = get_existing_nodes_names(
470  	        utils.get_corosync_conf_facade()
471  	    )
472  	    unknown_nodes = set(nodes) - set(all_nodes)
473  	    if unknown_nodes:
474  	        if report_list:
475  	            report_and_exit_on_error(report_processor, report_list)
476  	        utils.err(
477  	            "nodes '%s' do not appear to exist in configuration"
478  	            % "', '".join(sorted(unknown_nodes))
479  	        )
480  	
481  	    utils.read_known_hosts_file()  # cache known hosts
482  	    stopping_all = set(nodes) >= set(all_nodes)
483  	    if "--force" not in utils.pcs_options and not stopping_all:
484  	        error_list = []
485  	        for node in nodes:
486  	            retval, data = utils.get_remote_quorumtool_output(node)
487  	            if retval != 0:
488  	                error_list.append(node + ": " + data)
489  	                continue
490  	            try:
491  	                quorum_status_facade = QuorumStatusFacade.from_string(data)
492  	                if not quorum_status_facade.is_quorate:
493  	                    # Get quorum status from a quorate node, non-quorate nodes
494  	                    # may provide inaccurate info. If no node is quorate, there
495  	                    # is no quorum to be lost and therefore no error to be
496  	                    # reported.
497  	                    continue
498  	                if quorum_status_facade.stopping_nodes_cause_quorum_loss(nodes):
499  	                    utils.err(
500  	                        "Stopping the node(s) will cause a loss of the quorum"
501  	                        + ", use --force to override"
502  	                    )
503  	                else:
504  	                    # We have the info, no need to print errors
505  	                    error_list = []
506  	                    break
507  	            except QuorumStatusException:
508  	                if not utils.is_node_offline_by_quorumtool_output(data):
509  	                    error_list.append(node + ": Unable to get quorum status")
510  	                # else the node seems to be stopped already
511  	        if error_list:
512  	            utils.err(
513  	                "Unable to determine whether stopping the nodes will cause "
514  	                + "a loss of the quorum, use --force to override\n"
515  	                + "\n".join(error_list)
516  	            )
517  	
518  	    was_error = False
519  	    node_errors = parallel_for_nodes(
520  	        utils.repeat_if_timeout(utils.stopPacemaker), nodes, quiet=True
521  	    )
522  	    accessible_nodes = [node for node in nodes if node not in node_errors]
523  	    if node_errors:
524  	        utils.err(
525  	            "unable to stop all nodes\n" + "\n".join(node_errors.values()),
526  	            exit_after_error=not accessible_nodes,
527  	        )
528  	        was_error = True
529  	
530  	    for node in node_errors:
531  	        print_to_stderr(
532  	            "{0}: Not stopping cluster - node is unreachable".format(node)
533  	        )
534  	
535  	    node_errors = parallel_for_nodes(
536  	        utils.stopCorosync, accessible_nodes, quiet=True
537  	    )
538  	    if node_errors:
539  	        utils.err(
540  	            "unable to stop all nodes\n" + "\n".join(node_errors.values())
541  	        )
542  	    if was_error:
543  	        utils.err("unable to stop all nodes")
544  	
545  	
546  	def enable_cluster(
547  	    argv: Argv, report_processor: ReportProcessorToConsole
548  	) -> None:
549  	    """
550  	    Commandline options:
551  	      * --request-timeout - timeout for HTTP requests, effective only if at
552  	        least one node has been specified
553  	    """
554  	    if argv:
555  	        enable_cluster_nodes(argv)
556  	        return
557  	
558  	    try:
559  	        utils.enableServices()
560  	    except LibraryError as e:
561  	        report_and_exit_on_error(report_processor, list(e.args))
562  	
563  	
564  	def disable_cluster(
565  	    argv: Argv, report_processor: ReportProcessorToConsole
566  	) -> None:
567  	    """
568  	    Commandline options:
569  	      * --request-timeout - timeout for HTTP requests, effective only if at
570  	        least one node has been specified
571  	    """
572  	    if argv:
573  	        disable_cluster_nodes(argv)
574  	        return
575  	
576  	    try:
577  	        utils.disableServices()
578  	    except LibraryError as e:
579  	        report_and_exit_on_error(report_processor, list(e.args))
580  	
581  	
582  	def enable_cluster_all(report_processor: ReportProcessorToConsole) -> None:
583  	    """
584  	    Commandline options:
585  	      * --request-timeout - timeout for HTTP requests
586  	    """
587  	    all_nodes, report_list = get_existing_nodes_names(
588  	        utils.get_corosync_conf_facade()
589  	    )
590  	    if not all_nodes:
591  	        report_list.append(
592  	            reports.ReportItem.error(
593  	                reports.messages.CorosyncConfigNoNodesDefined()
594  	            )
595  	        )
596  	    if report_list:
597  	        report_and_exit_on_error(report_processor, report_list)
598  	
599  	    enable_cluster_nodes(all_nodes)
600  	
601  	
602  	def disable_cluster_all(report_processor: ReportProcessorToConsole) -> None:
603  	    """
604  	    Commandline options:
605  	      * --request-timeout - timeout for HTTP requests
606  	    """
607  	    all_nodes, report_list = get_existing_nodes_names(
608  	        utils.get_corosync_conf_facade()
609  	    )
610  	    if not all_nodes:
611  	        report_list.append(
612  	            reports.ReportItem.error(
613  	                reports.messages.CorosyncConfigNoNodesDefined()
614  	            )
615  	        )
616  	    if report_list:
617  	        report_and_exit_on_error(report_processor, report_list)
618  	
619  	    disable_cluster_nodes(all_nodes)
620  	
621  	
622  	def enable_cluster_nodes(nodes: StringIterable) -> None:
623  	    """
624  	    Commandline options:
625  	      * --request-timeout - timeout for HTTP requests
626  	    """
627  	    error_list = utils.map_for_error_list(utils.enableCluster, nodes)
628  	    if error_list:
629  	        utils.err("unable to enable all nodes\n" + "\n".join(error_list))
630  	
631  	
632  	def disable_cluster_nodes(nodes: StringIterable) -> None:
633  	    """
634  	    Commandline options:
635  	      * --request-timeout - timeout for HTTP requests
636  	    """
637  	    error_list = utils.map_for_error_list(utils.disableCluster, nodes)
638  	    if error_list:
639  	        utils.err("unable to disable all nodes\n" + "\n".join(error_list))
640  	
641  	
642  	def destroy_cluster(argv: Argv) -> None:
643  	    """
644  	    Commandline options:
645  	      * --request-timeout - timeout for HTTP requests
646  	    """
647  	    if argv:
648  	        utils.read_known_hosts_file()  # cache known hosts
649  	        # stop pacemaker and resources while cluster is still quorate
650  	        nodes = argv
651  	        node_errors = parallel_for_nodes(
652  	            utils.repeat_if_timeout(utils.stopPacemaker), nodes, quiet=True
653  	        )
654  	        # proceed with destroy regardless of errors
655  	        # destroy will stop any remaining cluster daemons
656  	        node_errors = parallel_for_nodes(
657  	            utils.destroyCluster, nodes, quiet=True
658  	        )
659  	        if node_errors:
660  	            utils.err(
661  	                "unable to destroy cluster\n" + "\n".join(node_errors.values())
662  	            )
663  	
664  	
665  	def stop_cluster(
666  	    argv: Argv, report_processor: ReportProcessorToConsole
667  	) -> None:
668  	    """
669  	    Commandline options:
670  	      * --force - no error when possible quorum loss
671  	      * --request-timeout - timeout for HTTP requests - effective only when at
672  	        least one node has been specified
673  	      * --pacemaker - stop pacemaker, only effective when no node has been
674  	        specified
675  	    """
676  	    if argv:
677  	        stop_cluster_nodes(argv, report_processor)
678  	        return
679  	
680  	    if "--force" not in utils.pcs_options:
681  	        # corosync 3.0.1 and older:
682  	        # - retval is 0 on success if a node is not in a partition with quorum
683  	        # - retval is 1 on error OR on success if a node has quorum
684  	        # corosync 3.0.2 and newer:
685  	        # - retval is 0 on success if a node has quorum
686  	        # - retval is 1 on error
687  	        # - retval is 2 on success if a node is not in a partition with quorum
688  	        output, dummy_retval = utils.run(["corosync-quorumtool", "-p", "-s"])
689  	        try:
690  	            if QuorumStatusFacade.from_string(
691  	                output
692  	            ).stopping_local_node_cause_quorum_loss():
693  	                utils.err(
694  	                    "Stopping the node will cause a loss of the quorum"
695  	                    + ", use --force to override"
696  	                )
697  	        except QuorumStatusException:
698  	            if not utils.is_node_offline_by_quorumtool_output(output):
699  	                utils.err(
700  	                    "Unable to determine whether stopping the node will cause "
701  	                    + "a loss of the quorum, use --force to override"
702  	                )
703  	            # else the node seems to be stopped already, proceed to be sure
704  	
705  	    stop_all = (
706  	        "--pacemaker" not in utils.pcs_options
707  	        and "--corosync" not in utils.pcs_options
708  	    )
709  	    if stop_all or "--pacemaker" in utils.pcs_options:
710  	        stop_cluster_pacemaker()
711  	    if stop_all or "--corosync" in utils.pcs_options:
712  	        stop_cluster_corosync()
713  	
714  	
715  	def stop_cluster_pacemaker() -> None:
716  	    """
717  	    Commandline options: no options
718  	    """
719  	    print_to_stderr("Stopping Cluster (pacemaker)...")
720  	    utils.stop_service("pacemaker")
721  	
722  	
723  	def stop_cluster_corosync() -> None:
724  	    """
725  	    Commandline options: no options
726  	    """
727  	    print_to_stderr("Stopping Cluster (corosync)...")
728  	    service_list = []
729  	    if utils.need_to_handle_qdevice_service():
730  	        service_list.append("corosync-qdevice")
731  	    service_list.append("corosync")
732  	    for service in service_list:
733  	        utils.stop_service(service)
734  	
735  	
736  	def kill_cluster(lib: Any, argv: Argv, modifiers: InputModifiers) -> None:
737  	    """
738  	    Options: no options
739  	    """
740  	    del lib
741  	    if argv:
742  	        raise CmdLineInputError()
743  	    modifiers.ensure_only_supported()
744  	    dummy_output, dummy_retval = kill_local_cluster_services()
745  	
746  	
747  	#    if dummy_retval != 0:
748  	#        print "Error: unable to execute killall -9"
749  	#        print output
750  	#        sys.exit(1)
751  	
752  	
753  	def kill_local_cluster_services() -> tuple[str, int]:
754  	    """
755  	    Commandline options: no options
756  	    """
757  	    all_cluster_daemons = [
758  	        # Daemons taken from cluster-clean script in pacemaker
759  	        "pacemaker-attrd",
760  	        "pacemaker-based",
761  	        "pacemaker-controld",
762  	        "pacemaker-execd",
763  	        "pacemaker-fenced",
764  	        "pacemaker-remoted",
765  	        "pacemaker-schedulerd",
766  	        "pacemakerd",
767  	        "dlm_controld",
768  	        "gfs_controld",
769  	        # Corosync daemons
770  	        "corosync-qdevice",
771  	        "corosync",
772  	    ]
773  	    return utils.run([settings.killall_exec, "-9"] + all_cluster_daemons)
774  	
775  	
776  	def cluster_push(lib: Any, argv: Argv, modifiers: InputModifiers) -> None:  # noqa: PLR0912, PLR0915
777  	    """
778  	    Options:
779  	      * --wait
780  	      * --config - push only configuration section of CIB
781  	      * -f - CIB file
782  	    """
783  	
784  	    def get_details_from_crm_verify():
785  	        # get a new runner to run crm_verify command and pass the CIB filename
786  	        # into it so that the verify is run on the file instead on the live
787  	        # cluster CIB
788  	        verify_runner = utils.cmd_runner(cib_file_override=filename)
789  	        # Request verbose output, otherwise we may only get an unhelpful
790  	        # message:
791  	        # Configuration invalid (with errors) (-V may provide more detail)
792  	        # verify_returncode is always expected to be non-zero to indicate
793  	        # invalid CIB - ve run the verify because the CIB is invalid
794  	        (
795  	            verify_stdout,
796  	            verify_stderr,
797  	            verify_returncode,
798  	            verify_can_be_more_verbose,
799  	        ) = lib_pacemaker.verify(verify_runner, verbose=True)
800  	        return join_multilines([verify_stdout, verify_stderr])
801  	
802  	    del lib
803  	    modifiers.ensure_only_supported("--wait", "--config", "-f")
804  	    if len(argv) > 2:
805  	        raise CmdLineInputError()
806  	
807  	    filename = None
808  	    scope = None
809  	    timeout = None
810  	    diff_against = None
811  	
812  	    if modifiers.get("--wait"):
813  	        timeout = utils.validate_wait_get_timeout()
814  	    for arg in argv:
815  	        if "=" not in arg:
816  	            filename = arg
817  	        else:
818  	            arg_name, arg_value = arg.split("=", 1)
819  	            if arg_name == "scope":
820  	                if modifiers.get("--config"):
821  	                    utils.err("Cannot use both scope and --config")
822  	                if not utils.is_valid_cib_scope(arg_value):
823  	                    utils.err("invalid CIB scope '%s'" % arg_value)
824  	                else:
825  	                    scope = arg_value
826  	            elif arg_name == "diff-against":
827  	                diff_against = arg_value
828  	            else:
829  	                raise CmdLineInputError()
830  	    if modifiers.get("--config"):
831  	        scope = "configuration"
832  	    if diff_against and scope:
833  	        utils.err("Cannot use both scope and diff-against")
834  	    if not filename:
835  	        raise CmdLineInputError()
836  	
837  	    try:
CID (unavailable; MK=26a3af0897e6812f1acaacd47e7ce85f) (#1 of 1): XML external entity processing enabled (SIGMA.xml_external_entity_enabled):
(1) Event Sigma main event: The application uses Python's built in `xml` module which does not properly handle erroneous or maliciously constructed data, making the application vulnerable to one or more types of XML attacks.
(2) Event remediation: Avoid using the `xml` module. Consider using the `defusedxml` module or similar which safely prevents all XML entity attacks.
838  	        new_cib_dom = xml.dom.minidom.parse(filename)
839  	        if scope and not new_cib_dom.getElementsByTagName(scope):
840  	            utils.err(
841  	                "unable to push cib, scope '%s' not present in new cib" % scope
842  	            )
843  	    except (OSError, ExpatError) as e:
844  	        utils.err("unable to parse new cib: %s" % e)
845  	
846  	    EXITCODE_INVALID_CIB = 78
847  	    runner = utils.cmd_runner()
848  	
849  	    if diff_against:
850  	        command = [
851  	            settings.crm_diff_exec,
852  	            "--original",
853  	            diff_against,
854  	            "--new",
855  	            filename,
856  	            "--no-version",
857  	        ]
858  	        patch, stderr, retval = runner.run(command)
859  	        #  0 (CRM_EX_OK) - success with no difference
860  	        #  1 (CRM_EX_ERROR) - success with difference
861  	        # 64 (CRM_EX_USAGE) - usage error
862  	        # 65 (CRM_EX_DATAERR) - XML fragments not parseable
863  	        if retval > 1:
864  	            utils.err("unable to diff the CIBs:\n" + stderr)
865  	        if retval == 0:
866  	            print_to_stderr(
867  	                "The new CIB is the same as the original CIB, nothing to push."
868  	            )
869  	            sys.exit(0)
870  	
871  	        command = [
872  	            settings.cibadmin_exec,
873  	            "--patch",
874  	            "--xml-pipe",
875  	        ]
876  	        output, stderr, retval = runner.run(command, patch)
877  	        if retval != 0:
878  	            push_output = stderr + output
879  	            verify_output = (
880  	                get_details_from_crm_verify()
881  	                if retval == EXITCODE_INVALID_CIB
882  	                else ""
883  	            )
884  	            error_text = (
885  	                f"{push_output}\n\n{verify_output}"
886  	                if verify_output.strip()
887  	                else push_output
888  	            )
889  	            utils.err("unable to push cib\n" + error_text)
890  	
891  	    else:
892  	        command = ["cibadmin", "--replace", "--xml-file", filename]
893  	        if scope:
894  	            command.append("--scope=%s" % scope)
895  	        output, retval = utils.run(command)
896  	        # 103 (CRM_EX_OLD) - update older than existing config
897  	        if retval == 103:
898  	            utils.err(
899  	                "Unable to push to the CIB because pushed configuration "
900  	                "is older than existing one. If you are sure you want to "
901  	                "push this configuration, try to use --config to replace only "
902  	                "configuration part instead of whole CIB. Otherwise get current"
903  	                " configuration by running command 'pcs cluster cib' and update"
904  	                " that."
905  	            )
906  	        elif retval != 0:
907  	            verify_output = (
908  	                get_details_from_crm_verify()
909  	                if retval == EXITCODE_INVALID_CIB
910  	                else ""
911  	            )
912  	            error_text = (
913  	                f"{output}\n\n{verify_output}"
914  	                if verify_output.strip()
915  	                else output
916  	            )
917  	            utils.err("unable to push cib\n" + error_text)
918  	
919  	    print_to_stderr("CIB updated")
920  	    try:
921  	        cib_errors = lib_pacemaker.get_cib_verification_errors(runner)
922  	        if cib_errors:
923  	            print_to_stderr("\n".join(cib_errors))
924  	    except lib_pacemaker.BadApiResultFormat as e:
925  	        print_to_stderr(
926  	            f"Unable to verify CIB: {e.original_exception}\n"
927  	            f"crm_verify output:\n{e.pacemaker_response}"
928  	        )
929  	
930  	    if not modifiers.is_specified("--wait"):
931  	        return
932  	    cmd = ["crm_resource", "--wait"]
933  	    if timeout:
934  	        cmd.extend(["--timeout", str(timeout)])
935  	    output, retval = utils.run(cmd)
936  	    if retval != 0:
937  	        msg = []
938  	        if retval == settings.pacemaker_wait_timeout_status:
939  	            msg.append("waiting timeout")
940  	        if output:
941  	            msg.append("\n" + output)
942  	        utils.err("\n".join(msg).strip())
943  	
944  	
945  	def cluster_edit(lib: Any, argv: Argv, modifiers: InputModifiers) -> None:  # noqa: PLR0912
946  	    """
947  	    Options:
948  	      * --config - edit configuration section of CIB
949  	      * -f - CIB file
950  	      * --wait
951  	    """
952  	    modifiers.ensure_only_supported("--config", "--wait", "-f")
953  	    if "EDITOR" in os.environ:
954  	        if len(argv) > 1:
955  	            raise CmdLineInputError()
956  	
957  	        scope = None
958  	        scope_arg = ""
959  	        for arg in argv:
960  	            if "=" not in arg:
961  	                raise CmdLineInputError()
962  	            arg_name, arg_value = arg.split("=", 1)
963  	            if arg_name == "scope" and not modifiers.get("--config"):
964  	                if not utils.is_valid_cib_scope(arg_value):
965  	                    utils.err("invalid CIB scope '%s'" % arg_value)
966  	                else:
967  	                    scope_arg = arg
968  	                    scope = arg_value
969  	            else:
970  	                raise CmdLineInputError()
971  	        if modifiers.get("--config"):
972  	            scope = "configuration"
973  	            # Leave scope_arg empty as cluster_push will pick up a --config
974  	            # option from utils.pcs_options
975  	            scope_arg = ""
976  	
977  	        editor = os.environ["EDITOR"]
978  	        cib = utils.get_cib(scope)
979  	        with tempfile.NamedTemporaryFile(mode="w+", suffix=".pcs") as tempcib:
980  	            tempcib.write(cib)
981  	            tempcib.flush()
982  	            try:
983  	                subprocess.call([editor, tempcib.name])
984  	            except OSError:
985  	                utils.err("unable to open file with $EDITOR: " + editor)
986  	
987  	            tempcib.seek(0)
988  	            newcib = "".join(tempcib.readlines())
989  	            if newcib == cib:
990  	                print_to_stderr("CIB not updated, no changes detected")
991  	            else:
992  	                cluster_push(
993  	                    lib,
994  	                    [arg for arg in [tempcib.name, scope_arg] if arg],
995  	                    modifiers.get_subset("--wait", "--config", "-f"),
996  	                )
997  	
998  	    else:
999  	        utils.err("$EDITOR environment variable is not set")
1000 	
1001 	
1002 	def get_cib(lib: Any, argv: Argv, modifiers: InputModifiers) -> None:  # noqa: PLR0912
1003 	    """
1004 	    Options:
1005 	      * --config show configuration section of CIB
1006 	      * -f - CIB file
1007 	    """
1008 	    del lib
1009 	    modifiers.ensure_only_supported("--config", "-f")
1010 	    if len(argv) > 2:
1011 	        raise CmdLineInputError()
1012 	
1013 	    filename = None
1014 	    scope = None
1015 	    for arg in argv:
1016 	        if "=" not in arg:
1017 	            filename = arg
1018 	        else:
1019 	            arg_name, arg_value = arg.split("=", 1)
1020 	            if arg_name == "scope" and not modifiers.get("--config"):
1021 	                if not utils.is_valid_cib_scope(arg_value):
1022 	                    utils.err("invalid CIB scope '%s'" % arg_value)
1023 	                else:
1024 	                    scope = arg_value
1025 	            else:
1026 	                raise CmdLineInputError()
1027 	    if modifiers.get("--config"):
1028 	        scope = "configuration"
1029 	
1030 	    if not filename:
1031 	        print(utils.get_cib(scope).rstrip())
1032 	    else:
1033 	        output = utils.get_cib(scope)
1034 	        if not output:
1035 	            utils.err("No data in the CIB")
1036 	        try:
1037 	            with open(filename, "w") as cib_file:
1038 	                cib_file.write(output)
1039 	        except OSError as e:
1040 	            utils.err(
1041 	                "Unable to write to file '%s', %s" % (filename, e.strerror)
1042 	            )
1043 	
1044 	
1045 	class RemoteAddNodes(RunRemotelyBase):
1046 	    def __init__(self, report_processor, target, data):
1047 	        super().__init__(report_processor)
1048 	        self._target = target
1049 	        self._data = data
1050 	        self._success = False
1051 	
1052 	    def get_initial_request_list(self):
1053 	        return [
1054 	            Request(
1055 	                self._target,
1056 	                RequestData(
1057 	                    "remote/cluster_add_nodes",
1058 	                    [("data_json", json.dumps(self._data))],
1059 	                ),
1060 	            )
1061 	        ]
1062 	
1063 	    def _process_response(self, response):
1064 	        node_label = response.request.target.label
1065 	        report_item = self._get_response_report(response)
1066 	        if report_item is not None:
1067 	            self._report(report_item)
1068 	            return
1069 	
1070 	        try:
1071 	            output = json.loads(response.data)
1072 	            for report_dict in output["report_list"]:
1073 	                self._report(
1074 	                    reports.ReportItem(
1075 	                        severity=reports.ReportItemSeverity(
1076 	                            report_dict["severity"],
1077 	                            report_dict["forceable"],
1078 	                        ),
1079 	                        message=reports.messages.LegacyCommonMessage(
1080 	                            report_dict["code"],
1081 	                            report_dict["info"],
1082 	                            report_dict["report_text"],
1083 	                        ),
1084 	                    )
1085 	                )
1086 	            if output["status"] == "success":
1087 	                self._success = True
1088 	            elif output["status"] != "error":
1089 	                print_to_stderr("Error: {}".format(output["status_msg"]))
1090 	
1091 	        except (KeyError, json.JSONDecodeError):
1092 	            self._report(
1093 	                reports.ReportItem.warning(
1094 	                    reports.messages.InvalidResponseFormat(node_label)
1095 	                )
1096 	            )
1097 	
1098 	    def on_complete(self):
1099 	        return self._success
1100 	
1101 	
1102 	def node_add_outside_cluster(
1103 	    lib: Any, argv: Argv, modifiers: InputModifiers
1104 	) -> None:
1105 	    """
1106 	    Options:
1107 	      * --wait - wait until new node will start up, effective only when --start
1108 	        is specified
1109 	      * --start - start new node
1110 	      * --enable - enable new node
1111 	      * --force - treat validation issues and not resolvable addresses as
1112 	        warnings instead of errors
1113 	      * --skip-offline - skip unreachable nodes
1114 	      * --no-watchdog-validation - do not validatate watchdogs
1115 	      * --request-timeout - HTTP request timeout
1116 	    """
1117 	    del lib
1118 	    modifiers.ensure_only_supported(
1119 	        "--wait",
1120 	        "--start",
1121 	        "--enable",
1122 	        "--force",
1123 	        "--skip-offline",
1124 	        "--no-watchdog-validation",
1125 	        "--request-timeout",
1126 	    )
1127 	    if len(argv) < 2:
1128 	        raise CmdLineInputError(
1129 	            "Usage: pcs cluster node add-outside <cluster node> <node name> "
1130 	            "[addr=<node address>]... [watchdog=<watchdog path>] "
1131 	            "[device=<SBD device path>]... [--start [--wait[=<n>]]] [--enable] "
1132 	            "[--no-watchdog-validation]"
1133 	        )
1134 	
1135 	    cluster_node, *argv = argv
1136 	    node_dict = _parse_add_node(argv)
1137 	
1138 	    force_flags = []
1139 	    if modifiers.get("--force"):
1140 	        force_flags.append(reports.codes.FORCE)
1141 	    if modifiers.get("--skip-offline"):
1142 	        force_flags.append(reports.codes.SKIP_OFFLINE_NODES)
1143 	    cmd_data = dict(
1144 	        nodes=[node_dict],
1145 	        wait=modifiers.get("--wait"),
1146 	        start=modifiers.get("--start"),
1147 	        enable=modifiers.get("--enable"),
1148 	        no_watchdog_validation=modifiers.get("--no-watchdog-validation"),
1149 	        force_flags=force_flags,
1150 	    )
1151 	
1152 	    lib_env = utils.get_lib_env()
1153 	    report_processor = lib_env.report_processor
1154 	    target_factory = lib_env.get_node_target_factory()
1155 	    report_list, target_list = target_factory.get_target_list_with_reports(
1156 	        [cluster_node],
1157 	        skip_non_existing=False,
1158 	        allow_skip=False,
1159 	    )
1160 	    report_processor.report_list(report_list)
1161 	    if report_processor.has_errors:
1162 	        raise LibraryError()
1163 	
1164 	    com_cmd = RemoteAddNodes(report_processor, target_list[0], cmd_data)
1165 	    was_successful = run_com_cmd(lib_env.get_node_communicator(), com_cmd)
1166 	
1167 	    if not was_successful:
1168 	        raise LibraryError()
1169 	
1170 	
1171 	def node_remove(lib: Any, argv: Argv, modifiers: InputModifiers) -> None:
1172 	    """
1173 	    Options:
1174 	      * --force - continue even though the action may cause qourum loss
1175 	      * --skip-offline - skip unreachable nodes
1176 	      * --request-timeout - HTTP request timeout
1177 	    """
1178 	    modifiers.ensure_only_supported(
1179 	        "--force",
1180 	        "--skip-offline",
1181 	        "--request-timeout",
1182 	    )
1183 	    if not argv:
1184 	        raise CmdLineInputError()
1185 	
1186 	    force_flags = []
1187 	    if modifiers.get("--force"):
1188 	        force_flags.append(reports.codes.FORCE)
1189 	    if modifiers.get("--skip-offline"):
1190 	        force_flags.append(reports.codes.SKIP_OFFLINE_NODES)
1191 	
1192 	    lib.cluster.remove_nodes(argv, force_flags=force_flags)
1193 	
1194 	
1195 	def cluster_uidgid(  # noqa: PLR0912
1196 	    lib: Any, argv: Argv, modifiers: InputModifiers, silent_list: bool = False
1197 	) -> None:
1198 	    """
1199 	    Options: no options
1200 	    """
1201 	    del lib
1202 	    modifiers.ensure_only_supported()
1203 	    if not argv:
1204 	        uid_gid_files = os.listdir(settings.corosync_uidgid_dir)
1205 	        uid_gid_lines: list[str] = []
1206 	        for ug_file in uid_gid_files:
1207 	            uid_gid_dict = utils.read_uid_gid_file(ug_file)
1208 	            if "uid" in uid_gid_dict or "gid" in uid_gid_dict:
1209 	                line = "UID/GID: uid="
1210 	                if "uid" in uid_gid_dict:
1211 	                    line += uid_gid_dict["uid"]
1212 	                line += " gid="
1213 	                if "gid" in uid_gid_dict:
1214 	                    line += uid_gid_dict["gid"]
1215 	
1216 	                uid_gid_lines.append(line)
1217 	        if uid_gid_lines:
1218 	            print("\n".join(sorted(uid_gid_lines)))
1219 	        elif not silent_list:
1220 	            print_to_stderr("No uidgids configured")
1221 	        return
1222 	
1223 	    command = argv.pop(0)
1224 	    uid = ""
1225 	    gid = ""
1226 	
1227 	    if command in {"add", "delete", "remove"} and argv:
1228 	        for arg in argv:
1229 	            if arg.find("=") == -1:
1230 	                utils.err(
1231 	                    "uidgid options must be of the form uid=<uid> gid=<gid>"
1232 	                )
1233 	
1234 	            (key, value) = arg.split("=", 1)
1235 	            if key not in {"uid", "gid"}:
1236 	                utils.err(
1237 	                    "%s is not a valid key, you must use uid or gid" % key
1238 	                )
1239 	
1240 	            if key == "uid":
1241 	                uid = value
1242 	            if key == "gid":
1243 	                gid = value
1244 	        if uid == "" and gid == "":
1245 	            utils.err("you must set either uid or gid")
1246 	
1247 	        if command == "add":
1248 	            utils.write_uid_gid_file(uid, gid)
1249 	        elif command in {"delete", "remove"}:
1250 	            file_removed = utils.remove_uid_gid_file(uid, gid)
1251 	            if not file_removed:
1252 	                utils.err(
1253 	                    "no uidgid files with uid=%s and gid=%s found" % (uid, gid)
1254 	                )
1255 	    else:
1256 	        raise CmdLineInputError()
1257 	
1258 	
1259 	# Completely tear down the cluster & remove config files
1260 	# Code taken from cluster-clean script in pacemaker
1261 	def cluster_destroy(lib: Any, argv: Argv, modifiers: InputModifiers) -> None:  # noqa: PLR0912
1262 	    """
1263 	    Options:
1264 	      * --all - destroy cluster on all cluster nodes => destroy whole cluster
1265 	      * --request-timeout - timeout of HTTP requests, effective only with --all
1266 	      * --yes - required for destroying the cluster
1267 	    """
1268 	    modifiers.ensure_only_supported(
1269 	        "--all",
1270 	        "--request-timeout",
1271 	        "--yes",
1272 	        hint_syntax_changed="1.0",
1273 	    )
1274 	    if argv:
1275 	        raise CmdLineInputError()
1276 	    if utils.is_run_interactive():
1277 	        warn(
1278 	            "It is recommended to run 'pcs cluster stop' before "
1279 	            "destroying the cluster."
1280 	        )
1281 	        if not utils.get_continue_confirmation(
1282 	            "This would kill all cluster processes and then PERMANENTLY remove "
1283 	            "cluster state and configuration",
1284 	            bool(modifiers.get("--yes")),
1285 	        ):
1286 	            return
1287 	    if modifiers.get("--all"):
1288 	        # load data
1289 	        cib = None
1290 	        lib_env = utils.get_lib_env()
1291 	        try:
1292 	            cib = lib_env.get_cib()
1293 	        except LibraryError:
1294 	            warn(
1295 	                "Unable to load CIB to get guest and remote nodes from it, "
1296 	                "those nodes will not be deconfigured."
1297 	            )
1298 	        corosync_nodes, report_list = get_existing_nodes_names(
1299 	            utils.get_corosync_conf_facade()
1300 	        )
1301 	        if not corosync_nodes:
1302 	            report_list.append(
1303 	                reports.ReportItem.error(
1304 	                    reports.messages.CorosyncConfigNoNodesDefined()
1305 	                )
1306 	            )
1307 	        if report_list:
1308 	            report_and_exit_on_error(lib.env.report_processor, report_list)
1309 	
1310 	        # destroy remote and guest nodes
1311 	        if cib is not None:
1312 	            try:
1313 	                all_remote_nodes, report_list = get_existing_nodes_names(
1314 	                    cib=cib
1315 	                )
1316 	                if report_list:
1317 	                    report_and_exit_on_error(
1318 	                        lib.env.report_processor, report_list
1319 	                    )
1320 	                if all_remote_nodes:
1321 	                    _destroy_pcmk_remote_env(
1322 	                        lib_env,
1323 	                        all_remote_nodes,
1324 	                        skip_offline_nodes=True,
1325 	                        allow_fails=True,
1326 	                    )
1327 	            except LibraryError as e:
1328 	                report_and_exit_on_error(lib.env.report_processor, list(e.args))
1329 	
1330 	        # destroy full-stack nodes
1331 	        destroy_cluster(corosync_nodes)
1332 	    else:
1333 	        print_to_stderr("Shutting down pacemaker/corosync services...")
1334 	        for service in ["pacemaker", "corosync-qdevice", "corosync"]:
1335 	            # It is safe to ignore error since we want it not to be running
1336 	            # anyways.
1337 	            with contextlib.suppress(LibraryError):
1338 	                utils.stop_service(service)
1339 	        print_to_stderr("Killing any remaining services...")
1340 	        kill_local_cluster_services()
1341 	        # previously errors were suppressed in here, let's keep it that way
1342 	        # for now
1343 	        with contextlib.suppress(Exception):
1344 	            utils.disableServices()
1345 	
1346 	        # it's not a big deal if sbd disable fails
1347 	        with contextlib.suppress(Exception):
1348 	            service_manager = utils.get_service_manager()
1349 	            service_manager.disable(settings.sbd_service_name)
1350 	
1351 	        print_to_stderr("Removing all cluster configuration files...")
1352 	        dummy_output, dummy_retval = utils.run(
1353 	            [
1354 	                settings.rm_exec,
1355 	                "-f",
1356 	                settings.corosync_conf_file,
1357 	                settings.corosync_authkey_file,
1358 	                settings.pacemaker_authkey_file,
1359 	                settings.pcsd_dr_config_location,
1360 	            ]
1361 	        )
1362 	        state_files = [
1363 	            "cib-*",
1364 	            "cib.*",
1365 	            "cib.xml*",
1366 	            "core.*",
1367 	            "cts.*",
1368 	            "hostcache",
1369 	            "pe*.bz2",
1370 	        ]
1371 	        for name in state_files:
1372 	            dummy_output, dummy_retval = utils.run(
1373 	                [
1374 	                    settings.find_exec,
1375 	                    settings.pacemaker_local_state_dir,
1376 	                    "-name",
1377 	                    name,
1378 	                    "-exec",
1379 	                    settings.rm_exec,
1380 	                    "-f",
1381 	                    "{}",
1382 	                    ";",
1383 	                ]
1384 	            )
1385 	        # errors from deleting other files are suppressed as well we do not
1386 	        # want to fail if qdevice was not set up
1387 	        with contextlib.suppress(Exception):
1388 	            qdevice_net.client_destroy()
1389 	
1390 	
1391 	def cluster_verify(lib: Any, argv: Argv, modifiers: InputModifiers) -> None:
1392 	    """
1393 	    Options:
1394 	      * -f - CIB file
1395 	      * --full - more verbose output
1396 	    """
1397 	    modifiers.ensure_only_supported("-f", "--full")
1398 	    if argv:
1399 	        raise CmdLineInputError()
1400 	
1401 	    lib.cluster.verify(verbose=modifiers.get("--full"))
1402 	
1403 	
1404 	def cluster_report(lib: Any, argv: Argv, modifiers: InputModifiers) -> None:  # noqa: PLR0912
1405 	    """
1406 	    Options:
1407 	      * --from - timestamp
1408 	      * --to - timestamp
1409 	      * --overwrite - allow overwriting existing files
1410 	        The resulting file should be stored on the machine where pcs cli is
1411 	        running, not on the machine where pcs daemon is running. Therefore we
1412 	        want to use --overwrite and not --force.
1413 	    """
1414 	
1415 	    del lib
1416 	    modifiers.ensure_only_supported(
1417 	        "--from",
1418 	        "--overwrite",
1419 	        "--to",
1420 	        hint_syntax_changed="1.0",
1421 	    )
1422 	    if len(argv) != 1:
1423 	        raise CmdLineInputError()
1424 	
1425 	    outfile = argv[0]
1426 	    dest_outfile = outfile + ".tar.bz2"
1427 	    if os.path.exists(dest_outfile):
1428 	        if not modifiers.get("--overwrite"):
1429 	            utils.err(
1430 	                dest_outfile + " already exists, use --overwrite to overwrite"
1431 	            )
1432 	            return
1433 	        try:
1434 	            os.remove(dest_outfile)
1435 	        except OSError as e:
1436 	            utils.err(f"Unable to remove {dest_outfile}: {format_os_error(e)}")
1437 	    crm_report_opts = []
1438 	
1439 	    crm_report_opts.append("-f")
1440 	    if modifiers.is_specified("--from"):
1441 	        crm_report_opts.append(str(modifiers.get("--from")))
1442 	        if modifiers.is_specified("--to"):
1443 	            crm_report_opts.append("-t")
1444 	            crm_report_opts.append(str(modifiers.get("--to")))
1445 	    else:
1446 	        yesterday = datetime.datetime.now() - datetime.timedelta(1)
1447 	        crm_report_opts.append(yesterday.strftime("%Y-%m-%d %H:%M"))
1448 	
1449 	    crm_report_opts.append(outfile)
1450 	    output, retval = utils.run([settings.crm_report_exec] + crm_report_opts)
1451 	    if retval != 0 and (
1452 	        "ERROR: Cannot determine nodes; specify --nodes or --single-node"
1453 	        in output
1454 	    ):
1455 	        utils.err("cluster is not configured on this node")
1456 	    newoutput = ""
1457 	    for line in output.split("\n"):
1458 	        if line.startswith(("cat:", "grep", "tail")):
1459 	            continue
1460 	        if "We will attempt to remove" in line:
1461 	            continue
1462 	        if "-p option" in line:
1463 	            continue
1464 	        if "However, doing" in line:
1465 	            continue
1466 	        if "to diagnose" in line:
1467 	            continue
1468 	        new_line = line
1469 	        if "--dest" in line:
1470 	            new_line = line.replace("--dest", "<dest>")
1471 	        newoutput = newoutput + new_line + "\n"
1472 	    if retval != 0:
1473 	        utils.err(newoutput)
1474 	    print_to_stderr(newoutput)
1475 	
1476 	
1477 	# TODO this should be implemented in multiple lib commands, and the cli should
1478 	# only call these commands as needed
1479 	# - lib command for checking auth, that returns not authorized nodes
1480 	# - if any not authorized nodes
1481 	#   - the cli asks for a username and pass
1482 	#   - call lib command for authorizing hosts
1483 	# - else:
1484 	#   - call lib command to send the configs to other nodes
1485 	#
1486 	# This command itself is always run as root, see app.py (_non_root_run)
1487 	# So we do not need to deal with the configs in .pcs for non-root run
1488 	def cluster_auth_cmd(lib: Any, argv: Argv, modifiers: InputModifiers) -> None:  # noqa: PLR0912
1489 	    """
1490 	    Options:
1491 	      * --corosync_conf - corosync.conf file
1492 	      * --request-timeout - timeout of HTTP requests
1493 	      * -u - username
1494 	      * -p - password
1495 	    """
1496 	    modifiers.ensure_only_supported(
1497 	        "--corosync_conf", "--request-timeout", "-u", "-p"
1498 	    )
1499 	    if argv:
1500 	        raise CmdLineInputError()
1501 	    lib_env = utils.get_lib_env()
1502 	    target_factory = lib_env.get_node_target_factory()
1503 	    corosync_conf = lib_env.get_corosync_conf()
1504 	    cluster_node_list = corosync_conf.get_nodes()
1505 	    cluster_node_names = []
1506 	    missing_name = False
1507 	    for node in cluster_node_list:
1508 	        if node.name:
1509 	            cluster_node_names.append(node.name)
1510 	        else:
1511 	            missing_name = True
1512 	    if missing_name:
1513 	        warn(
1514 	            "Skipping nodes which do not have their name defined in "
1515 	            "corosync.conf, use the 'pcs host auth' command to authenticate "
1516 	            "them"
1517 	        )
1518 	    target_list = []
1519 	    not_authorized_node_name_list = []
1520 	    for node_name in cluster_node_names:
1521 	        try:
1522 	            target_list.append(target_factory.get_target(node_name))
1523 	        except HostNotFound:
1524 	            print_to_stderr("{}: Not authorized".format(node_name))
1525 	            not_authorized_node_name_list.append(node_name)
1526 	    com_cmd = CheckAuth(lib_env.report_processor)
1527 	    com_cmd.set_targets(target_list)
1528 	    not_authorized_node_name_list.extend(
1529 	        run_and_raise(lib_env.get_node_communicator(), com_cmd)
1530 	    )
1531 	    if not_authorized_node_name_list:
1532 	        print(
1533 	            "Nodes to authorize: {}".format(
1534 	                ", ".join(not_authorized_node_name_list)
1535 	            )
1536 	        )
1537 	        username, password = utils.get_user_and_pass()
1538 	        not_auth_node_list = []
1539 	        for node_name in not_authorized_node_name_list:
1540 	            for node in cluster_node_list:
1541 	                if node.name == node_name:
1542 	                    if node.addrs_plain():
1543 	                        not_auth_node_list.append(node)
1544 	                    else:
1545 	                        print_to_stderr(
1546 	                            f"{node.name}: No addresses defined in "
1547 	                            "corosync.conf, use the 'pcs host auth' command to "
1548 	                            "authenticate the node"
1549 	                        )
1550 	        nodes_to_auth_data = {
1551 	            node.name: HostAuthData(
1552 	                username,
1553 	                password,
1554 	                [
1555 	                    Destination(
1556 	                        node.addrs_plain()[0], settings.pcsd_default_port
1557 	                    )
1558 	                ],
1559 	            )
1560 	            for node in not_auth_node_list
1561 	        }
1562 	        lib.auth.auth_hosts(nodes_to_auth_data)
1563 	    else:
1564 	        # TODO backwards compatibility
1565 	        # The command overwrites known-hosts and pcsd_settings.conf on all
1566 	        # cluster nodes with local version, only if all of the nodes are
1567 	        # already authorized. We should investigate what is the reason why
1568 	        # the command does this, and decide if we should drop/keep/change this
1569 	        configs = {}
1570 	        for file_type_code in SYNCED_CONFIGS:
1571 	            file_instance = FileInstance.for_common(file_type_code)
1572 	            if not file_instance.raw_file.exists():
1573 	                # it's not an error if the file does not exist locally, we just
1574 	                # wont send it
1575 	                continue
1576 	            try:
1577 	                configs[file_type_code] = file_instance.read_raw().decode(
1578 	                    "utf-8"
1579 	                )
1580 	            except RawFileError as e:
1581 	                # in case of error when reading some file, we still might be able
1582 	                # to read and send the others without issues
1583 	                lib_env.report_processor.report(
1584 	                    raw_file_error_report(e, is_forced_or_warning=True)
1585 	                )
1586 	        set_configs_cmd = SetConfigs(
1587 	            lib_env.report_processor,
1588 	            corosync_conf.get_cluster_name(),
1589 	            configs,
1590 	            force=True,
1591 	            rejection_severity=reports.ReportItemSeverity.error(),
1592 	        )
1593 	        set_configs_cmd.set_targets(target_list)
1594 	        run_and_raise(lib_env.get_node_communicator(), set_configs_cmd)
1595 	
1596 	
1597 	def _parse_node_options(
1598 	    node: str,
1599 	    options: Argv,
1600 	    additional_options: StringCollection = (),
1601 	    additional_repeatable_options: StringCollection = (),
1602 	) -> dict[str, str | list[str]]:
1603 	    """
1604 	    Commandline options: no options
1605 	    """
1606 	    ADDR_OPT_KEYWORD = "addr"
1607 	    supported_options = {ADDR_OPT_KEYWORD} | set(additional_options)
1608 	    repeatable_options = {ADDR_OPT_KEYWORD} | set(additional_repeatable_options)
1609 	    parser = KeyValueParser(options, repeatable_options)
1610 	    parsed_unique = parser.get_unique()
1611 	    parsed_repeatable = parser.get_repeatable()
1612 	    unknown_options = (
1613 	        set(parsed_unique.keys()) | set(parsed_repeatable)
1614 	    ) - supported_options
1615 	    if unknown_options:
1616 	        raise CmdLineInputError(
1617 	            f"Unknown options {format_list(unknown_options)} for node '{node}'"
1618 	        )
1619 	    parsed_unique["name"] = node
1620 	    if ADDR_OPT_KEYWORD in parsed_repeatable:
1621 	        parsed_repeatable["addrs"] = parsed_repeatable[ADDR_OPT_KEYWORD]
1622 	        del parsed_repeatable[ADDR_OPT_KEYWORD]
1623 	    return parsed_unique | parsed_repeatable
1624 	
1625 	
1626 	TRANSPORT_KEYWORD = "transport"
1627 	TRANSPORT_DEFAULT_SECTION = "__default__"
1628 	LINK_KEYWORD = "link"
1629 	
1630 	
1631 	def _parse_transport(
1632 	    transport_args: Argv,
1633 	) -> tuple[str, dict[str, dict[str, str] | list[dict[str, str]]]]:
1634 	    """
1635 	    Commandline options: no options
1636 	    """
1637 	    if not transport_args:
1638 	        raise CmdLineInputError(
1639 	            f"{TRANSPORT_KEYWORD.capitalize()} type not defined"
1640 	        )
1641 	    transport_type, *transport_options = transport_args
1642 	
1643 	    keywords = {"compression", "crypto", LINK_KEYWORD}
1644 	    parsed_options = parse_args.group_by_keywords(
1645 	        transport_options,
1646 	        keywords,
1647 	        implicit_first_keyword=TRANSPORT_DEFAULT_SECTION,
1648 	    )
1649 	    options: dict[str, dict[str, str] | list[dict[str, str]]] = {
1650 	        section: KeyValueParser(
1651 	            parsed_options.get_args_flat(section)
1652 	        ).get_unique()
1653 	        for section in keywords | {TRANSPORT_DEFAULT_SECTION}
1654 	        if section != LINK_KEYWORD
1655 	    }
1656 	    options[LINK_KEYWORD] = [
1657 	        KeyValueParser(link_options).get_unique()
1658 	        for link_options in parsed_options.get_args_groups(LINK_KEYWORD)
1659 	    ]
1660 	
1661 	    return transport_type, options
1662 	
1663 	
1664 	def cluster_setup(lib: Any, argv: Argv, modifiers: InputModifiers) -> None:
1665 	    """
1666 	    Options:
1667 	      * --wait - only effective when used with --start
1668 	      * --start - start cluster
1669 	      * --enable - enable cluster
1670 	      * --force - some validation issues and unresolvable addresses are treated
1671 	        as warnings
1672 	      * --no-keys-sync - do not create and distribute corosync and pacemaker
1673 	        authkeys
1674 	      * --no-cluster-uuid - do not generate a cluster UUID during setup
1675 	      * --corosync_conf - corosync.conf file path, do not talk to cluster nodes
1676 	      * --overwrite - allow overwriting existing files
1677 	    """
1678 	    is_local = modifiers.is_specified("--corosync_conf")
1679 	
1680 	    allowed_options_common = ["--force", "--no-cluster-uuid"]
1681 	    allowed_options_live = [
1682 	        "--wait",
1683 	        "--start",
1684 	        "--enable",
1685 	        "--no-keys-sync",
1686 	    ]
1687 	    allowed_options_local = ["--corosync_conf", "--overwrite"]
1688 	    modifiers.ensure_only_supported(
1689 	        *(
1690 	            allowed_options_common
1691 	            + allowed_options_live
1692 	            + allowed_options_local
1693 	        ),
1694 	    )
1695 	    if is_local and modifiers.is_specified_any(allowed_options_live):
1696 	        raise CmdLineInputError(
1697 	            f"Cannot specify any of {format_list(allowed_options_live)} "
1698 	            "when '--corosync_conf' is specified"
1699 	        )
1700 	    if not is_local and modifiers.is_specified("--overwrite"):
1701 	        raise CmdLineInputError(
1702 	            "Cannot specify '--overwrite' when '--corosync_conf' is not "
1703 	            "specified"
1704 	        )
1705 	
1706 	    if len(argv) < 2:
1707 	        raise CmdLineInputError()
1708 	    cluster_name, *argv = argv
1709 	    keywords = [TRANSPORT_KEYWORD, "totem", "quorum"]
1710 	    parsed_args = parse_args.group_by_keywords(
1711 	        argv, keywords, implicit_first_keyword="nodes"
1712 	    )
1713 	    parsed_args.ensure_unique_keywords()
1714 	    nodes = [
1715 	        _parse_node_options(node, options)
1716 	        for node, options in parse_args.split_list_by_any_keywords(
1717 	            parsed_args.get_args_flat("nodes"), "node name"
1718 	        ).items()
1719 	    ]
1720 	
1721 	    transport_type = None
1722 	    transport_options: dict[str, dict[str, str] | list[dict[str, str]]] = {}
1723 	
1724 	    if parsed_args.has_keyword(TRANSPORT_KEYWORD):
1725 	        transport_type, transport_options = _parse_transport(
1726 	            parsed_args.get_args_flat(TRANSPORT_KEYWORD)
1727 	        )
1728 	
1729 	    force_flags = []
1730 	    if modifiers.get("--force"):
1731 	        force_flags.append(reports.codes.FORCE)
1732 	
1733 	    totem_options = KeyValueParser(
1734 	        parsed_args.get_args_flat("totem")
1735 	    ).get_unique()
1736 	    quorum_options = KeyValueParser(
1737 	        parsed_args.get_args_flat("quorum")
1738 	    ).get_unique()
1739 	
1740 	    if not is_local:
1741 	        lib.cluster.setup(
1742 	            cluster_name,
1743 	            nodes,
1744 	            transport_type=transport_type,
1745 	            transport_options=transport_options.get(
1746 	                TRANSPORT_DEFAULT_SECTION, {}
1747 	            ),
1748 	            link_list=transport_options.get(LINK_KEYWORD, []),
1749 	            compression_options=transport_options.get("compression", {}),
1750 	            crypto_options=transport_options.get("crypto", {}),
1751 	            totem_options=totem_options,
1752 	            quorum_options=quorum_options,
1753 	            wait=modifiers.get("--wait"),
1754 	            start=modifiers.get("--start"),
1755 	            enable=modifiers.get("--enable"),
1756 	            no_keys_sync=modifiers.get("--no-keys-sync"),
1757 	            no_cluster_uuid=modifiers.is_specified("--no-cluster-uuid"),
1758 	            force_flags=force_flags,
1759 	        )
1760 	        return
1761 	
1762 	    corosync_conf_data = lib.cluster.setup_local(
1763 	        cluster_name,
1764 	        nodes,
1765 	        transport_type=transport_type,
1766 	        transport_options=transport_options.get(TRANSPORT_DEFAULT_SECTION, {}),
1767 	        link_list=transport_options.get(LINK_KEYWORD, []),
1768 	        compression_options=transport_options.get("compression", {}),
1769 	        crypto_options=transport_options.get("crypto", {}),
1770 	        totem_options=totem_options,
1771 	        quorum_options=quorum_options,
1772 	        no_cluster_uuid=modifiers.is_specified("--no-cluster-uuid"),
1773 	        force_flags=force_flags,
1774 	    )
1775 	
1776 	    corosync_conf_file = pcs_file.RawFile(
1777 	        file_metadata.for_file_type(
1778 	            file_type_codes.COROSYNC_CONF, modifiers.get("--corosync_conf")
1779 	        )
1780 	    )
1781 	    overwrite = modifiers.is_specified("--overwrite")
1782 	    try:
1783 	        corosync_conf_file.write(corosync_conf_data, can_overwrite=overwrite)
1784 	    except pcs_file.FileAlreadyExists as e:
1785 	        utils.err(
1786 	            reports.messages.FileAlreadyExists(
1787 	                e.metadata.file_type_code,
1788 	                e.metadata.path,
1789 	            ).message
1790 	            + ", use --overwrite to overwrite existing file(s)"
1791 	        )
1792 	    except pcs_file.RawFileError as e:
1793 	        utils.err(
1794 	            reports.messages.FileIoError(
1795 	                e.metadata.file_type_code,
1796 	                e.action,
1797 	                e.reason,
1798 	                file_path=e.metadata.path,
1799 	            ).message
1800 	        )
1801 	
1802 	
1803 	def config_update(lib: Any, argv: Argv, modifiers: InputModifiers) -> None:
1804 	    """
1805 	    Options:
1806 	      * --corosync_conf - corosync.conf file path, do not talk to cluster nodes
1807 	    """
1808 	    modifiers.ensure_only_supported("--corosync_conf")
1809 	    parsed_args = parse_args.group_by_keywords(
1810 	        argv,
1811 	        ["transport", "compression", "crypto", "totem"],
1812 	    )
1813 	
1814 	    transport_options = KeyValueParser(
1815 	        parsed_args.get_args_flat("transport")
1816 	    ).get_unique()
1817 	    compression_options = KeyValueParser(
1818 	        parsed_args.get_args_flat("compression")
1819 	    ).get_unique()
1820 	    crypto_options = KeyValueParser(
1821 	        parsed_args.get_args_flat("crypto")
1822 	    ).get_unique()
1823 	    totem_options = KeyValueParser(
1824 	        parsed_args.get_args_flat("totem")
1825 	    ).get_unique()
1826 	
1827 	    if not modifiers.is_specified("--corosync_conf"):
1828 	        lib.cluster.config_update(
1829 	            transport_options,
1830 	            compression_options,
1831 	            crypto_options,
1832 	            totem_options,
1833 	        )
1834 	        return
1835 	
1836 	    _corosync_conf_local_cmd_call(
1837 	        modifiers.get("--corosync_conf"),
1838 	        lambda corosync_conf_content: lib.cluster.config_update_local(
1839 	            corosync_conf_content,
1840 	            transport_options,
1841 	            compression_options,
1842 	            crypto_options,
1843 	            totem_options,
1844 	        ),
1845 	    )
1846 	
1847 	
1848 	def _format_options(label: str, options: Mapping[str, str]) -> list[str]:
1849 	    output = []
1850 	    if options:
1851 	        output.append(f"{label}:")
1852 	        output.extend(
1853 	            indent([f"{opt}: {val}" for opt, val in sorted(options.items())])
1854 	        )
1855 	    return output
1856 	
1857 	
1858 	def _format_nodes(nodes: Iterable[CorosyncNodeDto]) -> list[str]:
1859 	    output = ["Nodes:"]
1860 	    for node in sorted(nodes, key=lambda node: node.name):
1861 	        node_attrs = [
1862 	            f"Link {addr.link} address: {addr.addr}"
1863 	            for addr in sorted(node.addrs, key=lambda addr: addr.link)
1864 	        ] + [f"nodeid: {node.nodeid}"]
1865 	        output.extend(indent([f"{node.name}:"] + indent(node_attrs)))
1866 	    return output
1867 	
1868 	
1869 	def config_show(
1870 	    lib: Any, argv: Argv, modifiers: parse_args.InputModifiers
1871 	) -> None:
1872 	    """
1873 	    Options:
1874 	      * --corosync_conf - corosync.conf file path, do not talk to cluster nodes
1875 	      * --output-format - supported formats: text, cmd, json
1876 	    """
1877 	    modifiers.ensure_only_supported(
1878 	        "--corosync_conf", output_format_supported=True
1879 	    )
1880 	    if argv:
1881 	        raise CmdLineInputError()
1882 	    output_format = modifiers.get_output_format()
1883 	    corosync_conf_dto = lib.cluster.get_corosync_conf_struct()
1884 	    if output_format == OUTPUT_FORMAT_VALUE_CMD:
1885 	        if corosync_conf_dto.quorum_device is not None:
1886 	            warn(
1887 	                "Quorum device configuration detected but not yet supported by "
1888 	                "this command."
1889 	            )
1890 	        output = " \\\n".join(_config_get_cmd(corosync_conf_dto))
1891 	    elif output_format == OUTPUT_FORMAT_VALUE_JSON:
1892 	        output = json.dumps(dto.to_dict(corosync_conf_dto))
1893 	    else:
1894 	        output = "\n".join(_config_get_text(corosync_conf_dto))
1895 	    print(output)
1896 	
1897 	
1898 	def _config_get_text(corosync_conf: CorosyncConfDto) -> list[str]:
1899 	    lines = [f"Cluster Name: {corosync_conf.cluster_name}"]
1900 	    if corosync_conf.cluster_uuid:
1901 	        lines.append(f"Cluster UUID: {corosync_conf.cluster_uuid}")
1902 	    lines.append(f"Transport: {corosync_conf.transport.lower()}")
1903 	    lines.extend(_format_nodes(corosync_conf.nodes))
1904 	    if corosync_conf.links_options:
1905 	        lines.append("Links:")
1906 	        for linknum, link_options in sorted(
1907 	            corosync_conf.links_options.items()
1908 	        ):
1909 	            lines.extend(
1910 	                indent(_format_options(f"Link {linknum}", link_options))
1911 	            )
1912 	
1913 	    lines.extend(
1914 	        _format_options("Transport Options", corosync_conf.transport_options)
1915 	    )
1916 	    lines.extend(
1917 	        _format_options(
1918 	            "Compression Options", corosync_conf.compression_options
1919 	        )
1920 	    )
1921 	    lines.extend(
1922 	        _format_options("Crypto Options", corosync_conf.crypto_options)
1923 	    )
1924 	    lines.extend(_format_options("Totem Options", corosync_conf.totem_options))
1925 	    lines.extend(
1926 	        _format_options("Quorum Options", corosync_conf.quorum_options)
1927 	    )
1928 	    if corosync_conf.quorum_device:
1929 	        lines.append(f"Quorum Device: {corosync_conf.quorum_device.model}")
1930 	        lines.extend(
1931 	            indent(
1932 	                _format_options(
1933 	                    "Options", corosync_conf.quorum_device.generic_options
1934 	                )
1935 	            )
1936 	        )
1937 	        lines.extend(
1938 	            indent(
1939 	                _format_options(
1940 	                    "Model Options",
1941 	                    corosync_conf.quorum_device.model_options,
1942 	                )
1943 	            )
1944 	        )
1945 	        lines.extend(
1946 	            indent(
1947 	                _format_options(
1948 	                    "Heuristics",
1949 	                    corosync_conf.quorum_device.heuristics_options,
1950 	                )
1951 	            )
1952 	        )
1953 	    return lines
1954 	
1955 	
1956 	def _corosync_node_to_cmd_line(node: CorosyncNodeDto) -> str:
1957 	    return " ".join(
1958 	        [node.name]
1959 	        + [
1960 	            f"addr={addr.addr}"
1961 	            for addr in sorted(node.addrs, key=lambda addr: addr.link)
1962 	        ]
1963 	    )
1964 	
1965 	
1966 	def _section_to_lines(
1967 	    options: Mapping[str, str], keyword: str | None = None
1968 	) -> list[str]:
1969 	    output: list[str] = []
1970 	    if options:
1971 	        if keyword:
1972 	            output.append(keyword)
1973 	        output.extend(
1974 	            indent([f"{key}={val}" for key, val in sorted(options.items())])
1975 	        )
1976 	    return indent(output)
1977 	
1978 	
1979 	def _config_get_cmd(corosync_conf: CorosyncConfDto) -> list[str]:
1980 	    lines = [f"pcs cluster setup {corosync_conf.cluster_name}"]
1981 	    lines += indent(
1982 	        [
1983 	            _corosync_node_to_cmd_line(node)
1984 	            for node in sorted(
1985 	                corosync_conf.nodes, key=lambda node: node.nodeid
1986 	            )
1987 	        ]
1988 	    )
1989 	    transport = [
1990 	        "transport",
1991 	        str(corosync_conf.transport.value).lower(),
1992 	    ] + _section_to_lines(corosync_conf.transport_options)
1993 	    for _, link in sorted(corosync_conf.links_options.items()):
1994 	        transport.extend(_section_to_lines(link, "link"))
1995 	    transport.extend(
1996 	        _section_to_lines(corosync_conf.compression_options, "compression")
1997 	    )
1998 	    transport.extend(_section_to_lines(corosync_conf.crypto_options, "crypto"))
1999 	    lines.extend(indent(transport))
2000 	    lines.extend(_section_to_lines(corosync_conf.totem_options, "totem"))
2001 	    lines.extend(_section_to_lines(corosync_conf.quorum_options, "quorum"))
2002 	    if not corosync_conf.cluster_uuid:
2003 	        lines.extend(indent(["--no-cluster-uuid"]))
2004 	    return lines
2005 	
2006 	
2007 	def _parse_add_node(argv: Argv) -> dict[str, str | list[str]]:
2008 	    DEVICE_KEYWORD = "device"
2009 	    WATCHDOG_KEYWORD = "watchdog"
2010 	    hostname, *argv = argv
2011 	    node_dict = _parse_node_options(
2012 	        hostname,
2013 	        argv,
2014 	        additional_options={DEVICE_KEYWORD, WATCHDOG_KEYWORD},
2015 	        additional_repeatable_options={DEVICE_KEYWORD},
2016 	    )
2017 	    if DEVICE_KEYWORD in node_dict:
2018 	        node_dict[f"{DEVICE_KEYWORD}s"] = node_dict[DEVICE_KEYWORD]
2019 	        del node_dict[DEVICE_KEYWORD]
2020 	    return node_dict
2021 	
2022 	
2023 	def node_add(lib: Any, argv: Argv, modifiers: InputModifiers) -> None:
2024 	    """
2025 	    Options:
2026 	      * --wait - wait until new node will start up, effective only when --start
2027 	        is specified
2028 	      * --start - start new node
2029 	      * --enable - enable new node
2030 	      * --force - treat validation issues and not resolvable addresses as
2031 	        warnings instead of errors
2032 	      * --skip-offline - skip unreachable nodes
2033 	      * --no-watchdog-validation - do not validatate watchdogs
2034 	      * --request-timeout - HTTP request timeout
2035 	    """
2036 	    modifiers.ensure_only_supported(
2037 	        "--wait",
2038 	        "--start",
2039 	        "--enable",
2040 	        "--force",
2041 	        "--skip-offline",
2042 	        "--no-watchdog-validation",
2043 	        "--request-timeout",
2044 	    )
2045 	    if not argv:
2046 	        raise CmdLineInputError()
2047 	
2048 	    node_dict = _parse_add_node(argv)
2049 	
2050 	    force_flags = []
2051 	    if modifiers.get("--force"):
2052 	        force_flags.append(reports.codes.FORCE)
2053 	    if modifiers.get("--skip-offline"):
2054 	        force_flags.append(reports.codes.SKIP_OFFLINE_NODES)
2055 	
2056 	    lib.cluster.add_nodes(
2057 	        nodes=[node_dict],
2058 	        wait=modifiers.get("--wait"),
2059 	        start=modifiers.get("--start"),
2060 	        enable=modifiers.get("--enable"),
2061 	        no_watchdog_validation=modifiers.get("--no-watchdog-validation"),
2062 	        force_flags=force_flags,
2063 	    )
2064 	
2065 	
2066 	def remove_nodes_from_cib(
2067 	    lib: Any, argv: Argv, modifiers: InputModifiers
2068 	) -> None:
2069 	    """
2070 	    Options: no options
2071 	    """
2072 	    modifiers.ensure_only_supported()
2073 	    if not argv:
2074 	        raise CmdLineInputError("No nodes specified")
2075 	    lib.cluster.remove_nodes_from_cib(argv)
2076 	
2077 	
2078 	def link_add(lib: Any, argv: Argv, modifiers: InputModifiers) -> None:
2079 	    """
2080 	    Options:
2081 	      * --force - treat validation issues and not resolvable addresses as
2082 	        warnings instead of errors
2083 	      * --skip-offline - skip unreachable nodes
2084 	      * --request-timeout - HTTP request timeout
2085 	    """
2086 	    modifiers.ensure_only_supported(
2087 	        "--force", "--request-timeout", "--skip-offline"
2088 	    )
2089 	    if not argv:
2090 	        raise CmdLineInputError()
2091 	
2092 	    force_flags = []
2093 	    if modifiers.get("--force"):
2094 	        force_flags.append(reports.codes.FORCE)
2095 	    if modifiers.get("--skip-offline"):
2096 	        force_flags.append(reports.codes.SKIP_OFFLINE_NODES)
2097 	
2098 	    parsed = parse_args.group_by_keywords(
2099 	        argv, {"options"}, implicit_first_keyword="nodes"
2100 	    )
2101 	    parsed.ensure_unique_keywords()
2102 	
2103 	    lib.cluster.add_link(
2104 	        KeyValueParser(parsed.get_args_flat("nodes")).get_unique(),
2105 	        KeyValueParser(parsed.get_args_flat("options")).get_unique(),
2106 	        force_flags=force_flags,
2107 	    )
2108 	
2109 	
2110 	def link_remove(lib: Any, argv: Argv, modifiers: InputModifiers) -> None:
2111 	    """
2112 	    Options:
2113 	      * --skip-offline - skip unreachable nodes
2114 	      * --request-timeout - HTTP request timeout
2115 	    """
2116 	    modifiers.ensure_only_supported("--request-timeout", "--skip-offline")
2117 	    if not argv:
2118 	        raise CmdLineInputError()
2119 	
2120 	    force_flags = []
2121 	    if modifiers.get("--skip-offline"):
2122 	        force_flags.append(reports.codes.SKIP_OFFLINE_NODES)
2123 	
2124 	    lib.cluster.remove_links(argv, force_flags=force_flags)
2125 	
2126 	
2127 	def link_update(lib: Any, argv: Argv, modifiers: InputModifiers) -> None:
2128 	    """
2129 	    Options:
2130 	      * --force - treat validation issues and not resolvable addresses as
2131 	        warnings instead of errors
2132 	      * --skip-offline - skip unreachable nodes
2133 	      * --request-timeout - HTTP request timeout
2134 	    """
2135 	    modifiers.ensure_only_supported(
2136 	        "--force", "--request-timeout", "--skip-offline"
2137 	    )
2138 	    if len(argv) < 2:
2139 	        raise CmdLineInputError()
2140 	
2141 	    force_flags = []
2142 	    if modifiers.get("--force"):
2143 	        force_flags.append(reports.codes.FORCE)
2144 	    if modifiers.get("--skip-offline"):
2145 	        force_flags.append(reports.codes.SKIP_OFFLINE_NODES)
2146 	
2147 	    linknumber = argv[0]
2148 	    parsed = parse_args.group_by_keywords(
2149 	        argv[1:], {"options"}, implicit_first_keyword="nodes"
2150 	    )
2151 	    parsed.ensure_unique_keywords()
2152 	
2153 	    lib.cluster.update_link(
2154 	        linknumber,
2155 	        KeyValueParser(parsed.get_args_flat("nodes")).get_unique(),
2156 	        KeyValueParser(parsed.get_args_flat("options")).get_unique(),
2157 	        force_flags=force_flags,
2158 	    )
2159 	
2160 	
2161 	def generate_uuid(lib: Any, argv: Argv, modifiers: InputModifiers) -> None:
2162 	    """
2163 	    Options:
2164 	      * --force - allow to rewrite an existing UUID in corosync.conf
2165 	      * --corosync_conf - corosync.conf file path, do not talk to cluster nodes
2166 	    """
2167 	    modifiers.ensure_only_supported("--force", "--corosync_conf")
2168 	    if argv:
2169 	        raise CmdLineInputError()
2170 	
2171 	    force_flags = []
2172 	    if modifiers.get("--force"):
2173 	        force_flags.append(reports.codes.FORCE)
2174 	
2175 	    if not modifiers.is_specified("--corosync_conf"):
2176 	        lib.cluster.generate_cluster_uuid(force_flags=force_flags)
2177 	        return
2178 	
2179 	    _corosync_conf_local_cmd_call(
2180 	        modifiers.get("--corosync_conf"),
2181 	        lambda corosync_conf_content: lib.cluster.generate_cluster_uuid_local(
2182 	            corosync_conf_content, force_flags=force_flags
2183 	        ),
2184 	    )
2185