From 849861d2bdf76f70c7ee0b97387a27082f8a3fdd Mon Sep 17 00:00:00 2001 From: Enno G Date: Mon, 26 Jun 2023 11:05:59 +0200 Subject: [PATCH 16/46] fence_eaton_ssh: new fence agent for Eaton ePDU G3 over SSH (#549) * fence_eaton_ssh: Initial add * Docker: Add dockerized build environment * Fix incorrect repository path in configure.ac --- README.md | 19 +- agents/eaton_ssh/fence_eaton_ssh.py | 318 ++++++++++++++++++++++++ configure.ac | 2 +- docker/Dockerfile | 34 +++ docker/README.md | 10 + docker/entrypoint.sh | 8 + fence-agents.spec.in | 13 + tests/data/metadata/fence_eaton_ssh.xml | 206 +++++++++++++++ 8 files changed, 603 insertions(+), 7 deletions(-) create mode 100644 agents/eaton_ssh/fence_eaton_ssh.py create mode 100644 docker/Dockerfile create mode 100644 docker/README.md create mode 100755 docker/entrypoint.sh create mode 100644 tests/data/metadata/fence_eaton_ssh.xml diff --git a/README.md b/README.md index d9fcb94b..0f3ebbde 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,25 @@ # Fence agents -Fence agents were developed as device "drivers" which are able to prevent computers from destroying data on shared storage. Their aim is to isolate a corrupted computer, using one of three methods: +Fence agents were developed as device "drivers" which are able to prevent computers from destroying data on shared +storage. Their aim is to isolate a corrupted computer, using one of three methods: - * Power - A computer that is switched off cannot corrupt data, but it is important to not do a "soft-reboot" as we won't know if this is possible. This also works for virtual machines when the fence device is a hypervisor. - * Network - Switches can prevent routing to a given computer, so even if a computer is powered on it won't be able to harm the data. + * Power - A computer that is switched off cannot corrupt data, but it is important to not do a "soft-reboot" as we + won't know if this is possible. This also works for virtual machines when the fence device is a hypervisor. + * Network - Switches can prevent routing to a given computer, so even if a computer is powered on it won't be able to + harm the data. * Configuration - Fibre-channel switches or SCSI devices allow us to limit who can write to managed disks. -Fence agents do not use configuration files, as configuration management is outside of their scope. All of the configuration has to be specified either as command-line arguments or lines of standard input (see the complete list for more info). +Fence agents do not use configuration files, as configuration management is outside of their scope. All of the +configuration has to be specified either as command-line arguments or lines of standard input (see the complete list +for more info). -Because many fence agents are quite similar to each other, a fencing library (in Python) was developed. Please use it for further development. Creating or modifying a new fence agent should be quite simple using this library. +Because many fence agents are quite similar to each other, a fencing library (in Python) was developed. Please use it +for further development. Creating or modifying a new fence agent should be quite simple using this library. ## Where can I find more information? * [ClusterLabs website](http://www.clusterlabs.org/) * [User and developer documentation](https://github.com/ClusterLabs/fence-agents/tree/master/doc/FenceAgentAPI.md) -* Mailing lists for [users](http://oss.clusterlabs.org/mailman/listinfo/users) and [developers](http://oss.clusterlabs.org/mailman/listinfo/developers) +* Mailing lists for [users](http://oss.clusterlabs.org/mailman/listinfo/users) and + [developers](http://oss.clusterlabs.org/mailman/listinfo/developers) * #clusterlabs IRC channel on [freenode](http://freenode.net/) diff --git a/agents/eaton_ssh/fence_eaton_ssh.py b/agents/eaton_ssh/fence_eaton_ssh.py new file mode 100644 index 00000000..8e536a2e --- /dev/null +++ b/agents/eaton_ssh/fence_eaton_ssh.py @@ -0,0 +1,318 @@ +#!@PYTHON@ -tt + +""" +Plug numbering starts with 1! There were no tests performed so far with daisy chained PDUs. + +Example usage: + fence_eaton_ssh -v -a -l -p --login-timeout=60 --action status --plug 1 +""" + +##### +## +## The Following Agent Has Been Tested On: +## +## Model Firmware +## +---------------------------------------------+ +## EMAB04 04.02.0001 +##### + +import enum +import sys +import atexit + +sys.path.append("@FENCEAGENTSLIBDIR@") +from fencing import * +from fencing import fail, EC_STATUS, EC_LOGIN_DENIED + + +class FenceEatonPowerActions(enum.Enum): + """ + Status of the plug on the PDU. + """ + ERROR = -1 + OFF = 0 + ON = 1 + PENDING_OFF = 2 + PENDING_ON = 3 + + +def get_plug_names(conn, plug_ids, command_prompt, shell_timout): + """ + Get the names of plugs via their ID. + + :param conn: The "fspawn" object. + :param plug_ids: The list of plug IDs. Plugs start with the ID 1. + :param command_prompt: The characters that make up the base prompt. This is important to detect a finished command. + :param shell_timeout: The maximum time the shell should wait for a response. + :returns: The name of the requested plugs. + """ + # fspawn is subclassed from pexpect which is not correctly type annotated in all cases. + result = {} + full_node_mapping = {} + conn.send_eol("get PDU.OutletSystem.Outlet[x].iName") + conn.log_expect(command_prompt, shell_timout) + result_plug_names = conn.before.split("\n") # type: ignore + if len(result_plug_names) != 3: + fail(EC_STATUS) + plug_names = result_plug_names.split("|") + for counter in range(1, len(plug_names)): + full_node_mapping[counter] = plug_names[counter] + for plug_id in plug_ids: + result[plug_id] = full_node_mapping[plug_id] + return result + + +def get_plug_ids(conn, nodenames, command_prompt, shell_timout): + """ + Get the IDs that map to the given nodenames. Non existing names are skipped. + + :param conn: The "fspawn" object. + :param nodenames: The list of human readable names that should be converted to IDs. + :param command_prompt: The characters that make up the base prompt. This is important to detect a finished command. + :param shell_timeout: The maximum time the shell should wait for a response. + :returns: A dictionary - possibly empty - where the keys are the node names and the values are the node IDs. + """ + result = {} + full_node_mapping = {} + conn.send_eol("get PDU.OutletSystem.Outlet[x].iName") + conn.log_expect(command_prompt, shell_timout) + result_plug_names = conn.before.split("\n") # type: ignore + if len(result_plug_names) != 3: + fail(EC_STATUS) + plug_names = result_plug_names.split("|") + for counter in range(1, len(plug_names)): + full_node_mapping[plug_names[counter]] = counter + for node in nodenames: + if node in full_node_mapping: + result[node] = full_node_mapping[node] + return result + + +def get_plug_count(conn, command_prompt, shell_timout): + """ + Get the number of plugs that the PDU has. + + In case the PDU is daisy chained this also contains the plugs of the other PDUs. + + :param conn: The "fspawn" object. + :param command_prompt: The characters that make up the base prompt. This is important to detect a finished command. + :param shell_timeout: The maximum time the shell should wait for a response. + :returns: The number of plugs that the PDU has. + """ + # fspawn is subclassed from pexpect which is not correctly type annotated in all cases. + conn.send_eol("get PDU.OutletSystem.Outlet.Count") + conn.log_expect(command_prompt, shell_timout) + result_plug_count = conn.before.split("\n") # type: ignore + if len(result_plug_count) != 3: + fail(EC_STATUS) + return int(result_plug_count[1].strip()) + + +def get_plug_status(conn, plug_id, command_prompt, shell_timout): + """ + Get the current status of the plug. The return value of this doesn't account for operations that will act via + schedules or a delay. As such the status is only valid at the time of retrieval. + + :param conn: The "fspawn" object. + :param plug_id: The ID of the plug that should be powered off. Counting plugs starts at 1. + :returns: The current status of the plug. + """ + # fspawn is subclassed from pexpect which is not correctly type annotated in all cases. + conn.send_eol(f"get PDU.OutletSystem.Outlet[{plug_id}].PresentStatus.SwitchOnOff") + conn.log_expect(command_prompt, shell_timout) + result_plug_status = conn.before.split("\n") # type: ignore + if len(result_plug_status) != 3: + fail(EC_STATUS) + if result_plug_status[1].strip() == "0": + return FenceEatonPowerActions.OFF + elif result_plug_status[1].strip() == "1": + return FenceEatonPowerActions.ON + else: + return FenceEatonPowerActions.ERROR + + +def power_on_plug(conn, plug_id, command_prompt, shell_timout, delay=0): + """ + Powers on a plug with an optional delay. + + :param conn: The "fspawn" object. + :param plug_id: The ID of the plug that should be powered off. Counting plugs starts at 1. + :param command_prompt: The characters that make up the base prompt. This is important to detect a finished command. + :param shell_timeout: The maximum time the shell should wait for a response. + :param delay: The delay in seconds. Passing "-1" aborts the power off action. + """ + conn.send_eol(f"set PDU.OutletSystem.Outlet[{plug_id}].DelayBeforeStartup {delay}") + conn.log_expect(command_prompt, shell_timout) + + +def power_off_plug(conn, plug_id, command_prompt, shell_timout, delay=0): + """ + Powers off a plug with an optional delay. + + :param conn: The "fspawn" object. + :param plug_id: The ID of the plug that should be powered off. Counting plugs starts at 1. + :param command_prompt: The characters that make up the base prompt. This is important to detect a finished command. + :param shell_timeout: The maximum time the shell should wait for a response. + :param delay: The delay in seconds. Passing "-1" aborts the power off action. + """ + conn.send_eol(f"set PDU.OutletSystem.Outlet[{plug_id}].DelayBeforeShutdown {delay}") + conn.log_expect(command_prompt, shell_timout) + + +def get_power_status(conn, options): + """ + Retrieve the power status for the requested plug. Since we have a serial like interface via SSH we need to parse the + output of the SSH session manually. + + If abnormal behavior is detected the method will exit via "fail()". + + :param conn: The "fspawn" object. + :param options: The option dictionary. + :returns: In case there is an error this method does not return but instead calls "sys.exit". Otherwhise one of + "off", "on" or "error" is returned. + """ + if conn is None: + fail(EC_LOGIN_DENIED) + + requested_plug = options.get("--plug", "") + if not requested_plug: + fail(EC_STATUS) + plug_status = get_plug_status( + conn, # type: ignore + int(requested_plug), + options["--command-prompt"], + int(options["--shell-timeout"]) + ) + if plug_status == FenceEatonPowerActions.OFF: + return "off" + elif plug_status == FenceEatonPowerActions.ON: + return "on" + else: + return "error" + + +def set_power_status(conn, options): + """ + Set the power status for the requested plug. Only resposible for powering on and off. + + If abnormal behavior is detected the method will exit via "fail()". + + :param conn: The "fspawn" object. + :param options: The option dictionary. + :returns: In case there is an error this method does not return but instead calls "sys.exit". + """ + if conn is None: + fail(EC_LOGIN_DENIED) + + requested_plug = options.get("--plug", "") + if not requested_plug: + fail(EC_STATUS) + requested_action = options.get("--action", "") + if not requested_action: + fail(EC_STATUS) + + if requested_action == "off": + power_off_plug( + conn, # type: ignore + int(requested_plug), + options["--command-prompt"], + int(options["--shell-timeout"]) + ) + elif requested_action == "on": + power_on_plug( + conn, # type: ignore + int(requested_plug), + options["--command-prompt"], + int(options["--shell-timeout"]) + ) + else: + fail(EC_STATUS) + + +def get_outlet_list(conn, options): + """ + Retrieves the list of plugs with their correspondin status. + + :param conn: The "fspawn" object. + :param options: The option dictionary. + :returns: Keys are the Plug IDs which each have a Tuple with the alias for the plug and its status. + """ + if conn is None: + fail(EC_LOGIN_DENIED) + + result = {} + plug_count = get_plug_count(conn, options["--command-prompt"], int(options["--shell-timeout"])) # type: ignore + for counter in range(1, plug_count): + plug_names = get_plug_names( + conn, # type: ignore + [counter], + options["--command-prompt"], + int(options["--shell-timeout"]) + ) + plug_status_enum = get_plug_status( + conn, # type: ignore + counter, + options["--command-prompt"], + int(options["--shell-timeout"]) + ) + if plug_status_enum == FenceEatonPowerActions.OFF: + plug_status = "OFF" + elif plug_status_enum == FenceEatonPowerActions.ON: + plug_status = "ON" + else: + plug_status = None + result[str(counter)] = (plug_names[counter], plug_status) + return result + + +def reboot_cycle(conn, options) -> None: + """ + Responsible for power cycling a machine. Not responsible for singular on and off actions. + + :param conn: The "fspawn" object. + :param options: The option dictionary. + """ + requested_plug = options.get("--plug", "") + if not requested_plug: + fail(EC_STATUS) + + power_off_plug( + conn, # type: ignore + int(requested_plug), + options["--command-prompt"], + int(options["--shell-timeout"]) + ) + power_on_plug( + conn, # type: ignore + int(requested_plug), + options["--command-prompt"], + int(options["--shell-timeout"]) + ) + + +def main(): + """ + Main entrypoint for the fence_agent. + """ + device_opt = ["secure", "ipaddr", "login", "passwd", "port", "cmd_prompt"] + atexit.register(atexit_handler) + options = check_input(device_opt, process_input(device_opt)) + options["--ssh"] = None + options["--ipport"] = 22 + options["--command-prompt"] = "pdu#0>" + + docs = {} + docs["shortdesc"] = "Fence agent for Eaton ePDU G3 over SSH" + docs["longdesc"] = "fence_eaton_ssh is a fence agent that connects to Eaton ePDU devices. It logs into \ +device via ssh and reboot a specified outlet." + docs["vendorurl"] = "https://www.eaton.com/" + show_docs(options, docs) + + conn = fence_login(options) + result = fence_action(conn, options, set_power_status, get_power_status, get_outlet_list, reboot_cycle) + fence_logout(conn, "quit") + sys.exit(result) + + +if __name__ == "__main__": + main() diff --git a/configure.ac b/configure.ac index 65a9718d..8436ba25 100644 --- a/configure.ac +++ b/configure.ac @@ -567,7 +567,7 @@ if test "x$VERSION" = "xUNKNOWN"; then configure was unable to determine the source tree's current version. This generally happens when using git archive (or the github download button) generated tarball/zip file. In order to workaround this issue, either use git - clone https://github.com/ClusterLabs/fence-virt.git or use an official release + clone https://github.com/ClusterLabs/fence-agents.git or use an official release tarball. Alternatively you can add a compatible version in a .tarball-version file at the top of the source tree, wipe your autom4te.cache dir and generated configure, and rerun autogen.sh. diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 00000000..6ac9480c --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,34 @@ +FROM opensuse/leap:15.5 + +RUN zypper in -y \ + git \ + autoconf \ + automake \ + libtool \ + make \ + gcc \ + libcorosync-devel \ + libxslt1 \ + libxslt-tools \ + python3-devel \ + python3-httplib2 \ + python3-pexpect \ + python3-pycurl \ + python3-requests \ + python3-suds-jurko \ + python3-openwsman \ + python3-boto3 \ + python3-novaclient \ + python3-keystoneclient \ + mozilla-nss-devel \ + mozilla-nspr-devel \ + libvirt-devel \ + libxml2-devel \ + flex \ + bison \ + libuuid-devel \ + systemd + +WORKDIR /code +VOLUME /code +ENTRYPOINT ["./docker/entrypoint.sh"] diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 00000000..2aef7421 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,10 @@ +# Dockerfile to build the fence-agents locally + +Usage is as follows: + +``` +podman build -f docker/Dockerfile -t fence-agents:main . +podman run -it -v $PWD:/code --rm localhost/fence-agents:main +``` + +In case you are running docker replace `podman` with `docker` and it should work the same. diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 00000000..caa778e2 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,8 @@ +#!/usr/bin/bash + +echo "### Running autogen" +./autogen.sh +echo "### Running configure" +./configure +echo "### Running make" +make diff --git a/fence-agents.spec.in b/fence-agents.spec.in index b6af20d9..343f1c1a 100644 --- a/fence-agents.spec.in +++ b/fence-agents.spec.in @@ -55,6 +55,7 @@ fence-agents-docker \\ fence-agents-drac \\ fence-agents-drac5 \\ fence-agents-eaton-snmp \\ +fence-agents-eaton-ssh \\ fence-agents-ecloud \\ fence-agents-emerson \\ fence-agents-eps \\ @@ -631,6 +632,18 @@ via the SNMP protocol. %{_sbindir}/fence_eaton_snmp %{_mandir}/man8/fence_eaton_snmp.8* +%package eaton-ssh +License: GPL-2.0-or-later AND LGPL-2.0-or-later +Summary: Fence agent for Eaton network power switches +Requires: fence-agents-common = %{version}-%{release} +BuildArch: noarch +%description eaton-ssh +Fence agent for Eaton network power switches that are accessed +via the serial protocol tunnel over SSH. +%files eaton-ssh +%{_sbindir}/fence_eaton_ssh +%{_mandir}/man8/fence_eaton_ssh.8* + %package ecloud License: GPL-2.0-or-later AND LGPL-2.0-or-later Summary: Fence agent for eCloud and eCloud VPC diff --git a/tests/data/metadata/fence_eaton_ssh.xml b/tests/data/metadata/fence_eaton_ssh.xml new file mode 100644 index 00000000..a3be1ac6 --- /dev/null +++ b/tests/data/metadata/fence_eaton_ssh.xml @@ -0,0 +1,206 @@ + + +fence_eaton_ssh is a fence agent that connects to Eaton ePDU devices. It logs into device via ssh and reboot a specified outlet. +https://www.eaton.com/ + + + + + Fencing action + + + + + Force Python regex for command prompt + + + + + Force Python regex for command prompt + + + + Identity file (private key) for SSH + + + + + Forces agent to use IPv4 addresses only + + + + + Forces agent to use IPv6 addresses only + + + + + IP address or hostname of fencing device + + + + + IP address or hostname of fencing device + + + + + TCP/UDP port to use for connection with device + + + + + Login name + + + + + Login password or passphrase + + + + + Script to run to retrieve password + + + + + Login password or passphrase + + + + + Script to run to retrieve password + + + + + Physical plug number on device, UUID or identification of machine + + + + + Physical plug number on device, UUID or identification of machine + + + + + Use SSH connection + + + + + Use SSH connection + + + + + SSH options to use + + + + + Login name + + + + + Disable logging to stderr. Does not affect --verbose or --debug-file or logging to syslog. + + + + + Verbose mode. Multiple -v flags can be stacked on the command line (e.g., -vvv) to increase verbosity. + + + + + Level of debugging detail in output. Defaults to the number of --verbose flags specified on the command line, or to 1 if verbose=1 in a stonith device configuration (i.e., on stdin). + + + + + Write debug information to given file + + + + Write debug information to given file + + + + + Display version information and exit + + + + + Display help and exit + + + + + Separator for plug parameter when specifying more than 1 plug + + + + + Separator for CSV created by 'list' operation + + + + + Wait X seconds before fencing is started + + + + + Disable timeout (true/false) (default: true when run from Pacemaker 2.0+) + + + + + Wait X seconds for cmd prompt after login + + + + + Test X seconds for status change after ON/OFF + + + + + Wait X seconds after issuing ON/OFF + + + + + Wait X seconds for cmd prompt after issuing command + + + + + Sleep X seconds between status calls during a STONITH action + + + + + Count of attempts to retry power on + + + + Path to ssh binary + + + + + + + + + + + + + + + -- 2.25.1