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 self.routes_data = md['routes']
298
299 self._set_provisioned()
300 return True
301
302 def device_name_to_device(self, name):
303 return self.ds_cfg['disk_aliases'].get(name)
304
305 def get_config_obj(self):
306 if self.smartos_type == SMARTOS_ENV_KVM:
307 return BUILTIN_CLOUD_CONFIG
308 return {}
309
310 def get_instance_id(self):
311 return self.metadata['instance-id']
312
313 @property
314 def network_config(self):
315 if self._network_config is None:
316 if self.network_data is not None:
317 self._network_config = (
318 convert_smartos_network_data(
319 network_data=self.network_data,
320 dns_servers=self.metadata['dns_servers'],
321 dns_domain=self.metadata['dns_domain'],
322 routes=self.routes_data))
323 return self._network_config
324
325
326 class JoyentMetadataFetchException(Exception):
327 pass
328
329
330 class JoyentMetadataTimeoutException(JoyentMetadataFetchException):
331 pass
332
333
334 class JoyentMetadataClient(object):
335 """
336 A client implementing v2 of the Joyent Metadata Protocol Specification.
337
338 The full specification can be found at
339 http://eng.joyent.com/mdata/protocol.html
340 """
341 line_regex = re.compile(
342 r'V2 (?P<length>\d+) (?P<checksum>[0-9a-f]+)'
343 r' (?P<body>(?P<request_id>[0-9a-f]+) (?P<status>SUCCESS|NOTFOUND)'
344 r'( (?P<payload>.+))?)')
345
346 def __init__(self, smartos_type=None, fp=None):
347 if smartos_type is None:
348 smartos_type = get_smartos_environ()
349 self.smartos_type = smartos_type
350 self.fp = fp
351
352 def _checksum(self, body):
353 return '{0:08x}'.format(
354 binascii.crc32(body.encode('utf-8')) & 0xffffffff)
355
356 def _get_value_from_frame(self, expected_request_id, frame):
357 match = self.line_regex.match(frame)
358 if match is None:
359 raise JoyentMetadataFetchException(
360 'No regex match for frame "%s"' % frame)
361 frame_data = match.groupdict()
362 if int(frame_data['length']) != len(frame_data['body']):
363 raise JoyentMetadataFetchException(
364 'Incorrect frame length given ({0} != {1}).'.format(
365 frame_data['length'], len(frame_data['body'])))
366 expected_checksum = self._checksum(frame_data['body'])
367 if frame_data['checksum'] != expected_checksum:
368 raise JoyentMetadataFetchException(
369 'Invalid checksum (expected: {0}; got {1}).'.format(
370 expected_checksum, frame_data['checksum']))
371 if frame_data['request_id'] != expected_request_id:
372 raise JoyentMetadataFetchException(
373 'Request ID mismatch (expected: {0}; got {1}).'.format(
374 expected_request_id, frame_data['request_id']))
375 if not frame_data.get('payload', None):
376 LOG.debug('No value found.')
377 return None
378 value = util.b64d(frame_data['payload'])
379 LOG.debug('Value "%s" found.', value)
380 return value
381
382 def _readline(self):
383 """
384 Reads a line a byte at a time until \n is encountered. Returns an
385 ascii string with the trailing newline removed.
386
387 If a timeout (per-byte) is set and it expires, a
388 JoyentMetadataFetchException will be thrown.
389 """
390 response = bytearray()
391 while True:
392 try:
393 byte = self.fp.read(1)
394 if byte == b'\n':
395 return response.decode('ascii')
396 if byte == b'':
397 raise JoyentMetadataTimeoutException(
398 "Partial response: '%s'" % response.decode('ascii'))
399 response.extend(byte)
400 except OSError as exc:
401 if exc.errno == errno.EAGAIN:
402 raise JoyentMetadataTimeoutException(
403 "Partial response: '%s'" % response.decode('ascii'))
404 raise
405
406 def _write(self, msg):
407 self.fp.write(msg.encode('ascii'))
408 self.fp.flush()
409
410 def _negotiate(self):
411 LOG.debug('Negotiating protocol V2')
412 self._write('NEGOTIATE V2\n')
413 self.fp.flush()
414 response = self._readline()
415 if response != 'V2_OK':
416 raise JoyentMetadataFetchException(
417 'Invalid response "%s" to "NEGOTIATE V2"' % response)
418 LOG.debug('Negotiation complete')
419
420 def request(self, rtype, param=None):
421 request_id = '{0:08x}'.format(random.randint(0, 0xffffffff))
422 message_body = ' '.join((request_id, rtype,))
423 if param:
424 message_body += ' ' + base64.b64encode(param.encode()).decode()
425 msg = 'V2 {0} {1} {2}\n'.format(
426 len(message_body), self._checksum(message_body), message_body)
427 LOG.debug('Writing "%s" to metadata transport.', msg)
428
429 need_close = False
430 if not self.fp:
431 self.open_transport()
432 need_close = True
433
434 self._write(msg)
435
436 response = self._readline()
437 if need_close:
438 self.close_transport()
439
440 LOG.debug('Read "%s" from metadata transport.', response)
441
442 if 'SUCCESS' not in response:
443 return None
444
445 value = self._get_value_from_frame(request_id, response)
446 return value
447
448 def get(self, key, default=None, strip=False):
449 # Do a couple tries in case someone else has the serial port open
450 # before this process opened it. This also helps in the event that
451 # the metadata server goes away in middle of a conversation.
452 for tries in [1, 2]:
453 try:
454 result = self.request(rtype='GET', param=key)
455 if result is None:
456 return default
457 if result and strip:
458 result = result.strip()
459 return result
460 except JoyentMetadataFetchException as exc:
461 LOG.warning('Try %d: GET "%s" failed: %s', tries, key, exc)
462 last_exc = exc
463 pass
464 raise(last_exc)
465
466 def get_json(self, key, default=None):
467 result = self.get(key, default=default)
468 if result is None:
469 return default
470 return json.loads(result)
471
472 def list(self):
473 result = self.request(rtype='KEYS')
474 if not result:
475 return []
476 return result.split('\n')
477
478 def put(self, key, val):
479 param = b' '.join([base64.b64encode(i.encode())
480 for i in (key, val)]).decode()
481 return self.request(rtype='PUT', param=param)
482
483 def delete(self, key):
484 return self.request(rtype='DELETE', param=key)
485
486 def close_transport(self):
487 if self.fp:
488 self.fp.close()
489 self.fp = None
490
491 def __enter__(self):
492 if self.fp:
493 return self
494 self.open_transport()
495 return self
496
497 def __exit__(self, exc_type, exc_value, traceback):
498 self.close_transport()
499 return
500
501 def open_transport(self):
502 raise NotImplementedError
503
504
505 class JoyentMetadataSocketClient(JoyentMetadataClient):
506 def __init__(self, socketpath, smartos_type=SMARTOS_ENV_LX_BRAND):
507 super(JoyentMetadataSocketClient, self).__init__(smartos_type)
508 self.socketpath = socketpath
509
510 def open_transport(self):
511 sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
512 sock.connect(self.socketpath)
513 self.fp = sock.makefile('rwb')
514 self._negotiate()
515
516 def exists(self):
517 return os.path.exists(self.socketpath)
518
519 def __repr__(self):
520 return "%s(socketpath=%s)" % (self.__class__.__name__, self.socketpath)
521
522
523 class JoyentMetadataSerialClient(JoyentMetadataClient):
524 def __init__(self, device, timeout=10, smartos_type=SMARTOS_ENV_KVM):
525 super(JoyentMetadataSerialClient, self).__init__(smartos_type)
526 self.device = device
527 self.timeout = timeout
528
529 def exists(self):
530 return os.path.exists(self.device)
531
532 def open_transport(self):
533 ser = serial.Serial(self.device, timeout=self.timeout)
534 if not ser.isOpen():
535 # There is some sort of a race between cloud-init and mdata-get
536 # that can cause the serial device to not open on the initial
537 # attempt.
538 for tries in range(1, 11):
539 try:
540 ser.open()
541 assert(ser.isOpen())
542 break
543 except OSError as exc:
544 # This is probably a SerialException, which is a subclass
545 # of OSError. SerialException is not used becasue of
546 # existing efforts to make pyserial optional.
547 LOG.debug("Failed to open %s on try %d: %s", self.device,
548 tries, exc)
549 time.sleep(0.1)
550 else:
551 raise SystemError("Unable to open %s" % self.device)
552 fcntl.lockf(ser, fcntl.LOCK_EX)
553 self.fp = ser
554 self._flush()
555 self._negotiate()
556
557 def bad_readline(self):
558 response = self.fp.read_until('\n').decode('ascii')
559 if response[-1:] != '\n':
560 raise JoyentMetadataTimeoutException(
561 "Partial response: b'%s'" % response)
562 return(response[0:-1])
563
564 def _flush(self):
565 LOG.debug('Flushing input')
566 # Read any pending data
567 timeout = self.fp.timeout
568 self.fp.timeout = 0.1
569 while True:
570 try:
571 self._readline()
572 except JoyentMetadataTimeoutException:
573 break
574 LOG.debug('Input empty')
575
576 # Send a newline and expect "invalid command". Keep trying until
577 # successful. Retry rather frequently so that the "Is the host
578 # metadata service running" appears on the console soon after someone
579 # attaches in an effort to debug.
580 if timeout > 5:
581 self.fp.timeout = 5
582 else:
583 self.fp.timeout = timeout
584 while True:
585 LOG.debug('Writing newline, expecting "invalid command"')
586 self._write('\n')
587 try:
588 response = self._readline()
589 if response == 'invalid command':
590 break
591 if response == 'FAILURE':
592 LOG.debug('Got "FAILURE". Retrying.');
593 continue
594 LOG.warning('Unexpected response "%s" during flush', response)
595 except JoyentMetadataTimeoutException:
596 LOG.warning('Timeout while initializing metadata client. ' +
597 'Is the host metadata service running?')
598 LOG.debug('Got "invalid command". Flush complete.')
599 self.fp.timeout = timeout
600
601 def __repr__(self):
602 return "%s(device=%s, timeout=%s)" % (
603 self.__class__.__name__, self.device, self.timeout)
604
605
606 class JoyentMetadataLegacySerialClient(JoyentMetadataSerialClient):
607 """V1 of the protocol was not safe for all values.
608 Thus, we allowed the user to pass values in as base64 encoded.
609 Users may still reasonably expect to be able to send base64 data
610 and have it transparently decoded. So even though the V2 format is
611 now used, and is safe (using base64 itself), we keep legacy support.
612
613 The way for a user to do this was:
614 a.) specify 'base64_keys' key whose value is a comma delimited
615 list of keys that were base64 encoded.
616 b.) base64_all: string interpreted as a boolean that indicates
617 if all keys are base64 encoded.
618 c.) set a key named b64-<keyname> with a boolean indicating that
619 <keyname> is base64 encoded."""
620
621 def __init__(self, device, timeout=10, smartos_type=None):
622 s = super(JoyentMetadataLegacySerialClient, self)
623 s.__init__(device, timeout, smartos_type)
624 self.base64_keys = None
625 self.base64_all = None
626
627 def _init_base64_keys(self, reset=False):
628 if reset:
629 self.base64_keys = None
630 self.base64_all = None
631
632 keys = None
633 if self.base64_all is None:
634 keys = self.list()
635 if 'base64_all' in keys:
636 self.base64_all = util.is_true(self._get("base64_all"))
637 else:
638 self.base64_all = False
639
640 if self.base64_all:
641 # short circuit if base64_all is true
642 return
643
644 if self.base64_keys is None:
645 if keys is None:
646 keys = self.list()
647 b64_keys = set()
648 if 'base64_keys' in keys:
649 b64_keys = set(self._get("base64_keys").split(","))
650
651 # now add any b64-<keyname> that has a true value
652 for key in [k[3:] for k in keys if k.startswith("b64-")]:
653 if util.is_true(self._get(key)):
654 b64_keys.add(key)
655 else:
656 if key in b64_keys:
657 b64_keys.remove(key)
658
659 self.base64_keys = b64_keys
660
661 def _get(self, key, default=None, strip=False):
662 return (super(JoyentMetadataLegacySerialClient, self).
663 get(key, default=default, strip=strip))
664
665 def is_b64_encoded(self, key, reset=False):
666 if key in NO_BASE64_DECODE:
667 return False
668
669 self._init_base64_keys(reset=reset)
670 if self.base64_all:
671 return True
672
673 return key in self.base64_keys
674
675 def get(self, key, default=None, strip=False):
676 mdefault = object()
677 val = self._get(key, strip=False, default=mdefault)
678 if val is mdefault:
679 return default
680
681 if self.is_b64_encoded(key):
682 try:
683 val = base64.b64decode(val.encode()).decode()
684 # Bogus input produces different errors in Python 2 and 3
685 except (TypeError, binascii.Error):
686 LOG.warning("Failed base64 decoding key '%s': %s", key, val)
687
688 if strip:
689 val = val.strip()
690
691 return val
692
693
694 def jmc_client_factory(
695 smartos_type=None, metadata_sockfile=METADATA_SOCKFILE,
696 serial_device=SERIAL_DEVICE, serial_timeout=SERIAL_TIMEOUT,
697 uname_version=None):
698
699 if smartos_type is None:
700 smartos_type = get_smartos_environ(uname_version)
701
702 if smartos_type is None:
703 return None
704 elif smartos_type == SMARTOS_ENV_KVM:
705 return JoyentMetadataLegacySerialClient(
706 device=serial_device, timeout=serial_timeout,
707 smartos_type=smartos_type)
708 elif smartos_type == SMARTOS_ENV_LX_BRAND:
709 return JoyentMetadataSocketClient(socketpath=metadata_sockfile,
710 smartos_type=smartos_type)
711
712 raise ValueError("Unknown value for smartos_type: %s" % smartos_type)
713
714
715 def write_boot_content(content, content_f, link=None, shebang=False,
716 mode=0o400):
717 """
718 Write the content to content_f. Under the following rules:
719 1. If no content, remove the file
720 2. Write the content
721 3. If executable and no file magic, add it
722 4. If there is a link, create it
723
724 @param content: what to write
725 @param content_f: the file name
726 @param backup_d: the directory to save the backup at
727 @param link: if defined, location to create a symlink to
728 @param shebang: if no file magic, set shebang
729 @param mode: file mode
730
731 Becuase of the way that Cloud-init executes scripts (no shell),
732 a script will fail to execute if does not have a magic bit (shebang) set
733 for the file. If shebang=True, then the script will be checked for a magic
734 bit and to the SmartOS default of assuming that bash.
735 """
736
737 if not content and os.path.exists(content_f):
738 os.unlink(content_f)
739 if link and os.path.islink(link):
740 os.unlink(link)
741 if not content:
742 return
743
744 util.write_file(content_f, content, mode=mode)
745
746 if shebang and not content.startswith("#!"):
747 try:
748 cmd = ["file", "--brief", "--mime-type", content_f]
749 (f_type, _err) = util.subp(cmd)
750 LOG.debug("script %s mime type is %s", content_f, f_type)
751 if f_type.strip() == "text/plain":
752 new_content = "\n".join(["#!/bin/bash", content])
753 util.write_file(content_f, new_content, mode=mode)
754 LOG.debug("added shebang to file %s", content_f)
755
756 except Exception as e:
757 util.logexc(LOG, ("Failed to identify script type for %s" %
758 content_f, e))
759
760 if link:
761 try:
762 if os.path.islink(link):
763 os.unlink(link)
764 if content and os.path.exists(content_f):
765 util.ensure_dir(os.path.dirname(link))
766 os.symlink(content_f, link)
767 except IOError as e:
768 util.logexc(LOG, "failed establishing content link: %s", e)
769
770
771 def get_smartos_environ(uname_version=None, product_name=None):
772 uname = os.uname()
773
774 # SDC LX-Brand Zones lack dmidecode (no /dev/mem) but
775 # report 'BrandZ virtual linux' as the kernel version
776 if uname_version is None:
777 uname_version = uname[3]
778 if uname_version.lower() == 'brandz virtual linux':
779 return SMARTOS_ENV_LX_BRAND
780
781 if product_name is None:
782 system_type = util.read_dmi_data("system-product-name")
783 else:
784 system_type = product_name
785
786 if system_type and 'smartdc' in system_type.lower():
787 return SMARTOS_ENV_KVM
788
789 return None
790
791
792 # Convert SMARTOS 'sdc:nics' data to network_config yaml
793 def convert_smartos_network_data(network_data=None,
794 dns_servers=None, dns_domain=None,
795 routes=None):
796 """Return a dictionary of network_config by parsing provided
797 SMARTOS sdc:nics configuration data
798
799 sdc:nics data is a dictionary of properties of a nic and the ip
800 configuration desired. Additional nic dictionaries are appended
801 to the list.
802
803 Converting the format is straightforward though it does include
804 duplicate information as well as data which appears to be relevant
805 to the hostOS rather than the guest.
806
807 For each entry in the nics list returned from query sdc:nics, we
808 create a type: physical entry, and extract the interface properties:
809 'mac' -> 'mac_address', 'mtu', 'interface' -> 'name'. The remaining
810 keys are related to ip configuration. For each ip in the 'ips' list
811 we create a subnet entry under 'subnets' pairing the ip to a one in
812 the 'gateways' list.
813 """
814
815 valid_keys = {
816 'physical': [
817 'mac_address',
818 'mtu',
819 'name',
820 'params',
821 'subnets',
822 'type',
823 ],
824 'subnet': [
825 'address',
826 'broadcast',
827 'dns_nameservers',
828 'dns_search',
829 'metric',
830 'pointopoint',
831 'routes',
832 'scope',
833 'type',
834 ],
835 'route': [
836 'destination',
837 'gateway',
838 ],
839 }
840
841 if dns_servers:
842 if not isinstance(dns_servers, (list, tuple)):
843 dns_servers = [dns_servers]
844 else:
845 dns_servers = []
846
847 if dns_domain:
848 if not isinstance(dns_domain, (list, tuple)):
849 dns_domain = [dns_domain]
850 else:
851 dns_domain = []
852
853 def is_valid_ipv4(addr):
854 return '.' in addr
855
856 def is_valid_ipv6(addr):
857 return ':' in addr
858
859 pgws = {
860 'ipv4': {'match': is_valid_ipv4, 'gw': None},
861 'ipv6': {'match': is_valid_ipv6, 'gw': None},
862 }
863
864 config = []
865 for nic in network_data:
866 cfg = dict((k, v) for k, v in nic.items()
867 if k in valid_keys['physical'])
868 cfg.update({
869 'type': 'physical',
870 'name': nic['interface']})
871 if 'mac' in nic:
872 cfg.update({'mac_address': nic['mac']})
873
874 subnets = []
875 for ip in nic.get('ips', []):
876 if ip == "dhcp":
877 subnet = {'type': 'dhcp4'}
878 else:
879 subnet = dict((k, v) for k, v in nic.items()
880 if k in valid_keys['subnet'])
881 subnet.update({
882 'type': 'static',
883 'address': ip,
884 })
885
886 proto = 'ipv4' if is_valid_ipv4(ip) else 'ipv6'
887 # Only use gateways for 'primary' nics
888 if 'primary' in nic and nic.get('primary', False):
889 # the ips and gateways list may be N to M, here
890 # we map the ip index into the gateways list,
891 # and handle the case that we could have more ips
892 # than gateways. we only consume the first gateway
893 if not pgws[proto]['gw']:
894 gateways = [gw for gw in nic.get('gateways', [])
895 if pgws[proto]['match'](gw)]
896 if len(gateways):
897 pgws[proto]['gw'] = gateways[0]
898 subnet.update({'gateway': pgws[proto]['gw']})
899
900 subnets.append(subnet)
901 cfg.update({'subnets': subnets})
902 config.append(cfg)
903
904 if routes:
905 for route in routes:
906 cfg = dict((k, v) for k, v in route.items()
907 if k in valid_keys['route'])
908 # Linux uses the value of 'gateway' to determine automatically if
909 # the route is a forward/next-hop (non-local IP for gateway) or an
910 # interface/resolver (local IP for gateway). So we can ignore
911 # the 'interface' attribute of sdc:routes, because SDC guarantees
912 # that the gateway is a local IP for "interface=true".
913 cfg.update({
914 'type': 'route',
915 'destination': route['destination'],
916 'gateway': route['gateway']})
917 config.append(cfg)
918
919 if dns_servers:
920 config.append(
921 {'type': 'nameserver', 'address': dns_servers,
922 'search': dns_domain})
923
924 return {'version': 1, 'config': config}
925
926
927 # Used to match classes to dependencies
928 datasources = [
929 (DataSourceSmartOS, (sources.DEP_FILESYSTEM, )),
930 ]
931
932
933 # Return a list of data sources that match this set of dependencies
934 def get_datasource_list(depends):
935 return sources.list_from_depends(depends, datasources)
936
937
938 if __name__ == "__main__":
939 import sys
940 jmc = jmc_client_factory()
941 if jmc is None:
942 print("Do not appear to be on smartos.")
943 sys.exit(1)
944 if len(sys.argv) == 1:
945 keys = (list(SMARTOS_ATTRIB_JSON.keys()) +
946 list(SMARTOS_ATTRIB_MAP.keys()) + ['network_config'])
947 else:
948 keys = sys.argv[1:]
949
950 def load_key(client, key, data):
951 if key in data:
952 return data[key]
953
954 if key in SMARTOS_ATTRIB_JSON:
955 keyname = SMARTOS_ATTRIB_JSON[key]
956 data[key] = client.get_json(keyname)
957 elif key == "network_config":
958 for depkey in ('network-data', 'dns_servers', 'dns_domain', 'routes'):
959 load_key(client, depkey, data)
960 data[key] = convert_smartos_network_data(
961 network_data=data['network-data'],
962 dns_servers=data['dns_servers'],
963 dns_domain=data['dns_domain'],
964 routes=data['routes'])
965 else:
966 if key in SMARTOS_ATTRIB_MAP:
967 keyname, strip = SMARTOS_ATTRIB_MAP[key]
968 else:
969 keyname, strip = (key, False)
970 data[key] = client.get(keyname, strip=strip)
971
972 return data[key]
973
974 data = {}
975 for key in keys:
976 load_key(client=jmc, key=key, data=data)
977
978 print(json.dumps(data, indent=1, sort_keys=True,
979 separators=(',', ': ')))
980
981 # vi: ts=4 expandtab