1#!/usr/bin/python2 -u 2 3import os, sys, re, tempfile 4from optparse import OptionParser 5import common 6from autotest_lib.client.common_lib import utils 7from autotest_lib.database import database_connection 8 9MIGRATE_TABLE = 'migrate_info' 10 11_AUTODIR = os.path.join(os.path.dirname(__file__), '..') 12_MIGRATIONS_DIRS = { 13 'AUTOTEST_WEB': os.path.join(_AUTODIR, 'frontend', 'migrations'), 14 'TKO': os.path.join(_AUTODIR, 'tko', 'migrations'), 15 'AUTOTEST_SERVER_DB': os.path.join(_AUTODIR, 'database', 16 'server_db_migrations'), 17} 18_DEFAULT_MIGRATIONS_DIR = 'migrations' # use CWD 19 20class Migration(object): 21 """Represents a database migration.""" 22 _UP_ATTRIBUTES = ('migrate_up', 'UP_SQL') 23 _DOWN_ATTRIBUTES = ('migrate_down', 'DOWN_SQL') 24 25 def __init__(self, name, version, module): 26 self.name = name 27 self.version = version 28 self.module = module 29 self._check_attributes(self._UP_ATTRIBUTES) 30 self._check_attributes(self._DOWN_ATTRIBUTES) 31 32 33 @classmethod 34 def from_file(cls, filename): 35 """Instantiates a Migration from a file. 36 37 @param filename: Name of a migration file. 38 39 @return An instantiated Migration object. 40 41 """ 42 version = int(filename[:3]) 43 name = filename[:-3] 44 module = __import__(name, globals(), locals(), []) 45 return cls(name, version, module) 46 47 48 def _check_attributes(self, attributes): 49 method_name, sql_name = attributes 50 assert (hasattr(self.module, method_name) or 51 hasattr(self.module, sql_name)) 52 53 54 def _execute_migration(self, attributes, manager): 55 method_name, sql_name = attributes 56 method = getattr(self.module, method_name, None) 57 if method: 58 assert callable(method) 59 method(manager) 60 else: 61 sql = getattr(self.module, sql_name) 62 assert isinstance(sql, basestring) 63 manager.execute_script(sql) 64 65 66 def migrate_up(self, manager): 67 """Performs an up migration (to a newer version). 68 69 @param manager: A MigrationManager object. 70 71 """ 72 self._execute_migration(self._UP_ATTRIBUTES, manager) 73 74 75 def migrate_down(self, manager): 76 """Performs a down migration (to an older version). 77 78 @param manager: A MigrationManager object. 79 80 """ 81 self._execute_migration(self._DOWN_ATTRIBUTES, manager) 82 83 84class MigrationManager(object): 85 """Managest database migrations.""" 86 connection = None 87 cursor = None 88 migrations_dir = None 89 90 def __init__(self, database_connection, migrations_dir=None, force=False): 91 self._database = database_connection 92 self.force = force 93 # A boolean, this will only be set to True if this migration should be 94 # simulated rather than actually taken. For use with migrations that 95 # may make destructive queries 96 self.simulate = False 97 self._set_migrations_dir(migrations_dir) 98 99 100 def _set_migrations_dir(self, migrations_dir=None): 101 config_section = self._config_section() 102 if migrations_dir is None: 103 migrations_dir = os.path.abspath( 104 _MIGRATIONS_DIRS.get(config_section, _DEFAULT_MIGRATIONS_DIR)) 105 self.migrations_dir = migrations_dir 106 sys.path.append(migrations_dir) 107 assert os.path.exists(migrations_dir), migrations_dir + " doesn't exist" 108 109 110 def _config_section(self): 111 return self._database.global_config_section 112 113 114 def get_db_name(self): 115 """Gets the database name.""" 116 return self._database.get_database_info()['db_name'] 117 118 119 def execute(self, query, *parameters): 120 """Executes a database query. 121 122 @param query: The query to execute. 123 @param parameters: Associated parameters for the query. 124 125 @return The result of the query. 126 127 """ 128 return self._database.execute(query, parameters) 129 130 131 def execute_script(self, script): 132 """Executes a set of database queries. 133 134 @param script: A string of semicolon-separated queries. 135 136 """ 137 sql_statements = [statement.strip() 138 for statement in script.split(';') 139 if statement.strip()] 140 for statement in sql_statements: 141 self.execute(statement) 142 143 144 def check_migrate_table_exists(self): 145 """Checks whether the migration table exists.""" 146 try: 147 self.execute("SELECT * FROM %s" % MIGRATE_TABLE) 148 return True 149 except self._database.DatabaseError, exc: 150 # we can't check for more specifics due to differences between DB 151 # backends (we can't even check for a subclass of DatabaseError) 152 return False 153 154 155 def create_migrate_table(self): 156 """Creates the migration table.""" 157 if not self.check_migrate_table_exists(): 158 self.execute("CREATE TABLE %s (`version` integer)" % 159 MIGRATE_TABLE) 160 else: 161 self.execute("DELETE FROM %s" % MIGRATE_TABLE) 162 self.execute("INSERT INTO %s VALUES (0)" % MIGRATE_TABLE) 163 assert self._database.rowcount == 1 164 165 166 def set_db_version(self, version): 167 """Sets the database version. 168 169 @param version: The version to which to set the database. 170 171 """ 172 assert isinstance(version, int) 173 self.execute("UPDATE %s SET version=%%s" % MIGRATE_TABLE, 174 version) 175 assert self._database.rowcount == 1 176 177 178 def get_db_version(self): 179 """Gets the database version. 180 181 @return The database version. 182 183 """ 184 if not self.check_migrate_table_exists(): 185 return 0 186 rows = self.execute("SELECT * FROM %s" % MIGRATE_TABLE) 187 if len(rows) == 0: 188 return 0 189 assert len(rows) == 1 and len(rows[0]) == 1 190 return rows[0][0] 191 192 193 def get_migrations(self, minimum_version=None, maximum_version=None): 194 """Gets the list of migrations to perform. 195 196 @param minimum_version: The minimum database version. 197 @param maximum_version: The maximum database version. 198 199 @return A list of Migration objects. 200 201 """ 202 migrate_files = [filename for filename 203 in os.listdir(self.migrations_dir) 204 if re.match(r'^\d\d\d_.*\.py$', filename)] 205 migrate_files.sort() 206 migrations = [Migration.from_file(filename) 207 for filename in migrate_files] 208 if minimum_version is not None: 209 migrations = [migration for migration in migrations 210 if migration.version >= minimum_version] 211 if maximum_version is not None: 212 migrations = [migration for migration in migrations 213 if migration.version <= maximum_version] 214 return migrations 215 216 217 def do_migration(self, migration, migrate_up=True): 218 """Performs a migration. 219 220 @param migration: The Migration to perform. 221 @param migrate_up: Whether to migrate up (if not, then migrates down). 222 223 """ 224 print 'Applying migration %s' % migration.name, # no newline 225 if migrate_up: 226 print 'up' 227 assert self.get_db_version() == migration.version - 1 228 migration.migrate_up(self) 229 new_version = migration.version 230 else: 231 print 'down' 232 assert self.get_db_version() == migration.version 233 migration.migrate_down(self) 234 new_version = migration.version - 1 235 self.set_db_version(new_version) 236 237 238 def migrate_to_version(self, version): 239 """Performs a migration to a specified version. 240 241 @param version: The version to which to migrate the database. 242 243 """ 244 current_version = self.get_db_version() 245 if current_version == 0 and self._config_section() == 'AUTOTEST_WEB': 246 self._migrate_from_base() 247 current_version = self.get_db_version() 248 249 if current_version < version: 250 lower, upper = current_version, version 251 migrate_up = True 252 else: 253 lower, upper = version, current_version 254 migrate_up = False 255 256 migrations = self.get_migrations(lower + 1, upper) 257 if not migrate_up: 258 migrations.reverse() 259 for migration in migrations: 260 self.do_migration(migration, migrate_up) 261 262 assert self.get_db_version() == version 263 print 'At version', version 264 265 266 def _migrate_from_base(self): 267 """Initialize the AFE database. 268 """ 269 self.confirm_initialization() 270 271 migration_script = utils.read_file( 272 os.path.join(os.path.dirname(__file__), 'schema_129.sql')) 273 migration_script = migration_script % ( 274 dict(username=self._database.get_database_info()['username'])) 275 self.execute_script(migration_script) 276 277 self.create_migrate_table() 278 self.set_db_version(129) 279 280 281 def confirm_initialization(self): 282 """Confirms with the user that we should initialize the database. 283 284 @raises Exception, if the user chooses to abort the migration. 285 286 """ 287 if not self.force: 288 response = raw_input( 289 'Your %s database does not appear to be initialized. Do you ' 290 'want to recreate it (this will result in loss of any existing ' 291 'data) (yes/No)? ' % self.get_db_name()) 292 if response != 'yes': 293 raise Exception('User has chosen to abort migration') 294 295 296 def get_latest_version(self): 297 """Gets the latest database version.""" 298 migrations = self.get_migrations() 299 return migrations[-1].version 300 301 302 def migrate_to_latest(self): 303 """Migrates the database to the latest version.""" 304 latest_version = self.get_latest_version() 305 self.migrate_to_version(latest_version) 306 307 308 def initialize_test_db(self): 309 """Initializes a test database.""" 310 db_name = self.get_db_name() 311 test_db_name = 'test_' + db_name 312 # first, connect to no DB so we can create a test DB 313 self._database.connect(db_name='') 314 print 'Creating test DB', test_db_name 315 self.execute('CREATE DATABASE ' + test_db_name) 316 self._database.disconnect() 317 # now connect to the test DB 318 self._database.connect(db_name=test_db_name) 319 320 321 def remove_test_db(self): 322 """Removes a test database.""" 323 print 'Removing test DB' 324 self.execute('DROP DATABASE ' + self.get_db_name()) 325 # reset connection back to real DB 326 self._database.disconnect() 327 self._database.connect() 328 329 330 def get_mysql_args(self): 331 """Returns the mysql arguments as a string.""" 332 return ('-u %(username)s -p%(password)s -h %(host)s %(db_name)s' % 333 self._database.get_database_info()) 334 335 336 def migrate_to_version_or_latest(self, version): 337 """Migrates to either a specified version, or the latest version. 338 339 @param version: The version to which to migrate the database, 340 or None in order to migrate to the latest version. 341 342 """ 343 if version is None: 344 self.migrate_to_latest() 345 else: 346 self.migrate_to_version(version) 347 348 349 def do_sync_db(self, version=None): 350 """Migrates the database. 351 352 @param version: The version to which to migrate the database. 353 354 """ 355 print 'Migration starting for database', self.get_db_name() 356 self.migrate_to_version_or_latest(version) 357 print 'Migration complete' 358 359 360 def test_sync_db(self, version=None): 361 """Create a fresh database and run all migrations on it. 362 363 @param version: The version to which to migrate the database. 364 365 """ 366 self.initialize_test_db() 367 try: 368 print 'Starting migration test on DB', self.get_db_name() 369 self.migrate_to_version_or_latest(version) 370 # show schema to the user 371 os.system('mysqldump %s --no-data=true ' 372 '--add-drop-table=false' % 373 self.get_mysql_args()) 374 finally: 375 self.remove_test_db() 376 print 'Test finished successfully' 377 378 379 def simulate_sync_db(self, version=None): 380 """Creates a fresh DB, copies existing DB to it, then synchronizes it. 381 382 @param version: The version to which to migrate the database. 383 384 """ 385 db_version = self.get_db_version() 386 # don't do anything if we're already at the latest version 387 if db_version == self.get_latest_version(): 388 print 'Skipping simulation, already at latest version' 389 return 390 # get existing data 391 self.initialize_and_fill_test_db() 392 try: 393 print 'Starting migration test on DB', self.get_db_name() 394 self.migrate_to_version_or_latest(version) 395 finally: 396 self.remove_test_db() 397 print 'Test finished successfully' 398 399 400 def initialize_and_fill_test_db(self): 401 """Initializes and fills up a test database.""" 402 print 'Dumping existing data' 403 dump_fd, dump_file = tempfile.mkstemp('.migrate_dump') 404 os.system('mysqldump %s >%s' % 405 (self.get_mysql_args(), dump_file)) 406 # fill in test DB 407 self.initialize_test_db() 408 print 'Filling in test DB' 409 os.system('mysql %s <%s' % (self.get_mysql_args(), dump_file)) 410 os.close(dump_fd) 411 os.remove(dump_file) 412 413 414USAGE = """\ 415%s [options] sync|test|simulate|safesync [version] 416Options: 417 -d --database Which database to act on 418 -f --force Don't ask for confirmation 419 --debug Print all DB queries"""\ 420 % sys.argv[0] 421 422 423def main(): 424 """Main function for the migration script.""" 425 parser = OptionParser() 426 parser.add_option("-d", "--database", 427 help="which database to act on", 428 dest="database", 429 default="AUTOTEST_WEB") 430 parser.add_option("-f", "--force", help="don't ask for confirmation", 431 action="store_true") 432 parser.add_option('--debug', help='print all DB queries', 433 action='store_true') 434 (options, args) = parser.parse_args() 435 manager = get_migration_manager(db_name=options.database, 436 debug=options.debug, force=options.force) 437 438 if len(args) > 0: 439 if len(args) > 1: 440 version = int(args[1]) 441 else: 442 version = None 443 if args[0] == 'sync': 444 manager.do_sync_db(version) 445 elif args[0] == 'test': 446 manager.simulate=True 447 manager.test_sync_db(version) 448 elif args[0] == 'simulate': 449 manager.simulate=True 450 manager.simulate_sync_db(version) 451 elif args[0] == 'safesync': 452 print 'Simluating migration' 453 manager.simulate=True 454 manager.simulate_sync_db(version) 455 print 'Performing real migration' 456 manager.simulate=False 457 manager.do_sync_db(version) 458 else: 459 print USAGE 460 return 461 462 print USAGE 463 464 465def get_migration_manager(db_name, debug, force): 466 """Creates a MigrationManager object. 467 468 @param db_name: The database name. 469 @param debug: Whether to print debug messages. 470 @param force: Whether to force migration without asking for confirmation. 471 472 @return A created MigrationManager object. 473 474 """ 475 database = database_connection.DatabaseConnection(db_name) 476 database.debug = debug 477 database.reconnect_enabled = False 478 database.connect() 479 return MigrationManager(database, force=force) 480 481 482if __name__ == '__main__': 483 main() 484