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