1#!/usr/bin/python 2 3# Copyright (c) 2012 The Chromium OS Authors. All rights reserved. 4# Use of this source code is governed by a BSD-style license that can be 5# found in the LICENSE file. 6 7"""Module used to back up the mysql db and upload to Google Storage. 8 9Usage: 10 backup_mysql_db.py --type=weekly --gs_bucket=gs://my_bucket --keep 10 11 12 gs_bucket may refer to a local location by omitting gs:// and giving a local 13 path if desired for testing. The example usage above creates a dump 14 of the autotest db, uploads it to gs://my_bucket/weekly/dump_file.date and 15 cleans up older dumps if there are more than 10 in that directory. 16""" 17 18import datetime 19from distutils import version 20import logging 21import optparse 22import os 23import tempfile 24 25import common 26 27from autotest_lib.client.common_lib import error 28from autotest_lib.client.common_lib import global_config 29from autotest_lib.client.common_lib import logging_manager 30from autotest_lib.client.common_lib import utils 31from autotest_lib.utils import test_importer 32 33from chromite.lib import metrics 34from chromite.lib import ts_mon_config 35 36_ATTEMPTS = 3 37_GSUTIL_BIN = 'gsutil' 38_GS_BUCKET = 'gs://chromeos-lab/backup/database' 39# TODO(scottz): Should we need to ignore more than one database a general 40# function should be designed that lists tables in the database and properly 41# creates the --ignore-table= args to be passed to mysqldump. 42# Tables to ignore when dumping all databases. 43# performance_schema is an internal database that cannot be dumped 44IGNORE_TABLES = ['performance_schema.cond_instances', 45 'performance_schema.events_waits_current', 46 'performance_schema.cond_instances', 47 'performance_schema.events_waits_history', 48 'performance_schema.events_waits_history_long', 49 'performance_schema.events_waits_summary_by_instance', 50 ('performance_schema.' 51 'events_waits_summary_by_thread_by_event_name'), 52 'performance_schema.events_waits_summary_global_by_event_name', 53 'performance_schema.file_instances', 54 'performance_schema.file_summary_by_event_name', 55 'performance_schema.file_summary_by_instance', 56 'performance_schema.mutex_instances', 57 'performance_schema.performance_timers', 58 'performance_schema.rwlock_instances', 59 'performance_schema.setup_consumers', 60 'performance_schema.setup_instruments', 61 'performance_schema.setup_timers', 62 'performance_schema.threads'] 63 64# Conventional mysqldump schedules. 65_DAILY = 'daily' 66_WEEKLY = 'weekly' 67_MONTHLY = 'monthly' 68 69# Back up server db 70_SERVER_DB = 'server_db' 71 72# Contrary to a conventional mysql dump which takes O(hours) on large databases, 73# a host dump is the cheapest form of backup possible. We dump the output of a 74# of a mysql command showing all hosts and their pool labels to a text file that 75# is backed up to google storage. 76_ONLY_HOSTS = 'only_hosts' 77_ONLY_SHARDS = 'only_shards' 78_SCHEDULER_TYPES = [_SERVER_DB, _ONLY_HOSTS, _ONLY_SHARDS, _DAILY, _WEEKLY, _MONTHLY] 79 80class BackupError(Exception): 81 """Raised for error occurred during backup.""" 82 83 84class MySqlArchiver(object): 85 """Class that archives the Autotest MySQL DB to Google Storage. 86 87 Vars: 88 gs_dir: The path to the directory in Google Storage that this dump file 89 will be uploaded to. 90 number_to_keep: The number of dumps we should store. 91 """ 92 _AUTOTEST_DB = "chromeos_autotest_db" 93 _SERVER_DB = "chromeos_lab_servers" 94 95 96 def __init__(self, scheduled_type, number_to_keep, gs_bucket): 97 # For conventional scheduled type, we back up all databases. 98 # self._db is only used when scheduled_type is not 99 # conventional scheduled type. 100 self._db = self._get_db_name(scheduled_type) 101 self._gs_dir = '/'.join([gs_bucket, scheduled_type]) 102 self._number_to_keep = number_to_keep 103 self._type = scheduled_type 104 105 106 @classmethod 107 def _get_db_name(cls, scheduled_type): 108 """Get the db name to backup. 109 110 @param scheduled_type: one of _SCHEDULER_TYPES. 111 112 @returns: The name of the db to backup. 113 Or None for backup all dbs. 114 """ 115 if scheduled_type == _SERVER_DB: 116 return cls._SERVER_DB 117 elif scheduled_type in [_ONLY_HOSTS, _ONLY_SHARDS]: 118 return cls._AUTOTEST_DB 119 else: 120 return None 121 122 @staticmethod 123 def _get_user_pass(): 124 """Returns a tuple containing the user/pass to use to access the DB.""" 125 user = global_config.global_config.get_config_value( 126 'CROS', 'db_backup_user') 127 password = global_config.global_config.get_config_value( 128 'CROS', 'db_backup_password') 129 return user, password 130 131 132 def create_mysql_dump(self): 133 """Returns the path to a mysql dump of the current autotest DB.""" 134 user, password = self._get_user_pass() 135 _, filename = tempfile.mkstemp('autotest_db_dump') 136 logging.debug('Dumping mysql database to file %s', filename) 137 extra_dump_args = '' 138 for entry in IGNORE_TABLES: 139 extra_dump_args += '--ignore-table=%s ' % entry 140 141 if not self._db: 142 extra_dump_args += "--all-databases" 143 db_name = self._db or '' 144 utils.system('set -o pipefail; mysqldump --user=%s ' 145 '--password=%s %s %s| gzip - > %s' % ( 146 user, password, extra_dump_args, db_name, filename)) 147 return filename 148 149 150 def _create_dump_from_query(self, query): 151 """Dumps result of a query into a text file. 152 153 @param query: Query to execute. 154 155 @return: The path to a tempfile containing the response of the query. 156 """ 157 if not self._db: 158 raise BackupError("_create_dump_from_query requires a specific db.") 159 parameters = {'db': self._db, 'query': query} 160 parameters['user'], parameters['password'] = self._get_user_pass() 161 _, parameters['filename'] = tempfile.mkstemp('autotest_db_dump') 162 utils.system( 163 'set -o pipefail; mysql -u %(user)s -p%(password)s ' 164 '%(db)s -e "%(query)s" > %(filename)s' % 165 parameters) 166 return parameters['filename'] 167 168 169 def create_host_dump(self): 170 """Dumps hosts and their labels into a text file. 171 172 @return: The path to a tempfile containing a dump of 173 hosts and their pool labels. 174 """ 175 respect_static_labels = global_config.global_config.get_config_value( 176 'SKYLAB', 'respect_static_labels', type=bool, default=False) 177 template = ('SELECT hosts.hostname, labels.name FROM afe_hosts AS ' 178 'hosts JOIN %(hosts_labels_table)s AS hlt ON ' 179 'hosts.id = hlt.host_id ' 180 'JOIN %(labels_table)s AS labels ' 181 'ON labels.id = hlt.%(column)s ' 182 'WHERE labels.name LIKE \'%%pool%%\';') 183 if respect_static_labels: 184 # HACK: We're not checking the replaced_by_static_label on the 185 # pool label and just hard coding the fact that pool labels are 186 # indeed static labels. Expedience. 187 query = template % { 188 'hosts_labels_table': 'afe_static_hosts_labels', 189 'labels_table': 'afe_static_labels', 190 'column': 'staticlabel_id', 191 } 192 else: 193 query = template % { 194 'hosts_labels_table': 'afe_hosts_labels', 195 'labels_table': 'afe_labels', 196 'column': 'label_id', 197 } 198 return self._create_dump_from_query(query) 199 200 201 def create_shards_dump(self): 202 """Dumps shards and their labels into a text file. 203 204 @return: The path to a tempfile containing a dump of 205 shards and their labels. 206 """ 207 query = ('SELECT hostname, labels.name FROM afe_shards AS shards ' 208 'JOIN afe_shards_labels ' 209 'ON shards.id = afe_shards_labels.shard_id ' 210 'JOIN afe_labels AS labels ' 211 'ON labels.id = afe_shards_labels.label_id;') 212 return self._create_dump_from_query(query) 213 214 215 def dump(self): 216 """Creates a data dump based on the type of schedule. 217 218 @return: The path to a file containing the dump. 219 """ 220 if self._type == _ONLY_HOSTS: 221 return self.create_host_dump() 222 if self._type == _ONLY_SHARDS: 223 return self.create_shards_dump() 224 return self.create_mysql_dump() 225 226 227 def _get_name(self): 228 """Returns the name of the dump as presented to google storage.""" 229 if self._type in [_ONLY_HOSTS, _ONLY_SHARDS]: 230 file_type = 'txt' 231 else: 232 file_type = 'gz' 233 return 'autotest-dump.%s.%s' % ( 234 datetime.datetime.now().strftime('%y.%m.%d'), file_type) 235 236 237 @staticmethod 238 def _retry_run(cmd): 239 """Run the specified |cmd| string, retrying if necessary. 240 241 Args: 242 cmd: The command to run. 243 """ 244 for attempt in range(_ATTEMPTS): 245 try: 246 return utils.system_output(cmd) 247 except error.CmdError: 248 if attempt == _ATTEMPTS - 1: 249 raise 250 else: 251 logging.error('Failed to run %r', cmd) 252 253 254 def upload_to_google_storage(self, dump_file): 255 """Uploads the given |dump_file| to Google Storage. 256 257 @param dump_file: The path to the file containing the dump. 258 """ 259 cmd = '%(gs_util)s cp %(dump_file)s %(gs_dir)s/%(name)s' 260 input_dict = dict(gs_util=_GSUTIL_BIN, dump_file=dump_file, 261 name=self._get_name(), gs_dir=self._gs_dir) 262 cmd = cmd % input_dict 263 logging.debug('Uploading mysql dump to google storage') 264 self._retry_run(cmd) 265 os.remove(dump_file) 266 267 268 def _get_gs_command(self, cmd): 269 """Returns an array representing the command for rm or ls.""" 270 # Helpful code to allow us to test without gs. 271 assert cmd in ['rm', 'ls'] 272 gs_bin = _GSUTIL_BIN 273 if self._gs_dir.startswith('gs://'): 274 cmd_array = [gs_bin, cmd] 275 else: 276 cmd_array = [cmd] 277 278 return cmd_array 279 280 281 def _do_ls(self): 282 """Returns the output of running ls on the gs bucket.""" 283 cmd = self._get_gs_command('ls') + [self._gs_dir] 284 return self._retry_run(' '.join(cmd)) 285 286 287 def cleanup(self): 288 """Cleans up the gs bucket to ensure we don't over archive.""" 289 logging.debug('Cleaning up previously archived dump files.') 290 listing = self._do_ls() 291 ordered_listing = sorted(listing.splitlines(), key=version.LooseVersion) 292 if len(ordered_listing) < self._number_to_keep: 293 logging.debug('Cleanup found nothing to do.') 294 return 295 296 to_remove = ordered_listing[:-self._number_to_keep] 297 rm_cmd = self._get_gs_command('rm') 298 for artifact in to_remove: 299 cmd = ' '.join(rm_cmd + [artifact]) 300 self._retry_run(cmd) 301 302 303def parse_options(): 304 """Parses given options.""" 305 parser = optparse.OptionParser() 306 parser.add_option('--gs_bucket', default=_GS_BUCKET, 307 help='Google storage bucket to store mysql db dumps.') 308 parser.add_option('--keep', default=10, type=int, 309 help='Number of dumps to keep of specified type.') 310 parser.add_option('--type', default=_DAILY, 311 help='The type of mysql dump to store.') 312 parser.add_option('--verbose', default=False, action='store_true', 313 help='Google storage bucket to store mysql db dumps.') 314 options = parser.parse_args()[0] 315 if options.type not in _SCHEDULER_TYPES: 316 parser.error('Type must be either: %s.' % ', '.join(_SCHEDULER_TYPES)) 317 318 return options 319 320 321def main(): 322 """Runs the program.""" 323 options = parse_options() 324 logging_manager.configure_logging(test_importer.TestImporterLoggingConfig(), 325 verbose=options.verbose) 326 backup_succeeded = False 327 328 with ts_mon_config.SetupTsMonGlobalState(service_name='mysql_db_backup', 329 indirect=True): 330 with metrics.SecondsTimer( 331 'chromeos/autotest/afe_db/backup/durations', 332 fields={'type': options.type}): 333 try: 334 logging.debug('Start db backup: %s', options.type) 335 archiver = MySqlArchiver( 336 options.type, options.keep, options.gs_bucket) 337 dump_file = archiver.dump() 338 logging.debug('Uploading backup: %s', options.type) 339 archiver.upload_to_google_storage(dump_file) 340 archiver.cleanup() 341 logging.debug('Db backup completed: %s', options.type) 342 backup_succeeded = True 343 finally: 344 metrics.Counter( 345 'chromeos/autotest/db/db_backup/completed').increment( 346 fields={'success': backup_succeeded, 347 'type': options.type}) 348 349 350if __name__ == '__main__': 351 main() 352