diff --git a/odoo/cli/__init__.py b/odoo/cli/__init__.py index 2bfeb98a75e..b65ae2e7bc5 100644 --- a/odoo/cli/__init__.py +++ b/odoo/cli/__init__.py @@ -16,3 +16,4 @@ from . import populate from . import tsconfig from . import neutralize from . import genproxytoken +from . import db diff --git a/odoo/cli/command.py b/odoo/cli/command.py index 113c86d5631..4b41222a223 100644 --- a/odoo/cli/command.py +++ b/odoo/cli/command.py @@ -8,16 +8,11 @@ import odoo from odoo.modules import get_modules, get_module_path, initialize_sys_path commands = {} - -class CommandType(type): - def __init__(cls, name, bases, attrs): - super(CommandType, cls).__init__(name, bases, attrs) - name = getattr(cls, name, cls.__name__.lower()) - cls.name = name - if name != 'command': - commands[name] = cls - -Command = CommandType('Command', (object,), {'run': lambda self, args: None}) +class Command: + name = None + def __init_subclass__(cls): + cls.name = cls.name or cls.__name__.lower() + commands[cls.name] = cls class Help(Command): """Display the list of available commands""" diff --git a/odoo/cli/db.py b/odoo/cli/db.py new file mode 100644 index 00000000000..8f4455bc5d6 --- /dev/null +++ b/odoo/cli/db.py @@ -0,0 +1,164 @@ +import argparse +import io +import urllib.parse +import zipfile +from functools import partial +from pathlib import Path +from sys import argv, stdout, stderr + +import requests + +from . import Command +from .server import report_configuration +from ..service.db import dump_db, exp_drop, exp_db_exist, exp_duplicate_database, exp_rename, restore_db +from ..tools import config + +eprint = partial(print, file=stderr, flush=True) + +class Db(Command): + name = 'db' + + def run(self, cmdargs): + """Command-line version of the database manager. + + Doesn't provide a `create` command as that's not useful. Commands are + all filestore-aware. + """ + parser = argparse.ArgumentParser( + prog="%s %s" % (Path(argv[0]).name, self.name), + description=self.__doc__ + ) + parser.add_argument('-c', '--config') + parser.add_argument('-D', '--data-dir') + parser.add_argument('--addons-path') + parser.add_argument('-r', '--db_user') + parser.add_argument('-w', '--db_password') + parser.add_argument('--pg_path') + parser.add_argument('--db_host') + parser.add_argument('--db_port') + parser.add_argument('--db_sslmode') + parser.set_defaults(func=lambda _: exit(parser.format_help())) + + subs = parser.add_subparsers() + load = subs.add_parser( + "load", help="Load a dump file.", + description="Loads a dump file into odoo, dump file can be a URL. " + "If `database` is provided, uses that as the database name. " + "Otherwise uses the dump file name without extension.") + load.set_defaults(func=self.load) + load.add_argument( + '-f', '--force', action='store_const', default=False, const=True, + help="delete `database` database before loading if it exists" + ) + load.add_argument( + 'database', nargs='?', + help="database to create, defaults to dump file's name " + "(without extension)" + ) + load.add_argument('dump_file', help="zip or pg_dump file to load") + + dump = subs.add_parser( + "dump", help="Create a dump with filestore.", + description="Creates a dump file. The dump is always in zip format " + "(with filestore), to get a no-filestore format use " + "pg_dump directly.") + dump.set_defaults(func=self.dump) + dump.add_argument('database', help="database to dump") + dump.add_argument( + 'dump_path', nargs='?', default='-', + help="if provided, database is dumped to specified path, otherwise " + "or if `-`, dumped to stdout", + ) + + duplicate = subs.add_parser("duplicate", help="Duplicate a database including filestore.") + duplicate.set_defaults(func=self.duplicate) + duplicate.add_argument( + '-f', '--force', action='store_const', default=False, const=True, + help="delete `target` database before copying if it exists" + ) + duplicate.add_argument("source") + duplicate.add_argument("target", help="database to copy `source` to, must not exist unless `-f` is specified in which case it will be dropped first") + + rename = subs.add_parser("rename", help="Rename a database including filestore.") + rename.set_defaults(func=self.rename) + rename.add_argument( + '-f', '--force', action='store_const', default=False, const=True, + help="delete `target` database before renaming if it exists" + ) + rename.add_argument('source') + rename.add_argument("target", help="database to rename `source` to, must not exist unless `-f` is specified, in which case it will be dropped first") + + drop = subs.add_parser("drop", help="Delete a database including filestore") + drop.set_defaults(func=self.drop) + drop.add_argument("database", help="database to delete") + + args = parser.parse_args(cmdargs) + + config.parse_config([ + val + for k, v in vars(args).items() + if v is not None + if k in ['config', 'data_dir', 'addons_path'] or k.startswith(('db_', 'pg_')) + for val in [ + '--data-dir' if k == 'data_dir'\ + else '--addons-path' if k == 'addons_path'\ + else f'--{k}', + v, + ] + ]) + # force db management active to bypass check when only a + # `check_db_management_enabled` version is available. + config['list_db'] = True + report_configuration() + + args.func(args) + + def load(self, args): + db_name = args.database or Path(args.dump_file).stem + self._check_target(db_name, delete_if_exists=args.force) + + url = urllib.parse.urlparse(args.dump_file) + if url.scheme: + eprint(f"Fetching {args.dump_file}...", end='') + r = requests.get(args.dump_file, timeout=10) + if not r.ok: + exit(f" unable to fetch {args.dump_file}: {r.reason}") + + eprint(" done") + dump_file = io.BytesIO(r.content) + else: + eprint(f"Restoring {args.dump_file}...") + dump_file = args.dump_file + + if not zipfile.is_zipfile(dump_file): + exit("Not a zipped dump file, use `pg_restore` to restore raw dumps," + " and `psql` to execute sql dumps or scripts.") + + restore_db(db=db_name, dump_file=dump_file, copy=True) + + def dump(self, args): + if args.dump_path == '-': + dump_db(args.database, stdout.buffer) + else: + with open(args.dump_path, 'wb') as f: + dump_db(args.database, f) + + def duplicate(self, args): + self._check_target(args.target, delete_if_exists=args.force) + exp_duplicate_database(args.source, args.target) + + def rename(self, args): + self._check_target(args.target, delete_if_exists=args.force) + exp_rename(args.source, args.target) + + def drop(self, args): + if not exp_drop(args.database): + exit(f"Database {args.database} does not exist.") + + def _check_target(self, target, *, delete_if_exists): + if exp_db_exist(target): + if delete_if_exists: + exp_drop(target) + else: + exit(f"Target database {target} exists, aborting.\n\n" + f"\tuse `--force` to delete the existing database anyway.") diff --git a/odoo/service/db.py b/odoo/service/db.py index 1af06084b05..76517bc31ea 100644 --- a/odoo/service/db.py +++ b/odoo/service/db.py @@ -279,9 +279,10 @@ def exp_restore(db_name, data, copy=False): def restore_db(db, dump_file, copy=False): assert isinstance(db, str) if exp_db_exist(db): - _logger.info('RESTORE DB: %s already exists', db) + _logger.warning('RESTORE DB: %s already exists', db) raise Exception("Database already exists") + _logger.info('RESTORING DB: %s', db) _create_empty_database(db) filestore_path = None