server/serverctl.py
changeset 11057 0b59724cb3f2
parent 11052 058bb3dc685f
child 11058 23eb30449fe5
equal deleted inserted replaced
11052:058bb3dc685f 11057:0b59724cb3f2
     1 # copyright 2003-2014 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
       
     2 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
       
     3 #
       
     4 # This file is part of CubicWeb.
       
     5 #
       
     6 # CubicWeb is free software: you can redistribute it and/or modify it under the
       
     7 # terms of the GNU Lesser General Public License as published by the Free
       
     8 # Software Foundation, either version 2.1 of the License, or (at your option)
       
     9 # any later version.
       
    10 #
       
    11 # CubicWeb is distributed in the hope that it will be useful, but WITHOUT
       
    12 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
       
    13 # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
       
    14 # details.
       
    15 #
       
    16 # You should have received a copy of the GNU Lesser General Public License along
       
    17 # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
       
    18 """cubicweb-ctl commands and command handlers specific to the repository"""
       
    19 from __future__ import print_function
       
    20 
       
    21 __docformat__ = 'restructuredtext en'
       
    22 
       
    23 # *ctl module should limit the number of import to be imported as quickly as
       
    24 # possible (for cubicweb-ctl reactivity, necessary for instance for usable bash
       
    25 # completion). So import locally in command helpers.
       
    26 import sys
       
    27 import os
       
    28 from contextlib import contextmanager
       
    29 import logging
       
    30 import subprocess
       
    31 
       
    32 from six import string_types
       
    33 from six.moves import input
       
    34 
       
    35 from logilab.common import nullobject
       
    36 from logilab.common.configuration import Configuration, merge_options
       
    37 from logilab.common.shellutils import ASK, generate_password
       
    38 
       
    39 from logilab.database import get_db_helper, get_connection
       
    40 
       
    41 from cubicweb import AuthenticationError, ExecutionError, ConfigurationError
       
    42 from cubicweb.toolsutils import Command, CommandHandler, underline_title
       
    43 from cubicweb.cwctl import CWCTL, check_options_consistency, ConfigureInstanceCommand
       
    44 from cubicweb.server import SOURCE_TYPES
       
    45 from cubicweb.server.serverconfig import (
       
    46     USER_OPTIONS, ServerConfiguration, SourceConfiguration,
       
    47     ask_source_config, generate_source_config)
       
    48 
       
    49 # utility functions ###########################################################
       
    50 
       
    51 def source_cnx(source, dbname=None, special_privs=False, interactive=True):
       
    52     """open and return a connection to the system database defined in the
       
    53     given server.serverconfig
       
    54     """
       
    55     from getpass import getpass
       
    56     dbhost = source.get('db-host')
       
    57     if dbname is None:
       
    58         dbname = source['db-name']
       
    59     driver = source['db-driver']
       
    60     dbhelper = get_db_helper(driver)
       
    61     if interactive:
       
    62         print('-> connecting to %s database' % driver, end=' ')
       
    63         if dbhost:
       
    64             print('%s@%s' % (dbname, dbhost), end=' ')
       
    65         else:
       
    66             print(dbname, end=' ')
       
    67     if dbhelper.users_support:
       
    68         if not interactive or (not special_privs and source.get('db-user')):
       
    69             user = source.get('db-user', os.environ.get('USER', ''))
       
    70             if interactive:
       
    71                 print('as', user)
       
    72             password = source.get('db-password')
       
    73         else:
       
    74             print()
       
    75             if special_privs:
       
    76                 print('WARNING')
       
    77                 print ('the user will need the following special access rights '
       
    78                        'on the database:')
       
    79                 print(special_privs)
       
    80                 print()
       
    81             default_user = source.get('db-user', os.environ.get('USER', ''))
       
    82             user = input('Connect as user ? [%r]: ' % default_user)
       
    83             user = user.strip() or default_user
       
    84             if user == source.get('db-user'):
       
    85                 password = source.get('db-password')
       
    86             else:
       
    87                 password = getpass('password: ')
       
    88     else:
       
    89         user = password = None
       
    90     extra_args = source.get('db-extra-arguments')
       
    91     extra = extra_args and {'extra_args': extra_args} or {}
       
    92     cnx = get_connection(driver, dbhost, dbname, user, password=password,
       
    93                          port=source.get('db-port'),
       
    94                          schema=source.get('db-namespace'),
       
    95                          **extra)
       
    96     try:
       
    97         cnx.logged_user = user
       
    98     except AttributeError:
       
    99         # C object, __slots__
       
   100         from logilab.database import _SimpleConnectionWrapper
       
   101         cnx = _SimpleConnectionWrapper(cnx)
       
   102         cnx.logged_user = user
       
   103     return cnx
       
   104 
       
   105 def system_source_cnx(source, dbms_system_base=False,
       
   106                       special_privs='CREATE/DROP DATABASE', interactive=True):
       
   107     """shortcut to get a connextion to the instance system database
       
   108     defined in the given config. If <dbms_system_base> is True,
       
   109     connect to the dbms system database instead (for task such as
       
   110     create/drop the instance database)
       
   111     """
       
   112     if dbms_system_base:
       
   113         system_db = get_db_helper(source['db-driver']).system_database()
       
   114         return source_cnx(source, system_db, special_privs=special_privs,
       
   115                           interactive=interactive)
       
   116     return source_cnx(source, special_privs=special_privs,
       
   117                       interactive=interactive)
       
   118 
       
   119 def _db_sys_cnx(source, special_privs, interactive=True):
       
   120     """return a connection on the RDMS system table (to create/drop a user or a
       
   121     database)
       
   122     """
       
   123     import logilab.common as lgp
       
   124     lgp.USE_MX_DATETIME = False
       
   125     driver = source['db-driver']
       
   126     helper = get_db_helper(driver)
       
   127     # connect on the dbms system base to create our base
       
   128     cnx = system_source_cnx(source, True, special_privs=special_privs,
       
   129                             interactive=interactive)
       
   130     # disable autocommit (isolation_level(1)) because DROP and
       
   131     # CREATE DATABASE can't be executed in a transaction
       
   132     set_isolation_level = getattr(cnx, 'set_isolation_level', None)
       
   133     if set_isolation_level is not None:
       
   134         # set_isolation_level() is psycopg specific
       
   135         set_isolation_level(0)
       
   136     return cnx
       
   137 
       
   138 def repo_cnx(config):
       
   139     """return a in-memory repository and a repoapi connection to it"""
       
   140     from cubicweb import repoapi
       
   141     from cubicweb.server.utils import manager_userpasswd
       
   142     try:
       
   143         login = config.default_admin_config['login']
       
   144         pwd = config.default_admin_config['password']
       
   145     except KeyError:
       
   146         login, pwd = manager_userpasswd()
       
   147     while True:
       
   148         try:
       
   149             repo = repoapi.get_repository(config=config)
       
   150             cnx = repoapi.connect(repo, login, password=pwd)
       
   151             return repo, cnx
       
   152         except AuthenticationError:
       
   153             print('-> Error: wrong user/password.')
       
   154             # reset cubes else we'll have an assertion error on next retry
       
   155             config._cubes = None
       
   156         login, pwd = manager_userpasswd()
       
   157 
       
   158 
       
   159 # repository specific command handlers ########################################
       
   160 
       
   161 class RepositoryCreateHandler(CommandHandler):
       
   162     cmdname = 'create'
       
   163     cfgname = 'repository'
       
   164 
       
   165     def bootstrap(self, cubes, automatic=False, inputlevel=0):
       
   166         """create an instance by copying files from the given cube and by asking
       
   167         information necessary to build required configuration files
       
   168         """
       
   169         config = self.config
       
   170         if not automatic:
       
   171             print(underline_title('Configuring the repository'))
       
   172             config.input_config('email', inputlevel)
       
   173             print('\n'+underline_title('Configuring the sources'))
       
   174         sourcesfile = config.sources_file()
       
   175         # hack to make Method('default_instance_id') usable in db option defs
       
   176         # (in native.py)
       
   177         sconfig = SourceConfiguration(config,
       
   178                                       options=SOURCE_TYPES['native'].options)
       
   179         if not automatic:
       
   180             sconfig.input_config(inputlevel=inputlevel)
       
   181             print()
       
   182         sourcescfg = {'system': sconfig}
       
   183         if automatic:
       
   184             # XXX modify a copy
       
   185             password = generate_password()
       
   186             print('-> set administrator account to admin / %s' % password)
       
   187             USER_OPTIONS[1][1]['default'] = password
       
   188             sconfig = Configuration(options=USER_OPTIONS)
       
   189         else:
       
   190             sconfig = Configuration(options=USER_OPTIONS)
       
   191             sconfig.input_config(inputlevel=inputlevel)
       
   192         sourcescfg['admin'] = sconfig
       
   193         config.write_sources_file(sourcescfg)
       
   194         # remember selected cubes for later initialization of the database
       
   195         config.write_bootstrap_cubes_file(cubes)
       
   196 
       
   197     def postcreate(self, automatic=False, inputlevel=0):
       
   198         if automatic:
       
   199             CWCTL.run(['db-create', '--automatic', self.config.appid])
       
   200         elif ASK.confirm('Run db-create to create the system database ?'):
       
   201             CWCTL.run(['db-create', '--config-level', str(inputlevel),
       
   202                        self.config.appid])
       
   203         else:
       
   204             print('-> nevermind, you can do it later with '
       
   205                   '"cubicweb-ctl db-create %s".' % self.config.appid)
       
   206 
       
   207 
       
   208 @contextmanager
       
   209 def db_transaction(source, privilege):
       
   210     """Open a transaction to the instance database"""
       
   211     cnx = system_source_cnx(source, special_privs=privilege)
       
   212     cursor = cnx.cursor()
       
   213     try:
       
   214         yield cursor
       
   215     except:
       
   216         cnx.rollback()
       
   217         cnx.close()
       
   218         raise
       
   219     else:
       
   220         cnx.commit()
       
   221         cnx.close()
       
   222 
       
   223 
       
   224 @contextmanager
       
   225 def db_sys_transaction(source, privilege):
       
   226     """Open a transaction to the system database"""
       
   227     cnx = _db_sys_cnx(source, privilege)
       
   228     cursor = cnx.cursor()
       
   229     try:
       
   230         yield cursor
       
   231     except:
       
   232         cnx.rollback()
       
   233         cnx.close()
       
   234         raise
       
   235     else:
       
   236         cnx.commit()
       
   237         cnx.close()
       
   238 
       
   239 
       
   240 class RepositoryDeleteHandler(CommandHandler):
       
   241     cmdname = 'delete'
       
   242     cfgname = 'repository'
       
   243 
       
   244     def _drop_namespace(self, source):
       
   245         db_namespace = source.get('db-namespace')
       
   246         with db_transaction(source, privilege='DROP SCHEMA') as cursor:
       
   247             helper = get_db_helper(source['db-driver'])
       
   248             helper.drop_schema(cursor, db_namespace)
       
   249             print('-> database schema %s dropped' % db_namespace)
       
   250 
       
   251     def _drop_database(self, source):
       
   252         dbname = source['db-name']
       
   253         if source['db-driver'] == 'sqlite':
       
   254             print('deleting database file %(db-name)s' % source)
       
   255             os.unlink(source['db-name'])
       
   256             print('-> database %(db-name)s dropped.' % source)
       
   257         else:
       
   258             helper = get_db_helper(source['db-driver'])
       
   259             with db_sys_transaction(source, privilege='DROP DATABASE') as cursor:
       
   260                 print('dropping database %(db-name)s' % source)
       
   261                 cursor.execute('DROP DATABASE "%(db-name)s"' % source)
       
   262                 print('-> database %(db-name)s dropped.' % source)
       
   263 
       
   264     def _drop_user(self, source):
       
   265         user = source['db-user'] or None
       
   266         if user is not None:
       
   267             with db_sys_transaction(source, privilege='DROP USER') as cursor:
       
   268                 print('dropping user %s' % user)
       
   269                 cursor.execute('DROP USER %s' % user)
       
   270 
       
   271     def _cleanup_steps(self, source):
       
   272         # 1/ delete namespace if used
       
   273         db_namespace = source.get('db-namespace')
       
   274         if db_namespace:
       
   275             yield ('Delete database namespace "%s"' % db_namespace,
       
   276                    self._drop_namespace, True)
       
   277         # 2/ delete database
       
   278         yield ('Delete database "%(db-name)s"' % source,
       
   279                self._drop_database, True)
       
   280         # 3/ delete user
       
   281         helper = get_db_helper(source['db-driver'])
       
   282         if source['db-user'] and helper.users_support:
       
   283             # XXX should check we are not connected as user
       
   284             yield ('Delete user "%(db-user)s"' % source,
       
   285                    self._drop_user, False)
       
   286 
       
   287     def cleanup(self):
       
   288         """remove instance's configuration and database"""
       
   289         source = self.config.system_source_config
       
   290         for msg, step, default in self._cleanup_steps(source):
       
   291             if ASK.confirm(msg, default_is_yes=default):
       
   292                 try:
       
   293                     step(source)
       
   294                 except Exception as exc:
       
   295                     print('ERROR', exc)
       
   296                     if ASK.confirm('An error occurred. Continue anyway?',
       
   297                                    default_is_yes=False):
       
   298                         continue
       
   299                     raise ExecutionError(str(exc))
       
   300 
       
   301 
       
   302 # repository specific commands ################################################
       
   303 
       
   304 def createdb(helper, source, dbcnx, cursor, **kwargs):
       
   305     if dbcnx.logged_user != source['db-user']:
       
   306         helper.create_database(cursor, source['db-name'], source['db-user'],
       
   307                                source['db-encoding'], **kwargs)
       
   308     else:
       
   309         helper.create_database(cursor, source['db-name'],
       
   310                                dbencoding=source['db-encoding'], **kwargs)
       
   311 
       
   312 
       
   313 class CreateInstanceDBCommand(Command):
       
   314     """Create the system database of an instance (run after 'create').
       
   315 
       
   316     You will be prompted for a login / password to use to connect to
       
   317     the system database.  The given user should have almost all rights
       
   318     on the database (ie a super user on the DBMS allowed to create
       
   319     database, users, languages...).
       
   320 
       
   321     <instance>
       
   322       the identifier of the instance to initialize.
       
   323     """
       
   324     name = 'db-create'
       
   325     arguments = '<instance>'
       
   326     min_args = max_args = 1
       
   327     options = (
       
   328         ('automatic',
       
   329          {'short': 'a', 'action' : 'store_true',
       
   330           'default': False,
       
   331           'help': 'automatic mode: never ask and use default answer to every '
       
   332           'question. this may require that your login match a database super '
       
   333           'user (allowed to create database & all).',
       
   334           }),
       
   335         ('config-level',
       
   336          {'short': 'l', 'type' : 'int', 'metavar': '<level>',
       
   337           'default': 0,
       
   338           'help': 'configuration level (0..2): 0 will ask for essential '
       
   339           'configuration parameters only while 2 will ask for all parameters',
       
   340           }),
       
   341         ('create-db',
       
   342          {'short': 'c', 'type': 'yn', 'metavar': '<y or n>',
       
   343           'default': True,
       
   344           'help': 'create the database (yes by default)'
       
   345           }),
       
   346         )
       
   347 
       
   348     def run(self, args):
       
   349         """run the command with its specific arguments"""
       
   350         check_options_consistency(self.config)
       
   351         automatic = self.get('automatic')
       
   352         appid = args.pop()
       
   353         config = ServerConfiguration.config_for(appid)
       
   354         source = config.system_source_config
       
   355         dbname = source['db-name']
       
   356         driver = source['db-driver']
       
   357         helper = get_db_helper(driver)
       
   358         if driver == 'sqlite':
       
   359             if os.path.exists(dbname) and (
       
   360                 automatic or
       
   361                 ASK.confirm('Database %s already exists. Drop it?' % dbname)):
       
   362                 os.unlink(dbname)
       
   363         elif self.config.create_db:
       
   364             print('\n'+underline_title('Creating the system database'))
       
   365             # connect on the dbms system base to create our base
       
   366             dbcnx = _db_sys_cnx(source, 'CREATE/DROP DATABASE and / or USER',
       
   367                                 interactive=not automatic)
       
   368             cursor = dbcnx.cursor()
       
   369             try:
       
   370                 if helper.users_support:
       
   371                     user = source['db-user']
       
   372                     if not helper.user_exists(cursor, user) and (automatic or \
       
   373                            ASK.confirm('Create db user %s ?' % user, default_is_yes=False)):
       
   374                         helper.create_user(source['db-user'], source.get('db-password'))
       
   375                         print('-> user %s created.' % user)
       
   376                 if dbname in helper.list_databases(cursor):
       
   377                     if automatic or ASK.confirm('Database %s already exists -- do you want to drop it ?' % dbname):
       
   378                         cursor.execute('DROP DATABASE "%s"' % dbname)
       
   379                     else:
       
   380                         print('you may want to run "cubicweb-ctl db-init '
       
   381                               '--drop %s" manually to continue.' % config.appid)
       
   382                         return
       
   383                 createdb(helper, source, dbcnx, cursor)
       
   384                 dbcnx.commit()
       
   385                 print('-> database %s created.' % dbname)
       
   386             except BaseException:
       
   387                 dbcnx.rollback()
       
   388                 raise
       
   389         cnx = system_source_cnx(source, special_privs='CREATE LANGUAGE/SCHEMA',
       
   390                                 interactive=not automatic)
       
   391         cursor = cnx.cursor()
       
   392         helper.init_fti_extensions(cursor)
       
   393         namespace = source.get('db-namespace')
       
   394         if namespace and ASK.confirm('Create schema %s in database %s ?'
       
   395                                      % (namespace, dbname)):
       
   396             helper.create_schema(cursor, namespace)
       
   397         cnx.commit()
       
   398         # postgres specific stuff
       
   399         if driver == 'postgres':
       
   400             # install plpythonu/plpgsql languages
       
   401             langs = ('plpythonu', 'plpgsql')
       
   402             for extlang in langs:
       
   403                 if automatic or ASK.confirm('Create language %s ?' % extlang):
       
   404                     try:
       
   405                         helper.create_language(cursor, extlang)
       
   406                     except Exception as exc:
       
   407                         print('-> ERROR:', exc)
       
   408                         print('-> could not create language %s, some stored procedures might be unusable' % extlang)
       
   409                         cnx.rollback()
       
   410                     else:
       
   411                         cnx.commit()
       
   412         print('-> database for instance %s created and necessary extensions installed.' % appid)
       
   413         print()
       
   414         if automatic:
       
   415             CWCTL.run(['db-init', '--automatic', '--config-level', '0',
       
   416                        config.appid])
       
   417         elif ASK.confirm('Run db-init to initialize the system database ?'):
       
   418             CWCTL.run(['db-init', '--config-level',
       
   419                        str(self.config.config_level), config.appid])
       
   420         else:
       
   421             print('-> nevermind, you can do it later with '
       
   422                   '"cubicweb-ctl db-init %s".' % config.appid)
       
   423 
       
   424 
       
   425 class InitInstanceCommand(Command):
       
   426     """Initialize the system database of an instance (run after 'db-create').
       
   427 
       
   428     Notice this will be done using user specified in the sources files, so this
       
   429     user should have the create tables grant permissions on the database.
       
   430 
       
   431     <instance>
       
   432       the identifier of the instance to initialize.
       
   433     """
       
   434     name = 'db-init'
       
   435     arguments = '<instance>'
       
   436     min_args = max_args = 1
       
   437     options = (
       
   438         ('automatic',
       
   439          {'short': 'a', 'action' : 'store_true',
       
   440           'default': False,
       
   441           'help': 'automatic mode: never ask and use default answer to every '
       
   442           'question.',
       
   443           }),
       
   444         ('config-level',
       
   445          {'short': 'l', 'type': 'int', 'default': 0,
       
   446           'help': 'level threshold for questions asked when configuring '
       
   447           'another source'
       
   448           }),
       
   449         ('drop',
       
   450          {'short': 'd', 'action': 'store_true',
       
   451           'default': False,
       
   452           'help': 'insert drop statements to remove previously existant '
       
   453           'tables, indexes... (no by default)'
       
   454           }),
       
   455         )
       
   456 
       
   457     def run(self, args):
       
   458         check_options_consistency(self.config)
       
   459         print('\n'+underline_title('Initializing the system database'))
       
   460         from cubicweb.server import init_repository
       
   461         appid = args[0]
       
   462         config = ServerConfiguration.config_for(appid)
       
   463         try:
       
   464             system = config.system_source_config
       
   465             extra_args = system.get('db-extra-arguments')
       
   466             extra = extra_args and {'extra_args': extra_args} or {}
       
   467             get_connection(
       
   468                 system['db-driver'], database=system['db-name'],
       
   469                 host=system.get('db-host'), port=system.get('db-port'),
       
   470                 user=system.get('db-user') or '', password=system.get('db-password') or '',
       
   471                 schema=system.get('db-namespace'), **extra)
       
   472         except Exception as ex:
       
   473             raise ConfigurationError(
       
   474                 'You seem to have provided wrong connection information in '\
       
   475                 'the %s file. Resolve this first (error: %s).'
       
   476                 % (config.sources_file(), str(ex).strip()))
       
   477         init_repository(config, drop=self.config.drop)
       
   478         if not self.config.automatic:
       
   479             while ASK.confirm('Enter another source ?', default_is_yes=False):
       
   480                 CWCTL.run(['source-add', '--config-level',
       
   481                            str(self.config.config_level), config.appid])
       
   482 
       
   483 
       
   484 class AddSourceCommand(Command):
       
   485     """Add a data source to an instance.
       
   486 
       
   487     <instance>
       
   488       the identifier of the instance to initialize.
       
   489     """
       
   490     name = 'source-add'
       
   491     arguments = '<instance>'
       
   492     min_args = max_args = 1
       
   493     options = (
       
   494         ('config-level',
       
   495          {'short': 'l', 'type': 'int', 'default': 1,
       
   496           'help': 'level threshold for questions asked when configuring another source'
       
   497           }),
       
   498         )
       
   499 
       
   500     def run(self, args):
       
   501         appid = args[0]
       
   502         config = ServerConfiguration.config_for(appid)
       
   503         repo, cnx = repo_cnx(config)
       
   504         repo.hm.call_hooks('server_maintenance', repo=repo)
       
   505         try:
       
   506             with cnx:
       
   507                 used = set(n for n, in cnx.execute('Any SN WHERE S is CWSource, S name SN'))
       
   508                 cubes = repo.get_cubes()
       
   509                 while True:
       
   510                     type = input('source type (%s): '
       
   511                                         % ', '.join(sorted(SOURCE_TYPES)))
       
   512                     if type not in SOURCE_TYPES:
       
   513                         print('-> unknown source type, use one of the available types.')
       
   514                         continue
       
   515                     sourcemodule = SOURCE_TYPES[type].module
       
   516                     if not sourcemodule.startswith('cubicweb.'):
       
   517                         # module names look like cubes.mycube.themodule
       
   518                         sourcecube = SOURCE_TYPES[type].module.split('.', 2)[1]
       
   519                         # if the source adapter is coming from an external component,
       
   520                         # ensure it's specified in used cubes
       
   521                         if not sourcecube in cubes:
       
   522                             print ('-> this source type require the %s cube which is '
       
   523                                    'not used by the instance.')
       
   524                             continue
       
   525                     break
       
   526                 while True:
       
   527                     parser = input('parser type (%s): '
       
   528                                         % ', '.join(sorted(repo.vreg['parsers'])))
       
   529                     if parser in repo.vreg['parsers']:
       
   530                         break
       
   531                     print('-> unknown parser identifier, use one of the available types.')
       
   532                 while True:
       
   533                     sourceuri = input('source identifier (a unique name used to '
       
   534                                           'tell sources apart): ').strip()
       
   535                     if not sourceuri:
       
   536                         print('-> mandatory.')
       
   537                     else:
       
   538                         sourceuri = unicode(sourceuri, sys.stdin.encoding)
       
   539                         if sourceuri in used:
       
   540                             print('-> uri already used, choose another one.')
       
   541                         else:
       
   542                             break
       
   543                 url = input('source URL (leave empty for none): ').strip()
       
   544                 url = unicode(url) if url else None
       
   545                 # XXX configurable inputlevel
       
   546                 sconfig = ask_source_config(config, type, inputlevel=self.config.config_level)
       
   547                 cfgstr = unicode(generate_source_config(sconfig), sys.stdin.encoding)
       
   548                 cnx.create_entity('CWSource', name=sourceuri, type=unicode(type),
       
   549                                   config=cfgstr, parser=unicode(parser), url=unicode(url))
       
   550                 cnx.commit()
       
   551         finally:
       
   552             repo.hm.call_hooks('server_shutdown')
       
   553 
       
   554 
       
   555 class GrantUserOnInstanceCommand(Command):
       
   556     """Grant a database user on a repository system database.
       
   557 
       
   558     <instance>
       
   559       the identifier of the instance
       
   560     <user>
       
   561       the database's user requiring grant access
       
   562     """
       
   563     name = 'db-grant-user'
       
   564     arguments = '<instance> <user>'
       
   565     min_args = max_args = 2
       
   566     options = (
       
   567         ('set-owner',
       
   568          {'short': 'o', 'type' : 'yn', 'metavar' : '<yes or no>',
       
   569           'default' : False,
       
   570           'help': 'Set the user as tables owner if yes (no by default).'}
       
   571          ),
       
   572         )
       
   573     def run(self, args):
       
   574         """run the command with its specific arguments"""
       
   575         from cubicweb.server.sqlutils import sqlexec, sqlgrants
       
   576         appid, user = args
       
   577         config = ServerConfiguration.config_for(appid)
       
   578         source = config.system_source_config
       
   579         set_owner = self.config.set_owner
       
   580         cnx = system_source_cnx(source, special_privs='GRANT')
       
   581         cursor = cnx.cursor()
       
   582         schema = config.load_schema()
       
   583         try:
       
   584             sqlexec(sqlgrants(schema, source['db-driver'], user,
       
   585                               set_owner=set_owner), cursor)
       
   586         except Exception as ex:
       
   587             cnx.rollback()
       
   588             import traceback
       
   589             traceback.print_exc()
       
   590             print('-> an error occurred:', ex)
       
   591         else:
       
   592             cnx.commit()
       
   593             print('-> rights granted to %s on instance %s.' % (appid, user))
       
   594 
       
   595 
       
   596 class ResetAdminPasswordCommand(Command):
       
   597     """Reset the administrator password.
       
   598 
       
   599     <instance>
       
   600       the identifier of the instance
       
   601     """
       
   602     name = 'reset-admin-pwd'
       
   603     arguments = '<instance>'
       
   604     min_args = max_args = 1
       
   605     options = (
       
   606         ('password',
       
   607          {'short': 'p', 'type' : 'string', 'metavar' : '<new-password>',
       
   608           'default' : None,
       
   609           'help': 'Use this password instead of prompt for one.\n'
       
   610                   '/!\ THIS IS AN INSECURE PRACTICE /!\ \n'
       
   611                   'the password will appear in shell history'}
       
   612          ),
       
   613         )
       
   614 
       
   615     def run(self, args):
       
   616         """run the command with its specific arguments"""
       
   617         from cubicweb.server.utils import crypt_password, manager_userpasswd
       
   618         appid = args[0]
       
   619         config = ServerConfiguration.config_for(appid)
       
   620         sourcescfg = config.read_sources_file()
       
   621         try:
       
   622             adminlogin = sourcescfg['admin']['login']
       
   623         except KeyError:
       
   624             print('-> Error: could not get cubicweb administrator login.')
       
   625             sys.exit(1)
       
   626         cnx = source_cnx(sourcescfg['system'])
       
   627         driver = sourcescfg['system']['db-driver']
       
   628         dbhelper = get_db_helper(driver)
       
   629         cursor = cnx.cursor()
       
   630         # check admin exists
       
   631         cursor.execute("SELECT * FROM cw_CWUser WHERE cw_login=%(l)s",
       
   632                        {'l': adminlogin})
       
   633         if not cursor.fetchall():
       
   634             print("-> error: admin user %r specified in sources doesn't exist "
       
   635                   "in the database" % adminlogin)
       
   636             print("   fix your sources file before running this command")
       
   637             cnx.close()
       
   638             sys.exit(1)
       
   639         if self.config.password is None:
       
   640             # ask for a new password
       
   641             msg = 'new password for %s' % adminlogin
       
   642             _, pwd = manager_userpasswd(adminlogin, confirm=True, passwdmsg=msg)
       
   643         else:
       
   644             pwd = self.config.password
       
   645         try:
       
   646             cursor.execute("UPDATE cw_CWUser SET cw_upassword=%(p)s WHERE cw_login=%(l)s",
       
   647                            {'p': dbhelper.binary_value(crypt_password(pwd)), 'l': adminlogin})
       
   648             sconfig = Configuration(options=USER_OPTIONS)
       
   649             sconfig['login'] = adminlogin
       
   650             sconfig['password'] = pwd
       
   651             sourcescfg['admin'] = sconfig
       
   652             config.write_sources_file(sourcescfg)
       
   653         except Exception as ex:
       
   654             cnx.rollback()
       
   655             import traceback
       
   656             traceback.print_exc()
       
   657             print('-> an error occurred:', ex)
       
   658         else:
       
   659             cnx.commit()
       
   660             print('-> password reset, sources file regenerated.')
       
   661         cnx.close()
       
   662 
       
   663 
       
   664 
       
   665 def _remote_dump(host, appid, output, sudo=False):
       
   666     # XXX generate unique/portable file name
       
   667     from datetime import date
       
   668     filename = '%s-%s.tgz' % (appid, date.today().strftime('%Y-%m-%d'))
       
   669     dmpcmd = 'cubicweb-ctl db-dump -o /tmp/%s %s' % (filename, appid)
       
   670     if sudo:
       
   671         dmpcmd = 'sudo %s' % (dmpcmd)
       
   672     dmpcmd = 'ssh -t %s "%s"' % (host, dmpcmd)
       
   673     print(dmpcmd)
       
   674     if os.system(dmpcmd):
       
   675         raise ExecutionError('Error while dumping the database')
       
   676     if output is None:
       
   677         output = filename
       
   678     cmd = 'scp %s:/tmp/%s %s' % (host, filename, output)
       
   679     print(cmd)
       
   680     if os.system(cmd):
       
   681         raise ExecutionError('Error while retrieving the dump at /tmp/%s' % filename)
       
   682     rmcmd = 'ssh -t %s "rm -f /tmp/%s"' % (host, filename)
       
   683     print(rmcmd)
       
   684     if os.system(rmcmd) and not ASK.confirm(
       
   685         'An error occurred while deleting remote dump at /tmp/%s. '
       
   686         'Continue anyway?' % filename):
       
   687         raise ExecutionError('Error while deleting remote dump at /tmp/%s' % filename)
       
   688 
       
   689 
       
   690 def _local_dump(appid, output, format='native'):
       
   691     config = ServerConfiguration.config_for(appid)
       
   692     config.quick_start = True
       
   693     mih = config.migration_handler(verbosity=1)
       
   694     mih.backup_database(output, askconfirm=False, format=format)
       
   695     mih.shutdown()
       
   696 
       
   697 def _local_restore(appid, backupfile, drop, format='native'):
       
   698     config = ServerConfiguration.config_for(appid)
       
   699     config.verbosity = 1 # else we won't be asked for confirmation on problems
       
   700     config.quick_start = True
       
   701     mih = config.migration_handler(connect=False, verbosity=1)
       
   702     mih.restore_database(backupfile, drop, askconfirm=False, format=format)
       
   703     repo = mih.repo
       
   704     # version of the database
       
   705     dbversions = repo.get_versions()
       
   706     mih.shutdown()
       
   707     if not dbversions:
       
   708         print("bad or missing version information in the database, don't upgrade file system")
       
   709         return
       
   710     # version of installed software
       
   711     eversion = dbversions['cubicweb']
       
   712     status = instance_status(config, eversion, dbversions)
       
   713     # * database version > installed software
       
   714     if status == 'needsoftupgrade':
       
   715         print("** The database of %s is more recent than the installed software!" % config.appid)
       
   716         print("** Upgrade your software, then migrate the database by running the command")
       
   717         print("** 'cubicweb-ctl upgrade %s'" % config.appid)
       
   718         return
       
   719     # * database version < installed software, an upgrade will be necessary
       
   720     #   anyway, just rewrite vc.conf and warn user he has to upgrade
       
   721     elif status == 'needapplupgrade':
       
   722         print("** The database of %s is older than the installed software." % config.appid)
       
   723         print("** Migrate the database by running the command")
       
   724         print("** 'cubicweb-ctl upgrade %s'" % config.appid)
       
   725         return
       
   726     # * database version = installed software, database version = instance fs version
       
   727     #   ok!
       
   728 
       
   729 def instance_status(config, cubicwebapplversion, vcconf):
       
   730     cubicwebversion = config.cubicweb_version()
       
   731     if cubicwebapplversion > cubicwebversion:
       
   732         return 'needsoftupgrade'
       
   733     if cubicwebapplversion < cubicwebversion:
       
   734         return 'needapplupgrade'
       
   735     for cube in config.cubes():
       
   736         try:
       
   737             softversion = config.cube_version(cube)
       
   738         except ConfigurationError:
       
   739             print('-> Error: no cube version information for %s, please check that the cube is installed.' % cube)
       
   740             continue
       
   741         try:
       
   742             applversion = vcconf[cube]
       
   743         except KeyError:
       
   744             print('-> Error: no cube version information for %s in version configuration.' % cube)
       
   745             continue
       
   746         if softversion == applversion:
       
   747             continue
       
   748         if softversion > applversion:
       
   749             return 'needsoftupgrade'
       
   750         elif softversion < applversion:
       
   751             return 'needapplupgrade'
       
   752     return None
       
   753 
       
   754 
       
   755 class DBDumpCommand(Command):
       
   756     """Backup the system database of an instance.
       
   757 
       
   758     <instance>
       
   759       the identifier of the instance to backup
       
   760       format [[user@]host:]appname
       
   761     """
       
   762     name = 'db-dump'
       
   763     arguments = '<instance>'
       
   764     min_args = max_args = 1
       
   765     options = (
       
   766         ('output',
       
   767          {'short': 'o', 'type' : 'string', 'metavar' : '<file>',
       
   768           'default' : None,
       
   769           'help': 'Specify the backup file where the backup will be stored.'}
       
   770          ),
       
   771         ('sudo',
       
   772          {'short': 's', 'action' : 'store_true',
       
   773           'default' : False,
       
   774           'help': 'Use sudo on the remote host.'}
       
   775          ),
       
   776         ('format',
       
   777          {'short': 'f', 'default': 'native', 'type': 'choice',
       
   778           'choices': ('native', 'portable'),
       
   779           'help': '"native" format uses db backend utilities to dump the database. '
       
   780                   '"portable" format uses a database independent format'}
       
   781          ),
       
   782         )
       
   783 
       
   784     def run(self, args):
       
   785         appid = args[0]
       
   786         if ':' in appid:
       
   787             host, appid = appid.split(':')
       
   788             _remote_dump(host, appid, self.config.output, self.config.sudo)
       
   789         else:
       
   790             _local_dump(appid, self.config.output, format=self.config.format)
       
   791 
       
   792 
       
   793 
       
   794 
       
   795 class DBRestoreCommand(Command):
       
   796     """Restore the system database of an instance.
       
   797 
       
   798     <instance>
       
   799       the identifier of the instance to restore
       
   800     """
       
   801     name = 'db-restore'
       
   802     arguments = '<instance> <backupfile>'
       
   803     min_args = max_args = 2
       
   804 
       
   805     options = (
       
   806         ('no-drop',
       
   807          {'short': 'n', 'action' : 'store_true', 'default' : False,
       
   808           'help': 'for some reason the database doesn\'t exist and so '
       
   809           'should not be dropped.'}
       
   810          ),
       
   811         ('format',
       
   812          {'short': 'f', 'default': 'native', 'type': 'choice',
       
   813           'choices': ('native', 'portable'),
       
   814           'help': 'the format used when dumping the database'}),
       
   815         )
       
   816 
       
   817     def run(self, args):
       
   818         appid, backupfile = args
       
   819         if self.config.format == 'portable':
       
   820             # we need to ensure a DB exist before restoring from portable format
       
   821             if not self.config.no_drop:
       
   822                 try:
       
   823                     CWCTL.run(['db-create', '--automatic', appid])
       
   824                 except SystemExit as exc:
       
   825                     # continue if the command exited with status 0 (success)
       
   826                     if exc.code:
       
   827                         raise
       
   828         _local_restore(appid, backupfile,
       
   829                        drop=not self.config.no_drop,
       
   830                        format=self.config.format)
       
   831         if self.config.format == 'portable':
       
   832             try:
       
   833                 CWCTL.run(['db-rebuild-fti', appid])
       
   834             except SystemExit as exc:
       
   835                 if exc.code:
       
   836                     raise
       
   837 
       
   838 
       
   839 class DBCopyCommand(Command):
       
   840     """Copy the system database of an instance (backup and restore).
       
   841 
       
   842     <src-instance>
       
   843       the identifier of the instance to backup
       
   844       format [[user@]host:]appname
       
   845 
       
   846     <dest-instance>
       
   847       the identifier of the instance to restore
       
   848     """
       
   849     name = 'db-copy'
       
   850     arguments = '<src-instance> <dest-instance>'
       
   851     min_args = max_args = 2
       
   852     options = (
       
   853         ('no-drop',
       
   854          {'short': 'n', 'action' : 'store_true',
       
   855           'default' : False,
       
   856           'help': 'For some reason the database doesn\'t exist and so '
       
   857           'should not be dropped.'}
       
   858          ),
       
   859         ('keep-dump',
       
   860          {'short': 'k', 'action' : 'store_true',
       
   861           'default' : False,
       
   862           'help': 'Specify that the dump file should not be automatically removed.'}
       
   863          ),
       
   864         ('sudo',
       
   865          {'short': 's', 'action' : 'store_true',
       
   866           'default' : False,
       
   867           'help': 'Use sudo on the remote host.'}
       
   868          ),
       
   869         ('format',
       
   870          {'short': 'f', 'default': 'native', 'type': 'choice',
       
   871           'choices': ('native', 'portable'),
       
   872           'help': '"native" format uses db backend utilities to dump the database. '
       
   873                   '"portable" format uses a database independent format'}
       
   874          ),
       
   875         )
       
   876 
       
   877     def run(self, args):
       
   878         import tempfile
       
   879         srcappid, destappid = args
       
   880         fd, output = tempfile.mkstemp()
       
   881         os.close(fd)
       
   882         if ':' in srcappid:
       
   883             host, srcappid = srcappid.split(':')
       
   884             _remote_dump(host, srcappid, output, self.config.sudo)
       
   885         else:
       
   886             _local_dump(srcappid, output, format=self.config.format)
       
   887         _local_restore(destappid, output, not self.config.no_drop,
       
   888                        self.config.format)
       
   889         if self.config.keep_dump:
       
   890             print('-> you can get the dump file at', output)
       
   891         else:
       
   892             os.remove(output)
       
   893 
       
   894 
       
   895 class CheckRepositoryCommand(Command):
       
   896     """Check integrity of the system database of an instance.
       
   897 
       
   898     <instance>
       
   899       the identifier of the instance to check
       
   900     """
       
   901     name = 'db-check'
       
   902     arguments = '<instance>'
       
   903     min_args = max_args = 1
       
   904     options = (
       
   905         ('checks',
       
   906          {'short': 'c', 'type' : 'csv', 'metavar' : '<check list>',
       
   907           'default' : ('entities', 'relations',
       
   908                        'mandatory_relations', 'mandatory_attributes',
       
   909                        'metadata', 'schema', 'text_index'),
       
   910           'help': 'Comma separated list of check to run. By default run all \
       
   911 checks, i.e. entities, relations, mandatory_relations, mandatory_attributes, \
       
   912 metadata, text_index and schema.'}
       
   913          ),
       
   914 
       
   915         ('autofix',
       
   916          {'short': 'a', 'type' : 'yn', 'metavar' : '<yes or no>',
       
   917           'default' : False,
       
   918           'help': 'Automatically correct integrity problems if this option \
       
   919 is set to "y" or "yes", else only display them'}
       
   920          ),
       
   921         ('reindex',
       
   922          {'short': 'r', 'type' : 'yn', 'metavar' : '<yes or no>',
       
   923           'default' : False,
       
   924           'help': 're-indexes the database for full text search if this \
       
   925 option is set to "y" or "yes" (may be long for large database).'}
       
   926          ),
       
   927         ('force',
       
   928          {'short': 'f', 'action' : 'store_true',
       
   929           'default' : False,
       
   930           'help': 'don\'t check instance is up to date.'}
       
   931          ),
       
   932 
       
   933         )
       
   934 
       
   935     def run(self, args):
       
   936         from cubicweb.server.checkintegrity import check
       
   937         appid = args[0]
       
   938         config = ServerConfiguration.config_for(appid)
       
   939         config.repairing = self.config.force
       
   940         repo, _cnx = repo_cnx(config)
       
   941         with repo.internal_cnx() as cnx:
       
   942             check(repo, cnx,
       
   943                   self.config.checks,
       
   944                   self.config.reindex,
       
   945                   self.config.autofix)
       
   946 
       
   947 
       
   948 class RebuildFTICommand(Command):
       
   949     """Rebuild the full-text index of the system database of an instance.
       
   950 
       
   951     <instance> [etype(s)]
       
   952       the identifier of the instance to rebuild
       
   953 
       
   954     If no etype is specified, cubicweb will reindex everything, otherwise
       
   955     only specified etypes will be considered.
       
   956     """
       
   957     name = 'db-rebuild-fti'
       
   958     arguments = '<instance>'
       
   959     min_args = 1
       
   960 
       
   961     def run(self, args):
       
   962         from cubicweb.server.checkintegrity import reindex_entities
       
   963         appid = args.pop(0)
       
   964         etypes = args or None
       
   965         config = ServerConfiguration.config_for(appid)
       
   966         repo, cnx = repo_cnx(config)
       
   967         with cnx:
       
   968             reindex_entities(repo.schema, cnx, etypes=etypes)
       
   969             cnx.commit()
       
   970 
       
   971 
       
   972 class SynchronizeSourceCommand(Command):
       
   973     """Force a source synchronization.
       
   974 
       
   975     <instance>
       
   976       the identifier of the instance
       
   977     <source>
       
   978       the name of the source to synchronize.
       
   979     """
       
   980     name = 'source-sync'
       
   981     arguments = '<instance> <source>'
       
   982     min_args = max_args = 2
       
   983     options = (
       
   984             ('loglevel',
       
   985              {'short': 'l', 'type' : 'choice', 'metavar': '<log level>',
       
   986               'default': 'info', 'choices': ('debug', 'info', 'warning', 'error'),
       
   987              }),
       
   988     )
       
   989 
       
   990     def run(self, args):
       
   991         from cubicweb import repoapi
       
   992         from cubicweb.cwctl import init_cmdline_log_threshold
       
   993         config = ServerConfiguration.config_for(args[0])
       
   994         config.global_set_option('log-file', None)
       
   995         config.log_format = '%(levelname)s %(name)s: %(message)s'
       
   996         init_cmdline_log_threshold(config, self['loglevel'])
       
   997         repo = repoapi.get_repository(config=config)
       
   998         repo.hm.call_hooks('server_maintenance', repo=repo)
       
   999         try:
       
  1000             try:
       
  1001                 source = repo.sources_by_uri[args[1]]
       
  1002             except KeyError:
       
  1003                 raise ExecutionError('no source named %r' % args[1])
       
  1004             with repo.internal_cnx() as cnx:
       
  1005                 stats = source.pull_data(cnx, force=True, raise_on_error=True)
       
  1006         finally:
       
  1007             repo.shutdown()
       
  1008         for key, val in stats.items():
       
  1009             if val:
       
  1010                 print(key, ':', val)
       
  1011 
       
  1012 
       
  1013 
       
  1014 def permissionshandler(relation, perms):
       
  1015     from yams.schema import RelationDefinitionSchema
       
  1016     from yams.buildobjs import DEFAULT_ATTRPERMS
       
  1017     from cubicweb.schema import (PUB_SYSTEM_ENTITY_PERMS, PUB_SYSTEM_REL_PERMS,
       
  1018                                  PUB_SYSTEM_ATTR_PERMS, RO_REL_PERMS, RO_ATTR_PERMS)
       
  1019     defaultrelperms = (DEFAULT_ATTRPERMS, PUB_SYSTEM_REL_PERMS,
       
  1020                        PUB_SYSTEM_ATTR_PERMS, RO_REL_PERMS, RO_ATTR_PERMS)
       
  1021     defaulteperms = (PUB_SYSTEM_ENTITY_PERMS,)
       
  1022     # canonicalize vs str/unicode
       
  1023     for p in ('read', 'add', 'update', 'delete'):
       
  1024         rule = perms.get(p)
       
  1025         if rule:
       
  1026             perms[p] = tuple(str(x) if isinstance(x, string_types) else x
       
  1027                              for x in rule)
       
  1028     return perms, perms in defaultrelperms or perms in defaulteperms
       
  1029 
       
  1030 
       
  1031 class SchemaDiffCommand(Command):
       
  1032     """Generate a diff between schema and fsschema description.
       
  1033 
       
  1034     <instance>
       
  1035       the identifier of the instance
       
  1036     <diff-tool>
       
  1037       the name of the diff tool to compare the two generated files.
       
  1038     """
       
  1039     name = 'schema-diff'
       
  1040     arguments = '<instance> <diff-tool>'
       
  1041     min_args = max_args = 2
       
  1042 
       
  1043     def run(self, args):
       
  1044         from yams.diff import schema_diff
       
  1045         from cubicweb import repoapi
       
  1046         appid = args.pop(0)
       
  1047         diff_tool = args.pop(0)
       
  1048         config = ServerConfiguration.config_for(appid)
       
  1049         repo = repoapi.get_repository(config=config)
       
  1050         fsschema = config.load_schema(expand_cubes=True)
       
  1051         schema_diff(fsschema, repo.schema, permissionshandler, diff_tool, ignore=('eid',))
       
  1052 
       
  1053 
       
  1054 for cmdclass in (CreateInstanceDBCommand, InitInstanceCommand,
       
  1055                  GrantUserOnInstanceCommand, ResetAdminPasswordCommand,
       
  1056                  DBDumpCommand, DBRestoreCommand, DBCopyCommand,
       
  1057                  AddSourceCommand, CheckRepositoryCommand, RebuildFTICommand,
       
  1058                  SynchronizeSourceCommand, SchemaDiffCommand,
       
  1059                  ):
       
  1060     CWCTL.register(cmdclass)
       
  1061 
       
  1062 # extend configure command to set options in sources config file ###############
       
  1063 
       
  1064 db_options = (
       
  1065     ('db',
       
  1066      {'short': 'd', 'type' : 'named', 'metavar' : '[section1.]key1:value1,[section2.]key2:value2',
       
  1067       'default': None,
       
  1068       'help': '''set <key> in <section> to <value> in "source" configuration file. If <section> is not specified, it defaults to "system".
       
  1069 
       
  1070 Beware that changing admin.login or admin.password using this command
       
  1071 will NOT update the database with new admin credentials.  Use the
       
  1072 reset-admin-pwd command instead.
       
  1073 ''',
       
  1074       }),
       
  1075     )
       
  1076 
       
  1077 ConfigureInstanceCommand.options = merge_options(
       
  1078         ConfigureInstanceCommand.options + db_options)
       
  1079 
       
  1080 configure_instance = ConfigureInstanceCommand.configure_instance
       
  1081 def configure_instance2(self, appid):
       
  1082     configure_instance(self, appid)
       
  1083     if self.config.db is not None:
       
  1084         appcfg = ServerConfiguration.config_for(appid)
       
  1085         srccfg = appcfg.read_sources_file()
       
  1086         for key, value in self.config.db.items():
       
  1087             if '.' in key:
       
  1088                 section, key = key.split('.', 1)
       
  1089             else:
       
  1090                 section = 'system'
       
  1091             try:
       
  1092                 srccfg[section][key] = value
       
  1093             except KeyError:
       
  1094                 raise ConfigurationError('unknown configuration key "%s" in section "%s" for source' % (key, section))
       
  1095         admcfg = Configuration(options=USER_OPTIONS)
       
  1096         admcfg['login'] = srccfg['admin']['login']
       
  1097         admcfg['password'] = srccfg['admin']['password']
       
  1098         srccfg['admin'] = admcfg
       
  1099         appcfg.write_sources_file(srccfg)
       
  1100 ConfigureInstanceCommand.configure_instance = configure_instance2