blob: 8a1cd75d10ebbfeff152755c99a60c640765fb23 [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
akutz6501f902018-08-24 12:19:05 -0500172
akutz0d1fce52019-06-01 18:54:29 -0500173def decode(key, enc_type, data):
174 '''
175 decode returns the decoded string value of data
176 key is a string used to identify the data being decoded in log messages
177 ----
178 In py 2.7:
179 json.loads method takes string as input
180 zlib.decompress takes and returns a string
181 base64.b64decode takes and returns a string
182 -----
183 In py 3.6 and newer:
184 json.loads method takes bytes or string as input
185 zlib.decompress takes and returns a bytes
186 base64.b64decode takes bytes or string and returns bytes
187 -----
188 In py > 3, < 3.6:
189 json.loads method takes string as input
190 zlib.decompress takes and returns a bytes
191 base64.b64decode takes bytes or string and returns bytes
192 -----
193 Given the above conditions the output from zlib.decompress and
194 base64.b64decode would be bytes with newer python and str in older
195 version. Thus we would covert the output to str before returning
196 '''
197 LOG.debug("Getting encoded data for key=%s, enc=%s", key, enc_type)
akutz6501f902018-08-24 12:19:05 -0500198
akutz0d1fce52019-06-01 18:54:29 -0500199 raw_data = None
200 if enc_type == "gzip+base64" or enc_type == "gz+b64":
201 LOG.debug("Decoding %s format %s", enc_type, key)
202 raw_data = zlib.decompress(base64.b64decode(data), zlib.MAX_WBITS | 16)
203 elif enc_type == "base64" or enc_type == "b64":
204 LOG.debug("Decoding %s format %s", enc_type, key)
205 raw_data = base64.b64decode(data)
206 else:
207 LOG.debug("Plain-text data %s", key)
208 raw_data = data
Sidharth Surana3a421682018-10-10 15:42:08 -0700209
akutz0d1fce52019-06-01 18:54:29 -0500210 if isinstance(raw_data, bytes):
211 return raw_data.decode('utf-8')
212 return raw_data
213
214
215def get_guestinfo_value(key):
216 '''
217 Returns a guestinfo value for the specified key.
218 '''
219 LOG.debug("Getting guestinfo value for key %s", key)
220 try:
221 (stdout, stderr) = util.subp(
222 [VMTOOLSD, "--cmd", "info-get guestinfo." + key])
223 if stderr == NOVAL:
224 LOG.debug("No value found for key %s", key)
225 elif not stdout:
226 LOG.error("Failed to get guestinfo value for key %s", key)
akutz6501f902018-08-24 12:19:05 -0500227 else:
akutz0d1fce52019-06-01 18:54:29 -0500228 return stdout.rstrip()
229 except util.ProcessExecutionError as error:
230 if error.stderr == NOVAL:
231 LOG.debug("No value found for key %s", key)
232 else:
233 util.logexc(
234 LOG, "Failed to get guestinfo value for key %s: %s", key, error)
235 except Exception:
236 util.logexc(
237 LOG, "Unexpected error while trying to get guestinfo value for key %s", key)
238 return None
akutz6501f902018-08-24 12:19:05 -0500239
akutz0d1fce52019-06-01 18:54:29 -0500240
241def guestinfo(key):
242 '''
243 guestinfo returns the guestinfo value for the provided key, decoding
244 the value when required
245 '''
246 data = get_guestinfo_value(key)
247 if not data:
akutz6501f902018-08-24 12:19:05 -0500248 return None
akutz0d1fce52019-06-01 18:54:29 -0500249 enc_type = get_guestinfo_value(key + '.encoding')
250 return decode('guestinfo.' + key, enc_type, data)
251
252
253def load(data):
254 '''
255 load first attempts to unmarshal the provided data as JSON, and if
256 that fails then attempts to unmarshal the data as YAML. If data is
257 None then a new dictionary is returned.
258 '''
259 if not data:
260 return {}
261 try:
262 return json.loads(data)
263 except:
264 return safeyaml.load(data)
265
266
267def load_metadata():
268 '''
269 load_metadata loads the metadata from the guestinfo data, optionally
270 decoding the network config when required
271 '''
272 data = load(guestinfo('metadata'))
akutz84389c82019-06-02 19:24:38 -0500273 LOG.debug('loaded metadata %s', data)
akutz0d1fce52019-06-01 18:54:29 -0500274
275 network = None
276 if 'network' in data:
277 network = data['network']
278 del data['network']
279
280 network_enc = None
281 if 'network.encoding' in data:
282 network_enc = data['network.encoding']
283 del data['network.encoding']
284
285 if network:
akutz84389c82019-06-02 19:24:38 -0500286 LOG.debug('network data found')
287 if isinstance(network, collections.Mapping):
288 LOG.debug("network data copied to 'config' key")
289 network = {
290 'config': copy.deepcopy(network)
291 }
292 else:
293 LOG.debug("network data to be decoded %s", network)
akutz0d1fce52019-06-01 18:54:29 -0500294 dec_net = decode('metadata.network', network_enc, network)
akutz84389c82019-06-02 19:24:38 -0500295 network = {
296 'config': load(dec_net),
297 }
298
299 LOG.debug('network data %s', network)
akutz0d1fce52019-06-01 18:54:29 -0500300 data['network'] = network
301
302 return data
303
akutz6501f902018-08-24 12:19:05 -0500304
akutz77457a62018-08-22 16:07:21 -0500305def get_datasource_list(depends):
akutz0d1fce52019-06-01 18:54:29 -0500306 '''
akutz77457a62018-08-22 16:07:21 -0500307 Return a list of data sources that match this set of dependencies
akutz0d1fce52019-06-01 18:54:29 -0500308 '''
Andrew Kutz4f66b8b2018-09-16 18:28:59 -0500309 return [DataSourceVMwareGuestInfo]
akutz0d1fce52019-06-01 18:54:29 -0500310
311
akutzffc4dd52019-06-02 11:34:55 -0500312def get_default_ip_addrs():
313 '''
314 Returns the default IPv4 and IPv6 addresses based on the device(s) used for
315 the default route. Please note that None may be returned for either address
316 family if that family has no default route or if there are multiple
317 addresses associated with the device used by the default route for a given
318 address.
319 '''
320 gateways = netifaces.gateways()
321 if 'default' not in gateways:
322 return None, None
323
324 default_gw = gateways['default']
325 if netifaces.AF_INET not in default_gw and netifaces.AF_INET6 not in default_gw:
326 return None, None
327
328 ipv4 = None
329 ipv6 = None
330
331 gw4 = default_gw.get(netifaces.AF_INET)
332 if gw4:
333 _, dev4 = gw4
akutz0b519f72019-06-02 14:58:57 -0500334 addr4_fams = netifaces.ifaddresses(dev4)
335 if addr4_fams:
336 af_inet4 = addr4_fams.get(netifaces.AF_INET)
337 if af_inet4:
338 if len(af_inet4) > 1:
akutzffc4dd52019-06-02 11:34:55 -0500339 LOG.warn(
akutz0b519f72019-06-02 14:58:57 -0500340 "device %s has more than one ipv4 address: %s", dev4, af_inet4)
341 elif 'addr' in af_inet4[0]:
342 ipv4 = af_inet4[0]['addr']
akutzffc4dd52019-06-02 11:34:55 -0500343
344 # Try to get the default IPv6 address by first seeing if there is a default
akutz0b519f72019-06-02 14:58:57 -0500345 # IPv6 route.
akutzffc4dd52019-06-02 11:34:55 -0500346 gw6 = default_gw.get(netifaces.AF_INET6)
347 if gw6:
348 _, dev6 = gw6
349 addr6_fams = netifaces.ifaddresses(dev6)
350 if addr6_fams:
351 af_inet6 = addr6_fams.get(netifaces.AF_INET6)
352 if af_inet6:
353 if len(af_inet6) > 1:
354 LOG.warn(
355 "device %s has more than one ipv6 address: %s", dev6, af_inet6)
356 elif 'addr' in af_inet6[0]:
357 ipv6 = af_inet6[0]['addr']
akutz0b519f72019-06-02 14:58:57 -0500358
359 # If there is a default IPv4 address but not IPv6, then see if there is a
360 # single IPv6 address associated with the same device associated with the
361 # default IPv4 address.
362 if ipv4 and not ipv6:
363 af_inet6 = addr4_fams.get(netifaces.AF_INET6)
akutzffc4dd52019-06-02 11:34:55 -0500364 if af_inet6:
365 if len(af_inet6) > 1:
366 LOG.warn(
367 "device %s has more than one ipv6 address: %s", dev4, af_inet6)
368 elif 'addr' in af_inet6[0]:
369 ipv6 = af_inet6[0]['addr']
370
akutz0b519f72019-06-02 14:58:57 -0500371 # If there is a default IPv6 address but not IPv4, then see if there is a
372 # single IPv4 address associated with the same device associated with the
373 # default IPv6 address.
374 if not ipv4 and ipv6:
375 af_inet4 = addr6_fams.get(netifaces.AF_INET4)
376 if af_inet4:
377 if len(af_inet4) > 1:
378 LOG.warn(
379 "device %s has more than one ipv4 address: %s", dev6, af_inet4)
380 elif 'addr' in af_inet4[0]:
381 ipv4 = af_inet4[0]['addr']
382
akutzffc4dd52019-06-02 11:34:55 -0500383 return ipv4, ipv6
384
Keerthana Kf0d27ea2019-09-05 21:46:31 +0530385# patched socket.getfqdn() - see https://bugs.python.org/issue5004
386def getfqdn(name=''):
387 """Get fully qualified domain name from name.
388 An empty argument is interpreted as meaning the local host.
389 """
390 name = name.strip()
391 if not name or name == '0.0.0.0':
392 name = socket.gethostname()
393 try:
394 addrs = socket.getaddrinfo(name, None, 0, socket.SOCK_DGRAM, 0, socket.AI_CANONNAME)
395 except socket.error:
396 pass
397 else:
398 for addr in addrs:
399 if addr[3]:
400 name = addr[3]
401 break
402 return name
akutzffc4dd52019-06-02 11:34:55 -0500403
akutz0d1fce52019-06-01 18:54:29 -0500404def get_host_info():
405 '''
406 Returns host information such as the host name and network interfaces.
407 '''
akutz0d1fce52019-06-01 18:54:29 -0500408
409 host_info = {
410 'network': {
411 'interfaces': {
412 'by-mac': collections.OrderedDict(),
akutzffc4dd52019-06-02 11:34:55 -0500413 'by-ipv4': collections.OrderedDict(),
414 'by-ipv6': collections.OrderedDict(),
akutz0d1fce52019-06-01 18:54:29 -0500415 },
416 },
417 }
418
Keerthana Kf0d27ea2019-09-05 21:46:31 +0530419 hostname = getfqdn(socket.gethostname())
akutz0d1fce52019-06-01 18:54:29 -0500420 if hostname:
akutzffc4dd52019-06-02 11:34:55 -0500421 host_info['hostname'] = hostname
akutz0d1fce52019-06-01 18:54:29 -0500422 host_info['local-hostname'] = hostname
423
akutzffc4dd52019-06-02 11:34:55 -0500424 default_ipv4, default_ipv6 = get_default_ip_addrs()
425 if default_ipv4:
426 host_info['local-ipv4'] = default_ipv4
427 if default_ipv6:
428 host_info['local-ipv6'] = default_ipv6
429
akutz0d1fce52019-06-01 18:54:29 -0500430 by_mac = host_info['network']['interfaces']['by-mac']
akutzffc4dd52019-06-02 11:34:55 -0500431 by_ipv4 = host_info['network']['interfaces']['by-ipv4']
432 by_ipv6 = host_info['network']['interfaces']['by-ipv6']
akutz0d1fce52019-06-01 18:54:29 -0500433
434 ifaces = netifaces.interfaces()
435 for dev_name in ifaces:
436 addr_fams = netifaces.ifaddresses(dev_name)
437 af_link = addr_fams.get(netifaces.AF_LINK)
akutz0b519f72019-06-02 14:58:57 -0500438 af_inet4 = addr_fams.get(netifaces.AF_INET)
akutz0d1fce52019-06-01 18:54:29 -0500439 af_inet6 = addr_fams.get(netifaces.AF_INET6)
440
441 mac = None
442 if af_link and 'addr' in af_link[0]:
443 mac = af_link[0]['addr']
444
445 # Do not bother recording localhost
446 if mac == "00:00:00:00:00:00":
447 continue
448
akutz0b519f72019-06-02 14:58:57 -0500449 if mac and (af_inet4 or af_inet6):
akutz0d1fce52019-06-01 18:54:29 -0500450 key = mac
451 val = {}
akutz0b519f72019-06-02 14:58:57 -0500452 if af_inet4:
453 val["ipv4"] = af_inet4
akutz0d1fce52019-06-01 18:54:29 -0500454 if af_inet6:
akutzffc4dd52019-06-02 11:34:55 -0500455 val["ipv6"] = af_inet6
akutz0d1fce52019-06-01 18:54:29 -0500456 by_mac[key] = val
457
akutz0b519f72019-06-02 14:58:57 -0500458 if af_inet4:
459 for ip_info in af_inet4:
akutz0d1fce52019-06-01 18:54:29 -0500460 key = ip_info['addr']
akutzffc4dd52019-06-02 11:34:55 -0500461 if key == '127.0.0.1':
462 continue
akutz84389c82019-06-02 19:24:38 -0500463 val = copy.deepcopy(ip_info)
akutz0d1fce52019-06-01 18:54:29 -0500464 del val['addr']
465 if mac:
466 val['mac'] = mac
akutzffc4dd52019-06-02 11:34:55 -0500467 by_ipv4[key] = val
akutz0d1fce52019-06-01 18:54:29 -0500468
469 if af_inet6:
470 for ip_info in af_inet6:
471 key = ip_info['addr']
akutzffc4dd52019-06-02 11:34:55 -0500472 if key == '::1':
473 continue
akutz84389c82019-06-02 19:24:38 -0500474 val = copy.deepcopy(ip_info)
akutz0d1fce52019-06-01 18:54:29 -0500475 del val['addr']
476 if mac:
477 val['mac'] = mac
akutzffc4dd52019-06-02 11:34:55 -0500478 by_ipv6[key] = val
akutz0d1fce52019-06-01 18:54:29 -0500479
480 return host_info
481
482
akutzffc4dd52019-06-02 11:34:55 -0500483def main():
484 '''
485 Executed when this file is used as a program.
486 '''
487 metadata = {'network': {'config': {'dhcp': True}}}
488 host_info = get_host_info()
489 metadata = always_merger.merge(metadata, host_info)
490 print(util.json_dumps(metadata))
491
492
akutz0d1fce52019-06-01 18:54:29 -0500493if __name__ == "__main__":
akutzffc4dd52019-06-02 11:34:55 -0500494 main()
akutz0d1fce52019-06-01 18:54:29 -0500495
496# vi: ts=4 expandtab