Build-in inventory plugin code structure with gce working

supporting and related changes
 - Fix inconsistency between can_update / can_start
 - Avoid creating inventory file twice unnecessarily
 - Non-functional consolidation in Azure injection logic
 - Inject GCE creds as indented JSON for readability
 - Create new injector class structure, add gce
 - Reduce management command overrides of runtime environment
This commit is contained in:
AlanCoding
2018-12-07 11:08:25 -05:00
parent 90ea9a8cc4
commit 6c130fa6c3
6 changed files with 213 additions and 54 deletions

View File

@@ -124,7 +124,13 @@ class AnsibleInventoryLoader(object):
def get_base_args(self): def get_base_args(self):
# get ansible-inventory absolute path for running in bubblewrap/proot, in Popen # get ansible-inventory absolute path for running in bubblewrap/proot, in Popen
bargs= [self.get_path_to_ansible_inventory(), '-i', self.source]
# NOTE: why do we add "python" to the start of these args?
# the script that runs ansible-inventory specifies a python interpreter
# that makes no sense in light of the fact that we put all the dependencies
# inside of /venv/ansible, so we override the specified interpreter
# https://github.com/ansible/ansible/issues/50714
bargs= ['python', self.get_path_to_ansible_inventory(), '-i', self.source]
logger.debug('Using base command: {}'.format(' '.join(bargs))) logger.debug('Using base command: {}'.format(' '.join(bargs)))
return bargs return bargs

View File

@@ -24,12 +24,15 @@ def gce(cred, env, private_data_dir):
'type': 'service_account', 'type': 'service_account',
'private_key': cred.get_input('ssh_key_data', default=''), 'private_key': cred.get_input('ssh_key_data', default=''),
'client_email': username, 'client_email': username,
'project_id': project 'project_id': project,
# need token uri for inventory plugins
# should this really be hard coded? Good question.
'token_uri': 'https://accounts.google.com/o/oauth2/token',
} }
handle, path = tempfile.mkstemp(dir=private_data_dir)
f = os.fdopen(handle, 'w') path = os.path.join(private_data_dir, 'creds.json')
json.dump(json_cred, f) with open(path, 'w') as f:
f.close() json.dump(json_cred, f, indent=2)
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
env['GCE_CREDENTIALS_FILE_PATH'] = path env['GCE_CREDENTIALS_FILE_PATH'] = path
@@ -38,13 +41,13 @@ def azure_rm(cred, env, private_data_dir):
client = cred.get_input('client', default='') client = cred.get_input('client', default='')
tenant = cred.get_input('tenant', default='') tenant = cred.get_input('tenant', default='')
env['AZURE_SUBSCRIPTION_ID'] = cred.get_input('subscription', default='')
if len(client) and len(tenant): if len(client) and len(tenant):
env['AZURE_CLIENT_ID'] = client env['AZURE_CLIENT_ID'] = client
env['AZURE_TENANT'] = tenant env['AZURE_TENANT'] = tenant
env['AZURE_SECRET'] = cred.get_input('secret', default='') env['AZURE_SECRET'] = cred.get_input('secret', default='')
env['AZURE_SUBSCRIPTION_ID'] = cred.get_input('subscription', default='')
else: else:
env['AZURE_SUBSCRIPTION_ID'] = cred.get_input('subscription', default='')
env['AZURE_AD_USER'] = cred.get_input('username', default='') env['AZURE_AD_USER'] = cred.get_input('username', default='')
env['AZURE_PASSWORD'] = cred.get_input('password', default='') env['AZURE_PASSWORD'] = cred.get_input('password', default='')

View File

@@ -10,6 +10,11 @@ import re
import copy import copy
import os.path import os.path
from urllib.parse import urljoin from urllib.parse import urljoin
import yaml
import configparser
import stat
import tempfile
from distutils.version import LooseVersion as Version
# Django # Django
from django.conf import settings from django.conf import settings
@@ -1015,6 +1020,8 @@ class InventorySourceOptions(BaseModel):
Common fields for InventorySource and InventoryUpdate. Common fields for InventorySource and InventoryUpdate.
''' '''
injectors = dict()
SOURCE_CHOICES = [ SOURCE_CHOICES = [
('', _('Manual')), ('', _('Manual')),
('file', _('File, Directory or Script')), ('file', _('File, Directory or Script')),
@@ -1308,6 +1315,8 @@ class InventorySourceOptions(BaseModel):
return None return None
def get_inventory_plugin_name(self): def get_inventory_plugin_name(self):
if self.source in InventorySourceOptions.injectors:
return InventorySourceOptions.injectors[self.source].plugin_name
if self.source in CLOUD_PROVIDERS or self.source == 'custom': if self.source in CLOUD_PROVIDERS or self.source == 'custom':
# TODO: today, all vendored sources are scripts # TODO: today, all vendored sources are scripts
# in future release inventory plugins will replace these # in future release inventory plugins will replace these
@@ -1532,8 +1541,15 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions, RelatedJobsMix
return bool(self.source_script) return bool(self.source_script)
elif self.source == 'scm': elif self.source == 'scm':
return bool(self.source_project) return bool(self.source_project)
else: elif self.source == 'file':
return bool(self.source in CLOUD_INVENTORY_SOURCES) return False
elif self.source == 'ec2':
# Permit credential-less ec2 updates to allow IAM roles
return True
elif self.source == 'gce':
# These updates will hang if correct credential is not supplied
return bool(self.get_cloud_credential().kind == 'gce')
return True
def create_inventory_update(self, **kwargs): def create_inventory_update(self, **kwargs):
return self.create_unified_job(**kwargs) return self.create_unified_job(**kwargs)
@@ -1695,6 +1711,14 @@ class InventoryUpdate(UnifiedJob, InventorySourceOptions, JobNotificationMixin,
def get_ui_url(self): def get_ui_url(self):
return urljoin(settings.TOWER_URL_BASE, "/#/jobs/inventory/{}".format(self.pk)) return urljoin(settings.TOWER_URL_BASE, "/#/jobs/inventory/{}".format(self.pk))
@property
def ansible_virtualenv_path(self):
if self.inventory and self.inventory.organization:
virtualenv = self.inventory.organization.custom_virtualenv
if virtualenv:
return virtualenv
return settings.ANSIBLE_VENV_PATH
def get_actual_source_path(self): def get_actual_source_path(self):
'''Alias to source_path that combines with project path for for SCM file based sources''' '''Alias to source_path that combines with project path for for SCM file based sources'''
if self.inventory_source_id is None or self.inventory_source.source_project_id is None: if self.inventory_source_id is None or self.inventory_source.source_project_id is None:
@@ -1717,13 +1741,7 @@ class InventoryUpdate(UnifiedJob, InventorySourceOptions, JobNotificationMixin,
def can_start(self): def can_start(self):
if not super(InventoryUpdate, self).can_start: if not super(InventoryUpdate, self).can_start:
return False return False
elif not self.inventory_source or not self.inventory_source._can_update():
if (self.source not in ('custom', 'ec2', 'scm') and
not (self.get_cloud_credential())):
return False
elif self.source == 'scm' and not self.inventory_source.source_project:
return False
elif self.source == 'file':
return False return False
return True return True
@@ -1801,3 +1819,88 @@ class CustomInventoryScript(CommonModelNameNotUnique, ResourceMixin):
def get_absolute_url(self, request=None): def get_absolute_url(self, request=None):
return reverse('api:inventory_script_detail', kwargs={'pk': self.pk}, request=request) return reverse('api:inventory_script_detail', kwargs={'pk': self.pk}, request=request)
# TODO: move these to their own file somewhere?
class PluginFileInjector(object):
plugin_name = None
initial_version = None
def __init__(self, ansible_version):
# This is InventoryOptions instance, could be source or inventory update
self.ansible_version = ansible_version
@property
def filename(self):
return '{0}.yml'.format(self.plugin_name)
def inventory_contents(self, inventory_source):
return yaml.safe_dump(self.inventory_as_dict(inventory_source), default_flow_style=False)
def should_use_plugin(self):
return bool(
self.initial_version and
Version(self.ansible_version) >= Version(self.initial_version)
)
def build_env(self, *args, **kwargs):
if self.should_use_plugin():
return self.build_plugin_env(*args, **kwargs)
else:
return self.build_script_env(*args, **kwargs)
def build_plugin_env(self, inventory_update, env, private_data_dir):
return env
def build_script_env(self, inventory_update, env, private_data_dir):
return env
def build_private_data(self, *args, **kwargs):
if self.should_use_plugin():
return self.build_private_data(*args, **kwargs)
else:
return self.build_private_data(*args, **kwargs)
def build_script_private_data(self, *args, **kwargs):
pass
def build_plugin_private_data(self, *args, **kwargs):
pass
class gce(PluginFileInjector):
plugin_name = 'gcp_compute'
initial_version = '2.6'
def build_script_env(self, inventory_update, env, private_data_dir):
env['GCE_ZONE'] = inventory_update.source_regions if inventory_update.source_regions != 'all' else '' # noqa
# by default, the GCE inventory source caches results on disk for
# 5 minutes; disable this behavior
cp = configparser.ConfigParser()
cp.add_section('cache')
cp.set('cache', 'cache_max_age', '0')
handle, path = tempfile.mkstemp(dir=private_data_dir)
cp.write(os.fdopen(handle, 'w'))
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
env['GCE_INI_PATH'] = path
return env
def inventory_as_dict(self, inventory_source):
# NOTE: generalizing this to be use templating like credential types would be nice
# but with YAML content that need to inject list parameters into the YAML,
# it is hard to see any clean way we can possibly do this
ret = dict(
plugin='gcp_compute',
projects=[inventory_source.get_cloud_credential().project],
filters=None, # necessary cruft, see: https://github.com/ansible/ansible/pull/50025
service_account_file="creds.json",
auth_kind="serviceaccount"
)
if inventory_source.source_regions:
ret['zones'] = inventory_source.source_regions.split(',')
return ret
for cls in PluginFileInjector.__subclasses__():
InventorySourceOptions.injectors[cls.__name__] = cls

View File

@@ -52,7 +52,7 @@ from awx.main.access import access_registry
from awx.main.models import ( from awx.main.models import (
Schedule, TowerScheduleState, Instance, InstanceGroup, Schedule, TowerScheduleState, Instance, InstanceGroup,
UnifiedJob, Notification, UnifiedJob, Notification,
Inventory, SmartInventoryMembership, Inventory, InventorySource, SmartInventoryMembership,
Job, AdHocCommand, ProjectUpdate, InventoryUpdate, SystemJob, Job, AdHocCommand, ProjectUpdate, InventoryUpdate, SystemJob,
JobEvent, ProjectUpdateEvent, InventoryUpdateEvent, AdHocCommandEvent, SystemJobEvent, JobEvent, ProjectUpdateEvent, InventoryUpdateEvent, AdHocCommandEvent, SystemJobEvent,
build_safe_env build_safe_env
@@ -67,6 +67,7 @@ from awx.main.utils import (get_ssh_version, update_scm_url,
get_licenser, get_licenser,
ignore_inventory_computed_fields, ignore_inventory_computed_fields,
ignore_inventory_group_removal, extract_ansible_vars, schedule_task_manager) ignore_inventory_group_removal, extract_ansible_vars, schedule_task_manager)
from awx.main.utils.common import _get_ansible_version
from awx.main.utils.safe_yaml import safe_dump, sanitize_jinja from awx.main.utils.safe_yaml import safe_dump, sanitize_jinja
from awx.main.utils.reload import stop_local_services from awx.main.utils.reload import stop_local_services
from awx.main.utils.pglock import advisory_lock from awx.main.utils.pglock import advisory_lock
@@ -713,12 +714,25 @@ class BaseTask(object):
logger.error('Failed to update %s after %d retries.', logger.error('Failed to update %s after %d retries.',
self.model._meta.object_name, _attempt) self.model._meta.object_name, _attempt)
def get_ansible_version(self, instance):
if not hasattr(self, '_ansible_version'):
self._ansible_version = _get_ansible_version(
ansible_path=self.get_path_to_ansible(instance, executable='ansible'))
return self._ansible_version
def get_path_to(self, *args): def get_path_to(self, *args):
''' '''
Return absolute path relative to this file. Return absolute path relative to this file.
''' '''
return os.path.abspath(os.path.join(os.path.dirname(__file__), *args)) return os.path.abspath(os.path.join(os.path.dirname(__file__), *args))
def get_path_to_ansible(self, instance, executable='ansible-playbook', **kwargs):
venv_path = getattr(instance, 'ansible_virtualenv_path', settings.ANSIBLE_VENV_PATH)
venv_exe = os.path.join(venv_path, 'bin', executable)
if os.path.exists(venv_exe):
return venv_exe
return shutil.which(executable)
def build_private_data(self, instance, private_data_dir): def build_private_data(self, instance, private_data_dir):
''' '''
Return SSH private key data (only if stored in DB as ssh_key_data). Return SSH private key data (only if stored in DB as ssh_key_data).
@@ -2134,9 +2148,13 @@ class RunInventoryUpdate(BaseTask):
def build_env(self, inventory_update, private_data_dir, isolated, private_data_files=None): def build_env(self, inventory_update, private_data_dir, isolated, private_data_files=None):
"""Build environment dictionary for inventory import. """Build environment dictionary for inventory import.
This is the mechanism by which any data that needs to be passed This used to be the mechanism by which any data that needs to be passed
to the inventory update script is set up. In particular, this is how to the inventory update script is set up. In particular, this is how
inventory update is aware of its proper credentials. inventory update is aware of its proper credentials.
Most environment injection is now accomplished by the credential
injectors. The primary purpose this still serves is to
still point to the inventory update INI or config file.
""" """
env = super(RunInventoryUpdate, self).build_env(inventory_update, env = super(RunInventoryUpdate, self).build_env(inventory_update,
private_data_dir, private_data_dir,
@@ -2145,6 +2163,7 @@ class RunInventoryUpdate(BaseTask):
if private_data_files is None: if private_data_files is None:
private_data_files = {} private_data_files = {}
self.add_awx_venv(env) self.add_awx_venv(env)
self.add_ansible_venv(inventory_update.ansible_virtualenv_path, env)
# Pass inventory source ID to inventory script. # Pass inventory source ID to inventory script.
env['INVENTORY_SOURCE_ID'] = str(inventory_update.inventory_source_id) env['INVENTORY_SOURCE_ID'] = str(inventory_update.inventory_source_id)
env['INVENTORY_UPDATE_ID'] = str(inventory_update.pk) env['INVENTORY_UPDATE_ID'] = str(inventory_update.pk)
@@ -2176,25 +2195,19 @@ class RunInventoryUpdate(BaseTask):
inventory_update.get_cloud_credential(), '' inventory_update.get_cloud_credential(), ''
) )
if inventory_update.source == 'gce': if inventory_update.source in InventorySource.injectors:
env['GCE_ZONE'] = inventory_update.source_regions if inventory_update.source_regions != 'all' else '' # noqa # TODO: mapping from credential.kind to inventory_source.source
injector = InventorySource.injectors[inventory_update.source](self.get_ansible_version(inventory_update))
env = injector.build_env(inventory_update, env, private_data_dir)
# by default, the GCE inventory source caches results on disk for if inventory_update.source == 'tower':
# 5 minutes; disable this behavior env['TOWER_INVENTORY'] = inventory_update.instance_filters
cp = configparser.ConfigParser() env['TOWER_LICENSE_TYPE'] = get_licenser().validate()['license_type']
cp.add_section('cache')
cp.set('cache', 'cache_max_age', '0') if inventory_update.source in ['scm', 'custom']:
handle, path = tempfile.mkstemp(dir=private_data_dir)
cp.write(os.fdopen(handle, 'w'))
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
env['GCE_INI_PATH'] = path
elif inventory_update.source in ['scm', 'custom']:
for env_k in inventory_update.source_vars_dict: for env_k in inventory_update.source_vars_dict:
if str(env_k) not in env and str(env_k) not in settings.INV_ENV_VARIABLE_BLACKLIST: if str(env_k) not in env and str(env_k) not in settings.INV_ENV_VARIABLE_BLACKLIST:
env[str(env_k)] = str(inventory_update.source_vars_dict[env_k]) env[str(env_k)] = str(inventory_update.source_vars_dict[env_k])
elif inventory_update.source == 'tower':
env['TOWER_INVENTORY'] = inventory_update.instance_filters
env['TOWER_LICENSE_TYPE'] = get_licenser().validate()['license_type']
elif inventory_update.source == 'file': elif inventory_update.source == 'file':
raise NotImplementedError('Cannot update file sources through the task system.') raise NotImplementedError('Cannot update file sources through the task system.')
return env return env
@@ -2259,33 +2272,61 @@ class RunInventoryUpdate(BaseTask):
getattr(settings, '%s_INSTANCE_ID_VAR' % src.upper()),]) getattr(settings, '%s_INSTANCE_ID_VAR' % src.upper()),])
# Add arguments for the source inventory script # Add arguments for the source inventory script
args.append('--source') args.append('--source')
if src in CLOUD_PROVIDERS: args.append(self.build_inventory(inventory_update, private_data_dir))
# Get the path to the inventory plugin, and append it to our if src == 'custom':
# arguments.
plugin_path = self.get_path_to('..', 'plugins', 'inventory',
'%s.py' % src)
args.append(plugin_path)
elif src == 'scm':
args.append(inventory_update.get_actual_source_path())
elif src == 'custom':
handle, path = tempfile.mkstemp(dir=private_data_dir)
f = os.fdopen(handle, 'w')
if inventory_update.source_script is None:
raise RuntimeError('Inventory Script does not exist')
f.write(inventory_update.source_script.script)
f.close()
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
args.append(path)
args.append("--custom") args.append("--custom")
args.append('-v%d' % inventory_update.verbosity) args.append('-v%d' % inventory_update.verbosity)
if settings.DEBUG: if settings.DEBUG:
args.append('--traceback') args.append('--traceback')
return args return args
def build_inventory(self, inventory_update, private_data_dir):
src = inventory_update.source
if src in CLOUD_PROVIDERS:
if src in InventorySource.injectors:
cloud_cred = inventory_update.get_cloud_credential()
injector = InventorySource.injectors[cloud_cred.kind](self.get_ansible_version(inventory_update))
content = injector.inventory_contents(inventory_update)
content = content.encode('utf-8')
# must be a statically named file
inventory_path = os.path.join(private_data_dir, injector.filename)
with open(inventory_path, 'w') as f:
f.write(content)
os.chmod(inventory_path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
else:
# Get the path to the inventory plugin, and append it to our
# arguments.
inventory_path = self.get_path_to('..', 'plugins', 'inventory', '%s.py' % src)
elif src == 'scm':
inventory_path = inventory_update.get_actual_source_path()
elif src == 'custom':
handle, inventory_path = tempfile.mkstemp(dir=private_data_dir)
f = os.fdopen(handle, 'w')
if inventory_update.source_script is None:
raise RuntimeError('Inventory Script does not exist')
f.write(inventory_update.source_script.script)
f.close()
os.chmod(inventory_path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
return inventory_path
def build_cwd(self, inventory_update, private_data_dir): def build_cwd(self, inventory_update, private_data_dir):
if inventory_update.source == 'scm' and inventory_update.source_project_update: '''
There are two cases where the inventory "source" is in a different
location from the private data:
- deprecated vendored inventory scripts in awx/plugins/inventory
- SCM, where source needs to live in the project folder
in these cases, the inventory does not exist in the standard tempdir
'''
src = inventory_update.source
if src == 'scm' and inventory_update.source_project_update:
return inventory_update.source_project_update.get_project_path(check_if_exists=False) return inventory_update.source_project_update.get_project_path(check_if_exists=False)
return self.get_path_to('..', 'plugins', 'inventory') if src in CLOUD_PROVIDERS:
injector = None
if src in InventorySource.injectors:
injector = InventorySource.injectors[src](self.get_ansible_version(inventory_update))
if (not injector) or (not injector.should_use_plugin()):
return self.get_path_to('..', 'plugins', 'inventory')
return private_data_dir
def build_playbook_path_relative_to_cwd(self, inventory_update, private_data_dir): def build_playbook_path_relative_to_cwd(self, inventory_update, private_data_dir):
return None return None

View File

@@ -30,6 +30,7 @@ azure-graphrbac==0.40.0
# AWS # AWS
boto==2.47.0 # last which does not break ec2 scripts boto==2.47.0 # last which does not break ec2 scripts
boto3==1.6.2 boto3==1.6.2
google-auth==1.6.2 # needed for gce inventory imports
# netconf for network modules # netconf for network modules
ncclient==0.6.3 ncclient==0.6.3
# netaddr filter # netaddr filter

View File

@@ -38,6 +38,7 @@ bcrypt==3.1.4 # via paramiko
boto3==1.6.2 boto3==1.6.2
boto==2.47.0 boto==2.47.0
botocore==1.9.3 # via boto3, s3transfer botocore==1.9.3 # via boto3, s3transfer
cachetools==3.0.0 # via google-auth
certifi==2018.1.18 # via msrest, requests certifi==2018.1.18 # via msrest, requests
cffi==1.11.5 # via bcrypt, cryptography, pynacl cffi==1.11.5 # via bcrypt, cryptography, pynacl
chardet==3.0.4 # via requests chardet==3.0.4 # via requests
@@ -50,6 +51,8 @@ docutils==0.14 # via botocore
dogpile.cache==0.6.5 # via openstacksdk dogpile.cache==0.6.5 # via openstacksdk
entrypoints==0.2.3 # via keyring entrypoints==0.2.3 # via keyring
enum34==1.1.6; python_version < '3' # via cryptography, knack, msrest, ovirt-engine-sdk-python enum34==1.1.6; python_version < '3' # via cryptography, knack, msrest, ovirt-engine-sdk-python
futures==3.2.0 # via openstacksdk, s3transfer
google-auth==1.6.2
humanfriendly==4.8 # via azure-cli-core humanfriendly==4.8 # via azure-cli-core
idna==2.6 # via cryptography, requests idna==2.6 # via cryptography, requests
ipaddress==1.0.19 # via cryptography, openstacksdk ipaddress==1.0.19 # via cryptography, openstacksdk
@@ -81,6 +84,7 @@ pbr==3.1.1 # via keystoneauth1, openstacksdk, os-service-types, s
pexpect==4.6.0 pexpect==4.6.0
psutil==5.4.3 psutil==5.4.3
ptyprocess==0.5.2 # via pexpect ptyprocess==0.5.2 # via pexpect
pyasn1-modules==0.2.3 # via google-auth
pyasn1==0.4.2 # via paramiko pyasn1==0.4.2 # via paramiko
pycparser==2.18 # via cffi pycparser==2.18 # via cffi
pycurl==7.43.0.1 # via ovirt-engine-sdk-python pycurl==7.43.0.1 # via ovirt-engine-sdk-python
@@ -100,11 +104,12 @@ requests-ntlm==1.1.0 # via pywinrm
requests-oauthlib==0.8.0 # via msrest requests-oauthlib==0.8.0 # via msrest
requests==2.20.0 requests==2.20.0
requestsexceptions==1.4.0 # via openstacksdk, os-client-config requestsexceptions==1.4.0 # via openstacksdk, os-client-config
rsa==4.0 # via google-auth
s3transfer==0.1.13 # via boto3 s3transfer==0.1.13 # via boto3
secretstorage==2.3.1 # via keyring secretstorage==2.3.1 # via keyring
selectors2==2.0.1 # via ncclient selectors2==2.0.1 # via ncclient
shade==1.27.0 shade==1.27.0
six==1.11.0 # via azure-cli-core, bcrypt, cryptography, isodate, keystoneauth1, knack, munch, ncclient, ntlm-auth, openstacksdk, ovirt-engine-sdk-python, packaging, pynacl, pyopenssl, python-dateutil, pyvmomi, pywinrm, stevedore six==1.11.0 # via azure-cli-core, bcrypt, cryptography, google-auth, isodate, keystoneauth1, knack, munch, ncclient, ntlm-auth, openstacksdk, ovirt-engine-sdk-python, packaging, pynacl, pyopenssl, python-dateutil, pyvmomi, pywinrm, stevedore
stevedore==1.28.0 # via keystoneauth1 stevedore==1.28.0 # via keystoneauth1
tabulate==0.7.7 # via azure-cli-core, knack tabulate==0.7.7 # via azure-cli-core, knack
urllib3==1.24 # via requests urllib3==1.24 # via requests