blob: 5c4a4bb4995a99c35d2fcc59604d7bec4649162d [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
akutz77457a62018-08-22 16:07:21 -0500119 return True
120
akutz0d1fce52019-06-01 18:54:29 -0500121 def setup(self, is_new_instance):
122 """setup(is_new_instance)
123
124 This is called before user-data and vendor-data have been processed.
125
126 Unless the datasource has set mode to 'local', then networking
127 per 'fallback' or per 'network_config' will have been written and
128 brought up the OS at this point.
129 """
130
akutzffc4dd52019-06-02 11:34:55 -0500131 # Get information about the host.
akutz0d1fce52019-06-01 18:54:29 -0500132 host_info = get_host_info()
133 LOG.info("got host-info: %s", host_info)
akutzffc4dd52019-06-02 11:34:55 -0500134
135 # Ensure the metadata gets updated with information about the
136 # host, including the network interfaces, default IP addresses,
137 # etc.
138 self.metadata = always_merger.merge(self.metadata, host_info)
akutz0d1fce52019-06-01 18:54:29 -0500139
140 # Persist the instance data for versions of cloud-init that support
141 # doing so. This occurs here rather than in the get_data call in
142 # order to ensure that the network interfaces are up and can be
143 # persisted with the metadata.
144 try:
145 self.persist_instance_data()
146 except AttributeError:
147 pass
148
akutz6501f902018-08-24 12:19:05 -0500149 @property
150 def network_config(self):
akutz0d1fce52019-06-01 18:54:29 -0500151 if 'network' in self.metadata:
152 LOG.debug("using metadata network config")
153 else:
154 LOG.debug("using fallback network config")
155 self.metadata['network'] = {
156 'config': self.distro.generate_fallback_config(),
157 }
158 return self.metadata['network']['config']
akutz77457a62018-08-22 16:07:21 -0500159
160 def get_instance_id(self):
akutz6501f902018-08-24 12:19:05 -0500161 # Pull the instance ID out of the metadata if present. Otherwise
162 # read the file /sys/class/dmi/id/product_uuid for the instance ID.
163 if self.metadata and 'instance-id' in self.metadata:
164 return self.metadata['instance-id']
akutz77457a62018-08-22 16:07:21 -0500165 with open('/sys/class/dmi/id/product_uuid', 'r') as id_file:
akutz0d1fce52019-06-01 18:54:29 -0500166 self.metadata['instance-id'] = str(id_file.read()).rstrip()
167 return self.metadata['instance-id']
akutz77457a62018-08-22 16:07:21 -0500168
akutz6501f902018-08-24 12:19:05 -0500169
akutz0d1fce52019-06-01 18:54:29 -0500170def decode(key, enc_type, data):
171 '''
172 decode returns the decoded string value of data
173 key is a string used to identify the data being decoded in log messages
174 ----
175 In py 2.7:
176 json.loads method takes string as input
177 zlib.decompress takes and returns a string
178 base64.b64decode takes and returns a string
179 -----
180 In py 3.6 and newer:
181 json.loads method takes bytes or string as input
182 zlib.decompress takes and returns a bytes
183 base64.b64decode takes bytes or string and returns bytes
184 -----
185 In py > 3, < 3.6:
186 json.loads method takes string as input
187 zlib.decompress takes and returns a bytes
188 base64.b64decode takes bytes or string and returns bytes
189 -----
190 Given the above conditions the output from zlib.decompress and
191 base64.b64decode would be bytes with newer python and str in older
192 version. Thus we would covert the output to str before returning
193 '''
194 LOG.debug("Getting encoded data for key=%s, enc=%s", key, enc_type)
akutz6501f902018-08-24 12:19:05 -0500195
akutz0d1fce52019-06-01 18:54:29 -0500196 raw_data = None
197 if enc_type == "gzip+base64" or enc_type == "gz+b64":
198 LOG.debug("Decoding %s format %s", enc_type, key)
199 raw_data = zlib.decompress(base64.b64decode(data), zlib.MAX_WBITS | 16)
200 elif enc_type == "base64" or enc_type == "b64":
201 LOG.debug("Decoding %s format %s", enc_type, key)
202 raw_data = base64.b64decode(data)
203 else:
204 LOG.debug("Plain-text data %s", key)
205 raw_data = data
Sidharth Surana3a421682018-10-10 15:42:08 -0700206
akutz0d1fce52019-06-01 18:54:29 -0500207 if isinstance(raw_data, bytes):
208 return raw_data.decode('utf-8')
209 return raw_data
210
211
212def get_guestinfo_value(key):
213 '''
214 Returns a guestinfo value for the specified key.
215 '''
216 LOG.debug("Getting guestinfo value for key %s", key)
217 try:
218 (stdout, stderr) = util.subp(
219 [VMTOOLSD, "--cmd", "info-get guestinfo." + key])
220 if stderr == NOVAL:
221 LOG.debug("No value found for key %s", key)
222 elif not stdout:
223 LOG.error("Failed to get guestinfo value for key %s", key)
akutz6501f902018-08-24 12:19:05 -0500224 else:
akutz0d1fce52019-06-01 18:54:29 -0500225 return stdout.rstrip()
226 except util.ProcessExecutionError as error:
227 if error.stderr == NOVAL:
228 LOG.debug("No value found for key %s", key)
229 else:
230 util.logexc(
231 LOG, "Failed to get guestinfo value for key %s: %s", key, error)
232 except Exception:
233 util.logexc(
234 LOG, "Unexpected error while trying to get guestinfo value for key %s", key)
235 return None
akutz6501f902018-08-24 12:19:05 -0500236
akutz0d1fce52019-06-01 18:54:29 -0500237
238def guestinfo(key):
239 '''
240 guestinfo returns the guestinfo value for the provided key, decoding
241 the value when required
242 '''
243 data = get_guestinfo_value(key)
244 if not data:
akutz6501f902018-08-24 12:19:05 -0500245 return None
akutz0d1fce52019-06-01 18:54:29 -0500246 enc_type = get_guestinfo_value(key + '.encoding')
247 return decode('guestinfo.' + key, enc_type, data)
248
249
250def load(data):
251 '''
252 load first attempts to unmarshal the provided data as JSON, and if
253 that fails then attempts to unmarshal the data as YAML. If data is
254 None then a new dictionary is returned.
255 '''
256 if not data:
257 return {}
258 try:
259 return json.loads(data)
260 except:
261 return safeyaml.load(data)
262
263
264def load_metadata():
265 '''
266 load_metadata loads the metadata from the guestinfo data, optionally
267 decoding the network config when required
268 '''
269 data = load(guestinfo('metadata'))
akutz84389c82019-06-02 19:24:38 -0500270 LOG.debug('loaded metadata %s', data)
akutz0d1fce52019-06-01 18:54:29 -0500271
272 network = None
273 if 'network' in data:
274 network = data['network']
275 del data['network']
276
277 network_enc = None
278 if 'network.encoding' in data:
279 network_enc = data['network.encoding']
280 del data['network.encoding']
281
282 if network:
akutz84389c82019-06-02 19:24:38 -0500283 LOG.debug('network data found')
284 if isinstance(network, collections.Mapping):
285 LOG.debug("network data copied to 'config' key")
286 network = {
287 'config': copy.deepcopy(network)
288 }
289 else:
290 LOG.debug("network data to be decoded %s", network)
akutz0d1fce52019-06-01 18:54:29 -0500291 dec_net = decode('metadata.network', network_enc, network)
akutz84389c82019-06-02 19:24:38 -0500292 network = {
293 'config': load(dec_net),
294 }
295
296 LOG.debug('network data %s', network)
akutz0d1fce52019-06-01 18:54:29 -0500297 data['network'] = network
298
299 return data
300
akutz6501f902018-08-24 12:19:05 -0500301
akutz77457a62018-08-22 16:07:21 -0500302def get_datasource_list(depends):
akutz0d1fce52019-06-01 18:54:29 -0500303 '''
akutz77457a62018-08-22 16:07:21 -0500304 Return a list of data sources that match this set of dependencies
akutz0d1fce52019-06-01 18:54:29 -0500305 '''
Andrew Kutz4f66b8b2018-09-16 18:28:59 -0500306 return [DataSourceVMwareGuestInfo]
akutz0d1fce52019-06-01 18:54:29 -0500307
308
akutzffc4dd52019-06-02 11:34:55 -0500309def get_default_ip_addrs():
310 '''
311 Returns the default IPv4 and IPv6 addresses based on the device(s) used for
312 the default route. Please note that None may be returned for either address
313 family if that family has no default route or if there are multiple
314 addresses associated with the device used by the default route for a given
315 address.
316 '''
317 gateways = netifaces.gateways()
318 if 'default' not in gateways:
319 return None, None
320
321 default_gw = gateways['default']
322 if netifaces.AF_INET not in default_gw and netifaces.AF_INET6 not in default_gw:
323 return None, None
324
325 ipv4 = None
326 ipv6 = None
327
328 gw4 = default_gw.get(netifaces.AF_INET)
329 if gw4:
330 _, dev4 = gw4
akutz0b519f72019-06-02 14:58:57 -0500331 addr4_fams = netifaces.ifaddresses(dev4)
332 if addr4_fams:
333 af_inet4 = addr4_fams.get(netifaces.AF_INET)
334 if af_inet4:
335 if len(af_inet4) > 1:
akutzffc4dd52019-06-02 11:34:55 -0500336 LOG.warn(
akutz0b519f72019-06-02 14:58:57 -0500337 "device %s has more than one ipv4 address: %s", dev4, af_inet4)
338 elif 'addr' in af_inet4[0]:
339 ipv4 = af_inet4[0]['addr']
akutzffc4dd52019-06-02 11:34:55 -0500340
341 # Try to get the default IPv6 address by first seeing if there is a default
akutz0b519f72019-06-02 14:58:57 -0500342 # IPv6 route.
akutzffc4dd52019-06-02 11:34:55 -0500343 gw6 = default_gw.get(netifaces.AF_INET6)
344 if gw6:
345 _, dev6 = gw6
346 addr6_fams = netifaces.ifaddresses(dev6)
347 if addr6_fams:
348 af_inet6 = addr6_fams.get(netifaces.AF_INET6)
349 if af_inet6:
350 if len(af_inet6) > 1:
351 LOG.warn(
352 "device %s has more than one ipv6 address: %s", dev6, af_inet6)
353 elif 'addr' in af_inet6[0]:
354 ipv6 = af_inet6[0]['addr']
akutz0b519f72019-06-02 14:58:57 -0500355
356 # If there is a default IPv4 address but not IPv6, then see if there is a
357 # single IPv6 address associated with the same device associated with the
358 # default IPv4 address.
359 if ipv4 and not ipv6:
360 af_inet6 = addr4_fams.get(netifaces.AF_INET6)
akutzffc4dd52019-06-02 11:34:55 -0500361 if af_inet6:
362 if len(af_inet6) > 1:
363 LOG.warn(
364 "device %s has more than one ipv6 address: %s", dev4, af_inet6)
365 elif 'addr' in af_inet6[0]:
366 ipv6 = af_inet6[0]['addr']
367
akutz0b519f72019-06-02 14:58:57 -0500368 # If there is a default IPv6 address but not IPv4, then see if there is a
369 # single IPv4 address associated with the same device associated with the
370 # default IPv6 address.
371 if not ipv4 and ipv6:
372 af_inet4 = addr6_fams.get(netifaces.AF_INET4)
373 if af_inet4:
374 if len(af_inet4) > 1:
375 LOG.warn(
376 "device %s has more than one ipv4 address: %s", dev6, af_inet4)
377 elif 'addr' in af_inet4[0]:
378 ipv4 = af_inet4[0]['addr']
379
akutzffc4dd52019-06-02 11:34:55 -0500380 return ipv4, ipv6
381
382
akutz0d1fce52019-06-01 18:54:29 -0500383def get_host_info():
384 '''
385 Returns host information such as the host name and network interfaces.
386 '''
akutz0d1fce52019-06-01 18:54:29 -0500387
388 host_info = {
389 'network': {
390 'interfaces': {
391 'by-mac': collections.OrderedDict(),
akutzffc4dd52019-06-02 11:34:55 -0500392 'by-ipv4': collections.OrderedDict(),
393 'by-ipv6': collections.OrderedDict(),
akutz0d1fce52019-06-01 18:54:29 -0500394 },
395 },
396 }
397
398 hostname = socket.getfqdn()
399 if hostname:
akutzffc4dd52019-06-02 11:34:55 -0500400 host_info['hostname'] = hostname
akutz0d1fce52019-06-01 18:54:29 -0500401 host_info['local-hostname'] = hostname
402
akutzffc4dd52019-06-02 11:34:55 -0500403 default_ipv4, default_ipv6 = get_default_ip_addrs()
404 if default_ipv4:
405 host_info['local-ipv4'] = default_ipv4
406 if default_ipv6:
407 host_info['local-ipv6'] = default_ipv6
408
akutz0d1fce52019-06-01 18:54:29 -0500409 by_mac = host_info['network']['interfaces']['by-mac']
akutzffc4dd52019-06-02 11:34:55 -0500410 by_ipv4 = host_info['network']['interfaces']['by-ipv4']
411 by_ipv6 = host_info['network']['interfaces']['by-ipv6']
akutz0d1fce52019-06-01 18:54:29 -0500412
413 ifaces = netifaces.interfaces()
414 for dev_name in ifaces:
415 addr_fams = netifaces.ifaddresses(dev_name)
416 af_link = addr_fams.get(netifaces.AF_LINK)
akutz0b519f72019-06-02 14:58:57 -0500417 af_inet4 = addr_fams.get(netifaces.AF_INET)
akutz0d1fce52019-06-01 18:54:29 -0500418 af_inet6 = addr_fams.get(netifaces.AF_INET6)
419
420 mac = None
421 if af_link and 'addr' in af_link[0]:
422 mac = af_link[0]['addr']
423
424 # Do not bother recording localhost
425 if mac == "00:00:00:00:00:00":
426 continue
427
akutz0b519f72019-06-02 14:58:57 -0500428 if mac and (af_inet4 or af_inet6):
akutz0d1fce52019-06-01 18:54:29 -0500429 key = mac
430 val = {}
akutz0b519f72019-06-02 14:58:57 -0500431 if af_inet4:
432 val["ipv4"] = af_inet4
akutz0d1fce52019-06-01 18:54:29 -0500433 if af_inet6:
akutzffc4dd52019-06-02 11:34:55 -0500434 val["ipv6"] = af_inet6
akutz0d1fce52019-06-01 18:54:29 -0500435 by_mac[key] = val
436
akutz0b519f72019-06-02 14:58:57 -0500437 if af_inet4:
438 for ip_info in af_inet4:
akutz0d1fce52019-06-01 18:54:29 -0500439 key = ip_info['addr']
akutzffc4dd52019-06-02 11:34:55 -0500440 if key == '127.0.0.1':
441 continue
akutz84389c82019-06-02 19:24:38 -0500442 val = copy.deepcopy(ip_info)
akutz0d1fce52019-06-01 18:54:29 -0500443 del val['addr']
444 if mac:
445 val['mac'] = mac
akutzffc4dd52019-06-02 11:34:55 -0500446 by_ipv4[key] = val
akutz0d1fce52019-06-01 18:54:29 -0500447
448 if af_inet6:
449 for ip_info in af_inet6:
450 key = ip_info['addr']
akutzffc4dd52019-06-02 11:34:55 -0500451 if key == '::1':
452 continue
akutz84389c82019-06-02 19:24:38 -0500453 val = copy.deepcopy(ip_info)
akutz0d1fce52019-06-01 18:54:29 -0500454 del val['addr']
455 if mac:
456 val['mac'] = mac
akutzffc4dd52019-06-02 11:34:55 -0500457 by_ipv6[key] = val
akutz0d1fce52019-06-01 18:54:29 -0500458
459 return host_info
460
461
akutzffc4dd52019-06-02 11:34:55 -0500462def main():
463 '''
464 Executed when this file is used as a program.
465 '''
466 metadata = {'network': {'config': {'dhcp': True}}}
467 host_info = get_host_info()
468 metadata = always_merger.merge(metadata, host_info)
469 print(util.json_dumps(metadata))
470
471
akutz0d1fce52019-06-01 18:54:29 -0500472if __name__ == "__main__":
akutzffc4dd52019-06-02 11:34:55 -0500473 main()
akutz0d1fce52019-06-01 18:54:29 -0500474
475# vi: ts=4 expandtab