Putting names at the top of files is is not recommended. Collective wisdom for
[opensuse:osc.git] / osc / conf.py
1 #!/usr/bin/python
2
3 # Copyright (C) 2006 Novell Inc.  All rights reserved.
4 # This program is free software; it may be used, copied, modified
5 # and distributed under the terms of the GNU General Public Licence,
6 # either version 2, or (at your option) any later version.
7
8 """Read osc configuration and store it in a dictionary
9
10 This module reads and parses ~/.oscrc. The resulting configuration is stored
11 for later usage in a dictionary named 'config'. 
12
13 In the absence of .oscrc, it tries .netrc.
14 If information is missing, it asks the user questions.
15
16 After reading the config, urllib2 is initialized.
17
18 The configuration dictionary could look like this:
19
20 {'apisrv': 'https://api.opensuse.org/',
21  'user': 'joe',
22  'api_host_options': {'api.opensuse.org': {'user': 'joe', 'pass': 'secret'},
23                       'apitest.opensuse.org': {'user': 'joe', 'pass': 'secret',
24                                                'http_headers':(('Host','api.suse.de'),
25                                                                ('User','faye'))},
26                       'foo.opensuse.org': {'user': 'foo', 'pass': 'foo'}},
27  'build-cmd': '/usr/bin/build',
28  'build-root': '/abuild/oscbuild-%(repo)s-%(arch)s',
29  'packagecachedir': '/var/cache/osbuild',
30  'su-wrapper': 'sudo',
31  }
32
33 """
34
35 import OscConfigParser
36 from osc import oscerr
37
38 # being global to this module, this dict can be accessed from outside
39 # it will hold the parsed configuration
40 config = { }
41
42 DEFAULTS = { 'apiurl': 'https://api.opensuse.org',
43              'user': 'your_username',
44              'pass': 'your_password',
45              'packagecachedir': '/var/tmp/osbuild-packagecache',
46              'su-wrapper': 'su -c',
47
48              # build type settings
49              'build-cmd': '/usr/bin/build',
50              'build-type' : '', # may be empty for chroot, kvm or xen
51              'build-root': '/var/tmp/build-root',
52              'build-device': '', # required for VM builds
53              'build-memory' : '',# required for VM builds
54              'build-swap' : '',  # optional for VM builds
55
56              'debug': '0',
57              'http_debug': '0',
58              'traceback': '0',
59              'post_mortem': '0',
60              'cookiejar': '~/.osc_cookiejar',
61              # disable project tracking by default
62              'do_package_tracking': '0',
63              # default for osc build
64              'extra-pkgs': 'vim gdb strace',
65              # default platform
66              'build_platform': 'openSUSE_Factory',
67 }
68 boolean_opts = ['debug', 'do_package_tracking', 'http_debug', 'post_mortem', 'traceback']
69
70 new_conf_template = """
71 [general]
72
73 # URL to access API server, e.g. %(apiurl)s
74 # you also need a section [%(apiurl)s] with the credentials
75 apiurl = %(apiurl)s
76
77 # Downloaded packages are cached here. Must be writable by you.
78 #packagecachedir = %(packagecachedir)s
79
80 # Wrapper to call build as root (sudo, su -, ...)
81 #su-wrapper = %(su-wrapper)s
82
83 # rootdir to setup the chroot environment
84 # can contain %%(repo)s, %%(arch)s, %%(project)s and %%(package)s for replacement, e.g.
85 # /srv/oscbuild/%%(repo)s-%%(arch)s or
86 # /srv/oscbuild/%%(repo)s-%%(arch)s-%%(project)s-%%(package)s
87 #build-root = %(build-root)s
88
89 # extra packages to install when building packages locally (osc build)
90 # this corresponds to osc build's -x option and can be overridden with that
91 # -x '' can also be given on the command line to override this setting, or
92 # you can have an empty setting here.
93 #extra-pkgs = vim gdb strace
94
95 # build platform is used if the platform argument is omitted to osc build
96 #build_platform = openSUSE_Factory
97
98 # show info useful for debugging 
99 #debug = 1
100     
101 # show HTTP traffic useful for debugging 
102 #http_debug = 1
103     
104 # jump into the debugger in case of errors
105 #post_mortem = 1
106     
107 # print call traces in case of errors
108 #traceback = 1
109     
110 [%(apiurl)s]
111 user = %(user)s
112 pass = %(pass)s
113 # set aliases for this apiurl
114 # aliases = foo, bar
115 # additional headers to pass to a request, e.g. for special authentication
116 #http_headers = Host: foofoobar,
117 #       User: mumblegack
118 """
119
120
121 account_not_configured_text ="""
122 Your user account / password are not configured yet.
123 You will be asked for them below, and they will be stored in
124 %s for future use.
125 """
126
127 config_incomplete_text = """
128
129 Your configuration file %s is not complete.
130 Make sure that it has a [general] section.
131 (You can copy&paste the below. Some commented defaults are shown.)
132
133 """
134
135 config_missing_apiurl_text = """
136 the apiurl \'%s\' does not exist in the config file. Please enter
137 your credentials for this apiurl.
138 """
139
140 cookiejar = None
141
142 def parse_apisrv_url(scheme, apisrv):
143     import urlparse
144     if apisrv.startswith('http://') or apisrv.startswith('https://'):
145         return urlparse.urlsplit(apisrv)[0:2]
146     elif scheme != None:
147         return scheme, apisrv
148     else:
149         from urllib2 import URLError
150         msg = 'invalid apiurl \'%s\' (specify the protocol (http:// or https://))' % apisrv
151         raise URLError(msg)
152
153 def urljoin(scheme, apisrv):
154     return '://'.join([scheme, apisrv])
155
156 def get_apiurl_api_host_options(apiurl):
157     """
158     Returns all apihost specific options for the given apiurl, None if
159     no such specific optiosn exist.
160     """
161     # FIXME: in A Better World (tm) there was a config object which
162     # knows this instead of having to extract it from a url where it
163     # had been mingled into before.  But this works fine for now.
164
165     apiurl = urljoin(*parse_apisrv_url(None, apiurl))
166     try:
167         return config['api_host_options'][apiurl]
168     except KeyError:
169         raise oscerr.ConfigMissingApiurl('missing credentials for apiurl: \'%s\'' % apiurl,
170                                          '', apiurl)
171
172 def get_apiurl_usr(apiurl):
173     """
174     returns the user for this host - if this host does not exist in the
175     internal api_host_options the default user is returned.
176     """
177     # FIXME: maybe there should be defaults not just for the user but
178     # for all apihost specific options.  The ConfigParser class
179     # actually even does this but for some reason we don't use it
180     # (yet?).
181
182     import sys
183     try:
184         return get_apiurl_api_host_options(apiurl)['user']
185     except KeyError:
186         print >>sys.stderr, 'no specific section found in config file for host of [\'%s\'] - using default user: \'%s\'' \
187             % (apiurl, config['user'])
188         return config['user']
189
190 def init_basicauth(config):
191     """initialize urllib2 with the credentials for Basic Authentication"""
192
193     from osc.core import __version__
194     import os, urllib2
195     import cookielib
196
197     global cookiejar
198
199     # HTTPS proxy is not supported by urllib2. It only leads to an error
200     # or, at best, a warning.
201     # https://bugzilla.novell.com/show_bug.cgi?id=214983
202     # https://bugzilla.novell.com/show_bug.cgi?id=298378
203     if 'https_proxy' in os.environ:
204         del os.environ['https_proxy']
205     if 'HTTPS_PROXY' in os.environ:
206         del os.environ['HTTPS_PROXY']
207
208     if config['http_debug']:
209         # brute force
210         def urllib2_debug_init(self, debuglevel=0):
211             self._debuglevel = 1
212         urllib2.AbstractHTTPHandler.__init__ = urllib2_debug_init
213
214     authhandler = urllib2.HTTPBasicAuthHandler( \
215         urllib2.HTTPPasswordMgrWithDefaultRealm())
216
217     cookie_file = os.path.expanduser(config['cookiejar'])
218     cookiejar = cookielib.LWPCookieJar(cookie_file)
219     try: 
220         cookiejar.load(ignore_discard=True)
221     except IOError:
222         try:
223             open(cookie_file, 'w').close()
224             os.chmod(cookie_file, 0600)
225         except:
226             #print 'Unable to create cookiejar file: \'%s\'. Using RAM-based cookies.' % cookie_file
227             cookiejar = cookielib.CookieJar()
228
229     opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(cookiejar), authhandler)
230     urllib2.install_opener(opener)
231
232     opener.addheaders = [('User-agent', 'osc/%s' % __version__)]
233
234     # with None as first argument, it will always use this username/password
235     # combination for urls for which arg2 (apisrv) is a super-url
236     for host, auth in config['api_host_options'].iteritems():
237         authhandler.add_password(None, host, auth['user'], auth['pass'])
238
239
240 def get_configParser(conffile=None, force_read=False):
241     """
242     Returns an ConfigParser() object. After its first invocation the
243     ConfigParser object is stored in a method attribute and this attribute
244     is returned unless you pass force_read=True.
245     """
246     import os
247     conffile = conffile or os.environ.get('OSC_CONFIG', '~/.oscrc')
248     conffile = os.path.expanduser(conffile)
249     if force_read or not get_configParser.__dict__.has_key('cp'):
250         get_configParser.cp = OscConfigParser.OscConfigParser(DEFAULTS)
251         get_configParser.cp.read(conffile)
252     return get_configParser.cp
253
254
255 def write_initial_config(conffile, entries, custom_template = ''):
256     """
257     write osc's intial configuration file. entries is a dict which contains values
258     for the config file (e.g. { 'user' : 'username', 'pass' : 'password' } ).
259     custom_template is an optional configuration template.
260     """
261     import os, StringIO, sys
262     conf_template = custom_template or new_conf_template
263     config = DEFAULTS.copy()
264     config.update(entries)
265     sio = StringIO.StringIO(conf_template.strip() % config)
266     cp = OscConfigParser.OscConfigParser(DEFAULTS)
267     cp.readfp(sio)
268
269     file = None
270     try:
271         file = open(conffile, 'w')
272     except IOError, e:
273         raise oscerr.OscIOError(e, 'cannot open configfile \'%s\'' % conffile)
274     try:
275         try:
276             os.chmod(conffile, 0600)
277             cp.write(file, True)
278         except IOError, e:
279             raise oscerr.OscIOError(e, 'cannot write configfile \'s\'' % conffile)
280     finally:
281         if file: file.close()
282
283
284 def get_config(override_conffile = None, 
285                override_apiurl = None,
286                override_debug = None, 
287                override_http_debug = None, 
288                override_traceback = None,
289                override_post_mortem = None):
290     """do the actual work (see module documentation)"""
291     import os
292     import sys
293     import re
294     global config
295
296     conffile = override_conffile or os.environ.get('OSC_CONFIG', '~/.oscrc')
297     conffile = os.path.expanduser(conffile)
298
299     if not os.path.exists(conffile):
300         raise oscerr.NoConfigfile(conffile, \
301                                   account_not_configured_text % conffile)
302
303     # okay, we made sure that .oscrc exists
304
305     cp = get_configParser(conffile)
306
307     if not cp.has_section('general'):
308         # FIXME: it might be sufficient to just assume defaults?
309         msg = config_incomplete_text % conffile
310         msg += new_conf_template % DEFAULTS
311         raise oscerr.ConfigError(msg, conffile)
312
313     config = dict(cp.items('general', raw=1))
314
315     # backward compatibility
316     if config.has_key('apisrv'):
317         apisrv = config['apisrv'].lstrip('http://')
318         apisrv = apisrv.lstrip('https://')
319         scheme = config.get('scheme', 'https')
320         config['apiurl'] = urljoin(scheme, apisrv)
321     if config.has_key('apisrv') or config.has_key('scheme'):
322         import warnings
323         warnings.warn("Use of the 'scheme' or 'apisrv' config option is deprecated!",
324                       DeprecationWarning)
325
326     for i in boolean_opts:
327         try:
328             config[i] = cp.getboolean('general', i)
329         except ValueError, e:
330             raise oscerr.ConfigError('cannot parse \'%s\' setting: ' % i + str(e), conffile)
331
332     config['packagecachedir'] = os.path.expanduser(config['packagecachedir'])
333
334     re_clist = re.compile('[, ]+')
335     config['extra-pkgs'] = [ i.strip() for i in re_clist.split(config['extra-pkgs'].strip()) if i ]
336     if config['extra-pkgs'] == []: 
337         config['extra-pkgs'] = None
338
339     # collect the usernames, passwords and additional options for each api host
340     api_host_options = {}
341
342     # Regexp to split extra http headers into a dictionary
343     # the text to be matched looks essentially looks this:
344     # "Attribute1: value1, Attribute2: value2, ..."
345     # there may be arbitray leading and intermitting whitespace.
346     # the following regexp does _not_ support quoted commas within the value.
347     http_header_regexp = re.compile(r"\s*(.*?)\s*:\s*(.*?)\s*(?:,\s*|\Z)")
348
349     aliases = {}
350     for url in [ x for x in cp.sections() if x != 'general' ]:
351         # backward compatiblity
352         scheme, host = \
353             parse_apisrv_url(config.get('scheme', 'https'), url)
354         apiurl = urljoin(scheme, host)
355         #FIXME: this could actually be the ideal spot to take defaults
356         #from the general section.
357         user         = cp.get(url, 'user')
358         password     = cp.get(url, 'pass')
359
360         if cp.has_option(url, 'http_headers'):
361             http_headers = cp.get(url, 'http_headers')
362             http_headers = http_header_regexp.findall(http_headers)
363         else:
364             http_headers = []
365         if cp.has_option(url, 'aliases'):
366             for i in cp.get(url, 'aliases').split(','):
367                 key = i.strip()
368                 if key == '':
369                     continue
370                 if aliases.has_key(key):
371                     msg = 'duplicate alias entry: \'%s\' is already used for another apiurl' % key
372                     raise oscerr.ConfigError(msg, conffile)
373                 aliases[key] = url
374
375         api_host_options[apiurl] = { 'user': user,
376                                      'pass': password,
377                                      'http_headers': http_headers};
378
379     # add the auth data we collected to the config dict
380     config['api_host_options'] = api_host_options
381
382     # override values which we were called with
383     if override_debug: 
384         config['debug'] = override_debug
385     if override_http_debug:
386         config['http_debug'] = override_http_debug
387     if override_traceback:
388         config['traceback'] = override_traceback
389     if override_post_mortem:
390         config['post_mortem'] = override_post_mortem
391     if override_apiurl:
392         apiurl = aliases.get(override_apiurl, override_apiurl)
393         # check if apiurl is a valid url
394         parse_apisrv_url(None, apiurl)
395         config['apiurl'] = apiurl
396
397     # XXX unless config['user'] goes away (and is replaced with a handy function, or 
398     # config becomes an object, even better), set the global 'user' here as well,
399     # provided that there _are_ credentials for the chosen apiurl:
400     try:
401         config['user'] = get_apiurl_usr(config['apiurl'])
402     except oscerr.ConfigMissingApiurl, e:
403         e.msg = config_missing_apiurl_text % config['apiurl']
404         e.file = conffile
405         raise e
406
407     # finally, initialize urllib2 for to use the credentials for Basic Authentication
408     init_basicauth(config)
409