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