60edf11750fe6a6691c1c201661a981f3cd6f5c3
[kamaki] / kamaki / cli / config / __init__.py
1 # Copyright 2011-2013 GRNET S.A. All rights reserved.
2 #
3 # Redistribution and use in source and binary forms, with or
4 # without modification, are permitted provided that the following
5 # conditions are met:
6 #
7 #   1. Redistributions of source code must retain the above
8 #      copyright notice, this list of conditions and the following
9 #      disclaimer.
10 #
11 #   2. Redistributions in binary form must reproduce the above
12 #      copyright notice, this list of conditions and the following
13 #      disclaimer in the documentation and/or other materials
14 #      provided with the distribution.
15 #
16 # THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
17 # OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
18 # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
19 # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
20 # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
23 # USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
24 # AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
25 # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
26 # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27 # POSSIBILITY OF SUCH DAMAGE.
28 #
29 # The views and conclusions contained in the software and
30 # documentation are those of the authors and should not be
31 # interpreted as representing official policies, either expressed
32 # or implied, of GRNET S.A.
33
34 import os
35 from logging import getLogger
36 from sys import stdout, stderr
37
38 from collections import defaultdict
39 from ConfigParser import RawConfigParser, NoOptionError, NoSectionError, Error
40 from re import match
41
42 from kamaki.cli.errors import CLISyntaxError
43 from kamaki import __version__
44
45 try:
46     from collections import OrderedDict
47 except ImportError:
48     from kamaki.clients.utils.ordereddict import OrderedDict
49
50
51 class InvalidCloudNameError(Error):
52     """A valid cloud name must pass through this regex: ([~@#$:.-\w]+)"""
53
54
55 log = getLogger(__name__)
56
57 # Path to the file that stores the configuration
58 CONFIG_PATH = os.path.expanduser('~/.kamakirc')
59 HISTORY_PATH = os.path.expanduser('~/.kamaki.history')
60 CLOUD_PREFIX = 'cloud'
61
62 # Name of a shell variable to bypass the CONFIG_PATH value
63 CONFIG_ENV = 'KAMAKI_CONFIG'
64
65 version = ''
66 for c in '%s' % __version__:
67     if c not in '0.123456789':
68         break
69     version += c
70 HEADER = '# Kamaki configuration file v%s\n' % version
71
72 DEFAULTS = {
73     'global': {
74         'default_cloud': '',
75         'colors': 'off',
76         'log_file': os.path.expanduser('~/.kamaki.log'),
77         'log_token': 'off',
78         'log_data': 'off',
79         'log_pid': 'off',
80         'max_threads': 7,
81         'history_file': HISTORY_PATH,
82         'user_cli': 'astakos',
83         'file_cli': 'pithos',
84         'server_cli': 'cyclades',
85         'flavor_cli': 'cyclades',
86         'network_cli': 'cyclades',
87         'image_cli': 'image',
88         'config_cli': 'config',
89         'history_cli': 'history'
90         #  Optional command specs:
91         #  'livetest_cli': 'livetest',
92         #  'astakos_cli': 'snf-astakos'
93     },
94     CLOUD_PREFIX: {
95         #'default': {
96         #    'url': '',
97         #    'token': ''
98         #    'pithos_type': 'object-store',
99         #    'pithos_version': 'v1',
100         #    'cyclades_type': 'compute',
101         #    'cyclades_version': 'v2.0',
102         #    'plankton_type': 'image',
103         #    'plankton_version': '',
104         #    'astakos_type': 'identity',
105         #    'astakos_version': 'v2.0'
106         #}
107     }
108 }
109
110
111 try:
112     import astakosclient
113     DEFAULTS['global'].update(dict(astakos_cli='snf-astakos'))
114 except ImportError:
115     pass
116
117
118 class Config(RawConfigParser):
119
120     def __init__(self, path=None, with_defaults=True):
121         RawConfigParser.__init__(self, dict_type=OrderedDict)
122         self.path = path or os.environ.get(CONFIG_ENV, CONFIG_PATH)
123         self._overrides = defaultdict(dict)
124         if with_defaults:
125             self._load_defaults()
126         self.read(self.path)
127
128         for section in self.sections():
129             r = self._cloud_name(section)
130             if r:
131                 for k, v in self.items(section):
132                     self.set_cloud(r, k, v)
133                 self.remove_section(section)
134
135     @staticmethod
136     def _cloud_name(full_section_name):
137         if not full_section_name.startswith(CLOUD_PREFIX + ' '):
138             return None
139         matcher = match(CLOUD_PREFIX + ' "([~@#$.:\-\w]+)"', full_section_name)
140         if matcher:
141             return matcher.groups()[0]
142         else:
143             icn = full_section_name[len(CLOUD_PREFIX) + 1:]
144             raise InvalidCloudNameError('Invalid Cloud Name %s' % icn)
145
146     def rescue_old_file(self, err=stderr):
147         lost_terms = []
148         global_terms = DEFAULTS['global'].keys()
149         translations = dict(
150             config=dict(serv='', cmd='config'),
151             history=dict(serv='', cmd='history'),
152             pithos=dict(serv='pithos', cmd='file'),
153             file=dict(serv='pithos', cmd='file'),
154             store=dict(serv='pithos', cmd='file'),
155             storage=dict(serv='pithos', cmd='file'),
156             image=dict(serv='plankton', cmd='image'),
157             plankton=dict(serv='plankton', cmd='image'),
158             compute=dict(serv='compute', cmd=''),
159             cyclades=dict(serv='compute', cmd='server'),
160             server=dict(serv='compute', cmd='server'),
161             flavor=dict(serv='compute', cmd='flavor'),
162             network=dict(serv='compute', cmd='network'),
163             astakos=dict(serv='astakos', cmd='user'),
164             user=dict(serv='astakos', cmd='user'),
165         )
166
167         self.set('global', 'default_' + CLOUD_PREFIX, 'default')
168         for s in self.sections():
169             if s in ('global', ):
170                 # global.url, global.token -->
171                 # cloud.default.url, cloud.default.token
172                 for term in set(self.keys(s)).difference(global_terms):
173                     if term not in ('url', 'token'):
174                         lost_terms.append('%s.%s = %s' % (
175                             s, term, self.get(s, term)))
176                         self.remove_option(s, term)
177                         continue
178                     gval = self.get(s, term)
179                     default_cloud = self.get(
180                         'global', 'default_cloud') or 'default'
181                     try:
182                         cval = self.get_cloud(default_cloud, term)
183                     except KeyError:
184                         cval = ''
185                     if gval and cval and (
186                         gval.lower().strip('/') != cval.lower().strip('/')):
187                             raise CLISyntaxError(
188                                 'Conflicting values for default %s' % (
189                                     term),
190                                 importance=2, details=[
191                                     ' global.%s:  %s' % (term, gval),
192                                     ' %s.%s.%s:  %s' % (
193                                         CLOUD_PREFIX,
194                                         default_cloud,
195                                         term,
196                                         cval),
197                                     'Please remove one of them manually:',
198                                     ' /config delete global.%s' % term,
199                                     ' or'
200                                     ' /config delete %s.%s.%s' % (
201                                         CLOUD_PREFIX, default_cloud, term),
202                                     'and try again'])
203                     elif gval:
204                         err.write(u'... rescue %s.%s => %s.%s.%s\n' % (
205                             s, term, CLOUD_PREFIX, default_cloud, term))
206                         err.flush()
207                         self.set_cloud('default', term, gval)
208                     self.remove_option(s, term)
209             # translation for <service> or <command> settings
210             # <service> or <command group> settings --> translation --> global
211             elif s in translations:
212
213                 if s in ('history',):
214                     k = 'file'
215                     v = self.get(s, k)
216                     if v:
217                         err.write(u'... rescue %s.%s => global.%s_%s\n' % (
218                             s, k, s, k))
219                         err.flush()
220                         self.set('global', '%s_%s' % (s, k), v)
221                         self.remove_option(s, k)
222
223                 trn = translations[s]
224                 for k, v in self.items(s, False):
225                     if v and k in ('cli',):
226                         err.write(u'... rescue %s.%s => global.%s_cli\n' % (
227                             s, k, trn['cmd']))
228                         err.flush()
229                         self.set('global', '%s_cli' % trn['cmd'], v)
230                     elif k in ('container',) and trn['serv'] in ('pithos',):
231                         err.write(
232                             u'... rescue %s.%s => %s.default.pithos_%s\n' % (
233                                 s, k, CLOUD_PREFIX, k))
234                         err.flush()
235                         self.set_cloud('default', 'pithos_%s' % k, v)
236                     else:
237                         lost_terms.append('%s.%s = %s' % (s, k, v))
238                 self.remove_section(s)
239         #  self.pretty_print()
240         return lost_terms
241
242     def pretty_print(self, out=stdout):
243         for s in self.sections():
244             out.write(s)
245             out.flush()
246             for k, v in self.items(s):
247                 if isinstance(v, dict):
248                     out.write(u'\t%s => {\n' % k)
249                     out.flush()
250                     for ki, vi in v.items():
251                         out.write(u'\t\t%s => %s\n' % (ki, vi))
252                         out.flush()
253                     out.write(u'\t}\n')
254                 else:
255                     out.write(u'\t %s => %s\n' % (k, v))
256                 out.flush()
257
258     def guess_version(self):
259         """
260         :returns: (float) version of the config file or 0.0 if unrecognized
261         """
262         checker = Config(self.path, with_defaults=False)
263         sections = checker.sections()
264         log.debug('Config file heuristic 1: old global section ?')
265         if 'global' in sections:
266             if checker.get('global', 'url') or checker.get('global', 'token'):
267                 log.debug('..... config file has an old global section')
268                 return 0.8
269         log.debug('........ nope')
270         log.debug('Config file heuristic 2: Any cloud sections ?')
271         if CLOUD_PREFIX in sections:
272             for r in self.keys(CLOUD_PREFIX):
273                 log.debug('... found cloud "%s"' % r)
274                 return 0.9
275         log.debug('........ nope')
276         log.debug('All heuristics failed, cannot decide')
277         return 0.9
278
279     def get_cloud(self, cloud, option):
280         """
281         :param cloud: (str) cloud alias
282
283         :param option: (str) option in cloud section
284
285         :returns: (str) the value assigned on this option
286
287         :raises KeyError: if cloud or cloud's option does not exist
288         """
289         r = self.get(CLOUD_PREFIX, cloud)
290         if not r:
291             raise KeyError('Cloud "%s" does not exist' % cloud)
292         return r[option]
293
294     def get_global(self, option):
295         return self.get('global', option)
296
297     def set_cloud(self, cloud, option, value):
298         try:
299             d = self.get(CLOUD_PREFIX, cloud) or dict()
300         except KeyError:
301             d = dict()
302         d[option] = value
303         self.set(CLOUD_PREFIX, cloud, d)
304
305     def set_global(self, option, value):
306         self.set('global', option, value)
307
308     def _load_defaults(self):
309         for section, options in DEFAULTS.items():
310             for option, val in options.items():
311                 self.set(section, option, val)
312
313     def _get_dict(self, section, include_defaults=True):
314         try:
315             d = dict(DEFAULTS[section]) if include_defaults else {}
316         except KeyError:
317             d = {}
318         try:
319             d.update(RawConfigParser.items(self, section))
320         except NoSectionError:
321             pass
322         return d
323
324     def reload(self):
325         self = self.__init__(self.path)
326
327     def get(self, section, option):
328         """
329         :param section: (str) HINT: for clouds, use cloud.<section>
330
331         :param option: (str)
332
333         :returns: (str) the value stored at section: {option: value}
334         """
335         value = self._overrides.get(section, {}).get(option)
336         if value is not None:
337             return value
338         prefix = CLOUD_PREFIX + '.'
339         if section.startswith(prefix):
340             return self.get_cloud(section[len(prefix):], option)
341         try:
342             return RawConfigParser.get(self, section, option)
343         except (NoSectionError, NoOptionError):
344             return DEFAULTS.get(section, {}).get(option)
345
346     def set(self, section, option, value):
347         """
348         :param section: (str) HINT: for remotes use cloud.<section>
349
350         :param option: (str)
351
352         :param value: str
353         """
354         prefix = CLOUD_PREFIX + '.'
355         if section.startswith(prefix):
356             cloud = self._cloud_name(
357                 CLOUD_PREFIX + ' "' + section[len(prefix):] + '"')
358             return self.set_cloud(cloud, option, value)
359         if section not in RawConfigParser.sections(self):
360             self.add_section(section)
361         RawConfigParser.set(self, section, option, value)
362
363     def remove_option(self, section, option, also_remove_default=False):
364         try:
365             if also_remove_default:
366                 DEFAULTS[section].pop(option)
367             RawConfigParser.remove_option(self, section, option)
368         except NoSectionError:
369             pass
370
371     def remove_from_cloud(self, cloud, option):
372         d = self.get(CLOUD_PREFIX, cloud)
373         if isinstance(d, dict):
374             d.pop(option)
375
376     def keys(self, section, include_defaults=True):
377         d = self._get_dict(section, include_defaults)
378         return d.keys()
379
380     def items(self, section, include_defaults=True):
381         d = self._get_dict(section, include_defaults)
382         return d.items()
383
384     def override(self, section, option, value):
385         self._overrides[section][option] = value
386
387     def write(self):
388         cld_bu = self._get_dict(CLOUD_PREFIX)
389         try:
390             for r, d in self.items(CLOUD_PREFIX):
391                 for k, v in d.items():
392                     self.set(CLOUD_PREFIX + ' "%s"' % r, k, v)
393             self.remove_section(CLOUD_PREFIX)
394
395             with open(self.path, 'w') as f:
396                 os.chmod(self.path, 0600)
397                 f.write(HEADER.lstrip())
398                 f.flush()
399                 RawConfigParser.write(self, f)
400         finally:
401             if CLOUD_PREFIX not in self.sections():
402                 self.add_section(CLOUD_PREFIX)
403             for cloud, d in cld_bu.items():
404                 self.set(CLOUD_PREFIX, cloud, d)