Seed code for ironic_virtmedia_driver
[ta/ironic-virtmedia-driver.git] / src / ironic_virtmedia_driver / virtmedia_ssh_boot.py
1 # Copyright 2019 Nokia
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 #     http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14 #
15
16 import os
17
18 from oslo_concurrency import processutils
19 from oslo_log import log as logging
20 from oslo_utils import strutils
21
22 import retrying
23 import paramiko
24 import six
25
26 from ironic.common import exception
27 from ironic.common import utils
28 from ironic.common.i18n import _, _translators
29 from ironic.drivers import utils as driver_utils
30 from ironic.conf import CONF
31 from ironic_virtmedia_driver import virtmedia
32
33 LOG = logging.getLogger(__name__)
34
35 REQUIRED_PROPERTIES = {
36     'ssh_address': _("IP address of the node to ssh into from where the VMs can be managed. "
37                      "Required."),
38     'ssh_username': _("username to authenticate as. Required."),
39     'ssh_key_contents': _("private key(s). If ssh_password is also specified "
40                           "it will be used for unlocking the private key. Do "
41                           "not specify ssh_key_filename when this property is "
42                           "specified.")
43 }
44
45 COMMON_PROPERTIES = REQUIRED_PROPERTIES.copy()
46 CONSOLE_PROPERTIES = {
47     'ssh_terminal_port': _("node's UDP port to connect to. Only required for "
48                            "console access and only applicable for 'virsh'.")
49 }
50
51 def _get_command_sets():
52     """Retrieves the virt_type-specific commands.
53     Returns commands are as follows:
54
55     base_cmd: Used by most sub-commands as the primary executable
56     list_all: Lists all VMs (by virt_type identifier) that can be managed.
57         One name per line, must not be quoted.
58     get_disk_list: Gets the list of Block devices connected to the VM
59     attach_disk_device: Attaches a given disk device to VM
60     detach_disk_device: Detaches a disk device from a VM
61     """
62     virt_type="virsh"
63     if virt_type == "virsh":
64         virsh_cmds = {
65             'base_cmd': 'LC_ALL=C /usr/bin/virsh',
66             'list_all': 'list --all --name',
67             'get_disk_list': (
68                 "domblklist --domain {_NodeName_} | "
69                 "grep var | awk -F \" \" '{print $1}'"),
70             'attach_disk_device': 'attach-disk --domain {_NodeName_} --source /var/lib/libvirt/images/{_ImageName_} --target {_TargetDev_} --sourcetype file --mode readonly --type {_DevType_} --config',
71             'detach_disk_device': 'detach-disk --domain {_NodeName_} --target {_TargetDev_} --config',
72         }
73
74         return virsh_cmds
75     else:
76         raise exception.InvalidParameterValue(_(
77             "SSHPowerDriver '%(virt_type)s' is not a valid virt_type, ") %
78             {'virt_type': virt_type})
79
80 def _ssh_execute(ssh_obj, cmd_to_exec):
81     """Executes a command via ssh.
82
83     Executes a command via ssh and returns a list of the lines of the
84     output from the command.
85
86     :param ssh_obj: paramiko.SSHClient, an active ssh connection.
87     :param cmd_to_exec: command to execute.
88     :returns: list of the lines of output from the command.
89     :raises: SSHCommandFailed on an error from ssh.
90
91     """
92     LOG.debug(_translators.log_error("Executing SSH cmd: %r"), cmd_to_exec)
93     try:
94         output_list = processutils.ssh_execute(ssh_obj,
95                                                cmd_to_exec)[0].split('\n')
96     except Exception as e:
97         LOG.error(_translators.log_error("Cannot execute SSH cmd %(cmd)s. Reason: %(err)s."),
98                   {'cmd': cmd_to_exec, 'err': e})
99         raise exception.SSHCommandFailed(cmd=cmd_to_exec)
100
101     return output_list
102
103
104 def _parse_driver_info(node):
105     """Gets the information needed for accessing the node.
106
107     :param node: the Node of interest.
108     :returns: dictionary of information.
109     :raises: InvalidParameterValue if any required parameters are incorrect.
110     :raises: MissingParameterValue if any required parameters are missing.
111
112     """
113     info = node.driver_info or {}
114     missing_info = [key for key in REQUIRED_PROPERTIES if not info.get(key)]
115     if missing_info:
116         raise exception.MissingParameterValue(_(
117             "SSHPowerDriver requires the following parameters to be set in "
118             "node's driver_info: %s.") % missing_info)
119
120     address = info.get('ssh_address')
121     username = info.get('ssh_username')
122     key_contents = info.get('ssh_key_contents')
123     terminal_port = info.get('ssh_terminal_port')
124
125     if terminal_port is not None:
126         terminal_port = utils.validate_network_port(terminal_port,
127                                                     'ssh_terminal_port')
128
129     # NOTE(deva): we map 'address' from API to 'host' for common utils
130     res = {
131         'host': address,
132         'username': username,
133         'port': 22,
134         'uuid': node.uuid,
135         'terminal_port': terminal_port
136     }
137
138     cmd_set = _get_command_sets()
139     res['cmd_set'] = cmd_set
140
141     if key_contents:
142         res['key_contents'] = key_contents
143     else:
144         raise exception.InvalidParameterValue(_(
145             "ssh_virtmedia Driver requires ssh_key_contents to be set."))
146     return res
147
148 def _get_ssh_connection(connection):
149     """Returns an SSH client connected to a node.
150
151     :param node: the Node.
152     :returns: paramiko.SSHClient, an active ssh connection.
153
154     """
155     try:
156         ssh = paramiko.SSHClient()
157         ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
158         key_contents = connection.get('key_contents')
159         if key_contents:
160             data = six.StringIO(key_contents)
161             if "BEGIN RSA PRIVATE" in key_contents:
162                 pkey = paramiko.RSAKey.from_private_key(data)
163             elif "BEGIN DSA PRIVATE" in key_contents:
164                 pkey = paramiko.DSSKey.from_private_key(data)
165             else:
166                 # Can't include the key contents - secure material.
167                 raise ValueError(_("Invalid private key"))
168         else:
169             pkey = None
170         ssh.connect(connection.get('host'),
171                     username=connection.get('username'),
172                     password=None,
173                     port=connection.get('port', 22),
174                     pkey=pkey,
175                     key_filename=connection.get('key_filename'),
176                     timeout=connection.get('timeout', 10))
177
178         # send TCP keepalive packets every 20 seconds
179         ssh.get_transport().set_keepalive(20)
180     except Exception as e:
181         LOG.debug("SSH connect failed: %s", e)
182         raise exception.SSHConnectFailed(host=connection.get('host'))
183
184     return ssh
185
186 def _get_disk_attachment_status(driver_info, node_name, ssh_obj, target_disk='hda'):
187     cmd_to_exec = "%s %s" % (driver_info['cmd_set']['base_cmd'],
188                              driver_info['cmd_set']['get_disk_list'])
189     cmd_to_exec = cmd_to_exec.replace('{_NodeName_}', node_name)
190     blk_dev_list = _ssh_execute(ssh_obj, cmd_to_exec)
191     LOG.debug("Attached block devices list for node %(node_name)s: %(blk_dev_list)s", {'node_name': node_name, 'blk_dev_list': blk_dev_list})
192     if target_disk in blk_dev_list:
193         return True
194     else:
195         return False
196
197 def _get_sftp_connection(connection):
198     try:
199         key_contents = connection.get('key_contents')
200         if key_contents:
201             data = six.StringIO(key_contents)
202             if "BEGIN RSA PRIVATE" in key_contents:
203                 pkey = paramiko.RSAKey.from_private_key(data)
204             elif "BEGIN DSA PRIVATE" in key_contents:
205                 pkey = paramiko.DSSKey.from_private_key(data)
206             else:
207                 # Can't include the key contents - secure material.
208                 raise ValueError(_("Invalid private key"))
209         else:
210             pkey = None
211
212         sftp_obj = paramiko.Transport((connection.get('host'), connection.get('port', 22)))
213         sftp_obj.connect(None, username=connection.get('username'),
214                     password=None, pkey=pkey)
215         return sftp_obj
216     except Exception as e:
217         LOG.error(_translators.log_error("Cannot establish connection to sftp target. Reason: %(err)s."),
218                   {'err': e})
219         raise exception.CommunicationError(e)
220
221 def _copy_media_to_virt_server(sftp_obj, media_file):
222     LOG.debug("Copying file: %s to target" %(media_file))
223     sftp = paramiko.SFTPClient.from_transport(sftp_obj)
224     try:
225         sftp.put('/remote_image_share_root/%s' %media_file, '/var/lib/libvirt/images/%s' %media_file)
226     except Exception as e:
227         LOG.error(_translators.log_error("Cannot copy %(media_file)s to target. Reason: %(err)s."),
228                   {'media_file': media_file, 'err': e})
229         raise exception.CommunicationError(media_file)
230
231 class VirtualMediaAndSSHBoot(virtmedia.VirtmediaBoot):
232     def __init__(self):
233         """Constructor of VirtualMediaAndSSHBoot.
234
235         :raises: InvalidParameterValue, if config option has invalid value.
236         """
237         super(VirtualMediaAndSSHBoot, self).__init__()
238
239     def _attach_virtual_cd(self, task, image_filename):
240         driver_info = _parse_driver_info(task.node)
241         ssh_obj = _get_ssh_connection(driver_info)
242         sftp_obj = _get_sftp_connection(driver_info)
243         node_name = task.node.name
244         if _get_disk_attachment_status(driver_info, node_name, ssh_obj, 'hda'):
245             LOG.debug("A CD is already attached to node %s, not taking any action", node_name)
246             return
247
248         _copy_media_to_virt_server(sftp_obj, image_filename)
249         cmd_to_exec = "%s %s" % (driver_info['cmd_set']['base_cmd'],
250                                  driver_info['cmd_set']['attach_disk_device'])
251         cmd_to_exec = cmd_to_exec.replace('{_NodeName_}', node_name)
252         cmd_to_exec = cmd_to_exec.replace('{_ImageName_}', image_filename)
253         cmd_to_exec = cmd_to_exec.replace('{_TargetDev_}', 'hda')
254         cmd_to_exec = cmd_to_exec.replace('{_DevType_}', 'cdrom')
255         LOG.debug("Ironic node-name: %s, virsh domain name: %s, image_filename: %s" %(task.node.name, node_name, image_filename))
256
257         _ssh_execute(ssh_obj, cmd_to_exec)
258
259     def _detach_virtual_cd(self, task):
260         driver_info = _parse_driver_info(task.node)
261         ssh_obj = _get_ssh_connection(driver_info)
262         node_name = task.node.name
263
264         if not _get_disk_attachment_status(driver_info, node_name, ssh_obj, 'hda'):
265             LOG.debug("No CD is attached to node %s, not taking any action", node_name)
266             return
267
268         cmd_to_exec = "%s %s" % (driver_info['cmd_set']['base_cmd'],
269                                  driver_info['cmd_set']['detach_disk_device'])
270         cmd_to_exec = cmd_to_exec.replace('{_NodeName_}', node_name)
271         cmd_to_exec = cmd_to_exec.replace('{_TargetDev_}', 'hda')
272         _ssh_execute(ssh_obj, cmd_to_exec)