blob: 3a3b8847c3d8b6c370cfd934b4e7308b11ae8a71 [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
21import collections
akutz77457a62018-08-22 16:07:21 -050022import base64
akutz6501f902018-08-24 12:19:05 -050023import zlib
24import json
akutz0d1fce52019-06-01 18:54:29 -050025from distutils.spawn import find_executable
akutz77457a62018-08-22 16:07:21 -050026
27from cloudinit import log as logging
28from cloudinit import sources
29from cloudinit import util
akutz6501f902018-08-24 12:19:05 -050030from cloudinit import safeyaml
akutz77457a62018-08-22 16:07:21 -050031
akutz77457a62018-08-22 16:07:21 -050032LOG = logging.getLogger(__name__)
akutz0d1fce52019-06-01 18:54:29 -050033NOVAL = "No value found"
34VMTOOLSD = find_executable("vmtoolsd")
akutz77457a62018-08-22 16:07:21 -050035
akutz0d1fce52019-06-01 18:54:29 -050036
37class NetworkConfigError(Exception):
38 '''
39 NetworkConfigError is raised when there is an issue getting or
40 applying network configuration.
41 '''
42 pass
43
44
Andrew Kutz4f66b8b2018-09-16 18:28:59 -050045class DataSourceVMwareGuestInfo(sources.DataSource):
akutz0d1fce52019-06-01 18:54:29 -050046 '''
47 This cloud-init datasource was designed for use with CentOS 7,
48 which uses cloud-init 0.7.9. However, this datasource should
49 work with any Linux distribution for which cloud-init is
50 avaialble.
51
52 The documentation for cloud-init 0.7.9's datasource is
53 available at http://bit.ly/cloudinit-datasource-0-7-9. The
54 current documentation for cloud-init is found at
55 https://cloudinit.readthedocs.io/en/latest/.
56
57 Setting the hostname:
58 The hostname is set by way of the metadata key "local-hostname".
59
60 Setting the instance ID:
61 The instance ID may be set by way of the metadata key "instance-id".
62 However, if this value is absent then then the instance ID is
63 read from the file /sys/class/dmi/id/product_uuid.
64
65 Configuring the network:
66 The network is configured by setting the metadata key "network"
67 with a value consistent with Network Config Versions 1 or 2,
68 depending on the Linux distro's version of cloud-init:
69
70 Network Config Version 1 - http://bit.ly/cloudinit-net-conf-v1
71 Network Config Version 2 - http://bit.ly/cloudinit-net-conf-v2
72
73 For example, CentOS 7's official cloud-init package is version
74 0.7.9 and does not support Network Config Version 2. However,
75 this datasource still supports supplying Network Config Version 2
76 data as long as the Linux distro's cloud-init package is new
77 enough to parse the data.
78
79 The metadata key "network.encoding" may be used to indicate the
80 format of the metadata key "network". Valid encodings are base64
81 and gzip+base64.
82 '''
83
84 dsname = 'VMwareGuestInfo'
85
akutz77457a62018-08-22 16:07:21 -050086 def __init__(self, sys_cfg, distro, paths, ud_proc=None):
87 sources.DataSource.__init__(self, sys_cfg, distro, paths, ud_proc)
akutz0d1fce52019-06-01 18:54:29 -050088 if not VMTOOLSD:
akutz77457a62018-08-22 16:07:21 -050089 LOG.error("Failed to find vmtoolsd")
90
91 def get_data(self):
akutz0d1fce52019-06-01 18:54:29 -050092 """
93 This method should really be _get_data in accordance with the most
94 recent versions of cloud-init. However, because the datasource
95 supports as far back as cloud-init 0.7.9, get_data is still used.
96
97 Because of this the method attempts to do some of the same things
98 that the get_data functions in newer versions of cloud-init do,
99 such as calling persist_instance_data.
100 """
101 if not VMTOOLSD:
akutz77457a62018-08-22 16:07:21 -0500102 LOG.error("vmtoolsd is required to fetch guestinfo value")
103 return False
akutz6501f902018-08-24 12:19:05 -0500104
akutz0d1fce52019-06-01 18:54:29 -0500105 # Get the metadata.
106 self.metadata = load_metadata()
akutz6501f902018-08-24 12:19:05 -0500107
akutz0d1fce52019-06-01 18:54:29 -0500108 # Get the user data.
109 self.userdata_raw = guestinfo('userdata')
akutz6501f902018-08-24 12:19:05 -0500110
akutz0d1fce52019-06-01 18:54:29 -0500111 # Get the vendor data.
112 self.vendordata_raw = guestinfo('vendordata')
akutz6501f902018-08-24 12:19:05 -0500113
akutz77457a62018-08-22 16:07:21 -0500114 return True
115
akutz0d1fce52019-06-01 18:54:29 -0500116 def setup(self, is_new_instance):
117 """setup(is_new_instance)
118
119 This is called before user-data and vendor-data have been processed.
120
121 Unless the datasource has set mode to 'local', then networking
122 per 'fallback' or per 'network_config' will have been written and
123 brought up the OS at this point.
124 """
125
126 # Set the hostname.
127 hostname = self.metadata.get('local-hostname')
128 if hostname:
129 self.distro.set_hostname(hostname)
130 LOG.info("set hostname %s", hostname)
131
132 # Update the metadata with the actual host name and actual network
133 # interface information.
134 host_info = get_host_info()
135 LOG.info("got host-info: %s", host_info)
136 hostname = host_info.get('local-hostname', hostname)
137 self.metadata['local-hostname'] = hostname
138 interfaces = host_info['network']['interfaces']
139 self.metadata['network']['interfaces'] = interfaces
140
141 # Persist the instance data for versions of cloud-init that support
142 # doing so. This occurs here rather than in the get_data call in
143 # order to ensure that the network interfaces are up and can be
144 # persisted with the metadata.
145 try:
146 self.persist_instance_data()
147 except AttributeError:
148 pass
149
akutz6501f902018-08-24 12:19:05 -0500150 @property
151 def network_config(self):
akutz0d1fce52019-06-01 18:54:29 -0500152 if 'network' in self.metadata:
153 LOG.debug("using metadata network config")
154 else:
155 LOG.debug("using fallback network config")
156 self.metadata['network'] = {
157 'config': self.distro.generate_fallback_config(),
158 }
159 return self.metadata['network']['config']
akutz77457a62018-08-22 16:07:21 -0500160
161 def get_instance_id(self):
akutz6501f902018-08-24 12:19:05 -0500162 # Pull the instance ID out of the metadata if present. Otherwise
163 # read the file /sys/class/dmi/id/product_uuid for the instance ID.
164 if self.metadata and 'instance-id' in self.metadata:
165 return self.metadata['instance-id']
akutz77457a62018-08-22 16:07:21 -0500166 with open('/sys/class/dmi/id/product_uuid', 'r') as id_file:
akutz0d1fce52019-06-01 18:54:29 -0500167 self.metadata['instance-id'] = str(id_file.read()).rstrip()
168 return self.metadata['instance-id']
akutz77457a62018-08-22 16:07:21 -0500169
akutz6501f902018-08-24 12:19:05 -0500170
akutz0d1fce52019-06-01 18:54:29 -0500171def decode(key, enc_type, data):
172 '''
173 decode returns the decoded string value of data
174 key is a string used to identify the data being decoded in log messages
175 ----
176 In py 2.7:
177 json.loads method takes string as input
178 zlib.decompress takes and returns a string
179 base64.b64decode takes and returns a string
180 -----
181 In py 3.6 and newer:
182 json.loads method takes bytes or string as input
183 zlib.decompress takes and returns a bytes
184 base64.b64decode takes bytes or string and returns bytes
185 -----
186 In py > 3, < 3.6:
187 json.loads method takes string as input
188 zlib.decompress takes and returns a bytes
189 base64.b64decode takes bytes or string and returns bytes
190 -----
191 Given the above conditions the output from zlib.decompress and
192 base64.b64decode would be bytes with newer python and str in older
193 version. Thus we would covert the output to str before returning
194 '''
195 LOG.debug("Getting encoded data for key=%s, enc=%s", key, enc_type)
akutz6501f902018-08-24 12:19:05 -0500196
akutz0d1fce52019-06-01 18:54:29 -0500197 raw_data = None
198 if enc_type == "gzip+base64" or enc_type == "gz+b64":
199 LOG.debug("Decoding %s format %s", enc_type, key)
200 raw_data = zlib.decompress(base64.b64decode(data), zlib.MAX_WBITS | 16)
201 elif enc_type == "base64" or enc_type == "b64":
202 LOG.debug("Decoding %s format %s", enc_type, key)
203 raw_data = base64.b64decode(data)
204 else:
205 LOG.debug("Plain-text data %s", key)
206 raw_data = data
Sidharth Surana3a421682018-10-10 15:42:08 -0700207
akutz0d1fce52019-06-01 18:54:29 -0500208 if isinstance(raw_data, bytes):
209 return raw_data.decode('utf-8')
210 return raw_data
211
212
213def get_guestinfo_value(key):
214 '''
215 Returns a guestinfo value for the specified key.
216 '''
217 LOG.debug("Getting guestinfo value for key %s", key)
218 try:
219 (stdout, stderr) = util.subp(
220 [VMTOOLSD, "--cmd", "info-get guestinfo." + key])
221 if stderr == NOVAL:
222 LOG.debug("No value found for key %s", key)
223 elif not stdout:
224 LOG.error("Failed to get guestinfo value for key %s", key)
akutz6501f902018-08-24 12:19:05 -0500225 else:
akutz0d1fce52019-06-01 18:54:29 -0500226 return stdout.rstrip()
227 except util.ProcessExecutionError as error:
228 if error.stderr == NOVAL:
229 LOG.debug("No value found for key %s", key)
230 else:
231 util.logexc(
232 LOG, "Failed to get guestinfo value for key %s: %s", key, error)
233 except Exception:
234 util.logexc(
235 LOG, "Unexpected error while trying to get guestinfo value for key %s", key)
236 return None
akutz6501f902018-08-24 12:19:05 -0500237
akutz0d1fce52019-06-01 18:54:29 -0500238
239def guestinfo(key):
240 '''
241 guestinfo returns the guestinfo value for the provided key, decoding
242 the value when required
243 '''
244 data = get_guestinfo_value(key)
245 if not data:
akutz6501f902018-08-24 12:19:05 -0500246 return None
akutz0d1fce52019-06-01 18:54:29 -0500247 enc_type = get_guestinfo_value(key + '.encoding')
248 return decode('guestinfo.' + key, enc_type, data)
249
250
251def load(data):
252 '''
253 load first attempts to unmarshal the provided data as JSON, and if
254 that fails then attempts to unmarshal the data as YAML. If data is
255 None then a new dictionary is returned.
256 '''
257 if not data:
258 return {}
259 try:
260 return json.loads(data)
261 except:
262 return safeyaml.load(data)
263
264
265def load_metadata():
266 '''
267 load_metadata loads the metadata from the guestinfo data, optionally
268 decoding the network config when required
269 '''
270 data = load(guestinfo('metadata'))
271
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:
283 if not isinstance(network, collections.Mapping):
284 LOG.debug("decoding network data: %s", network)
285 dec_net = decode('metadata.network', network_enc, network)
286 network = load(dec_net)
287 if 'config' not in network:
288 raise NetworkConfigError("missing 'config' key")
289 data['network'] = network
290
291 return data
292
akutz6501f902018-08-24 12:19:05 -0500293
akutz77457a62018-08-22 16:07:21 -0500294def get_datasource_list(depends):
akutz0d1fce52019-06-01 18:54:29 -0500295 '''
akutz77457a62018-08-22 16:07:21 -0500296 Return a list of data sources that match this set of dependencies
akutz0d1fce52019-06-01 18:54:29 -0500297 '''
Andrew Kutz4f66b8b2018-09-16 18:28:59 -0500298 return [DataSourceVMwareGuestInfo]
akutz0d1fce52019-06-01 18:54:29 -0500299
300
301def get_host_info():
302 '''
303 Returns host information such as the host name and network interfaces.
304 '''
305 import netifaces
306 import socket
307
308 host_info = {
309 'network': {
310 'interfaces': {
311 'by-mac': collections.OrderedDict(),
312 'by-ip4': collections.OrderedDict(),
313 'by-ip6': collections.OrderedDict(),
314 },
315 },
316 }
317
318 hostname = socket.getfqdn()
319 if hostname:
320 host_info['local-hostname'] = hostname
321
322 by_mac = host_info['network']['interfaces']['by-mac']
323 by_ip4 = host_info['network']['interfaces']['by-ip4']
324 by_ip6 = host_info['network']['interfaces']['by-ip6']
325
326 ifaces = netifaces.interfaces()
327 for dev_name in ifaces:
328 addr_fams = netifaces.ifaddresses(dev_name)
329 af_link = addr_fams.get(netifaces.AF_LINK)
330 af_inet = addr_fams.get(netifaces.AF_INET)
331 af_inet6 = addr_fams.get(netifaces.AF_INET6)
332
333 mac = None
334 if af_link and 'addr' in af_link[0]:
335 mac = af_link[0]['addr']
336
337 # Do not bother recording localhost
338 if mac == "00:00:00:00:00:00":
339 continue
340
341 if mac and (af_inet or af_inet6):
342 key = mac
343 val = {}
344 if af_inet:
345 val["ip4"] = af_inet
346 if af_inet6:
347 val["ip6"] = af_inet6
348 by_mac[key] = val
349
350 if af_inet:
351 for ip_info in af_inet:
352 key = ip_info['addr']
353 val = ip_info.copy()
354 del val['addr']
355 if mac:
356 val['mac'] = mac
357 by_ip4[key] = val
358
359 if af_inet6:
360 for ip_info in af_inet6:
361 key = ip_info['addr']
362 val = ip_info.copy()
363 del val['addr']
364 if mac:
365 val['mac'] = mac
366 by_ip6[key] = val
367
368 return host_info
369
370
371if __name__ == "__main__":
372 print util.json_dumps(get_host_info())
373
374# vi: ts=4 expandtab