#!/usr/bin/python2 # Copyright (c) 2014 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. """Bootstrap mysql. The purpose of this module is to grant access to a new-user/host/password combination on a remote db server. For example, if we were bootstrapping a new autotest master A1 with a remote database server A2, the scheduler running on A1 needs to access the database on A2 with the credentials specified in the shadow_config of A1 (A1_user, A1_pass). To achieve this we ssh into A2 and execute the grant privileges command for (A1_user, A1_pass, A1_host). If OTOH the db server is running locally we only need to grant permissions for (A1_user, A1_pass, localhost). The operation to achieve this will look like: ssh/become into A2 Execute mysql -u -p -e "GRANT privileges on to 'A1_user'@A1 identified by 'A1_pass';" However this will only grant the right access permissions to A1, so we need to repeat for all subsequent db clients we add. This will happen through puppet. In the case of a vagrant cluster, a remote vm cannot ssh into the db server vm with plain old ssh. However, the entire vm cluster is provisioned at the same time, so we can grant access to all remote vm clients directly on the database server without knowing their ips by using the ip of the gateway. This works because the db server vm redirects its database port (3306) to a predefined port (defined in the vagrant file, defaults to 8002), and all other vms in the cluster can only access it through the vm host identified by the gateway. The operation to achieve this will look like: Provision the vagrant db server Execute mysql -u -p -e "GRANT privileges on to 'A1_user'@(gateway address) identified by 'A1_pass';" This will grant the right access permissions to all vms running on the host machine as long as they use the right port to access the database. """ import argparse import logging import socket import subprocess import sys import common from autotest_lib.client.common_lib import global_config from autotest_lib.client.common_lib import utils from autotest_lib.site_utils.lib import infra class MySQLCommandError(Exception): """Generic mysql command execution exception.""" class MySQLCommandExecutor(object): """Class to shell out to mysql. USE THIS CLASS WITH CARE. It doesn't protect against SQL injection on assumption that anyone with access to our servers can run the same commands directly instead of through this module. Do not expose it through a webserver, it is meant solely as a utility module to allow easy database bootstrapping via puppet. """ DEFAULT_USER = global_config.global_config.get_config_value( 'AUTOTEST_WEB', 'default_db_user', default='root') DEFAULT_PASS = global_config.global_config.get_config_value( 'AUTOTEST_WEB', 'default_db_pass', default='autotest') @classmethod def mysql_cmd(cls, cmd, user=DEFAULT_USER, password=DEFAULT_PASS, host='localhost', port=3306): """Wrap the given mysql command. @param cmd: The mysql command to wrap with the --execute option. @param host: The host against which to run the command. @param user: The user to use in the given command. @param password: The password for the user. @param port: The port mysql server is listening on. """ return ('mysql -u %s -p%s --host %s --port %s -e "%s"' % (user, password, host, port, cmd)) @staticmethod def execute(dest_server, full_cmd): """Execute a mysql statement on a remote server by sshing into it. @param dest_server: The hostname of the remote mysql server. @param full_cmd: The full mysql command to execute. @raises MySQLCommandError: If the full_cmd failed on dest_server. """ try: return infra.execute_command(dest_server, full_cmd) except subprocess.CalledProcessError as e: raise MySQLCommandError('Failed to execute %s against %s' % (full_cmd, dest_server)) @classmethod def ping(cls, db_server, user=DEFAULT_USER, password=DEFAULT_PASS, use_ssh=False): """Ping the given db server as 'user' using 'password'. @param db_server: The host running the mysql server. @param user: The user to use in the ping. @param password: The password of the user. @param use_ssh: If False, the command is executed on localhost by supplying --host=db_server in the mysql command. Otherwise we ssh/become into the db_server and execute the command with --host=localhost. @raises MySQLCommandError: If the ping command fails. """ if use_ssh: ssh_dest_server = db_server mysql_cmd_host = 'localhost' else: ssh_dest_server = 'localhost' mysql_cmd_host = db_server ping = cls.mysql_cmd( 'SELECT version();', host=mysql_cmd_host, user=user, password=password) cls.execute(ssh_dest_server, ping) def bootstrap(user, password, source_host, dest_host): """Bootstrap the given user against dest_host. Allow a user from source_host to access the db server running on dest_host. @param user: The user to bootstrap. @param password: The password for the user. @param source_host: The host from which the new user will access the db. @param dest_host: The hostname of the remote db server. @raises MySQLCommandError: If we can't ping the db server using the default user/password specified in the shadow_config under default_db_*, or we can't ping it with the new credentials after bootstrapping. """ # Confirm ssh/become access. try: infra.execute_command(dest_host, 'echo "hello"') except subprocess.CalledProcessError as e: logging.error("Cannot become/ssh into dest host. You need to bootstrap " "it using fab -H bootstrap from the " "chromeos-admin repo.") return # Confirm the default user has at least database read privileges. Note if # the default user has *only* read privileges everything else will still # fail. This is a remote enough case given our current setup that we can # avoid more complicated checking at this level. MySQLCommandExecutor.ping(dest_host, use_ssh=True) # Prepare and execute the grant statement for the new user. creds = { 'new_user': user, 'new_pass': password, 'new_host': source_host, } # TODO(beeps): Restrict these permissions. For now we have a couple of # databases which may/may-not exist on various roles that need refactoring. grant_privileges = ( "GRANT ALL PRIVILEGES ON *.* to '%(new_user)s'@'%(new_host)s' " "IDENTIFIED BY '%(new_pass)s'; FLUSH PRIVILEGES;") MySQLCommandExecutor.execute( dest_host, MySQLCommandExecutor.mysql_cmd(grant_privileges % creds)) # Confirm the new user can ping the remote database server from localhost. MySQLCommandExecutor.ping( dest_host, user=user, password=password, use_ssh=False) def get_gateway(): """Return the address of the default gateway. @raises: subprocess.CalledProcessError: If the address of the gateway cannot be determined via netstat. """ cmd = 'netstat -rn | grep "^0.0.0.0 " | cut -d " " -f10 | head -1' try: return infra.execute_command('localhost', cmd).rstrip('\n') except subprocess.CalledProcessError as e: logging.error('Unable to get gateway: %s', e) raise def _parse_args(args): parser = argparse.ArgumentParser(description='A script to bootstrap mysql ' 'with credentials from the shadow_config.') parser.add_argument( '--enable_gateway', action='store_true', dest='enable_gateway', default=False, help='Enable gateway access for vagrant testing.') return parser.parse_args(args) def main(argv): """Main bootstrapper method. Grants permissions to the appropriate user on localhost, then enables the access through the gateway if --enable_gateway is specified. """ args = _parse_args(argv) dest_host = global_config.global_config.get_config_value( 'AUTOTEST_WEB', 'host') user = global_config.global_config.get_config_value( 'AUTOTEST_WEB', 'user') password = global_config.global_config.get_config_value( 'AUTOTEST_WEB', 'password') # For access via localhost, one needs to specify localhost as the hostname. # Neither the ip or the actual hostname of localhost will suffice in # mysql version 5.5, without complications. local_hostname = ('localhost' if utils.is_localhost(dest_host) else socket.gethostname()) logging.info('Bootstrapping user %s on host %s against db server %s', user, local_hostname, dest_host) bootstrap(user, password, local_hostname, dest_host) if args.enable_gateway: gateway = get_gateway() logging.info('Enabling access through gateway %s', gateway) bootstrap(user, password, gateway, dest_host) if __name__ == '__main__': sys.exit(main(sys.argv[1:]))