Louigie

MAAS amt.py

Aug 30th, 2024
240
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 19.00 KB | Source Code | 0 0
  1. # Copyright 2015-2021 Canonical Ltd.  This software is licensed under the
  2. # GNU Affero General Public License version 3 (see the file LICENSE).
  3.  
  4. """AMT Power Driver."""
  5.  
  6.  
  7. import re
  8. from itertools import chain
  9. from os.path import dirname, join
  10. from time import sleep
  11.  
  12. from lxml import etree
  13. from provisioningserver.drivers import make_ip_extractor, make_setting_field
  14. from provisioningserver.drivers.power import (
  15.     PowerActionError,
  16.     PowerAuthError,
  17.     PowerConnError,
  18.     PowerDriver,
  19.     PowerSettingError,
  20.     is_power_parameter_set,
  21. )
  22. from provisioningserver.utils import shell, snap
  23.  
  24. AMT_ERRORS = {
  25.     "401 Unauthorized": {
  26.         "message": ("Incorrect password.  Check BMC configuration and try again."),
  27.         "exception": PowerAuthError,
  28.     },
  29.     "500 Can't connect": {
  30.         "message": (
  31.             "Could not connect to BMC.  " "Check BMC configuration and try again."
  32.         ),
  33.         "exception": PowerConnError,
  34.     },
  35. }
  36.  
  37.  
  38. REQUIRED_PACKAGES = [["wsman", "wsmancli"]]
  39.  
  40. AMT_HTTP = "HTTP"
  41. AMT_HTTPS = "HTTPS"
  42. AMT_SCHEMA_CHOICES = [[AMT_HTTP, "http"], [AMT_HTTPS, "https"]]
  43.  
  44.  
  45. class AMTPowerDriver(PowerDriver):
  46.     name = "amt"
  47.     chassis = False
  48.     can_probe = False
  49.     can_set_boot_order = False
  50.     description = "Intel AMT"
  51.     settings = [
  52.         make_setting_field(
  53.             "power_pass", "Power password", field_type="password", secret=True
  54.         ),
  55.         make_setting_field("power_address", "Power address", required=True),
  56.         make_setting_field(
  57.             "schema",
  58.             "Schema",
  59.             field_type="choice",
  60.             choices=AMT_SCHEMA_CHOICES,
  61.             # This was the first type of supported schema
  62.             default=AMT_HTTP,
  63.         ),
  64.     ]
  65.     ip_extractor = make_ip_extractor("power_address")
  66.  
  67.     def detect_missing_packages(self):
  68.         missing_packages = []
  69.         for binary, package in REQUIRED_PACKAGES:
  70.             if not shell.has_command_available(binary):
  71.                 missing_packages.append(package)
  72.         return missing_packages
  73.  
  74.     def _render_wsman_state_xml(self, power_change) -> bytes:
  75.         """Render wsman state XML."""
  76.         wsman_state_filename = join(dirname(__file__), "amt.wsman-state.xml")
  77.         wsman_state_ns = {
  78.             "p": (
  79.                 "http://schemas.dmtf.org/wbem/wscim/1/cim-schema"
  80.                 "/2/CIM_PowerManagementService"
  81.             )
  82.         }
  83.         tree = etree.parse(wsman_state_filename)
  84.         [ps] = tree.xpath("//p:PowerState", namespaces=wsman_state_ns)
  85.         power_states = {"on": "2", "off": "8", "restart": "10"}
  86.         ps.text = power_states[power_change]
  87.         return etree.tostring(tree)
  88.  
  89.     def _parse_multiple_xml_docs(self, xml: bytes):
  90.         """Parse multiple XML documents.
  91.  
  92.        Each document must commence with an XML document declaration, i.e.
  93.        <?xml ...
  94.  
  95.        Works around a weird decision in `wsman` where it returns multiple XML
  96.        documents in a single stream.
  97.        """
  98.         xmldecl = re.compile(b"<[?]xml\\s")
  99.         xmldecls = xmldecl.finditer(xml)
  100.         starts = [match.start() for match in xmldecls]
  101.         ends = starts[1:] + [len(xml)]
  102.         frags = (xml[start:end] for start, end in zip(starts, ends))
  103.         return (etree.fromstring(frag) for frag in frags)
  104.  
  105.     def get_power_state(self, xml: bytes) -> str:
  106.         """Get PowerState text from XML."""
  107.         namespaces = {
  108.             "h": (
  109.                 "http://schemas.dmtf.org/wbem/wscim/1/cim-schema"
  110.                 "/2/CIM_AssociatedPowerManagementService"
  111.             )
  112.         }
  113.         state = next(
  114.             chain.from_iterable(
  115.                 doc.xpath("//h:PowerState/text()", namespaces=namespaces)
  116.                 for doc in self._parse_multiple_xml_docs(xml)
  117.             )
  118.         )
  119.         return state
  120.  
  121.     def _set_pxe_boot(self, schema, host, power_pass):
  122.         """Set to PXE for next boot."""
  123.         wsman_pxe_options = {
  124.             "ChangeBootOrder": (
  125.                 join(dirname(__file__), "amt.wsman-pxe.xml"),
  126.                 (
  127.                     "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/"
  128.                     'CIM_BootConfigSetting?InstanceID="Intel(r) '
  129.                     'AMT: Boot Configuration 0"'
  130.                 ),
  131.             ),
  132.             "SetBootConfigRole": (
  133.                 join(dirname(__file__), "amt.wsman-boot-config.xml"),
  134.                 (
  135.                     "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/"
  136.                     "CIM_BootService?SystemCreationClassName="
  137.                     '"CIM_ComputerSystem"&SystemName="Intel(r) AMT"'
  138.                     '&CreationClassName="CIM_BootService"&Name="Intel(r)'
  139.                     ' AMT Boot Service"'
  140.                 ),
  141.             ),
  142.         }
  143.         wsman_opts = (
  144.             "--input",
  145.             "-",
  146.             "invoke",
  147.             "--method",
  148.         )
  149.         # Change boot order to PXE and enable boot config request
  150.         for method, (schema_file, schema_uri) in wsman_pxe_options.items():
  151.             with open(schema_file, "rb") as fd:
  152.                 command = self._get_wsman_command(
  153.                     schema, host, power_pass, *wsman_opts, method, schema_uri
  154.                 )
  155.                 self._run(command, power_pass, stdin=fd.read())
  156.  
  157.     def _run(self, command: tuple, power_pass: str, stdin: bytes = None) -> bytes:
  158.         """Run a subprocess with stdin."""
  159.         result = shell.run_command(
  160.             *command,
  161.             stdin=stdin,
  162.             extra_environ={"AMT_PASSWORD": power_pass},
  163.             decode=False,
  164.         )
  165.         if result.returncode != 0:
  166.             raise PowerActionError(
  167.                 "Failed to run command: %s with error: %s"
  168.                 % (command, result.stderr.decode("utf-8", "replace"))
  169.             )
  170.         return result.stdout
  171.  
  172.     def _issue_amttool_command(
  173.         self,
  174.         cmd: str,
  175.         ip_address: str,
  176.         power_pass: str,
  177.         amttool_boot_mode=None,
  178.         stdin=None,
  179.     ) -> bytes:
  180.         """Perform a command using amttool."""
  181.         command = ("amttool", ip_address, cmd)
  182.         if cmd in ("power-cycle", "powerup"):
  183.             command += (amttool_boot_mode,)
  184.         return self._run(command, power_pass, stdin=stdin)
  185.  
  186.     def _issue_wsman_command(
  187.         self, power_change: str, schema: str, host: str, power_pass: str
  188.     ) -> bytes:
  189.         """Perform a command using wsman."""
  190.         wsman_power_schema_uri = (
  191.             "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/"
  192.             "CIM_PowerManagementService?SystemCreationClassName="
  193.             '"CIM_ComputerSystem"&SystemName="Intel(r) AMT"'
  194.             '&CreationClassName="CIM_PowerManagementService"&Name='
  195.             '"Intel(r) AMT Power Management Service"'
  196.         )
  197.         wsman_query_schema_uri = (
  198.             "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/"
  199.             "CIM_AssociatedPowerManagementService"
  200.         )
  201.         command_args = ()
  202.         if power_change in ("on", "off", "restart"):
  203.             stdin = self._render_wsman_state_xml(power_change)
  204.             command_args += (
  205.                 "--input",
  206.                 "-",
  207.                 "invoke",
  208.                 "--method",
  209.                 "RequestPowerStateChange",
  210.                 wsman_power_schema_uri,
  211.             )
  212.         elif power_change == "query":
  213.             stdin = None  # No input for query
  214.             command_args += (
  215.                 "--optimize",
  216.                 "--encoding",
  217.                 "utf-8",
  218.                 "enumerate",
  219.                 wsman_query_schema_uri,
  220.             )
  221.         command = self._get_wsman_command(schema, host, power_pass, *command_args)
  222.         return self._run(command, power_pass, stdin=stdin)
  223.  
  224.     def amttool_query_state(self, ip_address, power_pass):
  225.         """Ask for node's power state: 'on' or 'off', via amttool."""
  226.         # Retry the state if it fails because it often fails the first time
  227.         for _ in range(10):
  228.             output = self._issue_amttool_command("info", ip_address, power_pass)
  229.             if output:
  230.                 break
  231.             # Wait 1 second between retries.  AMT controllers are generally
  232.             # very light and may not be comfortable with more frequent
  233.             # queries.
  234.             sleep(1)
  235.  
  236.         if not output:
  237.             raise PowerActionError("amttool power querying failed.")
  238.  
  239.         # Ensure that from this point forward that output is a str.
  240.         output = output.decode("utf-8")
  241.  
  242.         # Wide awake (S0), or asleep (S1-S4), but not a clean slate that
  243.         # will lead to a fresh boot.
  244.         if "S5" in output:
  245.             return "off"
  246.         for state in ("S0", "S1", "S2", "S3", "S4"):
  247.             if state in output:
  248.                 return "on"
  249.         raise PowerActionError("Got unknown power state from node: %s" % state)
  250.  
  251.     def wsman_query_state(self, schema, ip_address, power_pass):
  252.         """Ask for node's power state: 'on' or 'off', via wsman."""
  253.         # Retry the state if it fails because it often fails the first time.
  254.         for _ in range(10):
  255.             output = self._issue_wsman_command("query", schema, ip_address, power_pass)
  256.             if output:
  257.                 break
  258.             # Wait 1 second between retries.  AMT controllers are generally
  259.             # very light and may not be comfortable with more frequent
  260.             # queries.
  261.             sleep(1)
  262.  
  263.         if not output:
  264.             raise PowerActionError("wsman power querying failed.")
  265.         else:
  266.             state = self.get_power_state(output)
  267.             # There are a LOT of possible power states
  268.             # 1: Other                    9: Power Cycle (Off-Hard)
  269.             # 2: On                       10: Master Bus Reset
  270.             # 3: Sleep - Light            11: Diagnostic Interrupt (NMI)
  271.             # 4: Sleep - Deep             12: Off - Soft Graceful
  272.             # 5: Power Cycle (Off - Soft) 13: Off - Hard Graceful
  273.             # 6: Off - Hard               14: Master Bus Reset Graceful
  274.             # 7: Hibernate (Off - Soft)   15: Power Cycle (Off-Soft Graceful)
  275.             # 8: Off - Soft               16: Power Cycle (Off-Hard Graceful)
  276.             #                             17: Diagnostic Interrupt (INIT)
  277.  
  278.             # These are all power states that indicate that the system is
  279.             # either ON or will resume function in an ON or Powered Up
  280.             # state (e.g. being power cycled currently)
  281.             if state in ("2", "3", "4", "5", "7", "9", "10", "14", "15", "16"):
  282.                 return "on"
  283.             elif state in ("6", "8", "12", "13"):
  284.                 return "off"
  285.             else:
  286.                 raise PowerActionError("Got unknown power state from node: %s" % state)
  287.  
  288.     def amttool_restart(self, ip_address, power_pass, amttool_boot_mode):
  289.         """Restart the node via amttool."""
  290.         self._issue_amttool_command(
  291.             "power_cycle",
  292.             ip_address,
  293.             power_pass,
  294.             amttool_boot_mode=amttool_boot_mode,
  295.             stdin=b"yes",
  296.         )
  297.  
  298.     def amttool_power_on(self, ip_address, power_pass, amttool_boot_mode):
  299.         """Power on the node via amttool."""
  300.         # Try several times.  Power commands often fail the first time.
  301.         for _ in range(10):
  302.             # Issue the AMT command; amttool will prompt for confirmation.
  303.             self._issue_amttool_command(
  304.                 "powerup",
  305.                 ip_address,
  306.                 power_pass,
  307.                 amttool_boot_mode=amttool_boot_mode,
  308.                 stdin=b"yes",
  309.             )
  310.             if self.amttool_query_state(ip_address, power_pass) == "on":
  311.                 return
  312.             sleep(1)
  313.         raise PowerActionError("Machine is not powering on.  Giving up.")
  314.  
  315.     def wsman_power_on(self, schema, ip_address, power_pass, restart=False):
  316.         """Power on the node via wsman."""
  317.         power_command = "restart" if restart else "on"
  318.         self._set_pxe_boot(schema, ip_address, power_pass)
  319.         self._issue_wsman_command(power_command, schema, ip_address, power_pass)
  320.         # Check power state several times.  It usually takes a second or
  321.         # two to get the correct state.
  322.         for _ in range(10):
  323.             if self.wsman_query_state(schema, ip_address, power_pass) == "on":
  324.                 return  # Success.  Machine is on.
  325.             sleep(1)
  326.         raise PowerActionError("Machine is not powering on.  Giving up.")
  327.  
  328.     def amttool_power_off(self, ip_address, power_pass):
  329.         """Power off the node via amttool."""
  330.         # Try several times.  Power commands often fail the first time.
  331.         for _ in range(10):
  332.             if self.amttool_query_state(ip_address, power_pass) == "off":
  333.                 # Success.  Machine is off.
  334.                 return
  335.                 # Issue the AMT command; amttool will prompt for confirmation.
  336.             self._issue_amttool_command(
  337.                 "powerdown", ip_address, power_pass, stdin=b"yes"
  338.             )
  339.             sleep(1)
  340.         raise PowerActionError("Machine is not powering off.  Giving up.")
  341.  
  342.     def wsman_power_off(self, schema, ip_address, power_pass):
  343.         """Power off the node via wsman."""
  344.         # Issue the wsman command to change power state.
  345.         self._issue_wsman_command("off", schema, ip_address, power_pass)
  346.         # Check power state several times.  It usually takes a second or
  347.         # two to get the correct state.
  348.         for _ in range(10):
  349.             if self.wsman_query_state(schema, ip_address, power_pass) == "off":
  350.                 return  # Success.  Machine is off.
  351.             else:
  352.                 sleep(1)
  353.         raise PowerActionError("Machine is not powering off.  Giving up.")
  354.  
  355.     def _get_amt_command(self, schema, host, power_pass):
  356.         """Retrieve AMT command to use, either amttool or wsman
  357.        (if AMT version > 8), for the given system.
  358.        """
  359.         # XXX bug=1331214
  360.         # Check if the AMT ver > 8
  361.         # If so, we need wsman, not amttool
  362.         command = self._get_wsman_command(
  363.             schema,
  364.             host,
  365.             power_pass,
  366.             "identify",
  367.         )
  368.         result = shell.run_command(*command)
  369.         if not result.stdout:
  370.             for error, error_info in AMT_ERRORS.items():
  371.                 if error in result.stderr:
  372.                     raise error_info.get("exception")(error_info.get("message"))
  373.             raise PowerConnError(
  374.                 f"Unable to retrieve AMT version: {result.stderr}\n{command}"
  375.             )
  376.         else:
  377.             match = re.search(r"ProductVersion>AMT\s*([0-9]+)", result.stdout)
  378.             if match is None:
  379.                 raise PowerActionError(
  380.                     "Unable to extract AMT version from "
  381.                     f"amttool output: {result.stdout}"
  382.                 )
  383.             else:
  384.                 version = match.group(1)
  385.                 if int(version) > 8:
  386.                     return "wsman"
  387.                 else:
  388.                     return "amttool"
  389.  
  390.     def _get_amttool_boot_mode(self, boot_mode):
  391.         """Set amttool boot mode."""
  392.         # boot_mode tells us whether we're pxe booting or local booting.
  393.         # For local booting, the argument to amttool must be empty
  394.         # (NOT 'hd', it doesn't work!).
  395.         if boot_mode == "local":
  396.             return ""
  397.         else:
  398.             return boot_mode
  399.  
  400.     def _get_wsman_command(self, schema, host, password, *args):
  401.         base_path = snap.SnapPaths.from_environ().snap or "/"
  402.         command = (
  403.             "wsman",
  404.             "-C",
  405.             join(base_path, "etc/openwsman/openwsman_client.conf"),
  406.         )
  407.         if schema == AMT_HTTP:
  408.             command += (
  409.                 "--endpoint",
  410.                 f"http://admin:{password}@{host}:16992",
  411.             )
  412.         else:
  413.             command += (
  414.                 "--endpoint",
  415.                 f"https://admin:{password}@{host}:16993",
  416.                 "--noverifypeer",
  417.                 "--noverifyhost",
  418.             )
  419.         return command + args
  420.  
  421.     def _get_ip_address(self, power_address, ip_address):
  422.         """Get the IP address of the AMT BMC."""
  423.         # The user specified power_address overrides any automatically
  424.         # determined ip_address.
  425.         if is_power_parameter_set(power_address) and not is_power_parameter_set(
  426.             ip_address
  427.         ):
  428.             return power_address
  429.         elif is_power_parameter_set(ip_address):
  430.             return ip_address
  431.         else:
  432.             raise PowerSettingError(
  433.                 "No IP address provided.  "
  434.                 "Please update BMC configuration and try again."
  435.             )
  436.  
  437.     def power_on(self, system_id, context):
  438.         """Power on AMT node."""
  439.         schema = context.get("schema")
  440.         ip_address = self._get_ip_address(
  441.             context.get("power_address"), context.get("ip_address")
  442.         )
  443.         power_pass = context.get("power_pass")
  444.         amt_command = self._get_amt_command(schema, ip_address, power_pass)
  445.         if amt_command == "amttool":
  446.             amttool_boot_mode = self._get_amttool_boot_mode(context.get("boot_mode"))
  447.             if self.amttool_query_state(ip_address, power_pass) == "on":
  448.                 self.amttool_restart(ip_address, power_pass, amttool_boot_mode)
  449.             else:
  450.                 self.amttool_power_on(ip_address, power_pass, amttool_boot_mode)
  451.         elif amt_command == "wsman":
  452.             if self.wsman_query_state(schema, ip_address, power_pass) == "on":
  453.                 self.wsman_power_on(schema, ip_address, power_pass, restart=True)
  454.             else:
  455.                 self.wsman_power_on(schema, ip_address, power_pass)
  456.  
  457.     def power_off(self, system_id, context):
  458.         """Power off AMT node."""
  459.         schema = context.get("schema")
  460.         ip_address = self._get_ip_address(
  461.             context.get("power_address"), context.get("ip_address")
  462.         )
  463.         power_pass = context.get("power_pass")
  464.         amt_command = self._get_amt_command(schema, ip_address, power_pass)
  465.         if amt_command == "amttool":
  466.             if self.amttool_query_state(ip_address, power_pass) != "off":
  467.                 self.amttool_power_off(ip_address, power_pass)
  468.         elif amt_command == "wsman":
  469.             if self.wsman_query_state(schema, ip_address, power_pass) != "off":
  470.                 self.wsman_power_off(schema, ip_address, power_pass)
  471.  
  472.     def power_query(self, system_id, context):
  473.         """Power query AMT node."""
  474.         schema = context.get("schema")
  475.         ip_address = self._get_ip_address(
  476.             context.get("power_address"), context.get("ip_address")
  477.         )
  478.         power_pass = context.get("power_pass")
  479.         amt_command = self._get_amt_command(schema, ip_address, power_pass)
  480.         if amt_command == "amttool":
  481.             return self.amttool_query_state(ip_address, power_pass)
  482.         elif amt_command == "wsman":
  483.             return self.wsman_query_state(schema, ip_address, power_pass)
  484.  
Add Comment
Please, Sign In to add comment