Create database upgrade framework
[cascardo/ipsilon.git] / ipsilon / install / ipsilon-server-install
1 #!/usr/bin/python
2 # Copyright (C) 2014 Ipsilon project Contributors, for license see COPYING
3
4 from ipsilon.login.common import LoginMgrsInstall
5 from ipsilon.info.common import InfoProviderInstall
6 from ipsilon.providers.common import ProvidersInstall
7 from ipsilon.helpers.common import EnvHelpersInstall
8 from ipsilon.util.data import UserStore
9 from ipsilon.tools import files, dbupgrade
10 import ConfigParser
11 import argparse
12 import cherrypy
13 import json
14 import logging
15 import os
16 import pwd
17 import shutil
18 import socket
19 import subprocess
20 import sys
21 import time
22
23
24 TEMPLATES = '/usr/share/ipsilon/templates/install'
25 CONFDIR = '/etc/ipsilon'
26 DATADIR = '/var/lib/ipsilon'
27 HTTPDCONFD = '/etc/httpd/conf.d'
28 BINDIR = '/usr/libexec'
29 STATICDIR = '/usr/share/ipsilon'
30 WSGI_SOCKET_PREFIX = None
31
32
33 class ConfigurationError(StandardError):
34
35     def __init__(self, message):
36         StandardError.__init__(self, message)
37
38
39 #Silence cherrypy logging to screen
40 cherrypy.log.screen = False
41
42 # Regular logging
43 LOGFILE = '/var/log/ipsilon-install.log'
44 logger = logging.getLogger()
45
46
47 def openlogs():
48     global logger  # pylint: disable=W0603
49     if os.path.isfile(LOGFILE):
50         try:
51             created = '%s' % time.ctime(os.path.getctime(LOGFILE))
52             shutil.move(LOGFILE, '%s.%s' % (LOGFILE, created))
53         except IOError:
54             pass
55     logger = logging.getLogger()
56     try:
57         lh = logging.FileHandler(LOGFILE)
58     except IOError, e:
59         print >> sys.stderr, 'Unable to open %s (%s)' % (LOGFILE, str(e))
60         lh = logging.StreamHandler(sys.stderr)
61     formatter = logging.Formatter('[%(asctime)s] %(message)s')
62     lh.setFormatter(formatter)
63     lh.setLevel(logging.DEBUG)
64     logger.addHandler(lh)
65     logger.propagate = False
66     ch = logging.StreamHandler(sys.stdout)
67     formatter = logging.Formatter('%(message)s')
68     ch.setFormatter(formatter)
69     ch.setLevel(logging.INFO)
70     logger.addHandler(ch)
71     cherrypy.log.error_log.setLevel(logging.DEBUG)
72
73
74 def install(plugins, args):
75     logger.info('Installation initiated')
76     now = time.strftime("%Y%m%d%H%M%S", time.gmtime())
77     instance_conf = os.path.join(CONFDIR, args['instance'])
78
79     logger.info('Installing default config files')
80     ipsilon_conf = os.path.join(instance_conf, 'ipsilon.conf')
81     idp_conf = os.path.join(instance_conf, 'idp.conf')
82     args['httpd_conf'] = os.path.join(HTTPDCONFD,
83                                       'ipsilon-%s.conf' % args['instance'])
84     args['data_dir'] = os.path.join(DATADIR, args['instance'])
85     args['public_data_dir'] = os.path.join(args['data_dir'], 'public')
86     args['wellknown_dir'] = os.path.join(args['public_data_dir'],
87                                          'well-known')
88     if os.path.exists(ipsilon_conf):
89         shutil.move(ipsilon_conf, '%s.bakcup.%s' % (ipsilon_conf, now))
90     if os.path.exists(idp_conf):
91         shutil.move(idp_conf, '%s.backup.%s' % (idp_conf, now))
92     if not os.path.exists(instance_conf):
93         os.makedirs(instance_conf, 0700)
94     confopts = {'instance': args['instance'],
95                 'datadir': args['data_dir'],
96                 'publicdatadir': args['public_data_dir'],
97                 'wellknowndir': args['wellknown_dir'],
98                 'sysuser': args['system_user'],
99                 'ipsilondir': BINDIR,
100                 'staticdir': STATICDIR,
101                 'admindb': args['admin_dburi'] or args['database_url'] % {
102                     'datadir': args['data_dir'], 'dbname': 'adminconfig'},
103                 'usersdb': args['users_dburi'] or args['database_url'] % {
104                     'datadir': args['data_dir'], 'dbname': 'userprefs'},
105                 'transdb': args['transaction_dburi'] or args['database_url'] %
106                 {'datadir': args['data_dir'], 'dbname': 'transactions'},
107                 'samlsessionsdb': args['samlsessions_dburi'] or args[
108                     'database_url'] % {'datadir': args['data_dir'],
109                                        'dbname': 'saml2sessions'},
110                 'secure': "False" if args['secure'] == "no" else "True",
111                 'debugging': "True" if args['server_debugging'] else "False"}
112     # Testing database sessions
113     if 'session_type' in args:
114         confopts['sesstype'] = args['session_type']
115     else:
116         confopts['sesstype'] = 'file'
117     if 'session_dburi' in args:
118         confopts['sessopt'] = 'dburi'
119         confopts['sessval'] = args['session_dburi']
120     else:
121         confopts['sessopt'] = 'path'
122         confopts['sessval'] = os.path.join(args['data_dir'], 'sessions')
123     # Whether to disable security (for testing)
124     if args['secure'] == 'no':
125         confopts['secure'] = "False"
126         confopts['sslrequiressl'] = ""
127     else:
128         confopts['secure'] = "True"
129         confopts['sslrequiressl'] = "   SSLRequireSSL"
130     if WSGI_SOCKET_PREFIX:
131         confopts['wsgi_socket'] = 'WSGISocketPrefix %s' % WSGI_SOCKET_PREFIX
132     else:
133         confopts['wsgi_socket'] = ''
134     files.write_from_template(ipsilon_conf,
135                               os.path.join(TEMPLATES, 'ipsilon.conf'),
136                               confopts)
137     files.write_from_template(idp_conf,
138                               os.path.join(TEMPLATES, 'idp.conf'),
139                               confopts)
140     if not os.path.exists(args['httpd_conf']):
141         os.symlink(idp_conf, args['httpd_conf'])
142     if not os.path.exists(args['public_data_dir']):
143         os.makedirs(args['public_data_dir'], 0755)
144     if not os.path.exists(args['wellknown_dir']):
145         os.makedirs(args['wellknown_dir'], 0755)
146     sessdir = os.path.join(args['data_dir'], 'sessions')
147     if not os.path.exists(sessdir):
148         os.makedirs(sessdir, 0700)
149     data_conf = os.path.join(args['data_dir'], 'ipsilon.conf')
150     if not os.path.exists(data_conf):
151         os.symlink(ipsilon_conf, data_conf)
152     # Load the cherrypy config from the newly installed file so
153     # that db paths and all is properly set before configuring
154     # components
155     cherrypy.config.update(ipsilon_conf)
156
157     # Prepare to allow plugins to save things changed during install
158     changes = {'env_helper': {},
159                'login_manager': {},
160                'info_provider': {},
161                'auth_provider': {}}
162
163     # Move pre-existing dbs away
164     admin_db = cherrypy.config['admin.config.db']
165     if os.path.exists(admin_db):
166         shutil.move(admin_db, '%s.backup.%s' % (admin_db, now))
167     users_db = cherrypy.config['user.prefs.db']
168     if os.path.exists(users_db):
169         shutil.move(users_db, '%s.backup.%s' % (users_db, now))
170
171     # Initialize initial database schemas
172     dbupgrade.execute_upgrade(ipsilon_conf)
173
174     # Store primary admin
175     db = UserStore()
176     db.save_user_preferences(args['admin_user'], {'is_admin': 1})
177
178     logger.info('Configuring environment helpers')
179     for plugin_name in plugins['Environment Helpers']:
180         plugin = plugins['Environment Helpers'][plugin_name]
181         plugin_changes = {}
182         if plugin.configure_server(args, plugin_changes) == False:
183             msg = 'Configuration of environment helper %s failed' % plugin_name
184             raise ConfigurationError(msg)
185         changes['env_helper'][plugin_name] = plugin_changes
186
187     logger.info('Configuring login managers')
188     for plugin_name in args['lm_order']:
189         try:
190             plugin = plugins['Login Managers'][plugin_name]
191         except KeyError:
192             sys.exit('Login provider %s not installed' % plugin_name)
193         plugin_changes = {}
194         if plugin.configure(args, plugin_changes) == False:
195             msg = 'Configuration of login manager %s failed' % plugin_name
196             raise ConfigurationError(msg)
197         changes['login_manager'][plugin_name] = plugin_changes
198
199     logger.info('Configuring Info provider')
200     for plugin_name in plugins['Info Provider']:
201         plugin = plugins['Info Provider'][plugin_name]
202         plugin_changes = {}
203         if plugin.configure(args, plugin_changes) == False:
204             msg = 'Configuration of info provider %s failed' % plugin_name
205             raise ConfigurationError(msg)
206         changes['info_provider'][plugin_name] = plugin_changes
207
208     logger.info('Configuring Authentication Providers')
209     for plugin_name in plugins['Auth Providers']:
210         plugin = plugins['Auth Providers'][plugin_name]
211         plugin_changes = {}
212         if plugin.configure(args, plugin_changes) == False:
213             msg = 'Configuration of auth provider %s failed' % plugin_name
214             raise ConfigurationError(msg)
215         changes['auth_provider'][plugin_name] = plugin_changes
216
217     # Save any changes that were made
218     install_changes = os.path.join(instance_conf, 'install_changes')
219     changes = json.dumps(changes)
220     with open(install_changes, 'w+') as f:
221         f.write(changes)
222
223     # Initialize extra database schemas
224     dbupgrade.execute_upgrade(ipsilon_conf)
225
226     # Fixup permissions so only the ipsilon user can read these files
227     files.fix_user_dirs(instance_conf, opts['system_user'])
228     files.fix_user_dirs(args['data_dir'], opts['system_user'])
229     try:
230         subprocess.call(['/usr/sbin/restorecon', '-R', args['data_dir']])
231     except Exception:  # pylint: disable=broad-except
232         pass
233
234
235 def uninstall(plugins, args):
236     logger.info('Uninstallation initiated')
237     instance_conf = os.path.join(CONFDIR, args['instance'])
238
239     httpd_conf = os.path.join(HTTPDCONFD,
240                               'ipsilon-%s.conf' % args['instance'])
241     data_dir = os.path.join(DATADIR, args['instance'])
242
243     if not os.path.exists(instance_conf):
244         raise Exception('Could not find instance %s configuration'
245                         % args['instance'])
246     if not os.path.exists(httpd_conf):
247         raise Exception('Could not find instance %s httpd configuration'
248                         % args['instance'])
249     if not args['yes']:
250         sure = raw_input(('Are you certain you want to erase instance %s ' +
251                           '[yes/NO]: ')
252                          % args['instance'])
253         if sure != 'yes':
254             raise Exception('Aborting')
255
256     # Get the details of what we changed during installation
257     install_changes = os.path.join(instance_conf, 'install_changes')
258     with open(install_changes, 'r') as f:
259         changes = json.loads(f.read())
260
261     logger.info('Removing environment helpers')
262     for plugin_name in plugins['Environment Helpers']:
263         plugin = plugins['Environment Helpers'][plugin_name]
264         plugin_changes = changes['env_helper'].get(plugin_name, {})
265         if plugin.unconfigure(args, plugin_changes) == False:
266             logger.info('Removal of environment helper %s failed' % plugin_name)
267
268     logger.info('Removing login managers')
269     for plugin_name in plugins['Login Managers']:
270         plugin = plugins['Login Managers'][plugin_name]
271         plugin_changes = changes['login_manager'].get(plugin_name, {})
272         if plugin.unconfigure(args, plugin_changes) == False:
273             logger.info('Removal of login manager %s failed' % plugin_name)
274
275     logger.info('Removing Info providers')
276     for plugin_name in plugins['Info Provider']:
277         plugin = plugins['Info Provider'][plugin_name]
278         plugin_changes = changes['info_provider'].get(plugin_name, {})
279         if plugin.unconfigure(args, plugin_changes) == False:
280             logger.info('Removal of info provider %s failed' % plugin_name)
281
282     logger.info('Removing Authentication Providers')
283     for plugin_name in plugins['Auth Providers']:
284         plugin = plugins['Auth Providers'][plugin_name]
285         plugin_changes = changes['auth_provider'].get(plugin_name, {})
286         if plugin.unconfigure(args, plugin_changes) == False:
287             logger.info('Removal of auth provider %s failed' % plugin_name)
288
289     logger.info('Removing httpd configuration')
290     os.remove(httpd_conf)
291     logger.info('Erasing instance configuration')
292     shutil.rmtree(instance_conf)
293     logger.info('Erasing instance data')
294     shutil.rmtree(data_dir)
295     logger.info('Uninstalled instance %s' % args['instance'])
296
297
298 def find_plugins():
299     plugins = {
300         'Environment Helpers': EnvHelpersInstall().plugins,
301         'Login Managers': LoginMgrsInstall().plugins,
302         'Info Provider': InfoProviderInstall().plugins,
303         'Auth Providers': ProvidersInstall().plugins
304     }
305     return plugins
306
307
308 def parse_config_profile(args):
309     config = ConfigParser.RawConfigParser()
310     files = config.read(args['config_profile'])
311     if len(files) == 0:
312         raise ConfigurationError('Config Profile file %s not found!' %
313                                  args['config_profile'])
314
315     if 'globals' in config.sections():
316         G = config.options('globals')
317         for g in G:
318             val = config.get('globals', g)
319             if g in globals():
320                 globals()[g] = val
321             else:
322                 for k in globals():
323                     if k.lower() == g.lower():
324                         globals()[k] = val
325                         break
326
327     if 'arguments' in config.sections():
328         A = config.options('arguments')
329         for a in A:
330             args[a] = config.get('arguments', a)
331
332     return args
333
334
335 def parse_args(plugins):
336     parser = argparse.ArgumentParser(description='Ipsilon Install Options')
337     parser.add_argument('--version',
338                         action='version', version='%(prog)s 0.1')
339     parser.add_argument('-o', '--login-managers-order', dest='lm_order',
340                         help='Comma separated list of login managers')
341     parser.add_argument('--hostname',
342                         help="Machine's fully qualified host name")
343     parser.add_argument('--instance', default='idp',
344                         help="IdP instance name, each is a separate idp")
345     parser.add_argument('--system-user', default='ipsilon',
346                         help="User account used to run the server")
347     parser.add_argument('--admin-user', default='admin',
348                         help="User account that is assigned admin privileges")
349     parser.add_argument('--database-url',
350                         default='sqlite:///%(datadir)s/%(dbname)s.sqlite',
351                         help="The (templatized) database URL to use")
352     parser.add_argument('--secure', choices=['yes', 'no'], default='yes',
353                         help="Turn on all security checks")
354     parser.add_argument('--config-profile', default=None,
355                         help=argparse.SUPPRESS)
356     parser.add_argument('--server-debugging', action='store_true',
357                         help="Enable debugging")
358     parser.add_argument('--uninstall', action='store_true',
359                         help="Uninstall the server and all data")
360     parser.add_argument('--yes', action='store_true',
361                         help="Always answer yes")
362     parser.add_argument('--admin-dburi',
363                         help='Configuration database URI (override template)')
364     parser.add_argument('--users-dburi',
365                         help='User configuration database URI (override '
366                              'template)')
367     parser.add_argument('--transaction-dburi',
368                         help='Transaction database URI (override template)')
369     parser.add_argument('--samlsessions-dburi',
370                         help='SAML 2 sessions database URI (override template)')
371
372     lms = []
373
374     for plugin_group in plugins:
375         group = parser.add_argument_group(plugin_group)
376         for plugin_name in plugins[plugin_group]:
377             plugin = plugins[plugin_group][plugin_name]
378             if plugin.ptype == 'login':
379                 lms.append(plugin.name)
380             plugin.install_args(group)
381
382     args = vars(parser.parse_args())
383
384     if args['config_profile']:
385         args = parse_config_profile(args)
386
387     if not args['hostname']:
388         args['hostname'] = socket.getfqdn()
389
390     if args['uninstall']:
391         return args
392
393     if len(args['hostname'].split('.')) < 2:
394         raise ConfigurationError('Hostname: %s is not a FQDN')
395
396     for plugin_group in plugins:
397         for plugin_name in plugins[plugin_group]:
398             plugin = plugins[plugin_group][plugin_name]
399             plugin.validate_args(args)
400
401     try:
402         pwd.getpwnam(args['system_user'])
403     except KeyError:
404         raise ConfigurationError('User: %s not found on the system')
405
406     if args['lm_order'] is None:
407         args['lm_order'] = []
408         for name in lms:
409             if args[name] == 'yes':
410                 args['lm_order'].append(name)
411     else:
412         args['lm_order'] = args['lm_order'].split(',')
413
414     if len(args['lm_order']) == 0 and args['ipa'] != 'yes':
415         sys.exit('No login plugins are enabled.')
416
417     #FIXME: check instance is only alphanums
418
419     return args
420
421 if __name__ == '__main__':
422     opts = []
423     out = 0
424     openlogs()
425     try:
426         fplugins = find_plugins()
427         opts = parse_args(fplugins)
428
429         logger.setLevel(logging.DEBUG)
430
431         logger.debug('Installation arguments:')
432         for k in sorted(opts.iterkeys()):
433             logger.debug('%s: %s', k, opts[k])
434
435         if 'uninstall' in opts and opts['uninstall'] is True:
436             if not os.path.exists(os.path.join(CONFDIR, opts['instance'])):
437                 logger.info('Instance %s could not be found' % opts['instance'])
438                 sys.exit(0)
439             uninstall(fplugins, opts)
440         else:
441             install(fplugins, opts)
442     except Exception, e:  # pylint: disable=broad-except
443         logger.debug(e, exc_info=1)
444
445         if 'uninstall' in opts and opts['uninstall'] is True:
446             logger.info('Uninstallation aborted.')
447         else:
448             logger.info('Installation aborted.')
449         logger.info('See log file %s for details' % LOGFILE)
450         out = 1
451     except SystemExit:
452         out = 1
453         raise
454     finally:
455         if out == 0:
456             if 'uninstall' in opts and opts['uninstall'] is True:
457                 logger.info('Uninstallation complete.')
458             else:
459                 logger.info('Installation complete.')
460                 logger.info('Please restart HTTPD to enable the IdP instance.')
461     sys.exit(out)