| #!/usr/bin/python -u |
| |
| import os, sys, re, tempfile |
| from optparse import OptionParser |
| import common |
| from autotest_lib.client.common_lib import utils |
| from autotest_lib.database import database_connection |
| |
| MIGRATE_TABLE = 'migrate_info' |
| |
| _AUTODIR = os.path.join(os.path.dirname(__file__), '..') |
| _MIGRATIONS_DIRS = { |
| 'AUTOTEST_WEB': os.path.join(_AUTODIR, 'frontend', 'migrations'), |
| 'TKO': os.path.join(_AUTODIR, 'tko', 'migrations'), |
| 'AUTOTEST_SERVER_DB': os.path.join(_AUTODIR, 'database', |
| 'server_db_migrations'), |
| } |
| _DEFAULT_MIGRATIONS_DIR = 'migrations' # use CWD |
| |
| class Migration(object): |
| """Represents a database migration.""" |
| _UP_ATTRIBUTES = ('migrate_up', 'UP_SQL') |
| _DOWN_ATTRIBUTES = ('migrate_down', 'DOWN_SQL') |
| |
| def __init__(self, name, version, module): |
| self.name = name |
| self.version = version |
| self.module = module |
| self._check_attributes(self._UP_ATTRIBUTES) |
| self._check_attributes(self._DOWN_ATTRIBUTES) |
| |
| |
| @classmethod |
| def from_file(cls, filename): |
| """Instantiates a Migration from a file. |
| |
| @param filename: Name of a migration file. |
| |
| @return An instantiated Migration object. |
| |
| """ |
| version = int(filename[:3]) |
| name = filename[:-3] |
| module = __import__(name, globals(), locals(), []) |
| return cls(name, version, module) |
| |
| |
| def _check_attributes(self, attributes): |
| method_name, sql_name = attributes |
| assert (hasattr(self.module, method_name) or |
| hasattr(self.module, sql_name)) |
| |
| |
| def _execute_migration(self, attributes, manager): |
| method_name, sql_name = attributes |
| method = getattr(self.module, method_name, None) |
| if method: |
| assert callable(method) |
| method(manager) |
| else: |
| sql = getattr(self.module, sql_name) |
| assert isinstance(sql, basestring) |
| manager.execute_script(sql) |
| |
| |
| def migrate_up(self, manager): |
| """Performs an up migration (to a newer version). |
| |
| @param manager: A MigrationManager object. |
| |
| """ |
| self._execute_migration(self._UP_ATTRIBUTES, manager) |
| |
| |
| def migrate_down(self, manager): |
| """Performs a down migration (to an older version). |
| |
| @param manager: A MigrationManager object. |
| |
| """ |
| self._execute_migration(self._DOWN_ATTRIBUTES, manager) |
| |
| |
| class MigrationManager(object): |
| """Managest database migrations.""" |
| connection = None |
| cursor = None |
| migrations_dir = None |
| |
| def __init__(self, database_connection, migrations_dir=None, force=False): |
| self._database = database_connection |
| self.force = force |
| # A boolean, this will only be set to True if this migration should be |
| # simulated rather than actually taken. For use with migrations that |
| # may make destructive queries |
| self.simulate = False |
| self._set_migrations_dir(migrations_dir) |
| |
| |
| def _set_migrations_dir(self, migrations_dir=None): |
| config_section = self._config_section() |
| if migrations_dir is None: |
| migrations_dir = os.path.abspath( |
| _MIGRATIONS_DIRS.get(config_section, _DEFAULT_MIGRATIONS_DIR)) |
| self.migrations_dir = migrations_dir |
| sys.path.append(migrations_dir) |
| assert os.path.exists(migrations_dir), migrations_dir + " doesn't exist" |
| |
| |
| def _config_section(self): |
| return self._database.global_config_section |
| |
| |
| def get_db_name(self): |
| """Gets the database name.""" |
| return self._database.get_database_info()['db_name'] |
| |
| |
| def execute(self, query, *parameters): |
| """Executes a database query. |
| |
| @param query: The query to execute. |
| @param parameters: Associated parameters for the query. |
| |
| @return The result of the query. |
| |
| """ |
| return self._database.execute(query, parameters) |
| |
| |
| def execute_script(self, script): |
| """Executes a set of database queries. |
| |
| @param script: A string of semicolon-separated queries. |
| |
| """ |
| sql_statements = [statement.strip() |
| for statement in script.split(';') |
| if statement.strip()] |
| for statement in sql_statements: |
| self.execute(statement) |
| |
| |
| def check_migrate_table_exists(self): |
| """Checks whether the migration table exists.""" |
| try: |
| self.execute("SELECT * FROM %s" % MIGRATE_TABLE) |
| return True |
| except self._database.DatabaseError, exc: |
| # we can't check for more specifics due to differences between DB |
| # backends (we can't even check for a subclass of DatabaseError) |
| return False |
| |
| |
| def create_migrate_table(self): |
| """Creates the migration table.""" |
| if not self.check_migrate_table_exists(): |
| self.execute("CREATE TABLE %s (`version` integer)" % |
| MIGRATE_TABLE) |
| else: |
| self.execute("DELETE FROM %s" % MIGRATE_TABLE) |
| self.execute("INSERT INTO %s VALUES (0)" % MIGRATE_TABLE) |
| assert self._database.rowcount == 1 |
| |
| |
| def set_db_version(self, version): |
| """Sets the database version. |
| |
| @param version: The version to which to set the database. |
| |
| """ |
| assert isinstance(version, int) |
| self.execute("UPDATE %s SET version=%%s" % MIGRATE_TABLE, |
| version) |
| assert self._database.rowcount == 1 |
| |
| |
| def get_db_version(self): |
| """Gets the database version. |
| |
| @return The database version. |
| |
| """ |
| if not self.check_migrate_table_exists(): |
| return 0 |
| rows = self.execute("SELECT * FROM %s" % MIGRATE_TABLE) |
| if len(rows) == 0: |
| return 0 |
| assert len(rows) == 1 and len(rows[0]) == 1 |
| return rows[0][0] |
| |
| |
| def get_migrations(self, minimum_version=None, maximum_version=None): |
| """Gets the list of migrations to perform. |
| |
| @param minimum_version: The minimum database version. |
| @param maximum_version: The maximum database version. |
| |
| @return A list of Migration objects. |
| |
| """ |
| migrate_files = [filename for filename |
| in os.listdir(self.migrations_dir) |
| if re.match(r'^\d\d\d_.*\.py$', filename)] |
| migrate_files.sort() |
| migrations = [Migration.from_file(filename) |
| for filename in migrate_files] |
| if minimum_version is not None: |
| migrations = [migration for migration in migrations |
| if migration.version >= minimum_version] |
| if maximum_version is not None: |
| migrations = [migration for migration in migrations |
| if migration.version <= maximum_version] |
| return migrations |
| |
| |
| def do_migration(self, migration, migrate_up=True): |
| """Performs a migration. |
| |
| @param migration: The Migration to perform. |
| @param migrate_up: Whether to migrate up (if not, then migrates down). |
| |
| """ |
| print 'Applying migration %s' % migration.name, # no newline |
| if migrate_up: |
| print 'up' |
| assert self.get_db_version() == migration.version - 1 |
| migration.migrate_up(self) |
| new_version = migration.version |
| else: |
| print 'down' |
| assert self.get_db_version() == migration.version |
| migration.migrate_down(self) |
| new_version = migration.version - 1 |
| self.set_db_version(new_version) |
| |
| |
| def migrate_to_version(self, version): |
| """Performs a migration to a specified version. |
| |
| @param version: The version to which to migrate the database. |
| |
| """ |
| current_version = self.get_db_version() |
| if current_version == 0 and self._config_section() == 'AUTOTEST_WEB': |
| self._migrate_from_base() |
| current_version = self.get_db_version() |
| |
| if current_version < version: |
| lower, upper = current_version, version |
| migrate_up = True |
| else: |
| lower, upper = version, current_version |
| migrate_up = False |
| |
| migrations = self.get_migrations(lower + 1, upper) |
| if not migrate_up: |
| migrations.reverse() |
| for migration in migrations: |
| self.do_migration(migration, migrate_up) |
| |
| assert self.get_db_version() == version |
| print 'At version', version |
| |
| |
| def _migrate_from_base(self): |
| """Initialize the AFE database. |
| """ |
| self.confirm_initialization() |
| |
| migration_script = utils.read_file( |
| os.path.join(os.path.dirname(__file__), 'schema_129.sql')) |
| migration_script = migration_script % ( |
| dict(username=self._database.get_database_info()['username'])) |
| self.execute_script(migration_script) |
| |
| self.create_migrate_table() |
| self.set_db_version(129) |
| |
| |
| def confirm_initialization(self): |
| """Confirms with the user that we should initialize the database. |
| |
| @raises Exception, if the user chooses to abort the migration. |
| |
| """ |
| if not self.force: |
| response = raw_input( |
| 'Your %s database does not appear to be initialized. Do you ' |
| 'want to recreate it (this will result in loss of any existing ' |
| 'data) (yes/No)? ' % self.get_db_name()) |
| if response != 'yes': |
| raise Exception('User has chosen to abort migration') |
| |
| |
| def get_latest_version(self): |
| """Gets the latest database version.""" |
| migrations = self.get_migrations() |
| return migrations[-1].version |
| |
| |
| def migrate_to_latest(self): |
| """Migrates the database to the latest version.""" |
| latest_version = self.get_latest_version() |
| self.migrate_to_version(latest_version) |
| |
| |
| def initialize_test_db(self): |
| """Initializes a test database.""" |
| db_name = self.get_db_name() |
| test_db_name = 'test_' + db_name |
| # first, connect to no DB so we can create a test DB |
| self._database.connect(db_name='') |
| print 'Creating test DB', test_db_name |
| self.execute('CREATE DATABASE ' + test_db_name) |
| self._database.disconnect() |
| # now connect to the test DB |
| self._database.connect(db_name=test_db_name) |
| |
| |
| def remove_test_db(self): |
| """Removes a test database.""" |
| print 'Removing test DB' |
| self.execute('DROP DATABASE ' + self.get_db_name()) |
| # reset connection back to real DB |
| self._database.disconnect() |
| self._database.connect() |
| |
| |
| def get_mysql_args(self): |
| """Returns the mysql arguments as a string.""" |
| return ('-u %(username)s -p%(password)s -h %(host)s %(db_name)s' % |
| self._database.get_database_info()) |
| |
| |
| def migrate_to_version_or_latest(self, version): |
| """Migrates to either a specified version, or the latest version. |
| |
| @param version: The version to which to migrate the database, |
| or None in order to migrate to the latest version. |
| |
| """ |
| if version is None: |
| self.migrate_to_latest() |
| else: |
| self.migrate_to_version(version) |
| |
| |
| def do_sync_db(self, version=None): |
| """Migrates the database. |
| |
| @param version: The version to which to migrate the database. |
| |
| """ |
| print 'Migration starting for database', self.get_db_name() |
| self.migrate_to_version_or_latest(version) |
| print 'Migration complete' |
| |
| |
| def test_sync_db(self, version=None): |
| """Create a fresh database and run all migrations on it. |
| |
| @param version: The version to which to migrate the database. |
| |
| """ |
| self.initialize_test_db() |
| try: |
| print 'Starting migration test on DB', self.get_db_name() |
| self.migrate_to_version_or_latest(version) |
| # show schema to the user |
| os.system('mysqldump %s --no-data=true ' |
| '--add-drop-table=false' % |
| self.get_mysql_args()) |
| finally: |
| self.remove_test_db() |
| print 'Test finished successfully' |
| |
| |
| def simulate_sync_db(self, version=None): |
| """Creates a fresh DB, copies existing DB to it, then synchronizes it. |
| |
| @param version: The version to which to migrate the database. |
| |
| """ |
| db_version = self.get_db_version() |
| # don't do anything if we're already at the latest version |
| if db_version == self.get_latest_version(): |
| print 'Skipping simulation, already at latest version' |
| return |
| # get existing data |
| self.initialize_and_fill_test_db() |
| try: |
| print 'Starting migration test on DB', self.get_db_name() |
| self.migrate_to_version_or_latest(version) |
| finally: |
| self.remove_test_db() |
| print 'Test finished successfully' |
| |
| |
| def initialize_and_fill_test_db(self): |
| """Initializes and fills up a test database.""" |
| print 'Dumping existing data' |
| dump_fd, dump_file = tempfile.mkstemp('.migrate_dump') |
| os.system('mysqldump %s >%s' % |
| (self.get_mysql_args(), dump_file)) |
| # fill in test DB |
| self.initialize_test_db() |
| print 'Filling in test DB' |
| os.system('mysql %s <%s' % (self.get_mysql_args(), dump_file)) |
| os.close(dump_fd) |
| os.remove(dump_file) |
| |
| |
| USAGE = """\ |
| %s [options] sync|test|simulate|safesync [version] |
| Options: |
| -d --database Which database to act on |
| -f --force Don't ask for confirmation |
| --debug Print all DB queries"""\ |
| % sys.argv[0] |
| |
| |
| def main(): |
| """Main function for the migration script.""" |
| parser = OptionParser() |
| parser.add_option("-d", "--database", |
| help="which database to act on", |
| dest="database", |
| default="AUTOTEST_WEB") |
| parser.add_option("-f", "--force", help="don't ask for confirmation", |
| action="store_true") |
| parser.add_option('--debug', help='print all DB queries', |
| action='store_true') |
| (options, args) = parser.parse_args() |
| manager = get_migration_manager(db_name=options.database, |
| debug=options.debug, force=options.force) |
| |
| if len(args) > 0: |
| if len(args) > 1: |
| version = int(args[1]) |
| else: |
| version = None |
| if args[0] == 'sync': |
| manager.do_sync_db(version) |
| elif args[0] == 'test': |
| manager.simulate=True |
| manager.test_sync_db(version) |
| elif args[0] == 'simulate': |
| manager.simulate=True |
| manager.simulate_sync_db(version) |
| elif args[0] == 'safesync': |
| print 'Simluating migration' |
| manager.simulate=True |
| manager.simulate_sync_db(version) |
| print 'Performing real migration' |
| manager.simulate=False |
| manager.do_sync_db(version) |
| else: |
| print USAGE |
| return |
| |
| print USAGE |
| |
| |
| def get_migration_manager(db_name, debug, force): |
| """Creates a MigrationManager object. |
| |
| @param db_name: The database name. |
| @param debug: Whether to print debug messages. |
| @param force: Whether to force migration without asking for confirmation. |
| |
| @return A created MigrationManager object. |
| |
| """ |
| database = database_connection.DatabaseConnection(db_name) |
| database.debug = debug |
| database.reconnect_enabled = False |
| database.connect() |
| return MigrationManager(database, force=force) |
| |
| |
| if __name__ == '__main__': |
| main() |