Home | History | Annotate | Download | only in site_utils
      1 #!/usr/bin/python
      2 #
      3 # Copyright (c) 2015 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 
      8 """Tool for cleaning up labels that are not in use.
      9 
     10 Delete given labels from database when they are not in use.
     11 Labels that match the query `SELECT_USED_LABELS_FORMAT` are considered in use.
     12 When given labels are not in the used labels, those labels are deleted.
     13 
     14 For example, following command deletes all labels whose name begins with
     15 'cros-version' and are not in use.
     16 
     17 ./label_cleaner.py -p cros-version
     18 
     19 If '-p' option is not given, we delete labels whose name is exactly
     20 'cros-version' and are not in use.
     21 """
     22 
     23 
     24 import argparse
     25 import logging
     26 import os
     27 import socket
     28 import sys
     29 import tempfile
     30 
     31 import common
     32 # Installed via build_externals, must be after import common.
     33 import MySQLdb
     34 from autotest_lib.client.common_lib import global_config
     35 from autotest_lib.client.common_lib import logging_config
     36 from autotest_lib.server import frontend
     37 from chromite.lib import metrics
     38 from chromite.lib import ts_mon_config
     39 
     40 
     41 _METRICS_PREFIX = 'chromeos/autotest/afe_db/admin/label_cleaner'
     42 
     43 GLOBAL_AFE = global_config.global_config.get_config_value(
     44         'SERVER', 'global_afe_hostname')
     45 DB_SERVER = global_config.global_config.get_config_value('AUTOTEST_WEB', 'host')
     46 USER = global_config.global_config.get_config_value('AUTOTEST_WEB', 'user')
     47 PASSWD = global_config.global_config.get_config_value(
     48         'AUTOTEST_WEB', 'password')
     49 DATABASE = global_config.global_config.get_config_value(
     50         'AUTOTEST_WEB', 'database')
     51 RESPECT_STATIC_LABELS = global_config.global_config.get_config_value(
     52         'SKYLAB', 'respect_static_labels', type=bool, default=False)
     53 
     54 # Per-prefix metrics are generated only for the following prefixes. This
     55 # whitelist is a second level defence against populating the 'label_prefix'
     56 # field with arbitrary values provided on the commandline.
     57 _LABEL_PREFIX_METRICS_WHITELIST = (
     58         'cros-version',
     59         'fwro-version',
     60         'fwrw-version',
     61         'pool',
     62 )
     63 
     64 SELECT_USED_LABELS_FORMAT = """
     65 SELECT DISTINCT(label_id) FROM afe_autotests_dependency_labels UNION
     66 SELECT DISTINCT(label_id) FROM afe_hosts_labels UNION
     67 SELECT DISTINCT(label_id) FROM afe_jobs_dependency_labels UNION
     68 SELECT DISTINCT(label_id) FROM afe_shards_labels UNION
     69 SELECT DISTINCT(label_id) FROM afe_parameterized_jobs UNION
     70 SELECT DISTINCT(meta_host) FROM afe_host_queue_entries
     71 """
     72 
     73 SELECT_REPLACED_LABELS = """
     74 SELECT label_id FROM afe_replaced_labels
     75 """
     76 
     77 DELETE_LABELS_FORMAT = """
     78 DELETE FROM afe_labels WHERE id in (%s)
     79 """
     80 
     81 
     82 def get_used_labels(conn):
     83     """Get labels that are currently in use.
     84 
     85     @param conn: MySQLdb Connection object.
     86 
     87     @return: A list of label ids.
     88     """
     89     cursor = conn.cursor()
     90     sql = SELECT_USED_LABELS_FORMAT
     91     logging.debug('Running: %r', sql)
     92     cursor.execute(sql)
     93     rows = cursor.fetchall()
     94     return set(r[0] for r in rows)
     95 
     96 
     97 def fetch_labels(conn, label=None, prefix=False):
     98     """Fetch labels from database.
     99 
    100     @param conn: MySQLdb Connection object.
    101     @param label: (optional) Label name to fetch.
    102     @param prefix: If True, use `label` as a prefix. Otherwise, fetch
    103                    labels whose name is exactly same as `label`.
    104 
    105     @return: A list of label ids.
    106     """
    107     cursor = conn.cursor()
    108     if label is not None:
    109         if prefix:
    110             sql = 'SELECT id FROM afe_labels WHERE name LIKE "%s%%"' % label
    111         else:
    112             sql = 'SELECT id FROM afe_labels WHERE name = "%s"' % label
    113     else:
    114         sql = 'SELECT id FROM afe_labels'
    115     logging.debug('Running: %r', sql)
    116     cursor.execute(sql)
    117     rows = cursor.fetchall()
    118     # Don't delete labels whose replaced_by_static_label=True, since they're
    119     # actually maintained by afe_static_labels, not afe_labels.
    120     if not RESPECT_STATIC_LABELS:
    121         return set(r[0] for r in rows)
    122     else:
    123         cursor.execute(SELECT_REPLACED_LABELS)
    124         replaced_labels = cursor.fetchall()
    125         replaced_label_ids = set([r[0] for r in replaced_labels])
    126         return set(r[0] for r in rows) - replaced_label_ids
    127 
    128 
    129 def _delete_labels(conn, labels, dry_run):
    130     """Helper function of `delete_labels`."""
    131     labels_str = ','.join([str(l) for l in labels])
    132     sql = DELETE_LABELS_FORMAT % labels_str
    133     if dry_run:
    134         logging.info('[DRY RUN] Would have run: %r', sql)
    135     else:
    136         logging.debug('Running: %r', sql)
    137         conn.cursor().execute(sql)
    138         conn.commit()
    139 
    140 
    141 def delete_labels(conn, labels, max_delete, dry_run=False):
    142     """Delete given labels from database.
    143 
    144     @param conn: MySQLdb Connection object.
    145     @param labels: iterable of labels to delete.
    146     @param max_delete: Max number of records to delete in a query.
    147     @param dry_run: (Boolean) Whether this is a dry run.
    148     """
    149     while labels:
    150         chunk = labels[:max_delete]
    151         labels = labels[max_delete:]
    152         _delete_labels(conn, chunk, dry_run)
    153 
    154 
    155 def is_primary_server():
    156     """Check if this server's status is primary
    157 
    158     @return: True if primary, False otherwise.
    159     """
    160     server = frontend.AFE(server=GLOBAL_AFE).run(
    161             'get_servers', hostname=socket.getfqdn())
    162     if server and server[0]['status'] == 'primary':
    163         return True
    164     return False
    165 
    166 
    167 def clean_labels(options):
    168     """Cleans unused labels from AFE database"""
    169     msg = 'Label cleaner starts. Will delete '
    170     if options.prefix:
    171         msg += 'all labels whose prefix is "%s".'
    172     else:
    173         msg += 'a label "%s".'
    174     logging.info(msg, options.label)
    175     logging.info('Target database: %s.', options.db_server)
    176     if options.check_status and not is_primary_server():
    177         raise Exception('Cannot run in a non-primary server')
    178 
    179     conn = MySQLdb.connect(
    180             host=options.db_server,
    181             user=options.db_user,
    182             passwd=options.db_password,
    183             db=DATABASE,
    184     )
    185 
    186     all_labels = fetch_labels(conn)
    187     logging.info('Found total %d labels', len(all_labels))
    188     metrics.Gauge(_METRICS_PREFIX + '/total_labels_count').set(
    189             len(all_labels),
    190             fields={
    191                     'target_db': options.db_server,
    192                     'label_prefix': '',
    193             },
    194     )
    195 
    196     labels = fetch_labels(conn, options.label, options.prefix)
    197     logging.info('Found total %d labels matching %s', len(labels),
    198                  options.label)
    199     if options.prefix and options.label in _LABEL_PREFIX_METRICS_WHITELIST:
    200         metrics.Gauge(_METRICS_PREFIX + '/total_labels_count').set(
    201                 len(labels),
    202                 fields={
    203                         'target_db': options.db_server,
    204                         'label_prefix': options.label,
    205                 },
    206         )
    207 
    208     used_labels = get_used_labels(conn)
    209     logging.info('Found %d labels are used', len(used_labels))
    210     metrics.Gauge(_METRICS_PREFIX + '/used_labels_count').set(
    211             len(used_labels), fields={'target_db': options.db_server})
    212 
    213     to_delete = list(labels - used_labels)
    214     logging.info('Deleting %d unused labels', len(to_delete))
    215     delete_labels(conn, to_delete, options.max_delete, options.dry_run)
    216     metrics.Counter(_METRICS_PREFIX + '/labels_deleted').increment_by(
    217             len(to_delete), fields={'target_db': options.db_server})
    218 
    219 
    220 def main():
    221     """Cleans unused labels from AFE database"""
    222     parser = argparse.ArgumentParser(
    223             formatter_class=argparse.ArgumentDefaultsHelpFormatter)
    224     parser.add_argument(
    225             '--db',
    226             dest='db_server',
    227             help='Database server',
    228             default=DB_SERVER,
    229     )
    230     parser.add_argument(
    231             '--db-user',
    232             dest='db_user',
    233             help='Database user',
    234             default=USER,
    235     )
    236     parser.add_argument(
    237             '--db-password',
    238             dest='db_password',
    239             help='Database password',
    240             default=PASSWD,
    241     )
    242     parser.add_argument(
    243             '-p',
    244             dest='prefix',
    245             action='store_true',
    246             help=('Use argument <label> as a prefix for matching. '
    247                   'For example, when the argument <label> is "cros-version" '
    248                   'and this option is enabled, then labels whose name '
    249                   'beginning with "cros-version" are matched. When this '
    250                   'option is disabled, we match labels whose name is '
    251                   'exactly same as the argument <label>.'),
    252     )
    253     parser.add_argument(
    254             '-n',
    255             dest='max_delete',
    256             type=int,
    257             help='Max number of records to delete in each query.',
    258             default=100,
    259     )
    260     parser.add_argument(
    261             '-s',
    262             dest='check_status',
    263             action='store_true',
    264             help='Enforce to run only in a server that has primary status',
    265     )
    266     parser.add_argument(
    267             '--dry-run',
    268             dest='dry_run',
    269             action='store_true',
    270             help='Dry run mode. Do not actually delete any labels.',
    271     )
    272     parser.add_argument('label', help='Label name to delete')
    273     options = parser.parse_args()
    274 
    275     logging_config.LoggingConfig().configure_logging(
    276             datefmt='%Y-%m-%d %H:%M:%S',
    277             verbose=True)
    278 
    279     if options.dry_run:
    280         tfd, metrics_file=tempfile.mkstemp()
    281         os.close(tfd)
    282         ts_mon_context = ts_mon_config.SetupTsMonGlobalState(
    283                 'afe_label_cleaner',
    284                 auto_flush=False,
    285                 debug_file=metrics_file,
    286         )
    287     else:
    288         ts_mon_context = ts_mon_config.SetupTsMonGlobalState(
    289                 'afe_label_cleaner',
    290                 auto_flush=False,
    291         )
    292     with ts_mon_context:
    293         try:
    294             clean_labels(options)
    295         except:
    296             metrics.Counter(_METRICS_PREFIX + '/tick').increment(
    297                     fields={'target_db': options.db_server,
    298                             'success': False})
    299             raise
    300         else:
    301             metrics.Counter(_METRICS_PREFIX + '/tick').increment(
    302                     fields={'target_db': options.db_server,
    303                             'success': True})
    304         finally:
    305             metrics.Flush()
    306             if options.dry_run:
    307                 logging.info('Dumped ts_mon metrics to %s', metrics_file)
    308 
    309 
    310 if __name__ == '__main__':
    311     sys.exit(main())
    312