示例#1
0
def test_file():
    fs = settings.FileSetting(must_exist=False)

    #
    # Assert that a non-string value raises a validation error
    #
    with pytest.raises(settings.SettingValidationError):
        fs.validate(1)

    #
    # Assert that a string value does not raise a validation error
    #
    fs.validate('abc')

    #
    # Assert that if a file doesn't exist, a validation error is raised
    #
    fs.must_exist = True
    with pytest.raises(settings.SettingValidationError):
        fs.validate('abc')

    #
    # Assert that if a file does exist, nothing happens
    #
    fs.must_exist = True
    fs.validate(__file__)

    #
    # Assert value is in values list
    #
    fs.must_exist = False
    fs.values = ['abc', 'def']
    with pytest.raises(settings.SettingValidationError):
        fs.validate('ghi')
    fs.validate('def')
def test_dump():
    s = {
        'bool': settings.BooleanSetting(),
        'list_bool': settings.BooleanSetting(list=True),
        'integer': settings.IntegerSetting(),
        'list_integer': settings.IntegerSetting(list=True),
        'string': settings.StringSetting(),
        'list_string': settings.StringSetting(list=True),
        'file': settings.FileSetting(must_exist=False)
    }
    p = ConfigurationValidator(s)

    #
    # Test data loading
    #
    data_to_load = {
        'bool': 'True',
        'list_bool': 'True, false',
        'integer': '3',
        'list_integer': '4, 5',
        'string': 'abc',
        'list_string': 'wz, yz',
        'file': 'file.txt'
    }
    p.load(data_to_load)

    #
    # Perform full validation
    #
    p.validate(full=True)

    #
    # Assert that dumped data is properly transformed
    #
    expected_data = {
        'bool': True,
        'list_bool': [True, False],
        'integer': 3,
        'list_integer': [4, 5],
        'string': 'abc',
        'list_string': ['wz', 'yz'],
        'file': 'file.txt'
    }
    assert p.dump() == expected_data
示例#3
0
                        **GROUP_INSTANCES),
 'ami':
 settings.StringSetting(
     display_name='AMI',
     description='AMI ID to use for launching node instances',
     required=True,
     **GROUP_INSTANCES),
 'block_device_map':
 settings.StringSetting(
     display_name='Block device map',
     description='Used to define block devices (virtual hard drives)',
     **GROUP_INSTANCES),
 'cloud_init_script_template':
 settings.FileSetting(display_name='cloud-init script template',
                      description='Path to cloud-init script template',
                      mutually_exclusive=['user_data_script_template'],
                      base_path='/opt/tortuga/config/',
                      overrides=['user_data_script_template'],
                      **GROUP_INSTANCES),
 'user_data_script_template':
 settings.FileSetting(display_name='User data script template',
                      description='Path to user data template script',
                      mutually_exclusive=['cloud_init_script_template'],
                      base_path='/opt/tortuga/config/',
                      overrides=['cloud_init_script_template'],
                      **GROUP_INSTANCES),
 'vcpus':
 settings.IntegerSetting(
     display_name='Number of VCPUs',
     description='Override automatically detected virtual CPU count',
     **GROUP_INSTANCES),
 'monitoring_enabled':
class Oracleadapter(ResourceAdapter):
    """
    Drive Oracle Cloud Infrastructure.
    """
    __adaptername__ = 'oraclecloud'

    settings = {
        'availability_domain': settings.StringSetting(required=True),
        'compartment_id': settings.StringSetting(required=True),
        'shape': settings.StringSetting(default='VM.Standard1.1'),
        'vcpus': settings.IntegerSetting(),
        'subnet_id': settings.StringSetting(required=True),
        'image_id': settings.StringSetting(required=True),
        'user_data_script_template': settings.FileSetting(
            base_path='/tortuga/config/',
            default='oci_bootstrap.tmpl'
        ),
        'override_dns_domain': settings.BooleanSetting(default='False'),
        'dns_options': settings.StringSetting(),
        'dns_search': settings.StringSetting(),
        'dns_nameservers': settings.StringSetting(
            list=True,
            list_separator=' '
        )
    }

    def __init__(self, addHostSession=None):
        """
        Upon instantiation, read and validate config file.

        :return: Oci instance
        """
        super(Oracleadapter, self).__init__(addHostSession=addHostSession)

        config = {
            'region': None,
            'log_requests': False,
            'tenancy': None,
            'user': None,
            'pass_phrase': None,
            'fingerprint': None,
            'additional_user_agent': '',
            'key_file': os.path.join(os.path.expanduser('~'), '.ssh/id_rsa')
        }

        override_config = self.getResourceAdapterConfig()
        if override_config and isinstance(override_config, dict):
            config.update(override_config)

        self._timeouts = {
            'launch': override_config['launch_timeout'] if
                'launch_timeout' in override_config else 300
        }

        oci.config.validate_config(config)
        self.__vcpus = None
        self.__installer_ip = None
        self.__client = oci.core.compute_client.ComputeClient(config)
        self.__net_client = \
            oci.core.virtual_network_client.VirtualNetworkClient(config)
        self.__identity_client = \
            oci.identity.identity_client.IdentityClient(config)

    def __validate_keys(self, config):
        """
        Check all the required keys exist.

        :param config: Dictionary
        :return: None
        """
        provided_keys = set(config.keys())
        required_keys = {
            'availability_domain',
            'compartment_id',
            'shape',
            'subnet_id',
            'image_id'
        }

        missing_keys = required_keys.difference(provided_keys)

        if missing_keys:
            error_message = \
                'Required configuration setting(s) [%s] are missing' % (
                    ' '.join(missing_keys)
                )

            self.getLogger().error(error_message)

    @staticmethod
    def __cloud_instance_metadata() -> dict:
        """
        Get the cloud metadata.

        :returns: Dictionary metadata
        """
        response = urlopen('http://169.254.169.254/opc/v1/instance/')
        return json.load(response)

    @staticmethod
    def __cloud_vnic_metadata() -> dict:
        """
        Get the VNIC cloud metadata.

        :returns: Dictionary metadata
        """
        response = urlopen('http://169.254.169.254/opc/v1/vnics/')
        return json.load(response)

    @property
    def __cloud_launch_metadata(self) -> dict:
        """
        Get metadata needed to create metadata.

        :returns: Dictionary metadata
        """
        compute = self.__cloud_instance_metadata()
        vnic = self.__cloud_vnic_metadata()

        full_vnic = self.__net_client.get_vnic(
            vnic['vnicId']
        ).data

        return {
            'availability_domain': compute['availabilityDomain'],
            'compartment_id': compute['compartmentId'],
            'subnet_id': full_vnic.subnet_id,
            'image_id': compute['image'],
            'region': compute['region'],
            'tenancy_id': '',
            'user_id': '',
            'shape': compute['shape']
        }

    def start(self, addNodesRequest, dbSession, dbHardwareProfile,
              dbSoftwareProfile=None):
        """
        Create a cloud and bind with Tortuga.

        :return: List Instance objects
        """
        self.getLogger().debug(
            'start(): addNodesRequest=[%s], dbSession=[%s],'
            ' dbHardwareProfile=[%s], dbSoftwareProfile=[%s]' % (
                addNodesRequest,
                dbSession,
                dbHardwareProfile,
                dbSoftwareProfile
            )
        )

        with StopWatch() as stop_watch:
            nodes = self.__add_nodes(
                addNodesRequest,
                dbSession,
                dbHardwareProfile,
                dbSoftwareProfile
            )

        if len(nodes) < addNodesRequest['count']:
            self.getLogger().warning(
                '%s node(s) requested, only %s launched'
                ' successfully' % (
                    addNodesRequest['count'],
                    len(nodes)
                )
            )

        self.getLogger().debug(
            'start() session [%s] completed in'
            ' %0.2f seconds' % (
                self.addHostSession,
                stop_watch.result.seconds +
                stop_watch.result.microseconds / 1000000.0
            )
        )

        self.addHostApi.clear_session_nodes(nodes)

        return nodes

    def __add_nodes(self, add_nodes_request, db_session, db_hardware_profile,
                    db_software_profile):
        """
        Add nodes to the infrastructure.

        :return: List Nodes objects
        """

        # TODO: this validation needs to be moved
        # self.__validate_keys(session.config)

        node_spec = {
            'db_hardware_profile': db_hardware_profile,
            'db_software_profile': db_software_profile,
            'db_session': db_session,
            'configDict': self.getResourceAdapterConfig(),
        }

        return [result for result in self.__oci_add_nodes(
            count=int(add_nodes_request['count']),
            node_spec=node_spec)]

    def __oci_add_nodes(self, count=1, node_spec=None):
        """
        Wrapper around __oci_add_node() method. Launches Greenlets to
        perform add nodes operation in parallel using gevent.

        :param count: number of nodes to add
        :param node_spec: dict containing instance launch specification
        :return: list of Nodes
        """
        greenlets = []
        for _ in range(count):
            greenlets.append(gevent.spawn(self.__oci_add_node, node_spec))

        for result in gevent.iwait(greenlets):
            if result.value:
                yield result.value

    def __oci_add_node(self, node_spec):
        """
        Add one node and backing instance to Tortuga.

        :param node_spec: instance launch specification
        :return: Nodes object (or None, on failure)
        """
        with gevent.Timeout(self._timeouts['launch'], TimeoutError):
            node_dict = self.__oci_pre_launch_instance(node_spec=node_spec)

            try:
                instance = self._launch_instance(node_dict=node_dict,
                                                 node_spec=node_spec)
            except Exception as exc:
                if 'node' in node_dict:
                    self.__client.terminate_instance(
                        node_dict['instance_ocid'])
                    self._wait_for_instance_state(
                        node_dict['instance_ocid'], 'TERMINATED')
                    node_spec['db_session'].delete(node_dict['node'])
                    node_spec['db_session'].commit()

                self.getLogger().error(
                    'Error launching instance: [{}]'.format(exc)
                )

                return

            return self._instance_post_launch(
                instance, node_dict=node_dict, node_spec=node_spec)

    def __oci_pre_launch_instance(self, node_spec=None):
        """
        Creates Nodes object if Tortuga-generated host names are enabled,
        otherwise returns empty node dict.

        :param node_spec: dict containing instance launch specification
        :return: node dict
        """
        if node_spec['db_hardware_profile'].nameFormat == '*':
            return {}

        result = {}

        # Generate node name
        hostname, _ = self.addHostApi.generate_node_name(
            node_spec['db_session'],
            node_spec['db_hardware_profile'].nameFormat,
            dns_zone=self.private_dns_zone).split('.', 1)

        _, domain = self.installer_public_hostname.split('.', 1)

        name = '%s.%s' % (hostname, domain)

        # Create Nodes object
        node = self.__initialize_node(
            name,
            node_spec['db_hardware_profile'],
            node_spec['db_software_profile']
        )

        node.state = state.NODE_STATE_LAUNCHING

        result['node'] = node

        # Add to database and commit database session
        node_spec['db_session'].add(node)
        node_spec['db_session'].commit()

        return result

    def __initialize_node(self, name, db_hardware_profile,
                          db_software_profile):
        node = Node(name=name)
        node.softwareprofile = db_software_profile
        node.hardwareprofile = db_hardware_profile
        node.isIdle = False
        node.addHostSession = self.addHostSession

        return node

    def _launch_instance(self, node_dict=None, node_spec=None):
        """
        Launch instance and wait for it to reach RUNNING state.

        :param node_dict: Dictionary
        :param node_spec: Object
        :return: Instance object
        """

        session = OciSession(node_spec['configDict'])
        session.config['metadata']['user_data'] = \
            self.__get_user_data(session.config)

        # TODO: this is a temporary workaround until the OciSession
        # functionality is validated for this workflow
        launch_config = session.launch_config

        # TODO: make this work better.  Need to
        # find a way of injecting this into the
        # `get_node_vcpus` method.
        self.__vcpus = session.config['vcpus'] if \
            session.config['vcpus'] else \
            session.cores_from_shape
        self.getLogger().debug(
            'setting vcpus to %d' % (
                self.__vcpus
            )
        )

        if 'node' in node_dict:
            node = node_dict['node']

            self.getLogger().debug(
                'overriding instance name [%s]' % (
                    node.name)
            )

            launch_config.display_name = node.name
            launch_config.hostname_label = node.name.split('.', 1)[0]

        launch_instance = self.__client.launch_instance(launch_config)

        instance_ocid = launch_instance.data.id

        node_dict['instance_ocid'] = instance_ocid

        log_adapter = CustomAdapter(
            self.getLogger(), {'instance_ocid': instance_ocid})

        log_adapter.debug('launched')

        # TODO: implement a timeout waiting for an instance to start; this
        # will currently wait forever
        # TODO: check for launch error
        def logging_callback(instance, state):
            log_adapter.debug('state: %s; waiting...' % state)

        self._wait_for_instance_state(
            instance_ocid, 'RUNNING', callback=logging_callback)

        log_adapter.debug('state: RUNNING')

        return self.__client.get_instance(instance_ocid).data

    def get_node_vcpus(self, name):
        """
        Return resolved number of VCPUs.

        :param name: String node hostname
        :return: Integer vcpus
        """
        instance_cache = self.instanceCacheGet(name)
        if 'vcpus' in list(instance_cache.keys()):
            return int(instance_cache['vcpus'])
        return self.__vcpus

    def _instance_post_launch(self, instance, node_dict=None, node_spec=None):
        """
        Called after instance has launched successfully.

        :param instance: Oracle instance
        :param node_dict: instance/node mapping dict
        :param node_spec: instance launch specification
        :return: Nodes object
        """
        self.getLogger().debug(
            'Instance post-launch action for instance [%s]' % (
                instance.id)
        )

        if 'node' not in node_dict:
            domain = self.installer_public_hostname.split('.')[1:]
            fqdn = '.'.join([instance.display_name] + domain)

            node = self.__initialize_node(
                fqdn,
                node_spec['db_hardware_profile'],
                node_spec['db_software_profile']
            )

            node_spec['db_session'].add(node)

            node_dict['node'] = node
        else:
            node = node_dict['node']

        node.state = state.NODE_STATE_PROVISIONED

        # Get ip address from instance
        nics = []
        for ip in self.__get_instance_private_ips(
                instance.id, instance.compartment_id):
            nics.append(
                Nic(ip=ip, boot=True)
            )
        node.nics = nics

        node_spec['db_session'].commit()

        self.instanceCacheSet(
            node.name,
            {
                'id': instance.id,
                'compartment_id': instance.id,
                'shape': node_spec['configDict']['shape'],
                'vcpus': str(node_spec['configDict']['shape'].split('.')[-1])
            }
        )

        ip = [nic for nic in node.nics if nic.boot][0].ip

        self._pre_add_host(
            node.name,
            node.hardwareprofile.name,
            node.softwareprofile.name,
            ip)

        self.getLogger().debug(
            '_instance_post_launch(): node=[%s]' % (
                node)
        )

        self.fire_provisioned_event(node)

        return node

    def __get_instance_public_ips(self, instance_id, compartment_id):
        """
        Get public IP from the attached VNICs.

        :param instance_id: String instance id
        :param compartment_id: String compartment id
        :return: Generator String IPs
        """
        for vnic in self.__get_vnics_for_instance(instance_id, compartment_id):
            attached_vnic = self.__net_client.get_vnic(vnic.vnic_id)
            if attached_vnic:
                yield attached_vnic.data.public_ip

    def __get_instance_private_ips(self, instance_id, compartment_id):
        """
        Get private IP from the attached VNICs.

        :param instance_id: String instance id
        :param compartment_id: String compartment id
        :return: Generator String IPs
        """
        for vnic in self.__get_vnics_for_instance(instance_id, compartment_id):
            attached_vnic = self.__net_client.get_vnic(vnic.vnic_id)
            if attached_vnic:
                yield attached_vnic.data.private_ip

    def __get_vnics_for_instance(self, instance_id, compartment_id):
        """
        Get all VNICs attached to instance.

        :param instance_id: String instance id
        :param compartment_id: String compartment id
        :return: Generator VNIC objects
        """
        for vnic in self.__get_vnics(compartment_id):
            if vnic.instance_id == instance_id \
                    and vnic.lifecycle_state == 'ATTACHED':
                yield vnic

    def __get_vnics(self, compartment_id):
        """
        Get VNICs in compartment.

        :param compartment_id: String id
        :return: List VNIC objects
        """
        vnics = self.__client.list_vnic_attachments(compartment_id)

        return vnics.data

    def __get_common_user_data_settings(self, config, node=None):
        """
        Format resource adapters for the bootstrap
        template.

        :param config: Dictionary
        :param node: Node instance
        :return: Dictionary
        """
        installer_ip = self.__get_installer_ip(
            hardwareprofile=node.hardwareprofile if node else None)

        settings_dict = {
            'installerHostName': self.installer_public_hostname,
            'installerIp': '\'{0}\''.format(installer_ip)
                           if installer_ip else 'None',
            'adminport': self._cm.getAdminPort(),
            'cfmuser': self._cm.getCfmUser(),
            'cfmpassword': self._cm.getCfmPassword(),
            'override_dns_domain': str(config['override_dns_domain']),
            'dns_options': '\'{0}\''.format(config['dns_options'])
                           if config['dns_options'] else None,
            'dns_search': '\'{0}\''.format(config['dns_search'])
                          if config['dns_search'] else None,
            'dns_nameservers': self.__get_encoded_list(
                config['dns_nameservers']),
        }

        return settings_dict

    def __get_common_user_data_content(self, settings_dict):
        """
        Create header for bootstrap file.

        :param settings_dict: Dictionary
        :return: String
        """
        result = """\
installerHostName = '%(installerHostName)s'
installerIpAddress = %(installerIp)s
port = %(adminport)d
cfmUser = '******'
cfmPassword = '******'

# DNS resolution settings
override_dns_domain = %(override_dns_domain)s
dns_options = %(dns_options)s
dns_search = %(dns_search)s
dns_nameservers = %(dns_nameservers)s
""" % settings_dict

        return result

    def __get_user_data(self, config, node=None):
        """
        Compile the cloud-init script from
        bootstrap template and encode into
        base64.

        :param config: Dictionary
        :param node: Node instance
        :return: String
        """
        self.getLogger().info(
            'Using cloud-init script template [%s]' % (
                config['user_data_script_template']))

        settings_dict = self.__get_common_user_data_settings(config, node)

        with open(config['user_data_script_template']) as fp:
            result = ''

            for line in fp.readlines():
                if line.startswith('### SETTINGS'):
                    result += self.__get_common_user_data_content(
                        settings_dict)
                else:
                    result += line

        combined_message = MIMEMultipart()

        if node and not config['use_instance_hostname']:
            # Use cloud-init to set fully-qualified domain name of instance
            cloud_init = """#cloud-config

fqdn: %s
""" % node.name

            sub_message = MIMEText(
                cloud_init, 'text/cloud-config', sys.getdefaultencoding())
            filename = 'user-data.txt'
            sub_message.add_header(
                'Content-Disposition',
                'attachment; filename="%s"' % filename)
            combined_message.attach(sub_message)

            sub_message = MIMEText(
                result, 'text/x-shellscript', sys.getdefaultencoding())
            filename = 'bootstrap.py'
            sub_message.add_header(
                'Content-Disposition',
                'attachment; filename="%s"' % filename)
            combined_message.attach(sub_message)

            return b64encode(str(combined_message).encode()).deocde()

        # Fallback to default behaviour
        return b64encode(result.encode()).decode()

    def deleteNode(self, dbNodes):
        """
        Delete a node from the infrastructure.

        :param dbNodes: List Nodes object
        :return: None
        """
        self._async_delete_nodes(dbNodes)

        self.getLogger().info(
            '%d node(s) deleted' % (
                len(dbNodes))
        )

    def _wait_for_instance_state(self, instance_ocid, state, callback=None,
                                 timeout=None):
        """
        Wait for instance to reach state

        :param instance_ocid: Instance OCID
        :param state: Expected state of instance
        :param timeout: (optional) operation timeout
        :return: None
        """
        # TODO: implement timeout
        for nRetries in itertools.count(0):
            instance = self.__client.get_instance(instance_ocid)

            if instance.data.lifecycle_state == state:
                break

            if callback:
                # Only call the callback if the requested state hasn't yet been
                # reached
                callback(instance_ocid, instance.data.lifecycle_state)

            gevent.sleep(get_random_sleep_time(retries=nRetries) / 1000.0)

    def _delete_node(self, node):
        # TODO: add error handling; if the instance termination request
        # fails, we shouldn't be removing the node from the system
        try:
            instance_cache = self.instanceCacheGet(node.name)

            # TODO: what happens when you attempt to terminate an already
            # terminated instance? Exception?
            instance = self.__client.get_instance(instance_cache['id'])

            log_adapter = CustomAdapter(
                self.getLogger(), {'instance_ocid': instance_cache['id']})

            # Issue terminate request
            log_adapter.debug('Terminating...')

            self.__client.terminate_instance(instance.data.id)

            # Wait 3 seconds before checking state
            gevent.sleep(3)

            # Wait until state is 'TERMINATED'
            self._wait_for_instance_state(instance_cache['id'], 'TERMINATED')

            # Clean up the instance cache.
            self.instanceCacheDelete(node.name)
        except ResourceNotFound:
            pass

        # Remove Puppet certificate
        bhm = osUtility.getOsObjectFactory().getOsBootHostManager()
        bhm.deleteNodeCleanup(node)

    def __get_installer_ip(self, hardwareprofile=None):
        """
        Get IP address of the installer node.

        :param hardwareprofile: Object
        :return: String ip address
        """
        if self.__installer_ip is None:
            if hardwareprofile and hardwareprofile.nics:
                self.__installer_ip = hardwareprofile.nics[0].ip
            else:
                self.__installer_ip = self.installer_public_ipaddress

        return self.__installer_ip

    @staticmethod
    def __get_encoded_list(items):
        """
        Return Python list encoded in a string.

        :param items: List
        :return: String
        """
        return '[' + ', '.join(['\'%s\'' % item for item in items]) + ']' \
            if items else '[]'
示例#5
0
     description='Family of image used when creating compute nodes',
     mutually_exclusive=['image', 'image_url'],
     overrides=['image', 'image_url'],
     **GROUP_INSTANCES
 ),
 'tags': settings.TagListSetting(
     display_name='Tags',
     description='A comma-separated list of tags in the form of '
                 'key=value',
     **GROUP_INSTANCES
 ),
 'startup_script_template': settings.FileSetting(
     display_name='Start-up Script Template',
     required=True,
     description='Filename of "bootstrap" script used by Tortuga to '
                 'bootstrap compute nodes',
     default='startup_script.py',
     base_path='/opt/tortuga/config/',
     **GROUP_INSTANCES
 ),
 'default_ssh_user': settings.StringSetting(
     display_name='Default SSH User',
     required=True,
     description='Username of default user on created VMs. "centos" '
                 'is an appropriate value for CentOS-based VMs.',
     **GROUP_INSTANCES
 ),
 'vcpus': settings.IntegerSetting(
     display_name='VCPUs',
     description='Number of virtual CPUs for specified virtual '
                 'machine type',
     mutually_exclusive=['image'],
     overrides=['image'],
     **GROUP_INSTANCES),
 'image':
 settings.StringSetting(display_name='Image',
                        description='Name of VM image',
                        mutually_exclusive=['image_urn'],
                        overrides=['image_urn'],
                        **GROUP_INSTANCES),
 'cloud_init_script_template':
 settings.FileSetting(
     display_name='Cloud Init Script Template',
     description='Use this setting to specify the filename/path of'
     'the cloud-init script template. If the path is not'
     'fully-qualified (does not start with a leading'
     'forward slash), it is assumed the script path is '
     '$TORTUGA_ROOT/config',
     base_path='/opt/tortuga/config/',
     mutually_exclusive=['user_data_script_template'],
     overrides=['user_data_script_template'],
     **GROUP_INSTANCES),
 'user_data_script_template':
 settings.FileSetting(
     display_name='User Data Script Template',
     description='File name of bootstrap script template to be used '
     'on compute nodes. If the path is not '
     'fully-qualified (ie. does not start with a leading '
     'forward slash), it is assumed the script path is '
     '$TORTUGA_ROOT/config',
     base_path='/opt/tortuga/config/',
     mutually_exclusive=['cloud_init_script_template'],
示例#7
0
class Default(ResourceAdapter):
    __adaptername__ = 'default'

    settings: Dict[str, ra_settings.BaseSetting] = {
        'boot_host_hook_script': ra_settings.FileSetting()
    }

    def __init__(self, addHostSession: Optional[str] = None) -> None:
        super().__init__(addHostSession=addHostSession)

        self._bhm = \
            osUtility.getOsObjectFactory().getOsBootHostManager(self._cm)

    @property
    def hookScript(self):
        '''
        Load the hook script setting from the resource adapter
        configuration file and ensure it exists. If the hook script is
        not defined or is defined and does note exist, a warning message
        is logged and the method returns None.
        '''

        RA_SECTION = 'resource-adapter'
        OPTION = 'host_hook_script'

        cfgFile = configparser.ConfigParser()
        cfgFile.read(self.cfgFileName)

        if not cfgFile.has_section(RA_SECTION) or \
                not cfgFile.has_option(RA_SECTION, OPTION):
            self._logger.warning('Hook script is not defined')

            return None

        hookScript = cfgFile.get(RA_SECTION, OPTION)

        if not hookScript[0] in ['/', '$']:
            tmpHookScript = os.path.join(self._cm.getKitConfigBase(),
                                         hookScript)
        else:
            tmpHookScript = hookScript.replace('$TORTUGA_ROOT',
                                               self._cm.getRoot())

        if not os.path.join(tmpHookScript):
            self._logger.warning('Hook script [%s] does not exist' %
                                 (tmpHookScript))

            return None

        return tmpHookScript

    def hookAction(self, action, nodes, args=None):
        '''
        WARNING: this method may be subject to scalability concerns if
        batch node operations are implemented.
        '''

        hookScript = self.hookScript

        if not hookScript:
            return

        nodeArg = nodes if not isinstance(nodes, list) else ','.join(nodes)

        cmd = '%s %s' % (hookScript, action)

        if args:
            cmd += ' %s' % (args)

        cmd += ' %s' % (nodeArg)

        tortugaSubprocess.executeCommandAndIgnoreFailure(cmd)

    def deleteNode(self, nodes: List[Node]) -> None:
        """Remove boot configuration for deleted nodes
        """
        for node in nodes:
            self.__delete_boot_configuration(node)

        self.hookAction('delete', [node.name for node in nodes])

    def __delete_boot_configuration(self, node: Node) -> None:
        """Remove PXE boot files and DHCP configuration
        """
        self._bhm.rmPXEFile(node)
        self._bhm.removeDhcpLease(node)

    def rebootNode(self,
                   nodes: List[Node],
                   bSoftReset: Optional[bool] = False):
        self._logger.debug('rebootNode()')

        # Call the reboot script hook
        self.hookAction('reset', [node.name for node in nodes],
                        'soft' if bSoftReset else 'hard')

    def __get_node_details(self, addNodesRequest, dbHardwareProfile,
                           dbSoftwareProfile):         \
            # pylint: disable=no-self-use,unused-argument

        nodeDetails = addNodesRequest['nodeDetails'] \
            if 'nodeDetails' in addNodesRequest else []

        # Check if any interface has predefined MAC address
        macSpecified = nodeDetails and \
            'nics' in nodeDetails[0] and \
            [nic_ for nic_ in nodeDetails[0]['nics'] if 'mac' in nic_]

        # Check if any intercace has predefined IP address
        ipAddrSpecified = nodeDetails and \
            'nics' in nodeDetails[0] and \
            [ip_ for ip_ in nodeDetails[0]['nics'] if 'ip' in ip_]

        hostNameSpecified = nodeDetails and 'name' in nodeDetails[0]

        if macSpecified or ipAddrSpecified or hostNameSpecified:
            return nodeDetails

        return None

    def start(self,
              addNodesRequest,
              dbSession,
              dbHardwareProfile,
              dbSoftwareProfile=None) -> List[Node]:
        """
        Raises:
            CommandFailed
        """

        # 'nodeDetails' is a list of details (contained in a 'dict') for
        # one or more nodes. It can contain host name(s) and nic details
        # like MAC and/or IP address. It is an entirely optional data
        # structure and may be empty and/or undefined.

        nodeDetails = self.__get_node_details(addNodesRequest,
                                              dbHardwareProfile,
                                              dbSoftwareProfile)

        if not nodeDetails:
            raise CommandFailed('Invalid operation (DHCP discovery)')

        try:
            dns_zone = GlobalParametersDbHandler().getParameter(
                dbSession, 'DNSZone').value
        except ParameterNotFound:
            dns_zone = ''

        nodes = self.__add_predefined_nodes(addNodesRequest,
                                            dbSession,
                                            dbHardwareProfile,
                                            dbSoftwareProfile,
                                            dns_zone=dns_zone)

        # This is a necessary evil for the time being, until there's
        # a proper context manager implemented.
        self.addHostApi.clear_session_nodes(nodes)

        return nodes

    def validate_start_arguments(self, addNodesRequest: dict,
                                 dbHardwareProfile: HardwareProfile,
                                 dbSoftwareProfile: SoftwareProfile):         \
            # pylint: disable=unused-argument,no-self-use
        '''
        :raises CommandFailed:
        :raises NodeAlreadyExists:
        '''

        if dbSoftwareProfile is None:
            raise CommandFailed(
                "Software profile must be provided when adding nodes"
                " to this hardware profile")

        if dbHardwareProfile.location != 'local':
            # Only ensure that required installer components are enabled when
            # hardware profile is marked as 'local'.
            return

        # All resource adapters are responsible for doing their own
        # check for the configuration.  This may change in the
        # future!

        dbInstallerNode = dbHardwareProfile.nics[0].node \
            if dbHardwareProfile.nics else \
            NodesDbHandler().getNode(self.session, self._cm.getInstaller())

        components = [
            c for c in dbInstallerNode.softwareprofile.components
            if c.name == 'dhcpd'
        ]

        if not components:
            raise CommandFailed('dhcpd component must be enabled on the'
                                ' installer in order to provision local nodes')

        name_expected = dbHardwareProfile.nameFormat == '*'

        nodeDetails = addNodesRequest['nodeDetails'] \
            if 'nodeDetails' in addNodesRequest and \
            addNodesRequest['nodeDetails'] else None

        # extract host name from addNodesRequest
        name = nodeDetails[0]['name'] \
            if nodeDetails and 'name' in nodeDetails[0] else None

        mac_addr = None

        if nodeDetails and 'nics' in nodeDetails[0]:
            for nic in nodeDetails[0]['nics']:
                if 'mac' in nic:
                    mac_addr = nic['mac']
                    break

        # check if name is expected in nodeDetails
        if not nodeDetails and name_expected and not name:
            raise CommandFailed(
                'Name and MAC address must be specified for nodes'
                ' in hardware profile [%s]' % dbHardwareProfile.name)

        if not nodeDetails and not mac_addr:
            raise CommandFailed('MAC address must be specified for nodes in'
                                ' hardware profile [%s]' %
                                dbHardwareProfile.name)

        # if host name specified, ensure host does not already exist
        if name:
            try:
                NodesDbHandler().getNode(self.session, name)

                raise NodeAlreadyExists('Node [%s] already exists' % name)
            except NodeNotFound:
                # node does not already exist
                pass

    def __add_predefined_nodes(self,
                               addNodesRequest: dict,
                               dbSession,
                               dbHardwareProfile,
                               dbSoftwareProfile,
                               dns_zone: str = None) -> List[Node]:
        nodeDetails = addNodesRequest['nodeDetails'] \
            if 'nodeDetails' in addNodesRequest else []

        bGenerateIp = dbHardwareProfile.location != 'remote'

        newNodes = []

        for nodeDict in nodeDetails:
            addNodeRequest = {}

            addNodeRequest['addHostSession'] = self.addHostSession

            if 'rack' in addNodesRequest:
                # rack can be undefined, in which case it is not copied
                # into the node request
                addNodeRequest['rack'] = addNodesRequest['rack']

            if 'nics' in nodeDict:
                addNodeRequest['nics'] = nodeDict['nics']

            if 'name' in nodeDict:
                addNodeRequest['name'] = nodeDict['name']

            node = self.nodeApi.createNewNode(dbSession,
                                              addNodeRequest,
                                              dbHardwareProfile,
                                              dbSoftwareProfile,
                                              bGenerateIp=bGenerateIp,
                                              dns_zone=dns_zone)

            dbSession.add(node)

            # Create DHCP/PXE configuration
            self.writeLocalBootConfiguration(node, dbHardwareProfile,
                                             dbSoftwareProfile)

            # Get the provisioning nic
            nics = get_provisioning_nics(node)

            self._pre_add_host(node.name, dbHardwareProfile.name,
                               dbSoftwareProfile.name,
                               nics[0].ip if nics else None)

            newNodes.append(node)

        return newNodes

    def stop(self, hardwareProfileName, deviceName):         \
            # pylint: disable=unused-argument

        pass

    def addVolumeToNode(self, node, volume, isDirect=True):
        '''Add a disk to a node'''
        if not isDirect:
            raise UnsupportedOperation(
                'This node only supports direct volume attachment.')

        # Map the volume to a driveNumber
        openDriveNumber = self.sanApi.mapDrive(node, volume)

        try:
            # have the node connect the storage
            self.sanApi.connectStorageVolume(node, volume, node.getName())
        except Exception:  # noqa pylint: disable=broad-except
            self._logger.exception('Error adding volume to node')

            # Need to clean up mapping
            self.sanApi.unmapDrive(node, driveNumber=openDriveNumber)

            raise

    def removeVolumeFromNode(self, node, volume):
        '''Remove a disk from a node'''

        try:
            try:
                self.sanApi.disconnectStorageVolume(node, volume,
                                                    node.getName())
            except Exception:  # noqa pylint: disable=broad-except
                # Failed disconnect...
                self._logger.exception('Error disconnecting volume from node')

                raise
        finally:
            # Unmap Map the volume to a driveNumber
            self.sanApi.unmapDrive(node, volume=volume)

    def shutdownNode(self,
                     nodes: List[Node],
                     bSoftReset: Optional[bool] = False):
        """
        Shutdown specified node(s)
        """

        self.hookAction('shutdown', [node.name for node in nodes],
                        'soft' if bSoftReset else 'hard')

    def startupNode(self,
                    nodes: List[Node],
                    remainingNodeList: Optional[str] = None,
                    tmpBootMethod: Optional[str] = 'n'):         \
            # pylint: disable=unused-argument
        """
        Start the given node(s)
        """

        self.hookAction('start', [node.name for node in nodes])
示例#8
0
class Default(ResourceAdapter):
    __adaptername__ = 'default'

    settings: Dict[str, ra_settings.BaseSetting] = {
        'boot_host_hook_script': ra_settings.FileSetting()
    }

    def __init__(self, addHostSession: Optional[str] = None) -> None:
        super().__init__(addHostSession=addHostSession)

        self._bhm = \
            osUtility.getOsObjectFactory().getOsBootHostManager(self._cm)

        self.looping = False

    @property
    def hookScript(self):
        '''
        Load the hook script setting from the resource adapter
        configuration file and ensure it exists. If the hook script is
        not defined or is defined and does note exist, a warning message
        is logged and the method returns None.
        '''

        RA_SECTION = 'resource-adapter'
        OPTION = 'host_hook_script'

        cfgFile = configparser.ConfigParser()
        cfgFile.read(self.cfgFileName)

        if not cfgFile.has_section(RA_SECTION) or \
                not cfgFile.has_option(RA_SECTION, OPTION):
            self.getLogger().warning('Hook script is not defined')

            return None

        hookScript = cfgFile.get(RA_SECTION, OPTION)

        if not hookScript[0] in ['/', '$']:
            tmpHookScript = os.path.join(
                self._cm.getKitConfigBase(), hookScript)
        else:
            tmpHookScript = hookScript.replace(
                '$TORTUGA_ROOT', self._cm.getRoot())

        if not os.path.join(tmpHookScript):
            self.getLogger().warning(
                'Hook script [%s] does not exist' % (tmpHookScript))

            return None

        return tmpHookScript

    def hookAction(self, action, nodes, args=None):
        '''
        WARNING: this method may be subject to scalability concerns if
        batch node operations are implemented.
        '''

        hookScript = self.hookScript

        if not hookScript:
            return

        nodeArg = nodes if not isinstance(nodes, list) else ','.join(nodes)

        cmd = '%s %s' % (hookScript, action)

        if args:
            cmd += ' %s' % (args)

        cmd += ' %s' % (nodeArg)

        tortugaSubprocess.executeCommandAndIgnoreFailure(cmd)

    def transferNode(self, nodeIdSoftwareProfileTuples,
                     newSoftwareProfileName): \
            # pylint: disable=unused-argument
        """
        Raises:
            NodeNotFound
        """

        for dbNode, _ in nodeIdSoftwareProfileTuples:
            # Ensure PXE files are properly in place before triggering
            # the reboot.
            self._bhm.setNodeForNetworkBoot(self.session, dbNode)

        self.rebootNode([dbNode for dbNode, _ in nodeIdSoftwareProfileTuples])

    def suspendActiveNode(self, node: Node) -> bool: \
            # pylint: disable=no-self-use,unused-argument
        # not supported
        return False

    def idleActiveNode(self, nodes: List[Node]) -> str:
        # Shutdown nodes
        self.shutdownNode(nodes)

        return 'Discovered'

    def activateIdleNode(self, node: Node, softwareProfileName: str,
                         softwareProfileChanged: bool):
            # pylint: disable=no-self-use
        if softwareProfileChanged:
            softwareprofile = \
                SoftwareProfilesDbHandler().getSoftwareProfile(
                    self.session, softwareProfileName)

            # Mark node for network boot if software profile changed
            node.bootFrom = 0
        else:
            softwareprofile = None

        self._bhm.writePXEFile(
            self.session, node, localboot=not softwareProfileChanged,
            softwareprofile=softwareprofile
        )

    def abort(self):
        self.looping = False

    def deleteNode(self, nodes: List[Node]) -> None:
        self.hookAction('delete', [node.name for node in nodes])

    def rebootNode(self, nodes: List[Node],
                   bSoftReset: Optional[bool] = False):
        self.getLogger().debug('rebootNode()')

        # Call the reboot script hook
        self.hookAction('reset', [node.name for node in nodes],
                        'soft' if bSoftReset else 'hard')

    def __is_duplicate_mac_in_session(self, mac, session_nodes): \
            # pylint: disable=no-self-use
        for node in session_nodes:
            for nic in node.nics:
                if nic.mac == mac:
                    return True

        return False

    def __is_duplicate_mac(self, mac, session_nodes):
        if self.__is_duplicate_mac_in_session(mac, session_nodes):
            return True

        try:
            NodesDbHandler().getNodeByMac(self.session, mac)

            return True
        except NodeNotFound:
            return False

    def __get_node_details(self, addNodesRequest, dbHardwareProfile,
                           dbSoftwareProfile): \
            # pylint: disable=no-self-use,unused-argument
        nodeDetails = addNodesRequest['nodeDetails'] \
            if 'nodeDetails' in addNodesRequest else []

        # Check if any interface has predefined MAC address
        macSpecified = nodeDetails and \
            'nics' in nodeDetails[0] and \
            [nic_ for nic_ in nodeDetails[0]['nics'] if 'mac' in nic_]

        # Check if any intercace has predefined IP address
        ipAddrSpecified = nodeDetails and \
            'nics' in nodeDetails[0] and \
            [ip_ for ip_ in nodeDetails[0]['nics'] if 'ip' in ip_]

        hostNameSpecified = nodeDetails and 'name' in nodeDetails[0]

        if macSpecified or ipAddrSpecified or hostNameSpecified:
            return nodeDetails

        return None

    def start(self, addNodesRequest, dbSession, dbHardwareProfile,
              dbSoftwareProfile=None) -> List[Node]:
        """
        Raises:
            CommandFailed
        """

        # 'nodeDetails' is a list of details (contained in a 'dict') for
        # one or more nodes. It can contain host name(s) and nic details
        # like MAC and/or IP address. It is an entirely optional data
        # structure and may be empty and/or undefined.

        nodeDetails = self.__get_node_details(
            addNodesRequest, dbHardwareProfile, dbSoftwareProfile)

        # return self.__add_predefined_nodes(
        #     addNodesRequest, dbSession, dbHardwareProfile,
        #     dbSoftwareProfile) \
        #     if nodeDetails else self.__dhcp_discovery(
        #         addNodesRequest, dbSession, dbHardwareProfile,
        #         dbSoftwareProfile)

        if not nodeDetails:
            raise CommandFailed('Invalid operation (DHCP discovery)')

        try:
            dns_zone = GlobalParametersDbHandler().getParameter(
                dbSession, 'DNSZone').value
        except ParameterNotFound:
            dns_zone = ''

        nodes = self.__add_predefined_nodes(
            addNodesRequest, dbSession, dbHardwareProfile, dbSoftwareProfile,
            dns_zone=dns_zone)

        # This is a necessary evil for the time being, until there's
        # a proper context manager implemented.
        self.addHostApi.clear_session_nodes(nodes)

        return nodes

    def validate_start_arguments(self, addNodesRequest, dbHardwareProfile,
                                 dbSoftwareProfile): \
            # pylint: disable=unused-argument,no-self-use
        '''
        :raises CommandFailed:
        :raises NodeAlreadyExists:
        '''

        if dbSoftwareProfile is None:
            raise CommandFailed(
                "Software profile must be provided when adding nodes"
                " to this hardware profile")

        if dbHardwareProfile.location != 'local':
            # Only ensure that required installer components are enabled when
            # hardware profile is marked as 'local'.
            return

        # All resource adapters are responsible for doing their own
        # check for the configuration.  This may change in the
        # future!

        dbInstallerNode = dbHardwareProfile.nics[0].node \
            if dbHardwareProfile.nics else \
            NodesDbHandler().getNode(self.session, self._cm.getInstaller())

        components = [c for c in dbInstallerNode.softwareprofile.components
                        if c.name == 'dhcpd']

        if not components:
            raise CommandFailed(
                'dhcpd component must be enabled on the'
                ' installer in order to provision local nodes')

        name_expected = dbHardwareProfile.nameFormat == '*'

        nodeDetails = addNodesRequest['nodeDetails'] \
            if 'nodeDetails' in addNodesRequest and \
            addNodesRequest['nodeDetails'] else None

        # extract host name from addNodesRequest
        name = nodeDetails[0]['name'] \
            if nodeDetails and 'name' in nodeDetails[0] else None

        mac_addr = None

        if nodeDetails and 'nics' in nodeDetails[0]:
            for nic in nodeDetails[0]['nics']:
                if 'mac' in nic:
                    mac_addr = nic['mac']
                    break

        # check if name is expected in nodeDetails
        if not nodeDetails and name_expected and not name:
            raise CommandFailed(
                'Name and MAC address must be specified for nodes'
                ' in hardware profile [%s]' % dbHardwareProfile.name
            )

        if not nodeDetails and not mac_addr:
            raise CommandFailed(
                'MAC address must be specified for nodes in'
                ' hardware profile [%s]' % dbHardwareProfile.name
            )

        # if host name specified, ensure host does not already exist
        if name:
            try:
                NodesDbHandler().getNode(self.session, name)

                raise NodeAlreadyExists('Node [%s] already exists' % name)
            except NodeNotFound:
                # node does not already exist
                pass

    def __add_predefined_nodes(self, addNodesRequest: dict, dbSession,
                               dbHardwareProfile, dbSoftwareProfile,
                               dns_zone: str = None) -> List[Node]:
        nodeDetails = addNodesRequest['nodeDetails'] \
            if 'nodeDetails' in addNodesRequest else []

        bGenerateIp = dbHardwareProfile.location != 'remote'

        newNodes = []

        for nodeDict in nodeDetails:
            addNodeRequest = {}

            addNodeRequest['addHostSession'] = self.addHostSession

            if 'rack' in addNodesRequest:
                # rack can be undefined, in which case it is not copied
                # into the node request
                addNodeRequest['rack'] = addNodesRequest['rack']

            if 'nics' in nodeDict:
                addNodeRequest['nics'] = nodeDict['nics']

            if 'name' in nodeDict:
                addNodeRequest['name'] = nodeDict['name']

            node = self.nodeApi.createNewNode(
                dbSession, addNodeRequest, dbHardwareProfile,
                dbSoftwareProfile, bGenerateIp=bGenerateIp, dns_zone=dns_zone)

            dbSession.add(node)

            # Create DHCP/PXE configuration
            self.writeLocalBootConfiguration(
                node, dbHardwareProfile, dbSoftwareProfile)

            # Get the provisioning nic
            nics = get_provisioning_nics(node)

            self._pre_add_host(
                node.name,
                dbHardwareProfile.name,
                dbSoftwareProfile.name,
                nics[0].ip if nics else None)

            newNodes.append(node)

        return newNodes

    def __dhcp_discovery(self, addNodesRequest, dbSession, dbHardwareProfile,
                         dbSoftwareProfile):
        # Listen for DHCP requests

        if not dbHardwareProfile.nics:
            raise CommandFailed(
                'Hardware profile [%s] does not have a provisioning'
                ' NIC defined' % (dbHardwareProfile.name))

        newNodes = []

        deviceName = addNodesRequest['deviceName'] \
            if 'deviceName' in addNodesRequest else None

        nodeCount = addNodesRequest['count'] \
            if 'count' in addNodesRequest else 0

        bGenerateIp = dbHardwareProfile.location != 'remote'

        # Obtain platform-specific packet capture subprocess object
        addHostManager = osUtility.getOsObjectFactory().getOsAddHostManager()

        deviceName = dbHardwareProfile.nics[0].networkdevice.name

        p1 = addHostManager.dhcpCaptureSubprocess(deviceName)

        if nodeCount:
            self.getLogger().debug(
                'Adding [%s] new %s' % (
                    nodeCount, 'nodes' if nodeCount > 1 else 'node'))

        self.looping = True

        index = 0

        # Node count was not specified, so discover DHCP nodes
        # until manually aborted by user.
        msg = 'Waiting for new node...' if not nodeCount else \
            'Waiting for new node #1 of %d...' % (nodeCount)

        try:
            while self.looping:
                # May not need this...
                dataReady = select.select([p1.stdout], [], [], 5)

                if not dataReady[0]:
                    continue

                line = p1.stdout.readline()

                if not line:
                    self.getLogger().debug(
                        'DHCP packet capture process ended... exiting')

                    break

                self.getLogger().debug(
                    'Read line "%s" len=%s' % (line, len(line)))

                mac = addHostManager.getMacAddressFromCaptureEntry(line)

                if not mac:
                    continue

                self.getLogger().debug('Discovered MAC address [%s]' % (mac))

                if self.__is_duplicate_mac(mac, newNodes):
                    # Ignore DHCP request from known MAC
                    self.getLogger().debug(
                        'MAC address [%s] is already known' % (mac))

                    continue

                addNodeRequest = {}

                if 'rack' in addNodesRequest:
                    addNodeRequest['rack'] = addNodesRequest['rack']

                # Get nics based on hardware profile networks
                addNodeRequest['nics'] = initialize_nics(
                    dbHardwareProfile.nics[0],
                    dbHardwareProfile.hardwareprofilenetworks, mac)

                # We may be trying to create the same node for the
                # second time so we'll ignore errors
                try:
                    node = self.nodeApi.createNewNode(
                        None,
                        addNodeRequest,
                        dbHardwareProfile,
                        dbSoftwareProfile,
                        bGenerateIp=bGenerateIp)
                except NodeAlreadyExists as ex:
                    existingNodeName = ex.args[0]

                    self.getLogger().debug(
                        'Node [%s] already exists' % (existingNodeName))

                    continue
                except MacAddressAlreadyExists:
                    self.getLogger().debug(
                        'MAC address [%s] already exists' % (mac))

                    continue
                except IpAlreadyExists as ex:
                    self.getLogger().debug(
                        'IP address already in use by node'
                        ' [%s]: %s' % (existingNodeName, ex))

                    continue

                # Add the newly created node to the session
                dbSession.add(node)

                # Create DHCP/PXE configuration
                self.writeLocalBootConfiguration(
                    node, dbHardwareProfile, dbSoftwareProfile)

                index += 1

                # Use first provisioning nic
                nic = get_provisioning_nic(node)

                try:
                    msg = 'Added node [%s] IP [%s]' % (
                        node.name, nic.ip)

                    if nic.mac:
                        msg += ' MAC [%s]' % (nic.mac)

                    self.getLogger().info(msg)
                except Exception as ex:  # noqa pylint: disable=broad-except
                    self.getLogger().exception('Error setting status message')

                self._pre_add_host(
                    node.name,
                    dbHardwareProfile.name,
                    dbSoftwareProfile.name,
                    nic.ip)

                newNodes.append(node)

                if nodeCount > 0:
                    nodeCount -= 1
                    if not nodeCount:
                        self.looping = False
        except Exception as msg:  # noqa pylint: disable=broad-except
            self.getLogger().exception('DHCP discovery failed')

        try:
            os.kill(p1.pid, signal.SIGKILL)
            os.waitpid(p1.pid, 0)
        except Exception:  # noqa pylint: disable=broad-except
            self.getLogger().exception(
                'Error killing network capture process')

        # This is a necessary evil for the time being, until there's
        # a proper context manager implemented.
        self.addHostApi.clear_session_nodes(newNodes)

        return newNodes

    def stop(self, hardwareProfileName, deviceName): \
            # pylint: disable=unused-argument
        pass

    def addVolumeToNode(self, node, volume, isDirect=True):
        '''Add a disk to a node'''
        if not isDirect:
            raise UnsupportedOperation(
                'This node only supports direct volume attachment.')

        # Map the volume to a driveNumber
        openDriveNumber = self.sanApi.mapDrive(node, volume)

        try:
            # have the node connect the storage
            self.sanApi.connectStorageVolume(node, volume, node.getName())
        except Exception:  # noqa pylint: disable=broad-except
            self.getLogger().exception('Error adding volume to node')

            # Need to clean up mapping
            self.sanApi.unmapDrive(node, driveNumber=openDriveNumber)

            raise

    def removeVolumeFromNode(self, node, volume):
        '''Remove a disk from a node'''

        try:
            try:
                self.sanApi.disconnectStorageVolume(
                    node, volume, node.getName())
            except Exception:  # noqa pylint: disable=broad-except
                # Failed disconnect...
                self.getLogger().exception(
                    'Error disconnecting volume from node')

                raise
        finally:
            # Unmap Map the volume to a driveNumber
            self.sanApi.unmapDrive(node, volume=volume)

    def shutdownNode(self, nodes: List[Node],
                     bSoftReset: Optional[bool] = False):
        """
        Shutdown specified node(s)
        """

        self.hookAction(
            'shutdown', [node.name for node in nodes],
            'soft' if bSoftReset else 'hard')

    def startupNode(self, nodes: List[Node],
                    remainingNodeList: Optional[str] = None,
                    tmpBootMethod: Optional[str] = 'n'): \
            # pylint: disable=unused-argument
        """
        Start the given node(s)
        """

        self.hookAction('start', [node.name for node in nodes])