cli.py 19.2 KB
Newer Older
Giorgos Korfiatis's avatar
Giorgos Korfiatis committed
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
# Copyright (C) 2015 GRNET S.A.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

16
import cmd
17
import os
18 19
import sys
import logging
20
import argparse
21 22
import errno
import socket
23

24
from agkyra import protocol, protocol_client, gui, config
25 26 27 28 29 30 31 32 33 34 35


AGKYRA_DIR = config.AGKYRA_DIR
LOGGERFILE = os.path.join(AGKYRA_DIR, 'agkyra.log')
AGKYRA_LOGGER = logging.getLogger('agkyra')
HANDLER = logging.FileHandler(LOGGERFILE)
FORMATTER = logging.Formatter(
    "%(name)s:%(lineno)s %(levelname)s:%(asctime)s:%(message)s")
HANDLER.setFormatter(FORMATTER)
AGKYRA_LOGGER.addHandler(HANDLER)

36 37
logging.getLogger('astakosclient').addHandler(logging.NullHandler())

38
LOGGER = logging.getLogger(__name__)
39
STATUS = protocol.STATUS
40
NOTIFICATION = protocol.COMMON['NOTIFICATION']
41

42 43
remaining = lambda st: st['unsynced'] - (st['synced'] + st['failed'])

44

45 46 47 48
class ConfigError(protocol_client.UIClientError):
    """Error with config settings"""


49
class ConfigCommands(object):
50
    """Commands for handling Agkyra config options"""
51 52
    cnf = config.AgkyraConfig()

53 54
    def _validate_section(
            self, section, err_msg='"%s" is not a valid section'):
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
        """:raises ConfigError: if the section is invalid"""
        if not self.cnf.has_section(section):
            raise ConfigError(err_msg % section)

    def _assert_section_name(self, section, name, err_msg='%s "%s" not found'):
        """:raises ConfigError: if the section name does not exist"""
        if name not in self.cnf.keys(section):
            raise ConfigError(err_msg % (section, name))

    def _assert_has_option(
            self, section, name, option, err_msg='%s "%s" has no option "%s"'):
        """:raises ConfigError: if the option does not exist"""
        if option not in self.cnf.get(section, name):
            raise ConfigError(err_msg % (section, name, option))

70 71
    def print_option(self, section, name, option):
        """Print a configuration option"""
72 73 74 75
        self._validate_section(section)
        self._assert_section_name(section, name or option)
        if name:
            self._assert_has_option(section, name, option)
76 77
        section = '%s.%s' % (section, name) if name else section
        value = self.cnf.get(section, option)
78
        sys.stdout.write('  %s: %s\n' % (option, value))
79 80 81 82 83 84 85 86

    def list_section(self, section, name):
        """list contents of a section"""
        content = dict(self.cnf.items(section))
        if section in 'global' and name:
            self.print_option(section, '', name)
        else:
            if name:
87
                self._assert_section_name(section, name)
88 89 90 91 92
                content = content[name]
            for option in content.keys():
                self.print_option(section, name, option)

    def list_section_type(self, section):
93
        """Print the contents of a configuration section"""
94
        names = ['', ] if section in ('global', ) else self.cnf.keys(section)
95
        if not names:
96
            raise ConfigError('Section %s not found' % section)
97
        for name in names:
98 99
            sys.stdout.write('%s %s\n' % (section, name))
            sys.stdout.flush()
100 101 102 103 104 105 106
            self.list_section(section, name)

    def list_sections(self):
        """List all configuration sections"""
        for section in self.cnf.sections():
            self.list_section_type(section)

107
    def set_global_setting(self, section, option, value):
108
        assert section == 'global', 'section %s should be global"' % section
109 110
        self.cnf.set(section, option, value)
        self.cnf.write()
111
        return True
112 113

    def set_setting(self, section, name, option, value):
114
        self._validate_section(section)
115 116
        self.cnf.set('%s.%s' % (section, name), option, value)
        self.cnf.write()
117
        return True
118 119 120

    def delete_global_option(self, section, option, yes=False):
        """Delete global option"""
121 122 123
        assert section == 'global', 'Section must be global, not %s' % section
        self._assert_section_name(
            section, option, '%s option "%s" does not exist')
124 125 126
        if (not yes and 'y' != raw_input(
                'Delete %s option %s? [y|N]: ' % (section, option))):
            sys.stderr.write('Aborted\n')
127 128 129 130
            return False
        self.cnf.remove_option(section, option)
        self.cnf.write()
        return True
131 132 133

    def delete_section_option(self, section, name, option, yes=False):
        """Delete a section (sync or cloud) option"""
134 135 136
        self._validate_section(section)
        self._assert_section_name(section, name)
        self._assert_has_option(section, name, option)
137 138 139
        if (not yes and 'y' != raw_input(
                'Delete %s of %s "%s"? [y|N]: ' % (option, section, name))):
            sys.stderr.write('Aborted\n')
140 141 142 143 144
            return False
        if section == config.CLOUD_PREFIX:
            self.cnf.remove_from_cloud(name, option)
        elif section == config.SYNC_PREFIX:
            self.cnf.remove_from_sync(name, option)
145
        else:
146 147 148
            self.cnf.remove_option('%s.%s' % (section, name), option)
        self.cnf.write()
        return True
149 150 151

    def delete_section(self, section, name, yes=False):
        """Delete a section (sync or cloud)"""
152 153
        self._validate_section(section)
        self._assert_section_name(section, name)
154 155 156
        if (not yes and 'y' != raw_input(
                'Delete %s "%s"? [y|N]: ' % (section, name))):
            sys.stderr.write('Aborted\n')
157 158 159 160
            return False
        self.cnf.remove_option(section, name)
        self.cnf.write()
        return True
161 162


163 164 165 166 167 168 169 170 171 172 173 174
def handle_UI_error(func):
    def inner(*args, **kw):
        try:
            return func(*args, **kw)
        except protocol_client.UIClientError as uice:
            msg = '%s' % uice
            LOGGER.debug(msg)
            sys.stderr.write(msg)
            sys.stderr.flush()
    return inner


175
class AgkyraCLI(cmd.Cmd):
176 177
    """The CLI for Agkyra is connected to a protocol server"""
    cnf_cmds = ConfigCommands()
178 179
    helper = protocol.SessionHelper()

180
    def __init__(self, *args, **kwargs):
Giorgos Korfiatis's avatar
Giorgos Korfiatis committed
181
        self.callback = kwargs.pop('callback', os.path.realpath(sys.argv[0]))
182
        self.args = kwargs.pop('parsed_args', None)
183 184
        AGKYRA_LOGGER.setLevel(logging.DEBUG
                               if self.args.debug else logging.INFO)
Giorgos Korfiatis's avatar
Giorgos Korfiatis committed
185
        LOGGER.debug("Callback is %s" % self.callback)
186 187
        cmd.Cmd.__init__(self, *args, **kwargs)

188 189 190 191 192 193 194 195 196 197 198 199
    @staticmethod
    def parse_args():
        parser = argparse.ArgumentParser(
            description='Agkyra syncer launcher', add_help=False)
        parser.add_argument(
            '--help', '-h',
            action='store_true', help='Help on agkyra syntax and usage')
        parser.add_argument(
            '--debug', '-d',
            action='store_true', help='set logging level to "debug"')
        parser.add_argument('cmd', nargs="*")

200 201 202 203 204
        if not any([a for a in sys.argv[1:] if not a.startswith('-')]):
            parser.add_argument(
                '--version',
                action='store_true', help='Output agkyra version and exit')

205 206 207 208 209 210 211 212
        for terms in (['help', ], ['config', 'delete']):
            if not set(terms).difference(sys.argv):
                {
                    'help': lambda: parser.add_argument(
                        '--list', '-l',
                        action='store_true', help='List all commands'),
                    'config_delete': lambda: parser.add_argument(
                        '--yes', '-y',
213
                        action='store_true', help='Yes to all questions'),
214 215 216 217 218 219 220 221 222 223 224 225
                }['_'.join(terms)]()

        return parser.parse_args()

    def must_help(self, command):
        if self.args.help:
            self.do_help(command)
            return True
        return False

    def launch_daemon(self):
        """Launch the agkyra protocol server"""
226
        LOGGER.info('Starting the agkyra daemon')
227 228 229
        session_daemon = self.helper.create_session_daemon()
        if session_daemon:
            session_daemon.start()
230
            LOGGER.info('Daemon is shut down')
231
        else:
232
            LOGGER.info('Another daemon is running, aborting')
233

234 235 236 237
    @property
    def client(self):
        """Return the helper client instace or None"""
        self._client = getattr(self, '_client', None)
238 239
        if self._client is None:
            self._client = self.try_make_client()
240
        return self._client
241

242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260
    def try_make_client(self):
        session = self.helper.load_active_session()
        if session:
            client = protocol_client.UIClient(session)
            try:
                client.connect()
                return client
            except socket.error as e:
                if e.errno == errno.ECONNREFUSED:
                    sys.stderr.write(
                        "It seems that a previous Agkyra execution hasn't "
                        "exited properly.\nPlease, try again after a few "
                        "seconds.\n")
                    exit(1)
                else:
                    raise
        else:
            return None

261
    def do_help(self, line):
262
        """Help on agkyra GUI and CLI
263 264
        agkyra              Run agkyra with GUI (equivalent to "agkyra gui")
        agkyra <cmd>        Run a command through agkyra CLI
Giorgos Korfiatis's avatar
Giorgos Korfiatis committed
265
        agkyra --version    Print agkyra version and exit
266 267

        To get help on agkyra commands:
268 269 270
            help <cmd>            for an individual command
            help <--list | -l>    for all commands
        """
271 272
        if getattr(self.args, 'list', None):
            self.args.list = None
273 274 275 276
            prefix = 'do_'
            for c in self.get_names():
                if c.startswith(prefix):
                    actual_name = c[len(prefix):]
277
                    sys.stderr.write('- %s -\n' % actual_name)
278 279 280 281 282
                    self.do_help(actual_name)
        else:
            if not line:
                cmd.Cmd.do_help(self, 'help')
            cmd.Cmd.do_help(self, line)
283

284
    def emptyline(self):
285 286 287 288 289 290
        if self.args.version:
            from agkyra import __version__
            sys.stdout.write('%s\n' % __version__)
            sys.stdout.flush()
        elif not self.must_help(''):
            return self.do_gui('')
291 292 293 294

    def default(self, line):
        self.do_help(line)

295 296 297 298 299 300 301
    def config_list(self, args):
        """List (all or some) options
        list                                List all options
        list <global | cloud | sync>        List global | cloud | sync options
        list global OPTION                  Get global option
        list <cloud | sync> NAME            List options a cloud or sync
        list <cloud | sync> NAME OPTION     List an option from a cloud or sync
302 303 304
        """
        try:
            {
305 306 307 308
                0: self.cnf_cmds.list_sections,
                1: self.cnf_cmds.list_section_type,
                2: self.cnf_cmds.list_section,
                3: self.cnf_cmds.print_option
309 310
            }[len(args)](*args)
        except Exception as e:
311
            LOGGER.debug('%s\n' % e)
312 313
            if isinstance(e, ConfigError):
                raise
314
            sys.stderr.write(self.config_list.__doc__ + '\n')
315

316 317 318 319 320 321 322 323 324
    def _warn_user_about_setting_updates(self):
        if self.helper.load_active_session():
            sys.stderr.write(
                'Done\n'
                'WARNING: Setting updates will take effect after agkyra is '
                'shutdown and started again. To do this:\n'
                '\t$ agkyra shutdown\n\t$ agkyra start\n');
            sys.stderr.flush()

325 326 327 328 329 330 331
    def config_set(self, args):
        """Set an option
        set global OPTION VALUE                 Set a global option
        set <cloud | sync> NAME OPTION VALUE    Set an option on cloud or sync
                                                Creates a sync or cloud, if it
                                                does not exist
        """
332
        r = False
333
        try:
334
            r = {
335 336
                3: self.cnf_cmds.set_global_setting,
                4: self.cnf_cmds.set_setting
337 338
            }[len(args)](*args)
        except Exception as e:
339
            LOGGER.debug('%s\n' % e)
340 341
            if isinstance(e, ConfigError):
                raise
342
            sys.stderr.write(self.config_set.__doc__ + '\n')
343 344
        if r:
            self._warn_user_about_setting_updates()
345 346 347

    def config_delete(self, args):
        """Delete an option
348 349 350
        delete global OPTION [-y]               Delete a global option
        delete <cloud | sync> NAME [-y]         Delete a sync or cloud
        delete <cloud |sync> NAME OPTION [-y]   Delete a sync or cloud option
351
        """
352
        args.append(self.args.yes)
353
        r = False
354
        try:
355
            r = {
356 357 358
                3: self.cnf_cmds.delete_global_option if (
                    args[0] == 'global') else self.cnf_cmds.delete_section,
                4: self.cnf_cmds.delete_section_option
359 360
            }[len(args)](*args)
        except Exception as e:
361
            LOGGER.debug('%s\n' % e)
362 363
            if isinstance(e, ConfigError):
                raise
364
            sys.stderr.write(self.config_delete.__doc__ + '\n')
365 366
        if r:
            self._warn_user_about_setting_updates()
367

368 369 370 371 372 373
    def do_config(self, line):
        """Commands for managing the agkyra settings
        list   [global|cloud|sync [setting]]          List all or some settings
        set    <global|cloud|sync> <setting> <value>  Set a setting
        delete <global|cloud|sync> [setting]          Delete a setting or group
        """
374 375
        if self.must_help('config'):
            return
376 377 378 379 380 381
        args = line.split(' ')
        try:
            method = getattr(self, 'config_' + args[0])
            method(args[1:])
        except AttributeError:
            self.do_help('config')
382 383 384
        except ConfigError as ce:
            sys.stderr.write('%s\n' % ce)
            sys.stderr.flush()
385

386 387
    def do_status(self, line):
        """Get Agkyra client status. Status may be one of the following:
388 389 390
        Syncing     There is a process syncing right now
        Paused      Notifiers are active but syncing is paused
        Not running No active processes
391
        """
392 393 394 395
        self._status(line)

    @handle_UI_error
    def _status(self, line):
396 397
        if self.must_help('status'):
            return
398
        client = self.client
399 400
        status, msg = client.get_status() if client else None, 'Not running'
        if status:
401
            msg = NOTIFICATION[str(status['code'])]
402
            diff = remaining(status)
403 404 405
            if diff:
                msg = '%s, %s remaining' % (msg, diff)
        sys.stdout.write('%s\n' % msg)
406
        sys.stdout.flush()
407

408
    def do_start(self, line):
409 410 411 412
        """Start the session, set it in syncing mode
        start         Start syncing. If daemon is down, start it up
        start daemon  Start the agkyra daemon and wait
        """
413 414
        self._start(line)

Giorgos Korfiatis's avatar
Giorgos Korfiatis committed
415 416 417 418 419 420 421 422 423
    def make_server_if_needed(self):
        client = self.client
        if not client:
            sys.stderr.write('No Agkyra daemons running, starting one')
            protocol.launch_server(self.callback, self.args.debug)
            sys.stderr.write(' ... ')
            self.helper.wait_session_to_load()
            sys.stderr.write('OK\n')

424 425
    @handle_UI_error
    def _start(self, line):
426 427 428 429
        if self.must_help('start'):
            return
        if line in ['daemon']:
            return self.launch_daemon()
430 431 432 433
        if line:
            sys.stderr.write("Unrecognized subcommand '%s'.\n" % line)
            sys.stderr.flush()
            return
Giorgos Korfiatis's avatar
Giorgos Korfiatis committed
434
        self.make_server_if_needed()
435
        client = self.client
Giorgos Korfiatis's avatar
Giorgos Korfiatis committed
436
        status = client.get_status()
437 438 439 440
        if status['code'] == STATUS['SETTINGS READY']:
            client.init()
        status = client.get_status()
        if status['code'] in [STATUS['PAUSED'], STATUS['READY']]:
Giorgos Korfiatis's avatar
Giorgos Korfiatis committed
441 442 443 444 445 446 447
            client.start()
            sys.stderr.write('Starting syncer ... ')
            try:
                client.wait_until_syncing()
                sys.stderr.write('OK\n')
            except protocol_client.UIClientError as uice:
                sys.stderr.write('%s\n' % uice)
448
        elif status['code'] == STATUS['SYNCING']:
Giorgos Korfiatis's avatar
Giorgos Korfiatis committed
449
            sys.stderr.write('Already ')
450
        sys.stderr.flush()
451
        self.do_status(line)
452 453 454

    def do_pause(self, line):
        """Pause a session (stop it from syncing, but keep it running)"""
455 456 457 458
        self._pause(line)

    @handle_UI_error
    def _pause(self, line):
459 460
        if self.must_help('pause'):
            return
461 462 463
        client = self.client
        if client:
            status = client.get_status()
464
            if status['code'] == STATUS['PAUSED']:
465
                sys.stderr.write('Already ')
466 467 468 469 470
            else:
                client.pause()
                sys.stderr.write('Pausing syncer ... ')
                try:
                    client.wait_until_paused()
471
                    sys.stderr.write('OK\n')
472 473
                except protocol_client.UIClientError as uice:
                    sys.stderr.write('%s\n' % uice)
474
        sys.stderr.flush()
475
        self.do_status(line)
476

477 478
    def do_shutdown(self, line):
        """Shutdown Agkyra, if it is running"""
479 480 481 482
        self._shutdown(line)

    @handle_UI_error
    def _shutdown(self, line):
483 484
        if self.must_help('shutdown'):
            return
485 486 487
        client = self.client
        if client:
            client.shutdown()
488
            sys.stderr.write('Shutting down Agkyra ... ')
489 490 491 492
            success = self.helper.wait_session_to_stop()
            sys.stderr.write('Stopped' if success else 'Still up (timed out)')
            sys.stderr.write('\n')
        else:
493
            sys.stderr.write('Not running\n')
494
        sys.stderr.flush()
495 496 497 498 499 500 501 502 503 504


    # Systemic commands
    def do_gui(self, line):
        """Launch the agkyra GUI
        Only one GUI instance can run at a time.
        If an agkyra daemon is already running, the GUI will use it.
        """
        if self.must_help('gui'):
            return
505 506
        session = self.helper.load_active_session()
        if not session:
507
            self.make_server_if_needed()
508 509 510 511 512 513 514 515 516 517 518
            session = self.helper.wait_session_to_load()
        if session:
            LOGGER.info('Start new GUI')
            new_gui = gui.GUI(session, debug=self.args.debug)
            try:
                new_gui.start()
            except KeyboardInterrupt:
                LOGGER.info('GUI interrupted by user')
                sys.stderr.write('GUI interrupted by user, exiting\n')
                sys.stderr.flush()
                new_gui.clean_exit()
519 520
                if self.client:
                    self._shutdown('')
521 522 523 524 525 526 527 528 529 530
            except socket.error as e:
                if e.errno == errno.ECONNREFUSED:
                    sys.stderr.write(
                        "It seems that a previous Agkyra execution hasn't "
                        "exited properly.\nPlease, try again after a few "
                        "seconds.\n")
                    new_gui.clean_exit()
                    exit(1)
                else:
                    raise
531 532 533
        else:
            sys.stderr.write('Session failed to load\n')
            sys.stderr.flush()