diff --git a/cts/lab/CIB.py b/cts/lab/CIB.py index 11a7c5cf43..bc40cd05bd 100644 --- a/cts/lab/CIB.py +++ b/cts/lab/CIB.py @@ -1,500 +1,478 @@ """ CIB generator for Pacemaker's Cluster Test Suite (CTS) """ __copyright__ = "Copyright 2008-2023 the Pacemaker project contributors" __license__ = "GNU General Public License version 2 or later (GPLv2+) WITHOUT ANY WARRANTY" import os import warnings import tempfile from pacemaker.buildoptions import BuildOptions from pacemaker._cts.CTS import CtsLab +from pacemaker._cts.cibxml import Alerts, Clone, Expression, FencingTopology, Group, Nodes, OpDefaults, Option, Resource, Rule from pacemaker._cts.network import next_ip -class CibBase(object): - def __init__(self, Factory, tag, _id, **kwargs): - self.tag = tag - self.name = _id - self.kwargs = kwargs - self.children = [] - self.Factory = Factory - - def __repr__(self): - return "%s-%s" % (self.tag, self.name) - - def add_child(self, child): - self.children.append(child) - - def __setitem__(self, key, value): - if value: - self.kwargs[key] = value - else: - self.kwargs.pop(key, None) - -from cts.cib_xml import * - - class ConfigBase(object): cts_cib = None version = "unknown" Factory = None def __init__(self, CM, factory, tmpfile=None): self.CM = CM self.Factory = factory if not tmpfile: warnings.filterwarnings("ignore") f=tempfile.NamedTemporaryFile(delete=True) f.close() tmpfile = f.name warnings.resetwarnings() self.Factory.tmpfile = tmpfile def version(self): return self.version class CIB12(ConfigBase): version = "pacemaker-1.2" counter = 1 def _show(self, command=""): output = "" (_, result) = self.Factory.rsh(self.Factory.target, "HOME=/root CIB_file="+self.Factory.tmpfile+" cibadmin -Ql "+command, verbose=1) for line in result: output += line self.Factory.debug("Generated Config: "+line) return output def NewIP(self, name=None, standard="ocf"): if self.CM.Env["IPagent"] == "IPaddr2": ip = next_ip(self.CM.Env["IPBase"]) if not name: if ":" in ip: (prefix, sep, suffix) = ip.rpartition(":") name = "r"+suffix else: name = "r"+ip r = Resource(self.Factory, name, self.CM.Env["IPagent"], standard) r["ip"] = ip if ":" in ip: r["cidr_netmask"] = "64" r["nic"] = "eth0" else: r["cidr_netmask"] = "32" else: if not name: name = "r%s%d" % (self.CM.Env["IPagent"], self.counter) self.counter = self.counter + 1 r = Resource(self.Factory, name, self.CM.Env["IPagent"], standard) r.add_op("monitor", "5s") return r def get_node_id(self, node_name): """ Check the cluster configuration for a node ID. """ # We can't account for every possible configuration, # so we only return a node ID if: # * The node is specified in /etc/corosync/corosync.conf # with "ring0_addr:" equal to node_name and "nodeid:" # explicitly specified. # In all other cases, we return 0. node_id = 0 # awkward command: use } as record separator # so each corosync.conf "object" is one record; # match the "node {" record that has "ring0_addr: node_name"; # then print the substring of that record after "nodeid:" (rc, output) = self.Factory.rsh(self.Factory.target, r"""awk -v RS="}" """ r"""'/^(\s*nodelist\s*{)?\s*node\s*{.*(ring0_addr|name):\s*%s(\s+|$)/""" r"""{gsub(/.*nodeid:\s*/,"");gsub(/\s+.*$/,"");print}' %s""" % (node_name, BuildOptions.COROSYNC_CONFIG_FILE), verbose=1) if rc == 0 and len(output) == 1: try: node_id = int(output[0]) except ValueError: node_id = 0 return node_id def install(self, target): old = self.Factory.tmpfile # Force a rebuild self.cts_cib = None self.Factory.tmpfile = BuildOptions.CIB_DIR + "/cib.xml" self.contents(target) self.Factory.rsh(self.Factory.target, "chown " + BuildOptions.DAEMON_USER + " " + self.Factory.tmpfile) self.Factory.tmpfile = old def contents(self, target=None): # fencing resource if self.cts_cib: return self.cts_cib if target: self.Factory.target = target self.Factory.rsh(self.Factory.target, "HOME=/root cibadmin --empty %s > %s" % (self.version, self.Factory.tmpfile)) self.num_nodes = len(self.CM.Env["nodes"]) no_quorum = "stop" if self.num_nodes < 3: no_quorum = "ignore" self.Factory.log("Cluster only has %d nodes, configuring: no-quorum-policy=ignore" % self.num_nodes) # We don't need a nodes section unless we add attributes stn = None # Fencing resource # Define first so that the shell doesn't reject every update if self.CM.Env["DoFencing"]: # Define the "real" fencing device st = Resource(self.Factory, "Fencing", ""+self.CM.Env["stonith-type"], "stonith") # Set a threshold for unreliable stonith devices such as the vmware one st.add_meta("migration-threshold", "5") st.add_op("monitor", "120s", timeout="120s") st.add_op("stop", "0", timeout="60s") st.add_op("start", "0", timeout="60s") # For remote node tests, a cluster node is stopped and brought back up # as a remote node with the name "remote-OLDNAME". To allow fencing # devices to fence these nodes, create a list of all possible node names. all_node_names = [ prefix+n for n in self.CM.Env["nodes"] for prefix in ('', 'remote-') ] # Add all parameters specified by user entries = self.CM.Env["stonith-params"].split(',') for entry in entries: try: (name, value) = entry.split('=', 1) except ValueError: print("Warning: skipping invalid fencing parameter: %s" % entry) continue # Allow user to specify "all" as the node list, and expand it here if name in [ "hostlist", "pcmk_host_list" ] and value == "all": value = ' '.join(all_node_names) st[name] = value st.commit() # Test advanced fencing logic if True: stf_nodes = [] stt_nodes = [] attr_nodes = {} # Create the levels stl = FencingTopology(self.Factory) for node in self.CM.Env["nodes"]: # Remote node tests will rename the node remote_node = "remote-" + node # Randomly assign node to a fencing method ftype = self.CM.Env.random_gen.choice(["levels-and", "levels-or ", "broadcast "]) # For levels-and, randomly choose targeting by node name or attribute by = "" if ftype == "levels-and": node_id = self.get_node_id(node) if node_id == 0 or self.CM.Env.random_gen.choice([True, False]): by = " (by name)" else: attr_nodes[node] = node_id by = " (by attribute)" self.CM.log(" - Using %s fencing for node: %s%s" % (ftype, node, by)) if ftype == "levels-and": # If targeting by name, add a topology level for this node if node not in attr_nodes: stl.level(1, node, "FencingPass,Fencing") # Always target remote nodes by name, otherwise we would need to add # an attribute to the remote node only during remote tests (we don't # want nonexistent remote nodes showing up in the non-remote tests). # That complexity is not worth the effort. stl.level(1, remote_node, "FencingPass,Fencing") # Add the node (and its remote equivalent) to the list of levels-and nodes. stt_nodes.extend([node, remote_node]) elif ftype == "levels-or ": for n in [ node, remote_node ]: stl.level(1, n, "FencingFail") stl.level(2, n, "Fencing") stf_nodes.extend([node, remote_node]) # If any levels-and nodes were targeted by attribute, # create the attributes and a level for the attribute. if attr_nodes: stn = Nodes(self.Factory) for (node_name, node_id) in list(attr_nodes.items()): stn.add_node(node_name, node_id, { "cts-fencing" : "levels-and" }) stl.level(1, None, "FencingPass,Fencing", "cts-fencing", "levels-and") # Create a Dummy agent that always passes for levels-and if len(stt_nodes): stt = Resource(self.Factory, "FencingPass", "fence_dummy", "stonith") stt["pcmk_host_list"] = " ".join(stt_nodes) # Wait this many seconds before doing anything, handy for letting disks get flushed too stt["random_sleep_range"] = "30" stt["mode"] = "pass" stt.commit() # Create a Dummy agent that always fails for levels-or if len(stf_nodes): stf = Resource(self.Factory, "FencingFail", "fence_dummy", "stonith") stf["pcmk_host_list"] = " ".join(stf_nodes) # Wait this many seconds before doing anything, handy for letting disks get flushed too stf["random_sleep_range"] = "30" stf["mode"] = "fail" stf.commit() # Now commit the levels themselves stl.commit() o = Option(self.Factory) o["stonith-enabled"] = self.CM.Env["DoFencing"] o["start-failure-is-fatal"] = "false" o["pe-input-series-max"] = "5000" o["shutdown-escalation"] = "5min" o["batch-limit"] = "10" o["dc-deadtime"] = "5s" o["no-quorum-policy"] = no_quorum o.commit() o = OpDefaults(self.Factory) o["timeout"] = "90s" o.commit() # Commit the nodes section if we defined one if stn is not None: stn.commit() # Add an alerts section if possible if self.Factory.rsh.exists_on_all(self.CM.Env["notification-agent"], self.CM.Env["nodes"]): alerts = Alerts(self.Factory) alerts.add_alert(self.CM.Env["notification-agent"], self.CM.Env["notification-recipient"]) alerts.commit() # Add resources? if self.CM.Env["CIBResource"]: self.add_resources() if self.CM.cluster_monitor == 1: mon = Resource(self.Factory, "cluster_mon", "ocf", "ClusterMon", "pacemaker") mon.add_op("start", "0", requires="nothing") mon.add_op("monitor", "5s", requires="nothing") mon["update"] = "10" mon["extra_options"] = "-r -n" mon["user"] = "abeekhof" mon["htmlfile"] = "/suse/abeekhof/Export/cluster.html" mon.commit() #self._create('''location prefer-dc cluster_mon rule -INFINITY: \#is_dc eq false''') # generate cib self.cts_cib = self._show() if self.Factory.tmpfile != BuildOptions.CIB_DIR + "/cib.xml": self.Factory.rsh(self.Factory.target, "rm -f "+self.Factory.tmpfile) return self.cts_cib def add_resources(self): # Per-node resources for node in self.CM.Env["nodes"]: name = "rsc_"+node r = self.NewIP(name) r.prefer(node, "100") r.commit() # Migrator # Make this slightly sticky (since we have no other location constraints) to avoid relocation during Reattach m = Resource(self.Factory, "migrator","Dummy", "ocf", "pacemaker") m["passwd"] = "whatever" m.add_meta("resource-stickiness","1") m.add_meta("allow-migrate", "1") m.add_op("monitor", "P10S") m.commit() # Ping the test exerciser p = Resource(self.Factory, "ping-1","ping", "ocf", "pacemaker") p.add_op("monitor", "60s") p["host_list"] = self.CM.Env["cts-exerciser"] p["name"] = "connected" p["debug"] = "true" c = Clone(self.Factory, "Connectivity", p) c["globally-unique"] = "false" c.commit() # promotable clone resource s = Resource(self.Factory, "stateful-1", "Stateful", "ocf", "pacemaker") s.add_op("monitor", "15s", timeout="60s") s.add_op("monitor", "16s", timeout="60s", role="Promoted") ms = Clone(self.Factory, "promotable-1", s) ms["promotable"] = "true" ms["clone-max"] = self.num_nodes ms["clone-node-max"] = 1 ms["promoted-max"] = 1 ms["promoted-node-max"] = 1 # Require connectivity to run the promotable clone r = Rule(self.Factory, "connected", "-INFINITY", op="or") r.add_child(Expression(self.Factory, "m1-connected-1", "connected", "lt", "1")) r.add_child(Expression(self.Factory, "m1-connected-2", "connected", "not_defined", None)) ms.prefer("connected", rule=r) ms.commit() # Group Resource g = Group(self.Factory, "group-1") g.add_child(self.NewIP()) if self.CM.Env["have_systemd"]: sysd = Resource(self.Factory, "petulant", "pacemaker-cts-dummyd@10", "service") sysd.add_op("monitor", "P10S") g.add_child(sysd) else: g.add_child(self.NewIP()) g.add_child(self.NewIP()) # Make group depend on the promotable clone g.after("promotable-1", first="promote", then="start") g.colocate("promotable-1", "INFINITY", withrole="Promoted") g.commit() # LSB resource lsb = Resource(self.Factory, "lsb-dummy", "LSBDummy", "lsb") lsb.add_op("monitor", "5s") # LSB with group lsb.after("group-1") lsb.colocate("group-1") lsb.commit() class CIB20(CIB12): version = "pacemaker-2.5" class CIB30(CIB12): version = "pacemaker-3.7" #class HASI(CIB10): # def add_resources(self): # # DLM resource # self._create('''primitive dlm ocf:pacemaker:controld op monitor interval=120s''') # self._create('''clone dlm-clone dlm meta globally-unique=false interleave=true''') # O2CB resource # self._create('''primitive o2cb ocf:ocfs2:o2cb op monitor interval=120s''') # self._create('''clone o2cb-clone o2cb meta globally-unique=false interleave=true''') # self._create('''colocation o2cb-with-dlm INFINITY: o2cb-clone dlm-clone''') # self._create('''order start-o2cb-after-dlm mandatory: dlm-clone o2cb-clone''') class ConfigFactory(object): def __init__(self, CM): self.CM = CM self.rsh = self.CM.rsh self.register("pacemaker12", CIB12, CM, self) self.register("pacemaker20", CIB20, CM, self) self.register("pacemaker30", CIB30, CM, self) if not self.CM.Env["ListTests"]: self.target = self.CM.Env["nodes"][0] self.tmpfile = None def log(self, args): self.CM.log("cib: %s" % args) def debug(self, args): self.CM.debug("cib: %s" % args) def register(self, methodName, constructor, *args, **kargs): """register a constructor""" _args = [constructor] _args.extend(args) setattr(self, methodName, ConfigFactoryItem(*_args, **kargs)) def unregister(self, methodName): """unregister a constructor""" delattr(self, methodName) def createConfig(self, name="pacemaker-1.0"): if name == "pacemaker-1.0": name = "pacemaker10"; elif name == "pacemaker-1.2": name = "pacemaker12"; elif name == "pacemaker-2.0": name = "pacemaker20"; elif name.startswith("pacemaker-3."): name = "pacemaker30"; if hasattr(self, name): return getattr(self, name)() else: self.CM.log("Configuration variant '%s' is unknown. Defaulting to latest config" % name) return self.pacemaker30() class ConfigFactoryItem(object): def __init__(self, function, *args, **kargs): self._function = function self._args = args self._kargs = kargs def __call__(self, *args, **kargs): """call function""" _args = list(self._args) _args.extend(args) _kargs = self._kargs.copy() _kargs.update(kargs) return self._function(*_args,**_kargs) if __name__ == '__main__': """ Unit test (pass cluster node names as command line arguments) """ import cts.CM_corosync import sys if len(sys.argv) < 2: print("Usage: %s ..." % sys.argv[0]) sys.exit(1) args = [ "--nodes", " ".join(sys.argv[1:]), "--clobber-cib", "--populate-resources", "--stack", "corosync", "--test-ip-base", "fe80::1234:56:7890:1000", "--stonith", "rhcs", ] env = CtsLab(args) cm = CM_corosync.crm_corosync() CibFactory = ConfigFactory(cm) cib = CibFactory.createConfig("pacemaker-3.0") print(cib.contents()) diff --git a/cts/lab/Makefile.am b/cts/lab/Makefile.am index 77258489e0..14e3248fb3 100644 --- a/cts/lab/Makefile.am +++ b/cts/lab/Makefile.am @@ -1,28 +1,27 @@ # # Copyright 2001-2023 the Pacemaker project contributors # # The version control history for this file may have further details. # # This source code is licensed under the GNU General Public License version 2 # or later (GPLv2+) WITHOUT ANY WARRANTY. # MAINTAINERCLEANFILES = Makefile.in noinst_SCRIPTS = cluster_test \ OCFIPraTest.py # Commands intended to be run only via other commands halibdir = $(CRM_DAEMON_DIR) dist_halib_SCRIPTS = cts-log-watcher ctslibdir = $(pythondir)/cts ctslib_PYTHON = __init__.py \ CIB.py \ - cib_xml.py \ ClusterManager.py \ CM_corosync.py ctsdir = $(datadir)/$(PACKAGE)/tests/cts cts_SCRIPTS = CTSlab.py \ cts diff --git a/cts/lab/cib_xml.py b/cts/lab/cib_xml.py deleted file mode 100644 index 378dd29bc6..0000000000 --- a/cts/lab/cib_xml.py +++ /dev/null @@ -1,319 +0,0 @@ -""" CIB XML generator for Pacemaker's Cluster Test Suite (CTS) -""" - -__copyright__ = "Copyright 2008-2023 the Pacemaker project contributors" -__license__ = "GNU General Public License version 2 or later (GPLv2+) WITHOUT ANY WARRANTY" - -import sys - -from cts.CIB import CibBase - - -class XmlBase(CibBase): - def __init__(self, Factory, tag, _id, **kwargs): - CibBase.__init__(self, Factory, tag, _id, **kwargs) - - def show(self): - text = '''<%s''' % self.tag - if self.name: - text += ''' id="%s"''' % (self.name) - for k in list(self.kwargs.keys()): - text += ''' %s="%s"''' % (k, self.kwargs[k]) - - if not self.children: - text += '''/>''' - return text - - text += '''>''' - - for c in self.children: - text += c.show() - - text += '''''' % self.tag - return text - - def _run(self, operation, xml, section="all", options=""): - if self.name: - label = self.name - else: - label = "<%s>" % self.tag - self.Factory.debug("Writing out %s" % label) - fixed = "HOME=/root CIB_file="+self.Factory.tmpfile - fixed += " cibadmin --%s --scope %s %s --xml-text '%s'" % (operation, section, options, xml) - (rc, _) = self.Factory.rsh(self.Factory.target, fixed) - if rc != 0: - self.Factory.log("Configure call failed: "+fixed) - sys.exit(1) - - -class InstanceAttributes(XmlBase): - """ Create an section with name-value pairs """ - - def __init__(self, Factory, name, attrs): - XmlBase.__init__(self, Factory, "instance_attributes", name) - - # Create an for each attribute - for (attr, value) in list(attrs.items()): - self.add_child(XmlBase(Factory, "nvpair", "%s-%s" % (name, attr), - name=attr, value=value)) - - -class Node(XmlBase): - """ Create a section with node attributes for one node """ - - def __init__(self, Factory, node_name, node_id, node_attrs): - XmlBase.__init__(self, Factory, "node", node_id, uname=node_name) - self.add_child(InstanceAttributes(Factory, "%s-1" % node_name, node_attrs)) - - -class Nodes(XmlBase): - """ Create a section """ - - def __init__(self, Factory): - XmlBase.__init__(self, Factory, "nodes", None) - - def add_node(self, node_name, node_id, node_attrs): - self.add_child(Node(self.Factory, node_name, node_id, node_attrs)) - - def commit(self): - self._run("modify", self.show(), "configuration", "--allow-create") - - -class FencingTopology(XmlBase): - def __init__(self, Factory): - XmlBase.__init__(self, Factory, "fencing-topology", None) - - def level(self, index, target, devices, target_attr=None, target_value=None): - # Generate XML ID (sanitizing target-by-attribute levels) - - if target: - xml_id = "cts-%s.%d" % (target, index) - self.add_child(XmlBase(self.Factory, "fencing-level", xml_id, target=target, index=index, devices=devices)) - - else: - xml_id = "%s-%s.%d" % (target_attr, target_value, index) - child = XmlBase(self.Factory, "fencing-level", xml_id, index=index, devices=devices) - child["target-attribute"]=target_attr - child["target-value"]=target_value - self.add_child(child) - - def commit(self): - self._run("create", self.show(), "configuration", "--allow-create") - - -class Option(XmlBase): - def __init__(self, Factory, section="cib-bootstrap-options"): - XmlBase.__init__(self, Factory, "cluster_property_set", section) - - def __setitem__(self, key, value): - self.add_child(XmlBase(self.Factory, "nvpair", "cts-%s" % key, name=key, value=value)) - - def commit(self): - self._run("modify", self.show(), "crm_config", "--allow-create") - - -class OpDefaults(XmlBase): - def __init__(self, Factory): - XmlBase.__init__(self, Factory, "op_defaults", None) - self.meta = XmlBase(self.Factory, "meta_attributes", "cts-op_defaults-meta") - self.add_child(self.meta) - - def __setitem__(self, key, value): - self.meta.add_child(XmlBase(self.Factory, "nvpair", "cts-op_defaults-%s" % key, name=key, value=value)) - - def commit(self): - self._run("modify", self.show(), "configuration", "--allow-create") - - -class Alerts(XmlBase): - def __init__(self, Factory): - XmlBase.__init__(self, Factory, "alerts", None) - self.alert_count = 0 - - def add_alert(self, path, recipient): - self.alert_count = self.alert_count + 1 - alert = XmlBase(self.Factory, "alert", "alert-%d" % self.alert_count, - path=path) - recipient1 = XmlBase(self.Factory, "recipient", - "alert-%d-recipient-1" % self.alert_count, - value=recipient) - alert.add_child(recipient1) - self.add_child(alert) - - def commit(self): - self._run("modify", self.show(), "configuration", "--allow-create") - - -class Expression(XmlBase): - def __init__(self, Factory, name, attr, op, value=None): - XmlBase.__init__(self, Factory, "expression", name, attribute=attr, operation=op) - if value: - self["value"] = value - - -class Rule(XmlBase): - def __init__(self, Factory, name, score, op="and", expr=None): - XmlBase.__init__(self, Factory, "rule", "%s" % name) - self["boolean-op"] = op - self["score"] = score - if expr: - self.add_child(expr) - - -class Resource(XmlBase): - def __init__(self, Factory, name, rtype, standard, provider=None): - XmlBase.__init__(self, Factory, "native", name) - - self.rtype = rtype - self.standard = standard - self.provider = provider - - self.op = [] - self.meta = {} - self.param = {} - - self.scores = {} - self.needs = {} - self.coloc = {} - - if self.standard == "ocf" and not provider: - self.provider = "heartbeat" - elif self.standard == "lsb": - self.provider = None - - def __setitem__(self, key, value): - self.add_param(key, value) - - def add_op(self, name, interval, **kwargs): - self.op.append( - XmlBase(self.Factory, "op", "%s-%s" % (name, interval), name=name, interval=interval, **kwargs)) - - def add_param(self, name, value): - self.param[name] = value - - def add_meta(self, name, value): - self.meta[name] = value - - def prefer(self, node, score="INFINITY", rule=None): - if not rule: - rule = Rule(self.Factory, "prefer-%s-r" % node, score, - expr=Expression(self.Factory, "prefer-%s-e" % node, "#uname", "eq", node)) - self.scores[node] = rule - - def after(self, resource, kind="Mandatory", first="start", then="start", **kwargs): - kargs = kwargs.copy() - kargs["kind"] = kind - if then: - kargs["first-action"] = "start" - kargs["then-action"] = then - - if first: - kargs["first-action"] = first - - self.needs[resource] = kargs - - def colocate(self, resource, score="INFINITY", role=None, withrole=None, **kwargs): - kargs = kwargs.copy() - kargs["score"] = score - if role: - kargs["rsc-role"] = role - if withrole: - kargs["with-rsc-role"] = withrole - - self.coloc[resource] = kargs - - def constraints(self): - text = "" - - for k in list(self.scores.keys()): - text += '''''' % (k, self.name) - text += self.scores[k].show() - text += '''''' - - for k in list(self.needs.keys()): - text += '''''' - - for k in list(self.coloc.keys()): - text += '''''' - - text += "" - return text - - def show(self): - text = '''''' - - if len(self.meta) > 0: - text += '''''' % self.name - for p in list(self.meta.keys()): - text += '''''' % (self.name, p, p, self.meta[p]) - text += '''''' - - if len(self.param) > 0: - text += '''''' % self.name - for p in list(self.param.keys()): - text += '''''' % (self.name, p, p, self.param[p]) - text += '''''' - - if len(self.op) > 0: - text += '''''' - for o in self.op: - key = o.name - o.name = "%s-%s" % (self.name, key) - text += o.show() - o.name = key - text += '''''' - - text += '''''' - return text - - def commit(self): - self._run("create", self.show(), "resources") - self._run("modify", self.constraints()) - - -class Group(Resource): - def __init__(self, Factory, name): - Resource.__init__(self, Factory, name, None, None) - self.tag = "group" - - def __setitem__(self, key, value): - self.add_meta(key, value) - - def show(self): - text = '''<%s id="%s">''' % (self.tag, self.name) - - if len(self.meta) > 0: - text += '''''' % self.name - for p in list(self.meta.keys()): - text += '''''' % (self.name, p, p, self.meta[p]) - text += '''''' - - for c in self.children: - text += c.show() - text += '''''' % self.tag - return text - - -class Clone(Group): - def __init__(self, Factory, name, child=None): - Group.__init__(self, Factory, name) - self.tag = "clone" - if child: - self.add_child(child) - - def add_child(self, resource): - if not self.children: - self.children.append(resource) - else: - self.Factory.log("Clones can only have a single child. Ignoring %s" % resource.name) diff --git a/python/pacemaker/_cts/Makefile.am b/python/pacemaker/_cts/Makefile.am index 9c3d0afbb6..f1baaf66ef 100644 --- a/python/pacemaker/_cts/Makefile.am +++ b/python/pacemaker/_cts/Makefile.am @@ -1,31 +1,32 @@ # # Copyright 2023 the Pacemaker project contributors # # The version control history for this file may have further details. # # This source code is licensed under the GNU General Public License version 2 # or later (GPLv2+) WITHOUT ANY WARRANTY. # MAINTAINERCLEANFILES = Makefile.in pkgpythondir = $(pythondir)/$(PACKAGE)/_cts pkgpython_PYTHON = CTS.py \ __init__.py \ audits.py \ + cibxml.py \ corosync.py \ environment.py \ errors.py \ input.py \ logging.py \ network.py \ patterns.py \ process.py \ remote.py \ scenarios.py \ test.py \ timer.py \ watcher.py SUBDIRS = tests diff --git a/python/pacemaker/_cts/cibxml.py b/python/pacemaker/_cts/cibxml.py new file mode 100644 index 0000000000..a4aa0209e4 --- /dev/null +++ b/python/pacemaker/_cts/cibxml.py @@ -0,0 +1,723 @@ +""" CIB XML generator for Pacemaker's Cluster Test Suite (CTS) """ + +__all__ = [ "Alerts", "Clone", "Expression", "FencingTopology", "Group", "Nodes", "OpDefaults", "Option", "Resource", "Rule" ] +__copyright__ = "Copyright 2008-2023 the Pacemaker project contributors" +__license__ = "GNU General Public License version 2 or later (GPLv2+) WITHOUT ANY WARRANTY" + + +def key_val_string(**kwargs): + """ Given keyword arguments as key=value pairs, construct a single string + containing all those pairs separated by spaces. This is suitable for + using in an XML element as a list of its attributes. + + Any pairs that have value=None will be skipped. + + Note that a dictionary can be passed to this function instead of kwargs + by using a construction like: + + key_val_string(**{"a": 1, "b": 2}) + """ + + retval = "" + + for (k, v) in kwargs.items(): + if v is None: + continue + + retval += ' %s="%s"' % (k, v) + + return retval + + +def element(element_name, **kwargs): + """ Create an XML element string with the given element_name and attributes. + This element does not support having any children, so it will be closed + on the same line. The attributes are processed by key_val_string. + """ + + return "<%s %s/>" % (element_name, key_val_string(**kwargs)) + + +def containing_element(element_name, inner, **kwargs): + """ Like element, but surrounds some child text passed by the inner + parameter. + """ + + attrs = key_val_string(**kwargs) + return "<%s %s>%s" % (element_name, attrs, inner, element_name) + + +class XmlBase: + """ A base class for deriving all kinds of XML sections in the CIB. This + class contains only the most basic operations common to all sections. + It is up to subclasses to provide most behavior. + + Note that subclasses of this base class often have different sets of + arguments to their __init__ methods. In general this is not a great + practice, however it is so thoroughly used in these classes that trying + to straighten it out is likely to cause more bugs than just leaving it + alone for now. + """ + + def __init__(self, factory, tag, _id, **kwargs): + """ Create a new XmlBase instance + + Arguments: + + factory -- A CIB.ConfigFactory instance + tag -- The XML element's start and end tag + _id -- A unique name for the element + kwargs -- Any additional key/value pairs that should be added to + this element as attributes + """ + + self._children = [] + self._factory = factory + self._kwargs = kwargs + self._tag = tag + + self.name = _id + + def __repr__(self): + """ Return a short string description of this XML section """ + + return "%s-%s" % (self._tag, self.name) + + def add_child(self, child): + """ Add an XML section as a child of this one """ + + self._children.append(child) + + def __setitem__(self, key, value): + """ Add a key/value pair to this element, resulting in it becoming an + XML attribute. If value is None, remove the key. + """ + + if value: + self._kwargs[key] = value + else: + self._kwargs.pop(key, None) + + def show(self): + """ Return a string representation of this XML section, including all + of its children + """ + + text = '''<%s''' % self._tag + if self.name: + text += ''' id="%s"''' % self.name + + text += key_val_string(**self._kwargs) + + if not self._children: + text += '''/>''' + return text + + text += '''>''' + + for c in self._children: + text += c.show() + + text += '''''' % self._tag + return text + + def _run(self, operation, xml, section, options=""): + """ Update the CIB on the cluster to include this XML section, including + all of its children + + Arguments: + + operation -- Whether this update is a "create" or "modify" operation + xml -- The XML to update the CIB with, typically the result + of calling show + section -- Which section of the CIB this update applies to (see + the --scope argument to cibadmin for allowed values) + options -- Extra options to pass to cibadmin + """ + + if self.name: + label = self.name + else: + label = "<%s>" % self._tag + + self._factory.debug("Writing out %s" % label) + + fixed = "HOME=/root CIB_file=%s" % self._factory.tmpfile + fixed += " cibadmin --%s --scope %s %s --xml-text '%s'" % (operation, section, options, xml) + + (rc, _) = self._factory.rsh(self._factory.target, fixed) + if rc != 0: + raise RuntimeError("Configure call failed: %s" % fixed) + + +class InstanceAttributes(XmlBase): + """ A class that creates an XML section with + key/value pairs + """ + + def __init__(self, factory, _id, attrs): + """ Create a new InstanceAttributes instance + + Arguments: + + factory -- A CIB.ConfigFactory instance + _id -- A unique name for the element + attrs -- Key/value pairs to add as nvpair child elements + """ + + XmlBase.__init__(self, factory, "instance_attributes", _id) + + # Create an for each attribute + for (attr, value) in attrs.items(): + self.add_child(XmlBase(factory, "nvpair", "%s-%s" % (_id, attr), + name=attr, value=value)) + + +class Node(XmlBase): + """ A class that creates a XML section for a single node, complete + with node attributes + """ + + def __init__(self, factory, node_name, node_id, node_attrs): + """ Create a new Node instance + + Arguments: + + factory -- A CIB.ConfigFactory instance + node_name -- The value of the uname attribute for this node + node_id -- A unique name for the element + node_attrs -- Additional key/value pairs to set as instance + attributes for this node + """ + + XmlBase.__init__(self, factory, "node", node_id, uname=node_name) + self.add_child(InstanceAttributes(factory, "%s-1" % node_name, node_attrs)) + + +class Nodes(XmlBase): + """ A class that creates a XML section containing multiple Node + instances as children + """ + + def __init__(self, factory): + """ Create a new Nodes instance + + Arguments: + + factory -- A CIB.ConfigFactory instance + """ + + XmlBase.__init__(self, factory, "nodes", None) + + def add_node(self, node_name, node_id, node_attrs): + """ Add a child node element + + Arguments: + + node_name -- The value of the uname attribute for this node + node_id -- A unique name for the element + node_attrs -- Additional key/value pairs to set as instance + attributes for this node + """ + + self.add_child(Node(self._factory, node_name, node_id, node_attrs)) + + def commit(self): + """ Modify the CIB on the cluster to include this XML section """ + + self._run("modify", self.show(), "configuration", "--allow-create") + + +class FencingTopology(XmlBase): + """ A class that creates a XML section describing how + fencing is configured in the cluster + """ + + def __init__(self, factory): + """ Create a new FencingTopology instance + + Arguments: + + factory -- A CIB.ConfigFactory instance + """ + + XmlBase.__init__(self, factory, "fencing-topology", None) + + def level(self, index, target, devices, target_attr=None, target_value=None): + """ Generate a XML element + + index -- The order in which to attempt fencing-levels + (1 through 9). Levels are attempted in ascending + order until one succeeds. + target -- The name of a single node to which this level applies + devices -- A list of devices that must all be tried for this + level + target_attr -- The name of a node attribute that is set for nodes + to which this level applies + target_value -- The value of a node attribute that is set for nodes + to which this level applies + """ + + if target: + xml_id = "cts-%s.%d" % (target, index) + self.add_child(XmlBase(self._factory, "fencing-level", xml_id, target=target, index=index, devices=devices)) + + else: + xml_id = "%s-%s.%d" % (target_attr, target_value, index) + child = XmlBase(self._factory, "fencing-level", xml_id, index=index, devices=devices) + child["target-attribute"]=target_attr + child["target-value"]=target_value + self.add_child(child) + + def commit(self): + """ Create this XML section in the CIB """ + + self._run("create", self.show(), "configuration", "--allow-create") + + +class Option(XmlBase): + """ A class that creates a XML section of key/value + pairs for cluster-wide configuration settings + """ + + def __init__(self, factory, _id="cib-bootstrap-options"): + """ Create a new Option instance + + Arguments: + + factory -- A CIB.ConfigFactory instance + _id -- A unique name for the element + """ + + XmlBase.__init__(self, factory, "cluster_property_set", _id) + + def __setitem__(self, key, value): + """ Add a child nvpair element containing the given key/value pair """ + + self.add_child(XmlBase(self._factory, "nvpair", "cts-%s" % key, name=key, value=value)) + + def commit(self): + """ Modify the CIB on the cluster to include this XML section """ + + self._run("modify", self.show(), "crm_config", "--allow-create") + + +class OpDefaults(XmlBase): + """ A class that creates a XML section of key/value + pairs for operation default settings + """ + + def __init__(self, factory): + """ Create a new OpDefaults instance + + Arguments: + + factory -- A CIB.ConfigFactory instance + """ + + XmlBase.__init__(self, factory, "op_defaults", None) + self.meta = XmlBase(self._factory, "meta_attributes", "cts-op_defaults-meta") + self.add_child(self.meta) + + def __setitem__(self, key, value): + """ Add a child nvpair meta_attribute element containing the given + key/value pair + """ + + self.meta.add_child(XmlBase(self._factory, "nvpair", "cts-op_defaults-%s" % key, name=key, value=value)) + + def commit(self): + """ Modify the CIB on the cluster to include this XML section """ + + self._run("modify", self.show(), "configuration", "--allow-create") + + +class Alerts(XmlBase): + """ A class that creates an XML section """ + + def __init__(self, factory): + """ Create a new Alerts instance + + Arguments: + + factory -- A CIB.ConfigFactory instance + """ + + XmlBase.__init__(self, factory, "alerts", None) + self._alert_count = 0 + + def add_alert(self, path, recipient): + """ Create a new alert as a child of this XML section + + Arguments: + + path -- The path to a script to be called when a cluster + event occurs + recipient -- An environment variable to be passed to the script + """ + + self._alert_count += 1 + alert = XmlBase(self._factory, "alert", "alert-%d" % self._alert_count, + path=path) + recipient1 = XmlBase(self._factory, "recipient", + "alert-%d-recipient-1" % self._alert_count, + value=recipient) + alert.add_child(recipient1) + self.add_child(alert) + + def commit(self): + """ Modify the CIB on the cluster to include this XML section """ + + self._run("modify", self.show(), "configuration", "--allow-create") + + +class Expression(XmlBase): + """ A class that creates an XML element as part of some + constraint rule + """ + + def __init__(self, factory, _id, attr, op, value=None): + """ Create a new Expression instance + + Arguments: + + factory -- A CIB.ConfigFactory instance + _id -- A unique name for the element + attr -- The attribute to be tested + op -- The comparison to perform ("lt", "eq", "defined", etc.) + value -- Value for comparison (can be None for "defined" and + "not_defined" operations) + """ + + XmlBase.__init__(self, factory, "expression", _id, attribute=attr, operation=op) + if value: + self["value"] = value + + +class Rule(XmlBase): + """ A class that creates a XML section consisting of one or more + expressions, as part of some constraint + """ + + def __init__(self, factory, _id, score, op="and", expr=None): + """ Create a new Rule instance + + Arguments: + + factory -- A CIB.ConfigFactory instance + _id -- A unique name for the element + score -- If this rule is used in a location constraint and + evaluates to true, apply this score to the constraint + op -- If this rule contains more than one expression, use this + boolean op when evaluating + expr -- An Expression instance that can be added to this Rule + when it is created + """ + + XmlBase.__init__(self, factory, "rule", _id) + + self["boolean-op"] = op + self["score"] = score + + if expr: + self.add_child(expr) + + +class Resource(XmlBase): + """ A base class that creates all kinds of XML sections fully + describing a single cluster resource. This defaults to primitive + resources, but subclasses can create other types. + """ + + def __init__(self, factory, _id, rtype, standard, provider=None): + """ Create a new Resource instance + + Arguments: + + factory -- A CIB.ConfigFactory instance + _id -- A unique name for the element + rtype -- The name of the resource agent + standard -- The standard the resource agent follows ("ocf", + "systemd", etc.) + provider -- The vendor providing the resource agent + """ + + XmlBase.__init__(self, factory, "native", _id) + + self._provider = provider + self._rtype = rtype + self._standard = standard + + self._meta = {} + self._op = [] + self._param = {} + + self._coloc = {} + self._needs = {} + self._scores = {} + + if self._standard == "ocf" and not provider: + self._provider = "heartbeat" + elif self._standard == "lsb": + self._provider = None + + def __setitem__(self, key, value): + """ Add a child nvpair element containing the given key/value pair as + an instance attribute + """ + + self._add_param(key, value) + + def add_op(self, _id, interval, **kwargs): + """ Add an operation child XML element to this resource + + Arguments: + + _id -- A unique name for the element. Also, the action to + perform ("monitor", "start", "stop", etc.) + interval -- How frequently (in seconds) to perform the operation + kwargs -- Any additional key/value pairs that should be added to + this element as attributes + """ + + self._op.append(XmlBase(self._factory, "op", "%s-%s" % (_id, interval), + name=_id, interval=interval, **kwargs)) + + def _add_param(self, name, value): + """ Add a child nvpair element containing the given key/value pair as + an instance attribute + """ + + self._param[name] = value + + def add_meta(self, name, value): + """ Add a child nvpair element containing the given key/value pair as + a meta attribute + """ + + self._meta[name] = value + + def prefer(self, node, score="INFINITY", rule=None): + """ Add a location constraint where this resource prefers some node + + Arguments: + + node -- The name of the node to prefer + score -- Apply this score to the location constraint + rule -- A Rule instance to use in creating this constraint, instead + of creating a new rule + """ + + if not rule: + rule = Rule(self._factory, "prefer-%s-r" % node, score, + expr=Expression(self._factory, "prefer-%s-e" % node, "#uname", "eq", node)) + + self._scores[node] = rule + + def after(self, resource, kind="Mandatory", first="start", then="start", **kwargs): + """ Create an ordering constraint between this resource and some other + + Arguments: + + resource -- The name of the dependent resource + kind -- How to enforce the constraint ("mandatory", "optional", + "serialize") + first -- The action that this resource must complete before the + then-action can be initiated for the dependent resource + ("start", "stop", "promote", "demote") + then -- The action that the dependent resource can execute only + after the first-action has completed (same values as + first) + kwargs -- Any additional key/value pairs that should be added to + this element as attributes + """ + + kargs = kwargs.copy() + kargs["kind"] = kind + + if then: + kargs["first-action"] = "start" + kargs["then-action"] = then + + if first: + kargs["first-action"] = first + + self._needs[resource] = kargs + + def colocate(self, resource, score="INFINITY", role=None, withrole=None, **kwargs): + """ Create a colocation constraint between this resource and some other + + Arguments: + + resource -- The name of the resource that should be located relative + this one + score -- Apply this score to the colocation constraint + role -- Apply this colocation constraint only to promotable clones + in this role ("started", "promoted", "unpromoted") + withrole -- Apply this colocation constraint only to with-rsc promotable + clones in this role + kwargs -- Any additional key/value pairs that should be added to + this element as attributes + """ + + kargs = kwargs.copy() + kargs["score"] = score + + if role: + kargs["rsc-role"] = role + + if withrole: + kargs["with-rsc-role"] = withrole + + self._coloc[resource] = kargs + + def _constraints(self): + """ Generate a XML section containing all previously added + ordering and colocation constraints + """ + + text = "" + + for (k, v) in self._scores.items(): + attrs = {"id": "prefer-%s" % k, "rsc": self.name} + text += containing_element("rsc_location", v.show(), **attrs) + + for (k, kargs) in self._needs.items(): + attrs = {"id": "%s-after-%s" % (self.name, k), "first": k, "then": self.name} + text += element("rsc_order", **attrs, **kargs) + + for (k, kargs) in self._coloc.items(): + attrs = {"id": "%s-with-%s" % (self.name, k), "rsc": self.name, "with-rsc": k} + text += element("rsc_colocation", **attrs) + + text += "" + return text + + def show(self): + """ Return a string representation of this XML section, including all + of its children + """ + + text = '''''' + + if self._meta: + nvpairs = "" + for (p, v) in self._meta.items(): + attrs = {"id": "%s-%s" % (self.name, p), "name": p, "value": v} + nvpairs += element("nvpair", **attrs) + + text += containing_element("meta_attributes", nvpairs, + id="%s-meta" % self.name) + + if self._param: + nvpairs = "" + for (p, v) in self._param.items(): + attrs = {"id": "%s-%s" % (self.name, p), "name": p, "value": v} + nvpairs += element("nvpair", **attrs) + + text += containing_element("instance_attributes", nvpairs, + id="%s-params" % self.name) + + if self._op: + text += '''''' + + for o in self._op: + key = o.name + o.name = "%s-%s" % (self.name, key) + text += o.show() + o.name = key + + text += '''''' + + text += '''''' + return text + + def commit(self): + """ Modify the CIB on the cluster to include this XML section """ + + self._run("create", self.show(), "resources") + self._run("modify", self._constraints(), "constraints") + + +class Group(Resource): + """ A specialized Resource subclass that creates a XML section + describing a single group resource consisting of multiple child + primitive resources + """ + + def __init__(self, factory, _id): + """ Create a new Group instance + + Arguments: + + factory -- A CIB.ConfigFactory instance + _id -- A unique name for the element + """ + + Resource.__init__(self, factory, _id, None, None) + self.tag = "group" + + def __setitem__(self, key, value): + self.add_meta(key, value) + + def show(self): + """ Return a string representation of this XML section, including all + of its children + """ + + text = '''<%s id="%s">''' % (self.tag, self.name) + + if len(self._meta) > 0: + nvpairs = "" + for (p, v) in self._meta.items(): + attrs = {"id": "%s-%s" % (self.name, p), "name": p, "value": v} + nvpairs += element("nvpair", **attrs) + + text += containing_element("meta_attributes", nvpairs, + id="%s-meta" % self.name) + + for c in self._children: + text += c.show() + + text += '''''' % self.tag + return text + + +class Clone(Group): + """ A specialized Group subclass that creates a XML section + describing a clone resource containing multiple instances of a + single primitive resource + """ + + def __init__(self, factory, _id, child=None): + """ Create a new Clone instance + + Arguments: + + factory -- A CIB.ConfigFactory instance + _id -- A unique name for the element + child -- A Resource instance that can be added to this Clone + when it is created. Alternately, use add_child later. + Note that a Clone may only have one child. + """ + + Group.__init__(self, factory, _id) + self.tag = "clone" + + if child: + self.add_child(child) + + def add_child(self, child): + """ Add the given resource as a child of this Clone. Note that a + Clone resource only supports one child at a time. + """ + + if not self._children: + self._children.append(child) + else: + self._factory.log("Clones can only have a single child. Ignoring %s" % child.name)