You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
356 lines
13 KiB
356 lines
13 KiB
#!/usr/bin/python2
|
|
|
|
# Copyright (c) 2012 The Chromium OS Authors. All rights reserved.
|
|
# Use of this source code is governed by a BSD-style license that can be
|
|
# found in the LICENSE file.
|
|
|
|
"""Module used to back up the mysql db and upload to Google Storage.
|
|
|
|
Usage:
|
|
backup_mysql_db.py --type=weekly --gs_bucket=gs://my_bucket --keep 10
|
|
|
|
gs_bucket may refer to a local location by omitting gs:// and giving a local
|
|
path if desired for testing. The example usage above creates a dump
|
|
of the autotest db, uploads it to gs://my_bucket/weekly/dump_file.date and
|
|
cleans up older dumps if there are more than 10 in that directory.
|
|
"""
|
|
|
|
from __future__ import absolute_import
|
|
from __future__ import division
|
|
from __future__ import print_function
|
|
|
|
import datetime
|
|
from distutils import version
|
|
import logging
|
|
import optparse
|
|
import os
|
|
import tempfile
|
|
|
|
import common
|
|
|
|
from autotest_lib.client.common_lib import error
|
|
from autotest_lib.client.common_lib import global_config
|
|
from autotest_lib.client.common_lib import utils
|
|
|
|
from chromite.lib import metrics
|
|
from chromite.lib import ts_mon_config
|
|
from six.moves import range
|
|
|
|
_ATTEMPTS = 3
|
|
_GSUTIL_BIN = 'gsutil'
|
|
_GS_BUCKET = 'gs://chromeos-lab/backup/database'
|
|
# TODO(scottz): Should we need to ignore more than one database a general
|
|
# function should be designed that lists tables in the database and properly
|
|
# creates the --ignore-table= args to be passed to mysqldump.
|
|
# Tables to ignore when dumping all databases.
|
|
# performance_schema is an internal database that cannot be dumped
|
|
IGNORE_TABLES = ['performance_schema.cond_instances',
|
|
'performance_schema.events_waits_current',
|
|
'performance_schema.cond_instances',
|
|
'performance_schema.events_waits_history',
|
|
'performance_schema.events_waits_history_long',
|
|
'performance_schema.events_waits_summary_by_instance',
|
|
('performance_schema.'
|
|
'events_waits_summary_by_thread_by_event_name'),
|
|
'performance_schema.events_waits_summary_global_by_event_name',
|
|
'performance_schema.file_instances',
|
|
'performance_schema.file_summary_by_event_name',
|
|
'performance_schema.file_summary_by_instance',
|
|
'performance_schema.mutex_instances',
|
|
'performance_schema.performance_timers',
|
|
'performance_schema.rwlock_instances',
|
|
'performance_schema.setup_consumers',
|
|
'performance_schema.setup_instruments',
|
|
'performance_schema.setup_timers',
|
|
'performance_schema.threads']
|
|
|
|
# Conventional mysqldump schedules.
|
|
_DAILY = 'daily'
|
|
_WEEKLY = 'weekly'
|
|
_MONTHLY = 'monthly'
|
|
|
|
# Dump of server db only
|
|
_SERVER_DB = 'server_db'
|
|
|
|
# Contrary to a conventional mysql dump which takes O(hours) on large databases,
|
|
# a host dump is the cheapest form of backup possible. We dump the output of a
|
|
# of a mysql command showing all hosts and their pool labels to a text file that
|
|
# is backed up to google storage.
|
|
_ONLY_HOSTS = 'only_hosts'
|
|
_ONLY_SHARDS = 'only_shards'
|
|
_SCHEDULER_TYPES = [_SERVER_DB, _ONLY_HOSTS, _ONLY_SHARDS,
|
|
_DAILY, _WEEKLY, _MONTHLY]
|
|
|
|
class BackupError(Exception):
|
|
"""Raised for error occurred during backup."""
|
|
|
|
|
|
class MySqlArchiver(object):
|
|
"""Class that archives the Autotest MySQL DB to Google Storage.
|
|
|
|
Vars:
|
|
gs_dir: The path to the directory in Google Storage that this dump file
|
|
will be uploaded to.
|
|
number_to_keep: The number of dumps we should store.
|
|
"""
|
|
_AUTOTEST_DB = "chromeos_autotest_db"
|
|
_SERVER_DB = "chromeos_lab_servers"
|
|
|
|
|
|
def __init__(self, scheduled_type, number_to_keep, gs_bucket):
|
|
# For conventional scheduled type, we back up all databases.
|
|
# self._db is only used when scheduled_type is not
|
|
# conventional scheduled type.
|
|
self._db = self._get_db_name(scheduled_type)
|
|
self._gs_dir = '/'.join([gs_bucket, scheduled_type])
|
|
self._number_to_keep = number_to_keep
|
|
self._type = scheduled_type
|
|
|
|
|
|
@classmethod
|
|
def _get_db_name(cls, scheduled_type):
|
|
"""Get the db name to backup.
|
|
|
|
@param scheduled_type: one of _SCHEDULER_TYPES.
|
|
|
|
@returns: The name of the db to backup.
|
|
Or None for backup all dbs.
|
|
"""
|
|
if scheduled_type == _SERVER_DB:
|
|
return cls._SERVER_DB
|
|
elif scheduled_type in [_ONLY_HOSTS, _ONLY_SHARDS]:
|
|
return cls._AUTOTEST_DB
|
|
else:
|
|
return None
|
|
|
|
@staticmethod
|
|
def _get_user_pass():
|
|
"""Returns a tuple containing the user/pass to use to access the DB."""
|
|
user = global_config.global_config.get_config_value(
|
|
'CROS', 'db_backup_user')
|
|
password = global_config.global_config.get_config_value(
|
|
'CROS', 'db_backup_password')
|
|
return user, password
|
|
|
|
|
|
def create_mysql_dump(self):
|
|
"""Returns the path to a mysql dump of the current autotest DB."""
|
|
user, password = self._get_user_pass()
|
|
_, filename = tempfile.mkstemp('autotest_db_dump')
|
|
logging.debug('Dumping mysql database to file %s', filename)
|
|
extra_dump_args = ''
|
|
for entry in IGNORE_TABLES:
|
|
extra_dump_args += '--ignore-table=%s ' % entry
|
|
if self._type in [_WEEKLY, _MONTHLY]:
|
|
extra_dump_args += '--dump-slave '
|
|
|
|
if not self._db:
|
|
extra_dump_args += "--all-databases"
|
|
db_name = self._db or ''
|
|
utils.system('set -o pipefail; mysqldump --user=%s '
|
|
'--password=%s %s %s| gzip - > %s' % (
|
|
user, password, extra_dump_args, db_name, filename))
|
|
return filename
|
|
|
|
|
|
def _create_dump_from_query(self, query):
|
|
"""Dumps result of a query into a text file.
|
|
|
|
@param query: Query to execute.
|
|
|
|
@return: The path to a tempfile containing the response of the query.
|
|
"""
|
|
if not self._db:
|
|
raise BackupError("_create_dump_from_query requires a specific db.")
|
|
parameters = {'db': self._db, 'query': query}
|
|
parameters['user'], parameters['password'] = self._get_user_pass()
|
|
_, parameters['filename'] = tempfile.mkstemp('autotest_db_dump')
|
|
utils.system(
|
|
'set -o pipefail; mysql -u %(user)s -p%(password)s '
|
|
'%(db)s -e "%(query)s" > %(filename)s' %
|
|
parameters)
|
|
return parameters['filename']
|
|
|
|
|
|
def create_host_dump(self):
|
|
"""Dumps hosts and their labels into a text file.
|
|
|
|
@return: The path to a tempfile containing a dump of
|
|
hosts and their pool labels.
|
|
"""
|
|
respect_static_labels = global_config.global_config.get_config_value(
|
|
'SKYLAB', 'respect_static_labels', type=bool, default=False)
|
|
template = ('SELECT hosts.hostname, labels.name FROM afe_hosts AS '
|
|
'hosts JOIN %(hosts_labels_table)s AS hlt ON '
|
|
'hosts.id = hlt.host_id '
|
|
'JOIN %(labels_table)s AS labels '
|
|
'ON labels.id = hlt.%(column)s '
|
|
'WHERE labels.name LIKE \'%%pool%%\';')
|
|
if respect_static_labels:
|
|
# HACK: We're not checking the replaced_by_static_label on the
|
|
# pool label and just hard coding the fact that pool labels are
|
|
# indeed static labels. Expedience.
|
|
query = template % {
|
|
'hosts_labels_table': 'afe_static_hosts_labels',
|
|
'labels_table': 'afe_static_labels',
|
|
'column': 'staticlabel_id',
|
|
}
|
|
else:
|
|
query = template % {
|
|
'hosts_labels_table': 'afe_hosts_labels',
|
|
'labels_table': 'afe_labels',
|
|
'column': 'label_id',
|
|
}
|
|
return self._create_dump_from_query(query)
|
|
|
|
|
|
def create_shards_dump(self):
|
|
"""Dumps shards and their labels into a text file.
|
|
|
|
@return: The path to a tempfile containing a dump of
|
|
shards and their labels.
|
|
"""
|
|
query = ('SELECT hostname, labels.name FROM afe_shards AS shards '
|
|
'JOIN afe_shards_labels '
|
|
'ON shards.id = afe_shards_labels.shard_id '
|
|
'JOIN afe_labels AS labels '
|
|
'ON labels.id = afe_shards_labels.label_id;')
|
|
return self._create_dump_from_query(query)
|
|
|
|
|
|
def dump(self):
|
|
"""Creates a data dump based on the type of schedule.
|
|
|
|
@return: The path to a file containing the dump.
|
|
"""
|
|
if self._type == _ONLY_HOSTS:
|
|
return self.create_host_dump()
|
|
if self._type == _ONLY_SHARDS:
|
|
return self.create_shards_dump()
|
|
return self.create_mysql_dump()
|
|
|
|
|
|
def _get_name(self):
|
|
"""Returns the name of the dump as presented to google storage."""
|
|
if self._type in [_ONLY_HOSTS, _ONLY_SHARDS]:
|
|
file_type = 'txt'
|
|
else:
|
|
file_type = 'gz'
|
|
return 'autotest-dump.%s.%s' % (
|
|
datetime.datetime.now().strftime('%y.%m.%d'), file_type)
|
|
|
|
|
|
@staticmethod
|
|
def _retry_run(cmd):
|
|
"""Run the specified |cmd| string, retrying if necessary.
|
|
|
|
Args:
|
|
cmd: The command to run.
|
|
"""
|
|
for attempt in range(_ATTEMPTS):
|
|
try:
|
|
return utils.system_output(cmd)
|
|
except error.CmdError:
|
|
if attempt == _ATTEMPTS - 1:
|
|
raise
|
|
else:
|
|
logging.error('Failed to run %r', cmd)
|
|
|
|
|
|
def upload_to_google_storage(self, dump_file):
|
|
"""Uploads the given |dump_file| to Google Storage.
|
|
|
|
@param dump_file: The path to the file containing the dump.
|
|
"""
|
|
cmd = '%(gs_util)s cp %(dump_file)s %(gs_dir)s/%(name)s'
|
|
input_dict = dict(gs_util=_GSUTIL_BIN, dump_file=dump_file,
|
|
name=self._get_name(), gs_dir=self._gs_dir)
|
|
cmd = cmd % input_dict
|
|
logging.debug('Uploading mysql dump to google storage')
|
|
self._retry_run(cmd)
|
|
os.remove(dump_file)
|
|
|
|
|
|
def _get_gs_command(self, cmd):
|
|
"""Returns an array representing the command for rm or ls."""
|
|
# Helpful code to allow us to test without gs.
|
|
assert cmd in ['rm', 'ls']
|
|
gs_bin = _GSUTIL_BIN
|
|
if self._gs_dir.startswith('gs://'):
|
|
cmd_array = [gs_bin, cmd]
|
|
else:
|
|
cmd_array = [cmd]
|
|
|
|
return cmd_array
|
|
|
|
|
|
def _do_ls(self):
|
|
"""Returns the output of running ls on the gs bucket."""
|
|
cmd = self._get_gs_command('ls') + [self._gs_dir]
|
|
return self._retry_run(' '.join(cmd))
|
|
|
|
|
|
def cleanup(self):
|
|
"""Cleans up the gs bucket to ensure we don't over archive."""
|
|
logging.debug('Cleaning up previously archived dump files.')
|
|
listing = self._do_ls()
|
|
ordered_listing = sorted(listing.splitlines(), key=version.LooseVersion)
|
|
if len(ordered_listing) < self._number_to_keep:
|
|
logging.debug('Cleanup found nothing to do.')
|
|
return
|
|
|
|
to_remove = ordered_listing[:-self._number_to_keep]
|
|
rm_cmd = self._get_gs_command('rm')
|
|
for artifact in to_remove:
|
|
cmd = ' '.join(rm_cmd + [artifact])
|
|
self._retry_run(cmd)
|
|
|
|
|
|
def parse_options():
|
|
"""Parses given options."""
|
|
parser = optparse.OptionParser()
|
|
parser.add_option('--gs_bucket', default=_GS_BUCKET,
|
|
help='Google storage bucket to store mysql db dumps.')
|
|
parser.add_option('--keep', default=10, type=int,
|
|
help='Number of dumps to keep of specified type.')
|
|
parser.add_option('--type', default=_DAILY,
|
|
help='The type of mysql dump to store.')
|
|
parser.add_option('--verbose', default=False, action='store_true',
|
|
help='Google storage bucket to store mysql db dumps.')
|
|
options = parser.parse_args()[0]
|
|
if options.type not in _SCHEDULER_TYPES:
|
|
parser.error('Type must be either: %s.' % ', '.join(_SCHEDULER_TYPES))
|
|
|
|
return options
|
|
|
|
|
|
def main():
|
|
"""Runs the program."""
|
|
options = parse_options()
|
|
backup_succeeded = False
|
|
|
|
with ts_mon_config.SetupTsMonGlobalState(service_name='mysql_db_backup',
|
|
indirect=True):
|
|
with metrics.SecondsTimer(
|
|
'chromeos/autotest/afe_db/backup/durations',
|
|
fields={'type': options.type}):
|
|
try:
|
|
logging.debug('Start db backup: %s', options.type)
|
|
archiver = MySqlArchiver(
|
|
options.type, options.keep, options.gs_bucket)
|
|
dump_file = archiver.dump()
|
|
logging.debug('Uploading backup: %s', options.type)
|
|
archiver.upload_to_google_storage(dump_file)
|
|
archiver.cleanup()
|
|
logging.debug('Db backup completed: %s', options.type)
|
|
backup_succeeded = True
|
|
finally:
|
|
metrics.Counter(
|
|
'chromeos/autotest/db/db_backup/completed').increment(
|
|
fields={'success': backup_succeeded,
|
|
'type': options.type})
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|