diff --git a/.gitignore b/.gitignore index 6e8512c..fb4514e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ SOURCES/mock-1.0.1.tar.gz SOURCES/multi_json-1.13.1.gem SOURCES/open4-1.3.4.gem SOURCES/orderedhash-0.0.6.gem -SOURCES/pcs-0.9.167.tar.gz +SOURCES/pcs-0.9.168.tar.gz SOURCES/pyagentx-0.4.pcs.1.tar.gz SOURCES/rack-1.6.11.gem SOURCES/rack-protection-1.5.5.gem diff --git a/.pcs.metadata b/.pcs.metadata index 09d03b4..edbbf5a 100644 --- a/.pcs.metadata +++ b/.pcs.metadata @@ -6,7 +6,7 @@ baa3446eb63557a24c4522dc5a61cfad082fa395 SOURCES/mock-1.0.1.tar.gz ff6e0965061cb6f604ee4d87a2cf96a2917f9f88 SOURCES/multi_json-1.13.1.gem 41a7fe9f8e3e02da5ae76c821b89c5b376a97746 SOURCES/open4-1.3.4.gem 709cc95025009e5d221e37cb0777e98582146809 SOURCES/orderedhash-0.0.6.gem -99ab7aca9cb978c82da9727b4cfeae03c3cf1b28 SOURCES/pcs-0.9.167.tar.gz +ff0268d0e67d09897d204046511dca6c70f9c8b6 SOURCES/pcs-0.9.168.tar.gz 276a92c6d679a71bd0daaf12cb7b3616f1a89b72 SOURCES/pyagentx-0.4.pcs.1.tar.gz 64a0cd32f46c0ff44ffda4055048fe6309903110 SOURCES/rack-1.6.11.gem f80ea6672253a90fa031db0c1e2e1fe056582118 SOURCES/rack-protection-1.5.5.gem diff --git a/SOURCES/bz1458153-01-give-back-orig.-master-behav.-resource-create.patch b/SOURCES/bz1458153-01-give-back-orig.-master-behav.-resource-create.patch index b3b3965..54fa1f1 100644 --- a/SOURCES/bz1458153-01-give-back-orig.-master-behav.-resource-create.patch +++ b/SOURCES/bz1458153-01-give-back-orig.-master-behav.-resource-create.patch @@ -1,7 +1,7 @@ -From 0b642f384198d9df09c33ba1473909b7aeb4a572 Mon Sep 17 00:00:00 2001 +From 00ef68086938945803d8b2c601e43735d5d5967e Mon Sep 17 00:00:00 2001 From: Ivan Devat Date: Mon, 5 Jun 2017 17:13:41 +0200 -Subject: [PATCH] give back orig. --master behav. (resource create) +Subject: [PATCH 3/6] give back orig. --master behav. (resource create) --- pcs/cli/common/parse_args.py | 8 +- @@ -14,10 +14,10 @@ Subject: [PATCH] give back orig. --master behav. (resource create) 7 files changed, 228 insertions(+), 59 deletions(-) diff --git a/pcs/cli/common/parse_args.py b/pcs/cli/common/parse_args.py -index 7d6f6006..5c709245 100644 +index 1f3e7b33..b9511d6c 100644 --- a/pcs/cli/common/parse_args.py +++ b/pcs/cli/common/parse_args.py -@@ -299,7 +299,13 @@ def upgrade_args(arg_list): +@@ -307,7 +307,13 @@ def upgrade_args(arg_list): and args_without_options[:2] == ["resource", "create"] ): @@ -87,10 +87,10 @@ index efe38d0e..900094c9 100644 ], upgrade_args([ diff --git a/pcs/resource.py b/pcs/resource.py -index c6bb0aca..f615f682 100644 +index 51233a12..62932387 100644 --- a/pcs/resource.py +++ b/pcs/resource.py -@@ -391,6 +391,25 @@ def resource_create(lib, argv, modifiers): +@@ -397,6 +397,25 @@ def resource_create(lib, argv, modifiers): ra_type = argv[1] parts = parse_create_args(argv[2:]) @@ -406,10 +406,10 @@ index ecb16384..57d95350 100644 , "Error: you can specify only one of clone, master, bundle or" diff --git a/pcs/test/test_constraints.py b/pcs/test/test_constraints.py -index d2278e08..142fb6f7 100644 +index 3feaa053..28d68177 100644 --- a/pcs/test/test_constraints.py +++ b/pcs/test/test_constraints.py -@@ -349,43 +349,43 @@ Ticket Constraints: +@@ -393,43 +393,43 @@ Ticket Constraints: def testColocationConstraints(self): # see also BundleColocation @@ -463,7 +463,7 @@ index d2278e08..142fb6f7 100644 output, returnVal = pcs(temp_cib, line) assert returnVal == 0 and output == "" -@@ -939,7 +939,7 @@ Ticket Constraints: +@@ -1054,7 +1054,7 @@ Ticket Constraints: assert returnVal == 1 def testLocationBadRules(self): @@ -472,7 +472,7 @@ index d2278e08..142fb6f7 100644 ac(o,"") assert r == 0 -@@ -960,7 +960,7 @@ Ticket Constraints: +@@ -1075,7 +1075,7 @@ Ticket Constraints: """) assert r == 0 @@ -481,7 +481,7 @@ index d2278e08..142fb6f7 100644 ac(o,"") assert r == 0 -@@ -999,7 +999,7 @@ Ticket Constraints: +@@ -1114,7 +1114,7 @@ Ticket Constraints: ac(o,"") assert r == 0 @@ -490,7 +490,7 @@ index d2278e08..142fb6f7 100644 ac(o, """\ Warning: changing a monitor operation interval from 10 to 11 to make the operation unique """) -@@ -1120,7 +1120,7 @@ Ticket Constraints: +@@ -1235,7 +1235,7 @@ Ticket Constraints: self.assertEqual(0, returnVal) output, returnVal = pcs( @@ -500,10 +500,10 @@ index d2278e08..142fb6f7 100644 ac(output, """\ Warning: changing a monitor operation interval from 10 to 11 to make the operation unique diff --git a/pcs/test/test_resource.py b/pcs/test/test_resource.py -index 3091517c..f514f9ed 100644 +index 2a110683..e49b59a1 100644 --- a/pcs/test/test_resource.py +++ b/pcs/test/test_resource.py -@@ -2738,7 +2738,7 @@ Ticket Constraints: +@@ -2898,7 +2898,7 @@ Ticket Constraints: output, returnVal = pcs( temp_cib, @@ -512,7 +512,7 @@ index 3091517c..f514f9ed 100644 ) assert returnVal == 0 assert output == "", [output] -@@ -2851,7 +2851,7 @@ Warning: changing a monitor operation interval from 10 to 11 to make the operati +@@ -3011,7 +3011,7 @@ Warning: changing a monitor operation interval from 10 to 11 to make the operati ac(o,"") assert r == 0 @@ -521,7 +521,7 @@ index 3091517c..f514f9ed 100644 ac(o,"") assert r == 0 -@@ -3077,7 +3077,7 @@ Warning: changing a monitor operation interval from 10 to 11 to make the operati +@@ -3237,7 +3237,7 @@ Warning: changing a monitor operation interval from 10 to 11 to make the operati output, returnVal = pcs( temp_cib, @@ -530,7 +530,7 @@ index 3091517c..f514f9ed 100644 ) ac(output, "") self.assertEqual(0, returnVal) -@@ -3742,7 +3742,7 @@ Error: Cannot remove more than one resource from cloned group +@@ -3902,7 +3902,7 @@ Error: Cannot remove more than one resource from cloned group # However those test the pcs library. I'm leaving these tests here to # test the cli part for now. self.assert_pcs_success( @@ -539,7 +539,7 @@ index 3091517c..f514f9ed 100644 "Warning: changing a monitor operation interval from 10 to 11 to make the operation unique\n" ) -@@ -5355,7 +5355,7 @@ class CloneMasterUpdate(unittest.TestCase, AssertPcsMixin): +@@ -5515,7 +5515,7 @@ class CloneMasterUpdate(unittest.TestCase, AssertPcsMixin): def test_no_op_allowed_in_master_update(self): self.assert_pcs_success( @@ -549,10 +549,10 @@ index 3091517c..f514f9ed 100644 self.assert_pcs_success( "resource show dummy-master", diff --git a/pcs/utils.py b/pcs/utils.py -index 8515aae9..c5e3c171 100644 +index 66f7ebf1..73721a2a 100644 --- a/pcs/utils.py +++ b/pcs/utils.py -@@ -2962,6 +2962,13 @@ def get_modifiers(): +@@ -2981,6 +2981,13 @@ def get_modifiers(): "wait": pcs_options.get("--wait", False), "watchdog": pcs_options.get("--watchdog", []), "no_watchdog_validation": "--no-watchdog-validation" in pcs_options, @@ -567,5 +567,5 @@ index 8515aae9..c5e3c171 100644 def exit_on_cmdline_input_errror(error, main_name, usage_name): -- -2.17.0 +2.20.1 diff --git a/SOURCES/bz1459503-01-OSP-workarounds-not-compatible-wi.patch b/SOURCES/bz1459503-01-OSP-workarounds-not-compatible-wi.patch index f822579..adefcb0 100644 --- a/SOURCES/bz1459503-01-OSP-workarounds-not-compatible-wi.patch +++ b/SOURCES/bz1459503-01-OSP-workarounds-not-compatible-wi.patch @@ -1,7 +1,7 @@ -From 2eb9635de627abfaa14ee83ba4c022f7ecd9d74b Mon Sep 17 00:00:00 2001 +From 0ae2d03488d5959e22e0cf6fd8dd1c10789dc819 Mon Sep 17 00:00:00 2001 From: Ivan Devat Date: Wed, 7 Jun 2017 14:36:05 +0200 -Subject: [PATCH] squash bz1459503 OSP workarounds not compatible wi +Subject: [PATCH 4/6] squash bz1459503 OSP workarounds not compatible wi reuse existing pcmk authkey during setup @@ -14,10 +14,10 @@ show only warn if `resource create` creates remote 4 files changed, 31 insertions(+), 22 deletions(-) diff --git a/pcs/cluster.py b/pcs/cluster.py -index b1f63d45..42e94a94 100644 +index 7727cbef..0bacae16 100644 --- a/pcs/cluster.py +++ b/pcs/cluster.py -@@ -519,13 +519,21 @@ def cluster_setup(argv): +@@ -525,13 +525,21 @@ def cluster_setup(argv): print("Destroying cluster on nodes: {0}...".format( ", ".join(primary_addr_list) )) @@ -41,10 +41,10 @@ index b1f63d45..42e94a94 100644 if modifiers["encryption"] == "1": file_definitions.update( diff --git a/pcs/lib/commands/resource.py b/pcs/lib/commands/resource.py -index 90dab8f4..de5cfb4e 100644 +index 1ad03a48..ad7a1a20 100644 --- a/pcs/lib/commands/resource.py +++ b/pcs/lib/commands/resource.py -@@ -76,7 +76,8 @@ def _validate_remote_connection( +@@ -79,7 +79,8 @@ def _validate_remote_connection( report_list.append( reports.get_problem_creator( report_codes.FORCE_NOT_SUITABLE_COMMAND, @@ -54,7 +54,7 @@ index 90dab8f4..de5cfb4e 100644 )(reports.use_command_node_add_remote) ) -@@ -106,7 +107,8 @@ def _validate_guest_change( +@@ -109,7 +110,8 @@ def _validate_guest_change( report_list.append( reports.get_problem_creator( report_codes.FORCE_NOT_SUITABLE_COMMAND, @@ -98,10 +98,10 @@ index 57d95350..3cd49e74 100644 def test_warn_when_on_pacemaker_remote_guest_attempt(self): diff --git a/pcs/test/test_resource.py b/pcs/test/test_resource.py -index f514f9ed..773b37cf 100644 +index e49b59a1..fed8465e 100644 --- a/pcs/test/test_resource.py +++ b/pcs/test/test_resource.py -@@ -5712,10 +5712,10 @@ class ResourceUpdateRemoteAndGuestChecks(unittest.TestCase, AssertPcsMixin): +@@ -5872,10 +5872,10 @@ class ResourceUpdateRemoteAndGuestChecks(unittest.TestCase, AssertPcsMixin): self.assert_pcs_success( "resource create R ocf:heartbeat:Dummy", ) @@ -115,7 +115,7 @@ index f514f9ed..773b37cf 100644 ) def test_update_warn_on_pacemaker_guest_attempt(self): self.assert_pcs_success( -@@ -5734,10 +5734,10 @@ class ResourceUpdateRemoteAndGuestChecks(unittest.TestCase, AssertPcsMixin): +@@ -5894,10 +5894,10 @@ class ResourceUpdateRemoteAndGuestChecks(unittest.TestCase, AssertPcsMixin): "Warning: this command is not sufficient for creating a guest node," " use 'pcs cluster node add-guest'\n" ) @@ -129,7 +129,7 @@ index f514f9ed..773b37cf 100644 ) def test_update_warn_on_pacemaker_guest_attempt_remove(self): -@@ -5758,10 +5758,10 @@ class ResourceUpdateRemoteAndGuestChecks(unittest.TestCase, AssertPcsMixin): +@@ -5918,10 +5918,10 @@ class ResourceUpdateRemoteAndGuestChecks(unittest.TestCase, AssertPcsMixin): self.assert_pcs_success( "resource create R ocf:heartbeat:Dummy", ) @@ -143,7 +143,7 @@ index f514f9ed..773b37cf 100644 ) def test_meta_warn_on_pacemaker_guest_attempt(self): -@@ -5782,10 +5782,10 @@ class ResourceUpdateRemoteAndGuestChecks(unittest.TestCase, AssertPcsMixin): +@@ -5942,10 +5942,10 @@ class ResourceUpdateRemoteAndGuestChecks(unittest.TestCase, AssertPcsMixin): "Warning: this command is not sufficient for creating a guest node," " use 'pcs cluster node add-guest'\n" ) @@ -158,5 +158,5 @@ index f514f9ed..773b37cf 100644 def test_meta_warn_on_pacemaker_guest_attempt_remove(self): -- -2.17.0 +2.20.1 diff --git a/SOURCES/bz1500012-01-provide-a-hint-about-the-set-opti.patch b/SOURCES/bz1500012-01-provide-a-hint-about-the-set-opti.patch new file mode 100644 index 0000000..5087fa5 --- /dev/null +++ b/SOURCES/bz1500012-01-provide-a-hint-about-the-set-opti.patch @@ -0,0 +1,141 @@ +From c85f907253f6379ad726a7f54635ccbe0b64af4c Mon Sep 17 00:00:00 2001 +From: Ivan Devat +Date: Mon, 4 Nov 2019 14:52:03 +0100 +Subject: [PATCH 1/4] squash bz1500012 provide a hint about the set opti + +d2754533 doc: fix `pcs constraint colocation add` command +abb8f0ea don't allow empty value for constraint options +--- + pcs/cli/common/parse_args.py | 9 +++++++-- + pcs/constraint.py | 7 +++++-- + pcs/pcs.8 | 4 ++-- + pcs/test/test_constraints.py | 9 +++++++++ + pcs/usage.py | 6 +++--- + 5 files changed, 26 insertions(+), 9 deletions(-) + +diff --git a/pcs/cli/common/parse_args.py b/pcs/cli/common/parse_args.py +index 6a3210ac..23d8799d 100644 +--- a/pcs/cli/common/parse_args.py ++++ b/pcs/cli/common/parse_args.py +@@ -46,7 +46,7 @@ def split_list(arg_list, separator): + bounds = zip([0]+[i+1 for i in separator_indexes], separator_indexes+[None]) + return [arg_list[i:j] for i, j in bounds] + +-def split_option(arg): ++def split_option(arg, allow_empty_value=True): + """ + Get (key, value) from a key=value commandline argument. + +@@ -54,12 +54,17 @@ def split_option(arg): + CmdLineInputError if the argument cannot be splitted. + + string arg -- commandline argument ++ allow_empty_value -- if True, empty value is allowed. Otherwise, ++ CmdLineInputError exception is raised + """ + if "=" not in arg: + raise CmdLineInputError("missing value of '{0}' option".format(arg)) + if arg.startswith("="): + raise CmdLineInputError("missing key in '{0}' option".format(arg)) +- return arg.split("=", 1) ++ key, value = arg.split("=", 1) ++ if not (value or allow_empty_value): ++ raise CmdLineInputError("value of '{0}' option is empty".format(key)) ++ return key, value + + def prepare_options(cmdline_args): + """return dictionary of options from commandline key=value args""" +diff --git a/pcs/constraint.py b/pcs/constraint.py +index c9f6fd5f..ca12aaa7 100644 +--- a/pcs/constraint.py ++++ b/pcs/constraint.py +@@ -208,7 +208,10 @@ def colocation_add(argv): + return SCORE_INFINITY, [] + score = SCORE_INFINITY if "=" in argv[0] else argv.pop(0) + # create a list of 2-tuples (name, value) +- arg_array = [parse_args.split_option(arg) for arg in argv] ++ arg_array = [ ++ parse_args.split_option(arg, allow_empty_value=False) ++ for arg in argv ++ ] + return score, arg_array + + def _validate_and_prepare_role(role): +@@ -444,7 +447,7 @@ def _order_add(resource1, resource2, options_list): + elif arg == "nonsymmetrical": + sym = "false" + else: +- name, value = parse_args.split_option(arg) ++ name, value = parse_args.split_option(arg, allow_empty_value=False) + if name == "id": + id_valid, id_error = utils.validate_xml_id( + value, "constraint id" +diff --git a/pcs/pcs.8 b/pcs/pcs.8 +index 5670f458..f08b5e46 100644 +--- a/pcs/pcs.8 ++++ b/pcs/pcs.8 +@@ -562,8 +562,8 @@ Remove resource from any ordering constraint + colocation [show] [\fB\-\-full\fR] + List all current colocation constraints (if \fB\-\-full\fR is specified show the internal constraint id's as well). + .TP +-colocation add [master|slave] with [master|slave] [score] [options] [id=constraint\-id] +-Request to run on the same node where pacemaker has determined should run. Positive values of score mean the resources should be run on the same node, negative values mean the resources should not be run on the same node. Specifying 'INFINITY' (or '\-INFINITY') for the score forces to run (or not run) with (score defaults to "INFINITY"). A role can be master or slave (if no role is specified, it defaults to 'started'). ++colocation add [] with [] [score] [options] [id=constraint\-id] ++Request to run on the same node where pacemaker has determined should run. Positive values of score mean the resources should be run on the same node, negative values mean the resources should not be run on the same node. Specifying 'INFINITY' (or '\-INFINITY') for the score forces to run (or not run) with (score defaults to "INFINITY"). A role can be: 'Master', 'Slave', 'Started', 'Stopped' (if no role is specified, it defaults to 'Started'). + .TP + colocation set [resourceN]... [options] [set ... [options]] [setoptions [constraint_options]] + Create a colocation constraint with a resource set. Available options are sequential=true/false and role=Stopped/Started/Master/Slave. Available constraint_options are id and either of: score, score\-attribute, score\-attribute\-mangle. +diff --git a/pcs/test/test_constraints.py b/pcs/test/test_constraints.py +index 36bcb652..3feaa053 100644 +--- a/pcs/test/test_constraints.py ++++ b/pcs/test/test_constraints.py +@@ -262,6 +262,11 @@ Ticket Constraints: + ac(o,"Location Constraints:\nOrdering Constraints:\n stop D1 then stop D2 (kind:Mandatory) (id:order-D1-D2-mandatory)\n start D1 then start D2 (kind:Mandatory) (id:order-D1-D2-mandatory-1)\nColocation Constraints:\nTicket Constraints:\n") + assert r == 0 + ++ def test_order_options_empty_value(self): ++ o, r = pcs("constraint order D1 then D2 option1=") ++ self.assertIn("value of 'option1' option is empty", o) ++ self.assertEqual(r, 1) ++ + def test_order_too_many_resources(self): + msg = ( + "Error: Multiple 'then's cannot be specified.\n" +@@ -525,6 +530,10 @@ Ticket Constraints: + self.assertIn(msg, o) + self.assertEqual(r, 1) + ++ def test_colocation_options_empty_value(self): ++ o, r = pcs("constraint colocation add D1 with D2 option1=") ++ self.assertIn("value of 'option1' option is empty", o) ++ self.assertEqual(r, 1) + + def testColocationSets(self): + # see also BundleColocation +diff --git a/pcs/usage.py b/pcs/usage.py +index 18baaf0e..37c92d26 100644 +--- a/pcs/usage.py ++++ b/pcs/usage.py +@@ -1200,7 +1200,7 @@ Commands: + List all current colocation constraints (if --full is specified show + the internal constraint id's as well). + +- colocation add [master|slave] with [master|slave] ++ colocation add [] with [] + [score] [options] [id=constraint-id] + Request to run on the same node where pacemaker has + determined should run. Positive values of score +@@ -1208,8 +1208,8 @@ Commands: + mean the resources should not be run on the same node. Specifying + 'INFINITY' (or '-INFINITY') for the score forces to + run (or not run) with (score defaults to "INFINITY"). +- A role can be master or slave (if no role is specified, it defaults to +- 'started'). ++ A role can be: 'Master', 'Slave', 'Started', 'Stopped' (if no role is ++ specified, it defaults to 'Started'). + + colocation set [resourceN]... [options] + [set ... [options]] +-- +2.21.0 + diff --git a/SOURCES/bz1671174-01-specify-full-path-when-running-external-tools.patch b/SOURCES/bz1671174-01-specify-full-path-when-running-external-tools.patch new file mode 100644 index 0000000..d83e867 --- /dev/null +++ b/SOURCES/bz1671174-01-specify-full-path-when-running-external-tools.patch @@ -0,0 +1,39 @@ +From e25955d8e2de7378585b4b348f9c10049a878c5e Mon Sep 17 00:00:00 2001 +From: Ivan Devat +Date: Mon, 4 Nov 2019 15:30:42 +0100 +Subject: [PATCH 4/4] specify full path when running external tools + +--- + pcsd/bootstrap.rb | 2 +- + pcsd/pcs.rb | 2 +- + 2 files changed, 2 insertions(+), 2 deletions(-) + +diff --git a/pcsd/bootstrap.rb b/pcsd/bootstrap.rb +index c932a0cc..e79b0d92 100644 +--- a/pcsd/bootstrap.rb ++++ b/pcsd/bootstrap.rb +@@ -63,7 +63,7 @@ end + COROSYNC_QUORUMTOOL = COROSYNC_BINARIES + "corosync-quorumtool" + + if not defined? $cur_node_name +- $cur_node_name = `hostname`.chomp ++ $cur_node_name = `/bin/hostname`.chomp + end + + def configure_logger(log_device) +diff --git a/pcsd/pcs.rb b/pcsd/pcs.rb +index ad153f62..12eb3eb1 100644 +--- a/pcsd/pcs.rb ++++ b/pcsd/pcs.rb +@@ -1849,7 +1849,7 @@ def cluster_status_from_nodes(auth_user, cluster_nodes, cluster_name) + end + + def get_node_uptime() +- uptime = `cat /proc/uptime`.chomp.split(' ')[0].split('.')[0].to_i ++ uptime = `/bin/cat /proc/uptime`.chomp.split(' ')[0].split('.')[0].to_i + mm, ss = uptime.divmod(60) + hh, mm = mm.divmod(60) + dd, hh = hh.divmod(24) +-- +2.21.0 + diff --git a/SOURCES/bz1734687-01-pcs-resource-bundle-reset-fails-if.patch b/SOURCES/bz1734687-01-pcs-resource-bundle-reset-fails-if.patch deleted file mode 100644 index 9bd7e35..0000000 --- a/SOURCES/bz1734687-01-pcs-resource-bundle-reset-fails-if.patch +++ /dev/null @@ -1,955 +0,0 @@ -From 833d54bec5e3ee6e49f654b4afdf053ac583062a Mon Sep 17 00:00:00 2001 -From: Ivan Devat -Date: Thu, 20 Jun 2019 11:44:46 +0200 -Subject: [PATCH] bz1734687-01-pcs-resource-bundle-reset-fails-if - ---- - pcs/cli/common/console_report.py | 15 ++ - pcs/cli/common/test/test_console_report.py | 14 +- - pcs/cli/resource/parse_args.py | 21 ++ - pcs/common/report_codes.py | 1 + - pcs/lib/cib/resource/bundle.py | 197 +++++++-------- - pcs/lib/commands/resource.py | 27 ++- - .../test/resource/test_bundle_reset.py | 226 ++++++++++++++++-- - pcs/lib/reports.py | 10 + - pcs/lib/xml_tools.py | 15 ++ - pcs/pcs.8 | 2 +- - pcs/resource.py | 29 ++- - pcs/test/cib_resource/test_bundle.py | 2 +- - pcs/usage.py | 2 +- - 13 files changed, 409 insertions(+), 152 deletions(-) - -diff --git a/pcs/cli/common/console_report.py b/pcs/cli/common/console_report.py -index 945b83f6..3b882e3c 100644 ---- a/pcs/cli/common/console_report.py -+++ b/pcs/cli/common/console_report.py -@@ -67,6 +67,11 @@ def format_fencing_level_target(target_type, target_value): - return "{0}={1}".format(target_value[0], target_value[1]) - return target_value - -+def format_list(a_list): -+ return ", ".join([ -+ "'{0}'".format(x) for x in sorted(a_list) -+ ]) -+ - def format_file_role(role): - return _file_role_translation.get(role, role) - -@@ -1458,6 +1463,16 @@ CODE_TO_MESSAGE_BUILDER_MAP = { - codes.SYSTEM_WILL_RESET: - "System will reset shortly" - , -+ codes.RESOURCE_BUNDLE_UNSUPPORTED_CONTAINER_TYPE: lambda info: -+ ( -+ "Bundle '{bundle_id}' uses unsupported container type, therefore " -+ "it is not possible to set its container options. Supported " -+ "container types are: {_container_types}" -+ ).format( -+ _container_types=format_list(info["supported_container_types"]), -+ **info -+ ) -+ , - codes.RESOURCE_IN_BUNDLE_NOT_ACCESSIBLE: lambda info: - ( - "Resource '{inner_resource_id}' will not be accessible by the " -diff --git a/pcs/cli/common/test/test_console_report.py b/pcs/cli/common/test/test_console_report.py -index ba7b4dbe..83d2b667 100644 ---- a/pcs/cli/common/test/test_console_report.py -+++ b/pcs/cli/common/test/test_console_report.py -@@ -2133,7 +2133,6 @@ class SbdWatchdogNotSupported(NameBuildTest): - } - ) - -- - class SbdWatchdogTestError(NameBuildTest): - code = codes.SBD_WATCHDOG_TEST_ERROR - def test_success(self): -@@ -2144,6 +2143,19 @@ class SbdWatchdogTestError(NameBuildTest): - } - ) - -+class ResourceBundleUnsupportedContainerType(NameBuildTest): -+ code = codes.RESOURCE_BUNDLE_UNSUPPORTED_CONTAINER_TYPE -+ def test_success(self): -+ self.assert_message_from_report( -+ ( -+ "Bundle 'bundle id' uses unsupported container type, therefore " -+ "it is not possible to set its container options. Supported " -+ "container types are: 'a', 'b', 'c'" -+ ), -+ reports.resource_bundle_unsupported_container_type( -+ "bundle id", ["b", "a", "c"] -+ ), -+ ) - - class ResourceInBundleNotAccessible(NameBuildTest): - code = codes.RESOURCE_IN_BUNDLE_NOT_ACCESSIBLE -diff --git a/pcs/cli/resource/parse_args.py b/pcs/cli/resource/parse_args.py -index 122a8f43..ea3db9ca 100644 ---- a/pcs/cli/resource/parse_args.py -+++ b/pcs/cli/resource/parse_args.py -@@ -102,6 +102,27 @@ def parse_bundle_create_options(arg_list): - } - return parts - -+def parse_bundle_reset_options(arg_list): -+ """ -+ Commandline options: no options -+ """ -+ groups = _parse_bundle_groups(arg_list) -+ container_options = groups.get("container", []) -+ parts = { -+ "container": prepare_options(container_options), -+ "network": prepare_options(groups.get("network", [])), -+ "port_map": [ -+ prepare_options(port_map) -+ for port_map in groups.get("port-map", []) -+ ], -+ "storage_map": [ -+ prepare_options(storage_map) -+ for storage_map in groups.get("storage-map", []) -+ ], -+ "meta": prepare_options(groups.get("meta", [])) -+ } -+ return parts -+ - def _split_bundle_map_update_op_and_options( - map_arg_list, result_parts, map_name - ): -diff --git a/pcs/common/report_codes.py b/pcs/common/report_codes.py -index f304d531..42825846 100644 ---- a/pcs/common/report_codes.py -+++ b/pcs/common/report_codes.py -@@ -190,6 +190,7 @@ QDEVICE_USED_BY_CLUSTERS = "QDEVICE_USED_BY_CLUSTERS" - REQUIRED_OPTION_IS_MISSING = "REQUIRED_OPTION_IS_MISSING" - REQUIRED_OPTION_OF_ALTERNATIVES_IS_MISSING = "REQUIRED_OPTION_OF_ALTERNATIVES_IS_MISSING" - RESOURCE_BUNDLE_ALREADY_CONTAINS_A_RESOURCE = "RESOURCE_BUNDLE_ALREADY_CONTAINS_A_RESOURCE" -+RESOURCE_BUNDLE_UNSUPPORTED_CONTAINER_TYPE = "RESOURCE_BUNDLE_UNSUPPORTED_CONTAINER_TYPE" - RESOURCE_IN_BUNDLE_NOT_ACCESSIBLE = "RESOURCE_IN_BUNDLE_NOT_ACCESSIBLE" - RESOURCE_CLEANUP_ERROR = "RESOURCE_CLEANUP_ERROR" - RESOURCE_DOES_NOT_RUN = "RESOURCE_DOES_NOT_RUN" -diff --git a/pcs/lib/cib/resource/bundle.py b/pcs/lib/cib/resource/bundle.py -index 349ca72c..31a359c0 100644 ---- a/pcs/lib/cib/resource/bundle.py -+++ b/pcs/lib/cib/resource/bundle.py -@@ -20,6 +20,7 @@ from pcs.lib.pacemaker.values import sanitize_id - from pcs.lib.xml_tools import ( - get_sub_element, - update_attributes_remove_empty, -+ reset_element, - ) - - TAG = "bundle" -@@ -84,15 +85,13 @@ def validate_new( - ] - ) - + -- validate_reset( -- id_provider, -- container_type, -- container_options, -- network_options, -- port_map, -- storage_map, -- force_options -- ) -+ _validate_container(container_type, container_options, force_options) -+ + -+ _validate_network_options_new(network_options, force_options) -+ + -+ _validate_port_map_list(port_map, id_provider, force_options) -+ + -+ _validate_storage_map_list(storage_map, id_provider, force_options) - ) - - def append_new( -@@ -129,14 +128,14 @@ def append_new( - return bundle_element - - def validate_reset( -- id_provider, container_type, container_options, network_options, -- port_map, storage_map, force_options=False -+ id_provider, bundle_el, container_options, network_options, port_map, -+ storage_map, force_options=False - ): - """ - Validate bundle parameters, return list of report items - - IdProvider id_provider -- elements' ids generator and uniqueness checker -- string container_type -- bundle container type -+ etree bundle_el -- the bundle to be reset - dict container_options -- container options - dict network_options -- network options - list of dict port_map -- list of port mapping options -@@ -144,7 +143,7 @@ def validate_reset( - bool force_options -- return warnings instead of forceable errors - """ - return ( -- _validate_container(container_type, container_options, force_options) -+ _validate_container_reset(bundle_el, container_options, force_options) - + - _validate_network_options_new(network_options, force_options) - + -@@ -153,71 +152,40 @@ def validate_reset( - _validate_storage_map_list(storage_map, id_provider, force_options) - ) - --def reset( -- bundle_element, id_provider, bundle_id, container_type, container_options, -- network_options, port_map, storage_map, meta_attributes --): -+def validate_reset_to_minimal(bundle_element): - """ -- Remove configuration of bundle_element and create new one. -+ Validate removing configuration of bundle_element and keep the minimal one. - - etree bundle_element -- the bundle element that will be reset -- IdProvider id_provider -- elements' ids generator -- string bundle_id -- id of the bundle -- string container_type -- bundle container type -- dict container_options -- container options -- dict network_options -- network options -- list of dict port_map -- list of port mapping options -- list of dict storage_map -- list of storage mapping options -- dict meta_attributes -- meta attributes - """ -- # pylint: disable=too-many-arguments -+ if not _is_supported_container(_get_container_element(bundle_element)): -+ return [_get_report_unsupported_container(bundle_element)] -+ return [] - -- # Old bundle configuration is removed and re-created. We aren't trying -- # to keep ids: -- # * It doesn't make sense to reference these ids. -- # * Newly created ids are based on (are prefixed by) the bundle element id, -- # which does not change. Therefore, it is VERY HIGHLY probable the newly -- # created ids will be the same as the original ones. -- elements_without_reset_impact = [] -+def reset_to_minimal(bundle_element): -+ """ -+ Remove configuration of bundle_element and keep the minimal one. - -+ etree bundle_element -- the bundle element that will be reset -+ """ - # Elements network, storage and meta_attributes must be kept even if they - # are without children. - # See https://bugzilla.redhat.com/show_bug.cgi?id=1642514 -- # -- # The only scenario that makes sense is that these elements are empty -- # and no attributes or children are requested for them. So we collect only -- # deleted tags and we will ensure creation minimal relevant elements at -- # least. -- indelible_tags = [] -- for child in list(bundle_element): -- if child.tag in ["network", "storage", META_ATTRIBUTES_TAG]: -- indelible_tags.append(child.tag) -- elif child.tag != "docker": -- # Only primitive should be found here, currently. -- # The order of various element tags has no practical impact so we -- # don't care about it here. -- elements_without_reset_impact.append(child) -- bundle_element.remove(child) -+ # Element of container type is required. - -- _append_container(bundle_element, container_type, container_options) -- if network_options or port_map or "network" in indelible_tags: -- _append_network( -- bundle_element, -- id_provider, -- bundle_id, -- network_options, -- port_map, -- ) -- if storage_map or "storage" in indelible_tags: -- _append_storage(bundle_element, id_provider, bundle_id, storage_map) -- if meta_attributes or META_ATTRIBUTES_TAG in indelible_tags: -- append_new_meta_attributes( -- bundle_element, -- meta_attributes, -- id_provider, -- ) -- for element in elements_without_reset_impact: -- bundle_element.append(element) -+ # There can be other elements beside bundle configuration (e.g. primitive). -+ # These elements stay untouched. -+ # Like any function that manipulates with cib, this also assumes prior -+ # validation that container is supported. -+ for child in list(bundle_element): -+ if child.tag in ["network", "storage"]: -+ reset_element(child) -+ if child.tag == META_ATTRIBUTES_TAG: -+ reset_element(child, keep_attrs=["id"]) -+ if child.tag == "docker": -+ # docker elements requires the "image" attribute to -+ # be set. -+ reset_element(child, keep_attrs=["image"]) - - def validate_update( - id_provider, bundle_el, container_options, network_options, -@@ -237,55 +205,26 @@ def validate_update( - list of string storage_map_remove -- list of storage mapping ids to remove - bool force_options -- return warnings instead of forceable errors - """ -- report_list = [] -- -- container_el = _get_container_element(bundle_el) -- if container_el is not None and container_el.tag == "docker": -- # TODO call the proper function once more container types are -- # supported by pacemaker -- report_list.extend( -- _validate_container_docker_options_update( -- container_el, -- container_options, -- force_options -- ) -- ) -- -- network_el = bundle_el.find("network") -- if network_el is None: -- report_list.extend( -- _validate_network_options_new(network_options, force_options) -- ) -- else: -- report_list.extend( -- _validate_network_options_update( -- bundle_el, -- network_el, -- network_options, -- force_options -- ) -- ) -- - # TODO It will probably be needed to split the following validators to - # create and update variants. It should be done once the need exists and - # not sooner. -- report_list.extend( -+ return ( -+ _validate_container_update(bundle_el, container_options, force_options) -+ + -+ _validate_network_update(bundle_el, network_options, force_options) -+ + - _validate_port_map_list(port_map_add, id_provider, force_options) -- ) -- report_list.extend( -+ + - _validate_storage_map_list(storage_map_add, id_provider, force_options) -- ) -- report_list.extend( -+ + - _validate_map_ids_exist( - bundle_el, "port-mapping", "port-map", port_map_remove - ) -- ) -- report_list.extend( -+ + - _validate_map_ids_exist( - bundle_el, "storage-mapping", "storage-map", storage_map_remove - ) - ) -- return report_list - - def update( - id_provider, bundle_el, container_options, network_options, -@@ -402,6 +341,19 @@ def get_inner_resource(bundle_el): - return resources[0] - return None - -+def _is_supported_container(container_el): -+ return ( -+ container_el is not None -+ and -+ container_el.tag == "docker" -+ ) -+ -+def _get_report_unsupported_container(bundle_el): -+ return reports.resource_bundle_unsupported_container_type( -+ bundle_el.get("id"), -+ ["docker"], -+ ) -+ - def _validate_container(container_type, container_options, force_options=False): - if container_type != "docker": - return [ -@@ -411,7 +363,10 @@ def _validate_container(container_type, container_options, force_options=False): - ["docker"], - ) - ] -+ return _validate_container_options(container_options, force_options) -+ - -+def _validate_container_options(container_options, force_options=False): - validators = [ - validate.is_required("image", "container"), - validate.value_not_empty("image", "image name"), -@@ -434,6 +389,30 @@ def _validate_container(container_type, container_options, force_options=False): - ) - ) - -+def _validate_container_reset(bundle_el, container_options, force_options): -+ # Unlike in the case of update, in reset empty options are not necessary -+ # valid - user MUST set everything (including required options e.g. image). -+ if ( -+ container_options -+ and -+ not _is_supported_container(_get_container_element(bundle_el)) -+ ): -+ return [_get_report_unsupported_container(bundle_el)] -+ return _validate_container_options(container_options, force_options) -+ -+def _validate_container_update(bundle_el, options, force_options): -+ # Validate container options only if they are being updated. Empty options -+ # are valid - user DOESN'T NEED to change anything. -+ if not options: -+ return [] -+ -+ container_el = _get_container_element(bundle_el) -+ if not _is_supported_container(container_el): -+ return [_get_report_unsupported_container(bundle_el)] -+ return _validate_container_docker_options_update( -+ container_el, options, force_options -+ ) -+ - def _validate_container_docker_options_update( - docker_el, options, force_options - ): -@@ -502,6 +481,14 @@ def _is_pcmk_remote_acccessible_after_update(network_el, options): - - return not (case1 or case2 or case3) - -+def _validate_network_update(bundle_el, options, force_options): -+ network_el = bundle_el.find("network") -+ if network_el is None: -+ return _validate_network_options_new(options, force_options) -+ return _validate_network_options_update( -+ bundle_el, network_el, options, force_options -+ ) -+ - def _validate_network_options_update( - bundle_el, network_el, options, force_options - ): -diff --git a/pcs/lib/commands/resource.py b/pcs/lib/commands/resource.py -index de5cfb4e..f34fef4b 100644 ---- a/pcs/lib/commands/resource.py -+++ b/pcs/lib/commands/resource.py -@@ -580,7 +580,7 @@ def bundle_create( - resource.common.disable(bundle_element) - - def bundle_reset( -- env, bundle_id, container_type, container_options=None, -+ env, bundle_id, container_options=None, - network_options=None, port_map=None, storage_map=None, meta_attributes=None, - force_options=False, - ensure_disabled=False, -@@ -592,7 +592,6 @@ def bundle_reset( - - LibraryEnvironment env -- provides communication with externals - string bundle_id -- id of the bundle to reset -- string container_type -- container engine name (docker, lxc...) - dict container_options -- container options - dict network_options -- network options - list of dict port_map -- a list of port mapping options -@@ -619,11 +618,17 @@ def bundle_reset( - ), - required_cib_version=Version(2, 8, 0), - ) as resources_section: -+ bundle_element = _find_bundle(resources_section, bundle_id) -+ env.report_processor.process_list( -+ resource.bundle.validate_reset_to_minimal(bundle_element) -+ ) -+ resource.bundle.reset_to_minimal(bundle_element) -+ - id_provider = IdProvider(resources_section) - env.report_processor.process_list( - resource.bundle.validate_reset( - id_provider, -- container_type, -+ bundle_element, - container_options, - network_options, - port_map, -@@ -633,23 +638,21 @@ def bundle_reset( - ) - ) - -- bundle_element = _find_bundle(resources_section, bundle_id) -- resource.bundle.reset( -- bundle_element, -+ resource.bundle.update( - id_provider, -- bundle_id, -- container_type, -+ bundle_element, - container_options, - network_options, -- port_map, -- storage_map, -- meta_attributes, -+ port_map_add=port_map, -+ port_map_remove=[], -+ storage_map_add=storage_map, -+ storage_map_remove=[], -+ meta_attributes=meta_attributes, - ) - - if ensure_disabled: - resource.common.disable(bundle_element) - -- - def bundle_update( - env, bundle_id, container_options=None, network_options=None, - port_map_add=None, port_map_remove=None, storage_map_add=None, -diff --git a/pcs/lib/commands/test/resource/test_bundle_reset.py b/pcs/lib/commands/test/resource/test_bundle_reset.py -index 8fbeae78..bdea4b39 100644 ---- a/pcs/lib/commands/test/resource/test_bundle_reset.py -+++ b/pcs/lib/commands/test/resource/test_bundle_reset.py -@@ -15,6 +15,7 @@ from pcs.lib.commands.test.resource.bundle_common import( - WaitMixin, - ) - from pcs.lib.errors import ReportItemSeverity as severities -+from pcs.test.tools import fixture - - class BaseMixin(FixturesMixin): - bundle_id = "B1" -@@ -24,16 +25,13 @@ class BaseMixin(FixturesMixin): - def initial_resources(self): - return self.fixture_resources_bundle_simple - -- def bundle_reset( -- self, bundle_id=None, container_type=None, **params -- ): -+ def bundle_reset(self, bundle_id=None, **params): - if "container_options" not in params: - params["container_options"] = {"image": self.image} - - bundle_reset( - self.env_assist.get_env(), - bundle_id=bundle_id or self.bundle_id, -- container_type=container_type or self.container_type, - **params - ) - -@@ -44,6 +42,8 @@ class Minimal(BaseMixin, SetUpMixin, TestCase): - container_type = "docker" - - def test_success_zero_change(self): -+ # Resets a bundle with only an image set to a bundle with the same -+ # image set and no other options. - self.config.env.push_cib(resources=self.initial_resources) - self.bundle_reset() - -@@ -87,6 +87,18 @@ class Minimal(BaseMixin, SetUpMixin, TestCase): - expected_in_processor=False, - ) - -+ def test_no_options_set(self): -+ self.env_assist.assert_raise_library_error( -+ lambda: bundle_reset(self.env_assist.get_env(), self.bundle_id), -+ [ -+ fixture.error( -+ report_codes.REQUIRED_OPTION_IS_MISSING, -+ option_names=["image"], -+ option_type="container", -+ ), -+ ] -+ ) -+ - class Full(BaseMixin, SetUpMixin, TestCase): - container_type = "docker" - fixture_primitive = """ -@@ -98,24 +110,11 @@ class Full(BaseMixin, SetUpMixin, TestCase): - return """ - - -- -- -- - <{container_type} - image="{image}" -- replicas="0" -- replicas-per-host="0" -+ replicas="1" -+ replicas-per-host="1" - /> -- -- -- - - -+ -+ -+ - {fixture_primitive} - - -@@ -211,8 +217,8 @@ class Full(BaseMixin, SetUpMixin, TestCase): - - - -@@ -251,14 +257,93 @@ class Full(BaseMixin, SetUpMixin, TestCase): - storage_map=[ - { - "options": "extra options 2", -- "source-dir": "/tmp/{0}2a".format(self.container_type), -- "target-dir": "/tmp/{0}2b".format(self.container_type), -+ "source-dir": "/tmp/{0}2aa".format(self.container_type), -+ "target-dir": "/tmp/{0}2bb".format(self.container_type), - }, - ], - meta_attributes={ - "target-role": "Started", - } - ) -+ -+ def test_success_keep_map_ids(self): -+ self.config.env.push_cib(replace={ -+ ".//resources/bundle/network": -+ """ -+ -+ -+ -+ -+ """.format(bundle_id=self.bundle_id, ) -+ , -+ ".//resources/bundle/storage": -+ """ -+ -+ -+ -+ """.format(bundle_id=self.bundle_id) -+ , -+ }) -+ -+ # Every value is kept as before except port_map and storage_map. -+ self.bundle_reset( -+ container_options={ -+ "image": self.image, -+ "replicas": "1", -+ "replicas-per-host": "1", -+ }, -+ network_options={ -+ "control-port": "12345", -+ "host-interface": "eth0", -+ "host-netmask": "24", -+ "ip-range-start": "192.168.100.200", -+ }, -+ port_map=[ -+ { -+ "id": "{bundle_id}-port-map-1001" -+ .format(bundle_id=self.bundle_id) -+ , -+ "internal-port": "3002", -+ "port": "3000", -+ }, -+ { -+ "id": "{bundle_id}-port-map-3000-3300" -+ .format(bundle_id=self.bundle_id) -+ , -+ "range": "4000-4400", -+ }, -+ ], -+ storage_map=[ -+ { -+ "id": "{bundle_id}-storage-map" -+ .format(bundle_id=self.bundle_id) -+ , -+ "options": "extra options 2", -+ "source-dir": "/tmp/docker/2aa", -+ "target-dir": "/tmp/docker/2bb", -+ }, -+ ], -+ meta_attributes={ -+ "target-role": "Stopped", -+ } -+ ) - class Parametrized( - BaseMixin, ParametrizedContainerMixin, UpgradeMixin, TestCase - ): -@@ -275,9 +360,104 @@ class ResetWithStorageMap(BaseMixin, StorageMapMixin, TestCase): - - class ResetWithMetaMap(BaseMixin, MetaMixin, TestCase): - container_type = "docker" -+ def test_success(self): -+ # When there is no meta attributes the new one are put on the first -+ # possition (since reset now uses update internally). This is the reason -+ # for overriding of this MetaMixin test. -+ self.config.env.push_cib( -+ resources=""" -+ -+ -+ -+ -+ -+ -+ <{container_type} image="{image}" /> -+ -+ -+ """ -+ .format( -+ container_type=self.container_type, -+ bundle_id=self.bundle_id, -+ image=self.image, -+ ) -+ ) -+ self.run_bundle_cmd( -+ meta_attributes={ -+ "target-role": "Stopped", -+ "is-managed": "false", -+ } -+ ) - - class ResetWithAllOptions(BaseMixin, AllOptionsMixin, TestCase): - container_type = "docker" - - class ResetWithWait(BaseMixin, WaitMixin, TestCase): - container_type = "docker" -+ -+class ResetUnknownContainerType(BaseMixin, SetUpMixin, TestCase): -+ container_type = "unknown" -+ def test_error_or_unknown_container(self): -+ self.env_assist.assert_raise_library_error( -+ lambda: bundle_reset(self.env_assist.get_env(), self.bundle_id), -+ [ -+ fixture.error( -+ report_codes.RESOURCE_BUNDLE_UNSUPPORTED_CONTAINER_TYPE, -+ bundle_id="B1", -+ supported_container_types=["docker"], -+ ), -+ ] -+ ) -+ -+class NoMetaIdRegenerationDocker(BaseMixin, SetUpMixin, TestCase): -+ container_type = "docker" -+ @property -+ def initial_resources(self): -+ return """ -+ -+ -+ -+ -+ -+ -+ -+ -+ """.format( -+ container_type=self.container_type, -+ bundle_id=self.bundle_id, -+ image=self.image, -+ ) -+ def test_dont_regenerate_meta_attributes_id(self): -+ self.config.env.push_cib(replace={ -+ ".//resources/bundle/meta_attributes": -+ """ -+ -+ -+ -+ """ -+ , -+ }) -+ self.bundle_reset( -+ container_options={ -+ "image": self.image, -+ "replicas": "1", -+ "replicas-per-host": "1", -+ }, -+ meta_attributes={ -+ "target-role": "Stopped", -+ } -+ ) -diff --git a/pcs/lib/reports.py b/pcs/lib/reports.py -index 92764551..045e8eca 100644 ---- a/pcs/lib/reports.py -+++ b/pcs/lib/reports.py -@@ -2947,6 +2947,16 @@ def system_will_reset(): - report_codes.SYSTEM_WILL_RESET, - ) - -+def resource_bundle_unsupported_container_type( -+ bundle_id, supported_container_types -+): -+ return ReportItem.error( -+ report_codes.RESOURCE_BUNDLE_UNSUPPORTED_CONTAINER_TYPE, -+ info=dict( -+ bundle_id=bundle_id, -+ supported_container_types=sorted(supported_container_types), -+ ), -+ ) - - def resource_in_bundle_not_accessible( - bundle_id, inner_resource_id, -diff --git a/pcs/lib/xml_tools.py b/pcs/lib/xml_tools.py -index acd30a71..8d59377c 100644 ---- a/pcs/lib/xml_tools.py -+++ b/pcs/lib/xml_tools.py -@@ -129,3 +129,18 @@ def remove_when_pointless(element, attribs_important=True): - - if not is_element_useful: - element.getparent().remove(element) -+ -+def reset_element(element, keep_attrs=None): -+ """ -+ Remove all subelements and all attributes (except mentioned in keep_attrs) -+ of given element. -+ -+ lxml.etree.element element -- element to reset -+ list keep_attrs -- names of attributes thas should be kept -+ """ -+ keep_attrs = keep_attrs or [] -+ for child in list(element): -+ element.remove(child) -+ for key in element.attrib.keys(): -+ if key not in keep_attrs: -+ del element.attrib[key] -diff --git a/pcs/pcs.8 b/pcs/pcs.8 -index 0ec4359a..5ecb7dab 100644 ---- a/pcs/pcs.8 -+++ b/pcs/pcs.8 -@@ -168,7 +168,7 @@ Configure a resource or group as a multi\-state (master/slave) resource. If \fB - bundle create container [] [network ] [port\-map ]... [storage\-map ]... [meta ] [\fB\-\-disabled\fR] [\fB\-\-wait\fR[=n]] - Create a new bundle encapsulating no resources. The bundle can be used either as it is or a resource may be put into it at any time. If \fB\-\-disabled\fR is specified, the bundle is not started automatically. If \fB\-\-wait\fR is specified, pcs will wait up to 'n' seconds for the bundle to start and then return 0 on success or 1 on error. If 'n' is not specified it defaults to 60 minutes. - .TP --bundle reset container [] [network ] [port\-map ]... [storage\-map ]... [meta ] [\fB\-\-disabled\fR] [\fB\-\-wait\fR[=n]] -+bundle reset [container ] [network ] [port\-map ]... [storage\-map ]... [meta ] [\fB\-\-disabled\fR] [\fB\-\-wait\fR[=n]] - Configure specified bundle with given options. Unlike bundle update, this command resets the bundle according given options - no previous options are kept. Resources inside the bundle are kept as they are. If \fB\-\-disabled\fR is specified, the bundle is not started automatically. If \fB\-\-wait\fR is specified, pcs will wait up to 'n' seconds for the bundle to start and then return 0 on success or 1 on error. If 'n' is not specified it defaults to 60 minutes. - .TP - bundle update [container ] [network ] [port\-map (add ) | (remove ...)]... [storage\-map (add ) | (remove ...)]... [meta ] [\fB\-\-wait\fR[=n]] -diff --git a/pcs/resource.py b/pcs/resource.py -index f615f682..dea30f49 100644 ---- a/pcs/resource.py -+++ b/pcs/resource.py -@@ -24,6 +24,7 @@ from pcs.cli.common.errors import CmdLineInputError - from pcs.cli.common.parse_args import prepare_options - from pcs.cli.resource.parse_args import ( - parse_bundle_create_options, -+ parse_bundle_reset_options, - parse_bundle_update_options, - parse_create as parse_create_args, - ) -@@ -2896,7 +2897,23 @@ def resource_bundle_create_cmd(lib, argv, modifiers): - * --wait - * -f - CIB file - """ -- _resource_bundle_configure(lib.resource.bundle_create, argv, modifiers) -+ if not argv: -+ raise CmdLineInputError() -+ -+ bundle_id = argv[0] -+ parts = parse_bundle_create_options(argv[1:]) -+ lib.resource.bundle_create( -+ bundle_id, -+ parts["container_type"], -+ container_options=parts["container"], -+ network_options=parts["network"], -+ port_map=parts["port_map"], -+ storage_map=parts["storage_map"], -+ meta_attributes=parts["meta"], -+ force_options=modifiers["force"], -+ ensure_disabled=modifiers["disabled"], -+ wait=modifiers["wait"] -+ ) - - def resource_bundle_reset_cmd(lib, argv, modifiers): - """ -@@ -2906,17 +2923,13 @@ def resource_bundle_reset_cmd(lib, argv, modifiers): - * --wait - * -f - CIB file - """ -- _resource_bundle_configure(lib.resource.bundle_reset, argv, modifiers) -- --def _resource_bundle_configure(call_lib, argv, modifiers): -- if len(argv) < 1: -+ if not argv: - raise CmdLineInputError() - - bundle_id = argv[0] -- parts = parse_bundle_create_options(argv[1:]) -- call_lib( -+ parts = parse_bundle_reset_options(argv[1:]) -+ lib.resource.bundle_reset( - bundle_id, -- parts["container_type"], - container_options=parts["container"], - network_options=parts["network"], - port_map=parts["port_map"], -diff --git a/pcs/test/cib_resource/test_bundle.py b/pcs/test/cib_resource/test_bundle.py -index 708de645..d5ce702a 100644 ---- a/pcs/test/cib_resource/test_bundle.py -+++ b/pcs/test/cib_resource/test_bundle.py -@@ -64,7 +64,7 @@ class BundleReset(BundleCreateCommon): - "resource bundle create B2 container docker image=pcs:test" - ) - self.assert_effect( -- "resource bundle reset B1 container docker image=pcs:new", -+ "resource bundle reset B1 container image=pcs:new", - """ - - -diff --git a/pcs/usage.py b/pcs/usage.py -index 80ba9168..4cdfc3ac 100644 ---- a/pcs/usage.py -+++ b/pcs/usage.py -@@ -450,7 +450,7 @@ Commands: - to start and then return 0 on success or 1 on error. If 'n' is not - specified it defaults to 60 minutes. - -- bundle reset container [] -+ bundle reset [container ] - [network ] [port-map ]... - [storage-map ]... [meta ] - [--disabled] [--wait[=n]] --- -2.21.0 - diff --git a/SOURCES/bz1760434-01-do-not-generate-custom-DH-key-unless-requested.patch b/SOURCES/bz1760434-01-do-not-generate-custom-DH-key-unless-requested.patch new file mode 100644 index 0000000..6105bcf --- /dev/null +++ b/SOURCES/bz1760434-01-do-not-generate-custom-DH-key-unless-requested.patch @@ -0,0 +1,50 @@ +From 3254abb3f30a051761eac1c03a236015fcd44cf9 Mon Sep 17 00:00:00 2001 +From: Tomas Jelinek +Date: Thu, 17 Oct 2019 11:52:30 +0200 +Subject: [PATCH 1/5] do not generate custom DH key unless requested + +--- + pcsd/ssl.rb | 17 ++++++++++------- + 1 file changed, 10 insertions(+), 7 deletions(-) + +diff --git a/pcsd/ssl.rb b/pcsd/ssl.rb +index c71aad08..de356e46 100644 +--- a/pcsd/ssl.rb ++++ b/pcsd/ssl.rb +@@ -153,14 +153,15 @@ else + end + end + +-dh_key_bits_default = 1024 +-dh_key_bits = dh_key_bits_default ++dh_key_bits = 0 + if ENV['PCSD_SSL_DH_KEX_BITS'] +- dh_key_bits = Integer(ENV['PCSD_SSL_DH_KEX_BITS']) rescue dh_key_bits_default ++ dh_key_bits = Integer(ENV['PCSD_SSL_DH_KEX_BITS']) rescue 0 ++end ++if dh_key_bits > 0 ++ $logger.info "Generating #{dh_key_bits}bits long DH key..." ++ dh_key = OpenSSL::PKey::DH.generate(dh_key_bits) ++ $logger.info "DH key created" + end +-$logger.info "Generating #{dh_key_bits}bits long DH key..." +-dh_key = OpenSSL::PKey::DH.generate(dh_key_bits) +-$logger.info "DH key created" + + default_bind = true + # see https://github.com/ClusterLabs/pcs/issues/51 +@@ -185,8 +186,10 @@ webrick_options = { + :SSLPrivateKey => OpenSSL::PKey::RSA.new(key), + :SSLCertName => [[ "CN", server_name ]], + :SSLOptions => get_ssl_options(), +- :SSLTmpDhCallback => lambda {|ctx, is_export, keylen| dh_key}, + } ++if dh_key_bits > 0 ++ webrick_options[:SSLTmpDhCallback] = lambda {|ctx, is_export, keylen| dh_key } ++end + + server = ::Rack::Handler::WEBrick + trap(:INT) do +-- +2.21.0 + diff --git a/SOURCES/bz1765606-01-Hiding-Server-Name-HTTP-header-fr.patch b/SOURCES/bz1765606-01-Hiding-Server-Name-HTTP-header-fr.patch new file mode 100644 index 0000000..b7158fa --- /dev/null +++ b/SOURCES/bz1765606-01-Hiding-Server-Name-HTTP-header-fr.patch @@ -0,0 +1,40 @@ +From 9b982f8586d3b5c13139e5aca2291dd4c6d7e628 Mon Sep 17 00:00:00 2001 +From: Ivan Devat +Date: Mon, 4 Nov 2019 15:21:35 +0100 +Subject: [PATCH 2/4] squash bz1765606 Hiding Server Name HTTP header fr + +327dba90 do not send Server HTTP header +985067ab do not send Server HTTP header +--- + pcsd/pcsd.rb | 13 +++++++++++++ + 1 file changed, 13 insertions(+) + +diff --git a/pcsd/pcsd.rb b/pcsd/pcsd.rb +index 3d167b8b..b4bf0494 100644 +--- a/pcsd/pcsd.rb ++++ b/pcsd/pcsd.rb +@@ -38,8 +38,21 @@ class HstsMiddleware + end + end + ++class RemoveServerHeaderMiddleware ++ def initialize(app) ++ @app = app ++ end ++ ++ def call(env) ++ status, headers, body = @app.call(env) ++ headers['Server'] = '' ++ [status, headers, body] ++ end ++end ++ + use Rack::CommonLogger + use HstsMiddleware ++use RemoveServerHeaderMiddleware + + set :app_file, __FILE__ + +-- +2.21.0 + diff --git a/SOURCES/bz1770973-01-The-cluster-should-not-be-allowed-t.patch b/SOURCES/bz1770973-01-The-cluster-should-not-be-allowed-t.patch new file mode 100644 index 0000000..3adcb27 --- /dev/null +++ b/SOURCES/bz1770973-01-The-cluster-should-not-be-allowed-t.patch @@ -0,0 +1,2616 @@ +From c19066d0dade1ae544e8f8d80513310d47e72ebe Mon Sep 17 00:00:00 2001 +From: Tomas Jelinek +Date: Tue, 19 Nov 2019 13:47:02 +0100 +Subject: [PATCH 2/6] squash bz1770973 The cluster should not be allowed to + disable a resource if dependent resources are still online + +add --simulate and --safe to resource disable + +Also add command 'resource safe-disable'. This is an alias for 'resource +disable --safe'. + +fix safe-disabling clones, groups, bundles + +fix simulate_cib_error report + +Putting only one CIB in the report is not enough info. Both original and +changed CIB as well as crm_simulate output would be needed. All that +info can be seen in debug messages. So there is no need to put it in the +report. +--- + pcs/cli/common/console_report.py | 15 + + pcs/cli/common/lib_wrapper.py | 2 + + pcs/cli/common/parse_args.py | 1 + + pcs/cli/common/test/test_console_report.py | 31 + + pcs/common/report_codes.py | 2 + + pcs/lib/cib/resource/common.py | 16 + + pcs/lib/cib/test/test_resource_common.py | 60 +- + pcs/lib/commands/resource.py | 101 +++ + .../resource/test_resource_enable_disable.py | 819 +++++++++++++++++- + pcs/lib/pacemaker/live.py | 66 +- + pcs/lib/pacemaker/simulate.py | 86 ++ + pcs/lib/pacemaker/test/test_live.py | 191 +++- + pcs/lib/pacemaker/test/test_simulate.py | 380 ++++++++ + pcs/lib/reports.py | 35 + + pcs/lib/tools.py | 5 +- + pcs/pcs.8 | 23 +- + pcs/resource.py | 52 +- + pcs/test/test_resource.py | 195 +++++ + .../tools/command_env/config_runner_pcmk.py | 55 +- + pcs/test/tools/command_env/mock_runner.py | 17 + + pcs/usage.py | 36 +- + pcs/utils.py | 3 + + pcsd/capabilities.xml | 14 + + 23 files changed, 2185 insertions(+), 20 deletions(-) + create mode 100644 pcs/lib/pacemaker/simulate.py + create mode 100644 pcs/lib/pacemaker/test/test_simulate.py + +diff --git a/pcs/cli/common/console_report.py b/pcs/cli/common/console_report.py +index 1824bd9a..b5885b6b 100644 +--- a/pcs/cli/common/console_report.py ++++ b/pcs/cli/common/console_report.py +@@ -815,6 +815,14 @@ CODE_TO_MESSAGE_BUILDER_MAP = { + .format(**info) + , + ++ codes.CIB_SIMULATE_ERROR: lambda info: ++ "Unable to simulate changes in CIB{_reason}" ++ .format( ++ _reason=format_optional(info["reason"], ": {0}"), ++ **info ++ ) ++ , ++ + codes.CIB_PUSH_FORCED_FULL_DUE_TO_CRM_FEATURE_SET: lambda info: + ( + "Replacing the whole CIB instead of applying a diff, a race " +@@ -1516,4 +1524,11 @@ CODE_TO_MESSAGE_BUILDER_MAP = { + codes.FENCE_HISTORY_NOT_SUPPORTED: + "Fence history is not supported, please upgrade pacemaker" + , ++ ++ codes.RESOURCE_DISABLE_AFFECTS_OTHER_RESOURCES: lambda info: ++ ( ++ "Disabling specified resources would have an effect on other " ++ "resources\n\n{crm_simulate_plaintext_output}" ++ ).format(**info) ++ , + } +diff --git a/pcs/cli/common/lib_wrapper.py b/pcs/cli/common/lib_wrapper.py +index a6cc0ca8..dffbace2 100644 +--- a/pcs/cli/common/lib_wrapper.py ++++ b/pcs/cli/common/lib_wrapper.py +@@ -344,6 +344,8 @@ def load_module(env, middleware_factory, name): + "create_in_group": resource.create_in_group, + "create_into_bundle": resource.create_into_bundle, + "disable": resource.disable, ++ "disable_safe": resource.disable_safe, ++ "disable_simulate": resource.disable_simulate, + "enable": resource.enable, + "get_failcounts": resource.get_failcounts, + "manage": resource.manage, +diff --git a/pcs/cli/common/parse_args.py b/pcs/cli/common/parse_args.py +index 23d8799d..1f3e7b33 100644 +--- a/pcs/cli/common/parse_args.py ++++ b/pcs/cli/common/parse_args.py +@@ -18,6 +18,7 @@ PCS_LONG_OPTIONS = [ + "force", "skip-offline", "autocorrect", "interactive", "autodelete", + "all", "full", "groups", "local", "wait", "config", "async", + "start", "enable", "disabled", "off", "request-timeout=", ++ "safe", "no-strict", "simulate", + "pacemaker", "corosync", + "no-default-ops", "defaults", "nodesc", + "clone", "master", "name=", "group=", "node=", +diff --git a/pcs/cli/common/test/test_console_report.py b/pcs/cli/common/test/test_console_report.py +index 95849615..59192dc2 100644 +--- a/pcs/cli/common/test/test_console_report.py ++++ b/pcs/cli/common/test/test_console_report.py +@@ -1934,6 +1934,21 @@ class CibDiffError(NameBuildTest): + ) + + ++class CibSimulateError(NameBuildTest): ++ code = codes.CIB_SIMULATE_ERROR ++ def test_success(self): ++ self.assert_message_from_report( ++ "Unable to simulate changes in CIB: error message", ++ reports.cib_simulate_error("error message") ++ ) ++ ++ def test_empty_reason(self): ++ self.assert_message_from_report( ++ "Unable to simulate changes in CIB", ++ reports.cib_simulate_error("") ++ ) ++ ++ + class TmpFileWrite(NameBuildTest): + code = codes.TMP_FILE_WRITE + def test_success(self): +@@ -2401,3 +2416,19 @@ class FenceHistoryNotSupported(NameBuildTest): + "Fence history is not supported, please upgrade pacemaker", + reports.fence_history_not_supported() + ) ++ ++ ++class ResourceDisableAffectsOtherResources(NameBuildTest): ++ code = codes.RESOURCE_DISABLE_AFFECTS_OTHER_RESOURCES ++ def test_success(self): ++ self.assert_message_from_report( ++ ( ++ "Disabling specified resources would have an effect on other " ++ "resources\n\ncrm_simulate output" ++ ), ++ reports.resource_disable_affects_other_resources( ++ ["D2", "D1"], ++ ["O2", "O1"], ++ "crm_simulate output", ++ ) ++ ) +diff --git a/pcs/common/report_codes.py b/pcs/common/report_codes.py +index 68b48215..8340ab62 100644 +--- a/pcs/common/report_codes.py ++++ b/pcs/common/report_codes.py +@@ -68,6 +68,7 @@ CIB_LOAD_ERROR_SCOPE_MISSING = "CIB_LOAD_ERROR_SCOPE_MISSING" + CIB_PUSH_FORCED_FULL_DUE_TO_CRM_FEATURE_SET = "CIB_PUSH_FORCED_FULL_DUE_TO_CRM_FEATURE_SET" + CIB_PUSH_ERROR = "CIB_PUSH_ERROR" + CIB_SAVE_TMP_ERROR = "CIB_SAVE_TMP_ERROR" ++CIB_SIMULATE_ERROR = "CIB_SIMULATE_ERROR" + CIB_UPGRADE_FAILED = "CIB_UPGRADE_FAILED" + CIB_UPGRADE_FAILED_TO_MINIMAL_REQUIRED_VERSION = "CIB_UPGRADE_FAILED_TO_MINIMAL_REQUIRED_VERSION" + CIB_UPGRADE_SUCCESSFUL = "CIB_UPGRADE_SUCCESSFUL" +@@ -185,6 +186,7 @@ RESOURCE_BUNDLE_ALREADY_CONTAINS_A_RESOURCE = "RESOURCE_BUNDLE_ALREADY_CONTAINS_ + RESOURCE_BUNDLE_UNSUPPORTED_CONTAINER_TYPE = "RESOURCE_BUNDLE_UNSUPPORTED_CONTAINER_TYPE" + RESOURCE_IN_BUNDLE_NOT_ACCESSIBLE = "RESOURCE_IN_BUNDLE_NOT_ACCESSIBLE" + RESOURCE_CLEANUP_ERROR = "RESOURCE_CLEANUP_ERROR" ++RESOURCE_DISABLE_AFFECTS_OTHER_RESOURCES = "RESOURCE_DISABLE_AFFECTS_OTHER_RESOURCES" + RESOURCE_DOES_NOT_RUN = "RESOURCE_DOES_NOT_RUN" + RESOURCE_FOR_CONSTRAINT_IS_MULTIINSTANCE = 'RESOURCE_FOR_CONSTRAINT_IS_MULTIINSTANCE' + RESOURCE_IS_GUEST_NODE_ALREADY = "RESOURCE_IS_GUEST_NODE_ALREADY" +diff --git a/pcs/lib/cib/resource/common.py b/pcs/lib/cib/resource/common.py +index 87b656b7..f408c2c4 100644 +--- a/pcs/lib/cib/resource/common.py ++++ b/pcs/lib/cib/resource/common.py +@@ -51,6 +51,22 @@ def find_primitives(resource_el): + return [resource_el] + return [] + ++def get_all_inner_resources(resource_el): ++ """ ++ Return all inner resources (both direct and indirect) of a resource ++ Example: for a clone containing a group, this function will return both ++ the group and the resources inside the group ++ ++ resource_el -- resource element to get its inner resources ++ """ ++ all_inner = set() ++ to_process = set([resource_el]) ++ while to_process: ++ new_inner = get_inner_resources(to_process.pop()) ++ to_process.update(set(new_inner) - all_inner) ++ all_inner.update(new_inner) ++ return all_inner ++ + def get_inner_resources(resource_el): + """ + Return list of inner resources (direct descendants) of a resource +diff --git a/pcs/lib/cib/test/test_resource_common.py b/pcs/lib/cib/test/test_resource_common.py +index 596ee57f..06684f95 100644 +--- a/pcs/lib/cib/test/test_resource_common.py ++++ b/pcs/lib/cib/test/test_resource_common.py +@@ -84,10 +84,12 @@ class IsCloneDeactivatedByMeta(TestCase): + + + class FindResourcesMixin(object): ++ _iterable_type = list ++ + def assert_find_resources(self, input_resource_id, output_resource_ids): + self.assertEqual( +- output_resource_ids, +- [ ++ self._iterable_type(output_resource_ids), ++ self._iterable_type([ + element.get("id", "") + for element in + self._tested_fn( +@@ -95,7 +97,7 @@ class FindResourcesMixin(object): + './/*[@id="{0}"]'.format(input_resource_id) + ) + ) +- ] ++ ]) + ) + + def test_group(self): +@@ -119,6 +121,27 @@ class FindResourcesMixin(object): + def test_bundle_with_primitive(self): + self.assert_find_resources("H-bundle", ["H"]) + ++ def test_primitive(self): ++ raise NotImplementedError() ++ ++ def test_primitive_in_clone(self): ++ raise NotImplementedError() ++ ++ def test_primitive_in_master(self): ++ raise NotImplementedError() ++ ++ def test_primitive_in_group(self): ++ raise NotImplementedError() ++ ++ def test_primitive_in_bundle(self): ++ raise NotImplementedError() ++ ++ def test_cloned_group(self): ++ raise NotImplementedError() ++ ++ def test_mastered_group(self): ++ raise NotImplementedError() ++ + + class FindPrimitives(TestCase, FindResourcesMixin): + _tested_fn = staticmethod(common.find_primitives) +@@ -150,6 +173,37 @@ class FindPrimitives(TestCase, FindResourcesMixin): + self.assert_find_resources("F-master", ["F1", "F2"]) + + ++class GetAllInnerResources(TestCase, FindResourcesMixin): ++ _iterable_type = set ++ _tested_fn = staticmethod(common.get_all_inner_resources) ++ ++ def test_primitive(self): ++ self.assert_find_resources("A", set()) ++ ++ def test_primitive_in_clone(self): ++ self.assert_find_resources("B", set()) ++ ++ def test_primitive_in_master(self): ++ self.assert_find_resources("C", set()) ++ ++ def test_primitive_in_group(self): ++ self.assert_find_resources("D1", set()) ++ self.assert_find_resources("D2", set()) ++ self.assert_find_resources("E1", set()) ++ self.assert_find_resources("E2", set()) ++ self.assert_find_resources("F1", set()) ++ self.assert_find_resources("F2", set()) ++ ++ def test_primitive_in_bundle(self): ++ self.assert_find_resources("H", set()) ++ ++ def test_cloned_group(self): ++ self.assert_find_resources("E-clone", {"E", "E1", "E2"}) ++ ++ def test_mastered_group(self): ++ self.assert_find_resources("F-master", {"F", "F1", "F2"}) ++ ++ + class GetInnerResources(TestCase, FindResourcesMixin): + _tested_fn = staticmethod(common.get_inner_resources) + +diff --git a/pcs/lib/commands/resource.py b/pcs/lib/commands/resource.py +index 09a68a68..1ad03a48 100644 +--- a/pcs/lib/commands/resource.py ++++ b/pcs/lib/commands/resource.py +@@ -23,6 +23,8 @@ from pcs.lib.cib.tools import ( + ) + from pcs.lib.env_tools import get_nodes + from pcs.lib.errors import LibraryError ++from pcs.lib.pacemaker import simulate as simulate_tools ++from pcs.lib.pacemaker.live import simulate_cib + from pcs.lib.pacemaker.values import ( + timeout_to_seconds, + validate_id, +@@ -37,6 +39,7 @@ from pcs.lib.resource_agent import( + find_valid_resource_agent_by_name as get_agent + ) + from pcs.lib.validate import value_time_interval ++from pcs.lib.xml_tools import get_root + + @contextmanager + def resource_environment( +@@ -717,13 +720,45 @@ def bundle_update( + meta_attributes + ) + ++def _disable_validate_and_edit_cib(env, resources_section, resource_ids): ++ resource_el_list = _find_resources_or_raise( ++ resources_section, ++ resource_ids ++ ) ++ env.report_processor.process_list( ++ _resource_list_enable_disable( ++ resource_el_list, ++ resource.common.disable, ++ env.get_cluster_state() ++ ) ++ ) ++ + def disable(env, resource_ids, wait): + """ + Disallow specified resource to be started by the cluster ++ + LibraryEnvironment env -- + strings resource_ids -- ids of the resources to be disabled + mixed wait -- False: no wait, None: wait default timeout, int: wait timeout + """ ++ with resource_environment( ++ env, wait, resource_ids, _ensure_disabled_after_wait(True) ++ ) as resources_section: ++ _disable_validate_and_edit_cib(env, resources_section, resource_ids) ++ ++def disable_safe(env, resource_ids, strict, wait): ++ """ ++ Disallow specified resource to be started by the cluster only if there is ++ no effect on other resources ++ ++ LibraryEnvironment env -- ++ strings resource_ids -- ids of the resources to be disabled ++ bool strict -- if False, allow resources to be migrated ++ mixed wait -- False: no wait, None: wait default timeout, int: wait timeout ++ """ ++ if not env.is_cib_live: ++ raise LibraryError(reports.live_environment_required(["CIB"])) ++ + with resource_environment( + env, wait, resource_ids, _ensure_disabled_after_wait(True) + ) as resources_section: +@@ -739,6 +774,72 @@ def disable(env, resource_ids, wait): + ) + ) + ++ inner_resources_names_set = set() ++ for resource_el in resource_el_list: ++ inner_resources_names_set.update({ ++ inner_resource_el.get("id") ++ for inner_resource_el ++ in resource.common.get_all_inner_resources(resource_el) ++ }) ++ ++ plaintext_status, transitions, dummy_cib = simulate_cib( ++ env.cmd_runner(), ++ get_root(resources_section) ++ ) ++ simulated_operations = ( ++ simulate_tools.get_operations_from_transitions(transitions) ++ ) ++ other_affected = set() ++ if strict: ++ other_affected = set( ++ simulate_tools.get_resources_from_operations( ++ simulated_operations, ++ exclude=resource_ids ++ ) ++ ) ++ else: ++ other_affected = set( ++ simulate_tools.get_resources_left_stopped( ++ simulated_operations, ++ exclude=resource_ids ++ ) ++ + ++ simulate_tools.get_resources_left_demoted( ++ simulated_operations, ++ exclude=resource_ids ++ ) ++ ) ++ ++ # Stopping a clone stops all its inner resources. That should not block ++ # stopping the clone. ++ other_affected = other_affected - inner_resources_names_set ++ if other_affected: ++ raise LibraryError( ++ reports.resource_disable_affects_other_resources( ++ resource_ids, ++ other_affected, ++ plaintext_status, ++ ) ++ ) ++ ++def disable_simulate(env, resource_ids): ++ """ ++ Simulate disallowing specified resource to be started by the cluster ++ ++ LibraryEnvironment env -- ++ strings resource_ids -- ids of the resources to be disabled ++ """ ++ if not env.is_cib_live: ++ raise LibraryError(reports.live_environment_required(["CIB"])) ++ ++ resources_section = get_resources(env.get_cib()) ++ _disable_validate_and_edit_cib(env, resources_section, resource_ids) ++ plaintext_status, dummy_transitions, dummy_cib = simulate_cib( ++ env.cmd_runner(), ++ get_root(resources_section) ++ ) ++ return plaintext_status ++ + def enable(env, resource_ids, wait): + """ + Allow specified resource to be started by the cluster +diff --git a/pcs/lib/commands/test/resource/test_resource_enable_disable.py b/pcs/lib/commands/test/resource/test_resource_enable_disable.py +index 194eb5fd..6514d9fc 100644 +--- a/pcs/lib/commands/test/resource/test_resource_enable_disable.py ++++ b/pcs/lib/commands/test/resource/test_resource_enable_disable.py +@@ -14,10 +14,11 @@ from pcs.lib.errors import ( + from pcs.test.tools import fixture + from pcs.test.tools.command_env import get_env_tools + from pcs.test.tools.misc import ( ++ get_test_resource as rc, + outdent, + skip_unless_pacemaker_supports_bundle, + ) +-from pcs.test.tools.pcs_unittest import TestCase ++from pcs.test.tools.pcs_unittest import TestCase, mock + + + TIMEOUT=10 +@@ -1646,3 +1647,819 @@ class EnableBundle(TestCase): + fixture_report_unmanaged("A-bundle"), + fixture_report_unmanaged("A"), + ]) ++ ++ ++@mock.patch("pcs.lib.pacemaker.live.write_tmpfile") ++class DisableSimulate(TestCase): ++ def setUp(self): ++ self.env_assist, self.config = get_env_tools(test_case=self) ++ self.tmpfile_new_cib = mock.MagicMock() ++ self.tmpfile_new_cib.name = rc("new_cib.tmp") ++ self.tmpfile_new_cib.read.return_value = "" ++ self.tmpfile_transitions = mock.MagicMock() ++ self.tmpfile_transitions.name = rc("transitions.tmp") ++ self.tmpfile_transitions.read.return_value = "" ++ ++ def test_not_live(self, mock_write_tmpfile): ++ mock_write_tmpfile.side_effect = [ ++ AssertionError("No other write_tmpfile call expected") ++ ] ++ self.config.env.set_cib_data("") ++ self.env_assist.assert_raise_library_error( ++ lambda: resource.disable_simulate(self.env_assist.get_env(), ["A"]), ++ [ ++ fixture.error( ++ report_codes.LIVE_ENVIRONMENT_REQUIRED, ++ forbidden_options=["CIB"] ++ ), ++ ], ++ expected_in_processor=False ++ ) ++ ++ def test_nonexistent_resource(self, mock_write_tmpfile): ++ mock_write_tmpfile.side_effect = [ ++ AssertionError("No other write_tmpfile call expected") ++ ] ++ self.config.runner.cib.load() ++ self.env_assist.assert_raise_library_error( ++ lambda: resource.disable_simulate(self.env_assist.get_env(), ["A"]), ++ [ ++ fixture.report_not_found("A", "resources"), ++ ], ++ expected_in_processor=False ++ ) ++ ++ def test_success(self, mock_write_tmpfile): ++ mock_write_tmpfile.side_effect = [ ++ self.tmpfile_new_cib, self.tmpfile_transitions, ++ AssertionError("No other write_tmpfile call expected") ++ ] ++ (self.config ++ .runner.cib.load(resources=fixture_primitive_cib_enabled) ++ .runner.pcmk.load_state(resources=fixture_primitive_status_managed) ++ .runner.pcmk.simulate_cib( ++ self.tmpfile_new_cib.name, ++ self.tmpfile_transitions.name, ++ stdout="simulate output", ++ resources=fixture_primitive_cib_disabled, ++ ) ++ ) ++ ++ result = resource.disable_simulate(self.env_assist.get_env(), ["A"]) ++ self.assertEqual("simulate output", result) ++ ++ def test_simulate_error(self, mock_write_tmpfile): ++ mock_write_tmpfile.side_effect = [ ++ self.tmpfile_new_cib, self.tmpfile_transitions, ++ AssertionError("No other write_tmpfile call expected") ++ ] ++ (self.config ++ .runner.cib.load(resources=fixture_primitive_cib_enabled) ++ .runner.pcmk.load_state(resources=fixture_primitive_status_managed) ++ .runner.pcmk.simulate_cib( ++ self.tmpfile_new_cib.name, ++ self.tmpfile_transitions.name, ++ stdout="some stdout", ++ stderr="some stderr", ++ returncode=1, ++ resources=fixture_primitive_cib_disabled, ++ ) ++ ) ++ ++ self.env_assist.assert_raise_library_error( ++ lambda: resource.disable_simulate(self.env_assist.get_env(), ["A"]), ++ [ ++ fixture.error( ++ report_codes.CIB_SIMULATE_ERROR, ++ reason="some stderr", ++ ), ++ ], ++ expected_in_processor=False ++ ) ++ ++ ++class DisableSafeMixin(object): ++ fixture_transitions_both_stopped = """ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ """ ++ fixture_transitions_one_migrated = """ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ """ ++ fixture_transitions_master_demoted = """ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ """ ++ fixture_transitions_master_migrated = """ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ """ ++ fixture_cib_with_master = """ ++ ++ ++ ++ ++ ++ ++ ++ ++ """ ++ fixture_cib_with_master_primitive_disabled = """ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ """ ++ fixture_status_with_master_managed = """ ++ ++ ++ ++ ++ ++ ++ ++ """ ++ ++ def setUp(self): ++ # pylint does not know this will be mixed into TestCase classes ++ # pylint: disable=invalid-name ++ self.env_assist, self.config = get_env_tools(test_case=self) ++ self.tmpfile_new_cib = mock.MagicMock() ++ self.tmpfile_new_cib.name = rc("new_cib.tmp") ++ self.tmpfile_new_cib.read.return_value = "" ++ self.tmpfile_transitions = mock.MagicMock() ++ self.tmpfile_transitions.name = rc("transitions.tmp") ++ self.tmpfile_transitions.read.return_value = "" ++ ++ def fixture_disable_both_resources(self, mock_write_tmpfile): ++ self.tmpfile_transitions.read.return_value = ( ++ self.fixture_transitions_both_stopped ++ ) ++ mock_write_tmpfile.side_effect = [ ++ self.tmpfile_new_cib, self.tmpfile_transitions, ++ AssertionError("No other write_tmpfile call expected") ++ ] ++ (self.config ++ .runner.cib.load(resources=fixture_two_primitives_cib_enabled) ++ .runner.pcmk.load_state( ++ resources=fixture_two_primitives_status_managed ++ ) ++ ) ++ ++ def fixture_migrate_one_resource(self, mock_write_tmpfile): ++ self.tmpfile_transitions.read.return_value = ( ++ self.fixture_transitions_one_migrated ++ ) ++ mock_write_tmpfile.side_effect = [ ++ self.tmpfile_new_cib, self.tmpfile_transitions, ++ AssertionError("No other write_tmpfile call expected") ++ ] ++ (self.config ++ .runner.cib.load(resources=fixture_two_primitives_cib_enabled) ++ .runner.pcmk.load_state( ++ resources=fixture_two_primitives_status_managed ++ ) ++ ) ++ ++ def test_not_live(self, mock_write_tmpfile): ++ mock_write_tmpfile.side_effect = [ ++ AssertionError("No other write_tmpfile call expected") ++ ] ++ self.config.env.set_cib_data("") ++ self.env_assist.assert_raise_library_error( ++ lambda: resource.disable_safe( ++ self.env_assist.get_env(), ++ ["A"], ++ self.strict, ++ False ++ ), ++ [ ++ fixture.error( ++ report_codes.LIVE_ENVIRONMENT_REQUIRED, ++ forbidden_options=["CIB"] ++ ), ++ ], ++ expected_in_processor=False ++ ) ++ ++ def test_nonexistent_resource(self, mock_write_tmpfile): ++ mock_write_tmpfile.side_effect = [ ++ AssertionError("No other write_tmpfile call expected") ++ ] ++ self.config.runner.cib.load() ++ self.env_assist.assert_raise_library_error( ++ lambda: resource.disable_safe( ++ self.env_assist.get_env(), ++ ["A"], ++ self.strict, ++ False ++ ), ++ [ ++ fixture.report_not_found("A", "resources"), ++ ], ++ expected_in_processor=False ++ ) ++ ++ def test_simulate_error(self, mock_write_tmpfile): ++ self.fixture_disable_both_resources(mock_write_tmpfile) ++ self.config.runner.pcmk.simulate_cib( ++ self.tmpfile_new_cib.name, ++ self.tmpfile_transitions.name, ++ stdout="some stdout", ++ stderr="some stderr", ++ returncode=1, ++ resources=fixture_two_primitives_cib_disabled_both, ++ ) ++ self.env_assist.assert_raise_library_error( ++ lambda: resource.disable_safe( ++ self.env_assist.get_env(), ++ ["A", "B"], ++ self.strict, ++ False, ++ ), ++ [ ++ fixture.error( ++ report_codes.CIB_SIMULATE_ERROR, ++ reason="some stderr", ++ ), ++ ], ++ expected_in_processor=False ++ ) ++ ++ def test_only_specified_resources_stopped(self, mock_write_tmpfile): ++ self.fixture_disable_both_resources(mock_write_tmpfile) ++ self.config.runner.pcmk.simulate_cib( ++ self.tmpfile_new_cib.name, ++ self.tmpfile_transitions.name, ++ stdout="simulate output", ++ resources=fixture_two_primitives_cib_disabled_both, ++ ) ++ self.config.env.push_cib( ++ resources=fixture_two_primitives_cib_disabled_both ++ ) ++ resource.disable_safe( ++ self.env_assist.get_env(), ++ ["A", "B"], ++ self.strict, ++ False, ++ ) ++ ++ def test_other_resources_stopped(self, mock_write_tmpfile): ++ self.fixture_disable_both_resources(mock_write_tmpfile) ++ self.config.runner.pcmk.simulate_cib( ++ self.tmpfile_new_cib.name, ++ self.tmpfile_transitions.name, ++ stdout="simulate output", ++ resources=fixture_two_primitives_cib_disabled, ++ ) ++ self.env_assist.assert_raise_library_error( ++ lambda: resource.disable_safe( ++ self.env_assist.get_env(), ++ ["A"], ++ self.strict, ++ False, ++ ), ++ [ ++ fixture.error( ++ report_codes.RESOURCE_DISABLE_AFFECTS_OTHER_RESOURCES, ++ disabled_resource_list=["A"], ++ affected_resource_list=["B"], ++ crm_simulate_plaintext_output="simulate output", ++ ), ++ ], ++ expected_in_processor=False ++ ) ++ ++ def test_master_demoted(self, mock_write_tmpfile): ++ self.tmpfile_transitions.read.return_value = ( ++ self.fixture_transitions_master_demoted ++ ) ++ mock_write_tmpfile.side_effect = [ ++ self.tmpfile_new_cib, self.tmpfile_transitions, ++ AssertionError("No other write_tmpfile call expected") ++ ] ++ (self.config ++ .runner.cib.load(resources=self.fixture_cib_with_master) ++ .runner.pcmk.load_state( ++ resources=self.fixture_status_with_master_managed ++ ) ++ .runner.pcmk.simulate_cib( ++ self.tmpfile_new_cib.name, ++ self.tmpfile_transitions.name, ++ stdout="simulate output", ++ resources=self.fixture_cib_with_master_primitive_disabled, ++ ) ++ ) ++ self.env_assist.assert_raise_library_error( ++ lambda: resource.disable_safe( ++ self.env_assist.get_env(), ++ ["A"], ++ self.strict, ++ False, ++ ), ++ [ ++ fixture.error( ++ report_codes.RESOURCE_DISABLE_AFFECTS_OTHER_RESOURCES, ++ disabled_resource_list=["A"], ++ affected_resource_list=["B"], ++ crm_simulate_plaintext_output="simulate output", ++ ), ++ ], ++ expected_in_processor=False ++ ) ++ ++ def test_wait_success(self, mock_write_tmpfile): ++ self.config.runner.pcmk.can_wait() ++ self.fixture_disable_both_resources(mock_write_tmpfile) ++ (self.config ++ .runner.pcmk.simulate_cib( ++ self.tmpfile_new_cib.name, ++ self.tmpfile_transitions.name, ++ stdout="simulate output", ++ resources=fixture_two_primitives_cib_disabled_both, ++ ) ++ .env.push_cib( ++ resources=fixture_two_primitives_cib_disabled_both, ++ wait=TIMEOUT ++ ) ++ .runner.pcmk.load_state( ++ name="runner.pcmk.load_state_2", ++ resources=""" ++ ++ ++ ++ ++ ++ ++ """ ++ ) ++ ) ++ resource.disable_safe( ++ self.env_assist.get_env(), ++ ["A", "B"], ++ self.strict, ++ TIMEOUT ++ ) ++ self.env_assist.assert_reports([ ++ fixture.report_resource_not_running("A"), ++ fixture.report_resource_not_running("B"), ++ ]) ++ ++ def test_inner_resources(self, mock_write_tmpfile): ++ cib_xml = """ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ """ ++ status_xml = """ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ """ ++ synapses = [] ++ index = 0 ++ for res_name, is_clone in [ ++ ("A", False), ++ ("B", True), ++ ("C", True), ++ ("D1", False), ++ ("D2", False), ++ ("E1", True), ++ ("E2", True), ++ ("F1", True), ++ ("F2", True), ++ ("H", False), ++ ]: ++ if is_clone: ++ synapses.append(""" ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ """.format( ++ index=index, ++ index_1=index + 1, ++ res_name=res_name, ++ )) ++ index += 2 ++ else: ++ synapses.append(""" ++ ++ ++ ++ ++ ++ ++ ++ """.format( ++ index=index, ++ res_name=res_name, ++ )) ++ index += 1 ++ transitions_xml = ( ++ "" + "\n".join(synapses) + "" ++ ) ++ ++ self.tmpfile_transitions.read.return_value = transitions_xml ++ mock_write_tmpfile.side_effect = [ ++ self.tmpfile_new_cib, self.tmpfile_transitions, ++ AssertionError("No other write_tmpfile call expected") ++ ] ++ (self.config ++ .runner.cib.load(resources=cib_xml) ++ .runner.pcmk.load_state(resources=status_xml) ++ ) ++ self.config.runner.pcmk.simulate_cib( ++ self.tmpfile_new_cib.name, ++ self.tmpfile_transitions.name, ++ stdout="simulate output", ++ resources=""" ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ """ ++ ) ++ self.env_assist.assert_raise_library_error( ++ lambda: resource.disable_safe( ++ self.env_assist.get_env(), ++ ["B-clone", "C-master", "D", "E-clone", "F-master", "H-bundle"], ++ self.strict, ++ False, ++ ), ++ [ ++ fixture.error( ++ report_codes.RESOURCE_DISABLE_AFFECTS_OTHER_RESOURCES, ++ disabled_resource_list=[ ++ "B-clone", "C-master", "D", "E-clone", "F-master", ++ "H-bundle" ++ ], ++ affected_resource_list=["A"], ++ crm_simulate_plaintext_output="simulate output", ++ ), ++ ], ++ expected_in_processor=False ++ ) ++ ++@mock.patch("pcs.lib.pacemaker.live.write_tmpfile") ++class DisableSafe(DisableSafeMixin, TestCase): ++ strict = False ++ ++ def test_resources_migrated(self, mock_write_tmpfile): ++ self.fixture_migrate_one_resource(mock_write_tmpfile) ++ self.config.runner.pcmk.simulate_cib( ++ self.tmpfile_new_cib.name, ++ self.tmpfile_transitions.name, ++ stdout="simulate output", ++ resources=fixture_two_primitives_cib_disabled, ++ ) ++ self.config.env.push_cib(resources=fixture_two_primitives_cib_disabled) ++ resource.disable_safe( ++ self.env_assist.get_env(), ++ ["A"], ++ self.strict, ++ False, ++ ) ++ ++ def test_master_migrated(self, mock_write_tmpfile): ++ self.tmpfile_transitions.read.return_value = ( ++ self.fixture_transitions_master_migrated ++ ) ++ mock_write_tmpfile.side_effect = [ ++ self.tmpfile_new_cib, self.tmpfile_transitions, ++ AssertionError("No other write_tmpfile call expected") ++ ] ++ (self.config ++ .runner.cib.load(resources=self.fixture_cib_with_master) ++ .runner.pcmk.load_state( ++ resources=self.fixture_status_with_master_managed ++ ) ++ .runner.pcmk.simulate_cib( ++ self.tmpfile_new_cib.name, ++ self.tmpfile_transitions.name, ++ stdout="simulate output", ++ resources=self.fixture_cib_with_master_primitive_disabled, ++ ) ++ .env.push_cib( ++ resources=self.fixture_cib_with_master_primitive_disabled ++ ) ++ ) ++ resource.disable_safe( ++ self.env_assist.get_env(), ++ ["A"], ++ self.strict, ++ False, ++ ) ++ ++@mock.patch("pcs.lib.pacemaker.live.write_tmpfile") ++class DisableSafeStrict(DisableSafeMixin, TestCase): ++ strict = True ++ ++ def test_resources_migrated(self, mock_write_tmpfile): ++ self.fixture_migrate_one_resource(mock_write_tmpfile) ++ self.config.runner.pcmk.simulate_cib( ++ self.tmpfile_new_cib.name, ++ self.tmpfile_transitions.name, ++ stdout="simulate output", ++ resources=fixture_two_primitives_cib_disabled, ++ ) ++ self.env_assist.assert_raise_library_error( ++ lambda: resource.disable_safe( ++ self.env_assist.get_env(), ++ ["A"], ++ self.strict, ++ False, ++ ), ++ [ ++ fixture.error( ++ report_codes.RESOURCE_DISABLE_AFFECTS_OTHER_RESOURCES, ++ disabled_resource_list=["A"], ++ affected_resource_list=["B"], ++ crm_simulate_plaintext_output="simulate output", ++ ), ++ ], ++ expected_in_processor=False ++ ) ++ ++ def test_master_migrated(self, mock_write_tmpfile): ++ self.tmpfile_transitions.read.return_value = ( ++ self.fixture_transitions_master_migrated ++ ) ++ mock_write_tmpfile.side_effect = [ ++ self.tmpfile_new_cib, self.tmpfile_transitions, ++ AssertionError("No other write_tmpfile call expected") ++ ] ++ (self.config ++ .runner.cib.load(resources=self.fixture_cib_with_master) ++ .runner.pcmk.load_state( ++ resources=self.fixture_status_with_master_managed ++ ) ++ .runner.pcmk.simulate_cib( ++ self.tmpfile_new_cib.name, ++ self.tmpfile_transitions.name, ++ stdout="simulate output", ++ resources=self.fixture_cib_with_master_primitive_disabled, ++ ) ++ ) ++ self.env_assist.assert_raise_library_error( ++ lambda: resource.disable_safe( ++ self.env_assist.get_env(), ++ ["A"], ++ self.strict, ++ False, ++ ), ++ [ ++ fixture.error( ++ report_codes.RESOURCE_DISABLE_AFFECTS_OTHER_RESOURCES, ++ disabled_resource_list=["A"], ++ affected_resource_list=["B"], ++ crm_simulate_plaintext_output="simulate output", ++ ), ++ ], ++ expected_in_processor=False ++ ) +diff --git a/pcs/lib/pacemaker/live.py b/pcs/lib/pacemaker/live.py +index 992cc639..bc444229 100644 +--- a/pcs/lib/pacemaker/live.py ++++ b/pcs/lib/pacemaker/live.py +@@ -9,8 +9,9 @@ import os.path + + from pcs import settings + from pcs.common.tools import ( ++ format_environment_error, + join_multilines, +- xml_fromstring ++ xml_fromstring, + ) + from pcs.lib import reports + from pcs.lib.cib.tools import get_pacemaker_version_by_which_cib_was_validated +@@ -205,6 +206,69 @@ def _upgrade_cib(runner): + reports.cib_upgrade_failed(join_multilines([stderr, stdout])) + ) + ++def simulate_cib_xml(runner, cib_xml): ++ """ ++ Run crm_simulate to get effects the cib would have on the live cluster ++ ++ CommandRunner runner -- runner ++ string cib_xml -- CIB XML to simulate ++ """ ++ try: ++ new_cib_file = write_tmpfile(None) ++ transitions_file = write_tmpfile(None) ++ except EnvironmentError as e: ++ raise LibraryError( ++ reports.cib_simulate_error(format_environment_error(e)) ++ ) ++ ++ cmd = [ ++ __exec("crm_simulate"), ++ "--simulate", ++ "--save-output", new_cib_file.name, ++ "--save-graph", transitions_file.name, ++ "--xml-pipe", ++ ] ++ stdout, stderr, retval = runner.run(cmd, stdin_string=cib_xml) ++ if retval != 0: ++ raise LibraryError( ++ reports.cib_simulate_error(stderr.strip()) ++ ) ++ ++ try: ++ new_cib_file.seek(0) ++ transitions_file.seek(0) ++ new_cib_xml = new_cib_file.read() ++ transitions_xml = transitions_file.read() ++ new_cib_file.close() ++ transitions_file.close() ++ return stdout, transitions_xml, new_cib_xml ++ except EnvironmentError as e: ++ raise LibraryError( ++ reports.cib_simulate_error(format_environment_error(e)) ++ ) ++ ++def simulate_cib(runner, cib): ++ """ ++ Run crm_simulate to get effects the cib would have on the live cluster ++ ++ CommandRunner runner -- runner ++ etree cib -- cib tree to simulate ++ """ ++ cib_xml = etree_to_str(cib) ++ try: ++ plaintext_result, transitions_xml, new_cib_xml = simulate_cib_xml( ++ runner, cib_xml ++ ) ++ return ( ++ plaintext_result.strip(), ++ xml_fromstring(transitions_xml), ++ xml_fromstring(new_cib_xml) ++ ) ++ except (etree.XMLSyntaxError, etree.DocumentInvalid) as e: ++ raise LibraryError( ++ reports.cib_simulate_error(str(e)) ++ ) ++ + ### wait for idle + + def has_wait_for_idle_support(runner): +diff --git a/pcs/lib/pacemaker/simulate.py b/pcs/lib/pacemaker/simulate.py +new file mode 100644 +index 00000000..aff38058 +--- /dev/null ++++ b/pcs/lib/pacemaker/simulate.py +@@ -0,0 +1,86 @@ ++from collections import defaultdict ++ ++def get_operations_from_transitions(transitions): ++ """ ++ Extract resource operations from simulated transitions ++ ++ etree transitions -- simulated transitions from crm_simulate ++ """ ++ operation_list = [] ++ watched_operations = { ++ "start", "stop", "promote", "demote", "migrate_from", "migrate_to" ++ } ++ for rsc_op in transitions.iterfind("synapse/action_set/rsc_op"): ++ operation = rsc_op.get("operation", "").lower() ++ if operation not in watched_operations: ++ continue ++ for primitive in rsc_op.iterfind("primitive"): ++ primitive_id = primitive.get("id") ++ operation_list.append(( ++ int(rsc_op.get("id")), ++ { ++ "primitive_id": primitive_id, ++ "primitive_long_id": ( ++ primitive.get("long-id") or primitive_id ++ ), ++ "operation": operation, ++ "on_node": rsc_op.get("on_node"), ++ } ++ )) ++ operation_list.sort(key=lambda x: x[0]) ++ op_list = [op[1] for op in operation_list] ++ return op_list ++ ++def get_resources_from_operations(operation_list, exclude=None): ++ """ ++ Get names of all resources from the provided operation list ++ ++ list operation_list -- result of get_operations_from_transitions ++ iterable exclude -- resources to exclude from the result ++ """ ++ exclude = exclude or set() ++ return sorted({ ++ op["primitive_id"] ++ for op in operation_list ++ if op["primitive_id"] not in exclude ++ }) ++ ++def get_resources_left_stopped(operation_list, exclude=None): ++ """ ++ Get names of resources which are left stopped by the provided operation list ++ ++ list operation_list -- result of get_operations_from_transitions ++ iterable exclude -- resources to exclude from the result ++ """ ++ return _resources_with_imbalanced_operations( ++ operation_list, "stop", "start", exclude ++ ) ++ ++def get_resources_left_demoted(operation_list, exclude=None): ++ """ ++ Get names of resources which are left demoted by the provided operation list ++ ++ list operation_list -- result of get_operations_from_transitions ++ iterable exclude -- resources to exclude from the result ++ """ ++ return _resources_with_imbalanced_operations( ++ operation_list, "demote", "promote", exclude ++ ) ++ ++def _resources_with_imbalanced_operations( ++ operation_list, increment_op, decrement_op, exclude ++): ++ exclude = exclude or set() ++ counter = defaultdict(int) ++ for res_op in operation_list: ++ resource = res_op["primitive_id"] ++ operation = res_op["operation"] ++ if operation == increment_op: ++ counter[resource] += 1 ++ elif operation == decrement_op: ++ counter[resource] -= 1 ++ return sorted([ ++ resource ++ for resource, count in counter.items() ++ if count > 0 and resource not in exclude ++ ]) +diff --git a/pcs/lib/pacemaker/test/test_live.py b/pcs/lib/pacemaker/test/test_live.py +index 904e3498..fad95037 100644 +--- a/pcs/lib/pacemaker/test/test_live.py ++++ b/pcs/lib/pacemaker/test/test_live.py +@@ -16,7 +16,7 @@ from pcs.test.tools import fixture + from pcs.test.tools.command_env import get_env_tools + from pcs.test.tools.misc import get_test_resource as rc + from pcs.test.tools.pcs_unittest import TestCase, mock +-from pcs.test.tools.xml import XmlManipulation ++from pcs.test.tools.xml import etree_to_str, XmlManipulation + + from pcs import settings + from pcs.common import report_codes +@@ -435,6 +435,195 @@ class EnsureCibVersionTest(TestCase): + mock_upgrade.assert_called_once_with(self.mock_runner) + mock_get_cib.assert_called_once_with(self.mock_runner) + ++ ++@mock.patch("pcs.lib.pacemaker.live.write_tmpfile") ++class SimulateCibXml(LibraryPacemakerTest): ++ def test_success(self, mock_write_tmpfile): ++ tmpfile_new_cib = mock.MagicMock() ++ tmpfile_new_cib.name = rc("new_cib.tmp") ++ tmpfile_new_cib.read.return_value = "new cib data" ++ tmpfile_transitions = mock.MagicMock() ++ tmpfile_transitions.name = rc("transitions.tmp") ++ tmpfile_transitions.read.return_value = "transitions data" ++ mock_write_tmpfile.side_effect = [tmpfile_new_cib, tmpfile_transitions] ++ ++ expected_stdout = "simulate output" ++ expected_stderr = "" ++ expected_retval = 0 ++ mock_runner = get_runner( ++ expected_stdout, ++ expected_stderr, ++ expected_retval ++ ) ++ ++ result = lib.simulate_cib_xml(mock_runner, "") ++ self.assertEqual(result[0], expected_stdout) ++ self.assertEqual(result[1], "transitions data") ++ self.assertEqual(result[2], "new cib data") ++ ++ mock_runner.run.assert_called_once_with( ++ [ ++ self.path("crm_simulate"), ++ "--simulate", ++ "--save-output", tmpfile_new_cib.name, ++ "--save-graph", tmpfile_transitions.name, ++ "--xml-pipe", ++ ], ++ stdin_string="" ++ ) ++ ++ def test_error_creating_cib(self, mock_write_tmpfile): ++ mock_write_tmpfile.side_effect = OSError(1, "some error") ++ mock_runner = get_runner() ++ assert_raise_library_error( ++ lambda: lib.simulate_cib_xml(mock_runner, ""), ++ fixture.error( ++ report_codes.CIB_SIMULATE_ERROR, ++ reason="some error", ++ ), ++ ) ++ mock_runner.run.assert_not_called() ++ ++ def test_error_creating_transitions(self, mock_write_tmpfile): ++ tmpfile_new_cib = mock.MagicMock() ++ mock_write_tmpfile.side_effect = [ ++ tmpfile_new_cib, ++ OSError(1, "some error") ++ ] ++ mock_runner = get_runner() ++ assert_raise_library_error( ++ lambda: lib.simulate_cib_xml(mock_runner, ""), ++ fixture.error( ++ report_codes.CIB_SIMULATE_ERROR, ++ reason="some error", ++ ), ++ ) ++ mock_runner.run.assert_not_called() ++ ++ def test_error_running_simulate(self, mock_write_tmpfile): ++ tmpfile_new_cib = mock.MagicMock() ++ tmpfile_new_cib.name = rc("new_cib.tmp") ++ tmpfile_transitions = mock.MagicMock() ++ tmpfile_transitions.name = rc("transitions.tmp") ++ mock_write_tmpfile.side_effect = [tmpfile_new_cib, tmpfile_transitions] ++ ++ expected_stdout = "some stdout" ++ expected_stderr = "some error" ++ expected_retval = 1 ++ mock_runner = get_runner( ++ expected_stdout, ++ expected_stderr, ++ expected_retval ++ ) ++ ++ assert_raise_library_error( ++ lambda: lib.simulate_cib_xml(mock_runner, ""), ++ fixture.error( ++ report_codes.CIB_SIMULATE_ERROR, ++ reason="some error", ++ ), ++ ) ++ ++ def test_error_reading_cib(self, mock_write_tmpfile): ++ tmpfile_new_cib = mock.MagicMock() ++ tmpfile_new_cib.name = rc("new_cib.tmp") ++ tmpfile_new_cib.read.side_effect = OSError(1, "some error") ++ tmpfile_transitions = mock.MagicMock() ++ tmpfile_transitions.name = rc("transitions.tmp") ++ mock_write_tmpfile.side_effect = [tmpfile_new_cib, tmpfile_transitions] ++ ++ expected_stdout = "simulate output" ++ expected_stderr = "" ++ expected_retval = 0 ++ mock_runner = get_runner( ++ expected_stdout, ++ expected_stderr, ++ expected_retval ++ ) ++ ++ assert_raise_library_error( ++ lambda: lib.simulate_cib_xml(mock_runner, ""), ++ fixture.error( ++ report_codes.CIB_SIMULATE_ERROR, ++ reason="some error", ++ ), ++ ) ++ ++ def test_error_reading_transitions(self, mock_write_tmpfile): ++ tmpfile_new_cib = mock.MagicMock() ++ tmpfile_new_cib.name = rc("new_cib.tmp") ++ tmpfile_new_cib.read.return_value = "new cib data" ++ tmpfile_transitions = mock.MagicMock() ++ tmpfile_transitions.name = rc("transitions.tmp") ++ tmpfile_transitions.read.side_effect = OSError(1, "some error") ++ mock_write_tmpfile.side_effect = [tmpfile_new_cib, tmpfile_transitions] ++ ++ expected_stdout = "simulate output" ++ expected_stderr = "" ++ expected_retval = 0 ++ mock_runner = get_runner( ++ expected_stdout, ++ expected_stderr, ++ expected_retval ++ ) ++ ++ assert_raise_library_error( ++ lambda: lib.simulate_cib_xml(mock_runner, ""), ++ fixture.error( ++ report_codes.CIB_SIMULATE_ERROR, ++ reason="some error", ++ ), ++ ) ++ ++ ++@mock.patch("pcs.lib.pacemaker.live.simulate_cib_xml") ++class SimulateCib(TestCase): ++ def setUp(self): ++ self.runner = "mock runner" ++ self.cib_xml = "" ++ self.cib = etree.fromstring(self.cib_xml) ++ self.simulate_output = " some output " ++ self.transitions = "" ++ self.new_cib = "" ++ ++ def test_success(self, mock_simulate): ++ mock_simulate.return_value = ( ++ self.simulate_output, self.transitions, self.new_cib ++ ) ++ result = lib.simulate_cib(self.runner, self.cib) ++ self.assertEqual(result[0], "some output") ++ self.assertEqual(etree_to_str(result[1]), self.transitions) ++ self.assertEqual(etree_to_str(result[2]), self.new_cib) ++ mock_simulate.assert_called_once_with(self.runner, self.cib_xml) ++ ++ def test_invalid_cib(self, mock_simulate): ++ mock_simulate.return_value = ( ++ self.simulate_output, "bad transitions", self.new_cib ++ ) ++ assert_raise_library_error( ++ lambda: lib.simulate_cib(self.runner, self.cib), ++ fixture.error( ++ report_codes.CIB_SIMULATE_ERROR, ++ reason=( ++ "Start tag expected, '<' not found, line 1, column 1" ++ ), ++ ), ++ ) ++ ++ def test_invalid_transitions(self, mock_simulate): ++ mock_simulate.return_value = ( ++ self.simulate_output, self.transitions, "bad new cib" ++ ) ++ assert_raise_library_error( ++ lambda: lib.simulate_cib(self.runner, self.cib), ++ fixture.error( ++ report_codes.CIB_SIMULATE_ERROR, ++ reason=( ++ "Start tag expected, '<' not found, line 1, column 1" ++ ), ++ ), ++ ) ++ + class GetLocalNodeStatusTest(LibraryPacemakerNodeStatusTest): + def test_offline(self): + expected_stdout = "some info" +diff --git a/pcs/lib/pacemaker/test/test_simulate.py b/pcs/lib/pacemaker/test/test_simulate.py +new file mode 100644 +index 00000000..2fe0789a +--- /dev/null ++++ b/pcs/lib/pacemaker/test/test_simulate.py +@@ -0,0 +1,380 @@ ++from __future__ import ( ++ absolute_import, ++ division, ++ print_function, ++) ++ ++from lxml import etree ++ ++from pcs.lib.pacemaker import simulate ++ ++from pcs.test.tools.pcs_unittest import TestCase ++from pcs.test.tools.misc import get_test_resource as rc ++ ++ ++class GetOperationsFromTransitions(TestCase): ++ def test_transitions1(self): ++ transitions = etree.parse(rc("transitions01.xml")) ++ self.assertEqual( ++ [ ++ { ++ "primitive_id": "dummy", ++ "primitive_long_id": "dummy", ++ "operation": "stop", ++ "on_node": "rh7-3", ++ }, ++ { ++ "primitive_id": "dummy", ++ "primitive_long_id": "dummy", ++ "operation": "start", ++ "on_node": "rh7-2", ++ }, ++ { ++ "primitive_id": "d0", ++ "primitive_long_id": "d0:1", ++ "operation": "stop", ++ "on_node": "rh7-1", ++ }, ++ { ++ "primitive_id": "d0", ++ "primitive_long_id": "d0:1", ++ "operation": "start", ++ "on_node": "rh7-2", ++ }, ++ { ++ "primitive_id": "state", ++ "primitive_long_id": "state:0", ++ "operation": "stop", ++ "on_node": "rh7-3", ++ }, ++ { ++ "primitive_id": "state", ++ "primitive_long_id": "state:0", ++ "operation": "start", ++ "on_node": "rh7-2", ++ }, ++ ], ++ simulate.get_operations_from_transitions(transitions) ++ ) ++ ++ def test_transitions2(self): ++ transitions = etree.parse(rc("transitions02.xml")) ++ self.assertEqual( ++ [ ++ { ++ "primitive_id": "RemoteNode", ++ "primitive_long_id": "RemoteNode", ++ "operation": "stop", ++ "on_node": "virt-143", ++ }, ++ { ++ "primitive_id": "RemoteNode", ++ "primitive_long_id": "RemoteNode", ++ "operation": "migrate_to", ++ "on_node": "virt-143", ++ }, ++ { ++ "primitive_id": "RemoteNode", ++ "primitive_long_id": "RemoteNode", ++ "operation": "migrate_from", ++ "on_node": "virt-142", ++ }, ++ { ++ "primitive_id": "dummy8", ++ "primitive_long_id": "dummy8", ++ "operation": "stop", ++ "on_node": "virt-143", ++ }, ++ { ++ "primitive_id": "dummy8", ++ "primitive_long_id": "dummy8", ++ "operation": "start", ++ "on_node": "virt-142", ++ } ++ ], ++ simulate.get_operations_from_transitions(transitions) ++ ) ++ ++class GetResourcesFromOperations(TestCase): ++ operations = [ ++ { ++ "primitive_id": "dummy2", ++ "primitive_long_id": "dummy2:1", ++ "operation": "stop", ++ "on_node": "node1", ++ }, ++ { ++ "primitive_id": "dummy1", ++ "primitive_long_id": "dummy1", ++ "operation": "stop", ++ "on_node": "node3", ++ }, ++ { ++ "primitive_id": "dummy1", ++ "primitive_long_id": "dummy1", ++ "operation": "start", ++ "on_node": "node2", ++ }, ++ ] ++ def test_no_operations(self): ++ self.assertEqual( ++ [], ++ simulate.get_resources_from_operations( ++ [] ++ ) ++ ) ++ ++ def test_no_operations_exclude(self): ++ self.assertEqual( ++ [], ++ simulate.get_resources_from_operations( ++ [], exclude={"dummy1"} ++ ) ++ ) ++ ++ def test_some_operations(self): ++ self.assertEqual( ++ ["dummy1", "dummy2"], ++ simulate.get_resources_from_operations( ++ self.operations ++ ) ++ ) ++ ++ def test_some_operations_exclude(self): ++ self.assertEqual( ++ ["dummy2"], ++ simulate.get_resources_from_operations( ++ self.operations, exclude={"dummy1", "dummy2:1", "dummyX"} ++ ) ++ ) ++ ++class GetResourcesLeftStoppedDemotedMixin(object): ++ def test_no_operations(self): ++ self.assertEqual([], self.call([])) ++ ++ def test_down(self): ++ self.assertEqual( ++ ["dummy"], ++ self.call([ ++ { ++ "primitive_id": "dummy", ++ "primitive_long_id": "dummy", ++ "operation": self.action_down, ++ "on_node": "node3", ++ }, ++ ]) ++ ) ++ ++ def test_up(self): ++ self.assertEqual( ++ [], ++ self.call([ ++ { ++ "primitive_id": "dummy", ++ "primitive_long_id": "dummy", ++ "operation": self.action_up, ++ "on_node": "node3", ++ }, ++ ]) ++ ) ++ ++ def test_down_up(self): ++ self.assertEqual( ++ [], ++ self.call([ ++ { ++ "primitive_id": "dummy", ++ "primitive_long_id": "dummy", ++ "operation": self.action_down, ++ "on_node": "node2", ++ }, ++ { ++ "primitive_id": "dummy", ++ "primitive_long_id": "dummy", ++ "operation": self.action_up, ++ "on_node": "node3", ++ }, ++ ]) ++ ) ++ ++ def test_up_down(self): ++ self.assertEqual( ++ [], ++ self.call([ ++ { ++ "primitive_id": "dummy", ++ "primitive_long_id": "dummy", ++ "operation": self.action_up, ++ "on_node": "node2", ++ }, ++ { ++ "primitive_id": "dummy", ++ "primitive_long_id": "dummy", ++ "operation": self.action_down, ++ "on_node": "node3", ++ }, ++ ]) ++ ) ++ ++ def test_mixed(self): ++ self.assertEqual( ++ ["dummy1", "dummy2"], ++ self.call([ ++ { ++ "primitive_id": "dummy1", ++ "primitive_long_id": "dummy1", ++ "operation": self.action_down, ++ "on_node": "node3", ++ }, ++ { ++ "primitive_id": "dummy2", ++ "primitive_long_id": "dummy2", ++ "operation": self.action_down, ++ "on_node": "node3", ++ }, ++ { ++ "primitive_id": "dummy3", ++ "primitive_long_id": "dummy3", ++ "operation": self.action_down, ++ "on_node": "node3", ++ }, ++ { ++ "primitive_id": "dummy3", ++ "primitive_long_id": "dummy3", ++ "operation": self.action_up, ++ "on_node": "node2", ++ }, ++ ]) ++ ) ++ ++ def test_exclude(self): ++ self.assertEqual( ++ ["dummy2"], ++ self.call( ++ [ ++ { ++ "primitive_id": "dummy1", ++ "primitive_long_id": "dummy1", ++ "operation": self.action_down, ++ "on_node": "node3", ++ }, ++ { ++ "primitive_id": "dummy2", ++ "primitive_long_id": "dummy2", ++ "operation": self.action_down, ++ "on_node": "node3", ++ }, ++ ], ++ exclude={"dummy1", "dummyX"} ++ ) ++ ) ++ ++class GetResourcesLeftStopped(GetResourcesLeftStoppedDemotedMixin, TestCase): ++ action_up = "start" ++ action_down = "stop" ++ call = staticmethod(simulate.get_resources_left_stopped) ++ ++ def test_clone_move(self): ++ self.assertEqual( ++ [], ++ self.call([ ++ { ++ "primitive_id": "dummy1", ++ "primitive_long_id": "dummy1:0", ++ "operation": self.action_down, ++ "on_node": "node3", ++ }, ++ { ++ "primitive_id": "dummy1", ++ "primitive_long_id": "dummy1:1", ++ "operation": self.action_down, ++ "on_node": "node1", ++ }, ++ { ++ "primitive_id": "dummy1", ++ "primitive_long_id": "dummy1:0", ++ "operation": self.action_up, ++ "on_node": "node2", ++ }, ++ { ++ "primitive_id": "dummy1", ++ "primitive_long_id": "dummy1:1", ++ "operation": self.action_up, ++ "on_node": "node4", ++ }, ++ ]) ++ ) ++ ++ def test_clone_stop(self): ++ self.assertEqual( ++ ["dummy1"], ++ self.call([ ++ { ++ "primitive_id": "dummy1", ++ "primitive_long_id": "dummy1:0", ++ "operation": self.action_down, ++ "on_node": "node3", ++ }, ++ { ++ "primitive_id": "dummy1", ++ "primitive_long_id": "dummy1:1", ++ "operation": self.action_down, ++ "on_node": "node1", ++ }, ++ { ++ "primitive_id": "dummy1", ++ "primitive_long_id": "dummy1:1", ++ "operation": self.action_up, ++ "on_node": "node4", ++ }, ++ ]) ++ ) ++ ++class GetResourcesLeftDemoted(GetResourcesLeftStoppedDemotedMixin, TestCase): ++ action_up = "promote" ++ action_down = "demote" ++ call = staticmethod(simulate.get_resources_left_demoted) ++ ++ def test_master_move(self): ++ self.assertEqual( ++ [], ++ self.call([ ++ { ++ "primitive_id": "dummy1", ++ "primitive_long_id": "dummy1:0", ++ "operation": self.action_down, ++ "on_node": "node3", ++ }, ++ { ++ "primitive_id": "dummy1", ++ "primitive_long_id": "dummy1:1", ++ "operation": self.action_up, ++ "on_node": "node4", ++ }, ++ ]) ++ ) ++ ++ def test_master_stop(self): ++ self.assertEqual( ++ ["dummy1"], ++ self.call([ ++ { ++ "primitive_id": "dummy1", ++ "primitive_long_id": "dummy1:0", ++ "operation": self.action_down, ++ "on_node": "node3", ++ }, ++ { ++ "primitive_id": "dummy1", ++ "primitive_long_id": "dummy1:1", ++ "operation": self.action_up, ++ "on_node": "node4", ++ }, ++ { ++ "primitive_id": "dummy1", ++ "primitive_long_id": "dummy1:2", ++ "operation": self.action_down, ++ "on_node": "node1", ++ }, ++ ]) ++ ) +diff --git a/pcs/lib/reports.py b/pcs/lib/reports.py +index 1408f620..3c5380a7 100644 +--- a/pcs/lib/reports.py ++++ b/pcs/lib/reports.py +@@ -1406,6 +1406,19 @@ def cib_diff_error(reason, cib_old, cib_new): + } + ) + ++def cib_simulate_error(reason): ++ """ ++ cannot simulate effects a CIB would have on a live cluster ++ ++ string reason -- error description ++ """ ++ return ReportItem.error( ++ report_codes.CIB_SIMULATE_ERROR, ++ info={ ++ "reason": reason, ++ } ++ ) ++ + def cib_push_forced_full_due_to_crm_feature_set(required_set, current_set): + """ + Pcs uses the "push full CIB" approach so race conditions may occur. +@@ -3021,3 +3034,25 @@ def fence_history_not_supported(): + return ReportItem.error( + report_codes.FENCE_HISTORY_NOT_SUPPORTED + ) ++ ++def resource_disable_affects_other_resources( ++ disabled_resource_list, ++ affected_resource_list, ++ crm_simulate_plaintext_output ++): ++ """ ++ User requested disabling resources without affecting other resources but ++ some resources would be affected ++ ++ iterable disabled_resource_list -- list of resources to disable ++ iterable affected_resource_list -- other affected resources ++ string crm_simulate_plaintext_output -- plaintext output from pacemaker ++ """ ++ return ReportItem.error( ++ report_codes.RESOURCE_DISABLE_AFFECTS_OTHER_RESOURCES, ++ info={ ++ "disabled_resource_list": sorted(disabled_resource_list), ++ "affected_resource_list": sorted(affected_resource_list), ++ "crm_simulate_plaintext_output": crm_simulate_plaintext_output, ++ } ++ ) +diff --git a/pcs/lib/tools.py b/pcs/lib/tools.py +index 0d0dc461..55983af9 100644 +--- a/pcs/lib/tools.py ++++ b/pcs/lib/tools.py +@@ -64,6 +64,7 @@ def write_tmpfile(data, binary=False): + """ + mode = "w+b" if binary else "w+" + tmpfile = tempfile.NamedTemporaryFile(mode=mode, suffix=".pcs") +- tmpfile.write(data) +- tmpfile.flush() ++ if data is not None: ++ tmpfile.write(data) ++ tmpfile.flush() + return tmpfile +diff --git a/pcs/pcs.8 b/pcs/pcs.8 +index 0e8b15f7..596ceb7a 100644 +--- a/pcs/pcs.8 ++++ b/pcs/pcs.8 +@@ -90,8 +90,27 @@ Deletes the resource, group, master or clone (and all resources within the group + enable ... [\fB\-\-wait\fR[=n]] + Allow the cluster to start the resources. Depending on the rest of the configuration (constraints, options, failures, etc), the resources may remain stopped. If \fB\-\-wait\fR is specified, pcs will wait up to 'n' seconds for the resources to start and then return 0 if the resources are started, or 1 if the resources have not yet started. If 'n' is not specified it defaults to 60 minutes. + .TP +-disable ... [\fB\-\-wait\fR[=n]] +-Attempt to stop the resources if they are running and forbid the cluster from starting them again. Depending on the rest of the configuration (constraints, options, failures, etc), the resources may remain started. If \fB\-\-wait\fR is specified, pcs will wait up to 'n' seconds for the resources to stop and then return 0 if the resources are stopped or 1 if the resources have not stopped. If 'n' is not specified it defaults to 60 minutes. ++disable ... [\fB\-\-safe\fR [\fB\-\-no\-strict\fR]] [\fB\-\-simulate\fR] [\fB\-\-wait\fR[=n]] ++Attempt to stop the resources if they are running and forbid the cluster from starting them again. Depending on the rest of the configuration (constraints, options, failures, etc), the resources may remain started. ++.br ++If \fB\-\-safe\fR is specified, no changes to the cluster configuration will be made if other than specified resources would be affected in any way. ++.br ++If \fB\-\-no\-strict\fR is specified, no changes to the cluster configuration will be made if other than specified resources would get stopped or demoted. Moving resources between nodes is allowed. ++.br ++If \fB\-\-simulate\fR is specified, no changes to the cluster configuration will be made and the effect of the changes will be printed instead. ++.br ++If \fB\-\-wait\fR is specified, pcs will wait up to 'n' seconds for the resources to stop and then return 0 if the resources are stopped or 1 if the resources have not stopped. If 'n' is not specified it defaults to 60 minutes. ++.TP ++safe\-disable ... [\fB\-\-no\-strict\fR] [\fB\-\-simulate\fR] [\fB\-\-wait\fR[=n]] [\fB\-\-force\fR] ++Attempt to stop the resources if they are running and forbid the cluster from starting them again. Depending on the rest of the configuration (constraints, options, failures, etc), the resources may remain started. No changes to the cluster configuration will be made if other than specified resources would be affected in any way. ++.br ++If \fB\-\-no\-strict\fR is specified, no changes to the cluster configuration will be made if other than specified resources would get stopped or demoted. Moving resources between nodes is allowed. ++.br ++If \fB\-\-simulate\fR is specified, no changes to the cluster configuration will be made and the effect of the changes will be printed instead. ++.br ++If \fB\-\-wait\fR is specified, pcs will wait up to 'n' seconds for the resources to stop and then return 0 if the resources are stopped or 1 if the resources have not stopped. If 'n' is not specified it defaults to 60 minutes. ++.br ++If \fB\-\-force\fR is specified, checks for safe disable will be skipped. + .TP + restart [node] [\fB\-\-wait\fR=n] + Restart the resource specified. If a node is specified and if the resource is a clone or master/slave it will be restarted only on the node specified. If \fB\-\-wait\fR is specified, then we will wait up to 'n' seconds for the resource to be restarted and return 0 if the restart was successful or 1 if it was not. +diff --git a/pcs/resource.py b/pcs/resource.py +index 27af2405..51233a12 100644 +--- a/pcs/resource.py ++++ b/pcs/resource.py +@@ -137,6 +137,8 @@ def resource_cmd(argv): + resource_enable_cmd(lib, argv_next, modifiers) + elif sub_cmd == "disable": + resource_disable_cmd(lib, argv_next, modifiers) ++ elif sub_cmd == "safe-disable": ++ resource_safe_disable_cmd(lib, argv_next, modifiers) + elif sub_cmd == "restart": + resource_restart(argv_next) + elif sub_cmd == "debug-start": +@@ -2034,11 +2036,53 @@ def resource_show(argv, stonith=False): + utils.err("unable to find resource '"+arg+"'") + resource_found = False + ++ + def resource_disable_cmd(lib, argv, modifiers): +- if len(argv) < 1: +- utils.err("You must specify resource(s) to disable") +- resources = argv +- lib.resource.disable(resources, modifiers["wait"]) ++ """ ++ Options: ++ * -f - CIB file ++ * --safe - only disable if no other resource gets stopped or demoted ++ * --simulate - do not push the CIB, print its effects ++ * --no-strict - allow disable if other resource is affected ++ * --wait ++ """ ++ if not argv: ++ raise CmdLineInputError("You must specify resource(s) to disable") ++ ++ if modifiers["simulate"]: ++ print(lib.resource.disable_simulate(argv)) ++ return ++ if modifiers["safe"] or modifiers["no-strict"]: ++ lib.resource.disable_safe( ++ argv, ++ not modifiers["no-strict"], ++ modifiers["wait"], ++ ) ++ return ++ lib.resource.disable(argv, modifiers["wait"]) ++ ++ ++def resource_safe_disable_cmd(lib, argv, modifiers): ++ """ ++ Options: ++ * --force - skip checks for safe resource disable ++ * --no-strict - allow disable if other resource is affected ++ * --simulate - do not push the CIB, print its effects ++ * --wait ++ """ ++ if modifiers["safe"]: ++ raise CmdLineInputError( ++ "Option '--safe' is not supported in this command" ++ ) ++ if modifiers["force"]: ++ warn( ++ "option '--force' is specified therefore checks for disabling " ++ "resource safely will be skipped" ++ ) ++ elif not modifiers["simulate"]: ++ modifiers["safe"] = True ++ resource_disable_cmd(lib, argv, modifiers) ++ + + def resource_enable_cmd(lib, argv, modifiers): + if len(argv) < 1: +diff --git a/pcs/test/test_resource.py b/pcs/test/test_resource.py +index 20f6af73..2a110683 100644 +--- a/pcs/test/test_resource.py ++++ b/pcs/test/test_resource.py +@@ -32,6 +32,7 @@ from pcs.test.bin_mock import get_mock_settings + + from pcs import utils + from pcs import resource ++from pcs.cli.common.errors import CmdLineInputError + + empty_cib = rc("cib-empty.xml") + temp_cib = rc("temp-cib.xml") +@@ -6450,3 +6451,197 @@ class FailcountShow(TestCase): + ), + full=True + ) ++ ++ ++class ResourceDisable(TestCase): ++ def setUp(self): ++ self.lib = mock.Mock(spec_set=["resource"]) ++ self.resource = mock.Mock( ++ spec_set=["disable", "disable_safe", "disable_simulate"] ++ ) ++ self.lib.resource = self.resource ++ ++ def run_cmd(self, argv, modifiers=None): ++ default_modifiers = { ++ "safe": False, ++ "simulate": False, ++ "no-strict": False, ++ "wait": False, ++ } ++ if modifiers: ++ default_modifiers.update(modifiers) ++ resource.resource_disable_cmd(self.lib, argv, default_modifiers) ++ ++ def test_no_args(self): ++ with self.assertRaises(CmdLineInputError) as cm: ++ self.run_cmd([]) ++ self.assertEqual( ++ cm.exception.message, ++ "You must specify resource(s) to disable" ++ ) ++ self.resource.disable.assert_not_called() ++ self.resource.disable_safe.assert_not_called() ++ self.resource.disable_simulate.assert_not_called() ++ ++ def test_one_resource(self): ++ self.run_cmd(["R1"]) ++ self.resource.disable.assert_called_once_with(["R1"], False) ++ self.resource.disable_safe.assert_not_called() ++ self.resource.disable_simulate.assert_not_called() ++ ++ def test_more_resources(self): ++ self.run_cmd(["R1", "R2"]) ++ self.resource.disable.assert_called_once_with(["R1", "R2"], False) ++ self.resource.disable_safe.assert_not_called() ++ self.resource.disable_simulate.assert_not_called() ++ ++ def test_safe(self): ++ self.run_cmd(["R1", "R2"], dict(safe=True)) ++ self.resource.disable_safe.assert_called_once_with( ++ ["R1", "R2"], True, False ++ ) ++ self.resource.disable.assert_not_called() ++ self.resource.disable_simulate.assert_not_called() ++ ++ def test_safe_wait(self): ++ self.run_cmd(["R1", "R2"], dict(safe=True, wait="10")) ++ self.resource.disable_safe.assert_called_once_with( ++ ["R1", "R2"], True, "10" ++ ) ++ self.resource.disable.assert_not_called() ++ self.resource.disable_simulate.assert_not_called() ++ ++ def test_safe_no_strict(self): ++ self.run_cmd(["R1", "R2"], {"no-strict": True}) ++ self.resource.disable_safe.assert_called_once_with( ++ ["R1", "R2"], False, False ++ ) ++ self.resource.disable.assert_not_called() ++ self.resource.disable_simulate.assert_not_called() ++ ++ def test_safe_no_strict_wait(self): ++ self.run_cmd(["R1", "R2"], {"no-strict": True, "wait": "10"}) ++ self.resource.disable_safe.assert_called_once_with( ++ ["R1", "R2"], False, "10" ++ ) ++ self.resource.disable.assert_not_called() ++ self.resource.disable_simulate.assert_not_called() ++ ++ @mock.patch("pcs.resource.print") ++ def test_simulate(self, mock_print): ++ self.resource.disable_simulate.return_value = "simulate output" ++ self.run_cmd(["R1", "R2"], dict(simulate=True)) ++ self.resource.disable_simulate.assert_called_once_with(["R1", "R2"]) ++ self.resource.disable.assert_not_called() ++ self.resource.disable_safe.assert_not_called() ++ mock_print.assert_called_once_with("simulate output") ++ ++ def test_wait(self): ++ self.run_cmd(["R1", "R2"], dict(wait="10")) ++ self.resource.disable.assert_called_once_with(["R1", "R2"], "10") ++ self.resource.disable_safe.assert_not_called() ++ self.resource.disable_simulate.assert_not_called() ++ ++ ++class ResourceSafeDisable(TestCase): ++ def setUp(self): ++ self.lib = mock.Mock(spec_set=["resource"]) ++ self.resource = mock.Mock( ++ spec_set=["disable", "disable_safe", "disable_simulate"] ++ ) ++ self.lib.resource = self.resource ++ self.force_warning = ( ++ "option '--force' is specified therefore checks for disabling " ++ "resource safely will be skipped" ++ ) ++ ++ def run_cmd(self, argv, modifiers=None): ++ default_modifiers = { ++ "safe": False, ++ "simulate": False, ++ "no-strict": False, ++ "wait": False, ++ "force": False, ++ } ++ if modifiers: ++ default_modifiers.update(modifiers) ++ resource.resource_safe_disable_cmd(self.lib, argv, default_modifiers) ++ ++ def test_no_args(self): ++ with self.assertRaises(CmdLineInputError) as cm: ++ self.run_cmd([]) ++ self.assertEqual( ++ cm.exception.message, ++ "You must specify resource(s) to disable" ++ ) ++ self.resource.disable.assert_not_called() ++ self.resource.disable_safe.assert_not_called() ++ self.resource.disable_simulate.assert_not_called() ++ ++ def test_one_resource(self): ++ self.run_cmd(["R1"]) ++ self.resource.disable_safe.assert_called_once_with(["R1"], True, False) ++ self.resource.disable.assert_not_called() ++ self.resource.disable_simulate.assert_not_called() ++ ++ def test_more_resources(self): ++ self.run_cmd(["R1", "R2"]) ++ self.resource.disable_safe.assert_called_once_with( ++ ["R1", "R2"], True, False ++ ) ++ self.resource.disable.assert_not_called() ++ self.resource.disable_simulate.assert_not_called() ++ ++ def test_wait(self): ++ self.run_cmd(["R1", "R2"], dict(wait="10")) ++ self.resource.disable_safe.assert_called_once_with( ++ ["R1", "R2"], True, "10" ++ ) ++ self.resource.disable.assert_not_called() ++ self.resource.disable_simulate.assert_not_called() ++ ++ def test_no_strict(self): ++ self.run_cmd(["R1", "R2"], {"no-strict": True}) ++ self.resource.disable_safe.assert_called_once_with( ++ ["R1", "R2"], False, False ++ ) ++ self.resource.disable.assert_not_called() ++ self.resource.disable_simulate.assert_not_called() ++ ++ def test_no_strict_wait(self): ++ self.run_cmd(["R1", "R2"], {"no-strict": True, "wait": "10"}) ++ self.resource.disable_safe.assert_called_once_with( ++ ["R1", "R2"], False, "10" ++ ) ++ self.resource.disable.assert_not_called() ++ self.resource.disable_simulate.assert_not_called() ++ ++ @mock.patch("pcs.resource.warn") ++ def test_force(self, mock_warn): ++ self.run_cmd(["R1", "R2"], {"force": True}) ++ self.resource.disable.assert_called_once_with( ++ ["R1", "R2"], False ++ ) ++ self.resource.disable_safe.assert_not_called() ++ self.resource.disable_simulate.assert_not_called() ++ mock_warn.assert_called_once_with(self.force_warning) ++ ++ @mock.patch("pcs.resource.warn") ++ def test_force_wait(self, mock_warn): ++ self.run_cmd(["R1", "R2"], {"force": True, "wait": "10"}) ++ self.resource.disable.assert_called_once_with( ++ ["R1", "R2"], "10" ++ ) ++ self.resource.disable_safe.assert_not_called() ++ self.resource.disable_simulate.assert_not_called() ++ mock_warn.assert_called_once_with(self.force_warning) ++ ++ ++ @mock.patch("pcs.resource.print") ++ def test_simulate(self, mock_print): ++ self.resource.disable_simulate.return_value = "simulate output" ++ self.run_cmd(["R1", "R2"], dict(simulate=True)) ++ self.resource.disable_simulate.assert_called_once_with(["R1", "R2"]) ++ self.resource.disable.assert_not_called() ++ self.resource.disable_safe.assert_not_called() ++ mock_print.assert_called_once_with("simulate output") +diff --git a/pcs/test/tools/command_env/config_runner_pcmk.py b/pcs/test/tools/command_env/config_runner_pcmk.py +index e9cc6397..e561dd5b 100644 +--- a/pcs/test/tools/command_env/config_runner_pcmk.py ++++ b/pcs/test/tools/command_env/config_runner_pcmk.py +@@ -7,8 +7,12 @@ import os + + from lxml import etree + +-from pcs.test.tools.command_env.mock_runner import Call as RunnerCall ++from pcs.test.tools.command_env.mock_runner import ( ++ Call as RunnerCall, ++ CheckStdinEqualXml, ++) + from pcs.test.tools.fixture import complete_state_resources ++from pcs.test.tools.fixture_cib import modify_cib + from pcs.test.tools.misc import get_test_resource as rc + from pcs.test.tools.xml import etree_to_str + +@@ -340,3 +344,52 @@ class PcmkShortcuts(object): + name, + RunnerCall("crm_node --force --remove {0}".format(node_name)), + ) ++ ++ def simulate_cib( ++ self, new_cib_filepath, transitions_filepath, ++ cib_modifiers=None, cib_load_name="runner.cib.load", ++ stdout="", stderr="", returncode=0, ++ name="runner.pcmk.simulate_cib", ++ **modifier_shortcuts ++ ): ++ """ ++ Create a call for simulating effects of cib changes ++ ++ string new_cib_filepath -- a temp file for storing a new cib ++ string transitions_filepath -- a temp file for storing transitions ++ list of callable modifiers -- every callable takes etree.Element and ++ returns new etree.Element with desired modification ++ string cib_load_name -- key of a call from whose stdout the cib is taken ++ string stdout -- pacemaker's stdout ++ string stderr -- pacemaker's stderr ++ int returncode -- pacemaker's returncode ++ string name -- key of the call ++ dict modifier_shortcuts -- a new modifier is generated from each ++ modifier shortcut. ++ As key there can be keys of MODIFIER_GENERATORS. ++ Value is passed into appropriate generator from MODIFIER_GENERATORS. ++ For details see pcs_test.tools.fixture_cib (mainly the variable ++ MODIFIER_GENERATORS - please refer it when you are adding params ++ here) ++ """ ++ cib_xml = modify_cib( ++ self.__calls.get(cib_load_name).stdout, ++ cib_modifiers, ++ **modifier_shortcuts ++ ) ++ cmd = [ ++ "crm_simulate", "--simulate", ++ "--save-output", new_cib_filepath, ++ "--save-graph", transitions_filepath, ++ "--xml-pipe", ++ ] ++ self.__calls.place( ++ name, ++ RunnerCall( ++ " ".join(cmd), ++ stdout=stdout, ++ stderr=stderr, ++ returncode=returncode, ++ check_stdin=CheckStdinEqualXml(cib_xml), ++ ), ++ ) +diff --git a/pcs/test/tools/command_env/mock_runner.py b/pcs/test/tools/command_env/mock_runner.py +index 393e4995..41a09ea4 100644 +--- a/pcs/test/tools/command_env/mock_runner.py ++++ b/pcs/test/tools/command_env/mock_runner.py +@@ -12,6 +12,22 @@ from pcs.test.tools.assertions import assert_xml_equal + + CALL_TYPE_RUNNER = "CALL_TYPE_RUNNER" + ++ ++class CheckStdinEqualXml(object): ++ def __init__(self, expected_stdin): ++ self.expected_stdin = expected_stdin ++ ++ def __call__(self, stdin, command, order_num): ++ assert_xml_equal( ++ self.expected_stdin, ++ stdin, ++ ( ++ "Trying to run command no. {0}" ++ "\n\n '{1}'\n\nwith expected xml stdin.\n" ++ ).format(order_num, command) ++ ) ++ ++ + def create_check_stdin_xml(expected_stdin): + def stdin_xml_check(stdin, command, order_num): + assert_xml_equal( +@@ -62,6 +78,7 @@ COMMAND_COMPLETIONS = { + "crm_mon": path.join(settings.pacemaker_binaries, "crm_mon"), + "crm_node": path.join(settings.pacemaker_binaries, "crm_node"), + "crm_resource": path.join(settings.pacemaker_binaries, "crm_resource"), ++ "crm_simulate": path.join(settings.pacemaker_binaries, "crm_simulate"), + "crm_verify": path.join(settings.pacemaker_binaries, "crm_verify"), + "sbd": settings.sbd_binary, + } +diff --git a/pcs/usage.py b/pcs/usage.py +index 582bc53f..acad3730 100644 +--- a/pcs/usage.py ++++ b/pcs/usage.py +@@ -252,14 +252,40 @@ Commands: + started, or 1 if the resources have not yet started. If 'n' is not + specified it defaults to 60 minutes. + +- disable ... [--wait[=n]] ++ disable ... [--safe [--no-strict]] [--simulate] [--wait[=n]] + Attempt to stop the resources if they are running and forbid the + cluster from starting them again. Depending on the rest of the + configuration (constraints, options, failures, etc), the resources may +- remain started. If --wait is specified, pcs will wait up to 'n' seconds +- for the resources to stop and then return 0 if the resources are +- stopped or 1 if the resources have not stopped. If 'n' is not specified +- it defaults to 60 minutes. ++ remain started. ++ If --safe is specified, no changes to the cluster configuration will be ++ made if other than specified resources would be affected in any way. ++ If --no-strict is specified, no changes to the cluster configuration ++ will be made if other than specified resources would get stopped or ++ demoted. Moving resources between nodes is allowed. ++ If --simulate is specified, no changes to the cluster configuration ++ will be made and the effect of the changes will be printed instead. ++ If --wait is specified, pcs will wait up to 'n' seconds for the ++ resources to stop and then return 0 if the resources are stopped or 1 ++ if the resources have not stopped. If 'n' is not specified it defaults ++ to 60 minutes. ++ ++ safe-disable ... [--no-strict] [--simulate] [--wait[=n]] ++ [--force] ++ Attempt to stop the resources if they are running and forbid the ++ cluster from starting them again. Depending on the rest of the ++ configuration (constraints, options, failures, etc), the resources may ++ remain started. No changes to the cluster configuration will be ++ made if other than specified resources would be affected in any way. ++ If --no-strict is specified, no changes to the cluster configuration ++ will be made if other than specified resources would get stopped or ++ demoted. Moving resources between nodes is allowed. ++ If --simulate is specified, no changes to the cluster configuration ++ will be made and the effect of the changes will be printed instead. ++ If --wait is specified, pcs will wait up to 'n' seconds for the ++ resources to stop and then return 0 if the resources are stopped or 1 ++ if the resources have not stopped. If 'n' is not specified it defaults ++ to 60 minutes. ++ If --force is specified, checks for safe disable will be skipped. + + restart [node] [--wait=n] + Restart the resource specified. If a node is specified and if the +diff --git a/pcs/utils.py b/pcs/utils.py +index 11e3b361..66f7ebf1 100644 +--- a/pcs/utils.py ++++ b/pcs/utils.py +@@ -2973,6 +2973,9 @@ def get_modifiers(): + "monitor": "--monitor" in pcs_options, + "name": pcs_options.get("--name", None), + "no-default-ops": "--no-default-ops" in pcs_options, ++ "no-strict": "--no-strict" in pcs_options, ++ "safe": "--safe" in pcs_options, ++ "simulate": "--simulate" in pcs_options, + "skip_offline_nodes": "--skip-offline" in pcs_options, + "start": "--start" in pcs_options, + "wait": pcs_options.get("--wait", False), +diff --git a/pcsd/capabilities.xml b/pcsd/capabilities.xml +index bc1d69c8..322ae300 100644 +--- a/pcsd/capabilities.xml ++++ b/pcsd/capabilities.xml +@@ -1134,6 +1134,20 @@ + pcs commands: resource disable --wait, resource enable --wait + + ++ ++ ++ Do not disable resources if other resources would be affected. ++ ++ pcs commands: resource disable --safe [--no-strict] ++ ++ ++ ++ ++ Show effects caused by disabling resources. ++ ++ pcs commands: resource disable --simulate ++ ++ + + + Put a resource into unmanaged and managed mode. +-- +2.20.1 + diff --git a/SOURCES/bz1770975-01-add-resource-relations-command.patch b/SOURCES/bz1770975-01-add-resource-relations-command.patch new file mode 100644 index 0000000..1ac28a7 --- /dev/null +++ b/SOURCES/bz1770975-01-add-resource-relations-command.patch @@ -0,0 +1,3035 @@ +From 5fa38022bbfe6134f71622174a0961d0e0bc3138 Mon Sep 17 00:00:00 2001 +From: Ondrej Mular +Date: Thu, 14 Nov 2019 13:40:57 +0100 +Subject: [PATCH 1/6] add `resource relations` command + +This new commad displays relations of a specified resource +--- + pcs/cli/common/lib_wrapper.py | 2 + + pcs/cli/common/printable_tree.py | 50 ++ + pcs/cli/common/test/test_printable_tree.py | 216 ++++++ + pcs/cli/resource/relations.py | 202 +++++ + pcs/cli/resource/test/test_relations.py | 492 +++++++++++++ + pcs/common/interface/__init__.py | 0 + pcs/common/interface/dto.py | 18 + + pcs/common/pacemaker/__init__.py | 0 + pcs/common/pacemaker/resource/__init__.py | 0 + pcs/common/pacemaker/resource/relations.py | 66 ++ + .../pacemaker/resource/test/__init__.py | 0 + .../pacemaker/resource/test/test_relations.py | 169 +++++ + pcs/lib/cib/resource/__init__.py | 1 + + pcs/lib/cib/resource/common.py | 48 ++ + pcs/lib/cib/resource/relations.py | 265 +++++++ + pcs/lib/cib/test/test_resource_common.py | 174 ++++- + pcs/lib/cib/test/test_resource_relations.py | 690 ++++++++++++++++++ + pcs/lib/commands/resource.py | 22 + + .../test/resource/test_resource_relations.py | 300 ++++++++ + pcs/pcs.8 | 3 + + pcs/resource.py | 3 + + pcs/test/tools/fixture_cib.py | 3 + + pcs/usage.py | 7 + + pcsd/capabilities.xml | 8 + + test/centos7/Dockerfile | 4 +- + 25 files changed, 2729 insertions(+), 14 deletions(-) + create mode 100644 pcs/cli/common/printable_tree.py + create mode 100644 pcs/cli/common/test/test_printable_tree.py + create mode 100644 pcs/cli/resource/relations.py + create mode 100644 pcs/cli/resource/test/test_relations.py + create mode 100644 pcs/common/interface/__init__.py + create mode 100644 pcs/common/interface/dto.py + create mode 100644 pcs/common/pacemaker/__init__.py + create mode 100644 pcs/common/pacemaker/resource/__init__.py + create mode 100644 pcs/common/pacemaker/resource/relations.py + create mode 100644 pcs/common/pacemaker/resource/test/__init__.py + create mode 100644 pcs/common/pacemaker/resource/test/test_relations.py + create mode 100644 pcs/lib/cib/resource/relations.py + create mode 100644 pcs/lib/cib/test/test_resource_relations.py + create mode 100644 pcs/lib/commands/test/resource/test_resource_relations.py + +diff --git a/pcs/cli/common/lib_wrapper.py b/pcs/cli/common/lib_wrapper.py +index bb7ca29d..a6cc0ca8 100644 +--- a/pcs/cli/common/lib_wrapper.py ++++ b/pcs/cli/common/lib_wrapper.py +@@ -348,6 +348,8 @@ def load_module(env, middleware_factory, name): + "get_failcounts": resource.get_failcounts, + "manage": resource.manage, + "unmanage": resource.unmanage, ++ "get_resource_relations_tree": ++ resource.get_resource_relations_tree, + } + ) + +diff --git a/pcs/cli/common/printable_tree.py b/pcs/cli/common/printable_tree.py +new file mode 100644 +index 00000000..f16efc99 +--- /dev/null ++++ b/pcs/cli/common/printable_tree.py +@@ -0,0 +1,50 @@ ++class PrintableTreeNode(object): ++ @property ++ def members(self): ++ raise NotImplementedError() ++ ++ @property ++ def detail(self): ++ raise NotImplementedError() ++ ++ @property ++ def is_leaf(self): ++ raise NotImplementedError() ++ ++ def get_title(self, verbose): ++ raise NotImplementedError() ++ ++ ++def tree_to_lines(node, verbose=False, title_prefix="", indent=""): ++ """ ++ Return sequence of strings representing lines to print out tree structure on ++ command line. ++ """ ++ result = [] ++ note = "" ++ if node.is_leaf: ++ note = " [displayed elsewhere]" ++ title = node.get_title(verbose) ++ result.append("{}{}{}".format(title_prefix, title, note)) ++ if node.is_leaf: ++ return result ++ _indent = "| " ++ if not node.members: ++ _indent = " " ++ for line in node.detail: ++ result.append("{}{}{}".format(indent, _indent, line)) ++ _indent = "| " ++ _title_prefix = "|- " ++ for member in node.members: ++ if member == node.members[-1]: ++ _indent = " " ++ _title_prefix = "`- " ++ result.extend( ++ tree_to_lines( ++ member, ++ verbose, ++ indent="{}{}".format(indent, _indent), ++ title_prefix="{}{}".format(indent, _title_prefix), ++ ) ++ ) ++ return result +diff --git a/pcs/cli/common/test/test_printable_tree.py b/pcs/cli/common/test/test_printable_tree.py +new file mode 100644 +index 00000000..6f4757ee +--- /dev/null ++++ b/pcs/cli/common/test/test_printable_tree.py +@@ -0,0 +1,216 @@ ++from pcs.test.tools.pcs_unittest import TestCase ++# from unittest import TestCase ++ ++from pcs.cli.common import printable_tree as lib ++ ++ ++class Node(object): ++ def __init__(self, title, detail, is_leaf, members): ++ self.title = title ++ self.detail = detail ++ self.is_leaf = is_leaf ++ self.members = members ++ ++ def get_title(self, verbose): ++ detail = " (verbose)" if verbose else "" ++ return "{}{}".format(self.title, detail) ++ ++def node(an_id, detail=0, leaf=False, members=None): ++ return Node( ++ "{}-title".format(an_id), ++ ["{}-detail{}".format(an_id, i) for i in range(detail)], ++ leaf, ++ members=members or [], ++ ) ++ ++class TreeToLines(TestCase): ++ def test_verbose(self): ++ self.assertEqual( ++ ["l0-title (verbose)"], lib.tree_to_lines(node("l0"), verbose=True) ++ ) ++ ++ def test_empty(self): ++ self.assertEqual(["l0-title"], lib.tree_to_lines(node("l0"))) ++ ++ def test_empty_leaf(self): ++ self.assertEqual( ++ ["l0-title [displayed elsewhere]"], ++ lib.tree_to_lines(node("l0", leaf=True)), ++ ) ++ ++ def test_detail_simple(self): ++ self.assertEqual( ++ [ ++ "l0-title", ++ " l0-detail0", ++ ], ++ lib.tree_to_lines(node("l0", 1)), ++ ) ++ ++ def test_detail(self): ++ self.assertEqual( ++ [ ++ "l0-title", ++ " l0-detail0", ++ " l0-detail1", ++ " l0-detail2", ++ ], ++ lib.tree_to_lines(node("l0", 3)), ++ ) ++ ++ def test_detail_leaf(self): ++ self.assertEqual( ++ ["l0-title [displayed elsewhere]"], ++ lib.tree_to_lines(node("l0", 3, True)), ++ ) ++ ++ def test_one_member(self): ++ self.assertEqual( ++ [ ++ "l0-title", ++ "`- l1-title", ++ ], ++ lib.tree_to_lines(node("l0", members=[node("l1")])), ++ ) ++ ++ def test_one_member_leaf(self): ++ self.assertEqual( ++ ["l0-title [displayed elsewhere]"], ++ lib.tree_to_lines(node("l0", leaf=True, members=[node("l1")])), ++ ) ++ ++ def test_multiple_members(self): ++ self.assertEqual( ++ [ ++ "l0-title", ++ "|- l1-title", ++ "|- l2-title", ++ "`- l3-title", ++ ], ++ lib.tree_to_lines( ++ node("l0", members=[node("l1"), node("l2"), node("l3")]) ++ ), ++ ) ++ ++ def test_multiple_members_detail(self): ++ self.assertEqual( ++ [ ++ "l0-title", ++ "| l0-detail0", ++ "| l0-detail1", ++ "|- l1-title", ++ "|- l2-title", ++ "`- l3-title", ++ ], ++ lib.tree_to_lines( ++ node( ++ "l0", detail=2, members=[node("l1"), node("l2"), node("l3")] ++ ) ++ ), ++ ) ++ ++ def test_multiple_members_detail_leaf(self): ++ self.assertEqual( ++ ["l0-title [displayed elsewhere]"], ++ lib.tree_to_lines( ++ node("l0", 2, True, [node("l1"), node("l2"), node("l3")]) ++ ), ++ ) ++ ++ def test_complex_tree_wide(self): ++ self.assertEqual( ++ [ ++ "0-title", ++ "| 0-detail0", ++ "| 0-detail1", ++ "|- 00-title", ++ "| 00-detail0", ++ "| 00-detail1", ++ "|- 01-title", ++ "| |- 010-title", ++ "| | `- 0100-title [displayed elsewhere]", ++ "| `- 011-title", ++ "|- 02-title", ++ "| | 02-detail0", ++ "| | 02-detail1", ++ "| | 02-detail2", ++ "| |- 020-title", ++ "| `- 021-title", ++ "| | 021-detail0", ++ "| |- 0210-title [displayed elsewhere]", ++ "| |- 0211-title", ++ "| | 0211-detail0", ++ "| | 0211-detail1", ++ "| `- 0212-title", ++ "| `- 02120-title", ++ "|- 03-title", ++ "| `- 030-title", ++ "| `- 0300-title", ++ "|- 04-title [displayed elsewhere]", ++ "`- 05-title", ++ " 05-detail0", ++ ], ++ lib.tree_to_lines( ++ node( ++ "0", ++ 2, ++ members=[ ++ node("00", 2), ++ node( ++ "01", members=[ ++ node("010", members=[node("0100", leaf=True)]), ++ node("011"), ++ ], ++ ), ++ node( ++ "02", 3, members=[ ++ node("020"), ++ node( ++ "021", 1, members=[ ++ node("0210", leaf=True), ++ node("0211", 2), ++ node("0212", members=[node("02120")]), ++ ], ++ ), ++ ], ++ ), ++ node( ++ "03", members=[node("030", members=[node("0300")])], ++ ), ++ node("04", leaf=True), ++ node("05", 1), ++ ], ++ ) ++ ), ++ ) ++ ++ def test_complex_tree_deep(self): ++ self.assertEqual( ++ [ ++ "0-title", ++ "| 0-detail0", ++ "`- 00-title", ++ " |- 000-title", ++ " | 000-detail0", ++ " | 000-detail1", ++ " `- 001-title", ++ " |- 0010-title [displayed elsewhere]", ++ " |- 0011-title", ++ " | 0011-detail0", ++ " | 0011-detail1", ++ " `- 0012-title", ++ " `- 00120-title", ++ ], ++ lib.tree_to_lines( ++ node("0", 1, members=[ ++ node("00", members=[ ++ node("000", 2), ++ node("001", members=[ ++ node("0010", leaf=True), ++ node("0011", 2), ++ node("0012", members=[node("00120")]), ++ ]), ++ ]), ++ ]) ++ ), ++ ) +diff --git a/pcs/cli/resource/relations.py b/pcs/cli/resource/relations.py +new file mode 100644 +index 00000000..07d34d4d +--- /dev/null ++++ b/pcs/cli/resource/relations.py +@@ -0,0 +1,202 @@ ++from __future__ import print_function ++ ++from pcs.common.pacemaker.resource.relations import ( ++ ResourceRelationDto, ++ ResourceRelationType, ++) ++from pcs.cli.common.console_report import format_optional ++from pcs.cli.common.errors import CmdLineInputError ++from pcs.cli.common.printable_tree import ( ++ tree_to_lines, ++ PrintableTreeNode, ++) ++ ++ ++def show_resource_relations_cmd(lib, argv, modifiers): ++ """ ++ Options: ++ * -f - CIB file ++ * --full - show constraint ids and resource types ++ """ ++ if len(argv) != 1: ++ raise CmdLineInputError() ++ tree = ResourcePrintableNode.from_dto( ++ ResourceRelationDto.from_dict( ++ lib.resource.get_resource_relations_tree(argv[0]) ++ ) ++ ) ++ for line in tree_to_lines(tree, verbose=modifiers["full"]): ++ print(line) ++ ++ ++class ResourceRelationBase(PrintableTreeNode): ++ def __init__( self, relation_entity, members, is_leaf): ++ self._relation_entity = relation_entity ++ self._members = members ++ self._is_leaf = is_leaf ++ ++ @property ++ def is_leaf(self): ++ return self._is_leaf ++ ++ @property ++ def relation_entity(self): ++ return self._relation_entity ++ ++ @property ++ def members(self): ++ return self._members ++ ++ @property ++ def detail(self): ++ raise NotImplementedError() ++ ++ def get_title(self, verbose): ++ raise NotImplementedError() ++ ++ ++class ResourcePrintableNode(ResourceRelationBase): ++ @classmethod ++ def from_dto(cls, resource_dto): ++ def _relation_comparator(item): ++ type_priorities = ( ++ ResourceRelationType.INNER_RESOURCES, ++ ResourceRelationType.OUTER_RESOURCE, ++ ResourceRelationType.ORDER, ++ ResourceRelationType.ORDER_SET, ++ ) ++ priority_map = { ++ _type: value for value, _type in enumerate(type_priorities) ++ } ++ return "{_type}_{_id}".format( ++ _type=priority_map.get( ++ # Hardcoded number 9 is intentional. If there is more than ++ # 10 items, it would be required to also prepend zeros for ++ # lower numbers. E.g: if there is 100 options, it should ++ # starts as 000, 001, ... ++ item.relation_entity.type, 9 # type: ignore ++ ), ++ _id=item.relation_entity.id ++ ) ++ ++ return cls( ++ resource_dto.relation_entity, ++ sorted( ++ [ ++ RelationPrintableNode.from_dto(member_dto) ++ for member_dto in resource_dto.members ++ ], ++ key=_relation_comparator, ++ ), ++ resource_dto.is_leaf ++ ) ++ ++ def get_title(self, verbose): ++ rsc_type = self._relation_entity.type ++ metadata = self._relation_entity.metadata ++ if rsc_type == "primitive": ++ rsc_type = "{_class}{_provider}{_type}".format( ++ _class=format_optional(metadata.get("class"), "{}:"), ++ _provider=format_optional(metadata.get("provider"), "{}:"), ++ _type=metadata.get("type"), ++ ) ++ detail = " (resource: {})".format(rsc_type) if verbose else "" ++ return "{}{}".format(self._relation_entity.id, detail) ++ ++ @property ++ def detail(self): ++ return [] ++ ++ ++class RelationPrintableNode(ResourceRelationBase): ++ @classmethod ++ def from_dto(cls, relation_dto): ++ return cls( ++ relation_dto.relation_entity, ++ sorted( ++ [ ++ ResourcePrintableNode.from_dto(member_dto) ++ for member_dto in relation_dto.members ++ ], ++ key=lambda item: item.relation_entity.id, ++ ), ++ relation_dto.is_leaf ++ ) ++ ++ def get_title(self, verbose): ++ rel_type_map = { ++ ResourceRelationType.ORDER: "order", ++ ResourceRelationType.ORDER_SET: "order set", ++ ResourceRelationType.INNER_RESOURCES: "inner resource(s)", ++ ResourceRelationType.OUTER_RESOURCE: "outer resource", ++ } ++ detail = ( ++ " ({})".format(self._relation_entity.metadata.get("id")) ++ if verbose ++ else "" ++ ) ++ return "{type}{detail}".format( ++ type=rel_type_map.get(self._relation_entity.type, ""), ++ detail=detail, ++ ) ++ ++ @property ++ def detail(self): ++ ent = self._relation_entity ++ if ent.type == ResourceRelationType.ORDER: ++ return _order_metadata_to_str(ent.metadata) ++ if ent.type == ResourceRelationType.ORDER_SET: ++ return _order_set_metadata_to_str(ent.metadata) ++ if ( ++ ent.type == ResourceRelationType.INNER_RESOURCES ++ and ++ len(ent.members) > 1 ++ ): ++ return ["members: {}".format(" ".join(ent.members))] ++ return [] ++ ++ ++def _order_metadata_to_str(metadata): ++ return [ ++ "{action1} {resource1} then {action2} {resource2}".format( ++ action1=metadata["first-action"], ++ resource1=metadata["first"], ++ action2=metadata["then-action"], ++ resource2=metadata["then"], ++ ) ++ ] + _order_common_metadata_to_str(metadata) ++ ++ ++def _order_set_metadata_to_str(metadata): ++ result = [] ++ for res_set in metadata["sets"]: ++ result.append(" set {resources}{options}".format( ++ resources=" ".join(res_set["members"]), ++ options=_resource_set_options_to_str(res_set["metadata"]), ++ )) ++ return _order_common_metadata_to_str(metadata) + result ++ ++ ++def _resource_set_options_to_str(metadata): ++ supported_keys = ( ++ "sequential", "require-all", "ordering", "action", "role", "kind", ++ "score", ++ ) ++ result = _filter_supported_keys(metadata, supported_keys) ++ return " ({})".format(result) if result else "" ++ ++ ++def _filter_supported_keys(data, supported_keys): ++ return " ".join([ ++ "{}={}".format(key, value) ++ for key, value in sorted(data.items()) ++ if key in supported_keys ++ ]) ++ ++ ++def _order_common_metadata_to_str(metadata): ++ result = _filter_supported_keys( ++ metadata, ("symmetrical", "kind", "require-all", "score") ++ ) ++ return [result] if result else [] ++ +diff --git a/pcs/cli/resource/test/test_relations.py b/pcs/cli/resource/test/test_relations.py +new file mode 100644 +index 00000000..77c1c782 +--- /dev/null ++++ b/pcs/cli/resource/test/test_relations.py +@@ -0,0 +1,492 @@ ++from pcs.test.tools.pcs_unittest import mock, TestCase ++ ++from pcs.common.pacemaker.resource.relations import ( ++ RelationEntityDto, ++ ResourceRelationDto, ++ ResourceRelationType, ++) ++from pcs.cli.common.errors import CmdLineInputError ++from pcs.cli.resource import relations ++ ++ ++DEFAULT_MODIFIERS = {"full": False} ++ ++ ++class ShowResourceRelationsCmd(TestCase): ++ def setUp(self): ++ self.maxDiff = None ++ self.lib_call = mock.Mock() ++ self.lib = mock.Mock(spec_set=["resource"]) ++ self.lib.resource = mock.Mock(spec_set=["get_resource_relations_tree"]) ++ self.lib.resource.get_resource_relations_tree = self.lib_call ++ self.lib_call.return_value = ResourceRelationDto( ++ RelationEntityDto( ++ "d1", "primitive", [], { ++ "class": "ocf", ++ "provider": "pacemaker", ++ "type": "Dummy", ++ } ++ ), ++ [ ++ ResourceRelationDto( ++ RelationEntityDto( ++ "order1", ResourceRelationType.ORDER, [], { ++ "first-action": "start", ++ "first": "d1", ++ "then-action": "start", ++ "then": "d2", ++ "kind": "Mandatory", ++ "symmetrical": "true", ++ } ++ ), ++ [ ++ ResourceRelationDto( ++ RelationEntityDto( ++ "d2", "primitive", [], { ++ "class": "ocf", ++ "provider": "heartbeat", ++ "type": "Dummy", ++ } ++ ), ++ [], ++ False ++ ), ++ ], ++ False ++ ), ++ ResourceRelationDto( ++ RelationEntityDto( ++ "inner:g1", ResourceRelationType.INNER_RESOURCES, [], {} ++ ), ++ [ ++ ResourceRelationDto( ++ RelationEntityDto("g1", "group", [], {}), ++ [], ++ True, ++ ), ++ ], ++ False ++ ) ++ ], ++ False, ++ ).to_dict() ++ ++ def test_no_args(self): ++ with self.assertRaises(CmdLineInputError) as cm: ++ relations.show_resource_relations_cmd( ++ self.lib, [], DEFAULT_MODIFIERS ++ ) ++ self.assertIsNone(cm.exception.message) ++ ++ def test_more_args(self): ++ with self.assertRaises(CmdLineInputError) as cm: ++ relations.show_resource_relations_cmd( ++ self.lib, ["a1", "a2"], DEFAULT_MODIFIERS ++ ) ++ self.assertIsNone(cm.exception.message) ++ ++ @mock.patch("pcs.cli.resource.relations.print") ++ def test_success(self, mock_print): ++ relations.show_resource_relations_cmd( ++ self.lib, ["d1"], DEFAULT_MODIFIERS ++ ) ++ self.lib_call.assert_called_once_with("d1") ++ self.assertEqual( ++ [ ++ mock.call("d1"), ++ mock.call("|- inner resource(s)"), ++ mock.call("| `- g1 [displayed elsewhere]"), ++ mock.call("`- order"), ++ mock.call(" | start d1 then start d2"), ++ mock.call(" | kind=Mandatory symmetrical=true"), ++ mock.call(" `- d2"), ++ ], ++ mock_print.call_args_list ++ ) ++ ++ @mock.patch("pcs.cli.resource.relations.print") ++ def test_verbose(self, mock_print): ++ relations.show_resource_relations_cmd(self.lib, ["d1"], {"full": True}) ++ self.lib_call.assert_called_once_with("d1") ++ self.assertEqual( ++ [ ++ mock.call("d1 (resource: ocf:pacemaker:Dummy)"), ++ mock.call("|- inner resource(s) (None)"), ++ mock.call("| `- g1 (resource: group) [displayed elsewhere]"), ++ mock.call("`- order (None)"), ++ mock.call(" | start d1 then start d2"), ++ mock.call(" | kind=Mandatory symmetrical=true"), ++ mock.call(" `- d2 (resource: ocf:heartbeat:Dummy)"), ++ ], ++ mock_print.call_args_list ++ ) ++ ++ ++def _fixture_dummy(_id): ++ return RelationEntityDto( ++ _id, "primitive", [], { ++ "class": "ocf", ++ "provider": "pacemaker", ++ "type": "Dummy", ++ } ++) ++ ++ ++D1_PRIMITIVE = _fixture_dummy("d1") ++D2_PRIMITIVE = _fixture_dummy("d2") ++ ++ ++def _fixture_res_rel_dto(ent): ++ return ResourceRelationDto(ent, [], True) ++ ++ ++class ResourcePrintableNode(TestCase): ++ def assert_member(self, member, ent): ++ self.assertTrue(isinstance(member, relations.RelationPrintableNode)) ++ self.assertEqual(ent, member.relation_entity) ++ self.assertEqual(True, member.is_leaf) ++ self.assertEqual(0, len(member.members)) ++ ++ def test_from_dto(self): ++ inner_ent = RelationEntityDto( ++ "inner:g1", ResourceRelationType.INNER_RESOURCES, [], {} ++ ) ++ outer_ent = RelationEntityDto( ++ "outer:g1", ResourceRelationType.OUTER_RESOURCE, [], {} ++ ) ++ order_ent1 = RelationEntityDto( ++ "order1", ResourceRelationType.ORDER, [], {} ++ ) ++ order_ent2 = RelationEntityDto( ++ "order2", ResourceRelationType.ORDER, [], {} ++ ) ++ order_set_ent = RelationEntityDto( ++ "order_set", ResourceRelationType.ORDER_SET, [], {} ++ ) ++ ++ dto = ResourceRelationDto( ++ D1_PRIMITIVE, ++ [ ++ _fixture_res_rel_dto(order_set_ent), ++ _fixture_res_rel_dto(order_ent2), ++ _fixture_res_rel_dto(outer_ent), ++ _fixture_res_rel_dto(inner_ent), ++ _fixture_res_rel_dto(order_ent1), ++ ], ++ False, ++ ) ++ obj = relations.ResourcePrintableNode.from_dto(dto) ++ self.assertEqual(D1_PRIMITIVE, obj.relation_entity) ++ self.assertEqual(False, obj.is_leaf) ++ expected_members = ( ++ inner_ent, outer_ent, order_ent1, order_ent2, order_set_ent ++ ) ++ self.assertEqual(len(expected_members), len(obj.members)) ++ for i, member in enumerate(obj.members): ++ self.assert_member(member, expected_members[i]) ++ ++ def test_primitive(self): ++ obj = relations.ResourcePrintableNode(D1_PRIMITIVE, [], False) ++ self.assertEqual( ++ "d1 (resource: ocf:pacemaker:Dummy)", obj.get_title(verbose=True) ++ ) ++ self.assertEqual([], obj.detail) ++ ++ def test_primitive_not_verbose(self): ++ obj = relations.ResourcePrintableNode(D1_PRIMITIVE, [], False) ++ self.assertEqual("d1", obj.get_title(verbose=False)) ++ self.assertEqual([], obj.detail) ++ ++ def test_primitive_without_provider_class(self): ++ obj = relations.ResourcePrintableNode( ++ RelationEntityDto( ++ "d1", "primitive", [], { ++ "type": "Dummy", ++ } ++ ), ++ [], ++ False, ++ ) ++ self.assertEqual("d1 (resource: Dummy)", obj.get_title(verbose=True)) ++ self.assertEqual([], obj.detail) ++ ++ def test_primitive_without_provider(self): ++ obj = relations.ResourcePrintableNode( ++ RelationEntityDto( ++ "d1", "primitive", [], { ++ "class": "ocf", ++ "type": "Dummy", ++ } ++ ), ++ [], ++ False, ++ ) ++ self.assertEqual( ++ "d1 (resource: ocf:Dummy)", obj.get_title(verbose=True) ++ ) ++ self.assertEqual([], obj.detail) ++ ++ def test_primitive_without_class(self): ++ obj = relations.ResourcePrintableNode( ++ RelationEntityDto( ++ "d1", "primitive", [], { ++ "provider": "pacemaker", ++ "type": "Dummy", ++ } ++ ), ++ [], ++ False, ++ ) ++ self.assertEqual( ++ "d1 (resource: pacemaker:Dummy)", obj.get_title(verbose=True) ++ ) ++ self.assertEqual([], obj.detail) ++ ++ def test_other(self): ++ obj = relations.ResourcePrintableNode( ++ RelationEntityDto("an_id", "a_type", [], {}), [], False, ++ ) ++ self.assertEqual( ++ "an_id (resource: a_type)", obj.get_title(verbose=True) ++ ) ++ self.assertEqual([], obj.detail) ++ ++ def test_other_not_verbose(self): ++ obj = relations.ResourcePrintableNode( ++ RelationEntityDto("an_id", "a_type", [], {}), [], False, ++ ) ++ self.assertEqual("an_id", obj.get_title(verbose=False)) ++ self.assertEqual([], obj.detail) ++ ++ ++class RelationPrintableNode(TestCase): ++ def setUp(self): ++ self.order_entity = RelationEntityDto( ++ "order1", ResourceRelationType.ORDER, [], { ++ "id": "order1", ++ "first-action": "start", ++ "first": "d1", ++ "then-action": "start", ++ "then": "d2", ++ } ++ ) ++ self.order_set_entity = RelationEntityDto( ++ "order_set_id", ResourceRelationType.ORDER_SET, [], { ++ "id": "order_set_id", ++ "sets": [ ++ { ++ "members": ["d1", "d2", "d3"], ++ "metadata": {}, ++ }, ++ { ++ "members": ["d4", "d5", "d0"], ++ "metadata": { ++ "sequential": "true", ++ "require-all": "false", ++ "score": "10", ++ }, ++ }, ++ ], ++ } ++ ) ++ ++ def assert_member(self, member, ent): ++ self.assertTrue(isinstance(member, relations.ResourcePrintableNode)) ++ self.assertEqual(ent, member.relation_entity) ++ self.assertEqual(True, member.is_leaf) ++ self.assertEqual(0, len(member.members)) ++ ++ def test_from_dto(self): ++ dto = ResourceRelationDto( ++ self.order_entity, ++ [ ++ ResourceRelationDto(D2_PRIMITIVE, [], True), ++ ResourceRelationDto(D1_PRIMITIVE, [], True), ++ ], ++ False ++ ) ++ obj = relations.RelationPrintableNode.from_dto(dto) ++ self.assertEqual(self.order_entity, obj.relation_entity) ++ self.assertEqual(False, obj.is_leaf) ++ self.assertEqual(2, len(obj.members)) ++ self.assert_member(obj.members[0], D1_PRIMITIVE) ++ self.assert_member(obj.members[1], D2_PRIMITIVE) ++ ++ def test_order_not_verbose(self): ++ obj = relations.RelationPrintableNode(self.order_entity, [], False) ++ self.assertEqual("order", obj.get_title(verbose=False)) ++ self.assertEqual(["start d1 then start d2"], obj.detail) ++ ++ def test_order(self): ++ obj = relations.RelationPrintableNode(self.order_entity, [], False) ++ self.assertEqual("order (order1)", obj.get_title(verbose=True)) ++ self.assertEqual(["start d1 then start d2"], obj.detail) ++ ++ def test_order_full(self): ++ self.order_entity.metadata.update({ ++ "kind": "Optional", ++ "symmetrical": "true", ++ "unsupported": "value", ++ "score": "1000", ++ }) ++ obj = relations.RelationPrintableNode(self.order_entity, [], False) ++ self.assertEqual("order (order1)", obj.get_title(verbose=True)) ++ self.assertEqual( ++ [ ++ "start d1 then start d2", ++ "kind=Optional score=1000 symmetrical=true" ++ ], ++ obj.detail, ++ ) ++ ++ def test_order_set_not_verbose(self): ++ obj = relations.RelationPrintableNode(self.order_set_entity, [], False) ++ self.assertEqual("order set", obj.get_title(verbose=False)) ++ self.assertEqual( ++ [ ++ " set d1 d2 d3", ++ " set d4 d5 d0 (require-all=false score=10 sequential=true)", ++ ], ++ obj.detail, ++ ) ++ ++ def test_order_set(self): ++ obj = relations.RelationPrintableNode(self.order_set_entity, [], False) ++ self.assertEqual( ++ "order set (order_set_id)", obj.get_title(verbose=True) ++ ) ++ self.assertEqual( ++ [ ++ " set d1 d2 d3", ++ " set d4 d5 d0 (require-all=false score=10 sequential=true)", ++ ], ++ obj.detail, ++ ) ++ ++ def test_order_set_full(self): ++ self.order_set_entity.metadata.update({ ++ "symmetrical": "true", ++ "kind": "Optional", ++ "require-all": "true", ++ "score": "100", ++ "unsupported": "value", ++ }) ++ self.order_set_entity.metadata["sets"].append({ ++ "members": ["d9", "d8", "d6", "d7"], ++ "metadata": { ++ "sequential": "true", ++ "require-all": "false", ++ "score": "10", ++ "ordering": "value", ++ "action": "start", ++ "role": "promoted", ++ "kind": "Optional", ++ "unsupported": "value", ++ }, ++ }) ++ obj = relations.RelationPrintableNode(self.order_set_entity, [], False) ++ self.assertEqual( ++ "order set (order_set_id)", obj.get_title(verbose=True) ++ ) ++ self.assertEqual( ++ [ ++ "kind=Optional require-all=true score=100 symmetrical=true", ++ " set d1 d2 d3", ++ " set d4 d5 d0 (require-all=false score=10 sequential=true)", ++ " set d9 d8 d6 d7 (action=start kind=Optional ordering=value " ++ "require-all=false role=promoted score=10 sequential=true)", ++ ], ++ obj.detail, ++ ) ++ ++ def test_multiple_inner_resources(self): ++ obj = relations.RelationPrintableNode( ++ RelationEntityDto( ++ "inner:g1", ++ ResourceRelationType.INNER_RESOURCES, ++ ["m1", "m2", "m0"], ++ {"id": "g1"} ++ ), ++ [], ++ False, ++ ) ++ self.assertEqual("inner resource(s) (g1)", obj.get_title(verbose=True)) ++ self.assertEqual(["members: m1 m2 m0"], obj.detail) ++ ++ def test_inner_resources_not_verbose(self): ++ obj = relations.RelationPrintableNode( ++ RelationEntityDto( ++ "inner:g1", ResourceRelationType.INNER_RESOURCES, ["m0"], { ++ "id": "g1", ++ } ++ ), ++ [], ++ False, ++ ) ++ self.assertEqual("inner resource(s)", obj.get_title(verbose=False)) ++ self.assertEqual([], obj.detail) ++ ++ def test_inner_resources(self): ++ obj = relations.RelationPrintableNode( ++ RelationEntityDto( ++ "inner:g1", ResourceRelationType.INNER_RESOURCES, ["m0"], { ++ "id": "g1", ++ } ++ ), ++ [], ++ False, ++ ) ++ self.assertEqual("inner resource(s) (g1)", obj.get_title(verbose=True)) ++ self.assertEqual([], obj.detail) ++ ++ def test_outer_resourcenot_verbose(self): ++ obj = relations.RelationPrintableNode( ++ RelationEntityDto( ++ "outer:g1", ResourceRelationType.OUTER_RESOURCE, [], { ++ "id": "g1", ++ } ++ ), ++ [], ++ False, ++ ) ++ self.assertEqual("outer resource", obj.get_title(verbose=False)) ++ self.assertEqual([], obj.detail) ++ ++ def test_outer_resource(self): ++ obj = relations.RelationPrintableNode( ++ RelationEntityDto( ++ "outer:g1", ResourceRelationType.OUTER_RESOURCE, [], { ++ "id": "g1", ++ } ++ ), ++ [], ++ False, ++ ) ++ self.assertEqual("outer resource (g1)", obj.get_title(verbose=True)) ++ self.assertEqual([], obj.detail) ++ ++ def test_unknown_not_verbose(self): ++ obj = relations.RelationPrintableNode( ++ RelationEntityDto( ++ "random", "undifined type", [], { ++ "id": "random_id", ++ } ++ ), ++ [], ++ False, ++ ) ++ self.assertEqual("", obj.get_title(verbose=False)) ++ self.assertEqual([], obj.detail) ++ ++ def test_unknown(self): ++ obj = relations.RelationPrintableNode( ++ RelationEntityDto( ++ "random", "undifined type", [], { ++ "id": "random_id", ++ } ++ ), ++ [], ++ False, ++ ) ++ self.assertEqual(" (random_id)", obj.get_title(verbose=True)) ++ self.assertEqual([], obj.detail) +diff --git a/pcs/common/interface/__init__.py b/pcs/common/interface/__init__.py +new file mode 100644 +index 00000000..e69de29b +diff --git a/pcs/common/interface/dto.py b/pcs/common/interface/dto.py +new file mode 100644 +index 00000000..b95367fd +--- /dev/null ++++ b/pcs/common/interface/dto.py +@@ -0,0 +1,18 @@ ++class DataTransferObject(object): ++ def to_dict(self): ++ raise NotImplementedError() ++ ++ @classmethod ++ def from_dict(cls, payload): ++ raise NotImplementedError() ++ ++ ++class ImplementsToDto(object): ++ def to_dto(self): ++ raise NotImplementedError() ++ ++ ++class ImplementsFromDto(object): ++ @classmethod ++ def from_dto(cls, dto_obj): ++ raise NotImplementedError() +diff --git a/pcs/common/pacemaker/__init__.py b/pcs/common/pacemaker/__init__.py +new file mode 100644 +index 00000000..e69de29b +diff --git a/pcs/common/pacemaker/resource/__init__.py b/pcs/common/pacemaker/resource/__init__.py +new file mode 100644 +index 00000000..e69de29b +diff --git a/pcs/common/pacemaker/resource/relations.py b/pcs/common/pacemaker/resource/relations.py +new file mode 100644 +index 00000000..c074a212 +--- /dev/null ++++ b/pcs/common/pacemaker/resource/relations.py +@@ -0,0 +1,66 @@ ++from pcs.common.interface.dto import DataTransferObject ++ ++ ++class ResourceRelationType(object): ++ ORDER = "ORDER" ++ ORDER_SET = "ORDER_SET" ++ INNER_RESOURCES = "INNER_RESOURCES" ++ OUTER_RESOURCE = "OUTER_RESOURCE" ++ ++ ++class RelationEntityDto(DataTransferObject): ++ def __init__(self, id_, type_, members, metadata): ++ # pylint: disable=invalid-name ++ self.id = id_ ++ self.type = type_ ++ self.members = members ++ self.metadata = metadata ++ ++ def __eq__(self, other): ++ return ( ++ isinstance(other, self.__class__) ++ and ++ self.to_dict() == other.to_dict() ++ ) ++ ++ def to_dict(self): ++ return dict( ++ id=self.id, ++ type=self.type, ++ members=self.members, ++ metadata=self.metadata, ++ ) ++ ++ @classmethod ++ def from_dict(cls, payload): ++ return cls( ++ payload["id"], ++ payload["type"], ++ payload["members"], ++ payload["metadata"], ++ ) ++ ++ ++class ResourceRelationDto(DataTransferObject): ++ def __init__(self, relation_entity, members, is_leaf): ++ self.relation_entity = relation_entity ++ self.members = members ++ self.is_leaf = is_leaf ++ ++ def to_dict(self): ++ return dict( ++ relation_entity=self.relation_entity.to_dict(), ++ members=[member.to_dict() for member in self.members], ++ is_leaf=self.is_leaf, ++ ) ++ ++ @classmethod ++ def from_dict(cls, payload): ++ return cls( ++ RelationEntityDto.from_dict(payload["relation_entity"]), ++ [ ++ ResourceRelationDto.from_dict(member_data) ++ for member_data in payload["members"] ++ ], ++ payload["is_leaf"], ++ ) +diff --git a/pcs/common/pacemaker/resource/test/__init__.py b/pcs/common/pacemaker/resource/test/__init__.py +new file mode 100644 +index 00000000..e69de29b +diff --git a/pcs/common/pacemaker/resource/test/test_relations.py b/pcs/common/pacemaker/resource/test/test_relations.py +new file mode 100644 +index 00000000..19127ddd +--- /dev/null ++++ b/pcs/common/pacemaker/resource/test/test_relations.py +@@ -0,0 +1,169 @@ ++from pcs.test.tools.pcs_unittest import TestCase ++ ++from pcs.common.pacemaker.resource import relations ++ ++ ++class RelationEntityDto(TestCase): ++ def setUp(self): ++ self.an_id = "an_id" ++ self.members = ["m2", "m1", "m3", "m0"] ++ self.metadata = dict( ++ key1="val1", ++ key0="vel0", ++ keyx="valx", ++ ) ++ self.str_type = "a_type" ++ self.enum_type = relations.ResourceRelationType.ORDER_SET ++ ++ def dto_fixture(self, a_type): ++ return relations.RelationEntityDto( ++ self.an_id, a_type, self.members, self.metadata ++ ) ++ ++ def dict_fixture(self, a_type): ++ return dict( ++ id=self.an_id, ++ type=a_type, ++ members=self.members, ++ metadata=self.metadata, ++ ) ++ ++ def test_to_dict_type_str(self): ++ dto = self.dto_fixture(self.str_type) ++ self.assertEqual(self.dict_fixture(self.str_type), dto.to_dict()) ++ ++ def test_to_dict_type_enum(self): ++ dto = self.dto_fixture(self.enum_type) ++ self.assertEqual(self.dict_fixture(self.enum_type), dto.to_dict()) ++ ++ def test_from_dict_type_str(self): ++ dto = relations.RelationEntityDto.from_dict( ++ self.dict_fixture(self.str_type) ++ ) ++ self.assertEqual(dto.id, self.an_id) ++ self.assertEqual(dto.type, self.str_type) ++ self.assertEqual(dto.members, self.members) ++ self.assertEqual(dto.metadata, self.metadata) ++ ++ def test_from_dict_type_enum(self): ++ dto = relations.RelationEntityDto.from_dict( ++ self.dict_fixture(self.enum_type) ++ ) ++ self.assertEqual(dto.id, self.an_id) ++ self.assertEqual(dto.type, self.enum_type) ++ self.assertEqual(dto.members, self.members) ++ self.assertEqual(dto.metadata, self.metadata) ++ ++ ++class ResourceRelationDto(TestCase): ++ @staticmethod ++ def ent_dto_fixture(an_id): ++ return relations.RelationEntityDto( ++ an_id, "a_type", ["m1", "m0", "m2"], dict(k1="v1", kx="vx") ++ ) ++ ++ def test_to_dict_no_members(self): ++ ent_dto = self.ent_dto_fixture("an_id") ++ dto = relations.ResourceRelationDto(ent_dto, [], True) ++ self.assertEqual( ++ dict( ++ relation_entity=ent_dto.to_dict(), ++ members=[], ++ is_leaf=True, ++ ), ++ dto.to_dict() ++ ) ++ ++ def test_to_dict_with_members(self): ++ ent_dto = self.ent_dto_fixture("an_id") ++ m1_ent = self.ent_dto_fixture("m1_ent") ++ m2_ent = self.ent_dto_fixture("m2_ent") ++ m3_ent = self.ent_dto_fixture("m3_ent") ++ members = [ ++ relations.ResourceRelationDto( ++ m1_ent, [], False ++ ), ++ relations.ResourceRelationDto( ++ m2_ent, ++ [relations.ResourceRelationDto(m3_ent, [], True)], ++ False ++ ), ++ ] ++ dto = relations.ResourceRelationDto(ent_dto, members, True) ++ self.assertEqual( ++ dict( ++ relation_entity=ent_dto.to_dict(), ++ members=[ ++ dict( ++ relation_entity=m1_ent.to_dict(), ++ members=[], ++ is_leaf=False, ++ ), ++ dict( ++ relation_entity=m2_ent.to_dict(), ++ members=[ ++ dict( ++ relation_entity=m3_ent.to_dict(), ++ members=[], ++ is_leaf=True, ++ ) ++ ], ++ is_leaf=False, ++ ), ++ ], ++ is_leaf=True, ++ ), ++ dto.to_dict() ++ ) ++ ++ def test_from_dict(self): ++ ent_dto = self.ent_dto_fixture("an_id") ++ m1_ent = self.ent_dto_fixture("m1_ent") ++ m2_ent = self.ent_dto_fixture("m2_ent") ++ m3_ent = self.ent_dto_fixture("m3_ent") ++ dto = relations.ResourceRelationDto.from_dict( ++ dict( ++ relation_entity=ent_dto.to_dict(), ++ members=[ ++ dict( ++ relation_entity=m1_ent.to_dict(), ++ members=[], ++ is_leaf=False, ++ ), ++ dict( ++ relation_entity=m2_ent.to_dict(), ++ members=[ ++ dict( ++ relation_entity=m3_ent.to_dict(), ++ members=[], ++ is_leaf=True, ++ ) ++ ], ++ is_leaf=False, ++ ), ++ ], ++ is_leaf=True, ++ ) ++ ) ++ self.assertEqual(ent_dto.to_dict(), dto.relation_entity.to_dict()) ++ self.assertEqual(True, dto.is_leaf) ++ self.assertEqual(2, len(dto.members)) ++ ++ self.assertEqual( ++ m1_ent.to_dict(), dto.members[0].relation_entity.to_dict() ++ ) ++ self.assertEqual(False, dto.members[0].is_leaf) ++ self.assertEqual(0, len(dto.members[0].members)) ++ ++ self.assertEqual( ++ m2_ent.to_dict(), dto.members[1].relation_entity.to_dict() ++ ) ++ self.assertEqual(False, dto.members[1].is_leaf) ++ self.assertEqual(1, len(dto.members[1].members)) ++ ++ self.assertEqual( ++ m3_ent.to_dict(), ++ dto.members[1].members[0].relation_entity.to_dict(), ++ ) ++ self.assertEqual(True, dto.members[1].members[0].is_leaf) ++ self.assertEqual(0, len(dto.members[1].members[0].members)) +diff --git a/pcs/lib/cib/resource/__init__.py b/pcs/lib/cib/resource/__init__.py +index 620af424..dedc03af 100644 +--- a/pcs/lib/cib/resource/__init__.py ++++ b/pcs/lib/cib/resource/__init__.py +@@ -12,5 +12,6 @@ from pcs.lib.cib.resource import ( + guest_node, + operations, + primitive, ++ relations, + remote_node, + ) +diff --git a/pcs/lib/cib/resource/common.py b/pcs/lib/cib/resource/common.py +index cc4bb1d0..87b656b7 100644 +--- a/pcs/lib/cib/resource/common.py ++++ b/pcs/lib/cib/resource/common.py +@@ -51,6 +51,54 @@ def find_primitives(resource_el): + return [resource_el] + return [] + ++def get_inner_resources(resource_el): ++ """ ++ Return list of inner resources (direct descendants) of a resource ++ specified as resource_el. ++ Example: for clone containing a group, this function will return only ++ group and not resource inside the group ++ ++ resource_el -- resource element to get its inner resources ++ """ ++ if is_bundle(resource_el): ++ in_bundle = get_bundle_inner_resource(resource_el) ++ return [in_bundle] if in_bundle is not None else [] ++ if is_any_clone(resource_el): ++ return [get_clone_inner_resource(resource_el)] ++ if is_group(resource_el): ++ return get_group_inner_resources(resource_el) ++ return [] ++ ++def is_wrapper_resource(resource_el): ++ """ ++ Return True for resource_el of types that can contain other resource(s) ++ (these are: group, bundle, clone) and False otherwise. ++ ++ resource_el -- resource element to check ++ """ ++ return ( ++ is_group(resource_el) ++ or ++ is_bundle(resource_el) ++ or ++ is_any_clone(resource_el) ++ ) ++ ++def get_parent_resource(resource_el): ++ """ ++ Return a direct ancestor of a specified resource or None if the resource ++ has no ancestor. ++ Example: for a resource in group which is in clone, this function will ++ return group element. ++ ++ resource_el -- resource element of which parent resource should be returned ++ """ ++ parent_el = resource_el.getparent() ++ if parent_el is not None and is_wrapper_resource(parent_el): ++ return parent_el ++ return None ++ ++ + def find_resources_to_enable(resource_el): + """ + Get resources to enable in order to enable specified resource succesfully +diff --git a/pcs/lib/cib/resource/relations.py b/pcs/lib/cib/resource/relations.py +new file mode 100644 +index 00000000..7935336a +--- /dev/null ++++ b/pcs/lib/cib/resource/relations.py +@@ -0,0 +1,265 @@ ++from pcs.common.pacemaker.resource.relations import ( ++ RelationEntityDto, ++ ResourceRelationDto, ++ ResourceRelationType, ++) ++from pcs.lib.cib import tools ++from pcs.lib.cib.resource import common ++ ++from pcs.lib.cib.resource.bundle import TAG as TAG_BUNDLE ++from pcs.lib.cib.resource.clone import ALL_TAGS as TAG_CLONE_ALL ++from pcs.lib.cib.resource.group import TAG as TAG_GROUP ++from pcs.lib.cib.resource.primitive import TAG as TAG_PRIMITIVE ++ ++ ++# character ':' ensures that there is no conflict with any id in CIB, as it ++# would be an invalid id ++INNER_RESOURCE_ID_TEMPLATE = "inner:{}" ++OUTER_RESOURCE_ID_TEMPLATE = "outer:{}" ++ ++ ++def _get_opposite_relation_id_template(relation_type): ++ return { ++ ResourceRelationType.INNER_RESOURCES: OUTER_RESOURCE_ID_TEMPLATE, ++ ResourceRelationType.OUTER_RESOURCE: INNER_RESOURCE_ID_TEMPLATE, ++ }.get(relation_type, "") ++ ++ ++class ResourceRelationNode(object): ++ def __init__(self, entity): ++ self._obj = entity ++ self._members = [] ++ self._is_leaf = False ++ self._parent = None ++ self._opposite_id = _get_opposite_relation_id_template( ++ self._obj.type ++ ).format(self._obj.metadata["id"]) ++ ++ @property ++ def obj(self): ++ return self._obj ++ ++ @property ++ def members(self): ++ return self._members ++ ++ def to_dto(self): ++ return ResourceRelationDto( ++ self._obj, ++ [member.to_dto() for member in self._members], ++ self._is_leaf, ++ ) ++ ++ def stop(self): ++ self._is_leaf = True ++ ++ def add_member(self, member): ++ # pylint: disable=protected-access ++ if member._parent is not None: ++ raise AssertionError( ++ "object {} already has a parent set: {}".format( ++ repr(member), repr(member._parent) ++ ) ++ ) ++ # we don't want opposite relations (inner resource vs outer resource) ++ # in a branch, so we are filtering them out ++ parents = set(self._get_all_parents()) ++ if ( ++ self != member ++ and ++ member.obj.id not in parents ++ and ++ ( ++ member._opposite_id not in parents ++ or ++ len(member.obj.members) > 1 ++ ) ++ ): ++ member._parent = self ++ self._members.append(member) ++ ++ def _get_all_parents(self): ++ # pylint: disable=protected-access ++ if self._parent is None: ++ return [] ++ return self._parent._get_all_parents() + [self._parent.obj.id] ++ ++ ++class ResourceRelationTreeBuilder(object): ++ def __init__(self, resource_entities, relation_entities): ++ self._resources = resource_entities ++ self._all = dict(resource_entities) ++ self._all.update(relation_entities) ++ self._init_structures() ++ ++ def _init_structures(self): ++ self._processed_nodes = set() ++ # queue ++ self._nodes_to_process = [] ++ ++ def get_tree(self, resource_id): ++ self._init_structures() ++ if resource_id not in self._resources: ++ raise AssertionError( ++ "Resource with id '{}' not found in resource " ++ "relation structures".format(resource_id) ++ ) ++ ++ # self._all is a superset of self._resources, see __init__ ++ root = ResourceRelationNode(self._all[resource_id]) ++ self._nodes_to_process.append(root) ++ ++ while self._nodes_to_process: ++ node = self._nodes_to_process.pop(0) ++ if node.obj.id in self._processed_nodes: ++ node.stop() ++ continue ++ self._processed_nodes.add(node.obj.id) ++ for node_id in node.obj.members: ++ node.add_member( ++ ResourceRelationNode(self._all[node_id]) ++ ) ++ self._nodes_to_process.extend(node.members) ++ return root ++ ++ ++class ResourceRelationsFetcher(object): ++ def __init__(self, cib): ++ self._cib = cib ++ self._resources_section = tools.get_resources(self._cib) ++ self._constraints_section = tools.get_constraints(self._cib) ++ ++ def get_relations(self, resource_id): ++ resources_to_process = {resource_id} ++ relations = {} ++ resources = {} ++ while resources_to_process: ++ res_id = resources_to_process.pop() ++ if res_id in resources: ++ # already processed ++ continue ++ res_el = self._get_resource_el(res_id) ++ res_relations = { ++ rel.id: rel for rel in self._get_resource_relations(res_el) ++ } ++ resources[res_id] = RelationEntityDto( ++ res_id, ++ res_el.tag, ++ list(res_relations.keys()), ++ dict(res_el.attrib), ++ ) ++ relations.update(res_relations) ++ resources_to_process.update( ++ self._get_all_members(res_relations.values()) ++ ) ++ return resources, relations ++ ++ def _get_resource_el(self, res_id): ++ # client of this class should ensure that res_id really exists in CIB, ++ # so here we don't need to handle possible reports ++ for tag in TAG_CLONE_ALL + [TAG_GROUP, TAG_PRIMITIVE, TAG_BUNDLE]: ++ element_list = self._resources_section.xpath( ++ './/{}[@id="{}"]'.format(tag, res_id) ++ ) ++ if element_list: ++ return element_list[0] ++ ++ @staticmethod ++ def _get_all_members(relation_list): ++ result = set() ++ for relation in relation_list: ++ result.update(relation.members) ++ return result ++ ++ def _get_resource_relations(self, resource_el): ++ resource_id = resource_el.attrib["id"] ++ relations = [ ++ _get_ordering_constraint_relation(item) ++ for item in self._get_ordering_coinstraints(resource_id) ++ ] + [ ++ _get_ordering_set_constraint_relation(item) ++ for item in self._get_ordering_set_constraints(resource_id) ++ ] ++ ++ # special type of relation, group (note that a group can be a resource ++ # and a relation) ++ if common.is_wrapper_resource(resource_el): ++ relations.append(_get_inner_resources_relation(resource_el)) ++ ++ # handle resources in a wrapper resource (group/bundle/clone relation) ++ parent_el = common.get_parent_resource(resource_el) ++ if parent_el is not None: ++ relations.append(_get_outer_resource_relation(parent_el)) ++ return relations ++ ++ def _get_ordering_coinstraints(self, resource_id): ++ return self._constraints_section.xpath(""" ++ .//rsc_order[ ++ not (descendant::resource_set) ++ and ++ (@first='{0}' or @then='{0}') ++ ] ++ """.format(resource_id)) ++ ++ def _get_ordering_set_constraints(self, resource_id): ++ return self._constraints_section.xpath( ++ ".//rsc_order[./resource_set/resource_ref[@id='{}']]".format( ++ resource_id ++ ) ++ ) ++ ++ ++# relation obj to RelationEntityDto obj ++def _get_inner_resources_relation(parent_resource_el): ++ attrs = parent_resource_el.attrib ++ return RelationEntityDto( ++ INNER_RESOURCE_ID_TEMPLATE.format(attrs["id"]), ++ ResourceRelationType.INNER_RESOURCES, ++ [ ++ res.attrib["id"] ++ for res in common.get_inner_resources(parent_resource_el) ++ ], ++ dict(attrs), ++ ) ++ ++ ++def _get_outer_resource_relation(parent_resource_el): ++ attrs = parent_resource_el.attrib ++ return RelationEntityDto( ++ OUTER_RESOURCE_ID_TEMPLATE.format(attrs["id"]), ++ ResourceRelationType.OUTER_RESOURCE, ++ [attrs["id"]], ++ dict(attrs), ++ ) ++ ++ ++def _get_ordering_constraint_relation(ord_const_el): ++ attrs = ord_const_el.attrib ++ return RelationEntityDto( ++ attrs["id"], ++ ResourceRelationType.ORDER, ++ [attrs["first"], attrs["then"]], ++ dict(attrs), ++ ) ++ ++ ++def _get_ordering_set_constraint_relation(ord_set_const_el): ++ attrs = ord_set_const_el.attrib ++ members = set() ++ metadata = dict(attrs) ++ metadata["sets"] = [] ++ for rsc_set_el in ord_set_const_el.findall("resource_set"): ++ rsc_set = dict( ++ id=rsc_set_el.get("id"), ++ metadata=dict(rsc_set_el.attrib), ++ members=[], ++ ) ++ metadata["sets"].append(rsc_set) ++ for rsc_ref in rsc_set_el.findall("resource_ref"): ++ rsc_id = rsc_ref.attrib["id"] ++ members.add(rsc_id) ++ rsc_set["members"].append(rsc_id) ++ ++ return RelationEntityDto( ++ attrs["id"], ResourceRelationType.ORDER_SET, sorted(members), metadata ++ ) +diff --git a/pcs/lib/cib/test/test_resource_common.py b/pcs/lib/cib/test/test_resource_common.py +index 21380060..596ee57f 100644 +--- a/pcs/lib/cib/test/test_resource_common.py ++++ b/pcs/lib/cib/test/test_resource_common.py +@@ -83,14 +83,14 @@ class IsCloneDeactivatedByMeta(TestCase): + self.assert_is_not_disabled({"clone-node-max": "1.1"}) + + +-class FindPrimitives(TestCase): ++class FindResourcesMixin(object): + def assert_find_resources(self, input_resource_id, output_resource_ids): + self.assertEqual( + output_resource_ids, + [ + element.get("id", "") + for element in +- common.find_primitives( ++ self._tested_fn( + fixture_cib.find( + './/*[@id="{0}"]'.format(input_resource_id) + ) +@@ -98,6 +98,31 @@ class FindPrimitives(TestCase): + ] + ) + ++ def test_group(self): ++ self.assert_find_resources("D", ["D1", "D2"]) ++ ++ def test_group_in_clone(self): ++ self.assert_find_resources("E", ["E1", "E2"]) ++ ++ def test_group_in_master(self): ++ self.assert_find_resources("F", ["F1", "F2"]) ++ ++ def test_cloned_primitive(self): ++ self.assert_find_resources("B-clone", ["B"]) ++ ++ def test_mastered_primitive(self): ++ self.assert_find_resources("C-master", ["C"]) ++ ++ def test_bundle_empty(self): ++ self.assert_find_resources("G-bundle", []) ++ ++ def test_bundle_with_primitive(self): ++ self.assert_find_resources("H-bundle", ["H"]) ++ ++ ++class FindPrimitives(TestCase, FindResourcesMixin): ++ _tested_fn = staticmethod(common.find_primitives) ++ + def test_primitive(self): + self.assert_find_resources("A", ["A"]) + +@@ -118,32 +143,155 @@ class FindPrimitives(TestCase): + def test_primitive_in_bundle(self): + self.assert_find_resources("H", ["H"]) + ++ def test_cloned_group(self): ++ self.assert_find_resources("E-clone", ["E1", "E2"]) ++ ++ def test_mastered_group(self): ++ self.assert_find_resources("F-master", ["F1", "F2"]) ++ ++ ++class GetInnerResources(TestCase, FindResourcesMixin): ++ _tested_fn = staticmethod(common.get_inner_resources) ++ ++ def test_primitive(self): ++ self.assert_find_resources("A", []) ++ ++ def test_primitive_in_clone(self): ++ self.assert_find_resources("B", []) ++ ++ def test_primitive_in_master(self): ++ self.assert_find_resources("C", []) ++ ++ def test_primitive_in_group(self): ++ self.assert_find_resources("D1", []) ++ self.assert_find_resources("D2", []) ++ self.assert_find_resources("E1", []) ++ self.assert_find_resources("E2", []) ++ self.assert_find_resources("F1", []) ++ self.assert_find_resources("F2", []) ++ ++ def test_primitive_in_bundle(self): ++ self.assert_find_resources("H", []) ++ ++ def test_mastered_group(self): ++ self.assert_find_resources("F-master", ["F"]) ++ ++ def test_cloned_group(self): ++ self.assert_find_resources("E-clone", ["E"]) ++ ++ ++class IsWrapperResource(TestCase): ++ def assert_is_wrapper(self, res_id, is_wrapper): ++ self.assertEqual( ++ is_wrapper, ++ common.is_wrapper_resource( ++ fixture_cib.find('.//*[@id="{0}"]'.format(res_id)) ++ ) ++ ) ++ ++ def test_primitive(self): ++ self.assert_is_wrapper("A", False) ++ ++ def test_primitive_in_clone(self): ++ self.assert_is_wrapper("B", False) ++ ++ def test_primitive_in_master(self): ++ self.assert_is_wrapper("C", False) ++ ++ def test_primitive_in_group(self): ++ self.assert_is_wrapper("D1", False) ++ self.assert_is_wrapper("D2", False) ++ self.assert_is_wrapper("E1", False) ++ self.assert_is_wrapper("E2", False) ++ self.assert_is_wrapper("F1", False) ++ self.assert_is_wrapper("F2", False) ++ ++ def test_primitive_in_bundle(self): ++ self.assert_is_wrapper("H", False) ++ ++ def test_cloned_group(self): ++ self.assert_is_wrapper("E-clone", True) ++ ++ def test_mastered_group(self): ++ self.assert_is_wrapper("F-master", True) ++ + def test_group(self): +- self.assert_find_resources("D", ["D1", "D2"]) ++ self.assert_is_wrapper("D", True) + + def test_group_in_clone(self): +- self.assert_find_resources("E", ["E1", "E2"]) ++ self.assert_is_wrapper("E", True) + + def test_group_in_master(self): +- self.assert_find_resources("F", ["F1", "F2"]) ++ self.assert_is_wrapper("F", True) + + def test_cloned_primitive(self): +- self.assert_find_resources("B-clone", ["B"]) +- +- def test_cloned_group(self): +- self.assert_find_resources("E-clone", ["E1", "E2"]) ++ self.assert_is_wrapper("B-clone", True) + + def test_mastered_primitive(self): +- self.assert_find_resources("C-master", ["C"]) ++ self.assert_is_wrapper("C-master", True) ++ ++ def test_bundle_empty(self): ++ self.assert_is_wrapper("G-bundle", True) ++ ++ def test_bundle_with_primitive(self): ++ self.assert_is_wrapper("H-bundle", True) ++ ++ ++class GetParentResource(TestCase): ++ def assert_parent_resource(self, input_resource_id, output_resource_id): ++ res_el = common.get_parent_resource(fixture_cib.find( ++ './/*[@id="{0}"]'.format(input_resource_id) ++ )) ++ self.assertEqual( ++ output_resource_id, res_el.get("id") if res_el is not None else None ++ ) ++ ++ def test_primitive(self): ++ self.assert_parent_resource("A", None) ++ ++ def test_primitive_in_clone(self): ++ self.assert_parent_resource("B", "B-clone") ++ ++ def test_primitive_in_master(self): ++ self.assert_parent_resource("C", "C-master") ++ ++ def test_primitive_in_group(self): ++ self.assert_parent_resource("D1", "D") ++ self.assert_parent_resource("D2", "D") ++ self.assert_parent_resource("E1", "E") ++ self.assert_parent_resource("E2", "E") ++ self.assert_parent_resource("F1", "F") ++ self.assert_parent_resource("F2", "F") ++ ++ def test_primitive_in_bundle(self): ++ self.assert_parent_resource("H", "H-bundle") ++ ++ def test_cloned_group(self): ++ self.assert_parent_resource("E-clone", None) + + def test_mastered_group(self): +- self.assert_find_resources("F-master", ["F1", "F2"]) ++ self.assert_parent_resource("F-master", None) ++ ++ def test_group(self): ++ self.assert_parent_resource("D", None) ++ ++ def test_group_in_clone(self): ++ self.assert_parent_resource("E", "E-clone") ++ ++ def test_group_in_master(self): ++ self.assert_parent_resource("F", "F-master") ++ ++ def test_cloned_primitive(self): ++ self.assert_parent_resource("B-clone", None) ++ ++ def test_mastered_primitive(self): ++ self.assert_parent_resource("C-master", None) + + def test_bundle_empty(self): +- self.assert_find_resources("G-bundle", []) ++ self.assert_parent_resource("G-bundle", None) + + def test_bundle_with_primitive(self): +- self.assert_find_resources("H-bundle", ["H"]) ++ self.assert_parent_resource("H-bundle", None) + + + class FindResourcesToEnable(TestCase): +diff --git a/pcs/lib/cib/test/test_resource_relations.py b/pcs/lib/cib/test/test_resource_relations.py +new file mode 100644 +index 00000000..63998962 +--- /dev/null ++++ b/pcs/lib/cib/test/test_resource_relations.py +@@ -0,0 +1,690 @@ ++from lxml import etree ++ ++from pcs.test.tools.pcs_unittest import TestCase ++from pcs.common.pacemaker.resource.relations import ( ++ RelationEntityDto, ++ ResourceRelationDto, ++ ResourceRelationType, ++) ++from pcs.lib.cib.resource import relations as lib ++ ++ ++def fixture_cib(resources, constraints): ++ return etree.fromstring(""" ++ ++ ++ ++ {} ++ ++ ++ {} ++ ++ ++ ++ """.format(resources, constraints)) ++ ++ ++def fixture_dummy_metadata(_id): ++ return { ++ "id": _id, ++ "class": "c", ++ "provider": "pcmk", ++ "type": "Dummy", ++ } ++ ++ ++class ResourceRelationNode(TestCase): ++ @staticmethod ++ def entity_fixture(index): ++ return RelationEntityDto.from_dict(dict( ++ id="ent_id{}".format(index), ++ type="ent_type", ++ members=[ ++ "{}{}".format(index, member) for member in ("m1", "m2", "m0") ++ ], ++ metadata=dict( ++ id="ent_id{}".format(index), ++ k0="val0", ++ k1="val1", ++ ) ++ )) ++ ++ def test_no_members(self): ++ ent = self.entity_fixture("0") ++ obj = lib.ResourceRelationNode(ent) ++ self.assertEqual( ++ ResourceRelationDto(ent, [], False).to_dict(), ++ obj.to_dto().to_dict(), ++ ) ++ ++ def test_with_members(self): ++ ent0 = self.entity_fixture("0") ++ ent1 = self.entity_fixture("1") ++ ent2 = self.entity_fixture("2") ++ ent3 = self.entity_fixture("3") ++ obj = lib.ResourceRelationNode(ent0) ++ obj.add_member(lib.ResourceRelationNode(ent1)) ++ member = lib.ResourceRelationNode(ent2) ++ member.add_member(lib.ResourceRelationNode(ent3)) ++ obj.add_member(member) ++ self.assertEqual( ++ ResourceRelationDto( ++ ent0, ++ [ ++ ResourceRelationDto(ent1, [], False), ++ ResourceRelationDto( ++ ent2, ++ [ ++ ResourceRelationDto(ent3, [], False), ++ ], ++ False, ++ ), ++ ], ++ False, ++ ).to_dict(), ++ obj.to_dto().to_dict(), ++ ) ++ ++ def test_stop(self): ++ ent = self.entity_fixture("0") ++ obj = lib.ResourceRelationNode(ent) ++ obj.stop() ++ self.assertEqual( ++ ResourceRelationDto(ent, [], True).to_dict(), ++ obj.to_dto().to_dict(), ++ ) ++ ++ def test_add_member(self): ++ ent0 = self.entity_fixture("0") ++ ent1 = self.entity_fixture("1") ++ obj = lib.ResourceRelationNode(ent0) ++ obj.add_member(lib.ResourceRelationNode(ent1)) ++ self.assertEqual( ++ ResourceRelationDto( ++ ent0, [ResourceRelationDto(ent1, [], False)], False, ++ ).to_dict(), ++ obj.to_dto().to_dict(), ++ ) ++ ++ def test_add_member_itself(self): ++ ent = self.entity_fixture("0") ++ obj = lib.ResourceRelationNode(ent) ++ obj.add_member(obj) ++ self.assertEqual( ++ ResourceRelationDto(ent, [], False).to_dict(), ++ obj.to_dto().to_dict(), ++ ) ++ ++ def test_add_member_already_have_parent(self): ++ obj0 = lib.ResourceRelationNode(self.entity_fixture("0")) ++ obj1 = lib.ResourceRelationNode(self.entity_fixture("1")) ++ obj2 = lib.ResourceRelationNode(self.entity_fixture("2")) ++ obj0.add_member(obj1) ++ with self.assertRaises(AssertionError): ++ obj2.add_member(obj1) ++ ++ def test_add_member_already_in_branch(self): ++ ent0 = self.entity_fixture("0") ++ ent1 = self.entity_fixture("1") ++ obj0 = lib.ResourceRelationNode(ent0) ++ obj1 = lib.ResourceRelationNode(ent1) ++ obj0.add_member(obj1) ++ obj1.add_member(obj0) ++ self.assertEqual( ++ ResourceRelationDto( ++ ent0, [ResourceRelationDto(ent1, [], False)], False ++ ).to_dict(), ++ obj0.to_dto().to_dict(), ++ ) ++ ++ def test_add_member_already_in_different_branch(self): ++ ent0 = self.entity_fixture("0") ++ ent1 = self.entity_fixture("1") ++ obj0 = lib.ResourceRelationNode(ent0) ++ obj0.add_member(lib.ResourceRelationNode(ent1)) ++ obj0.add_member(lib.ResourceRelationNode(ent1)) ++ self.assertEqual( ++ ResourceRelationDto( ++ ent0, [ ++ ResourceRelationDto(ent1, [], False), ++ ResourceRelationDto(ent1, [], False), ++ ], ++ False, ++ ).to_dict(), ++ obj0.to_dto().to_dict(), ++ ) ++ ++ ++class ResourceRelationsFetcher(TestCase): ++ def test_ordering_constraint(self): ++ obj = lib.ResourceRelationsFetcher(fixture_cib( ++ """ ++ ++ ++ """, ++ """ ++ ++ """ ++ )) ++ expected = ( ++ { ++ "d1": RelationEntityDto( ++ "d1", ++ "primitive", ++ ["order-d1-d2-mandatory"], ++ fixture_dummy_metadata("d1"), ++ ), ++ "d2": RelationEntityDto( ++ "d2", ++ "primitive", ++ ["order-d1-d2-mandatory"], ++ fixture_dummy_metadata("d2"), ++ ), ++ }, ++ { ++ "order-d1-d2-mandatory": RelationEntityDto( ++ "order-d1-d2-mandatory", ++ ResourceRelationType.ORDER, ++ members=["d1", "d2"], ++ metadata={ ++ "id": "order-d1-d2-mandatory", ++ "first": "d1", ++ "first-action": "start", ++ "then": "d2", ++ "then-action": "start", ++ "kind": "Mandatory", ++ }, ++ ), ++ }, ++ ) ++ for res in ("d1", "d2"): ++ self.assertEqual(expected, obj.get_relations(res)) ++ ++ def test_ordering_set_constraint(self): ++ obj = lib.ResourceRelationsFetcher(fixture_cib( ++ """ ++ ++ ++ ++ ++ ++ ++ """, ++ """ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ """ ++ )) ++ rsc_entity = lambda _id: RelationEntityDto( ++ _id, ++ "primitive", ++ ["pcs_rsc_order_set_1"], ++ fixture_dummy_metadata(_id), ++ ) ++ res_list = ("d1", "d2", "d3", "d4", "d5", "d6") ++ expected = ( ++ {_id: rsc_entity(_id) for _id in res_list}, ++ { ++ "pcs_rsc_order_set_1": RelationEntityDto( ++ "pcs_rsc_order_set_1", ++ ResourceRelationType.ORDER_SET, ++ members=["d1", "d2", "d3", "d4", "d5", "d6"], ++ metadata={ ++ "id": "pcs_rsc_order_set_1", ++ "sets": [ ++ { ++ "id": "pcs_rsc_set_1", ++ "metadata": { ++ "id": "pcs_rsc_set_1", ++ "sequential": "true", ++ "require-all": "true", ++ "action": "start", ++ }, ++ "members": ["d1", "d3", "d2"], ++ }, ++ { ++ "id": "pcs_rsc_set_2", ++ "metadata": { ++ "id": "pcs_rsc_set_2", ++ "sequential": "false", ++ "require-all": "false", ++ "action": "stop", ++ }, ++ "members": ["d6", "d5", "d4"], ++ }, ++ ], ++ "kind": "Serialize", ++ "symmetrical": "true", ++ }, ++ ), ++ }, ++ ) ++ for res in res_list: ++ self.assertEqual(expected, obj.get_relations(res)) ++ ++ def test_group(self): ++ obj = lib.ResourceRelationsFetcher(fixture_cib( ++ """ ++ ++ ++ ++ ++ """, ++ "" ++ )) ++ expected = ( ++ { ++ "d1": RelationEntityDto( ++ "d1", ++ "primitive", ++ ["outer:g1"], ++ fixture_dummy_metadata("d1"), ++ ), ++ "d2": RelationEntityDto( ++ "d2", ++ "primitive", ++ ["outer:g1"], ++ fixture_dummy_metadata("d2"), ++ ), ++ "g1": RelationEntityDto( ++ "g1", "group", ["inner:g1"], {"id": "g1"} ++ ), ++ }, ++ { ++ "inner:g1": RelationEntityDto( ++ "inner:g1", ++ ResourceRelationType.INNER_RESOURCES, ++ ["d1", "d2"], ++ {"id": "g1"}, ++ ), ++ "outer:g1": RelationEntityDto( ++ "outer:g1", ++ ResourceRelationType.OUTER_RESOURCE, ++ ["g1"], ++ {"id": "g1"}, ++ ), ++ }, ++ ) ++ for res in ("d1", "d2", "g1"): ++ self.assertEqual(expected, obj.get_relations(res)) ++ ++ def _test_wrapper(self, wrapper_tag): ++ obj = lib.ResourceRelationsFetcher(fixture_cib( ++ """ ++ <{0} id="w1"> ++ ++ ++ """.format(wrapper_tag), ++ "" ++ )) ++ expected = ( ++ { ++ "d1": RelationEntityDto( ++ "d1", ++ "primitive", ++ ["outer:w1"], ++ fixture_dummy_metadata("d1"), ++ ), ++ "w1": RelationEntityDto( ++ "w1", wrapper_tag, ["inner:w1"], {"id": "w1"} ++ ), ++ }, ++ { ++ "inner:w1": RelationEntityDto( ++ "inner:w1", ++ ResourceRelationType.INNER_RESOURCES, ++ ["d1"], ++ {"id": "w1"}, ++ ), ++ "outer:w1": RelationEntityDto( ++ "outer:w1", ++ ResourceRelationType.OUTER_RESOURCE, ++ ["w1"], ++ {"id": "w1"}, ++ ), ++ }, ++ ) ++ for res in ("d1", "w1"): ++ self.assertEqual(expected, obj.get_relations(res)) ++ ++ def test_clone(self): ++ self._test_wrapper("clone") ++ ++ def test_bundle(self): ++ self._test_wrapper("bundle") ++ ++ def test_cloned_group(self): ++ obj = lib.ResourceRelationsFetcher(fixture_cib( ++ """ ++ ++ ++ ++ ++ ++ ++ """, ++ "" ++ )) ++ expected = ( ++ { ++ "d1": RelationEntityDto( ++ "d1", ++ "primitive", ++ ["outer:g1"], ++ fixture_dummy_metadata("d1"), ++ ), ++ "d2": RelationEntityDto( ++ "d2", ++ "primitive", ++ ["outer:g1"], ++ fixture_dummy_metadata("d2"), ++ ), ++ "g1": RelationEntityDto( ++ "g1", "group", ["outer:c1", "inner:g1"], {"id": "g1"} ++ ), ++ "c1": RelationEntityDto( ++ "c1", "clone", ["inner:c1"], {"id": "c1"} ++ ) ++ }, ++ { ++ "inner:g1": RelationEntityDto( ++ "inner:g1", ++ ResourceRelationType.INNER_RESOURCES, ++ ["d1", "d2"], ++ {"id": "g1"}, ++ ), ++ "outer:g1": RelationEntityDto( ++ "outer:g1", ++ ResourceRelationType.OUTER_RESOURCE, ++ ["g1"], ++ {"id": "g1"}, ++ ), ++ "inner:c1": RelationEntityDto( ++ "inner:c1", ++ ResourceRelationType.INNER_RESOURCES, ++ ["g1"], ++ {"id": "c1"} ++ ), ++ "outer:c1": RelationEntityDto( ++ "outer:c1", ++ ResourceRelationType.OUTER_RESOURCE, ++ ["c1"], ++ {"id": "c1"} ++ ), ++ }, ++ ) ++ for res in ("d1", "d2", "g1", "c1"): ++ self.assertEqual(expected, obj.get_relations(res)) ++ ++ ++class ResourceRelationTreeBuilder(TestCase): ++ @staticmethod ++ def primitive_fixture(_id, members): ++ return RelationEntityDto( ++ _id, "primitive", members, fixture_dummy_metadata(_id) ++ ) ++ ++ def test_resource_not_present(self): ++ with self.assertRaises(AssertionError): ++ lib.ResourceRelationTreeBuilder({}, {}).get_tree("not_existing") ++ ++ def test_simple_order(self): ++ resources_members = ["order-d1-d2-mandatory"] ++ resources = { ++ "d1": self.primitive_fixture("d1", resources_members), ++ "d2": self.primitive_fixture("d2", resources_members), ++ } ++ relations = { ++ "order-d1-d2-mandatory": RelationEntityDto( ++ "order-d1-d2-mandatory", ++ ResourceRelationType.ORDER, ++ members=["d1", "d2"], ++ metadata={ ++ "id": "order-d1-d2-mandatory", ++ "first": "d1", ++ "first-action": "start", ++ "then": "d2", ++ "then-action": "start", ++ "kind": "Mandatory", ++ }, ++ ), ++ } ++ expected = dict( ++ relation_entity=resources["d2"].to_dict(), ++ is_leaf=False, ++ members=[ ++ dict( ++ relation_entity=( ++ relations["order-d1-d2-mandatory"].to_dict() ++ ), ++ is_leaf=False, ++ members=[ ++ dict( ++ relation_entity=resources["d1"].to_dict(), ++ is_leaf=False, ++ members=[], ++ ) ++ ] ++ ) ++ ], ++ ) ++ self.assertEqual( ++ expected, ++ lib.ResourceRelationTreeBuilder( ++ resources, relations ++ ).get_tree("d2").to_dto().to_dict() ++ ) ++ ++ def test_simple_order_set(self): ++ res_list = ("d1", "d2", "d3", "d4", "d5", "d6") ++ resources_members = ["pcs_rsc_order_set_1"] ++ resources = { ++ _id: self.primitive_fixture(_id, resources_members) ++ for _id in res_list ++ } ++ relations = { ++ "pcs_rsc_order_set_1": RelationEntityDto( ++ "pcs_rsc_order_set_1", ++ ResourceRelationType.ORDER_SET, ++ members=["d1", "d2", "d3", "d4", "d5", "d6"], ++ metadata={ ++ "id": "pcs_rsc_order_set_1", ++ "sets": [ ++ { ++ "id": "pcs_rsc_set_1", ++ "metadata": { ++ "id": "pcs_rsc_set_1", ++ "sequential": "true", ++ "require-all": "true", ++ "action": "start", ++ }, ++ "members": ["d1", "d3", "d2"], ++ }, ++ { ++ "id": "pcs_rsc_set_2", ++ "metadata": { ++ "id": "pcs_rsc_set_2", ++ "sequential": "false", ++ "require-all": "false", ++ "action": "stop", ++ }, ++ "members": ["d6", "d5", "d4"], ++ }, ++ ], ++ "kind": "Serialize", ++ "symmetrical": "true", ++ }, ++ ), ++ } ++ get_res = lambda _id: dict( ++ relation_entity=resources[_id].to_dict(), ++ is_leaf=False, ++ members=[], ++ ) ++ expected = dict( ++ relation_entity=resources["d5"].to_dict(), ++ is_leaf=False, ++ members=[ ++ dict( ++ relation_entity=relations["pcs_rsc_order_set_1"].to_dict(), ++ is_leaf=False, ++ members=[ ++ get_res(_id) for _id in ("d1", "d2", "d3", "d4", "d6") ++ ] ++ ) ++ ], ++ ) ++ self.assertEqual( ++ expected, ++ lib.ResourceRelationTreeBuilder( ++ resources, relations ++ ).get_tree("d5").to_dto().to_dict() ++ ) ++ ++ def test_simple_in_group(self): ++ resources_members = ["outer:g1"] ++ resources = { ++ "d1": self.primitive_fixture("d1", resources_members), ++ "d2": self.primitive_fixture("d2", resources_members), ++ "g1": RelationEntityDto( ++ "g1", "group", ["inner:g1"], {"id": "g1"} ++ ), ++ } ++ relations = { ++ "inner:g1": RelationEntityDto( ++ "inner:g1", ++ ResourceRelationType.INNER_RESOURCES, ++ ["d1", "d2"], ++ {"id": "g1"}, ++ ), ++ "outer:g1": RelationEntityDto( ++ "outer:g1", ++ ResourceRelationType.OUTER_RESOURCE, ++ ["g1"], ++ {"id": "g1"}, ++ ), ++ } ++ expected = dict( ++ relation_entity=resources["d1"].to_dict(), ++ is_leaf=False, ++ members=[ ++ dict( ++ relation_entity=relations["outer:g1"].to_dict(), ++ is_leaf=False, ++ members=[ ++ dict( ++ relation_entity=resources["g1"].to_dict(), ++ is_leaf=False, ++ members=[ ++ dict( ++ relation_entity=( ++ relations["inner:g1"].to_dict() ++ ), ++ is_leaf=False, ++ members=[ ++ dict( ++ relation_entity=( ++ resources["d2"].to_dict() ++ ), ++ is_leaf=False, ++ members=[], ++ ), ++ ], ++ ), ++ ], ++ ), ++ ], ++ ), ++ ], ++ ) ++ self.assertEqual( ++ expected, ++ lib.ResourceRelationTreeBuilder( ++ resources, relations ++ ).get_tree("d1").to_dto().to_dict() ++ ) ++ ++ def test_order_loop(self): ++ resources_members = ["order-d1-d2-mandatory", "order-d2-d1-mandatory"] ++ resources = { ++ "d1": self.primitive_fixture("d1", resources_members), ++ "d2": self.primitive_fixture("d2", resources_members), ++ } ++ order_fixture = lambda r1, r2: RelationEntityDto( ++ "order-{}-{}-mandatory".format(r1, r2), ++ ResourceRelationType.ORDER, ++ members=[r1, r2], ++ metadata={ ++ "id": "order-{}-{}-mandatory".format(r1, r2), ++ "first": r1, ++ "first-action": "start", ++ "then": r2, ++ "then-action": "start", ++ "kind": "Mandatory", ++ }, ++ ) ++ relations = { ++ "order-d1-d2-mandatory": order_fixture("d1", "d2"), ++ "order-d2-d1-mandatory": order_fixture("d2", "d1"), ++ } ++ expected = dict( ++ relation_entity=resources["d1"].to_dict(), ++ is_leaf=False, ++ members=[ ++ dict( ++ relation_entity=( ++ relations["order-d1-d2-mandatory"].to_dict() ++ ), ++ is_leaf=False, ++ members=[ ++ dict( ++ relation_entity=resources["d2"].to_dict(), ++ is_leaf=False, ++ members=[ ++ dict( ++ relation_entity=relations[ ++ "order-d2-d1-mandatory" ++ ].to_dict(), ++ is_leaf=True, ++ members=[], ++ ), ++ ], ++ ), ++ ], ++ ), ++ dict( ++ relation_entity=( ++ relations["order-d2-d1-mandatory"].to_dict() ++ ), ++ is_leaf=False, ++ members=[ ++ dict( ++ relation_entity=resources["d2"].to_dict(), ++ is_leaf=True, ++ members=[], ++ ), ++ ], ++ ), ++ ], ++ ) ++ self.assertEqual( ++ expected, ++ lib.ResourceRelationTreeBuilder( ++ resources, relations ++ ).get_tree("d1").to_dto().to_dict() ++ ) +diff --git a/pcs/lib/commands/resource.py b/pcs/lib/commands/resource.py +index 0073964d..09a68a68 100644 +--- a/pcs/lib/commands/resource.py ++++ b/pcs/lib/commands/resource.py +@@ -897,6 +897,28 @@ def get_failcounts( + interval=interval_ms + ) + ++ ++def get_resource_relations_tree(env, resource_id): ++ """ ++ Return a dict representing tree-like structure of resources and their ++ relations. ++ ++ env -- library environment ++ resource_id -- id of a resource which should be the root of the relation ++ tree ++ """ ++ cib = env.get_cib() ++ _find_resources_or_raise(get_resources(cib), [resource_id]) ++ resources_dict, relations_dict = ( ++ resource.relations.ResourceRelationsFetcher( ++ cib ++ ).get_relations(resource_id) ++ ) ++ return resource.relations.ResourceRelationTreeBuilder( ++ resources_dict, relations_dict ++ ).get_tree(resource_id).to_dto().to_dict() ++ ++ + def _find_resources_or_raise( + resources_section, resource_ids, additional_search=None + ): +diff --git a/pcs/lib/commands/test/resource/test_resource_relations.py b/pcs/lib/commands/test/resource/test_resource_relations.py +new file mode 100644 +index 00000000..404780f3 +--- /dev/null ++++ b/pcs/lib/commands/test/resource/test_resource_relations.py +@@ -0,0 +1,300 @@ ++from pcs.test.tools.pcs_unittest import TestCase ++from pcs.test.tools import fixture ++from pcs.test.tools.command_env import get_env_tools ++ ++from pcs.common.pacemaker.resource.relations import ResourceRelationType ++from pcs.lib.commands import resource ++ ++ ++def fixture_primitive(_id, members): ++ return dict( ++ id=_id, ++ type="primitive", ++ metadata={ ++ "id": _id, ++ "class": "ocf", ++ "provider": "pacemaker", ++ "type": "Dummy", ++ }, ++ members=members, ++ ) ++ ++ ++def fixture_primitive_xml(_id): ++ return """ ++ ++ """.format(_id) ++ ++def fixture_node(entity, members=None, leaf=False): ++ return dict( ++ relation_entity=entity, ++ is_leaf=leaf, ++ members=members or [], ++ ) ++ ++def fixture_order(res1, res2, kind="Mandatory", score=None): ++ _id = "order-{}-{}".format(res1, res2) ++ out = dict( ++ id=_id, ++ type=ResourceRelationType.ORDER, ++ members=[res1, res2], ++ metadata={ ++ "id": _id, ++ "first": res1, ++ "first-action": "start", ++ "then": res2, ++ "then-action": "start", ++ "kind": kind, ++ }, ++ ) ++ if score: ++ out["metadata"]["score"] = score ++ return out ++ ++ ++class GetResourceRelationsTree(TestCase): ++ def setUp(self): ++ self.env_assist, self.config = get_env_tools(test_case=self) ++ ++ def test_not_existing_resource(self): ++ self.config.runner.cib.load() ++ resource_id = "not_existing" ++ self.env_assist.assert_raise_library_error( ++ lambda: resource.get_resource_relations_tree( ++ self.env_assist.get_env(), ++ resource_id, ++ ), ++ [ ++ fixture.report_not_found(resource_id, context_type="resources"), ++ ], ++ expected_in_processor=False, ++ ) ++ ++ def test_simple(self): ++ self.config.runner.cib.load( ++ resources="{}".format( ++ fixture_primitive_xml("d1") + fixture_primitive_xml("d2") ++ ), ++ constraints=""" ++ ++ ++ ++ """, ++ ) ++ prim_members = ["order-d1-d2"] ++ expected = fixture_node( ++ fixture_primitive("d1", prim_members), ++ [ ++ fixture_node( ++ fixture_order("d1", "d2"), ++ [fixture_node(fixture_primitive("d2", prim_members))], ++ ) ++ ], ++ ) ++ self.assertEqual( ++ expected, ++ resource.get_resource_relations_tree( ++ self.env_assist.get_env(), "d1" ++ ) ++ ) ++ ++class GetResourceRelationsTreeComplex(TestCase): ++ def setUp(self): ++ self.env_assist, self.config = get_env_tools(test_case=self) ++ self.config.runner.cib.load( ++ resources=""" ++ ++ {primitives} ++ ++ ++ {in_group} ++ ++ ++ ++ """.format( ++ primitives=( ++ fixture_primitive_xml("d1") + ++ fixture_primitive_xml("d2") + ++ fixture_primitive_xml("d3") ++ ), ++ in_group=( ++ fixture_primitive_xml("cgd1") + ++ fixture_primitive_xml("cgd2") + ++ fixture_primitive_xml("cgd0") ++ ) ++ ), ++ constraints=""" ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ """, ++ ) ++ self.d1_members = ["order-d1-d2", "pcs_rsc_order_set_1"] ++ self.d2_members = [ ++ "order-d1-d2", "pcs_rsc_order_set_1", "order-cgd1-d2", ++ ] ++ self.order_set = dict( ++ id="pcs_rsc_order_set_1", ++ type=ResourceRelationType.ORDER_SET, ++ members=["cg", "d1", "d2", "d3"], ++ metadata={ ++ "id": "pcs_rsc_order_set_1", ++ "sets": [ ++ { ++ "id": "pcs_rsc_set_1", ++ "metadata": { ++ "id": "pcs_rsc_set_1", ++ "sequential": "true", ++ "require-all": "true", ++ "action": "start", ++ }, ++ "members": ["d1", "d3"], ++ }, ++ { ++ "id": "pcs_rsc_set_2", ++ "metadata": { ++ "id": "pcs_rsc_set_2", ++ "sequential": "false", ++ "require-all": "false", ++ "action": "stop", ++ }, ++ "members": ["cg", "d2"], ++ }, ++ ], ++ "kind": "Serialize", ++ "symmetrical": "true", ++ }, ++ ) ++ self.cg_ent = dict( ++ id="cg", ++ type="group", ++ members=["inner:cg", "pcs_rsc_order_set_1", "outer:c"], ++ metadata=dict(id="cg"), ++ ) ++ ++ def test_d1(self): ++ outer_cg = dict( ++ id="outer:cg", ++ type=ResourceRelationType.OUTER_RESOURCE, ++ members=["cg"], ++ metadata=dict(id="cg"), ++ ) ++ order_opt = fixture_order("cgd1", "d2", kind="Optional", score="10") ++ expected = fixture_node( ++ fixture_primitive("d1", self.d1_members), [ ++ fixture_node( ++ fixture_order("d1", "d2"), [ ++ fixture_node( ++ fixture_primitive("d2", self.d2_members), [ ++ fixture_node(self.order_set, leaf=True), ++ fixture_node( ++ order_opt, [ ++ fixture_node( ++ fixture_primitive( ++ "cgd1", ++ ["order-cgd1-d2", "outer:cg"], ++ ), [ ++ fixture_node( ++ outer_cg, [ ++ fixture_node( ++ self.cg_ent, ++ leaf=True, ++ ), ++ ] ++ ) ++ ] ++ ), ++ ], ++ ), ++ ], ++ ) ++ ] ++ ), ++ fixture_node( ++ self.order_set, [ ++ fixture_node( ++ self.cg_ent, [ ++ fixture_node( ++ dict( ++ id="inner:cg", ++ type=( ++ ResourceRelationType.INNER_RESOURCES ++ ), ++ members=["cgd1", "cgd2", "cgd0"], ++ metadata=dict(id="cg"), ++ ), [ ++ fixture_node( ++ fixture_primitive( ++ "cgd1", ++ ["order-cgd1-d2", "outer:cg"], ++ ), ++ leaf=True, ++ ), ++ fixture_node( ++ fixture_primitive( ++ "cgd2", ["outer:cg"] ++ ), ++ ), ++ fixture_node( ++ fixture_primitive( ++ "cgd0", ["outer:cg"] ++ ), ++ ), ++ ] ++ ), ++ fixture_node( ++ dict( ++ id="outer:c", ++ type=( ++ ResourceRelationType.OUTER_RESOURCE ++ ), ++ members=["c"], ++ metadata=dict(id="c") ++ ), [ ++ fixture_node( ++ dict( ++ id="c", ++ type="clone", ++ members=["inner:c"], ++ metadata=dict(id="c"), ++ ), ++ [] ++ ) ++ ] ++ ) ++ ], ++ ), ++ fixture_node( ++ fixture_primitive("d2", self.d2_members), leaf=True ++ ), ++ fixture_node( ++ fixture_primitive("d3", ["pcs_rsc_order_set_1"]), ++ ) ++ ], ++ ) ++ ], ++ ) ++ self.assertEqual( ++ expected, ++ resource.get_resource_relations_tree( ++ self.env_assist.get_env(), "d1" ++ ) ++ ) +diff --git a/pcs/pcs.8 b/pcs/pcs.8 +index f08b5e46..0e8b15f7 100644 +--- a/pcs/pcs.8 ++++ b/pcs/pcs.8 +@@ -209,6 +209,9 @@ Remove all constraints created by the 'relocate run' command. + .TP + utilization [ [= ...]] + Add specified utilization options to specified resource. If resource is not specified, shows utilization of all resources. If utilization options are not specified, shows utilization of specified resource. Utilization option should be in format name=value, value has to be integer. Options may be removed by setting an option without a value. Example: pcs resource utilization TestResource cpu= ram=20 ++.TP ++relations [\fB\-\-full\fR] ++Display relations of a resource specified by its id with other resources in a tree structure. Supported types of resource relations are: ordering constraints, ordering set constraints, relations defined by resource hierarchy (clones, groups, bundles). If \fB\-\-full\fR is used, more verbose output will be printed. + .SS "cluster" + .TP + auth [[:]] [...] [\fB\-u\fR ] [\fB\-p\fR ] [\fB\-\-force\fR] [\fB\-\-local\fR] +diff --git a/pcs/resource.py b/pcs/resource.py +index d3761327..27af2405 100644 +--- a/pcs/resource.py ++++ b/pcs/resource.py +@@ -28,6 +28,7 @@ from pcs.cli.resource.parse_args import ( + parse_bundle_update_options, + parse_create as parse_create_args, + ) ++from pcs.cli.resource.relations import show_resource_relations_cmd + import pcs.lib.cib.acl as lib_acl + from pcs.lib.cib.resource import ( + guest_node, +@@ -200,6 +201,8 @@ def resource_cmd(argv): + get_resource_agent_info(argv_next) + elif sub_cmd == "bundle": + resource_bundle_cmd(lib, argv_next, modifiers) ++ elif sub_cmd == "relations": ++ show_resource_relations_cmd(lib, argv_next, modifiers) + else: + usage.resource() + sys.exit(1) +diff --git a/pcs/test/tools/fixture_cib.py b/pcs/test/tools/fixture_cib.py +index 907650a7..04be4d09 100644 +--- a/pcs/test/tools/fixture_cib.py ++++ b/pcs/test/tools/fixture_cib.py +@@ -132,6 +132,9 @@ MODIFIER_GENERATORS = { + "append": append_all, + "resources": lambda xml: replace_all({"./configuration/resources": xml}), + "optional_in_conf": lambda xml: put_or_replace("./configuration", xml), ++ "constraints": lambda xml: replace_all( ++ {"./configuration/constraints": xml} ++ ), + #common modifier `put_or_replace` makes not sense - see explanation inside + #this function - all occurences should be satisfied by `optional_in_conf` + } +diff --git a/pcs/usage.py b/pcs/usage.py +index 37c92d26..582bc53f 100644 +--- a/pcs/usage.py ++++ b/pcs/usage.py +@@ -552,6 +552,13 @@ Commands: + integer. Options may be removed by setting an option without a value. + Example: pcs resource utilization TestResource cpu= ram=20 + ++ relations [--full] ++ Display relations of a resource specified by its id with other resources ++ in a tree structure. Supported types of resource relations are: ++ ordering constraints, ordering set constraints, relations defined by ++ resource hierarchy (clones, groups, bundles). If --full is used, more ++ verbose output will be printed. ++ + Examples: + + pcs resource show +diff --git a/pcsd/capabilities.xml b/pcsd/capabilities.xml +index 80fcd33b..bc1d69c8 100644 +--- a/pcsd/capabilities.xml ++++ b/pcsd/capabilities.xml +@@ -1261,6 +1261,14 @@ + pcs commands: resource restart + + ++ ++ ++ Display relations of a resource specified by its id with other resources ++ in a tree structure. ++ ++ pcs commands: resource relations ++ ++ + + + +diff --git a/test/centos7/Dockerfile b/test/centos7/Dockerfile +index 2969f8d5..9313dd1f 100644 +--- a/test/centos7/Dockerfile ++++ b/test/centos7/Dockerfile +@@ -10,7 +10,7 @@ RUN yum install -y \ + python \ + python-devel \ + python-lxml \ +- python-mock \ ++ python-pip \ + python-pycurl \ + # ruby + ruby \ +@@ -40,6 +40,8 @@ RUN yum install -y \ + fence-virt \ + booth-site + ++RUN pip install mock ++ + COPY . $src_path + + # build ruby gems +-- +2.20.1 + diff --git a/SOURCES/change-cman-to-rhel6-in-messages.patch b/SOURCES/change-cman-to-rhel6-in-messages.patch index 3d6024c..0589e6c 100644 --- a/SOURCES/change-cman-to-rhel6-in-messages.patch +++ b/SOURCES/change-cman-to-rhel6-in-messages.patch @@ -1,7 +1,7 @@ -From 2f1dd5e33e00cd36f47ca91ed21a3071f0ef0c6e Mon Sep 17 00:00:00 2001 +From 33e52f98527cc17a7e5ddfa51d6e21a022d8ce13 Mon Sep 17 00:00:00 2001 From: Ivan Devat Date: Mon, 23 May 2016 17:00:13 +0200 -Subject: [PATCH] change cman to rhel6 in messages +Subject: [PATCH 5/6] change cman to rhel6 in messages --- pcs/cli/common/console_report.py | 8 ++++---- @@ -17,10 +17,10 @@ Subject: [PATCH] change cman to rhel6 in messages 10 files changed, 48 insertions(+), 48 deletions(-) diff --git a/pcs/cli/common/console_report.py b/pcs/cli/common/console_report.py -index 4e0ae436..945b83f6 100644 +index b5885b6b..9108257e 100644 --- a/pcs/cli/common/console_report.py +++ b/pcs/cli/common/console_report.py -@@ -698,7 +698,7 @@ CODE_TO_MESSAGE_BUILDER_MAP = { +@@ -718,7 +718,7 @@ CODE_TO_MESSAGE_BUILDER_MAP = { , codes.CMAN_UNSUPPORTED_COMMAND: @@ -29,7 +29,7 @@ index 4e0ae436..945b83f6 100644 , codes.ID_ALREADY_EXISTS: lambda info: -@@ -958,7 +958,7 @@ CODE_TO_MESSAGE_BUILDER_MAP = { +@@ -986,7 +986,7 @@ CODE_TO_MESSAGE_BUILDER_MAP = { , codes.IGNORED_CMAN_UNSUPPORTED_OPTION: lambda info: @@ -38,7 +38,7 @@ index 4e0ae436..945b83f6 100644 .format(**info) , -@@ -967,12 +967,12 @@ CODE_TO_MESSAGE_BUILDER_MAP = { +@@ -995,12 +995,12 @@ CODE_TO_MESSAGE_BUILDER_MAP = { , codes.CMAN_UDPU_RESTART_REQUIRED: ( @@ -54,10 +54,10 @@ index 4e0ae436..945b83f6 100644 ), diff --git a/pcs/cluster.py b/pcs/cluster.py -index 42e94a94..d54d8fb9 100644 +index 0bacae16..ca47072d 100644 --- a/pcs/cluster.py +++ b/pcs/cluster.py -@@ -2111,7 +2111,7 @@ def node_add(lib_env, node0, node1, modifiers): +@@ -2122,7 +2122,7 @@ def node_add(lib_env, node0, node1, modifiers): else: utils.err("Unable to update any nodes") if utils.is_cman_with_udpu_transport(): @@ -66,7 +66,7 @@ index 42e94a94..d54d8fb9 100644 + "cluster restart is required to apply node addition") if wait: print() -@@ -2187,7 +2187,7 @@ def node_remove(lib_env, node0, modifiers): +@@ -2198,7 +2198,7 @@ def node_remove(lib_env, node0, modifiers): output, retval = utils.reloadCorosync() output, retval = utils.run(["crm_node", "--force", "-R", node0]) if utils.is_cman_with_udpu_transport(): @@ -75,7 +75,7 @@ index 42e94a94..d54d8fb9 100644 + "cluster restart is required to apply node removal") def cluster_localnode(argv): -@@ -2355,7 +2355,7 @@ def cluster_uidgid(argv, silent_list = False): +@@ -2366,7 +2366,7 @@ def cluster_uidgid(argv, silent_list = False): def cluster_get_corosync_conf(argv): if utils.is_rhel6(): @@ -85,10 +85,10 @@ index 42e94a94..d54d8fb9 100644 if len(argv) > 1: usage.cluster() diff --git a/pcs/config.py b/pcs/config.py -index 0afcd85d..8f31e79a 100644 +index 6c0f8194..f2cdbaa3 100644 --- a/pcs/config.py +++ b/pcs/config.py -@@ -740,7 +740,7 @@ def config_checkpoint_restore(argv): +@@ -741,7 +741,7 @@ def config_checkpoint_restore(argv): def config_import_cman(argv): if no_clufter: @@ -98,10 +98,10 @@ index 0afcd85d..8f31e79a 100644 cluster_conf = settings.cluster_conf_file dry_run_output = None diff --git a/pcs/pcs.8 b/pcs/pcs.8 -index 6440bb70..0ec4359a 100644 +index 596ceb7a..b100c82d 100644 --- a/pcs/pcs.8 +++ b/pcs/pcs.8 -@@ -215,13 +215,13 @@ auth [[:]] [...] [\fB\-u\fR ] [\fB\-p\fR ] [\fB\ +@@ -237,13 +237,13 @@ auth [[:]] [...] [\fB\-u\fR ] [\fB\-p\fR ] [\fB\ Authenticate pcs to pcsd on nodes specified, or on all nodes configured in the local cluster if no nodes are specified (authorization tokens are stored in ~/.pcs/tokens or /var/lib/pcsd/tokens for root). By default all nodes are also authenticated to each other, using \fB\-\-local\fR only authenticates the local node (and does not authenticate the remote nodes with each other). Using \fB\-\-force\fR forces re\-authentication to occur. .TP setup [\fB\-\-start\fR [\fB\-\-wait\fR[=]]] [\fB\-\-local\fR] [\fB\-\-enable\fR] \fB\-\-name\fR [] [...] [\fB\-\-transport\fR udpu|udp] [\fB\-\-rrpmode\fR active|passive] [\fB\-\-addr0\fR [[[\fB\-\-mcast0\fR
] [\fB\-\-mcastport0\fR ] [\fB\-\-ttl0\fR ]] | [\fB\-\-broadcast0\fR]] [\fB\-\-addr1\fR [[[\fB\-\-mcast1\fR
] [\fB\-\-mcastport1\fR ] [\fB\-\-ttl1\fR ]] | [\fB\-\-broadcast1\fR]]]] [\fB\-\-wait_for_all\fR=<0|1>] [\fB\-\-auto_tie_breaker\fR=<0|1>] [\fB\-\-last_man_standing\fR=<0|1> [\fB\-\-last_man_standing_window\fR=