blob: c9db71df22ec0705e1a52c5cf5d93b724fcf72b6 [file] [log] [blame]
akutzdd794a42018-09-18 10:04:21 -05001# Cloud-Init Datasource for VMware Guestinfo
2#
3# Copyright (c) 2018 VMware, Inc. All Rights Reserved.
4#
5# This product is licensed to you under the Apache 2.0 license (the "License").
6# You may not use this product except in compliance with the Apache 2.0 License.
7#
8# This product may include a number of subcomponents with separate copyright
9# notices and license terms. Your use of these subcomponents is subject to the
10# terms and conditions of the subcomponent's license, as noted in the LICENSE
11# file.
akutz77457a62018-08-22 16:07:21 -050012#
akutz6501f902018-08-24 12:19:05 -050013# Authors: Anish Swaminathan <anishs@vmware.com>
14# Andrew Kutz <akutz@vmware.com>
akutz77457a62018-08-22 16:07:21 -050015#
akutz0d1fce52019-06-01 18:54:29 -050016
17'''
18A cloud init datasource for VMware GuestInfo.
19'''
20
akutz77457a62018-08-22 16:07:21 -050021import base64
akutzffc4dd52019-06-02 11:34:55 -050022import collections
akutz84389c82019-06-02 19:24:38 -050023import copy
akutz0d1fce52019-06-01 18:54:29 -050024from distutils.spawn import find_executable
akutzffc4dd52019-06-02 11:34:55 -050025import json
26import socket
27import zlib
akutz77457a62018-08-22 16:07:21 -050028
29from cloudinit import log as logging
30from cloudinit import sources
31from cloudinit import util
akutz6501f902018-08-24 12:19:05 -050032from cloudinit import safeyaml
akutz77457a62018-08-22 16:07:21 -050033
akutzffc4dd52019-06-02 11:34:55 -050034from deepmerge import always_merger
35import netifaces
36
akutz77457a62018-08-22 16:07:21 -050037LOG = logging.getLogger(__name__)
akutz0d1fce52019-06-01 18:54:29 -050038NOVAL = "No value found"
39VMTOOLSD = find_executable("vmtoolsd")
akutz77457a62018-08-22 16:07:21 -050040
akutz0d1fce52019-06-01 18:54:29 -050041
42class NetworkConfigError(Exception):
43 '''
44 NetworkConfigError is raised when there is an issue getting or
45 applying network configuration.
46 '''
47 pass
48
49
Andrew Kutz4f66b8b2018-09-16 18:28:59 -050050class DataSourceVMwareGuestInfo(sources.DataSource):
akutz0d1fce52019-06-01 18:54:29 -050051 '''
52 This cloud-init datasource was designed for use with CentOS 7,
53 which uses cloud-init 0.7.9. However, this datasource should
54 work with any Linux distribution for which cloud-init is
55 avaialble.
56
57 The documentation for cloud-init 0.7.9's datasource is
58 available at http://bit.ly/cloudinit-datasource-0-7-9. The
59 current documentation for cloud-init is found at
60 https://cloudinit.readthedocs.io/en/latest/.
61
62 Setting the hostname:
63 The hostname is set by way of the metadata key "local-hostname".
64
65 Setting the instance ID:
66 The instance ID may be set by way of the metadata key "instance-id".
67 However, if this value is absent then then the instance ID is
68 read from the file /sys/class/dmi/id/product_uuid.
69
70 Configuring the network:
71 The network is configured by setting the metadata key "network"
72 with a value consistent with Network Config Versions 1 or 2,
73 depending on the Linux distro's version of cloud-init:
74
75 Network Config Version 1 - http://bit.ly/cloudinit-net-conf-v1
76 Network Config Version 2 - http://bit.ly/cloudinit-net-conf-v2
77
78 For example, CentOS 7's official cloud-init package is version
79 0.7.9 and does not support Network Config Version 2. However,
80 this datasource still supports supplying Network Config Version 2
81 data as long as the Linux distro's cloud-init package is new
82 enough to parse the data.
83
84 The metadata key "network.encoding" may be used to indicate the
85 format of the metadata key "network". Valid encodings are base64
86 and gzip+base64.
87 '''
88
89 dsname = 'VMwareGuestInfo'
90
akutz77457a62018-08-22 16:07:21 -050091 def __init__(self, sys_cfg, distro, paths, ud_proc=None):
92 sources.DataSource.__init__(self, sys_cfg, distro, paths, ud_proc)
akutz0d1fce52019-06-01 18:54:29 -050093 if not VMTOOLSD:
akutz77457a62018-08-22 16:07:21 -050094 LOG.error("Failed to find vmtoolsd")
95
96 def get_data(self):
akutz0d1fce52019-06-01 18:54:29 -050097 """
98 This method should really be _get_data in accordance with the most
99 recent versions of cloud-init. However, because the datasource
100 supports as far back as cloud-init 0.7.9, get_data is still used.
101
102 Because of this the method attempts to do some of the same things
103 that the get_data functions in newer versions of cloud-init do,
104 such as calling persist_instance_data.
105 """
106 if not VMTOOLSD:
akutz77457a62018-08-22 16:07:21 -0500107 LOG.error("vmtoolsd is required to fetch guestinfo value")
108 return False
akutz6501f902018-08-24 12:19:05 -0500109
akutz0d1fce52019-06-01 18:54:29 -0500110 # Get the metadata.
111 self.metadata = load_metadata()
akutz6501f902018-08-24 12:19:05 -0500112
akutz0d1fce52019-06-01 18:54:29 -0500113 # Get the user data.
114 self.userdata_raw = guestinfo('userdata')
akutz6501f902018-08-24 12:19:05 -0500115
akutz0d1fce52019-06-01 18:54:29 -0500116 # Get the vendor data.
117 self.vendordata_raw = guestinfo('vendordata')
akutz6501f902018-08-24 12:19:05 -0500118
Keerthana Kf0d27ea2019-09-05 21:46:31 +0530119 if self.metadata or self.userdata_raw or self.vendordata_raw:
120 return True
121 else:
122 return False
akutz77457a62018-08-22 16:07:21 -0500123
akutz0d1fce52019-06-01 18:54:29 -0500124 def setup(self, is_new_instance):
125 """setup(is_new_instance)
126
127 This is called before user-data and vendor-data have been processed.
128
129 Unless the datasource has set mode to 'local', then networking
130 per 'fallback' or per 'network_config' will have been written and
131 brought up the OS at this point.
132 """
133
akutzffc4dd52019-06-02 11:34:55 -0500134 # Get information about the host.
akutz0d1fce52019-06-01 18:54:29 -0500135 host_info = get_host_info()
136 LOG.info("got host-info: %s", host_info)
akutzffc4dd52019-06-02 11:34:55 -0500137
138 # Ensure the metadata gets updated with information about the
139 # host, including the network interfaces, default IP addresses,
140 # etc.
141 self.metadata = always_merger.merge(self.metadata, host_info)
akutz0d1fce52019-06-01 18:54:29 -0500142
143 # Persist the instance data for versions of cloud-init that support
144 # doing so. This occurs here rather than in the get_data call in
145 # order to ensure that the network interfaces are up and can be
146 # persisted with the metadata.
147 try:
148 self.persist_instance_data()
149 except AttributeError:
150 pass
151
akutz6501f902018-08-24 12:19:05 -0500152 @property
153 def network_config(self):
akutz0d1fce52019-06-01 18:54:29 -0500154 if 'network' in self.metadata:
155 LOG.debug("using metadata network config")
156 else:
157 LOG.debug("using fallback network config")
158 self.metadata['network'] = {
159 'config': self.distro.generate_fallback_config(),
160 }
161 return self.metadata['network']['config']
akutz77457a62018-08-22 16:07:21 -0500162
163 def get_instance_id(self):
akutz6501f902018-08-24 12:19:05 -0500164 # Pull the instance ID out of the metadata if present. Otherwise
165 # read the file /sys/class/dmi/id/product_uuid for the instance ID.
166 if self.metadata and 'instance-id' in self.metadata:
167 return self.metadata['instance-id']
akutz77457a62018-08-22 16:07:21 -0500168 with open('/sys/class/dmi/id/product_uuid', 'r') as id_file:
Andrey Klimentyeve1c5ed42019-10-09 12:32:44 +0300169 self.metadata['instance-id'] = str(id_file.read()).rstrip().lower()
akutz0d1fce52019-06-01 18:54:29 -0500170 return self.metadata['instance-id']
akutz77457a62018-08-22 16:07:21 -0500171
Andrey Klimentyeva23229d2019-10-17 15:30:00 +0300172 def get_public_ssh_keys(self):
173 public_keys_data = ""
174 if 'public-keys-data' in self.metadata:
175 public_keys_data = self.metadata['public-keys-data'].splitlines()
176
177 public_keys = []
178 if not public_keys_data:
179 return public_keys
180
181 for public_key in public_keys_data:
182 public_keys.append(public_key)
183
184 return public_keys
185
akutz6501f902018-08-24 12:19:05 -0500186
akutz0d1fce52019-06-01 18:54:29 -0500187def decode(key, enc_type, data):
188 '''
189 decode returns the decoded string value of data
190 key is a string used to identify the data being decoded in log messages
191 ----
192 In py 2.7:
193 json.loads method takes string as input
194 zlib.decompress takes and returns a string
195 base64.b64decode takes and returns a string
196 -----
197 In py 3.6 and newer:
198 json.loads method takes bytes or string as input
199 zlib.decompress takes and returns a bytes
200 base64.b64decode takes bytes or string and returns bytes
201 -----
202 In py > 3, < 3.6:
203 json.loads method takes string as input
204 zlib.decompress takes and returns a bytes
205 base64.b64decode takes bytes or string and returns bytes
206 -----
207 Given the above conditions the output from zlib.decompress and
208 base64.b64decode would be bytes with newer python and str in older
209 version. Thus we would covert the output to str before returning
210 '''
211 LOG.debug("Getting encoded data for key=%s, enc=%s", key, enc_type)
akutz6501f902018-08-24 12:19:05 -0500212
akutz0d1fce52019-06-01 18:54:29 -0500213 raw_data = None
214 if enc_type == "gzip+base64" or enc_type == "gz+b64":
215 LOG.debug("Decoding %s format %s", enc_type, key)
216 raw_data = zlib.decompress(base64.b64decode(data), zlib.MAX_WBITS | 16)
217 elif enc_type == "base64" or enc_type == "b64":
218 LOG.debug("Decoding %s format %s", enc_type, key)
219 raw_data = base64.b64decode(data)
220 else:
221 LOG.debug("Plain-text data %s", key)
222 raw_data = data
Sidharth Surana3a421682018-10-10 15:42:08 -0700223
akutz0d1fce52019-06-01 18:54:29 -0500224 if isinstance(raw_data, bytes):
225 return raw_data.decode('utf-8')
226 return raw_data
227
228
229def get_guestinfo_value(key):
230 '''
231 Returns a guestinfo value for the specified key.
232 '''
233 LOG.debug("Getting guestinfo value for key %s", key)
234 try:
235 (stdout, stderr) = util.subp(
236 [VMTOOLSD, "--cmd", "info-get guestinfo." + key])
237 if stderr == NOVAL:
238 LOG.debug("No value found for key %s", key)
239 elif not stdout:
240 LOG.error("Failed to get guestinfo value for key %s", key)
akutz6501f902018-08-24 12:19:05 -0500241 else:
akutz0d1fce52019-06-01 18:54:29 -0500242 return stdout.rstrip()
243 except util.ProcessExecutionError as error:
244 if error.stderr == NOVAL:
245 LOG.debug("No value found for key %s", key)
246 else:
247 util.logexc(
248 LOG, "Failed to get guestinfo value for key %s: %s", key, error)
249 except Exception:
250 util.logexc(
251 LOG, "Unexpected error while trying to get guestinfo value for key %s", key)
252 return None
akutz6501f902018-08-24 12:19:05 -0500253
akutz0d1fce52019-06-01 18:54:29 -0500254
255def guestinfo(key):
256 '''
257 guestinfo returns the guestinfo value for the provided key, decoding
258 the value when required
259 '''
260 data = get_guestinfo_value(key)
261 if not data:
akutz6501f902018-08-24 12:19:05 -0500262 return None
akutz0d1fce52019-06-01 18:54:29 -0500263 enc_type = get_guestinfo_value(key + '.encoding')
264 return decode('guestinfo.' + key, enc_type, data)
265
266
267def load(data):
268 '''
269 load first attempts to unmarshal the provided data as JSON, and if
270 that fails then attempts to unmarshal the data as YAML. If data is
271 None then a new dictionary is returned.
272 '''
273 if not data:
274 return {}
275 try:
276 return json.loads(data)
277 except:
278 return safeyaml.load(data)
279
280
281def load_metadata():
282 '''
283 load_metadata loads the metadata from the guestinfo data, optionally
284 decoding the network config when required
285 '''
286 data = load(guestinfo('metadata'))
akutz84389c82019-06-02 19:24:38 -0500287 LOG.debug('loaded metadata %s', data)
akutz0d1fce52019-06-01 18:54:29 -0500288
289 network = None
290 if 'network' in data:
291 network = data['network']
292 del data['network']
293
294 network_enc = None
295 if 'network.encoding' in data:
296 network_enc = data['network.encoding']
297 del data['network.encoding']
298
299 if network:
akutz84389c82019-06-02 19:24:38 -0500300 LOG.debug('network data found')
301 if isinstance(network, collections.Mapping):
302 LOG.debug("network data copied to 'config' key")
303 network = {
304 'config': copy.deepcopy(network)
305 }
306 else:
307 LOG.debug("network data to be decoded %s", network)
akutz0d1fce52019-06-01 18:54:29 -0500308 dec_net = decode('metadata.network', network_enc, network)
akutz84389c82019-06-02 19:24:38 -0500309 network = {
310 'config': load(dec_net),
311 }
312
313 LOG.debug('network data %s', network)
akutz0d1fce52019-06-01 18:54:29 -0500314 data['network'] = network
315
316 return data
317
akutz6501f902018-08-24 12:19:05 -0500318
akutz77457a62018-08-22 16:07:21 -0500319def get_datasource_list(depends):
akutz0d1fce52019-06-01 18:54:29 -0500320 '''
akutz77457a62018-08-22 16:07:21 -0500321 Return a list of data sources that match this set of dependencies
akutz0d1fce52019-06-01 18:54:29 -0500322 '''
Andrew Kutz4f66b8b2018-09-16 18:28:59 -0500323 return [DataSourceVMwareGuestInfo]
akutz0d1fce52019-06-01 18:54:29 -0500324
325
akutzffc4dd52019-06-02 11:34:55 -0500326def get_default_ip_addrs():
327 '''
328 Returns the default IPv4 and IPv6 addresses based on the device(s) used for
329 the default route. Please note that None may be returned for either address
330 family if that family has no default route or if there are multiple
331 addresses associated with the device used by the default route for a given
332 address.
333 '''
334 gateways = netifaces.gateways()
335 if 'default' not in gateways:
336 return None, None
337
338 default_gw = gateways['default']
339 if netifaces.AF_INET not in default_gw and netifaces.AF_INET6 not in default_gw:
340 return None, None
341
342 ipv4 = None
343 ipv6 = None
344
345 gw4 = default_gw.get(netifaces.AF_INET)
346 if gw4:
347 _, dev4 = gw4
akutz0b519f72019-06-02 14:58:57 -0500348 addr4_fams = netifaces.ifaddresses(dev4)
349 if addr4_fams:
350 af_inet4 = addr4_fams.get(netifaces.AF_INET)
351 if af_inet4:
352 if len(af_inet4) > 1:
akutzffc4dd52019-06-02 11:34:55 -0500353 LOG.warn(
akutz0b519f72019-06-02 14:58:57 -0500354 "device %s has more than one ipv4 address: %s", dev4, af_inet4)
355 elif 'addr' in af_inet4[0]:
356 ipv4 = af_inet4[0]['addr']
akutzffc4dd52019-06-02 11:34:55 -0500357
358 # Try to get the default IPv6 address by first seeing if there is a default
akutz0b519f72019-06-02 14:58:57 -0500359 # IPv6 route.
akutzffc4dd52019-06-02 11:34:55 -0500360 gw6 = default_gw.get(netifaces.AF_INET6)
361 if gw6:
362 _, dev6 = gw6
363 addr6_fams = netifaces.ifaddresses(dev6)
364 if addr6_fams:
365 af_inet6 = addr6_fams.get(netifaces.AF_INET6)
366 if af_inet6:
367 if len(af_inet6) > 1:
368 LOG.warn(
369 "device %s has more than one ipv6 address: %s", dev6, af_inet6)
370 elif 'addr' in af_inet6[0]:
371 ipv6 = af_inet6[0]['addr']
akutz0b519f72019-06-02 14:58:57 -0500372
373 # If there is a default IPv4 address but not IPv6, then see if there is a
374 # single IPv6 address associated with the same device associated with the
375 # default IPv4 address.
376 if ipv4 and not ipv6:
377 af_inet6 = addr4_fams.get(netifaces.AF_INET6)
akutzffc4dd52019-06-02 11:34:55 -0500378 if af_inet6:
379 if len(af_inet6) > 1:
380 LOG.warn(
381 "device %s has more than one ipv6 address: %s", dev4, af_inet6)
382 elif 'addr' in af_inet6[0]:
383 ipv6 = af_inet6[0]['addr']
384
akutz0b519f72019-06-02 14:58:57 -0500385 # If there is a default IPv6 address but not IPv4, then see if there is a
386 # single IPv4 address associated with the same device associated with the
387 # default IPv6 address.
388 if not ipv4 and ipv6:
389 af_inet4 = addr6_fams.get(netifaces.AF_INET4)
390 if af_inet4:
391 if len(af_inet4) > 1:
392 LOG.warn(
393 "device %s has more than one ipv4 address: %s", dev6, af_inet4)
394 elif 'addr' in af_inet4[0]:
395 ipv4 = af_inet4[0]['addr']
396
akutzffc4dd52019-06-02 11:34:55 -0500397 return ipv4, ipv6
398
Keerthana Kf0d27ea2019-09-05 21:46:31 +0530399# patched socket.getfqdn() - see https://bugs.python.org/issue5004
400def getfqdn(name=''):
401 """Get fully qualified domain name from name.
402 An empty argument is interpreted as meaning the local host.
403 """
404 name = name.strip()
405 if not name or name == '0.0.0.0':
406 name = socket.gethostname()
407 try:
408 addrs = socket.getaddrinfo(name, None, 0, socket.SOCK_DGRAM, 0, socket.AI_CANONNAME)
409 except socket.error:
410 pass
411 else:
412 for addr in addrs:
413 if addr[3]:
414 name = addr[3]
415 break
416 return name
akutzffc4dd52019-06-02 11:34:55 -0500417
akutz0d1fce52019-06-01 18:54:29 -0500418def get_host_info():
419 '''
420 Returns host information such as the host name and network interfaces.
421 '''
akutz0d1fce52019-06-01 18:54:29 -0500422
423 host_info = {
424 'network': {
425 'interfaces': {
426 'by-mac': collections.OrderedDict(),
akutzffc4dd52019-06-02 11:34:55 -0500427 'by-ipv4': collections.OrderedDict(),
428 'by-ipv6': collections.OrderedDict(),
akutz0d1fce52019-06-01 18:54:29 -0500429 },
430 },
431 }
432
Keerthana Kf0d27ea2019-09-05 21:46:31 +0530433 hostname = getfqdn(socket.gethostname())
akutz0d1fce52019-06-01 18:54:29 -0500434 if hostname:
akutzffc4dd52019-06-02 11:34:55 -0500435 host_info['hostname'] = hostname
akutz0d1fce52019-06-01 18:54:29 -0500436 host_info['local-hostname'] = hostname
437
akutzffc4dd52019-06-02 11:34:55 -0500438 default_ipv4, default_ipv6 = get_default_ip_addrs()
439 if default_ipv4:
440 host_info['local-ipv4'] = default_ipv4
441 if default_ipv6:
442 host_info['local-ipv6'] = default_ipv6
443
akutz0d1fce52019-06-01 18:54:29 -0500444 by_mac = host_info['network']['interfaces']['by-mac']
akutzffc4dd52019-06-02 11:34:55 -0500445 by_ipv4 = host_info['network']['interfaces']['by-ipv4']
446 by_ipv6 = host_info['network']['interfaces']['by-ipv6']
akutz0d1fce52019-06-01 18:54:29 -0500447
448 ifaces = netifaces.interfaces()
449 for dev_name in ifaces:
450 addr_fams = netifaces.ifaddresses(dev_name)
451 af_link = addr_fams.get(netifaces.AF_LINK)
akutz0b519f72019-06-02 14:58:57 -0500452 af_inet4 = addr_fams.get(netifaces.AF_INET)
akutz0d1fce52019-06-01 18:54:29 -0500453 af_inet6 = addr_fams.get(netifaces.AF_INET6)
454
455 mac = None
456 if af_link and 'addr' in af_link[0]:
457 mac = af_link[0]['addr']
458
459 # Do not bother recording localhost
460 if mac == "00:00:00:00:00:00":
461 continue
462
akutz0b519f72019-06-02 14:58:57 -0500463 if mac and (af_inet4 or af_inet6):
akutz0d1fce52019-06-01 18:54:29 -0500464 key = mac
465 val = {}
akutz0b519f72019-06-02 14:58:57 -0500466 if af_inet4:
467 val["ipv4"] = af_inet4
akutz0d1fce52019-06-01 18:54:29 -0500468 if af_inet6:
akutzffc4dd52019-06-02 11:34:55 -0500469 val["ipv6"] = af_inet6
akutz0d1fce52019-06-01 18:54:29 -0500470 by_mac[key] = val
471
akutz0b519f72019-06-02 14:58:57 -0500472 if af_inet4:
473 for ip_info in af_inet4:
akutz0d1fce52019-06-01 18:54:29 -0500474 key = ip_info['addr']
akutzffc4dd52019-06-02 11:34:55 -0500475 if key == '127.0.0.1':
476 continue
akutz84389c82019-06-02 19:24:38 -0500477 val = copy.deepcopy(ip_info)
akutz0d1fce52019-06-01 18:54:29 -0500478 del val['addr']
479 if mac:
480 val['mac'] = mac
akutzffc4dd52019-06-02 11:34:55 -0500481 by_ipv4[key] = val
akutz0d1fce52019-06-01 18:54:29 -0500482
483 if af_inet6:
484 for ip_info in af_inet6:
485 key = ip_info['addr']
akutzffc4dd52019-06-02 11:34:55 -0500486 if key == '::1':
487 continue
akutz84389c82019-06-02 19:24:38 -0500488 val = copy.deepcopy(ip_info)
akutz0d1fce52019-06-01 18:54:29 -0500489 del val['addr']
490 if mac:
491 val['mac'] = mac
akutzffc4dd52019-06-02 11:34:55 -0500492 by_ipv6[key] = val
akutz0d1fce52019-06-01 18:54:29 -0500493
494 return host_info
495
496
akutzffc4dd52019-06-02 11:34:55 -0500497def main():
498 '''
499 Executed when this file is used as a program.
500 '''
501 metadata = {'network': {'config': {'dhcp': True}}}
502 host_info = get_host_info()
503 metadata = always_merger.merge(metadata, host_info)
504 print(util.json_dumps(metadata))
505
506
akutz0d1fce52019-06-01 18:54:29 -0500507if __name__ == "__main__":
akutzffc4dd52019-06-02 11:34:55 -0500508 main()
akutz0d1fce52019-06-01 18:54:29 -0500509
510# vi: ts=4 expandtab