1# Copyright (C) 2009-2010 Canonical Ltd.
2# Copyright (C) 2012 Hewlett-Packard Development Company, L.P.
3# Copyright (C) 2012 Yahoo! Inc.
4#
5# Author: Scott Moser <scott.moser@canonical.com>
6# Author: Juerg Hafliger <juerg.haefliger@hp.com>
7# Author: Joshua Harlow <harlowja@yahoo-inc.com>
8#
9# This file is part of cloud-init. See LICENSE file for license information.
10
11import copy
12import os
13import time
14
15from cloudinit import dmi
16from cloudinit import ec2_utils as ec2
17from cloudinit import log as logging
18from cloudinit import net
19from cloudinit.net.dhcp import EphemeralDHCPv4, NoDHCPLeaseError
20from cloudinit import sources
21from cloudinit import url_helper as uhelp
22from cloudinit import util
23from cloudinit import warnings
24from cloudinit.event import EventScope, EventType
25
26LOG = logging.getLogger(__name__)
27
28SKIP_METADATA_URL_CODES = frozenset([uhelp.NOT_FOUND])
29
30STRICT_ID_PATH = ("datasource", "Ec2", "strict_id")
31STRICT_ID_DEFAULT = "warn"
32
33API_TOKEN_ROUTE = 'latest/api/token'
34AWS_TOKEN_TTL_SECONDS = '21600'
35AWS_TOKEN_PUT_HEADER = 'X-aws-ec2-metadata-token'
36AWS_TOKEN_REQ_HEADER = AWS_TOKEN_PUT_HEADER + '-ttl-seconds'
37AWS_TOKEN_REDACT = [AWS_TOKEN_PUT_HEADER, AWS_TOKEN_REQ_HEADER]
38
39
40class CloudNames(object):
41    ALIYUN = "aliyun"
42    AWS = "aws"
43    BRIGHTBOX = "brightbox"
44    ZSTACK = "zstack"
45    E24CLOUD = "e24cloud"
46    # UNKNOWN indicates no positive id.  If strict_id is 'warn' or 'false',
47    # then an attempt at the Ec2 Metadata service will be made.
48    UNKNOWN = "unknown"
49    # NO_EC2_METADATA indicates this platform does not have a Ec2 metadata
50    # service available. No attempt at the Ec2 Metadata service will be made.
51    NO_EC2_METADATA = "no-ec2-metadata"
52
53
54class DataSourceEc2(sources.DataSource):
55
56    dsname = 'Ec2'
57    # Default metadata urls that will be used if none are provided
58    # They will be checked for 'resolveability' and some of the
59    # following may be discarded if they do not resolve
60    metadata_urls = ["http://169.254.169.254", "http://instance-data.:8773"]
61
62    # The minimum supported metadata_version from the ec2 metadata apis
63    min_metadata_version = '2009-04-04'
64
65    # Priority ordered list of additional metadata versions which will be tried
66    # for extended metadata content. IPv6 support comes in 2016-09-02
67    extended_metadata_versions = ['2018-09-24', '2016-09-02']
68
69    # Setup read_url parameters per get_url_params.
70    url_max_wait = 120
71    url_timeout = 50
72
73    _api_token = None  # API token for accessing the metadata service
74    _network_config = sources.UNSET  # Used to cache calculated network cfg v1
75
76    # Whether we want to get network configuration from the metadata service.
77    perform_dhcp_setup = False
78
79    supported_update_events = {EventScope.NETWORK: {
80        EventType.BOOT_NEW_INSTANCE,
81        EventType.BOOT,
82        EventType.BOOT_LEGACY,
83        EventType.HOTPLUG,
84    }}
85
86    def __init__(self, sys_cfg, distro, paths):
87        super(DataSourceEc2, self).__init__(sys_cfg, distro, paths)
88        self.metadata_address = None
89
90    def _get_cloud_name(self):
91        """Return the cloud name as identified during _get_data."""
92        return identify_platform()
93
94    def _get_data(self):
95        strict_mode, _sleep = read_strict_mode(
96            util.get_cfg_by_path(self.sys_cfg, STRICT_ID_PATH,
97                                 STRICT_ID_DEFAULT), ("warn", None))
98
99        LOG.debug("strict_mode: %s, cloud_name=%s cloud_platform=%s",
100                  strict_mode, self.cloud_name, self.platform)
101        if strict_mode == "true" and self.cloud_name == CloudNames.UNKNOWN:
102            return False
103        elif self.cloud_name == CloudNames.NO_EC2_METADATA:
104            return False
105
106        if self.perform_dhcp_setup:  # Setup networking in init-local stage.
107            if util.is_FreeBSD():
108                LOG.debug("FreeBSD doesn't support running dhclient with -sf")
109                return False
110            try:
111                with EphemeralDHCPv4(self.fallback_interface):
112                    self._crawled_metadata = util.log_time(
113                        logfunc=LOG.debug, msg='Crawl of metadata service',
114                        func=self.crawl_metadata)
115            except NoDHCPLeaseError:
116                return False
117        else:
118            self._crawled_metadata = util.log_time(
119                logfunc=LOG.debug, msg='Crawl of metadata service',
120                func=self.crawl_metadata)
121        if not self._crawled_metadata:
122            return False
123        self.metadata = self._crawled_metadata.get('meta-data', None)
124        self.userdata_raw = self._crawled_metadata.get('user-data', None)
125        self.identity = self._crawled_metadata.get(
126            'dynamic', {}).get('instance-identity', {}).get('document', {})
127        return True
128
129    def is_classic_instance(self):
130        """Report if this instance type is Ec2 Classic (non-vpc)."""
131        if not self.metadata:
132            # Can return False on inconclusive as we are also called in
133            # network_config where metadata will be present.
134            # Secondary call site is in packaging postinst script.
135            return False
136        ifaces_md = self.metadata.get('network', {}).get('interfaces', {})
137        for _mac, mac_data in ifaces_md.get('macs', {}).items():
138            if 'vpc-id' in mac_data:
139                return False
140        return True
141
142    @property
143    def launch_index(self):
144        if not self.metadata:
145            return None
146        return self.metadata.get('ami-launch-index')
147
148    @property
149    def platform(self):
150        # Handle upgrade path of pickled ds
151        if not hasattr(self, '_platform_type'):
152            self._platform_type = DataSourceEc2.dsname.lower()
153        if not self._platform_type:
154            self._platform_type = DataSourceEc2.dsname.lower()
155        return self._platform_type
156
157    def get_metadata_api_version(self):
158        """Get the best supported api version from the metadata service.
159
160        Loop through all extended support metadata versions in order and
161        return the most-fully featured metadata api version discovered.
162
163        If extended_metadata_versions aren't present, return the datasource's
164        min_metadata_version.
165        """
166        # Assumes metadata service is already up
167        url_tmpl = '{0}/{1}/meta-data/instance-id'
168        headers = self._get_headers()
169        for api_ver in self.extended_metadata_versions:
170            url = url_tmpl.format(self.metadata_address, api_ver)
171            try:
172                resp = uhelp.readurl(url=url, headers=headers,
173                                     headers_redact=AWS_TOKEN_REDACT)
174            except uhelp.UrlError as e:
175                LOG.debug('url %s raised exception %s', url, e)
176            else:
177                if resp.code == 200:
178                    LOG.debug('Found preferred metadata version %s', api_ver)
179                    return api_ver
180                elif resp.code == 404:
181                    msg = 'Metadata api version %s not present. Headers: %s'
182                    LOG.debug(msg, api_ver, resp.headers)
183        return self.min_metadata_version
184
185    def get_instance_id(self):
186        if self.cloud_name == CloudNames.AWS:
187            # Prefer the ID from the instance identity document, but fall back
188            if not getattr(self, 'identity', None):
189                # If re-using cached datasource, it's get_data run didn't
190                # setup self.identity. So we need to do that now.
191                api_version = self.get_metadata_api_version()
192                self.identity = ec2.get_instance_identity(
193                    api_version, self.metadata_address,
194                    headers_cb=self._get_headers,
195                    headers_redact=AWS_TOKEN_REDACT,
196                    exception_cb=self._refresh_stale_aws_token_cb).get(
197                        'document', {})
198            return self.identity.get(
199                'instanceId', self.metadata['instance-id'])
200        else:
201            return self.metadata['instance-id']
202
203    def _maybe_fetch_api_token(self, mdurls, timeout=None, max_wait=None):
204        """ Get an API token for EC2 Instance Metadata Service.
205
206        On EC2. IMDS will always answer an API token, unless
207        the instance owner has disabled the IMDS HTTP endpoint or
208        the network topology conflicts with the configured hop-limit.
209        """
210        if self.cloud_name != CloudNames.AWS:
211            return
212
213        urls = []
214        url2base = {}
215        url_path = API_TOKEN_ROUTE
216        request_method = 'PUT'
217        for url in mdurls:
218            cur = '{0}/{1}'.format(url, url_path)
219            urls.append(cur)
220            url2base[cur] = url
221
222        # use the self._imds_exception_cb to check for Read errors
223        LOG.debug('Fetching Ec2 IMDSv2 API Token')
224
225        response = None
226        url = None
227        url_params = self.get_url_params()
228        try:
229            url, response = uhelp.wait_for_url(
230                urls=urls, max_wait=url_params.max_wait_seconds,
231                timeout=url_params.timeout_seconds, status_cb=LOG.warning,
232                headers_cb=self._get_headers,
233                exception_cb=self._imds_exception_cb,
234                request_method=request_method,
235                headers_redact=AWS_TOKEN_REDACT)
236        except uhelp.UrlError:
237            # We use the raised exception to interupt the retry loop.
238            # Nothing else to do here.
239            pass
240
241        if url and response:
242            self._api_token = response
243            return url2base[url]
244
245        # If we get here, then wait_for_url timed out, waiting for IMDS
246        # or the IMDS HTTP endpoint is disabled
247        return None
248
249    def wait_for_metadata_service(self):
250        mcfg = self.ds_cfg
251
252        url_params = self.get_url_params()
253        if url_params.max_wait_seconds <= 0:
254            return False
255
256        # Remove addresses from the list that wont resolve.
257        mdurls = mcfg.get("metadata_urls", self.metadata_urls)
258        filtered = [x for x in mdurls if util.is_resolvable_url(x)]
259
260        if set(filtered) != set(mdurls):
261            LOG.debug("Removed the following from metadata urls: %s",
262                      list((set(mdurls) - set(filtered))))
263
264        if len(filtered):
265            mdurls = filtered
266        else:
267            LOG.warning("Empty metadata url list! using default list")
268            mdurls = self.metadata_urls
269
270        # try the api token path first
271        metadata_address = self._maybe_fetch_api_token(mdurls)
272        # When running on EC2, we always access IMDS with an API token.
273        # If we could not get an API token, then we assume the IMDS
274        # endpoint was disabled and we move on without a data source.
275        # Fallback to IMDSv1 if not running on EC2
276        if not metadata_address and self.cloud_name != CloudNames.AWS:
277            # if we can't get a token, use instance-id path
278            urls = []
279            url2base = {}
280            url_path = '{ver}/meta-data/instance-id'.format(
281                ver=self.min_metadata_version)
282            request_method = 'GET'
283            for url in mdurls:
284                cur = '{0}/{1}'.format(url, url_path)
285                urls.append(cur)
286                url2base[cur] = url
287
288            start_time = time.time()
289            url, _ = uhelp.wait_for_url(
290                urls=urls, max_wait=url_params.max_wait_seconds,
291                timeout=url_params.timeout_seconds, status_cb=LOG.warning,
292                headers_redact=AWS_TOKEN_REDACT, headers_cb=self._get_headers,
293                request_method=request_method)
294
295            if url:
296                metadata_address = url2base[url]
297
298        if metadata_address:
299            self.metadata_address = metadata_address
300            LOG.debug("Using metadata source: '%s'", self.metadata_address)
301        elif self.cloud_name == CloudNames.AWS:
302            LOG.warning("IMDS's HTTP endpoint is probably disabled")
303        else:
304            LOG.critical("Giving up on md from %s after %s seconds",
305                         urls, int(time.time() - start_time))
306
307        return bool(metadata_address)
308
309    def device_name_to_device(self, name):
310        # Consult metadata service, that has
311        #  ephemeral0: sdb
312        # and return 'sdb' for input 'ephemeral0'
313        if 'block-device-mapping' not in self.metadata:
314            return None
315
316        # Example:
317        # 'block-device-mapping':
318        # {'ami': '/dev/sda1',
319        # 'ephemeral0': '/dev/sdb',
320        # 'root': '/dev/sda1'}
321        found = None
322        bdm = self.metadata['block-device-mapping']
323        if not isinstance(bdm, dict):
324            LOG.debug("block-device-mapping not a dictionary: '%s'", bdm)
325            return None
326
327        for (entname, device) in bdm.items():
328            if entname == name:
329                found = device
330                break
331            # LP: #513842 mapping in Euca has 'ephemeral' not 'ephemeral0'
332            if entname == "ephemeral" and name == "ephemeral0":
333                found = device
334
335        if found is None:
336            LOG.debug("Unable to convert %s to a device", name)
337            return None
338
339        ofound = found
340        if not found.startswith("/"):
341            found = "/dev/%s" % found
342
343        if os.path.exists(found):
344            return found
345
346        remapped = self._remap_device(os.path.basename(found))
347        if remapped:
348            LOG.debug("Remapped device name %s => %s", found, remapped)
349            return remapped
350
351        # On t1.micro, ephemeral0 will appear in block-device-mapping from
352        # metadata, but it will not exist on disk (and never will)
353        # at this point, we've verified that the path did not exist
354        # in the special case of 'ephemeral0' return None to avoid bogus
355        # fstab entry (LP: #744019)
356        if name == "ephemeral0":
357            return None
358        return ofound
359
360    @property
361    def availability_zone(self):
362        try:
363            if self.cloud_name == CloudNames.AWS:
364                return self.identity.get(
365                    'availabilityZone',
366                    self.metadata['placement']['availability-zone'])
367            else:
368                return self.metadata['placement']['availability-zone']
369        except KeyError:
370            return None
371
372    @property
373    def region(self):
374        if self.cloud_name == CloudNames.AWS:
375            region = self.identity.get('region')
376            # Fallback to trimming the availability zone if region is missing
377            if self.availability_zone and not region:
378                region = self.availability_zone[:-1]
379            return region
380        else:
381            az = self.availability_zone
382            if az is not None:
383                return az[:-1]
384        return None
385
386    def activate(self, cfg, is_new_instance):
387        if not is_new_instance:
388            return
389        if self.cloud_name == CloudNames.UNKNOWN:
390            warn_if_necessary(
391                util.get_cfg_by_path(cfg, STRICT_ID_PATH, STRICT_ID_DEFAULT),
392                cfg)
393
394    @property
395    def network_config(self):
396        """Return a network config dict for rendering ENI or netplan files."""
397        if self._network_config != sources.UNSET:
398            return self._network_config
399
400        if self.metadata is None:
401            # this would happen if get_data hadn't been called. leave as UNSET
402            LOG.warning(
403                "Unexpected call to network_config when metadata is None.")
404            return None
405
406        result = None
407        no_network_metadata_on_aws = bool(
408            'network' not in self.metadata and
409            self.cloud_name == CloudNames.AWS)
410        if no_network_metadata_on_aws:
411            LOG.debug("Metadata 'network' not present:"
412                      " Refreshing stale metadata from prior to upgrade.")
413            util.log_time(
414                logfunc=LOG.debug, msg='Re-crawl of metadata service',
415                func=self.get_data)
416
417        iface = self.fallback_interface
418        net_md = self.metadata.get('network')
419        if isinstance(net_md, dict):
420            # SRU_BLOCKER: xenial, bionic and eoan should default
421            # apply_full_imds_network_config to False to retain original
422            # behavior on those releases.
423            result = convert_ec2_metadata_network_config(
424                net_md, fallback_nic=iface,
425                full_network_config=util.get_cfg_option_bool(
426                    self.ds_cfg, 'apply_full_imds_network_config', True))
427
428            # RELEASE_BLOCKER: xenial should drop the below if statement,
429            # because the issue being addressed doesn't exist pre-netplan.
430            # (This datasource doesn't implement check_instance_id() so the
431            # datasource object is recreated every boot; this means we don't
432            # need to modify update_events on cloud-init upgrade.)
433
434            # Non-VPC (aka Classic) Ec2 instances need to rewrite the
435            # network config file every boot due to MAC address change.
436            if self.is_classic_instance():
437                self.default_update_events = copy.deepcopy(
438                    self.default_update_events)
439                self.default_update_events[EventScope.NETWORK].add(
440                    EventType.BOOT)
441                self.default_update_events[EventScope.NETWORK].add(
442                    EventType.BOOT_LEGACY)
443        else:
444            LOG.warning("Metadata 'network' key not valid: %s.", net_md)
445        self._network_config = result
446
447        return self._network_config
448
449    @property
450    def fallback_interface(self):
451        if self._fallback_interface is None:
452            # fallback_nic was used at one point, so restored objects may
453            # have an attribute there. respect that if found.
454            _legacy_fbnic = getattr(self, 'fallback_nic', None)
455            if _legacy_fbnic:
456                self._fallback_interface = _legacy_fbnic
457                self.fallback_nic = None
458            else:
459                return super(DataSourceEc2, self).fallback_interface
460        return self._fallback_interface
461
462    def crawl_metadata(self):
463        """Crawl metadata service when available.
464
465        @returns: Dictionary of crawled metadata content containing the keys:
466          meta-data, user-data and dynamic.
467        """
468        if not self.wait_for_metadata_service():
469            return {}
470        api_version = self.get_metadata_api_version()
471        redact = AWS_TOKEN_REDACT
472        crawled_metadata = {}
473        if self.cloud_name == CloudNames.AWS:
474            exc_cb = self._refresh_stale_aws_token_cb
475            exc_cb_ud = self._skip_or_refresh_stale_aws_token_cb
476        else:
477            exc_cb = exc_cb_ud = None
478        try:
479            crawled_metadata['user-data'] = ec2.get_instance_userdata(
480                api_version, self.metadata_address,
481                headers_cb=self._get_headers, headers_redact=redact,
482                exception_cb=exc_cb_ud)
483            crawled_metadata['meta-data'] = ec2.get_instance_metadata(
484                api_version, self.metadata_address,
485                headers_cb=self._get_headers, headers_redact=redact,
486                exception_cb=exc_cb)
487            if self.cloud_name == CloudNames.AWS:
488                identity = ec2.get_instance_identity(
489                    api_version, self.metadata_address,
490                    headers_cb=self._get_headers, headers_redact=redact,
491                    exception_cb=exc_cb)
492                crawled_metadata['dynamic'] = {'instance-identity': identity}
493        except Exception:
494            util.logexc(
495                LOG, "Failed reading from metadata address %s",
496                self.metadata_address)
497            return {}
498        crawled_metadata['_metadata_api_version'] = api_version
499        return crawled_metadata
500
501    def _refresh_api_token(self, seconds=AWS_TOKEN_TTL_SECONDS):
502        """Request new metadata API token.
503        @param seconds: The lifetime of the token in seconds
504
505        @return: The API token or None if unavailable.
506        """
507        if self.cloud_name != CloudNames.AWS:
508            return None
509        LOG.debug("Refreshing Ec2 metadata API token")
510        request_header = {AWS_TOKEN_REQ_HEADER: seconds}
511        token_url = '{}/{}'.format(self.metadata_address, API_TOKEN_ROUTE)
512        try:
513            response = uhelp.readurl(token_url, headers=request_header,
514                                     headers_redact=AWS_TOKEN_REDACT,
515                                     request_method="PUT")
516        except uhelp.UrlError as e:
517            LOG.warning(
518                'Unable to get API token: %s raised exception %s',
519                token_url, e)
520            return None
521        return response.contents
522
523    def _skip_or_refresh_stale_aws_token_cb(self, msg, exception):
524        """Callback will not retry on SKIP_USERDATA_CODES or if no token
525           is available."""
526        retry = ec2.skip_retry_on_codes(
527            ec2.SKIP_USERDATA_CODES, msg, exception)
528        if not retry:
529            return False  # False raises exception
530        return self._refresh_stale_aws_token_cb(msg, exception)
531
532    def _refresh_stale_aws_token_cb(self, msg, exception):
533        """Exception handler for Ec2 to refresh token if token is stale."""
534        if isinstance(exception, uhelp.UrlError) and exception.code == 401:
535            # With _api_token as None, _get_headers will _refresh_api_token.
536            LOG.debug("Clearing cached Ec2 API token due to expiry")
537            self._api_token = None
538        return True  # always retry
539
540    def _imds_exception_cb(self, msg, exception=None):
541        """Fail quickly on proper AWS if IMDSv2 rejects API token request
542
543        Guidance from Amazon is that if IMDSv2 had disabled token requests
544        by returning a 403, or cloud-init malformed requests resulting in
545        other 40X errors, we want the datasource detection to fail quickly
546        without retries as those symptoms will likely not be resolved by
547        retries.
548
549        Exceptions such as requests.ConnectionError due to IMDS being
550        temporarily unroutable or unavailable will still retry due to the
551        callsite wait_for_url.
552        """
553        if isinstance(exception, uhelp.UrlError):
554            # requests.ConnectionError will have exception.code == None
555            if exception.code and exception.code >= 400:
556                if exception.code == 403:
557                    LOG.warning('Ec2 IMDS endpoint returned a 403 error. '
558                                'HTTP endpoint is disabled. Aborting.')
559                else:
560                    LOG.warning('Fatal error while requesting '
561                                'Ec2 IMDSv2 API tokens')
562                raise exception
563
564    def _get_headers(self, url=''):
565        """Return a dict of headers for accessing a url.
566
567        If _api_token is unset on AWS, attempt to refresh the token via a PUT
568        and then return the updated token header.
569        """
570        if self.cloud_name != CloudNames.AWS:
571            return {}
572        # Request a 6 hour token if URL is API_TOKEN_ROUTE
573        request_token_header = {AWS_TOKEN_REQ_HEADER: AWS_TOKEN_TTL_SECONDS}
574        if API_TOKEN_ROUTE in url:
575            return request_token_header
576        if not self._api_token:
577            # If we don't yet have an API token, get one via a PUT against
578            # API_TOKEN_ROUTE. This _api_token may get unset by a 403 due
579            # to an invalid or expired token
580            self._api_token = self._refresh_api_token()
581            if not self._api_token:
582                return {}
583        return {AWS_TOKEN_PUT_HEADER: self._api_token}
584
585
586class DataSourceEc2Local(DataSourceEc2):
587    """Datasource run at init-local which sets up network to query metadata.
588
589    In init-local, no network is available. This subclass sets up minimal
590    networking with dhclient on a viable nic so that it can talk to the
591    metadata service. If the metadata service provides network configuration
592    then render the network configuration for that instance based on metadata.
593    """
594    perform_dhcp_setup = True  # Use dhcp before querying metadata
595
596    def get_data(self):
597        supported_platforms = (CloudNames.AWS,)
598        if self.cloud_name not in supported_platforms:
599            LOG.debug("Local Ec2 mode only supported on %s, not %s",
600                      supported_platforms, self.cloud_name)
601            return False
602        return super(DataSourceEc2Local, self).get_data()
603
604
605def read_strict_mode(cfgval, default):
606    try:
607        return parse_strict_mode(cfgval)
608    except ValueError as e:
609        LOG.warning(e)
610        return default
611
612
613def parse_strict_mode(cfgval):
614    # given a mode like:
615    #    true, false, warn,[sleep]
616    # return tuple with string mode (true|false|warn) and sleep.
617    if cfgval is True:
618        return 'true', None
619    if cfgval is False:
620        return 'false', None
621
622    if not cfgval:
623        return 'warn', 0
624
625    mode, _, sleep = cfgval.partition(",")
626    if mode not in ('true', 'false', 'warn'):
627        raise ValueError(
628            "Invalid mode '%s' in strict_id setting '%s': "
629            "Expected one of 'true', 'false', 'warn'." % (mode, cfgval))
630
631    if sleep:
632        try:
633            sleep = int(sleep)
634        except ValueError as e:
635            raise ValueError(
636                "Invalid sleep '%s' in strict_id setting '%s': not an integer"
637                % (sleep, cfgval)
638            ) from e
639    else:
640        sleep = None
641
642    return mode, sleep
643
644
645def warn_if_necessary(cfgval, cfg):
646    try:
647        mode, sleep = parse_strict_mode(cfgval)
648    except ValueError as e:
649        LOG.warning(e)
650        return
651
652    if mode == "false":
653        return
654
655    warnings.show_warning('non_ec2_md', cfg, mode=True, sleep=sleep)
656
657
658def identify_aws(data):
659    # data is a dictionary returned by _collect_platform_data.
660    if (data['uuid'].startswith('ec2') and
661            (data['uuid_source'] == 'hypervisor' or
662             data['uuid'] == data['serial'])):
663        return CloudNames.AWS
664
665    return None
666
667
668def identify_brightbox(data):
669    if data['serial'].endswith('.brightbox.com'):
670        return CloudNames.BRIGHTBOX
671
672
673def identify_zstack(data):
674    if data['asset_tag'].endswith('.zstack.io'):
675        return CloudNames.ZSTACK
676
677
678def identify_e24cloud(data):
679    if data['vendor'] == 'e24cloud':
680        return CloudNames.E24CLOUD
681
682
683def identify_platform():
684    # identify the platform and return an entry in CloudNames.
685    data = _collect_platform_data()
686    checks = (identify_aws, identify_brightbox, identify_zstack,
687              identify_e24cloud, lambda x: CloudNames.UNKNOWN)
688    for checker in checks:
689        try:
690            result = checker(data)
691            if result:
692                return result
693        except Exception as e:
694            LOG.warning("calling %s with %s raised exception: %s",
695                        checker, data, e)
696
697
698def _collect_platform_data():
699    """Returns a dictionary of platform info from dmi or /sys/hypervisor.
700
701    Keys in the dictionary are as follows:
702       uuid: system-uuid from dmi or /sys/hypervisor
703       uuid_source: 'hypervisor' (/sys/hypervisor/uuid) or 'dmi'
704       serial: dmi 'system-serial-number' (/sys/.../product_serial)
705       asset_tag: 'dmidecode -s chassis-asset-tag'
706       vendor: dmi 'system-manufacturer' (/sys/.../sys_vendor)
707
708    On Ec2 instances experimentation is that product_serial is upper case,
709    and product_uuid is lower case.  This returns lower case values for both.
710    """
711    data = {}
712    try:
713        uuid = util.load_file("/sys/hypervisor/uuid").strip()
714        data['uuid_source'] = 'hypervisor'
715    except Exception:
716        uuid = dmi.read_dmi_data('system-uuid')
717        data['uuid_source'] = 'dmi'
718
719    if uuid is None:
720        uuid = ''
721    data['uuid'] = uuid.lower()
722
723    serial = dmi.read_dmi_data('system-serial-number')
724    if serial is None:
725        serial = ''
726
727    data['serial'] = serial.lower()
728
729    asset_tag = dmi.read_dmi_data('chassis-asset-tag')
730    if asset_tag is None:
731        asset_tag = ''
732
733    data['asset_tag'] = asset_tag.lower()
734
735    vendor = dmi.read_dmi_data('system-manufacturer')
736    data['vendor'] = (vendor if vendor else '').lower()
737
738    return data
739
740
741def convert_ec2_metadata_network_config(
742        network_md, macs_to_nics=None, fallback_nic=None,
743        full_network_config=True):
744    """Convert ec2 metadata to network config version 2 data dict.
745
746    @param: network_md: 'network' portion of EC2 metadata.
747       generally formed as {"interfaces": {"macs": {}} where
748       'macs' is a dictionary with mac address as key and contents like:
749       {"device-number": "0", "interface-id": "...", "local-ipv4s": ...}
750    @param: macs_to_nics: Optional dict of mac addresses and nic names. If
751       not provided, get_interfaces_by_mac is called to get it from the OS.
752    @param: fallback_nic: Optionally provide the primary nic interface name.
753       This nic will be guaranteed to minimally have a dhcp4 configuration.
754    @param: full_network_config: Boolean set True to configure all networking
755       presented by IMDS. This includes rendering secondary IPv4 and IPv6
756       addresses on all NICs and rendering network config on secondary NICs.
757       If False, only the primary nic will be configured and only with dhcp
758       (IPv4/IPv6).
759
760    @return A dict of network config version 2 based on the metadata and macs.
761    """
762    netcfg = {'version': 2, 'ethernets': {}}
763    if not macs_to_nics:
764        macs_to_nics = net.get_interfaces_by_mac()
765    macs_metadata = network_md['interfaces']['macs']
766
767    if not full_network_config:
768        for mac, nic_name in macs_to_nics.items():
769            if nic_name == fallback_nic:
770                break
771        dev_config = {'dhcp4': True,
772                      'dhcp6': False,
773                      'match': {'macaddress': mac.lower()},
774                      'set-name': nic_name}
775        nic_metadata = macs_metadata.get(mac)
776        if nic_metadata.get('ipv6s'):  # Any IPv6 addresses configured
777            dev_config['dhcp6'] = True
778        netcfg['ethernets'][nic_name] = dev_config
779        return netcfg
780    # Apply network config for all nics and any secondary IPv4/v6 addresses
781    nic_idx = 0
782    for mac, nic_name in sorted(macs_to_nics.items()):
783        nic_metadata = macs_metadata.get(mac)
784        if not nic_metadata:
785            continue  # Not a physical nic represented in metadata
786        # device-number is zero-indexed, we want it 1-indexed for the
787        # multiplication on the following line
788        nic_idx = int(nic_metadata.get('device-number', nic_idx)) + 1
789        dhcp_override = {'route-metric': nic_idx * 100}
790        dev_config = {'dhcp4': True, 'dhcp4-overrides': dhcp_override,
791                      'dhcp6': False,
792                      'match': {'macaddress': mac.lower()},
793                      'set-name': nic_name}
794        if nic_metadata.get('ipv6s'):  # Any IPv6 addresses configured
795            dev_config['dhcp6'] = True
796            dev_config['dhcp6-overrides'] = dhcp_override
797        dev_config['addresses'] = get_secondary_addresses(nic_metadata, mac)
798        if not dev_config['addresses']:
799            dev_config.pop('addresses')  # Since we found none configured
800        netcfg['ethernets'][nic_name] = dev_config
801    # Remove route-metric dhcp overrides if only one nic configured
802    if len(netcfg['ethernets']) == 1:
803        for nic_name in netcfg['ethernets'].keys():
804            netcfg['ethernets'][nic_name].pop('dhcp4-overrides')
805            netcfg['ethernets'][nic_name].pop('dhcp6-overrides', None)
806    return netcfg
807
808
809def get_secondary_addresses(nic_metadata, mac):
810    """Parse interface-specific nic metadata and return any secondary IPs
811
812    :return: List of secondary IPv4 or IPv6 addresses to configure on the
813    interface
814    """
815    ipv4s = nic_metadata.get('local-ipv4s')
816    ipv6s = nic_metadata.get('ipv6s')
817    addresses = []
818    # In version < 2018-09-24 local_ipv4s or ipv6s is a str with one IP
819    if bool(isinstance(ipv4s, list) and len(ipv4s) > 1):
820        addresses.extend(
821            _get_secondary_addresses(
822                nic_metadata, 'subnet-ipv4-cidr-block', mac, ipv4s, '24'))
823    if bool(isinstance(ipv6s, list) and len(ipv6s) > 1):
824        addresses.extend(
825            _get_secondary_addresses(
826                nic_metadata, 'subnet-ipv6-cidr-block', mac, ipv6s, '128'))
827    return sorted(addresses)
828
829
830def _get_secondary_addresses(nic_metadata, cidr_key, mac, ips, default_prefix):
831    """Return list of IP addresses as CIDRs for secondary IPs
832
833    The CIDR prefix will be default_prefix if cidr_key is absent or not
834    parseable in nic_metadata.
835    """
836    addresses = []
837    cidr = nic_metadata.get(cidr_key)
838    prefix = default_prefix
839    if not cidr or len(cidr.split('/')) != 2:
840        ip_type = 'ipv4' if 'ipv4' in cidr_key else 'ipv6'
841        LOG.warning(
842            'Could not parse %s %s for mac %s. %s network'
843            ' config prefix defaults to /%s',
844            cidr_key, cidr, mac, ip_type, prefix)
845    else:
846        prefix = cidr.split('/')[1]
847    # We know we have > 1 ips for in metadata for this IP type
848    for ip in ips[1:]:
849        addresses.append(
850            '{ip}/{prefix}'.format(ip=ip, prefix=prefix))
851    return addresses
852
853
854# Used to match classes to dependencies
855datasources = [
856    (DataSourceEc2Local, (sources.DEP_FILESYSTEM,)),  # Run at init-local
857    (DataSourceEc2, (sources.DEP_FILESYSTEM, sources.DEP_NETWORK)),
858]
859
860
861# Return a list of data sources that match this set of dependencies
862def get_datasource_list(depends):
863    return sources.list_from_depends(depends, datasources)
864
865# vi: ts=4 expandtab
866