Fixes broken puppet-keystone calls to openstackclient
[apex.git] / build / os-client-config / config.py
1 # Copyright (c) 2014 Hewlett-Packard Development Company, L.P.
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License"); you may
4 # not use this file except in compliance with the License. You may obtain
5 # a copy of the License at
6 #
7 #      http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12 # License for the specific language governing permissions and limitations
13 # under the License.
14
15
16 # alias because we already had an option named argparse
17 import argparse as argparse_mod
18 import collections
19 import copy
20 import json
21 import os
22 import sys
23 import warnings
24
25 import appdirs
26 from keystoneauth1 import adapter
27 from keystoneauth1 import loading
28 import yaml
29
30 from os_client_config import _log
31 from os_client_config import cloud_config
32 from os_client_config import defaults
33 from os_client_config import exceptions
34 from os_client_config import vendors
35
36 APPDIRS = appdirs.AppDirs('openstack', 'OpenStack', multipath='/etc')
37 CONFIG_HOME = APPDIRS.user_config_dir
38 CACHE_PATH = APPDIRS.user_cache_dir
39
40 UNIX_CONFIG_HOME = os.path.join(
41     os.path.expanduser(os.path.join('~', '.config')), 'openstack')
42 UNIX_SITE_CONFIG_HOME = '/etc/openstack'
43
44 SITE_CONFIG_HOME = APPDIRS.site_config_dir
45
46 CONFIG_SEARCH_PATH = [
47     os.getcwd(),
48     CONFIG_HOME, UNIX_CONFIG_HOME,
49     SITE_CONFIG_HOME, UNIX_SITE_CONFIG_HOME
50 ]
51 YAML_SUFFIXES = ('.yaml', '.yml')
52 JSON_SUFFIXES = ('.json',)
53 CONFIG_FILES = [
54     os.path.join(d, 'clouds' + s)
55     for d in CONFIG_SEARCH_PATH
56     for s in YAML_SUFFIXES + JSON_SUFFIXES
57 ]
58 SECURE_FILES = [
59     os.path.join(d, 'secure' + s)
60     for d in CONFIG_SEARCH_PATH
61     for s in YAML_SUFFIXES + JSON_SUFFIXES
62 ]
63 VENDOR_FILES = [
64     os.path.join(d, 'clouds-public' + s)
65     for d in CONFIG_SEARCH_PATH
66     for s in YAML_SUFFIXES + JSON_SUFFIXES
67 ]
68
69 BOOL_KEYS = ('insecure', 'cache')
70
71
72 # NOTE(dtroyer): This turns out to be not the best idea so let's move
73 #                overriding defaults to a kwarg to OpenStackConfig.__init__()
74 #                Remove this sometime in June 2015 once OSC is comfortably
75 #                changed-over and global-defaults is updated.
76 def set_default(key, value):
77     warnings.warn(
78         "Use of set_default() is deprecated. Defaults should be set with the "
79         "`override_defaults` parameter of OpenStackConfig."
80     )
81     defaults.get_defaults()  # make sure the dict is initialized
82     defaults._defaults[key] = value
83
84
85 def get_boolean(value):
86     if value is None:
87         return False
88     if type(value) is bool:
89         return value
90     if value.lower() == 'true':
91         return True
92     return False
93
94
95 def _get_os_environ(envvar_prefix=None):
96     ret = defaults.get_defaults()
97     if not envvar_prefix:
98         # This makes the or below be OS_ or OS_ which is a no-op
99         envvar_prefix = 'OS_'
100     environkeys = [k for k in os.environ.keys()
101                    if (k.startswith('OS_') or k.startswith(envvar_prefix))
102                    and not k.startswith('OS_TEST')  # infra CI var
103                    and not k.startswith('OS_STD')   # infra CI var
104                    ]
105     for k in environkeys:
106         newkey = k.split('_', 1)[-1].lower()
107         ret[newkey] = os.environ[k]
108     # If the only environ keys are cloud and region_name, don't return anything
109     # because they are cloud selectors
110     if set(environkeys) - set(['OS_CLOUD', 'OS_REGION_NAME']):
111         return ret
112     return None
113
114
115 def _merge_clouds(old_dict, new_dict):
116     """Like dict.update, except handling nested dicts."""
117     ret = old_dict.copy()
118     for (k, v) in new_dict.items():
119         if isinstance(v, dict):
120             if k in ret:
121                 ret[k] = _merge_clouds(ret[k], v)
122             else:
123                 ret[k] = v.copy()
124         else:
125             ret[k] = v
126     return ret
127
128
129 def _auth_update(old_dict, new_dict_source):
130     """Like dict.update, except handling the nested dict called auth."""
131     new_dict = copy.deepcopy(new_dict_source)
132     for (k, v) in new_dict.items():
133         if k == 'auth':
134             if k in old_dict:
135                 old_dict[k].update(v)
136             else:
137                 old_dict[k] = v.copy()
138         else:
139             old_dict[k] = v
140     return old_dict
141
142
143 def _fix_argv(argv):
144     # Transform any _ characters in arg names to - so that we don't
145     # have to throw billions of compat argparse arguments around all
146     # over the place.
147     processed = collections.defaultdict(list)
148     for index in range(0, len(argv)):
149         if argv[index].startswith('--'):
150             split_args = argv[index].split('=')
151             orig = split_args[0]
152             new = orig.replace('_', '-')
153             if orig != new:
154                 split_args[0] = new
155                 argv[index] = "=".join(split_args)
156             # Save both for later so we can throw an error about dupes
157             processed[new].append(orig)
158     overlap = []
159     for new, old in processed.items():
160         if len(old) > 1:
161             overlap.extend(old)
162     if overlap:
163         raise exceptions.OpenStackConfigException(
164             "The following options were given: '{options}' which contain"
165             " duplicates except that one has _ and one has -. There is"
166             " no sane way for us to know what you're doing. Remove the"
167             " duplicate option and try again".format(
168                 options=','.join(overlap)))
169
170
171 class OpenStackConfig(object):
172
173     def __init__(self, config_files=None, vendor_files=None,
174                  override_defaults=None, force_ipv4=None,
175                  envvar_prefix=None, secure_files=None,
176                  pw_func=None, session_constructor=None):
177         self.log = _log.setup_logging(__name__)
178         self._session_constructor = session_constructor
179
180         self._config_files = config_files or CONFIG_FILES
181         self._secure_files = secure_files or SECURE_FILES
182         self._vendor_files = vendor_files or VENDOR_FILES
183
184         config_file_override = os.environ.pop('OS_CLIENT_CONFIG_FILE', None)
185         if config_file_override:
186             self._config_files.insert(0, config_file_override)
187
188         secure_file_override = os.environ.pop('OS_CLIENT_SECURE_FILE', None)
189         if secure_file_override:
190             self._secure_files.insert(0, secure_file_override)
191
192         self.defaults = defaults.get_defaults()
193         if override_defaults:
194             self.defaults.update(override_defaults)
195
196         # First, use a config file if it exists where expected
197         self.config_filename, self.cloud_config = self._load_config_file()
198         _, secure_config = self._load_secure_file()
199         if secure_config:
200             self.cloud_config = _merge_clouds(
201                 self.cloud_config, secure_config)
202
203         if not self.cloud_config:
204             self.cloud_config = {'clouds': {}}
205         if 'clouds' not in self.cloud_config:
206             self.cloud_config['clouds'] = {}
207
208         # Grab ipv6 preference settings from env
209         client_config = self.cloud_config.get('client', {})
210
211         if force_ipv4 is not None:
212             # If it's passed in to the constructor, honor it.
213             self.force_ipv4 = force_ipv4
214         else:
215             # Get the backwards compat value
216             prefer_ipv6 = get_boolean(
217                 os.environ.pop(
218                     'OS_PREFER_IPV6', client_config.get(
219                         'prefer_ipv6', client_config.get(
220                             'prefer-ipv6', True))))
221             force_ipv4 = get_boolean(
222                 os.environ.pop(
223                     'OS_FORCE_IPV4', client_config.get(
224                         'force_ipv4', client_config.get(
225                             'broken-ipv6', False))))
226
227             self.force_ipv4 = force_ipv4
228             if not prefer_ipv6:
229                 # this will only be false if someone set it explicitly
230                 # honor their wishes
231                 self.force_ipv4 = True
232
233         # Next, process environment variables and add them to the mix
234         self.envvar_key = os.environ.pop('OS_CLOUD_NAME', 'envvars')
235         if self.envvar_key in self.cloud_config['clouds']:
236             raise exceptions.OpenStackConfigException(
237                 '"{0}" defines a cloud named "{1}", but'
238                 ' OS_CLOUD_NAME is also set to "{1}". Please rename'
239                 ' either your environment based cloud, or one of your'
240                 ' file-based clouds.'.format(self.config_filename,
241                                              self.envvar_key))
242         # Pull out OS_CLOUD so that if it's the only thing set, do not
243         # make an envvars cloud
244         self.default_cloud = os.environ.pop('OS_CLOUD', None)
245
246         envvars = _get_os_environ(envvar_prefix=envvar_prefix)
247         if envvars:
248             self.cloud_config['clouds'][self.envvar_key] = envvars
249             if not self.default_cloud:
250                 self.default_cloud = self.envvar_key
251
252         # Finally, fall through and make a cloud that starts with defaults
253         # because we need somewhere to put arguments, and there are neither
254         # config files or env vars
255         if not self.cloud_config['clouds']:
256             self.cloud_config = dict(
257                 clouds=dict(defaults=dict(self.defaults)))
258             self.default_cloud = 'defaults'
259
260         self._cache_expiration_time = 0
261         self._cache_path = CACHE_PATH
262         self._cache_class = 'dogpile.cache.null'
263         self._cache_arguments = {}
264         self._cache_expiration = {}
265         if 'cache' in self.cloud_config:
266             cache_settings = self._normalize_keys(self.cloud_config['cache'])
267
268             # expiration_time used to be 'max_age' but the dogpile setting
269             # is expiration_time. Support max_age for backwards compat.
270             self._cache_expiration_time = cache_settings.get(
271                 'expiration_time', cache_settings.get(
272                     'max_age', self._cache_expiration_time))
273
274             # If cache class is given, use that. If not, but if cache time
275             # is given, default to memory. Otherwise, default to nothing.
276             # to memory.
277             if self._cache_expiration_time:
278                 self._cache_class = 'dogpile.cache.memory'
279             self._cache_class = self.cloud_config['cache'].get(
280                 'class', self._cache_class)
281
282             self._cache_path = os.path.expanduser(
283                 cache_settings.get('path', self._cache_path))
284             self._cache_arguments = cache_settings.get(
285                 'arguments', self._cache_arguments)
286             self._cache_expiration = cache_settings.get(
287                 'expiration', self._cache_expiration)
288
289         # Flag location to hold the peeked value of an argparse timeout value
290         self._argv_timeout = False
291
292         # Save the password callback
293         # password = self._pw_callback(prompt="Password: ")
294         self._pw_callback = pw_func
295
296     def get_extra_config(self, key, defaults=None):
297         """Fetch an arbitrary extra chunk of config, laying in defaults.
298
299         :param string key: name of the config section to fetch
300         :param dict defaults: (optional) default values to merge under the
301                                          found config
302         """
303         if not defaults:
304             defaults = {}
305         return _merge_clouds(
306             self._normalize_keys(defaults),
307             self._normalize_keys(self.cloud_config.get(key, {})))
308
309     def _load_config_file(self):
310         return self._load_yaml_json_file(self._config_files)
311
312     def _load_secure_file(self):
313         return self._load_yaml_json_file(self._secure_files)
314
315     def _load_vendor_file(self):
316         return self._load_yaml_json_file(self._vendor_files)
317
318     def _load_yaml_json_file(self, filelist):
319         for path in filelist:
320             if os.path.exists(path):
321                 with open(path, 'r') as f:
322                     if path.endswith('json'):
323                         return path, json.load(f)
324                     else:
325                         return path, yaml.safe_load(f)
326         return (None, {})
327
328     def _normalize_keys(self, config):
329         new_config = {}
330         for key, value in config.items():
331             key = key.replace('-', '_')
332             if isinstance(value, dict):
333                 new_config[key] = self._normalize_keys(value)
334             elif isinstance(value, bool):
335                 new_config[key] = value
336             elif isinstance(value, int) and key != 'verbose_level':
337                 new_config[key] = str(value)
338             elif isinstance(value, float):
339                 new_config[key] = str(value)
340             else:
341                 new_config[key] = value
342         return new_config
343
344     def get_cache_expiration_time(self):
345         return int(self._cache_expiration_time)
346
347     def get_cache_interval(self):
348         return self.get_cache_expiration_time()
349
350     def get_cache_max_age(self):
351         return self.get_cache_expiration_time()
352
353     def get_cache_path(self):
354         return self._cache_path
355
356     def get_cache_class(self):
357         return self._cache_class
358
359     def get_cache_arguments(self):
360         return copy.deepcopy(self._cache_arguments)
361
362     def get_cache_expiration(self):
363         return copy.deepcopy(self._cache_expiration)
364
365     def _expand_region_name(self, region_name):
366         return {'name': region_name, 'values': {}}
367
368     def _expand_regions(self, regions):
369         ret = []
370         for region in regions:
371             if isinstance(region, dict):
372                 ret.append(copy.deepcopy(region))
373             else:
374                 ret.append(self._expand_region_name(region))
375         return ret
376
377     def _get_regions(self, cloud):
378         if cloud not in self.cloud_config['clouds']:
379             return [self._expand_region_name('')]
380         regions = self._get_known_regions(cloud)
381         if not regions:
382             # We don't know of any regions use a workable default.
383             regions = [self._expand_region_name('')]
384         return regions
385
386     def _get_known_regions(self, cloud):
387         config = self._normalize_keys(self.cloud_config['clouds'][cloud])
388         if 'regions' in config:
389             return self._expand_regions(config['regions'])
390         elif 'region_name' in config:
391             if isinstance(config['region_name'], list):
392                 regions = config['region_name']
393             else:
394                 regions = config['region_name'].split(',')
395             if len(regions) > 1:
396                 warnings.warn(
397                     "Comma separated lists in region_name are deprecated."
398                     " Please use a yaml list in the regions"
399                     " parameter in {0} instead.".format(self.config_filename))
400             return self._expand_regions(regions)
401         else:
402             # crappit. we don't have a region defined.
403             new_cloud = dict()
404             our_cloud = self.cloud_config['clouds'].get(cloud, dict())
405             self._expand_vendor_profile(cloud, new_cloud, our_cloud)
406             if 'regions' in new_cloud and new_cloud['regions']:
407                 return self._expand_regions(new_cloud['regions'])
408             elif 'region_name' in new_cloud and new_cloud['region_name']:
409                 return [self._expand_region_name(new_cloud['region_name'])]
410
411     def _get_region(self, cloud=None, region_name=''):
412         if region_name is None:
413             region_name = ''
414         if not cloud:
415             return self._expand_region_name(region_name)
416
417         regions = self._get_known_regions(cloud)
418         if not regions:
419             return self._expand_region_name(region_name)
420
421         if not region_name:
422             return regions[0]
423
424         for region in regions:
425             if region['name'] == region_name:
426                 return region
427
428         raise exceptions.OpenStackConfigException(
429             'Region {region_name} is not a valid region name for cloud'
430             ' {cloud}. Valid choices are {region_list}. Please note that'
431             ' region names are case sensitive.'.format(
432                 region_name=region_name,
433                 region_list=','.join([r['name'] for r in regions]),
434                 cloud=cloud))
435
436     def get_cloud_names(self):
437         return self.cloud_config['clouds'].keys()
438
439     def _get_base_cloud_config(self, name):
440         cloud = dict()
441
442         # Only validate cloud name if one was given
443         if name and name not in self.cloud_config['clouds']:
444             raise exceptions.OpenStackConfigException(
445                 "Cloud {name} was not found.".format(
446                     name=name))
447
448         our_cloud = self.cloud_config['clouds'].get(name, dict())
449
450         # Get the defaults
451         cloud.update(self.defaults)
452         self._expand_vendor_profile(name, cloud, our_cloud)
453
454         if 'auth' not in cloud:
455             cloud['auth'] = dict()
456
457         _auth_update(cloud, our_cloud)
458         if 'cloud' in cloud:
459             del cloud['cloud']
460
461         return cloud
462
463     def _expand_vendor_profile(self, name, cloud, our_cloud):
464         # Expand a profile if it exists. 'cloud' is an old confusing name
465         # for this.
466         profile_name = our_cloud.get('profile', our_cloud.get('cloud', None))
467         if profile_name and profile_name != self.envvar_key:
468             if 'cloud' in our_cloud:
469                 warnings.warn(
470                     "{0} use the keyword 'cloud' to reference a known "
471                     "vendor profile. This has been deprecated in favor of the "
472                     "'profile' keyword.".format(self.config_filename))
473             vendor_filename, vendor_file = self._load_vendor_file()
474             if vendor_file and profile_name in vendor_file['public-clouds']:
475                 _auth_update(cloud, vendor_file['public-clouds'][profile_name])
476             else:
477                 profile_data = vendors.get_profile(profile_name)
478                 if profile_data:
479                     status = profile_data.pop('status', 'active')
480                     message = profile_data.pop('message', '')
481                     if status == 'deprecated':
482                         warnings.warn(
483                             "{profile_name} is deprecated: {message}".format(
484                                 profile_name=profile_name, message=message))
485                     elif status == 'shutdown':
486                         raise exceptions.OpenStackConfigException(
487                             "{profile_name} references a cloud that no longer"
488                             " exists: {message}".format(
489                                 profile_name=profile_name, message=message))
490                     _auth_update(cloud, profile_data)
491                 else:
492                     # Can't find the requested vendor config, go about business
493                     warnings.warn("Couldn't find the vendor profile '{0}', for"
494                                   " the cloud '{1}'".format(profile_name,
495                                                             name))
496
497     def _project_scoped(self, cloud):
498         return ('project_id' in cloud or 'project_name' in cloud
499                 or 'project_id' in cloud['auth']
500                 or 'project_name' in cloud['auth'])
501
502     def _validate_networks(self, networks, key):
503         value = None
504         for net in networks:
505             if value and net[key]:
506                 raise exceptions.OpenStackConfigException(
507                     "Duplicate network entries for {key}: {net1} and {net2}."
508                     " Only one network can be flagged with {key}".format(
509                         key=key,
510                         net1=value['name'],
511                         net2=net['name']))
512             if not value and net[key]:
513                 value = net
514
515     def _fix_backwards_networks(self, cloud):
516         # Leave the external_network and internal_network keys in the
517         # dict because consuming code might be expecting them.
518         networks = []
519         # Normalize existing network entries
520         for net in cloud.get('networks', []):
521             name = net.get('name')
522             if not name:
523                 raise exceptions.OpenStackConfigException(
524                     'Entry in network list is missing required field "name".')
525             network = dict(
526                 name=name,
527                 routes_externally=get_boolean(net.get('routes_externally')),
528                 nat_destination=get_boolean(net.get('nat_destination')),
529                 default_interface=get_boolean(net.get('default_interface')),
530             )
531             # routes_ipv4_externally defaults to the value of routes_externally
532             network['routes_ipv4_externally'] = get_boolean(
533                 net.get(
534                     'routes_ipv4_externally', network['routes_externally']))
535             # routes_ipv6_externally defaults to the value of routes_externally
536             network['routes_ipv6_externally'] = get_boolean(
537                 net.get(
538                     'routes_ipv6_externally', network['routes_externally']))
539             networks.append(network)
540
541         for key in ('external_network', 'internal_network'):
542             external = key.startswith('external')
543             if key in cloud and 'networks' in cloud:
544                 raise exceptions.OpenStackConfigException(
545                     "Both {key} and networks were specified in the config."
546                     " Please remove {key} from the config and use the network"
547                     " list to configure network behavior.".format(key=key))
548             if key in cloud:
549                 warnings.warn(
550                     "{key} is deprecated. Please replace with an entry in"
551                     " a dict inside of the networks list with name: {name}"
552                     " and routes_externally: {external}".format(
553                         key=key, name=cloud[key], external=external))
554                 networks.append(dict(
555                     name=cloud[key],
556                     routes_externally=external,
557                     nat_destination=not external,
558                     default_interface=external))
559
560         # Validate that we don't have duplicates
561         self._validate_networks(networks, 'nat_destination')
562         self._validate_networks(networks, 'default_interface')
563
564         cloud['networks'] = networks
565         return cloud
566
567     def _handle_domain_id(self, cloud):
568         # Allow people to just specify domain once if it's the same
569         mappings = {
570             'domain_id': ('user_domain_id', 'project_domain_id'),
571             'domain_name': ('user_domain_name', 'project_domain_name'),
572         }
573         for target_key, possible_values in mappings.items():
574             if not self._project_scoped(cloud):
575                 if target_key in cloud and target_key not in cloud['auth']:
576                     cloud['auth'][target_key] = cloud.pop(target_key)
577                 continue
578             for key in possible_values:
579                 if target_key in cloud['auth'] and key not in cloud['auth']:
580                     cloud['auth'][key] = cloud['auth'][target_key]
581             cloud.pop(target_key, None)
582             cloud['auth'].pop(target_key, None)
583         return cloud
584
585     def _fix_backwards_project(self, cloud):
586         # Do the lists backwards so that project_name is the ultimate winner
587         # Also handle moving domain names into auth so that domain mapping
588         # is easier
589         mappings = {
590             'domain_id': ('domain_id', 'domain-id'),
591             'domain_name': ('domain_name', 'domain-name'),
592             'user_domain_id': ('user_domain_id', 'user-domain-id'),
593             'user_domain_name': ('user_domain_name', 'user-domain-name'),
594             'project_domain_id': ('project_domain_id', 'project-domain-id'),
595             'project_domain_name': (
596                 'project_domain_name', 'project-domain-name'),
597             'token': ('auth-token', 'auth_token', 'token'),
598         }
599         if cloud.get('auth_type', None) == 'v2password':
600             # If v2password is explcitly requested, this is to deal with old
601             # clouds. That's fine - we need to map settings in the opposite
602             # direction
603             mappings['tenant_id'] = (
604                 'project_id', 'project-id', 'tenant_id', 'tenant-id')
605             mappings['tenant_name'] = (
606                 'project_name', 'project-name', 'tenant_name', 'tenant-name')
607         else:
608             mappings['project_id'] = (
609                 'tenant_id', 'tenant-id', 'project_id', 'project-id')
610             mappings['project_name'] = (
611                 'tenant_name', 'tenant-name', 'project_name', 'project-name')
612         for target_key, possible_values in mappings.items():
613             target = None
614             for key in possible_values:
615                 if key in cloud:
616                     target = str(cloud[key])
617                     del cloud[key]
618                 if key in cloud['auth']:
619                     target = str(cloud['auth'][key])
620                     del cloud['auth'][key]
621             if target:
622                 cloud['auth'][target_key] = target
623         return cloud
624
625     def _fix_backwards_auth_plugin(self, cloud):
626         # Do the lists backwards so that auth_type is the ultimate winner
627         mappings = {
628             'auth_type': ('auth_plugin', 'auth_type'),
629         }
630         for target_key, possible_values in mappings.items():
631             target = None
632             for key in possible_values:
633                 if key in cloud:
634                     target = cloud[key]
635                     del cloud[key]
636             cloud[target_key] = target
637         # Because we force alignment to v3 nouns, we want to force
638         # use of the auth plugin that can do auto-selection and dealing
639         # with that based on auth parameters. v2password is basically
640         # completely broken
641         return cloud
642
643     def register_argparse_arguments(self, parser, argv, service_keys=None):
644         """Register all of the common argparse options needed.
645
646         Given an argparse parser, register the keystoneauth Session arguments,
647         the keystoneauth Auth Plugin Options and os-cloud. Also, peek in the
648         argv to see if all of the auth plugin options should be registered
649         or merely the ones already configured.
650         :param argparse.ArgumentParser: parser to attach argparse options to
651         :param list argv: the arguments provided to the application
652         :param string service_keys: Service or list of services this argparse
653                                     should be specialized for, if known.
654                                     The first item in the list will be used
655                                     as the default value for service_type
656                                     (optional)
657
658         :raises exceptions.OpenStackConfigException if an invalid auth-type
659                                                     is requested
660         """
661
662         if service_keys is None:
663             service_keys = []
664
665         # Fix argv in place - mapping any keys with embedded _ in them to -
666         _fix_argv(argv)
667
668         local_parser = argparse_mod.ArgumentParser(add_help=False)
669
670         for p in (parser, local_parser):
671             p.add_argument(
672                 '--os-cloud',
673                 metavar='<name>',
674                 default=os.environ.get('OS_CLOUD', None),
675                 help='Named cloud to connect to')
676
677         # we need to peek to see if timeout was actually passed, since
678         # the keystoneauth declaration of it has a default, which means
679         # we have no clue if the value we get is from the ksa default
680         # for from the user passing it explicitly. We'll stash it for later
681         local_parser.add_argument('--timeout', metavar='<timeout>')
682
683         # We need for get_one_cloud to be able to peek at whether a token
684         # was passed so that we can swap the default from password to
685         # token if it was. And we need to also peek for --os-auth-token
686         # for novaclient backwards compat
687         local_parser.add_argument('--os-token')
688         local_parser.add_argument('--os-auth-token')
689
690         # Peek into the future and see if we have an auth-type set in
691         # config AND a cloud set, so that we know which command line
692         # arguments to register and show to the user (the user may want
693         # to say something like:
694         #   openstack --os-cloud=foo --os-oidctoken=bar
695         # although I think that user is the cause of my personal pain
696         options, _args = local_parser.parse_known_args(argv)
697         if options.timeout:
698             self._argv_timeout = True
699
700         # validate = False because we're not _actually_ loading here
701         # we're only peeking, so it's the wrong time to assert that
702         # the rest of the arguments given are invalid for the plugin
703         # chosen (for instance, --help may be requested, so that the
704         # user can see what options he may want to give
705         cloud = self.get_one_cloud(argparse=options, validate=False)
706         default_auth_type = cloud.config['auth_type']
707
708         try:
709             loading.register_auth_argparse_arguments(
710                 parser, argv, default=default_auth_type)
711         except Exception:
712             # Hidiing the keystoneauth exception because we're not actually
713             # loading the auth plugin at this point, so the error message
714             # from it doesn't actually make sense to os-client-config users
715             options, _args = parser.parse_known_args(argv)
716             plugin_names = loading.get_available_plugin_names()
717             raise exceptions.OpenStackConfigException(
718                 "An invalid auth-type was specified: {auth_type}."
719                 " Valid choices are: {plugin_names}.".format(
720                     auth_type=options.os_auth_type,
721                     plugin_names=",".join(plugin_names)))
722
723         if service_keys:
724             primary_service = service_keys[0]
725         else:
726             primary_service = None
727         loading.register_session_argparse_arguments(parser)
728         adapter.register_adapter_argparse_arguments(
729             parser, service_type=primary_service)
730         for service_key in service_keys:
731             # legacy clients have un-prefixed api-version options
732             parser.add_argument(
733                 '--{service_key}-api-version'.format(
734                     service_key=service_key.replace('_', '-'),
735                     help=argparse_mod.SUPPRESS))
736             adapter.register_service_adapter_argparse_arguments(
737                 parser, service_type=service_key)
738
739         # Backwards compat options for legacy clients
740         parser.add_argument('--http-timeout', help=argparse_mod.SUPPRESS)
741         parser.add_argument('--os-endpoint-type', help=argparse_mod.SUPPRESS)
742         parser.add_argument('--endpoint-type', help=argparse_mod.SUPPRESS)
743
744     def _fix_backwards_interface(self, cloud):
745         new_cloud = {}
746         for key in cloud.keys():
747             if key.endswith('endpoint_type'):
748                 target_key = key.replace('endpoint_type', 'interface')
749             else:
750                 target_key = key
751             new_cloud[target_key] = cloud[key]
752         return new_cloud
753
754     def _fix_backwards_api_timeout(self, cloud):
755         new_cloud = {}
756         # requests can only have one timeout, which means that in a single
757         # cloud there is no point in different timeout values. However,
758         # for some reason many of the legacy clients decided to shove their
759         # service name in to the arg name for reasons surpassin sanity. If
760         # we find any values that are not api_timeout, overwrite api_timeout
761         # with the value
762         service_timeout = None
763         for key in cloud.keys():
764             if key.endswith('timeout') and not (
765                     key == 'timeout' or key == 'api_timeout'):
766                 service_timeout = cloud[key]
767             else:
768                 new_cloud[key] = cloud[key]
769         if service_timeout is not None:
770             new_cloud['api_timeout'] = service_timeout
771         # The common argparse arg from keystoneauth is called timeout, but
772         # os-client-config expects it to be called api_timeout
773         if self._argv_timeout:
774             if 'timeout' in new_cloud and new_cloud['timeout']:
775                 new_cloud['api_timeout'] = new_cloud.pop('timeout')
776         return new_cloud
777
778     def get_all_clouds(self):
779
780         clouds = []
781
782         for cloud in self.get_cloud_names():
783             for region in self._get_regions(cloud):
784                 if region:
785                     clouds.append(self.get_one_cloud(
786                         cloud, region_name=region['name']))
787         return clouds
788
789     def _fix_args(self, args=None, argparse=None):
790         """Massage the passed-in options
791
792         Replace - with _ and strip os_ prefixes.
793
794         Convert an argparse Namespace object to a dict, removing values
795         that are either None or ''.
796         """
797         if not args:
798             args = {}
799
800         if argparse:
801             # Convert the passed-in Namespace
802             o_dict = vars(argparse)
803             parsed_args = dict()
804             for k in o_dict:
805                 if o_dict[k] is not None and o_dict[k] != '':
806                     parsed_args[k] = o_dict[k]
807             args.update(parsed_args)
808
809         os_args = dict()
810         new_args = dict()
811         for (key, val) in iter(args.items()):
812             if type(args[key]) == dict:
813                 # dive into the auth dict
814                 new_args[key] = self._fix_args(args[key])
815                 continue
816
817             key = key.replace('-', '_')
818             if key.startswith('os_'):
819                 os_args[key[3:]] = val
820             else:
821                 new_args[key] = val
822         new_args.update(os_args)
823         return new_args
824
825     def _find_winning_auth_value(self, opt, config):
826         opt_name = opt.name.replace('-', '_')
827         if opt_name in config:
828             return config[opt_name]
829         else:
830             deprecated = getattr(opt, 'deprecated', getattr(
831                 opt, 'deprecated_opts', []))
832             for d_opt in deprecated:
833                 d_opt_name = d_opt.name.replace('-', '_')
834                 if d_opt_name in config:
835                     return config[d_opt_name]
836
837     def auth_config_hook(self, config):
838         """Allow examination of config values before loading auth plugin
839
840         OpenStackClient will override this to perform additional checks
841         on auth_type.
842         """
843         return config
844
845     def _get_auth_loader(self, config):
846         # Re-use the admin_token plugin for the "None" plugin
847         # since it does not look up endpoints or tokens but rather
848         # does a passthrough. This is useful for things like Ironic
849         # that have a keystoneless operational mode, but means we're
850         # still dealing with a keystoneauth Session object, so all the
851         # _other_ things (SSL arg handling, timeout) all work consistently
852         if config['auth_type'] in (None, "None", ''):
853             config['auth_type'] = 'admin_token'
854             # Set to notused rather than None because validate_auth will
855             # strip the value if it's actually python None
856             config['auth']['token'] = 'notused'
857         elif config['auth_type'] == 'token_endpoint':
858             # Humans have been trained to use a thing called token_endpoint
859             # That it does not exist in keystoneauth is irrelvant- it not
860             # doing what they want causes them sorrow.
861             config['auth_type'] = 'admin_token'
862         return loading.get_plugin_loader(config['auth_type'])
863
864     def _validate_auth_ksc(self, config, cloud):
865         try:
866             import keystoneclient.auth as ksc_auth
867         except ImportError:
868             return config
869
870         # May throw a keystoneclient.exceptions.NoMatchingPlugin
871         plugin_options = ksc_auth.get_plugin_class(
872             config['auth_type']).get_options()
873
874         for p_opt in plugin_options:
875             # if it's in config.auth, win, kill it from config dict
876             # if it's in config and not in config.auth, move it
877             # deprecated loses to current
878             # provided beats default, deprecated or not
879             winning_value = self._find_winning_auth_value(
880                 p_opt,
881                 config['auth'],
882             )
883             if not winning_value:
884                 winning_value = self._find_winning_auth_value(
885                     p_opt,
886                     config,
887                 )
888
889             # if the plugin tells us that this value is required
890             # then error if it's doesn't exist now
891             if not winning_value and p_opt.required:
892                 raise exceptions.OpenStackConfigException(
893                     'Unable to find auth information for cloud'
894                     ' {cloud} in config files {files}'
895                     ' or environment variables. Missing value {auth_key}'
896                     ' required for auth plugin {plugin}'.format(
897                         cloud=cloud, files=','.join(self._config_files),
898                         auth_key=p_opt.name, plugin=config.get('auth_type')))
899
900             # Clean up after ourselves
901             for opt in [p_opt.name] + [o.name for o in p_opt.deprecated_opts]:
902                 opt = opt.replace('-', '_')
903                 config.pop(opt, None)
904                 config['auth'].pop(opt, None)
905
906             if winning_value:
907                 # Prefer the plugin configuration dest value if the value's key
908                 # is marked as depreciated.
909                 if p_opt.dest is None:
910                     config['auth'][p_opt.name.replace('-', '_')] = (
911                         winning_value)
912                 else:
913                     config['auth'][p_opt.dest] = winning_value
914                     if p_opt.dest == 'endpoint':
915                        config['auth']['url'] = winning_value
916
917         return config
918
919     def _validate_auth(self, config, loader):
920         # May throw a keystoneauth1.exceptions.NoMatchingPlugin
921
922         plugin_options = loader.get_options()
923
924         for p_opt in plugin_options:
925             # if it's in config.auth, win, kill it from config dict
926             # if it's in config and not in config.auth, move it
927             # deprecated loses to current
928             # provided beats default, deprecated or not
929             winning_value = self._find_winning_auth_value(
930                 p_opt,
931                 config['auth'],
932             )
933             if not winning_value:
934                 winning_value = self._find_winning_auth_value(
935                     p_opt,
936                     config,
937                 )
938
939             config = self._clean_up_after_ourselves(
940                 config,
941                 p_opt,
942                 winning_value,
943             )
944
945             if winning_value:
946                 # Prefer the plugin configuration dest value if the value's key
947                 # is marked as deprecated.
948                 if p_opt.dest is None:
949                     good_name = p_opt.name.replace('-', '_')
950                     config['auth'][good_name] = winning_value
951                 else:
952                     config['auth'][p_opt.dest] = winning_value
953                     if p_opt.dest == 'endpoint':
954                        config['auth']['url'] = winning_value
955
956
957             # See if this needs a prompting
958             config = self.option_prompt(config, p_opt)
959
960         return config
961
962     def _validate_auth_correctly(self, config, loader):
963         # May throw a keystoneauth1.exceptions.NoMatchingPlugin
964
965         plugin_options = loader.get_options()
966
967         for p_opt in plugin_options:
968             # if it's in config, win, move it and kill it from config dict
969             # if it's in config.auth but not in config it's good
970             # deprecated loses to current
971             # provided beats default, deprecated or not
972             winning_value = self._find_winning_auth_value(
973                 p_opt,
974                 config,
975             )
976             if not winning_value:
977                 winning_value = self._find_winning_auth_value(
978                     p_opt,
979                     config['auth'],
980                 )
981
982             config = self._clean_up_after_ourselves(
983                 config,
984                 p_opt,
985                 winning_value,
986             )
987
988             # See if this needs a prompting
989             config = self.option_prompt(config, p_opt)
990
991         return config
992
993     def option_prompt(self, config, p_opt):
994         """Prompt user for option that requires a value"""
995         if (
996                 p_opt.prompt is not None and
997                 p_opt.dest not in config['auth'] and
998                 self._pw_callback is not None
999         ):
1000             config['auth'][p_opt.dest] = self._pw_callback(p_opt.prompt)
1001         return config
1002
1003     def _clean_up_after_ourselves(self, config, p_opt, winning_value):
1004
1005         # Clean up after ourselves
1006         for opt in [p_opt.name] + [o.name for o in p_opt.deprecated]:
1007             opt = opt.replace('-', '_')
1008             config.pop(opt, None)
1009             config['auth'].pop(opt, None)
1010
1011         if winning_value:
1012             # Prefer the plugin configuration dest value if the value's key
1013             # is marked as depreciated.
1014             if p_opt.dest is None:
1015                 config['auth'][p_opt.name.replace('-', '_')] = (
1016                     winning_value)
1017             else:
1018                 config['auth'][p_opt.dest] = winning_value
1019         return config
1020
1021     def magic_fixes(self, config):
1022         """Perform the set of magic argument fixups"""
1023
1024         # Infer token plugin if a token was given
1025         if (('auth' in config and 'token' in config['auth']) or
1026                 ('auth_token' in config and config['auth_token']) or
1027                 ('token' in config and config['token'])):
1028             config.setdefault('token', config.pop('auth_token', None))
1029
1030         # These backwards compat values are only set via argparse. If it's
1031         # there, it's because it was passed in explicitly, and should win
1032         config = self._fix_backwards_api_timeout(config)
1033         if 'endpoint_type' in config:
1034             config['interface'] = config.pop('endpoint_type')
1035
1036         config = self._fix_backwards_auth_plugin(config)
1037         config = self._fix_backwards_project(config)
1038         config = self._fix_backwards_interface(config)
1039         config = self._fix_backwards_networks(config)
1040         config = self._handle_domain_id(config)
1041
1042         for key in BOOL_KEYS:
1043             if key in config:
1044                 if type(config[key]) is not bool:
1045                     config[key] = get_boolean(config[key])
1046
1047         # TODO(mordred): Special casing auth_url here. We should
1048         #                come back to this betterer later so that it's
1049         #                more generalized
1050         if 'auth' in config and 'auth_url' in config['auth']:
1051             config['auth']['auth_url'] = config['auth']['auth_url'].format(
1052                 **config)
1053
1054         return config
1055
1056     def get_one_cloud(self, cloud=None, validate=True,
1057                       argparse=None, **kwargs):
1058         """Retrieve a single cloud configuration and merge additional options
1059
1060         :param string cloud:
1061             The name of the configuration to load from clouds.yaml
1062         :param boolean validate:
1063             Validate the config. Setting this to False causes no auth plugin
1064             to be created. It's really only useful for testing.
1065         :param Namespace argparse:
1066             An argparse Namespace object; allows direct passing in of
1067             argparse options to be added to the cloud config.  Values
1068             of None and '' will be removed.
1069         :param region_name: Name of the region of the cloud.
1070         :param kwargs: Additional configuration options
1071
1072         :raises: keystoneauth1.exceptions.MissingRequiredOptions
1073             on missing required auth parameters
1074         """
1075
1076         args = self._fix_args(kwargs, argparse=argparse)
1077
1078         if cloud is None:
1079             if 'cloud' in args:
1080                 cloud = args['cloud']
1081             else:
1082                 cloud = self.default_cloud
1083
1084         config = self._get_base_cloud_config(cloud)
1085
1086         # Get region specific settings
1087         if 'region_name' not in args:
1088             args['region_name'] = ''
1089         region = self._get_region(cloud=cloud, region_name=args['region_name'])
1090         args['region_name'] = region['name']
1091         region_args = copy.deepcopy(region['values'])
1092
1093         # Regions is a list that we can use to create a list of cloud/region
1094         # objects. It does not belong in the single-cloud dict
1095         config.pop('regions', None)
1096
1097         # Can't just do update, because None values take over
1098         for arg_list in region_args, args:
1099             for (key, val) in iter(arg_list.items()):
1100                 if val is not None:
1101                     if key == 'auth' and config[key] is not None:
1102                         config[key] = _auth_update(config[key], val)
1103                     else:
1104                         config[key] = val
1105
1106         config = self.magic_fixes(config)
1107         config = self._normalize_keys(config)
1108
1109         # NOTE(dtroyer): OSC needs a hook into the auth args before the
1110         #                plugin is loaded in order to maintain backward-
1111         #                compatible behaviour
1112         config = self.auth_config_hook(config)
1113
1114         if validate:
1115             try:
1116                 loader = self._get_auth_loader(config)
1117                 config = self._validate_auth(config, loader)
1118                 auth_plugin = loader.load_from_options(**config['auth'])
1119             except Exception as e:
1120                 # We WANT the ksa exception normally
1121                 # but OSC can't handle it right now, so we try deferring
1122                 # to ksc. If that ALSO fails, it means there is likely
1123                 # a deeper issue, so we assume the ksa error was correct
1124                 self.log.debug("Deferring keystone exception: {e}".format(e=e))
1125                 auth_plugin = None
1126                 try:
1127                     config = self._validate_auth_ksc(config, cloud)
1128                 except Exception:
1129                     raise e
1130         else:
1131             auth_plugin = None
1132
1133         # If any of the defaults reference other values, we need to expand
1134         for (key, value) in config.items():
1135             if hasattr(value, 'format'):
1136                 config[key] = value.format(**config)
1137
1138         force_ipv4 = config.pop('force_ipv4', self.force_ipv4)
1139         prefer_ipv6 = config.pop('prefer_ipv6', True)
1140         if not prefer_ipv6:
1141             force_ipv4 = True
1142
1143         if cloud is None:
1144             cloud_name = ''
1145         else:
1146             cloud_name = str(cloud)
1147         return cloud_config.CloudConfig(
1148             name=cloud_name,
1149             region=config['region_name'],
1150             config=config,
1151             force_ipv4=force_ipv4,
1152             auth_plugin=auth_plugin,
1153             openstack_config=self,
1154             session_constructor=self._session_constructor,
1155         )
1156
1157     def get_one_cloud_osc(
1158         self,
1159         cloud=None,
1160         validate=True,
1161         argparse=None,
1162         **kwargs
1163     ):
1164         """Retrieve a single cloud configuration and merge additional options
1165
1166         :param string cloud:
1167             The name of the configuration to load from clouds.yaml
1168         :param boolean validate:
1169             Validate the config. Setting this to False causes no auth plugin
1170             to be created. It's really only useful for testing.
1171         :param Namespace argparse:
1172             An argparse Namespace object; allows direct passing in of
1173             argparse options to be added to the cloud config.  Values
1174             of None and '' will be removed.
1175         :param region_name: Name of the region of the cloud.
1176         :param kwargs: Additional configuration options
1177
1178         :raises: keystoneauth1.exceptions.MissingRequiredOptions
1179             on missing required auth parameters
1180         """
1181
1182         args = self._fix_args(kwargs, argparse=argparse)
1183
1184         if cloud is None:
1185             if 'cloud' in args:
1186                 cloud = args['cloud']
1187             else:
1188                 cloud = self.default_cloud
1189
1190         config = self._get_base_cloud_config(cloud)
1191
1192         # Get region specific settings
1193         if 'region_name' not in args:
1194             args['region_name'] = ''
1195         region = self._get_region(cloud=cloud, region_name=args['region_name'])
1196         args['region_name'] = region['name']
1197         region_args = copy.deepcopy(region['values'])
1198
1199         # Regions is a list that we can use to create a list of cloud/region
1200         # objects. It does not belong in the single-cloud dict
1201         config.pop('regions', None)
1202
1203         # Can't just do update, because None values take over
1204         for arg_list in region_args, args:
1205             for (key, val) in iter(arg_list.items()):
1206                 if val is not None:
1207                     if key == 'auth' and config[key] is not None:
1208                         config[key] = _auth_update(config[key], val)
1209                     else:
1210                         config[key] = val
1211
1212         config = self.magic_fixes(config)
1213
1214         # NOTE(dtroyer): OSC needs a hook into the auth args before the
1215         #                plugin is loaded in order to maintain backward-
1216         #                compatible behaviour
1217         config = self.auth_config_hook(config)
1218
1219         if validate:
1220             loader = self._get_auth_loader(config)
1221             config = self._validate_auth_correctly(config, loader)
1222             auth_plugin = loader.load_from_options(**config['auth'])
1223         else:
1224             auth_plugin = None
1225
1226         # If any of the defaults reference other values, we need to expand
1227         for (key, value) in config.items():
1228             if hasattr(value, 'format'):
1229                 config[key] = value.format(**config)
1230
1231         force_ipv4 = config.pop('force_ipv4', self.force_ipv4)
1232         prefer_ipv6 = config.pop('prefer_ipv6', True)
1233         if not prefer_ipv6:
1234             force_ipv4 = True
1235
1236         if cloud is None:
1237             cloud_name = ''
1238         else:
1239             cloud_name = str(cloud)
1240         return cloud_config.CloudConfig(
1241             name=cloud_name,
1242             region=config['region_name'],
1243             config=self._normalize_keys(config),
1244             force_ipv4=force_ipv4,
1245             auth_plugin=auth_plugin,
1246             openstack_config=self,
1247         )
1248
1249     @staticmethod
1250     def set_one_cloud(config_file, cloud, set_config=None):
1251         """Set a single cloud configuration.
1252
1253         :param string config_file:
1254             The path to the config file to edit. If this file does not exist
1255             it will be created.
1256         :param string cloud:
1257             The name of the configuration to save to clouds.yaml
1258         :param dict set_config: Configuration options to be set
1259         """
1260
1261         set_config = set_config or {}
1262         cur_config = {}
1263         try:
1264             with open(config_file) as fh:
1265                 cur_config = yaml.safe_load(fh)
1266         except IOError as e:
1267             # Not no such file
1268             if e.errno != 2:
1269                 raise
1270             pass
1271
1272         clouds_config = cur_config.get('clouds', {})
1273         cloud_config = _auth_update(clouds_config.get(cloud, {}), set_config)
1274         clouds_config[cloud] = cloud_config
1275         cur_config['clouds'] = clouds_config
1276
1277         with open(config_file, 'w') as fh:
1278             yaml.safe_dump(cur_config, fh, default_flow_style=False)
1279
1280 if __name__ == '__main__':
1281     config = OpenStackConfig().get_all_clouds()
1282     for cloud in config:
1283         print_cloud = False
1284         if len(sys.argv) == 1:
1285             print_cloud = True
1286         elif len(sys.argv) == 3 and (
1287                 sys.argv[1] == cloud.name and sys.argv[2] == cloud.region):
1288             print_cloud = True
1289         elif len(sys.argv) == 2 and (
1290                 sys.argv[1] == cloud.name):
1291             print_cloud = True
1292
1293         if print_cloud:
1294             print(cloud.name, cloud.region, cloud.config)