1 # Copyright (C) 2013 Canonical Ltd.
2 # Copyright (c) 2018, Joyent, Inc.
3 #
4 # Author: Ben Howard <ben.howard@canonical.com>
5 #
6 # This file is part of cloud-init. See LICENSE file for license information.
7
8 # Datasource for provisioning on SmartOS. This works on Joyent
9 # and public/private Clouds using SmartOS.
10 #
11 # SmartOS hosts use a serial console (/dev/ttyS1) on KVM Linux Guests
12 # The meta-data is transmitted via key/value pairs made by
13 # requests on the console. For example, to get the hostname, you
14 # would send "GET hostname" on /dev/ttyS1.
15 # For Linux Guests running in LX-Brand Zones on SmartOS hosts
16 # a socket (/native/.zonecontrol/metadata.sock) is used instead
17 # of a serial console.
18 #
19 # Certain behavior is defined by the DataDictionary
20 # http://us-east.manta.joyent.com/jmc/public/mdata/datadict.html
21 # Comments with "@datadictionary" are snippets of the definition
22
23 import base64
24 import binascii
25 import errno
26 import fcntl
27 import json
28 import os
29 import random
30 import re
31 import socket
32 import time
33
34 from cloudinit import log as logging
35 from cloudinit import serial
36 from cloudinit import sources
37 from cloudinit import util
38
39 LOG = logging.getLogger(__name__)
40
41 SMARTOS_ATTRIB_MAP = {
42 # Cloud-init Key : (SmartOS Key, Strip line endings)
43 'instance-id': ('sdc:uuid', True),
44 'local-hostname': ('hostname', True),
45 'public-keys': ('root_authorized_keys', True),
46 'user-script': ('user-script', False),
47 'legacy-user-data': ('user-data', False),
48 'user-data': ('cloud-init:user-data', False),
49 'iptables_disable': ('iptables_disable', True),
50 'motd_sys_info': ('motd_sys_info', True),
51 'availability_zone': ('sdc:datacenter_name', True),
52 'vendor-data': ('sdc:vendor-data', False),
53 'operator-script': ('sdc:operator-script', False),
54 'hostname': ('sdc:hostname', True),
55 'dns_domain': ('sdc:dns_domain', True),
56 }
57
58 SMARTOS_ATTRIB_JSON = {
59 # Cloud-init Key : (SmartOS Key known JSON)
60 'network-data': 'sdc:nics',
61 'dns_servers': 'sdc:resolvers',
62 'routes': 'sdc:routes',
63 }
64
65 SMARTOS_ENV_LX_BRAND = "lx-brand"
66 SMARTOS_ENV_KVM = "kvm"
67
68 DS_NAME = 'SmartOS'
69 DS_CFG_PATH = ['datasource', DS_NAME]
70 NO_BASE64_DECODE = [
71 'iptables_disable',
72 'motd_sys_info',
73 'root_authorized_keys',
74 'sdc:datacenter_name',
75 'sdc:uuid'
76 'user-data',
77 'user-script',
78 ]
79
80 METADATA_SOCKFILE = '/native/.zonecontrol/metadata.sock'
81 SERIAL_DEVICE = '/dev/ttyS1'
82 SERIAL_TIMEOUT = 60
83
84 # BUILT-IN DATASOURCE CONFIGURATION
85 # The following is the built-in configuration. If the values
86 # are not set via the system configuration, then these default
87 # will be used:
88 # serial_device: which serial device to use for the meta-data
89 # serial_timeout: how long to wait on the device
90 # no_base64_decode: values which are not base64 encoded and
91 # are fetched directly from SmartOS, not meta-data values
92 # base64_keys: meta-data keys that are delivered in base64
93 # base64_all: with the exclusion of no_base64_decode values,
94 # treat all meta-data as base64 encoded
95 # disk_setup: describes how to partition the ephemeral drive
96 # fs_setup: describes how to format the ephemeral drive
97 #
98 BUILTIN_DS_CONFIG = {
99 'serial_device': SERIAL_DEVICE,
100 'serial_timeout': SERIAL_TIMEOUT,
101 'metadata_sockfile': METADATA_SOCKFILE,
102 'no_base64_decode': NO_BASE64_DECODE,
103 'base64_keys': [],
104 'base64_all': False,
105 'disk_aliases': {'ephemeral0': '/dev/vdb'},
106 }
107
108 BUILTIN_CLOUD_CONFIG = {
109 'disk_setup': {
110 'ephemeral0': {'table_type': 'mbr',
111 'layout': False,
112 'overwrite': False}
113 },
114 'fs_setup': [{'label': 'ephemeral0',
115 'filesystem': 'ext3',
116 'device': 'ephemeral0'}],
117 }
118
119 # builtin vendor-data is a boothook that writes a script into
120 # /var/lib/cloud/scripts/per-boot. *That* script then handles
121 # executing the 'operator-script' and 'user-script' files
122 # that cloud-init writes into /var/lib/cloud/instance/data/
123 # if they exist.
124 #
125 # This is all very indirect, but its done like this so that at
126 # some point in the future, perhaps cloud-init wouldn't do it at
127 # all, but rather the vendor actually provide vendor-data that accomplished
128 # their desires. (That is the point of vendor-data).
129 #
130 # cloud-init does cheat a bit, and write the operator-script and user-script
131 # itself. It could have the vendor-script do that, but it seems better
132 # to not require the image to contain a tool (mdata-get) to read those
133 # keys when we have a perfectly good one inside cloud-init.
134 BUILTIN_VENDOR_DATA = """\
135 #cloud-boothook
136 #!/bin/sh
137 fname="%(per_boot_d)s/01_smartos_vendor_data.sh"
138 mkdir -p "${fname%%/*}"
139 cat > "$fname" <<"END_SCRIPT"
140 #!/bin/sh
141 ##
142 # This file is written as part of the default vendor data for SmartOS.
143 # The SmartOS datasource writes the listed file from the listed metadata key
144 # sdc:operator-script -> %(operator_script)s
145 # user-script -> %(user_script)s
146 #
147 # You can view content with 'mdata-get <key>'
148 #
149 for script in "%(operator_script)s" "%(user_script)s"; do
150 [ -x "$script" ] || continue
151 echo "executing '$script'" 1>&2
152 "$script"
153 done
154 END_SCRIPT
155 chmod +x "$fname"
156 """
157
158
159 # @datadictionary: this is legacy path for placing files from metadata
160 # per the SmartOS location. It is not preferable, but is done for
161 # legacy reasons
162 LEGACY_USER_D = "/var/db"
163
164
165 class DataSourceSmartOS(sources.DataSource):
166
167 dsname = "Joyent"
168
169 _unset = "_unset"
170 smartos_type = _unset
171 md_client = _unset
172
173 def __init__(self, sys_cfg, distro, paths):
174 sources.DataSource.__init__(self, sys_cfg, distro, paths)
175 self.ds_cfg = util.mergemanydict([
176 self.ds_cfg,
177 util.get_cfg_by_path(sys_cfg, DS_CFG_PATH, {}),
178 BUILTIN_DS_CONFIG])
179
180 self.metadata = {}
181 self.network_data = None
182 self._network_config = None
183
184 self.script_base_d = os.path.join(self.paths.get_cpath("scripts"))
185
186 self._init()
187
188 def __str__(self):
189 root = sources.DataSource.__str__(self)
190 return "%s [client=%s]" % (root, self.md_client)
191
192 def _init(self):
193 if self.smartos_type == self._unset:
194 self.smartos_type = get_smartos_environ()
195 if self.smartos_type is None:
196 self.md_client = None
197
198 if self.md_client == self._unset:
199 self.md_client = jmc_client_factory(
200 smartos_type=self.smartos_type,
201 metadata_sockfile=self.ds_cfg['metadata_sockfile'],
202 serial_device=self.ds_cfg['serial_device'],
203 serial_timeout=self.ds_cfg['serial_timeout'])
204
205 def _set_provisioned(self):
206 '''Mark the instance provisioning state as successful.
207
208 When run in a zone, the host OS will look for /var/svc/provisioning
209 to be renamed as /var/svc/provision_success. This should be done
210 after meta-data is successfully retrieved and from this point
211 the host considers the provision of the zone to be a success and
212 keeps the zone running.
213 '''
214
215 LOG.debug('Instance provisioning state set as successful')
216 svc_path = '/var/svc'
217 if os.path.exists('/'.join([svc_path, 'provisioning'])):
218 os.rename('/'.join([svc_path, 'provisioning']),
219 '/'.join([svc_path, 'provision_success']))
220
221 def _get_data(self):
222 self._init()
223
224 md = {}
225 ud = ""
226
227 if not self.smartos_type:
228 LOG.debug("Not running on smartos")
229 return False
230
231 if not self.md_client.exists():
232 LOG.debug("No metadata device '%r' found for SmartOS datasource",
233 self.md_client)
234 return False
235
236 # Open once for many requests, rather than once for each request
237 self.md_client.open_transport()
238
239 for ci_noun, attribute in SMARTOS_ATTRIB_MAP.items():
240 smartos_noun, strip = attribute
241 md[ci_noun] = self.md_client.get(smartos_noun, strip=strip)
242
243 for ci_noun, smartos_noun in SMARTOS_ATTRIB_JSON.items():
244 md[ci_noun] = self.md_client.get_json(smartos_noun)
245
246 self.md_client.close_transport()
247
248 # @datadictionary: This key may contain a program that is written
249 # to a file in the filesystem of the guest on each boot and then
250 # executed. It may be of any format that would be considered
251 # executable in the guest instance.
252 #
253 # We write 'user-script' and 'operator-script' into the
254 # instance/data directory. The default vendor-data then handles
255 # executing them later.
256 data_d = os.path.join(self.paths.get_cpath(), 'instances',
257 md['instance-id'], 'data')
258 user_script = os.path.join(data_d, 'user-script')
259 u_script_l = "%s/user-script" % LEGACY_USER_D
260 write_boot_content(md.get('user-script'), content_f=user_script,
261 link=u_script_l, shebang=True, mode=0o700)
262
263 operator_script = os.path.join(data_d, 'operator-script')
264 write_boot_content(md.get('operator-script'),
265 content_f=operator_script, shebang=False,
266 mode=0o700)
267
268 # @datadictionary: This key has no defined format, but its value
269 # is written to the file /var/db/mdata-user-data on each boot prior
270 # to the phase that runs user-script. This file is not to be executed.
271 # This allows a configuration file of some kind to be injected into
272 # the machine to be consumed by the user-script when it runs.
273 u_data = md.get('legacy-user-data')
274 u_data_f = "%s/mdata-user-data" % LEGACY_USER_D
275 write_boot_content(u_data, u_data_f)
276
277 # Handle the cloud-init regular meta
278 if not md['local-hostname']:
279 md['local-hostname'] = md['instance-id']
280
281 ud = None
282 if md['user-data']:
283 ud = md['user-data']
284
285 if not md['vendor-data']:
286 md['vendor-data'] = BUILTIN_VENDOR_DATA % {
287 'user_script': user_script,
288 'operator_script': operator_script,
289 'per_boot_d': os.path.join(self.paths.get_cpath("scripts"),
290 'per-boot'),
291 }
292
293 self.metadata = util.mergemanydict([md, self.metadata])
294 self.userdata_raw = ud
295 self.vendordata_raw = md['vendor-data']
296 self.network_data = md['network-data']
297
298 self._set_provisioned()
299 return True
300
301 def device_name_to_device(self, name):
302 return self.ds_cfg['disk_aliases'].get(name)
303
304 def get_config_obj(self):
305 if self.smartos_type == SMARTOS_ENV_KVM:
306 return BUILTIN_CLOUD_CONFIG
307 return {}
308
309 def get_instance_id(self):
310 return self.metadata['instance-id']
311
312 @property
313 def network_config(self):
314 if self._network_config is None:
315 if self.network_data is not None:
316 self._network_config = (
317 convert_smartos_network_data(
318 network_data=self.network_data,
319 dns_servers=self.metadata['dns_servers'],
320 dns_domain=self.metadata['dns_domain']))
321 return self._network_config
322
323
324 class JoyentMetadataFetchException(Exception):
325 pass
326
327
328 class JoyentMetadataTimeoutException(JoyentMetadataFetchException):
329 pass
330
331
332 class JoyentMetadataClient(object):
333 """
334 A client implementing v2 of the Joyent Metadata Protocol Specification.
335
336 The full specification can be found at
337 http://eng.joyent.com/mdata/protocol.html
338 """
339 line_regex = re.compile(
340 r'V2 (?P<length>\d+) (?P<checksum>[0-9a-f]+)'
341 r' (?P<body>(?P<request_id>[0-9a-f]+) (?P<status>SUCCESS|NOTFOUND)'
342 r'( (?P<payload>.+))?)')
343
344 def __init__(self, smartos_type=None, fp=None):
345 if smartos_type is None:
346 smartos_type = get_smartos_environ()
347 self.smartos_type = smartos_type
348 self.fp = fp
349
350 def _checksum(self, body):
351 return '{0:08x}'.format(
352 binascii.crc32(body.encode('utf-8')) & 0xffffffff)
353
354 def _get_value_from_frame(self, expected_request_id, frame):
355 match = self.line_regex.match(frame)
356 if match is None:
357 raise JoyentMetadataFetchException(
358 'No regex match for frame "%s"' % frame)
359 frame_data = match.groupdict()
360 if int(frame_data['length']) != len(frame_data['body']):
361 raise JoyentMetadataFetchException(
362 'Incorrect frame length given ({0} != {1}).'.format(
363 frame_data['length'], len(frame_data['body'])))
364 expected_checksum = self._checksum(frame_data['body'])
365 if frame_data['checksum'] != expected_checksum:
366 raise JoyentMetadataFetchException(
367 'Invalid checksum (expected: {0}; got {1}).'.format(
368 expected_checksum, frame_data['checksum']))
369 if frame_data['request_id'] != expected_request_id:
370 raise JoyentMetadataFetchException(
371 'Request ID mismatch (expected: {0}; got {1}).'.format(
372 expected_request_id, frame_data['request_id']))
373 if not frame_data.get('payload', None):
374 LOG.debug('No value found.')
375 return None
376 value = util.b64d(frame_data['payload'])
377 LOG.debug('Value "%s" found.', value)
378 return value
379
380 def _readline(self):
381 """
382 Reads a line a byte at a time until \n is encountered. Returns an
383 ascii string with the trailing newline removed.
384
385 If a timeout (per-byte) is set and it expires, a
386 JoyentMetadataFetchException will be thrown.
387 """
388 response = bytearray()
389 while True:
390 try:
391 byte = self.fp.read(1)
392 if byte == b'\n':
393 return response.decode('ascii')
394 if byte == b'':
395 raise JoyentMetadataTimeoutException(
396 "Partial response: '%s'" % response.decode('ascii'))
397 response.extend(byte)
398 except OSError as exc:
399 if exc.errno == errno.EAGAIN:
400 raise JoyentMetadataTimeoutException(
401 "Partial response: '%s'" % response.decode('ascii'))
402 raise
403
404 def _write(self, msg):
405 self.fp.write(msg.encode('ascii'))
406 self.fp.flush()
407
408 def _negotiate(self):
409 LOG.debug('Negotiating protocol V2')
410 self._write('NEGOTIATE V2\n')
411 self.fp.flush()
412 response = self._readline()
413 if response != 'V2_OK':
414 raise JoyentMetadataFetchException(
415 'Invalid response "%s" to "NEGOTIATE V2"' % response)
416 LOG.debug('Negotiation complete')
417
418 def request(self, rtype, param=None):
419 request_id = '{0:08x}'.format(random.randint(0, 0xffffffff))
420 message_body = ' '.join((request_id, rtype,))
421 if param:
422 message_body += ' ' + base64.b64encode(param.encode()).decode()
423 msg = 'V2 {0} {1} {2}\n'.format(
424 len(message_body), self._checksum(message_body), message_body)
425 LOG.debug('Writing "%s" to metadata transport.', msg)
426
427 need_close = False
428 if not self.fp:
429 self.open_transport()
430 need_close = True
431
432 self._write(msg)
433
434 response = self._readline()
435 if need_close:
436 self.close_transport()
437
438 LOG.debug('Read "%s" from metadata transport.', response)
439
440 if 'SUCCESS' not in response:
441 return None
442
443 value = self._get_value_from_frame(request_id, response)
444 return value
445
446 def get(self, key, default=None, strip=False):
447 # Do a couple tries in case someone else has the serial port open
448 # before this process opened it. This also helps in the event that
449 # the metadata server goes away in middle of a conversation.
450 for tries in [1, 2]:
451 try:
452 result = self.request(rtype='GET', param=key)
453 if result is None:
454 return default
455 if result and strip:
456 result = result.strip()
457 return result
458 except JoyentMetadataFetchException as exc:
459 LOG.warning('Try %d: GET "%s" failed: %s', tries, key, exc)
460 last_exc = exc
461 pass
462 raise(last_exc)
463
464 def get_json(self, key, default=None):
465 result = self.get(key, default=default)
466 if result is None:
467 return default
468 return json.loads(result)
469
470 def list(self):
471 result = self.request(rtype='KEYS')
472 if not result:
473 return []
474 return result.split('\n')
475
476 def put(self, key, val):
477 param = b' '.join([base64.b64encode(i.encode())
478 for i in (key, val)]).decode()
479 return self.request(rtype='PUT', param=param)
480
481 def delete(self, key):
482 return self.request(rtype='DELETE', param=key)
483
484 def close_transport(self):
485 if self.fp:
486 self.fp.close()
487 self.fp = None
488
489 def __enter__(self):
490 if self.fp:
491 return self
492 self.open_transport()
493 return self
494
495 def __exit__(self, exc_type, exc_value, traceback):
496 self.close_transport()
497 return
498
499 def open_transport(self):
500 raise NotImplementedError
501
502
503 class JoyentMetadataSocketClient(JoyentMetadataClient):
504 def __init__(self, socketpath, smartos_type=SMARTOS_ENV_LX_BRAND):
505 super(JoyentMetadataSocketClient, self).__init__(smartos_type)
506 self.socketpath = socketpath
507
508 def open_transport(self):
509 sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
510 sock.connect(self.socketpath)
511 self.fp = sock.makefile('rwb')
512 self._negotiate()
513
514 def exists(self):
515 return os.path.exists(self.socketpath)
516
517 def __repr__(self):
518 return "%s(socketpath=%s)" % (self.__class__.__name__, self.socketpath)
519
520
521 class JoyentMetadataSerialClient(JoyentMetadataClient):
522 def __init__(self, device, timeout=10, smartos_type=SMARTOS_ENV_KVM):
523 super(JoyentMetadataSerialClient, self).__init__(smartos_type)
524 self.device = device
525 self.timeout = timeout
526
527 def exists(self):
528 return os.path.exists(self.device)
529
530 def open_transport(self):
531 ser = serial.Serial(self.device, timeout=self.timeout)
532 if not ser.isOpen():
533 # There is some sort of a race between cloud-init and mdata-get
534 # that can cause the serial device to not open on the initial
535 # attempt.
536 for tries in range(1, 11):
537 try:
538 ser.open()
539 assert(ser.isOpen())
540 break
541 except OSError as exc:
542 # This is probably a SerialException, which is a subclass
543 # of OSError. SerialException is not used becasue of
544 # existing efforts to make pyserial optional.
545 LOG.debug("Failed to open %s on try %d: %s", self.device,
546 tries, exc)
547 time.sleep(0.1)
548 else:
549 raise SystemError("Unable to open %s" % self.device)
550 fcntl.lockf(ser, fcntl.LOCK_EX)
551 self.fp = ser
552 self._flush()
553 self._negotiate()
554
555 def bad_readline(self):
556 response = self.fp.read_until('\n').decode('ascii')
557 if response[-1:] != '\n':
558 raise JoyentMetadataTimeoutException(
559 "Partial response: b'%s'" % response)
560 return(response[0:-1])
561
562 def _flush(self):
563 LOG.debug('Flushing input')
564 # Read any pending data
565 timeout = self.fp.timeout
566 self.fp.timeout = 0.1
567 while True:
568 try:
569 self._readline()
570 except JoyentMetadataTimeoutException:
571 break
572 LOG.debug('Input empty')
573
574 # Send a newline and expect "invalid command". Keep trying until
575 # successful. Retry rather frequently so that the "Is the host
576 # metadata service running" appears on the console soon after someone
577 # attaches in an effort to debug.
578 if timeout > 5:
579 self.fp.timeout = 5
580 else:
581 self.fp.timeout = timeout
582 while True:
583 LOG.debug('Writing newline, expecting "invalid command"')
584 self._write('\n')
585 try:
586 response = self._readline()
587 if response == 'invalid command':
588 break
589 if response == 'FAILURE':
590 LOG.debug('Got "FAILURE". Retrying.');
591 continue
592 LOG.warning('Unexpected response "%s" during flush', response)
593 except JoyentMetadataTimeoutException:
594 LOG.warning('Timeout while initializing metadata client. ' +
595 'Is the host metadata service running?')
596 LOG.debug('Got "invalid command". Flush complete.')
597 self.fp.timeout = timeout
598
599 def __repr__(self):
600 return "%s(device=%s, timeout=%s)" % (
601 self.__class__.__name__, self.device, self.timeout)
602
603
604 class JoyentMetadataLegacySerialClient(JoyentMetadataSerialClient):
605 """V1 of the protocol was not safe for all values.
606 Thus, we allowed the user to pass values in as base64 encoded.
607 Users may still reasonably expect to be able to send base64 data
608 and have it transparently decoded. So even though the V2 format is
609 now used, and is safe (using base64 itself), we keep legacy support.
610
611 The way for a user to do this was:
612 a.) specify 'base64_keys' key whose value is a comma delimited
613 list of keys that were base64 encoded.
614 b.) base64_all: string interpreted as a boolean that indicates
615 if all keys are base64 encoded.
616 c.) set a key named b64-<keyname> with a boolean indicating that
617 <keyname> is base64 encoded."""
618
619 def __init__(self, device, timeout=10, smartos_type=None):
620 s = super(JoyentMetadataLegacySerialClient, self)
621 s.__init__(device, timeout, smartos_type)
622 self.base64_keys = None
623 self.base64_all = None
624
625 def _init_base64_keys(self, reset=False):
626 if reset:
627 self.base64_keys = None
628 self.base64_all = None
629
630 keys = None
631 if self.base64_all is None:
632 keys = self.list()
633 if 'base64_all' in keys:
634 self.base64_all = util.is_true(self._get("base64_all"))
635 else:
636 self.base64_all = False
637
638 if self.base64_all:
639 # short circuit if base64_all is true
640 return
641
642 if self.base64_keys is None:
643 if keys is None:
644 keys = self.list()
645 b64_keys = set()
646 if 'base64_keys' in keys:
647 b64_keys = set(self._get("base64_keys").split(","))
648
649 # now add any b64-<keyname> that has a true value
650 for key in [k[3:] for k in keys if k.startswith("b64-")]:
651 if util.is_true(self._get(key)):
652 b64_keys.add(key)
653 else:
654 if key in b64_keys:
655 b64_keys.remove(key)
656
657 self.base64_keys = b64_keys
658
659 def _get(self, key, default=None, strip=False):
660 return (super(JoyentMetadataLegacySerialClient, self).
661 get(key, default=default, strip=strip))
662
663 def is_b64_encoded(self, key, reset=False):
664 if key in NO_BASE64_DECODE:
665 return False
666
667 self._init_base64_keys(reset=reset)
668 if self.base64_all:
669 return True
670
671 return key in self.base64_keys
672
673 def get(self, key, default=None, strip=False):
674 mdefault = object()
675 val = self._get(key, strip=False, default=mdefault)
676 if val is mdefault:
677 return default
678
679 if self.is_b64_encoded(key):
680 try:
681 val = base64.b64decode(val.encode()).decode()
682 # Bogus input produces different errors in Python 2 and 3
683 except (TypeError, binascii.Error):
684 LOG.warning("Failed base64 decoding key '%s': %s", key, val)
685
686 if strip:
687 val = val.strip()
688
689 return val
690
691
692 def jmc_client_factory(
693 smartos_type=None, metadata_sockfile=METADATA_SOCKFILE,
694 serial_device=SERIAL_DEVICE, serial_timeout=SERIAL_TIMEOUT,
695 uname_version=None):
696
697 if smartos_type is None:
698 smartos_type = get_smartos_environ(uname_version)
699
700 if smartos_type is None:
701 return None
702 elif smartos_type == SMARTOS_ENV_KVM:
703 return JoyentMetadataLegacySerialClient(
704 device=serial_device, timeout=serial_timeout,
705 smartos_type=smartos_type)
706 elif smartos_type == SMARTOS_ENV_LX_BRAND:
707 return JoyentMetadataSocketClient(socketpath=metadata_sockfile,
708 smartos_type=smartos_type)
709
710 raise ValueError("Unknown value for smartos_type: %s" % smartos_type)
711
712
713 def write_boot_content(content, content_f, link=None, shebang=False,
714 mode=0o400):
715 """
716 Write the content to content_f. Under the following rules:
717 1. If no content, remove the file
718 2. Write the content
719 3. If executable and no file magic, add it
720 4. If there is a link, create it
721
722 @param content: what to write
723 @param content_f: the file name
724 @param backup_d: the directory to save the backup at
725 @param link: if defined, location to create a symlink to
726 @param shebang: if no file magic, set shebang
727 @param mode: file mode
728
729 Becuase of the way that Cloud-init executes scripts (no shell),
730 a script will fail to execute if does not have a magic bit (shebang) set
731 for the file. If shebang=True, then the script will be checked for a magic
732 bit and to the SmartOS default of assuming that bash.
733 """
734
735 if not content and os.path.exists(content_f):
736 os.unlink(content_f)
737 if link and os.path.islink(link):
738 os.unlink(link)
739 if not content:
740 return
741
742 util.write_file(content_f, content, mode=mode)
743
744 if shebang and not content.startswith("#!"):
745 try:
746 cmd = ["file", "--brief", "--mime-type", content_f]
747 (f_type, _err) = util.subp(cmd)
748 LOG.debug("script %s mime type is %s", content_f, f_type)
749 if f_type.strip() == "text/plain":
750 new_content = "\n".join(["#!/bin/bash", content])
751 util.write_file(content_f, new_content, mode=mode)
752 LOG.debug("added shebang to file %s", content_f)
753
754 except Exception as e:
755 util.logexc(LOG, ("Failed to identify script type for %s" %
756 content_f, e))
757
758 if link:
759 try:
760 if os.path.islink(link):
761 os.unlink(link)
762 if content and os.path.exists(content_f):
763 util.ensure_dir(os.path.dirname(link))
764 os.symlink(content_f, link)
765 except IOError as e:
766 util.logexc(LOG, "failed establishing content link: %s", e)
767
768
769 def get_smartos_environ(uname_version=None, product_name=None):
770 uname = os.uname()
771
772 # SDC LX-Brand Zones lack dmidecode (no /dev/mem) but
773 # report 'BrandZ virtual linux' as the kernel version
774 if uname_version is None:
775 uname_version = uname[3]
776 if uname_version.lower() == 'brandz virtual linux':
777 return SMARTOS_ENV_LX_BRAND
778
779 if product_name is None:
780 system_type = util.read_dmi_data("system-product-name")
781 else:
782 system_type = product_name
783
784 if system_type and 'smartdc' in system_type.lower():
785 return SMARTOS_ENV_KVM
786
787 return None
788
789
790 # Convert SMARTOS 'sdc:nics' data to network_config yaml
791 def convert_smartos_network_data(network_data=None,
792 dns_servers=None, dns_domain=None):
793 """Return a dictionary of network_config by parsing provided
794 SMARTOS sdc:nics configuration data
795
796 sdc:nics data is a dictionary of properties of a nic and the ip
797 configuration desired. Additional nic dictionaries are appended
798 to the list.
799
800 Converting the format is straightforward though it does include
801 duplicate information as well as data which appears to be relevant
802 to the hostOS rather than the guest.
803
804 For each entry in the nics list returned from query sdc:nics, we
805 create a type: physical entry, and extract the interface properties:
806 'mac' -> 'mac_address', 'mtu', 'interface' -> 'name'. The remaining
807 keys are related to ip configuration. For each ip in the 'ips' list
808 we create a subnet entry under 'subnets' pairing the ip to a one in
809 the 'gateways' list.
810 """
811
812 valid_keys = {
813 'physical': [
814 'mac_address',
815 'mtu',
816 'name',
817 'params',
818 'subnets',
819 'type',
820 ],
821 'subnet': [
822 'address',
823 'broadcast',
824 'dns_nameservers',
825 'dns_search',
826 'metric',
827 'pointopoint',
828 'routes',
829 'scope',
830 'type',
831 ],
832 }
833
834 if dns_servers:
835 if not isinstance(dns_servers, (list, tuple)):
836 dns_servers = [dns_servers]
837 else:
838 dns_servers = []
839
840 if dns_domain:
841 if not isinstance(dns_domain, (list, tuple)):
842 dns_domain = [dns_domain]
843 else:
844 dns_domain = []
845
846 def is_valid_ipv4(addr):
847 return '.' in addr
848
849 def is_valid_ipv6(addr):
850 return ':' in addr
851
852 pgws = {
853 'ipv4': {'match': is_valid_ipv4, 'gw': None},
854 'ipv6': {'match': is_valid_ipv6, 'gw': None},
855 }
856
857 config = []
858 for nic in network_data:
859 cfg = dict((k, v) for k, v in nic.items()
860 if k in valid_keys['physical'])
861 cfg.update({
862 'type': 'physical',
863 'name': nic['interface']})
864 if 'mac' in nic:
865 cfg.update({'mac_address': nic['mac']})
866
867 subnets = []
868 for ip in nic.get('ips', []):
869 if ip == "dhcp":
870 subnet = {'type': 'dhcp4'}
871 else:
872 subnet = dict((k, v) for k, v in nic.items()
873 if k in valid_keys['subnet'])
874 subnet.update({
875 'type': 'static',
876 'address': ip,
877 })
878
879 proto = 'ipv4' if is_valid_ipv4(ip) else 'ipv6'
880 # Only use gateways for 'primary' nics
881 if 'primary' in nic and nic.get('primary', False):
882 # the ips and gateways list may be N to M, here
883 # we map the ip index into the gateways list,
884 # and handle the case that we could have more ips
885 # than gateways. we only consume the first gateway
886 if not pgws[proto]['gw']:
887 gateways = [gw for gw in nic.get('gateways', [])
888 if pgws[proto]['match'](gw)]
889 if len(gateways):
890 pgws[proto]['gw'] = gateways[0]
891 subnet.update({'gateway': pgws[proto]['gw']})
892
893 subnets.append(subnet)
894 cfg.update({'subnets': subnets})
895 config.append(cfg)
896
897 if dns_servers:
898 config.append(
899 {'type': 'nameserver', 'address': dns_servers,
900 'search': dns_domain})
901
902 return {'version': 1, 'config': config}
903
904
905 # Used to match classes to dependencies
906 datasources = [
907 (DataSourceSmartOS, (sources.DEP_FILESYSTEM, )),
908 ]
909
910
911 # Return a list of data sources that match this set of dependencies
912 def get_datasource_list(depends):
913 return sources.list_from_depends(depends, datasources)
914
915
916 if __name__ == "__main__":
917 import sys
918 jmc = jmc_client_factory()
919 if jmc is None:
920 print("Do not appear to be on smartos.")
921 sys.exit(1)
922 if len(sys.argv) == 1:
923 keys = (list(SMARTOS_ATTRIB_JSON.keys()) +
924 list(SMARTOS_ATTRIB_MAP.keys()) + ['network_config'])
925 else:
926 keys = sys.argv[1:]
927
928 def load_key(client, key, data):
929 if key in data:
930 return data[key]
931
932 if key in SMARTOS_ATTRIB_JSON:
933 keyname = SMARTOS_ATTRIB_JSON[key]
934 data[key] = client.get_json(keyname)
935 elif key == "network_config":
936 for depkey in ('network-data', 'dns_servers', 'dns_domain'):
937 load_key(client, depkey, data)
938 data[key] = convert_smartos_network_data(
939 network_data=data['network-data'],
940 dns_servers=data['dns_servers'],
941 dns_domain=data['dns_domain'])
942 else:
943 if key in SMARTOS_ATTRIB_MAP:
944 keyname, strip = SMARTOS_ATTRIB_MAP[key]
945 else:
946 keyname, strip = (key, False)
947 data[key] = client.get(keyname, strip=strip)
948
949 return data[key]
950
951 data = {}
952 for key in keys:
953 load_key(client=jmc, key=key, data=data)
954
955 print(json.dumps(data, indent=1, sort_keys=True,
956 separators=(',', ': ')))
957
958 # vi: ts=4 expandtab