diff options
Diffstat (limited to 'cloudinit/sources')
21 files changed, 684 insertions, 237 deletions
| diff --git a/cloudinit/sources/DataSourceAliYun.py b/cloudinit/sources/DataSourceAliYun.py index 22279d09..858e0827 100644 --- a/cloudinit/sources/DataSourceAliYun.py +++ b/cloudinit/sources/DataSourceAliYun.py @@ -45,7 +45,7 @@ def _is_aliyun():  def parse_public_keys(public_keys):      keys = [] -    for key_id, key_body in public_keys.items(): +    for _key_id, key_body in public_keys.items():          if isinstance(key_body, str):              keys.append(key_body.strip())          elif isinstance(key_body, list): diff --git a/cloudinit/sources/DataSourceAltCloud.py b/cloudinit/sources/DataSourceAltCloud.py index e1d0055b..24fd65ff 100644 --- a/cloudinit/sources/DataSourceAltCloud.py +++ b/cloudinit/sources/DataSourceAltCloud.py @@ -29,7 +29,6 @@ CLOUD_INFO_FILE = '/etc/sysconfig/cloud-info'  # Shell command lists  CMD_PROBE_FLOPPY = ['modprobe', 'floppy'] -CMD_UDEVADM_SETTLE = ['udevadm', 'settle', '--timeout=5']  META_DATA_NOT_SUPPORTED = {      'block-device-mapping': {}, @@ -185,26 +184,24 @@ class DataSourceAltCloud(sources.DataSource):              cmd = CMD_PROBE_FLOPPY              (cmd_out, _err) = util.subp(cmd)              LOG.debug('Command: %s\nOutput%s', ' '.join(cmd), cmd_out) -        except ProcessExecutionError as _err: -            util.logexc(LOG, 'Failed command: %s\n%s', ' '.join(cmd), _err) +        except ProcessExecutionError as e: +            util.logexc(LOG, 'Failed command: %s\n%s', ' '.join(cmd), e)              return False -        except OSError as _err: -            util.logexc(LOG, 'Failed command: %s\n%s', ' '.join(cmd), _err) +        except OSError as e: +            util.logexc(LOG, 'Failed command: %s\n%s', ' '.join(cmd), e)              return False          floppy_dev = '/dev/fd0'          # udevadm settle for floppy device          try: -            cmd = CMD_UDEVADM_SETTLE -            cmd.append('--exit-if-exists=' + floppy_dev) -            (cmd_out, _err) = util.subp(cmd) +            (cmd_out, _err) = util.udevadm_settle(exists=floppy_dev, timeout=5)              LOG.debug('Command: %s\nOutput%s', ' '.join(cmd), cmd_out) -        except ProcessExecutionError as _err: -            util.logexc(LOG, 'Failed command: %s\n%s', ' '.join(cmd), _err) +        except ProcessExecutionError as e: +            util.logexc(LOG, 'Failed command: %s\n%s', ' '.join(cmd), e)              return False -        except OSError as _err: -            util.logexc(LOG, 'Failed command: %s\n%s', ' '.join(cmd), _err) +        except OSError as e: +            util.logexc(LOG, 'Failed command: %s\n%s', ' '.join(cmd), e)              return False          try: diff --git a/cloudinit/sources/DataSourceAzure.py b/cloudinit/sources/DataSourceAzure.py index 0ee622e2..7007d9ea 100644 --- a/cloudinit/sources/DataSourceAzure.py +++ b/cloudinit/sources/DataSourceAzure.py @@ -48,6 +48,7 @@ DEFAULT_FS = 'ext4'  # DMI chassis-asset-tag is set static for all azure instances  AZURE_CHASSIS_ASSET_TAG = '7783-7084-3265-9085-8269-3286-77'  REPROVISION_MARKER_FILE = "/var/lib/cloud/data/poll_imds" +REPORTED_READY_MARKER_FILE = "/var/lib/cloud/data/reported_ready"  IMDS_URL = "http://169.254.169.254/metadata/reprovisiondata" @@ -107,31 +108,24 @@ def find_dev_from_busdev(camcontrol_out, busdev):      return None -def get_dev_storvsc_sysctl(): +def execute_or_debug(cmd, fail_ret=None):      try: -        sysctl_out, err = util.subp(['sysctl', 'dev.storvsc']) +        return util.subp(cmd)[0]      except util.ProcessExecutionError: -        LOG.debug("Fail to execute sysctl dev.storvsc") -        sysctl_out = "" -    return sysctl_out +        LOG.debug("Failed to execute: %s", ' '.join(cmd)) +        return fail_ret + + +def get_dev_storvsc_sysctl(): +    return execute_or_debug(["sysctl", "dev.storvsc"], fail_ret="")  def get_camcontrol_dev_bus(): -    try: -        camcontrol_b_out, err = util.subp(['camcontrol', 'devlist', '-b']) -    except util.ProcessExecutionError: -        LOG.debug("Fail to execute camcontrol devlist -b") -        return None -    return camcontrol_b_out +    return execute_or_debug(['camcontrol', 'devlist', '-b'])  def get_camcontrol_dev(): -    try: -        camcontrol_out, err = util.subp(['camcontrol', 'devlist']) -    except util.ProcessExecutionError: -        LOG.debug("Fail to execute camcontrol devlist") -        return None -    return camcontrol_out +    return execute_or_debug(['camcontrol', 'devlist'])  def get_resource_disk_on_freebsd(port_id): @@ -214,6 +208,7 @@ BUILTIN_CLOUD_CONFIG = {  }  DS_CFG_PATH = ['datasource', DS_NAME] +DS_CFG_KEY_PRESERVE_NTFS = 'never_destroy_ntfs'  DEF_EPHEMERAL_LABEL = 'Temporary Storage'  # The redacted password fails to meet password complexity requirements @@ -400,14 +395,9 @@ class DataSourceAzure(sources.DataSource):          if found == ddir:              LOG.debug("using files cached in %s", ddir) -        # azure / hyper-v provides random data here -        # TODO. find the seed on FreeBSD platform -        # now update ds_cfg to reflect contents pass in config -        if not util.is_FreeBSD(): -            seed = util.load_file("/sys/firmware/acpi/tables/OEM0", -                                  quiet=True, decode=False) -            if seed: -                self.metadata['random_seed'] = seed +        seed = _get_random_seed() +        if seed: +            self.metadata['random_seed'] = seed          user_ds_cfg = util.get_cfg_by_path(self.cfg, DS_CFG_PATH, {})          self.ds_cfg = util.mergemanydict([user_ds_cfg, self.ds_cfg]) @@ -443,11 +433,12 @@ class DataSourceAzure(sources.DataSource):              LOG.debug("negotiating already done for %s",                        self.get_instance_id()) -    def _poll_imds(self, report_ready=True): +    def _poll_imds(self):          """Poll IMDS for the new provisioning data until we get a valid          response. Then return the returned JSON object."""          url = IMDS_URL + "?api-version=2017-04-02"          headers = {"Metadata": "true"} +        report_ready = bool(not os.path.isfile(REPORTED_READY_MARKER_FILE))          LOG.debug("Start polling IMDS")          def exc_cb(msg, exception): @@ -457,13 +448,17 @@ class DataSourceAzure(sources.DataSource):              # call DHCP and setup the ephemeral network to acquire the new IP.              return False -        need_report = report_ready          while True:              try:                  with EphemeralDHCPv4() as lease: -                    if need_report: +                    if report_ready: +                        path = REPORTED_READY_MARKER_FILE +                        LOG.info( +                            "Creating a marker file to report ready: %s", path) +                        util.write_file(path, "{pid}: {time}\n".format( +                            pid=os.getpid(), time=time()))                          self._report_ready(lease=lease) -                        need_report = False +                        report_ready = False                      return readurl(url, timeout=1, headers=headers,                                     exception_cb=exc_cb, infinite=True).contents              except UrlError: @@ -474,7 +469,7 @@ class DataSourceAzure(sources.DataSource):             before we go into our polling loop."""          try:              get_metadata_from_fabric(None, lease['unknown-245']) -        except Exception as exc: +        except Exception:              LOG.warning(                  "Error communicating with Azure fabric; You may experience."                  "connectivity issues.", exc_info=True) @@ -492,13 +487,15 @@ class DataSourceAzure(sources.DataSource):          jump back into the polling loop in order to retrieve the ovf_env."""          if not ret:              return False -        (md, self.userdata_raw, cfg, files) = ret +        (_md, self.userdata_raw, cfg, _files) = ret          path = REPROVISION_MARKER_FILE          if (cfg.get('PreprovisionedVm') is True or                  os.path.isfile(path)):              if not os.path.isfile(path): -                LOG.info("Creating a marker file to poll imds") -                util.write_file(path, "%s: %s\n" % (os.getpid(), time())) +                LOG.info("Creating a marker file to poll imds: %s", +                         path) +                util.write_file(path, "{pid}: {time}\n".format( +                    pid=os.getpid(), time=time()))              return True          return False @@ -528,16 +525,19 @@ class DataSourceAzure(sources.DataSource):                    self.ds_cfg['agent_command'])          try:              fabric_data = metadata_func() -        except Exception as exc: +        except Exception:              LOG.warning(                  "Error communicating with Azure fabric; You may experience."                  "connectivity issues.", exc_info=True)              return False +        util.del_file(REPORTED_READY_MARKER_FILE)          util.del_file(REPROVISION_MARKER_FILE)          return fabric_data      def activate(self, cfg, is_new_instance): -        address_ephemeral_resize(is_new_instance=is_new_instance) +        address_ephemeral_resize(is_new_instance=is_new_instance, +                                 preserve_ntfs=self.ds_cfg.get( +                                     DS_CFG_KEY_PRESERVE_NTFS, False))          return      @property @@ -581,17 +581,29 @@ def _has_ntfs_filesystem(devpath):      return os.path.realpath(devpath) in ntfs_devices -def can_dev_be_reformatted(devpath): -    """Determine if block device devpath is newly formatted ephemeral. +def can_dev_be_reformatted(devpath, preserve_ntfs): +    """Determine if the ephemeral drive at devpath should be reformatted. -    A newly formatted disk will: +    A fresh ephemeral disk is formatted by Azure and will:        a.) have a partition table (dos or gpt)        b.) have 1 partition that is ntfs formatted, or            have 2 partitions with the second partition ntfs formatted.            (larger instances with >2TB ephemeral disk have gpt, and will             have a microsoft reserved partition as part 1.  LP: #1686514)        c.) the ntfs partition will have no files other than possibly -          'dataloss_warning_readme.txt'""" +          'dataloss_warning_readme.txt' + +    User can indicate that NTFS should never be destroyed by setting +    DS_CFG_KEY_PRESERVE_NTFS in dscfg. +    If data is found on NTFS, user is warned to set DS_CFG_KEY_PRESERVE_NTFS +    to make sure cloud-init does not accidentally wipe their data. +    If cloud-init cannot mount the disk to check for data, destruction +    will be allowed, unless the dscfg key is set.""" +    if preserve_ntfs: +        msg = ('config says to never destroy NTFS (%s.%s), skipping checks' % +               (".".join(DS_CFG_PATH), DS_CFG_KEY_PRESERVE_NTFS)) +        return False, msg +      if not os.path.exists(devpath):          return False, 'device %s does not exist' % devpath @@ -624,18 +636,27 @@ def can_dev_be_reformatted(devpath):      bmsg = ('partition %s (%s) on device %s was ntfs formatted' %              (cand_part, cand_path, devpath))      try: -        file_count = util.mount_cb(cand_path, count_files) +        file_count = util.mount_cb(cand_path, count_files, mtype="ntfs", +                                   update_env_for_mount={'LANG': 'C'})      except util.MountFailedError as e: +        if "mount: unknown filesystem type 'ntfs'" in str(e): +            return True, (bmsg + ' but this system cannot mount NTFS,' +                          ' assuming there are no important files.' +                          ' Formatting allowed.')          return False, bmsg + ' but mount of %s failed: %s' % (cand_part, e)      if file_count != 0: +        LOG.warning("it looks like you're using NTFS on the ephemeral disk, " +                    'to ensure that filesystem does not get wiped, set ' +                    '%s.%s in config', '.'.join(DS_CFG_PATH), +                    DS_CFG_KEY_PRESERVE_NTFS)          return False, bmsg + ' but had %d files on it.' % file_count      return True, bmsg + ' and had no important files. Safe for reformatting.'  def address_ephemeral_resize(devpath=RESOURCE_DISK_PATH, maxwait=120, -                             is_new_instance=False): +                             is_new_instance=False, preserve_ntfs=False):      # wait for ephemeral disk to come up      naplen = .2      missing = util.wait_for_files([devpath], maxwait=maxwait, naplen=naplen, @@ -651,7 +672,7 @@ def address_ephemeral_resize(devpath=RESOURCE_DISK_PATH, maxwait=120,      if is_new_instance:          result, msg = (True, "First instance boot.")      else: -        result, msg = can_dev_be_reformatted(devpath) +        result, msg = can_dev_be_reformatted(devpath, preserve_ntfs)      LOG.debug("reformattable=%s: %s", result, msg)      if not result: @@ -965,6 +986,18 @@ def _check_freebsd_cdrom(cdrom_dev):      return False +def _get_random_seed(): +    """Return content random seed file if available, otherwise, +       return None.""" +    # azure / hyper-v provides random data here +    # TODO. find the seed on FreeBSD platform +    # now update ds_cfg to reflect contents pass in config +    if util.is_FreeBSD(): +        return None +    return util.load_file("/sys/firmware/acpi/tables/OEM0", +                          quiet=True, decode=False) + +  def list_possible_azure_ds_devs():      devlist = []      if util.is_FreeBSD(): diff --git a/cloudinit/sources/DataSourceCloudStack.py b/cloudinit/sources/DataSourceCloudStack.py index 0df545fc..d4b758f2 100644 --- a/cloudinit/sources/DataSourceCloudStack.py +++ b/cloudinit/sources/DataSourceCloudStack.py @@ -68,6 +68,10 @@ class DataSourceCloudStack(sources.DataSource):      dsname = 'CloudStack' +    # Setup read_url parameters per get_url_params. +    url_max_wait = 120 +    url_timeout = 50 +      def __init__(self, sys_cfg, distro, paths):          sources.DataSource.__init__(self, sys_cfg, distro, paths)          self.seed_dir = os.path.join(paths.seed_dir, 'cs') @@ -80,33 +84,18 @@ class DataSourceCloudStack(sources.DataSource):          self.metadata_address = "http://%s/" % (self.vr_addr,)          self.cfg = {} -    def _get_url_settings(self): -        mcfg = self.ds_cfg -        max_wait = 120 -        try: -            max_wait = int(mcfg.get("max_wait", max_wait)) -        except Exception: -            util.logexc(LOG, "Failed to get max wait. using %s", max_wait) +    def wait_for_metadata_service(self): +        url_params = self.get_url_params() -        if max_wait == 0: +        if url_params.max_wait_seconds <= 0:              return False -        timeout = 50 -        try: -            timeout = int(mcfg.get("timeout", timeout)) -        except Exception: -            util.logexc(LOG, "Failed to get timeout, using %s", timeout) - -        return (max_wait, timeout) - -    def wait_for_metadata_service(self): -        (max_wait, timeout) = self._get_url_settings() -          urls = [uhelp.combine_url(self.metadata_address,                                    'latest/meta-data/instance-id')]          start_time = time.time() -        url = uhelp.wait_for_url(urls=urls, max_wait=max_wait, -                                 timeout=timeout, status_cb=LOG.warn) +        url = uhelp.wait_for_url( +            urls=urls, max_wait=url_params.max_wait_seconds, +            timeout=url_params.timeout_seconds, status_cb=LOG.warn)          if url:              LOG.debug("Using metadata source: '%s'", url) diff --git a/cloudinit/sources/DataSourceConfigDrive.py b/cloudinit/sources/DataSourceConfigDrive.py index c7b5fe5f..4cb28977 100644 --- a/cloudinit/sources/DataSourceConfigDrive.py +++ b/cloudinit/sources/DataSourceConfigDrive.py @@ -43,7 +43,7 @@ class DataSourceConfigDrive(openstack.SourceMixin, sources.DataSource):          self.version = None          self.ec2_metadata = None          self._network_config = None -        self.network_json = None +        self.network_json = sources.UNSET          self.network_eni = None          self.known_macs = None          self.files = {} @@ -69,7 +69,8 @@ class DataSourceConfigDrive(openstack.SourceMixin, sources.DataSource):                  util.logexc(LOG, "Failed reading config drive from %s", sdir)          if not found: -            for dev in find_candidate_devs(): +            dslist = self.sys_cfg.get('datasource_list') +            for dev in find_candidate_devs(dslist=dslist):                  try:                      # Set mtype if freebsd and turn off sync                      if dev.startswith("/dev/cd"): @@ -148,7 +149,7 @@ class DataSourceConfigDrive(openstack.SourceMixin, sources.DataSource):      @property      def network_config(self):          if self._network_config is None: -            if self.network_json is not None: +            if self.network_json not in (None, sources.UNSET):                  LOG.debug("network config provided via network_json")                  self._network_config = openstack.convert_net_json(                      self.network_json, known_macs=self.known_macs) @@ -211,7 +212,7 @@ def write_injected_files(files):                  util.logexc(LOG, "Failed writing file: %s", filename) -def find_candidate_devs(probe_optical=True): +def find_candidate_devs(probe_optical=True, dslist=None):      """Return a list of devices that may contain the config drive.      The returned list is sorted by search order where the first item has @@ -227,6 +228,9 @@ def find_candidate_devs(probe_optical=True):          * either vfat or iso9660 formated          * labeled with 'config-2' or 'CONFIG-2'      """ +    if dslist is None: +        dslist = [] +      # query optical drive to get it in blkid cache for 2.6 kernels      if probe_optical:          for device in OPTICAL_DEVICES: @@ -257,7 +261,8 @@ def find_candidate_devs(probe_optical=True):      devices = [d for d in candidates                 if d in by_label or not util.is_partition(d)] -    if devices: +    LOG.debug("devices=%s dslist=%s", devices, dslist) +    if devices and "IBMCloud" in dslist:          # IBMCloud uses config-2 label, but limited to a single UUID.          ibm_platform, ibm_path = get_ibm_platform()          if ibm_path in devices: diff --git a/cloudinit/sources/DataSourceEc2.py b/cloudinit/sources/DataSourceEc2.py index 21e9ef84..968ab3f7 100644 --- a/cloudinit/sources/DataSourceEc2.py +++ b/cloudinit/sources/DataSourceEc2.py @@ -27,8 +27,6 @@ SKIP_METADATA_URL_CODES = frozenset([uhelp.NOT_FOUND])  STRICT_ID_PATH = ("datasource", "Ec2", "strict_id")  STRICT_ID_DEFAULT = "warn" -_unset = "_unset" -  class Platforms(object):      # TODO Rename and move to cloudinit.cloud.CloudNames @@ -59,15 +57,16 @@ class DataSourceEc2(sources.DataSource):      # for extended metadata content. IPv6 support comes in 2016-09-02      extended_metadata_versions = ['2016-09-02'] +    # Setup read_url parameters per get_url_params. +    url_max_wait = 120 +    url_timeout = 50 +      _cloud_platform = None -    _network_config = _unset  # Used for caching calculated network config v1 +    _network_config = sources.UNSET  # Used to cache calculated network cfg v1      # Whether we want to get network configuration from the metadata service. -    get_network_metadata = False - -    # Track the discovered fallback nic for use in configuration generation. -    _fallback_interface = None +    perform_dhcp_setup = False      def __init__(self, sys_cfg, distro, paths):          super(DataSourceEc2, self).__init__(sys_cfg, distro, paths) @@ -98,7 +97,7 @@ class DataSourceEc2(sources.DataSource):          elif self.cloud_platform == Platforms.NO_EC2_METADATA:              return False -        if self.get_network_metadata:  # Setup networking in init-local stage. +        if self.perform_dhcp_setup:  # Setup networking in init-local stage.              if util.is_FreeBSD():                  LOG.debug("FreeBSD doesn't support running dhclient with -sf")                  return False @@ -158,27 +157,11 @@ class DataSourceEc2(sources.DataSource):          else:              return self.metadata['instance-id'] -    def _get_url_settings(self): -        mcfg = self.ds_cfg -        max_wait = 120 -        try: -            max_wait = int(mcfg.get("max_wait", max_wait)) -        except Exception: -            util.logexc(LOG, "Failed to get max wait. using %s", max_wait) - -        timeout = 50 -        try: -            timeout = max(0, int(mcfg.get("timeout", timeout))) -        except Exception: -            util.logexc(LOG, "Failed to get timeout, using %s", timeout) - -        return (max_wait, timeout) -      def wait_for_metadata_service(self):          mcfg = self.ds_cfg -        (max_wait, timeout) = self._get_url_settings() -        if max_wait <= 0: +        url_params = self.get_url_params() +        if url_params.max_wait_seconds <= 0:              return False          # Remove addresses from the list that wont resolve. @@ -205,7 +188,8 @@ class DataSourceEc2(sources.DataSource):          start_time = time.time()          url = uhelp.wait_for_url( -            urls=urls, max_wait=max_wait, timeout=timeout, status_cb=LOG.warn) +            urls=urls, max_wait=url_params.max_wait_seconds, +            timeout=url_params.timeout_seconds, status_cb=LOG.warn)          if url:              self.metadata_address = url2base[url] @@ -310,11 +294,11 @@ class DataSourceEc2(sources.DataSource):      @property      def network_config(self):          """Return a network config dict for rendering ENI or netplan files.""" -        if self._network_config != _unset: +        if self._network_config != sources.UNSET:              return self._network_config          if self.metadata is None: -            # this would happen if get_data hadn't been called. leave as _unset +            # this would happen if get_data hadn't been called. leave as UNSET              LOG.warning(                  "Unexpected call to network_config when metadata is None.")              return None @@ -353,9 +337,7 @@ class DataSourceEc2(sources.DataSource):                  self._fallback_interface = _legacy_fbnic                  self.fallback_nic = None              else: -                self._fallback_interface = net.find_fallback_nic() -                if self._fallback_interface is None: -                    LOG.warning("Did not find a fallback interface on EC2.") +                return super(DataSourceEc2, self).fallback_interface          return self._fallback_interface      def _crawl_metadata(self): @@ -390,7 +372,7 @@ class DataSourceEc2Local(DataSourceEc2):      metadata service. If the metadata service provides network configuration      then render the network configuration for that instance based on metadata.      """ -    get_network_metadata = True  # Get metadata network config if present +    perform_dhcp_setup = True  # Use dhcp before querying metadata      def get_data(self):          supported_platforms = (Platforms.AWS,) diff --git a/cloudinit/sources/DataSourceIBMCloud.py b/cloudinit/sources/DataSourceIBMCloud.py index 02b3d56f..01106ec0 100644 --- a/cloudinit/sources/DataSourceIBMCloud.py +++ b/cloudinit/sources/DataSourceIBMCloud.py @@ -8,17 +8,11 @@ There are 2 different api exposed launch methods.   * template: This is the legacy method of launching instances.     When booting from an image template, the system boots first into     a "provisioning" mode.  There, host <-> guest mechanisms are utilized -   to execute code in the guest and provision it. +   to execute code in the guest and configure it.  The configuration +   includes configuring the system network and possibly installing +   packages and other software stack. -   Cloud-init will disable itself when it detects that it is in the -   provisioning mode.  It detects this by the presence of -   a file '/root/provisioningConfiguration.cfg'. - -   When provided with user-data, the "first boot" will contain a -   ConfigDrive-like disk labeled with 'METADATA'.  If there is no user-data -   provided, then there is no data-source. - -   Cloud-init never does any network configuration in this mode. +   After the provisioning is finished, the system reboots.   * os_code: Essentially "launch by OS Code" (Operating System Code).     This is a more modern approach.  There is no specific "provisioning" boot. @@ -30,11 +24,73 @@ There are 2 different api exposed launch methods.     mean that 1 in 8^16 (~4 billion) Xen ConfigDrive systems will be     incorrectly identified as IBMCloud. +The combination of these 2 launch methods and with or without user-data +creates 6 boot scenarios. + A. os_code with user-data + B. os_code without user-data +    Cloud-init is fully operational in this mode. + +    There is a block device attached with label 'config-2'. +    As it differs from OpenStack's config-2, we have to differentiate. +    We do so by requiring the UUID on the filesystem to be "9796-932E". + +    This disk will have the following files. Specifically note, there +    is no versioned path to the meta-data, only 'latest': +      openstack/latest/meta_data.json +      openstack/latest/network_data.json +      openstack/latest/user_data [optional] +      openstack/latest/vendor_data.json + +    vendor_data.json as of 2018-04 looks like this: +      {"cloud-init":"#!/bin/bash\necho 'root:$6$<snip>' | chpasswd -e"} + +    The only difference between A and B in this mode is the presence +    of user_data on the config disk. + + C. template, provisioning boot with user-data + D. template, provisioning boot without user-data. +    With ds-identify cloud-init is fully disabled in this mode. +    Without ds-identify, cloud-init None datasource will be used. + +    This is currently identified by the presence of +    /root/provisioningConfiguration.cfg . That file is placed into the +    system before it is booted. + +    The difference between C and D is the presence of the METADATA disk +    as described in E below.  There is no METADATA disk attached unless +    user-data is provided. + + E. template, post-provisioning boot with user-data. +    Cloud-init is fully operational in this mode. + +    This is identified by a block device with filesystem label "METADATA". +    The looks similar to a version-1 OpenStack config drive.  It will +    have the following files: + +       openstack/latest/user_data +       openstack/latest/meta_data.json +       openstack/content/interfaces +       meta.js + +    meta.js contains something similar to user_data.  cloud-init ignores it. +    cloud-init ignores the 'interfaces' style file here. +    In this mode, cloud-init has networking code disabled.  It relies +    on the provisioning boot to have configured networking. + + F. template, post-provisioning boot without user-data. +    With ds-identify, cloud-init will be fully disabled. +    Without ds-identify, cloud-init None datasource will be used. + +    There is no information available to identify this scenario. + +    The user will be able to ssh in as as root with their public keys that +    have been installed into /root/ssh/.authorized_keys +    during the provisioning stage. +  TODO:   * is uuid (/sys/hypervisor/uuid) stable for life of an instance?     it seems it is not the same as data's uuid in the os_code case     but is in the template case. -  """  import base64  import json @@ -138,8 +194,30 @@ def _is_xen():      return os.path.exists("/proc/xen") -def _is_ibm_provisioning(): -    return os.path.exists("/root/provisioningConfiguration.cfg") +def _is_ibm_provisioning( +        prov_cfg="/root/provisioningConfiguration.cfg", +        inst_log="/root/swinstall.log", +        boot_ref="/proc/1/environ"): +    """Return boolean indicating if this boot is ibm provisioning boot.""" +    if os.path.exists(prov_cfg): +        msg = "config '%s' exists." % prov_cfg +        result = True +        if os.path.exists(inst_log): +            if os.path.exists(boot_ref): +                result = (os.stat(inst_log).st_mtime > +                          os.stat(boot_ref).st_mtime) +                msg += (" log '%s' from %s boot." % +                        (inst_log, "current" if result else "previous")) +            else: +                msg += (" log '%s' existed, but no reference file '%s'." % +                        (inst_log, boot_ref)) +                result = False +        else: +            msg += " log '%s' did not exist." % inst_log +    else: +        result, msg = (False, "config '%s' did not exist." % prov_cfg) +    LOG.debug("ibm_provisioning=%s: %s", result, msg) +    return result  def get_ibm_platform(): @@ -189,7 +267,7 @@ def get_ibm_platform():          else:              return (Platforms.TEMPLATE_LIVE_METADATA, metadata_path)      elif _is_ibm_provisioning(): -            return (Platforms.TEMPLATE_PROVISIONING_NODATA, None) +        return (Platforms.TEMPLATE_PROVISIONING_NODATA, None)      return not_found diff --git a/cloudinit/sources/DataSourceMAAS.py b/cloudinit/sources/DataSourceMAAS.py index 6ac88635..bcb38544 100644 --- a/cloudinit/sources/DataSourceMAAS.py +++ b/cloudinit/sources/DataSourceMAAS.py @@ -198,13 +198,13 @@ def read_maas_seed_url(seed_url, read_file_or_url=None, timeout=None,      If version is None, then <version>/ will not be used.      """      if read_file_or_url is None: -        read_file_or_url = util.read_file_or_url +        read_file_or_url = url_helper.read_file_or_url      if seed_url.endswith("/"):          seed_url = seed_url[:-1]      md = {} -    for path, dictname, binary, optional in DS_FIELDS: +    for path, _dictname, binary, optional in DS_FIELDS:          if version is None:              url = "%s/%s" % (seed_url, path)          else: diff --git a/cloudinit/sources/DataSourceNoCloud.py b/cloudinit/sources/DataSourceNoCloud.py index 5d3a8ddb..2daea59d 100644 --- a/cloudinit/sources/DataSourceNoCloud.py +++ b/cloudinit/sources/DataSourceNoCloud.py @@ -78,7 +78,7 @@ class DataSourceNoCloud(sources.DataSource):                  LOG.debug("Using seeded data from %s", path)                  mydata = _merge_new_seed(mydata, seeded)                  break -            except ValueError as e: +            except ValueError:                  pass          # If the datasource config had a 'seedfrom' entry, then that takes @@ -117,7 +117,7 @@ class DataSourceNoCloud(sources.DataSource):                      try:                          seeded = util.mount_cb(dev, _pp2d_callback,                                                 pp2d_kwargs) -                    except ValueError as e: +                    except ValueError:                          if dev in label_list:                              LOG.warning("device %s with label=%s not a"                                          "valid seed.", dev, label) diff --git a/cloudinit/sources/DataSourceOVF.py b/cloudinit/sources/DataSourceOVF.py index dc914a72..178ccb0f 100644 --- a/cloudinit/sources/DataSourceOVF.py +++ b/cloudinit/sources/DataSourceOVF.py @@ -556,7 +556,7 @@ def search_file(dirpath, filename):      if not dirpath or not filename:          return None -    for root, dirs, files in os.walk(dirpath): +    for root, _dirs, files in os.walk(dirpath):          if filename in files:              return os.path.join(root, filename) diff --git a/cloudinit/sources/DataSourceOpenNebula.py b/cloudinit/sources/DataSourceOpenNebula.py index d4a41116..16c10785 100644 --- a/cloudinit/sources/DataSourceOpenNebula.py +++ b/cloudinit/sources/DataSourceOpenNebula.py @@ -378,7 +378,7 @@ def read_context_disk_dir(source_dir, asuser=None):          if asuser is not None:              try:                  pwd.getpwnam(asuser) -            except KeyError as e: +            except KeyError:                  raise BrokenContextDiskDir(                      "configured user '{user}' does not exist".format(                          user=asuser)) diff --git a/cloudinit/sources/DataSourceOpenStack.py b/cloudinit/sources/DataSourceOpenStack.py index e55a7638..365af96a 100644 --- a/cloudinit/sources/DataSourceOpenStack.py +++ b/cloudinit/sources/DataSourceOpenStack.py @@ -7,6 +7,7 @@  import time  from cloudinit import log as logging +from cloudinit.net.dhcp import EphemeralDHCPv4, NoDHCPLeaseError  from cloudinit import sources  from cloudinit import url_helper  from cloudinit import util @@ -22,51 +23,37 @@ DEFAULT_METADATA = {      "instance-id": DEFAULT_IID,  } +# OpenStack DMI constants +DMI_PRODUCT_NOVA = 'OpenStack Nova' +DMI_PRODUCT_COMPUTE = 'OpenStack Compute' +VALID_DMI_PRODUCT_NAMES = [DMI_PRODUCT_NOVA, DMI_PRODUCT_COMPUTE] +DMI_ASSET_TAG_OPENTELEKOM = 'OpenTelekomCloud' +VALID_DMI_ASSET_TAGS = [DMI_ASSET_TAG_OPENTELEKOM] +  class DataSourceOpenStack(openstack.SourceMixin, sources.DataSource):      dsname = "OpenStack" +    _network_config = sources.UNSET  # Used to cache calculated network cfg v1 + +    # Whether we want to get network configuration from the metadata service. +    perform_dhcp_setup = False +      def __init__(self, sys_cfg, distro, paths):          super(DataSourceOpenStack, self).__init__(sys_cfg, distro, paths)          self.metadata_address = None          self.ssl_details = util.fetch_ssl_details(self.paths)          self.version = None          self.files = {} -        self.ec2_metadata = None +        self.ec2_metadata = sources.UNSET +        self.network_json = sources.UNSET      def __str__(self):          root = sources.DataSource.__str__(self)          mstr = "%s [%s,ver=%s]" % (root, self.dsmode, self.version)          return mstr -    def _get_url_settings(self): -        # TODO(harlowja): this is shared with ec2 datasource, we should just -        # move it to a shared location instead... -        # Note: the defaults here are different though. - -        # max_wait < 0 indicates do not wait -        max_wait = -1 -        timeout = 10 -        retries = 5 - -        try: -            max_wait = int(self.ds_cfg.get("max_wait", max_wait)) -        except Exception: -            util.logexc(LOG, "Failed to get max wait. using %s", max_wait) - -        try: -            timeout = max(0, int(self.ds_cfg.get("timeout", timeout))) -        except Exception: -            util.logexc(LOG, "Failed to get timeout, using %s", timeout) - -        try: -            retries = int(self.ds_cfg.get("retries", retries)) -        except Exception: -            util.logexc(LOG, "Failed to get retries. using %s", retries) - -        return (max_wait, timeout, retries) -      def wait_for_metadata_service(self):          urls = self.ds_cfg.get("metadata_urls", [DEF_MD_URL])          filtered = [x for x in urls if util.is_resolvable_url(x)] @@ -86,10 +73,11 @@ class DataSourceOpenStack(openstack.SourceMixin, sources.DataSource):              md_urls.append(md_url)              url2base[md_url] = url -        (max_wait, timeout, retries) = self._get_url_settings() +        url_params = self.get_url_params()          start_time = time.time() -        avail_url = url_helper.wait_for_url(urls=md_urls, max_wait=max_wait, -                                            timeout=timeout) +        avail_url = url_helper.wait_for_url( +            urls=md_urls, max_wait=url_params.max_wait_seconds, +            timeout=url_params.timeout_seconds)          if avail_url:              LOG.debug("Using metadata source: '%s'", url2base[avail_url])          else: @@ -99,38 +87,66 @@ class DataSourceOpenStack(openstack.SourceMixin, sources.DataSource):          self.metadata_address = url2base.get(avail_url)          return bool(avail_url) -    def _get_data(self): -        try: -            if not self.wait_for_metadata_service(): -                return False -        except IOError: -            return False +    def check_instance_id(self, sys_cfg): +        # quickly (local check only) if self.instance_id is still valid +        return sources.instance_id_matches_system_uuid(self.get_instance_id()) -        (max_wait, timeout, retries) = self._get_url_settings() +    @property +    def network_config(self): +        """Return a network config dict for rendering ENI or netplan files.""" +        if self._network_config != sources.UNSET: +            return self._network_config + +        # RELEASE_BLOCKER: SRU to Xenial and Artful SRU should not provide +        # network_config by default unless configured in /etc/cloud/cloud.cfg*. +        # Patch Xenial and Artful before release to default to False. +        if util.is_false(self.ds_cfg.get('apply_network_config', True)): +            self._network_config = None +            return self._network_config +        if self.network_json == sources.UNSET: +            # this would happen if get_data hadn't been called. leave as UNSET +            LOG.warning( +                'Unexpected call to network_config when network_json is None.') +            return None + +        LOG.debug('network config provided via network_json') +        self._network_config = openstack.convert_net_json( +            self.network_json, known_macs=None) +        return self._network_config -        try: -            results = util.log_time(LOG.debug, -                                    'Crawl of openstack metadata service', -                                    read_metadata_service, -                                    args=[self.metadata_address], -                                    kwargs={'ssl_details': self.ssl_details, -                                            'retries': retries, -                                            'timeout': timeout}) -        except openstack.NonReadable: -            return False -        except (openstack.BrokenMetadata, IOError): -            util.logexc(LOG, "Broken metadata address %s", -                        self.metadata_address) +    def _get_data(self): +        """Crawl metadata, parse and persist that data for this instance. + +        @return: True when metadata discovered indicates OpenStack datasource. +            False when unable to contact metadata service or when metadata +            format is invalid or disabled. +        """ +        if not detect_openstack():              return False +        if self.perform_dhcp_setup:  # Setup networking in init-local stage. +            try: +                with EphemeralDHCPv4(self.fallback_interface): +                    results = util.log_time( +                        logfunc=LOG.debug, msg='Crawl of metadata service', +                        func=self._crawl_metadata) +            except (NoDHCPLeaseError, sources.InvalidMetaDataException) as e: +                util.logexc(LOG, str(e)) +                return False +        else: +            try: +                results = self._crawl_metadata() +            except sources.InvalidMetaDataException as e: +                util.logexc(LOG, str(e)) +                return False          self.dsmode = self._determine_dsmode([results.get('dsmode')])          if self.dsmode == sources.DSMODE_DISABLED:              return False -          md = results.get('metadata', {})          md = util.mergemanydict([md, DEFAULT_METADATA])          self.metadata = md          self.ec2_metadata = results.get('ec2-metadata') +        self.network_json = results.get('networkdata')          self.userdata_raw = results.get('userdata')          self.version = results['version']          self.files.update(results.get('files', {})) @@ -145,9 +161,50 @@ class DataSourceOpenStack(openstack.SourceMixin, sources.DataSource):          return True -    def check_instance_id(self, sys_cfg): -        # quickly (local check only) if self.instance_id is still valid -        return sources.instance_id_matches_system_uuid(self.get_instance_id()) +    def _crawl_metadata(self): +        """Crawl metadata service when available. + +        @returns: Dictionary with all metadata discovered for this datasource. +        @raise: InvalidMetaDataException on unreadable or broken +            metadata. +        """ +        try: +            if not self.wait_for_metadata_service(): +                raise sources.InvalidMetaDataException( +                    'No active metadata service found') +        except IOError as e: +            raise sources.InvalidMetaDataException( +                'IOError contacting metadata service: {error}'.format( +                    error=str(e))) + +        url_params = self.get_url_params() + +        try: +            result = util.log_time( +                LOG.debug, 'Crawl of openstack metadata service', +                read_metadata_service, args=[self.metadata_address], +                kwargs={'ssl_details': self.ssl_details, +                        'retries': url_params.num_retries, +                        'timeout': url_params.timeout_seconds}) +        except openstack.NonReadable as e: +            raise sources.InvalidMetaDataException(str(e)) +        except (openstack.BrokenMetadata, IOError): +            msg = 'Broken metadata address {addr}'.format( +                addr=self.metadata_address) +            raise sources.InvalidMetaDataException(msg) +        return result + + +class DataSourceOpenStackLocal(DataSourceOpenStack): +    """Run in init-local using a dhcp discovery prior to metadata crawl. + +    In init-local, no network is available. This subclass sets up minimal +    networking with dhclient on a viable nic so that it can talk to the +    metadata service. If the metadata service provides network configuration +    then render the network configuration for that instance based on metadata. +    """ + +    perform_dhcp_setup = True  # Get metadata network config if present  def read_metadata_service(base_url, ssl_details=None, @@ -157,8 +214,23 @@ def read_metadata_service(base_url, ssl_details=None,      return reader.read_v2() +def detect_openstack(): +    """Return True when a potential OpenStack platform is detected.""" +    if not util.is_x86(): +        return True  # Non-Intel cpus don't properly report dmi product names +    product_name = util.read_dmi_data('system-product-name') +    if product_name in VALID_DMI_PRODUCT_NAMES: +        return True +    elif util.read_dmi_data('chassis-asset-tag') in VALID_DMI_ASSET_TAGS: +        return True +    elif util.get_proc_env(1).get('product_name') == DMI_PRODUCT_NOVA: +        return True +    return False + +  # Used to match classes to dependencies  datasources = [ +    (DataSourceOpenStackLocal, (sources.DEP_FILESYSTEM,)),      (DataSourceOpenStack, (sources.DEP_FILESYSTEM, sources.DEP_NETWORK)),  ] diff --git a/cloudinit/sources/DataSourceSmartOS.py b/cloudinit/sources/DataSourceSmartOS.py index 86bfa5d8..f92e8b5c 100644 --- a/cloudinit/sources/DataSourceSmartOS.py +++ b/cloudinit/sources/DataSourceSmartOS.py @@ -1,4 +1,5 @@  # Copyright (C) 2013 Canonical Ltd. +# Copyright (c) 2018, Joyent, Inc.  #  # Author: Ben Howard <ben.howard@canonical.com>  # @@ -10,17 +11,19 @@  #    SmartOS hosts use a serial console (/dev/ttyS1) on KVM Linux Guests  #        The meta-data is transmitted via key/value pairs made by  #        requests on the console. For example, to get the hostname, you -#        would send "GET hostname" on /dev/ttyS1. +#        would send "GET sdc:hostname" on /dev/ttyS1.  #        For Linux Guests running in LX-Brand Zones on SmartOS hosts  #        a socket (/native/.zonecontrol/metadata.sock) is used instead  #        of a serial console.  #  #   Certain behavior is defined by the DataDictionary -#       http://us-east.manta.joyent.com/jmc/public/mdata/datadict.html +#       https://eng.joyent.com/mdata/datadict.html  #       Comments with "@datadictionary" are snippets of the definition  import base64  import binascii +import errno +import fcntl  import json  import os  import random @@ -108,7 +111,7 @@ BUILTIN_CLOUD_CONFIG = {                         'overwrite': False}      },      'fs_setup': [{'label': 'ephemeral0', -                  'filesystem': 'ext3', +                  'filesystem': 'ext4',                    'device': 'ephemeral0'}],  } @@ -162,9 +165,8 @@ class DataSourceSmartOS(sources.DataSource):      dsname = "Joyent" -    _unset = "_unset" -    smartos_type = _unset -    md_client = _unset +    smartos_type = sources.UNSET +    md_client = sources.UNSET      def __init__(self, sys_cfg, distro, paths):          sources.DataSource.__init__(self, sys_cfg, distro, paths) @@ -186,12 +188,12 @@ class DataSourceSmartOS(sources.DataSource):          return "%s [client=%s]" % (root, self.md_client)      def _init(self): -        if self.smartos_type == self._unset: +        if self.smartos_type == sources.UNSET:              self.smartos_type = get_smartos_environ()              if self.smartos_type is None:                  self.md_client = None -        if self.md_client == self._unset: +        if self.md_client == sources.UNSET:              self.md_client = jmc_client_factory(                  smartos_type=self.smartos_type,                  metadata_sockfile=self.ds_cfg['metadata_sockfile'], @@ -229,6 +231,9 @@ class DataSourceSmartOS(sources.DataSource):                        self.md_client)              return False +        # Open once for many requests, rather than once for each request +        self.md_client.open_transport() +          for ci_noun, attribute in SMARTOS_ATTRIB_MAP.items():              smartos_noun, strip = attribute              md[ci_noun] = self.md_client.get(smartos_noun, strip=strip) @@ -236,6 +241,8 @@ class DataSourceSmartOS(sources.DataSource):          for ci_noun, smartos_noun in SMARTOS_ATTRIB_JSON.items():              md[ci_noun] = self.md_client.get_json(smartos_noun) +        self.md_client.close_transport() +          # @datadictionary: This key may contain a program that is written          # to a file in the filesystem of the guest on each boot and then          # executed. It may be of any format that would be considered @@ -266,8 +273,14 @@ class DataSourceSmartOS(sources.DataSource):          write_boot_content(u_data, u_data_f)          # Handle the cloud-init regular meta + +        # The hostname may or may not be qualified with the local domain name. +        # This follows section 3.14 of RFC 2132.          if not md['local-hostname']: -            md['local-hostname'] = md['instance-id'] +            if md['hostname']: +                md['local-hostname'] = md['hostname'] +            else: +                md['local-hostname'] = md['instance-id']          ud = None          if md['user-data']: @@ -285,6 +298,7 @@ class DataSourceSmartOS(sources.DataSource):          self.userdata_raw = ud          self.vendordata_raw = md['vendor-data']          self.network_data = md['network-data'] +        self.routes_data = md['routes']          self._set_provisioned()          return True @@ -308,7 +322,8 @@ class DataSourceSmartOS(sources.DataSource):                      convert_smartos_network_data(                          network_data=self.network_data,                          dns_servers=self.metadata['dns_servers'], -                        dns_domain=self.metadata['dns_domain'])) +                        dns_domain=self.metadata['dns_domain'], +                        routes=self.routes_data))          return self._network_config @@ -316,6 +331,10 @@ class JoyentMetadataFetchException(Exception):      pass +class JoyentMetadataTimeoutException(JoyentMetadataFetchException): +    pass + +  class JoyentMetadataClient(object):      """      A client implementing v2 of the Joyent Metadata Protocol Specification. @@ -360,6 +379,47 @@ class JoyentMetadataClient(object):          LOG.debug('Value "%s" found.', value)          return value +    def _readline(self): +        """ +           Reads a line a byte at a time until \n is encountered.  Returns an +           ascii string with the trailing newline removed. + +           If a timeout (per-byte) is set and it expires, a +           JoyentMetadataFetchException will be thrown. +        """ +        response = [] + +        def as_ascii(): +            return b''.join(response).decode('ascii') + +        msg = "Partial response: '%s'" +        while True: +            try: +                byte = self.fp.read(1) +                if len(byte) == 0: +                    raise JoyentMetadataTimeoutException(msg % as_ascii()) +                if byte == b'\n': +                    return as_ascii() +                response.append(byte) +            except OSError as exc: +                if exc.errno == errno.EAGAIN: +                    raise JoyentMetadataTimeoutException(msg % as_ascii()) +                raise + +    def _write(self, msg): +        self.fp.write(msg.encode('ascii')) +        self.fp.flush() + +    def _negotiate(self): +        LOG.debug('Negotiating protocol V2') +        self._write('NEGOTIATE V2\n') +        response = self._readline() +        LOG.debug('read "%s"', response) +        if response != 'V2_OK': +            raise JoyentMetadataFetchException( +                'Invalid response "%s" to "NEGOTIATE V2"' % response) +        LOG.debug('Negotiation complete') +      def request(self, rtype, param=None):          request_id = '{0:08x}'.format(random.randint(0, 0xffffffff))          message_body = ' '.join((request_id, rtype,)) @@ -374,18 +434,11 @@ class JoyentMetadataClient(object):              self.open_transport()              need_close = True -        self.fp.write(msg.encode('ascii')) -        self.fp.flush() - -        response = bytearray() -        response.extend(self.fp.read(1)) -        while response[-1:] != b'\n': -            response.extend(self.fp.read(1)) - +        self._write(msg) +        response = self._readline()          if need_close:              self.close_transport() -        response = response.rstrip().decode('ascii')          LOG.debug('Read "%s" from metadata transport.', response)          if 'SUCCESS' not in response: @@ -410,9 +463,9 @@ class JoyentMetadataClient(object):      def list(self):          result = self.request(rtype='KEYS') -        if result: -            result = result.split('\n') -        return result +        if not result: +            return [] +        return result.split('\n')      def put(self, key, val):          param = b' '.join([base64.b64encode(i.encode()) @@ -450,6 +503,7 @@ class JoyentMetadataSocketClient(JoyentMetadataClient):          sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)          sock.connect(self.socketpath)          self.fp = sock.makefile('rwb') +        self._negotiate()      def exists(self):          return os.path.exists(self.socketpath) @@ -459,8 +513,9 @@ class JoyentMetadataSocketClient(JoyentMetadataClient):  class JoyentMetadataSerialClient(JoyentMetadataClient): -    def __init__(self, device, timeout=10, smartos_type=SMARTOS_ENV_KVM): -        super(JoyentMetadataSerialClient, self).__init__(smartos_type) +    def __init__(self, device, timeout=10, smartos_type=SMARTOS_ENV_KVM, +                 fp=None): +        super(JoyentMetadataSerialClient, self).__init__(smartos_type, fp)          self.device = device          self.timeout = timeout @@ -468,10 +523,51 @@ class JoyentMetadataSerialClient(JoyentMetadataClient):          return os.path.exists(self.device)      def open_transport(self): -        ser = serial.Serial(self.device, timeout=self.timeout) -        if not ser.isOpen(): -            raise SystemError("Unable to open %s" % self.device) -        self.fp = ser +        if self.fp is None: +            ser = serial.Serial(self.device, timeout=self.timeout) +            if not ser.isOpen(): +                raise SystemError("Unable to open %s" % self.device) +            self.fp = ser +            fcntl.lockf(ser, fcntl.LOCK_EX) +        self._flush() +        self._negotiate() + +    def _flush(self): +        LOG.debug('Flushing input') +        # Read any pending data +        timeout = self.fp.timeout +        self.fp.timeout = 0.1 +        while True: +            try: +                self._readline() +            except JoyentMetadataTimeoutException: +                break +        LOG.debug('Input empty') + +        # Send a newline and expect "invalid command".  Keep trying until +        # successful.  Retry rather frequently so that the "Is the host +        # metadata service running" appears on the console soon after someone +        # attaches in an effort to debug. +        if timeout > 5: +            self.fp.timeout = 5 +        else: +            self.fp.timeout = timeout +        while True: +            LOG.debug('Writing newline, expecting "invalid command"') +            self._write('\n') +            try: +                response = self._readline() +                if response == 'invalid command': +                    break +                if response == 'FAILURE': +                    LOG.debug('Got "FAILURE".  Retrying.') +                    continue +                LOG.warning('Unexpected response "%s" during flush', response) +            except JoyentMetadataTimeoutException: +                LOG.warning('Timeout while initializing metadata client. ' + +                            'Is the host metadata service running?') +        LOG.debug('Got "invalid command".  Flush complete.') +        self.fp.timeout = timeout      def __repr__(self):          return "%s(device=%s, timeout=%s)" % ( @@ -650,7 +746,7 @@ def get_smartos_environ(uname_version=None, product_name=None):      # report 'BrandZ virtual linux' as the kernel version      if uname_version is None:          uname_version = uname[3] -    if uname_version.lower() == 'brandz virtual linux': +    if uname_version == 'BrandZ virtual linux':          return SMARTOS_ENV_LX_BRAND      if product_name is None: @@ -658,7 +754,7 @@ def get_smartos_environ(uname_version=None, product_name=None):      else:          system_type = product_name -    if system_type and 'smartdc' in system_type.lower(): +    if system_type and system_type.startswith('SmartDC'):          return SMARTOS_ENV_KVM      return None @@ -666,7 +762,8 @@ def get_smartos_environ(uname_version=None, product_name=None):  # Convert SMARTOS 'sdc:nics' data to network_config yaml  def convert_smartos_network_data(network_data=None, -                                 dns_servers=None, dns_domain=None): +                                 dns_servers=None, dns_domain=None, +                                 routes=None):      """Return a dictionary of network_config by parsing provided         SMARTOS sdc:nics configuration data @@ -684,6 +781,10 @@ def convert_smartos_network_data(network_data=None,      keys are related to ip configuration.  For each ip in the 'ips' list      we create a subnet entry under 'subnets' pairing the ip to a one in      the 'gateways' list. + +    Each route in sdc:routes is mapped to a route on each interface. +    The sdc:routes properties 'dst' and 'gateway' map to 'network' and +    'gateway'.  The 'linklocal' sdc:routes property is ignored.      """      valid_keys = { @@ -706,6 +807,10 @@ def convert_smartos_network_data(network_data=None,              'scope',              'type',          ], +        'route': [ +            'network', +            'gateway', +        ],      }      if dns_servers: @@ -720,6 +825,9 @@ def convert_smartos_network_data(network_data=None,      else:          dns_domain = [] +    if not routes: +        routes = [] +      def is_valid_ipv4(addr):          return '.' in addr @@ -746,6 +854,7 @@ def convert_smartos_network_data(network_data=None,              if ip == "dhcp":                  subnet = {'type': 'dhcp4'}              else: +                routeents = []                  subnet = dict((k, v) for k, v in nic.items()                                if k in valid_keys['subnet'])                  subnet.update({ @@ -767,6 +876,25 @@ def convert_smartos_network_data(network_data=None,                              pgws[proto]['gw'] = gateways[0]                              subnet.update({'gateway': pgws[proto]['gw']}) +                for route in routes: +                    rcfg = dict((k, v) for k, v in route.items() +                                if k in valid_keys['route']) +                    # Linux uses the value of 'gateway' to determine +                    # automatically if the route is a forward/next-hop +                    # (non-local IP for gateway) or an interface/resolver +                    # (local IP for gateway).  So we can ignore the +                    # 'interface' attribute of sdc:routes, because SDC +                    # guarantees that the gateway is a local IP for +                    # "interface=true". +                    # +                    # Eventually we should be smart and compare "gateway" +                    # to see if it's in the prefix.  We can then smartly +                    # add or not-add this route.  But for now, +                    # when in doubt, use brute force! Routes for everyone! +                    rcfg.update({'network': route['dst']}) +                    routeents.append(rcfg) +                    subnet.update({'routes': routeents}) +              subnets.append(subnet)          cfg.update({'subnets': subnets})          config.append(cfg) @@ -810,12 +938,14 @@ if __name__ == "__main__":              keyname = SMARTOS_ATTRIB_JSON[key]              data[key] = client.get_json(keyname)          elif key == "network_config": -            for depkey in ('network-data', 'dns_servers', 'dns_domain'): +            for depkey in ('network-data', 'dns_servers', 'dns_domain', +                           'routes'):                  load_key(client, depkey, data)              data[key] = convert_smartos_network_data(                  network_data=data['network-data'],                  dns_servers=data['dns_servers'], -                dns_domain=data['dns_domain']) +                dns_domain=data['dns_domain'], +                routes=data['routes'])          else:              if key in SMARTOS_ATTRIB_MAP:                  keyname, strip = SMARTOS_ATTRIB_MAP[key] diff --git a/cloudinit/sources/__init__.py b/cloudinit/sources/__init__.py index df0b374a..90d74575 100644 --- a/cloudinit/sources/__init__.py +++ b/cloudinit/sources/__init__.py @@ -9,6 +9,7 @@  # This file is part of cloud-init. See LICENSE file for license information.  import abc +from collections import namedtuple  import copy  import json  import os @@ -17,6 +18,7 @@ import six  from cloudinit.atomic_helper import write_json  from cloudinit import importer  from cloudinit import log as logging +from cloudinit import net  from cloudinit import type_utils  from cloudinit import user_data as ud  from cloudinit import util @@ -41,6 +43,8 @@ INSTANCE_JSON_FILE = 'instance-data.json'  # Key which can be provide a cloud's official product name to cloud-init  METADATA_CLOUD_NAME_KEY = 'cloud-name' +UNSET = "_unset" +  LOG = logging.getLogger(__name__) @@ -48,6 +52,11 @@ class DataSourceNotFoundException(Exception):      pass +class InvalidMetaDataException(Exception): +    """Raised when metadata is broken, unavailable or disabled.""" +    pass + +  def process_base64_metadata(metadata, key_path=''):      """Strip ci-b64 prefix and return metadata with base64-encoded-keys set."""      md_copy = copy.deepcopy(metadata) @@ -68,6 +77,10 @@ def process_base64_metadata(metadata, key_path=''):      return md_copy +URLParams = namedtuple( +    'URLParms', ['max_wait_seconds', 'timeout_seconds', 'num_retries']) + +  @six.add_metaclass(abc.ABCMeta)  class DataSource(object): @@ -81,6 +94,14 @@ class DataSource(object):      # Cached cloud_name as determined by _get_cloud_name      _cloud_name = None +    # Track the discovered fallback nic for use in configuration generation. +    _fallback_interface = None + +    # read_url_params +    url_max_wait = -1   # max_wait < 0 means do not wait +    url_timeout = 10    # timeout for each metadata url read attempt +    url_retries = 5     # number of times to retry url upon 404 +      def __init__(self, sys_cfg, distro, paths, ud_proc=None):          self.sys_cfg = sys_cfg          self.distro = distro @@ -128,6 +149,14 @@ class DataSource(object):                  'meta-data': self.metadata,                  'user-data': self.get_userdata_raw(),                  'vendor-data': self.get_vendordata_raw()}} +        if hasattr(self, 'network_json'): +            network_json = getattr(self, 'network_json') +            if network_json != UNSET: +                instance_data['ds']['network_json'] = network_json +        if hasattr(self, 'ec2_metadata'): +            ec2_metadata = getattr(self, 'ec2_metadata') +            if ec2_metadata != UNSET: +                instance_data['ds']['ec2_metadata'] = ec2_metadata          instance_data.update(              self._get_standardized_metadata())          try: @@ -149,6 +178,42 @@ class DataSource(object):              'Subclasses of DataSource must implement _get_data which'              ' sets self.metadata, vendordata_raw and userdata_raw.') +    def get_url_params(self): +        """Return the Datasource's prefered url_read parameters. + +        Subclasses may override url_max_wait, url_timeout, url_retries. + +        @return: A URLParams object with max_wait_seconds, timeout_seconds, +            num_retries. +        """ +        max_wait = self.url_max_wait +        try: +            max_wait = int(self.ds_cfg.get("max_wait", self.url_max_wait)) +        except ValueError: +            util.logexc( +                LOG, "Config max_wait '%s' is not an int, using default '%s'", +                self.ds_cfg.get("max_wait"), max_wait) + +        timeout = self.url_timeout +        try: +            timeout = max( +                0, int(self.ds_cfg.get("timeout", self.url_timeout))) +        except ValueError: +            timeout = self.url_timeout +            util.logexc( +                LOG, "Config timeout '%s' is not an int, using default '%s'", +                self.ds_cfg.get('timeout'), timeout) + +        retries = self.url_retries +        try: +            retries = int(self.ds_cfg.get("retries", self.url_retries)) +        except Exception: +            util.logexc( +                LOG, "Config retries '%s' is not an int, using default '%s'", +                self.ds_cfg.get('retries'), retries) + +        return URLParams(max_wait, timeout, retries) +      def get_userdata(self, apply_filter=False):          if self.userdata is None:              self.userdata = self.ud_proc.process(self.get_userdata_raw()) @@ -162,6 +227,17 @@ class DataSource(object):          return self.vendordata      @property +    def fallback_interface(self): +        """Determine the network interface used during local network config.""" +        if self._fallback_interface is None: +            self._fallback_interface = net.find_fallback_nic() +            if self._fallback_interface is None: +                LOG.warning( +                    "Did not find a fallback interface on %s.", +                    self.cloud_name) +        return self._fallback_interface + +    @property      def cloud_name(self):          """Return lowercase cloud name as determined by the datasource. diff --git a/cloudinit/sources/helpers/azure.py b/cloudinit/sources/helpers/azure.py index 90c12df1..e5696b1f 100644 --- a/cloudinit/sources/helpers/azure.py +++ b/cloudinit/sources/helpers/azure.py @@ -14,6 +14,7 @@ from cloudinit import temp_utils  from contextlib import contextmanager  from xml.etree import ElementTree +from cloudinit import url_helper  from cloudinit import util  LOG = logging.getLogger(__name__) @@ -55,14 +56,14 @@ class AzureEndpointHttpClient(object):          if secure:              headers = self.headers.copy()              headers.update(self.extra_secure_headers) -        return util.read_file_or_url(url, headers=headers) +        return url_helper.read_file_or_url(url, headers=headers)      def post(self, url, data=None, extra_headers=None):          headers = self.headers          if extra_headers is not None:              headers = self.headers.copy()              headers.update(extra_headers) -        return util.read_file_or_url(url, data=data, headers=headers) +        return url_helper.read_file_or_url(url, data=data, headers=headers)  class GoalState(object): diff --git a/cloudinit/sources/helpers/digitalocean.py b/cloudinit/sources/helpers/digitalocean.py index 693f8d5c..0e7cccac 100644 --- a/cloudinit/sources/helpers/digitalocean.py +++ b/cloudinit/sources/helpers/digitalocean.py @@ -41,10 +41,9 @@ def assign_ipv4_link_local(nic=None):                             "address")      try: -        (result, _err) = util.subp(ip_addr_cmd) +        util.subp(ip_addr_cmd)          LOG.debug("assigned ip4LL address '%s' to '%s'", addr, nic) - -        (result, _err) = util.subp(ip_link_cmd) +        util.subp(ip_link_cmd)          LOG.debug("brought device '%s' up", nic)      except Exception:          util.logexc(LOG, "ip4LL address assignment of '%s' to '%s' failed." @@ -75,7 +74,7 @@ def del_ipv4_link_local(nic=None):      ip_addr_cmd = ['ip', 'addr', 'flush', 'dev', nic]      try: -        (result, _err) = util.subp(ip_addr_cmd) +        util.subp(ip_addr_cmd)          LOG.debug("removed ip4LL addresses from %s", nic)      except Exception as e: diff --git a/cloudinit/sources/helpers/openstack.py b/cloudinit/sources/helpers/openstack.py index 26f3168d..a4cf0667 100644 --- a/cloudinit/sources/helpers/openstack.py +++ b/cloudinit/sources/helpers/openstack.py @@ -638,7 +638,7 @@ def convert_net_json(network_json=None, known_macs=None):              known_macs = net.get_interfaces_by_mac()          # go through and fill out the link_id_info with names -        for link_id, info in link_id_info.items(): +        for _link_id, info in link_id_info.items():              if info.get('name'):                  continue              if info.get('mac') in known_macs: diff --git a/cloudinit/sources/helpers/vmware/imc/config_nic.py b/cloudinit/sources/helpers/vmware/imc/config_nic.py index 2d8900e2..3ef8c624 100644 --- a/cloudinit/sources/helpers/vmware/imc/config_nic.py +++ b/cloudinit/sources/helpers/vmware/imc/config_nic.py @@ -73,7 +73,7 @@ class NicConfigurator(object):          The mac address(es) are in the lower case          """          cmd = ['ip', 'addr', 'show'] -        (output, err) = util.subp(cmd) +        output, _err = util.subp(cmd)          sections = re.split(r'\n\d+: ', '\n' + output)[1:]          macPat = r'link/ether (([0-9A-Fa-f]{2}[:]){5}([0-9A-Fa-f]{2}))' diff --git a/cloudinit/sources/helpers/vmware/imc/config_passwd.py b/cloudinit/sources/helpers/vmware/imc/config_passwd.py index 75cfbaaf..8c91fa41 100644 --- a/cloudinit/sources/helpers/vmware/imc/config_passwd.py +++ b/cloudinit/sources/helpers/vmware/imc/config_passwd.py @@ -56,10 +56,10 @@ class PasswordConfigurator(object):          LOG.info('Expiring password.')          for user in uidUserList:              try: -                out, err = util.subp(['passwd', '--expire', user]) +                util.subp(['passwd', '--expire', user])              except util.ProcessExecutionError as e:                  if os.path.exists('/usr/bin/chage'): -                    out, e = util.subp(['chage', '-d', '0', user]) +                    util.subp(['chage', '-d', '0', user])                  else:                      LOG.warning('Failed to expire password for %s with error: '                                  '%s', user, e) diff --git a/cloudinit/sources/helpers/vmware/imc/guestcust_util.py b/cloudinit/sources/helpers/vmware/imc/guestcust_util.py index 44075255..a590f323 100644 --- a/cloudinit/sources/helpers/vmware/imc/guestcust_util.py +++ b/cloudinit/sources/helpers/vmware/imc/guestcust_util.py @@ -91,7 +91,7 @@ def enable_nics(nics):      for attempt in range(0, enableNicsWaitRetries):          logger.debug("Trying to connect interfaces, attempt %d", attempt) -        (out, err) = set_customization_status( +        (out, _err) = set_customization_status(              GuestCustStateEnum.GUESTCUST_STATE_RUNNING,              GuestCustEventEnum.GUESTCUST_EVENT_ENABLE_NICS,              nics) @@ -104,7 +104,7 @@ def enable_nics(nics):              return          for count in range(0, enableNicsWaitCount): -            (out, err) = set_customization_status( +            (out, _err) = set_customization_status(                  GuestCustStateEnum.GUESTCUST_STATE_RUNNING,                  GuestCustEventEnum.GUESTCUST_EVENT_QUERY_NICS,                  nics) diff --git a/cloudinit/sources/tests/test_init.py b/cloudinit/sources/tests/test_init.py index e7fda22a..d5bc98a4 100644 --- a/cloudinit/sources/tests/test_init.py +++ b/cloudinit/sources/tests/test_init.py @@ -17,6 +17,7 @@ from cloudinit import util  class DataSourceTestSubclassNet(DataSource):      dsname = 'MyTestSubclass' +    url_max_wait = 55      def __init__(self, sys_cfg, distro, paths, custom_userdata=None):          super(DataSourceTestSubclassNet, self).__init__( @@ -70,8 +71,7 @@ class TestDataSource(CiTestCase):          """Init uses DataSource.dsname for sourcing ds_cfg."""          sys_cfg = {'datasource': {'MyTestSubclass': {'key2': False}}}          distro = 'distrotest'  # generally should be a Distro object -        paths = Paths({}) -        datasource = DataSourceTestSubclassNet(sys_cfg, distro, paths) +        datasource = DataSourceTestSubclassNet(sys_cfg, distro, self.paths)          self.assertEqual({'key2': False}, datasource.ds_cfg)      def test_str_is_classname(self): @@ -81,6 +81,91 @@ class TestDataSource(CiTestCase):              'DataSourceTestSubclassNet',              str(DataSourceTestSubclassNet('', '', self.paths))) +    def test_datasource_get_url_params_defaults(self): +        """get_url_params default url config settings for the datasource.""" +        params = self.datasource.get_url_params() +        self.assertEqual(params.max_wait_seconds, self.datasource.url_max_wait) +        self.assertEqual(params.timeout_seconds, self.datasource.url_timeout) +        self.assertEqual(params.num_retries, self.datasource.url_retries) + +    def test_datasource_get_url_params_subclassed(self): +        """Subclasses can override get_url_params defaults.""" +        sys_cfg = {'datasource': {'MyTestSubclass': {'key2': False}}} +        distro = 'distrotest'  # generally should be a Distro object +        datasource = DataSourceTestSubclassNet(sys_cfg, distro, self.paths) +        expected = (datasource.url_max_wait, datasource.url_timeout, +                    datasource.url_retries) +        url_params = datasource.get_url_params() +        self.assertNotEqual(self.datasource.get_url_params(), url_params) +        self.assertEqual(expected, url_params) + +    def test_datasource_get_url_params_ds_config_override(self): +        """Datasource configuration options can override url param defaults.""" +        sys_cfg = { +            'datasource': { +                'MyTestSubclass': { +                    'max_wait': '1', 'timeout': '2', 'retries': '3'}}} +        datasource = DataSourceTestSubclassNet( +            sys_cfg, self.distro, self.paths) +        expected = (1, 2, 3) +        url_params = datasource.get_url_params() +        self.assertNotEqual( +            (datasource.url_max_wait, datasource.url_timeout, +             datasource.url_retries), +            url_params) +        self.assertEqual(expected, url_params) + +    def test_datasource_get_url_params_is_zero_or_greater(self): +        """get_url_params ignores timeouts with a value below 0.""" +        # Set an override that is below 0 which gets ignored. +        sys_cfg = {'datasource': {'_undef': {'timeout': '-1'}}} +        datasource = DataSource(sys_cfg, self.distro, self.paths) +        (_max_wait, timeout, _retries) = datasource.get_url_params() +        self.assertEqual(0, timeout) + +    def test_datasource_get_url_uses_defaults_on_errors(self): +        """On invalid system config values for url_params defaults are used.""" +        # All invalid values should be logged +        sys_cfg = {'datasource': { +            '_undef': { +                'max_wait': 'nope', 'timeout': 'bug', 'retries': 'nonint'}}} +        datasource = DataSource(sys_cfg, self.distro, self.paths) +        url_params = datasource.get_url_params() +        expected = (datasource.url_max_wait, datasource.url_timeout, +                    datasource.url_retries) +        self.assertEqual(expected, url_params) +        logs = self.logs.getvalue() +        expected_logs = [ +            "Config max_wait 'nope' is not an int, using default '-1'", +            "Config timeout 'bug' is not an int, using default '10'", +            "Config retries 'nonint' is not an int, using default '5'", +        ] +        for log in expected_logs: +            self.assertIn(log, logs) + +    @mock.patch('cloudinit.sources.net.find_fallback_nic') +    def test_fallback_interface_is_discovered(self, m_get_fallback_nic): +        """The fallback_interface is discovered via find_fallback_nic.""" +        m_get_fallback_nic.return_value = 'nic9' +        self.assertEqual('nic9', self.datasource.fallback_interface) + +    @mock.patch('cloudinit.sources.net.find_fallback_nic') +    def test_fallback_interface_logs_undiscovered(self, m_get_fallback_nic): +        """Log a warning when fallback_interface can not discover the nic.""" +        self.datasource._cloud_name = 'MySupahCloud' +        m_get_fallback_nic.return_value = None  # Couldn't discover nic +        self.assertIsNone(self.datasource.fallback_interface) +        self.assertEqual( +            'WARNING: Did not find a fallback interface on MySupahCloud.\n', +            self.logs.getvalue()) + +    @mock.patch('cloudinit.sources.net.find_fallback_nic') +    def test_wb_fallback_interface_is_cached(self, m_get_fallback_nic): +        """The fallback_interface is cached and won't be rediscovered.""" +        self.datasource._fallback_interface = 'nic10' +        self.assertEqual('nic10', self.datasource.fallback_interface) +        m_get_fallback_nic.assert_not_called() +      def test__get_data_unimplemented(self):          """Raise an error when _get_data is not implemented."""          with self.assertRaises(NotImplementedError) as context_manager: @@ -278,7 +363,7 @@ class TestDataSource(CiTestCase):          base_args = get_args(DataSource.get_hostname)  # pylint: disable=W1505          # Import all DataSource subclasses so we can inspect them.          modules = util.find_modules(os.path.dirname(os.path.dirname(__file__))) -        for loc, name in modules.items(): +        for _loc, name in modules.items():              mod_locs, _ = importer.find_module(name, ['cloudinit.sources'], [])              if mod_locs:                  importer.import_module(mod_locs[0]) | 
