summaryrefslogtreecommitdiff
path: root/cloudinit/sources/DataSourceEc2.py
diff options
context:
space:
mode:
authorzdc <zdc@users.noreply.github.com>2020-09-15 21:35:20 +0300
committerGitHub <noreply@github.com>2020-09-15 21:35:20 +0300
commit76adf82b8a4dbcf636151d292175b7d1ac182fcf (patch)
treef57f3db085a724df237ffa64b589c6bb6dd3b28f /cloudinit/sources/DataSourceEc2.py
parent1a790ee102fd405e5c3a20a17a69ba0c118ed874 (diff)
parent7cd260b313267dc7123cb99a75d4555e24909cca (diff)
downloadvyos-cloud-init-76adf82b8a4dbcf636151d292175b7d1ac182fcf.tar.gz
vyos-cloud-init-76adf82b8a4dbcf636151d292175b7d1ac182fcf.zip
Merge pull request #18 from zdc/T2117-equuleus-20.3
T2117: Cloud-init updated to 20.3
Diffstat (limited to 'cloudinit/sources/DataSourceEc2.py')
-rw-r--r--cloudinit/sources/DataSourceEc2.py200
1 files changed, 162 insertions, 38 deletions
diff --git a/cloudinit/sources/DataSourceEc2.py b/cloudinit/sources/DataSourceEc2.py
index 0f2bfef4..1d09c12a 100644
--- a/cloudinit/sources/DataSourceEc2.py
+++ b/cloudinit/sources/DataSourceEc2.py
@@ -29,7 +29,6 @@ STRICT_ID_PATH = ("datasource", "Ec2", "strict_id")
STRICT_ID_DEFAULT = "warn"
API_TOKEN_ROUTE = 'latest/api/token'
-API_TOKEN_DISABLED = '_ec2_disable_api_token'
AWS_TOKEN_TTL_SECONDS = '21600'
AWS_TOKEN_PUT_HEADER = 'X-aws-ec2-metadata-token'
AWS_TOKEN_REQ_HEADER = AWS_TOKEN_PUT_HEADER + '-ttl-seconds'
@@ -63,7 +62,7 @@ class DataSourceEc2(sources.DataSource):
# Priority ordered list of additional metadata versions which will be tried
# for extended metadata content. IPv6 support comes in 2016-09-02
- extended_metadata_versions = ['2016-09-02']
+ extended_metadata_versions = ['2018-09-24', '2016-09-02']
# Setup read_url parameters per get_url_params.
url_max_wait = 120
@@ -193,6 +192,12 @@ class DataSourceEc2(sources.DataSource):
return self.metadata['instance-id']
def _maybe_fetch_api_token(self, mdurls, timeout=None, max_wait=None):
+ """ Get an API token for EC2 Instance Metadata Service.
+
+ On EC2. IMDS will always answer an API token, unless
+ the instance owner has disabled the IMDS HTTP endpoint or
+ the network topology conflicts with the configured hop-limit.
+ """
if self.cloud_name != CloudNames.AWS:
return
@@ -205,18 +210,33 @@ class DataSourceEc2(sources.DataSource):
urls.append(cur)
url2base[cur] = url
- # use the self._status_cb to check for Read errors, which means
- # we can't reach the API token URL, so we should disable IMDSv2
+ # use the self._imds_exception_cb to check for Read errors
LOG.debug('Fetching Ec2 IMDSv2 API Token')
- url, response = uhelp.wait_for_url(
- urls=urls, max_wait=1, timeout=1, status_cb=self._status_cb,
- headers_cb=self._get_headers, request_method=request_method,
- headers_redact=AWS_TOKEN_REDACT)
+
+ response = None
+ url = None
+ url_params = self.get_url_params()
+ try:
+ url, response = uhelp.wait_for_url(
+ urls=urls, max_wait=url_params.max_wait_seconds,
+ timeout=url_params.timeout_seconds, status_cb=LOG.warning,
+ headers_cb=self._get_headers,
+ exception_cb=self._imds_exception_cb,
+ request_method=request_method,
+ headers_redact=AWS_TOKEN_REDACT)
+ except uhelp.UrlError:
+ # We use the raised exception to interupt the retry loop.
+ # Nothing else to do here.
+ pass
if url and response:
self._api_token = response
return url2base[url]
+ # If we get here, then wait_for_url timed out, waiting for IMDS
+ # or the IMDS HTTP endpoint is disabled
+ return None
+
def wait_for_metadata_service(self):
mcfg = self.ds_cfg
@@ -240,9 +260,11 @@ class DataSourceEc2(sources.DataSource):
# try the api token path first
metadata_address = self._maybe_fetch_api_token(mdurls)
- if not metadata_address:
- if self._api_token == API_TOKEN_DISABLED:
- LOG.warning('Retrying with IMDSv1')
+ # When running on EC2, we always access IMDS with an API token.
+ # If we could not get an API token, then we assume the IMDS
+ # endpoint was disabled and we move on without a data source.
+ # Fallback to IMDSv1 if not running on EC2
+ if not metadata_address and self.cloud_name != CloudNames.AWS:
# if we can't get a token, use instance-id path
urls = []
url2base = {}
@@ -267,6 +289,8 @@ class DataSourceEc2(sources.DataSource):
if metadata_address:
self.metadata_address = metadata_address
LOG.debug("Using metadata source: '%s'", self.metadata_address)
+ elif self.cloud_name == CloudNames.AWS:
+ LOG.warning("IMDS's HTTP endpoint is probably disabled")
else:
LOG.critical("Giving up on md from %s after %s seconds",
urls, int(time.time() - start_time))
@@ -381,13 +405,16 @@ class DataSourceEc2(sources.DataSource):
logfunc=LOG.debug, msg='Re-crawl of metadata service',
func=self.get_data)
- # Limit network configuration to only the primary/fallback nic
iface = self.fallback_interface
- macs_to_nics = {net.get_interface_mac(iface): iface}
net_md = self.metadata.get('network')
if isinstance(net_md, dict):
+ # SRU_BLOCKER: xenial, bionic and eoan should default
+ # apply_full_imds_network_config to False to retain original
+ # behavior on those releases.
result = convert_ec2_metadata_network_config(
- net_md, macs_to_nics=macs_to_nics, fallback_nic=iface)
+ net_md, fallback_nic=iface,
+ full_network_config=util.get_cfg_option_bool(
+ self.ds_cfg, 'apply_full_imds_network_config', True))
# RELEASE_BLOCKER: xenial should drop the below if statement,
# because the issue being addressed doesn't exist pre-netplan.
@@ -496,11 +523,29 @@ class DataSourceEc2(sources.DataSource):
self._api_token = None
return True # always retry
- def _status_cb(self, msg, exc=None):
- LOG.warning(msg)
- if 'Read timed out' in msg:
- LOG.warning('Cannot use Ec2 IMDSv2 API tokens, using IMDSv1')
- self._api_token = API_TOKEN_DISABLED
+ def _imds_exception_cb(self, msg, exception=None):
+ """Fail quickly on proper AWS if IMDSv2 rejects API token request
+
+ Guidance from Amazon is that if IMDSv2 had disabled token requests
+ by returning a 403, or cloud-init malformed requests resulting in
+ other 40X errors, we want the datasource detection to fail quickly
+ without retries as those symptoms will likely not be resolved by
+ retries.
+
+ Exceptions such as requests.ConnectionError due to IMDS being
+ temporarily unroutable or unavailable will still retry due to the
+ callsite wait_for_url.
+ """
+ if isinstance(exception, uhelp.UrlError):
+ # requests.ConnectionError will have exception.code == None
+ if exception.code and exception.code >= 400:
+ if exception.code == 403:
+ LOG.warning('Ec2 IMDS endpoint returned a 403 error. '
+ 'HTTP endpoint is disabled. Aborting.')
+ else:
+ LOG.warning('Fatal error while requesting '
+ 'Ec2 IMDSv2 API tokens')
+ raise exception
def _get_headers(self, url=''):
"""Return a dict of headers for accessing a url.
@@ -508,8 +553,7 @@ class DataSourceEc2(sources.DataSource):
If _api_token is unset on AWS, attempt to refresh the token via a PUT
and then return the updated token header.
"""
- if self.cloud_name != CloudNames.AWS or (self._api_token ==
- API_TOKEN_DISABLED):
+ if self.cloud_name != CloudNames.AWS:
return {}
# Request a 6 hour token if URL is API_TOKEN_ROUTE
request_token_header = {AWS_TOKEN_REQ_HEADER: AWS_TOKEN_TTL_SECONDS}
@@ -573,9 +617,11 @@ def parse_strict_mode(cfgval):
if sleep:
try:
sleep = int(sleep)
- except ValueError:
- raise ValueError("Invalid sleep '%s' in strict_id setting '%s': "
- "not an integer" % (sleep, cfgval))
+ except ValueError as e:
+ raise ValueError(
+ "Invalid sleep '%s' in strict_id setting '%s': not an integer"
+ % (sleep, cfgval)
+ ) from e
else:
sleep = None
@@ -678,9 +724,10 @@ def _collect_platform_data():
return data
-def convert_ec2_metadata_network_config(network_md, macs_to_nics=None,
- fallback_nic=None):
- """Convert ec2 metadata to network config version 1 data dict.
+def convert_ec2_metadata_network_config(
+ network_md, macs_to_nics=None, fallback_nic=None,
+ full_network_config=True):
+ """Convert ec2 metadata to network config version 2 data dict.
@param: network_md: 'network' portion of EC2 metadata.
generally formed as {"interfaces": {"macs": {}} where
@@ -690,28 +737,105 @@ def convert_ec2_metadata_network_config(network_md, macs_to_nics=None,
not provided, get_interfaces_by_mac is called to get it from the OS.
@param: fallback_nic: Optionally provide the primary nic interface name.
This nic will be guaranteed to minimally have a dhcp4 configuration.
+ @param: full_network_config: Boolean set True to configure all networking
+ presented by IMDS. This includes rendering secondary IPv4 and IPv6
+ addresses on all NICs and rendering network config on secondary NICs.
+ If False, only the primary nic will be configured and only with dhcp
+ (IPv4/IPv6).
- @return A dict of network config version 1 based on the metadata and macs.
+ @return A dict of network config version 2 based on the metadata and macs.
"""
- netcfg = {'version': 1, 'config': []}
+ netcfg = {'version': 2, 'ethernets': {}}
if not macs_to_nics:
macs_to_nics = net.get_interfaces_by_mac()
macs_metadata = network_md['interfaces']['macs']
- for mac, nic_name in macs_to_nics.items():
+
+ if not full_network_config:
+ for mac, nic_name in macs_to_nics.items():
+ if nic_name == fallback_nic:
+ break
+ dev_config = {'dhcp4': True,
+ 'dhcp6': False,
+ 'match': {'macaddress': mac.lower()},
+ 'set-name': nic_name}
+ nic_metadata = macs_metadata.get(mac)
+ if nic_metadata.get('ipv6s'): # Any IPv6 addresses configured
+ dev_config['dhcp6'] = True
+ netcfg['ethernets'][nic_name] = dev_config
+ return netcfg
+ # Apply network config for all nics and any secondary IPv4/v6 addresses
+ for mac, nic_name in sorted(macs_to_nics.items()):
nic_metadata = macs_metadata.get(mac)
if not nic_metadata:
continue # Not a physical nic represented in metadata
- nic_cfg = {'type': 'physical', 'name': nic_name, 'subnets': []}
- nic_cfg['mac_address'] = mac
- if (nic_name == fallback_nic or nic_metadata.get('public-ipv4s') or
- nic_metadata.get('local-ipv4s')):
- nic_cfg['subnets'].append({'type': 'dhcp4'})
- if nic_metadata.get('ipv6s'):
- nic_cfg['subnets'].append({'type': 'dhcp6'})
- netcfg['config'].append(nic_cfg)
+ # device-number is zero-indexed, we want it 1-indexed for the
+ # multiplication on the following line
+ nic_idx = int(nic_metadata['device-number']) + 1
+ dhcp_override = {'route-metric': nic_idx * 100}
+ dev_config = {'dhcp4': True, 'dhcp4-overrides': dhcp_override,
+ 'dhcp6': False,
+ 'match': {'macaddress': mac.lower()},
+ 'set-name': nic_name}
+ if nic_metadata.get('ipv6s'): # Any IPv6 addresses configured
+ dev_config['dhcp6'] = True
+ dev_config['dhcp6-overrides'] = dhcp_override
+ dev_config['addresses'] = get_secondary_addresses(nic_metadata, mac)
+ if not dev_config['addresses']:
+ dev_config.pop('addresses') # Since we found none configured
+ netcfg['ethernets'][nic_name] = dev_config
+ # Remove route-metric dhcp overrides if only one nic configured
+ if len(netcfg['ethernets']) == 1:
+ for nic_name in netcfg['ethernets'].keys():
+ netcfg['ethernets'][nic_name].pop('dhcp4-overrides')
+ netcfg['ethernets'][nic_name].pop('dhcp6-overrides', None)
return netcfg
+def get_secondary_addresses(nic_metadata, mac):
+ """Parse interface-specific nic metadata and return any secondary IPs
+
+ :return: List of secondary IPv4 or IPv6 addresses to configure on the
+ interface
+ """
+ ipv4s = nic_metadata.get('local-ipv4s')
+ ipv6s = nic_metadata.get('ipv6s')
+ addresses = []
+ # In version < 2018-09-24 local_ipv4s or ipv6s is a str with one IP
+ if bool(isinstance(ipv4s, list) and len(ipv4s) > 1):
+ addresses.extend(
+ _get_secondary_addresses(
+ nic_metadata, 'subnet-ipv4-cidr-block', mac, ipv4s, '24'))
+ if bool(isinstance(ipv6s, list) and len(ipv6s) > 1):
+ addresses.extend(
+ _get_secondary_addresses(
+ nic_metadata, 'subnet-ipv6-cidr-block', mac, ipv6s, '128'))
+ return sorted(addresses)
+
+
+def _get_secondary_addresses(nic_metadata, cidr_key, mac, ips, default_prefix):
+ """Return list of IP addresses as CIDRs for secondary IPs
+
+ The CIDR prefix will be default_prefix if cidr_key is absent or not
+ parseable in nic_metadata.
+ """
+ addresses = []
+ cidr = nic_metadata.get(cidr_key)
+ prefix = default_prefix
+ if not cidr or len(cidr.split('/')) != 2:
+ ip_type = 'ipv4' if 'ipv4' in cidr_key else 'ipv6'
+ LOG.warning(
+ 'Could not parse %s %s for mac %s. %s network'
+ ' config prefix defaults to /%s',
+ cidr_key, cidr, mac, ip_type, prefix)
+ else:
+ prefix = cidr.split('/')[1]
+ # We know we have > 1 ips for in metadata for this IP type
+ for ip in ips[1:]:
+ addresses.append(
+ '{ip}/{prefix}'.format(ip=ip, prefix=prefix))
+ return addresses
+
+
# Used to match classes to dependencies
datasources = [
(DataSourceEc2Local, (sources.DEP_FILESYSTEM,)), # Run at init-local