Mike Frysinger | d03e6b5 | 2019-08-03 12:49:01 -0400 | [diff] [blame] | 1 | #!/usr/bin/python2 |
Richard Barnette | 2468fbd | 2014-11-07 01:12:46 +0000 | [diff] [blame] | 2 | # Copyright (c) 2014 The Chromium OS Authors. All rights reserved. |
| 3 | # Use of this source code is governed by a BSD-style license that can be |
| 4 | # found in the LICENSE file. |
| 5 | |
| 6 | """Bootstrap mysql. |
| 7 | |
| 8 | The purpose of this module is to grant access to a new-user/host/password |
| 9 | combination on a remote db server. For example, if we were bootstrapping |
| 10 | a new autotest master A1 with a remote database server A2, the scheduler |
| 11 | running on A1 needs to access the database on A2 with the credentials |
| 12 | specified in the shadow_config of A1 (A1_user, A1_pass). To achieve this |
| 13 | we ssh into A2 and execute the grant privileges command for (A1_user, |
| 14 | A1_pass, A1_host). If OTOH the db server is running locally we only need |
| 15 | to grant permissions for (A1_user, A1_pass, localhost). |
| 16 | |
| 17 | The operation to achieve this will look like: |
| 18 | ssh/become into A2 |
| 19 | Execute mysql -u <default_user> -p<default_pass> -e |
| 20 | "GRANT privileges on <db> to 'A1_user'@A1 identified by 'A1_pass';" |
| 21 | |
| 22 | However this will only grant the right access permissions to A1, so we need |
| 23 | to repeat for all subsequent db clients we add. This will happen through puppet. |
| 24 | |
| 25 | In the case of a vagrant cluster, a remote vm cannot ssh into the db server |
| 26 | vm with plain old ssh. However, the entire vm cluster is provisioned at the |
| 27 | same time, so we can grant access to all remote vm clients directly on the |
| 28 | database server without knowing their ips by using the ip of the gateway. |
| 29 | This works because the db server vm redirects its database port (3306) to |
| 30 | a predefined port (defined in the vagrant file, defaults to 8002), and all |
| 31 | other vms in the cluster can only access it through the vm host identified |
| 32 | by the gateway. |
| 33 | |
| 34 | The operation to achieve this will look like: |
| 35 | Provision the vagrant db server |
| 36 | Execute mysql -u <default_user> -p<default_pass> -e |
| 37 | "GRANT privileges on <db> to 'A1_user'@(gateway address) |
| 38 | identified by 'A1_pass';" |
| 39 | This will grant the right access permissions to all vms running on the |
| 40 | host machine as long as they use the right port to access the database. |
| 41 | """ |
| 42 | |
| 43 | import argparse |
| 44 | import logging |
| 45 | import socket |
| 46 | import subprocess |
| 47 | import sys |
| 48 | |
| 49 | import common |
| 50 | |
| 51 | from autotest_lib.client.common_lib import global_config |
| 52 | from autotest_lib.client.common_lib import utils |
| 53 | from autotest_lib.site_utils.lib import infra |
| 54 | |
| 55 | |
| 56 | class MySQLCommandError(Exception): |
| 57 | """Generic mysql command execution exception.""" |
| 58 | |
| 59 | |
| 60 | class MySQLCommandExecutor(object): |
| 61 | """Class to shell out to mysql. |
| 62 | |
| 63 | USE THIS CLASS WITH CARE. It doesn't protect against SQL injection on |
| 64 | assumption that anyone with access to our servers can run the same |
| 65 | commands directly instead of through this module. Do not expose it |
| 66 | through a webserver, it is meant solely as a utility module to allow |
| 67 | easy database bootstrapping via puppet. |
| 68 | """ |
| 69 | |
| 70 | DEFAULT_USER = global_config.global_config.get_config_value( |
| 71 | 'AUTOTEST_WEB', 'default_db_user', default='root') |
| 72 | |
| 73 | DEFAULT_PASS = global_config.global_config.get_config_value( |
| 74 | 'AUTOTEST_WEB', 'default_db_pass', default='autotest') |
| 75 | |
| 76 | |
| 77 | @classmethod |
| 78 | def mysql_cmd(cls, cmd, user=DEFAULT_USER, password=DEFAULT_PASS, |
| 79 | host='localhost', port=3306): |
| 80 | """Wrap the given mysql command. |
| 81 | |
| 82 | @param cmd: The mysql command to wrap with the --execute option. |
| 83 | @param host: The host against which to run the command. |
| 84 | @param user: The user to use in the given command. |
| 85 | @param password: The password for the user. |
| 86 | @param port: The port mysql server is listening on. |
| 87 | """ |
| 88 | return ('mysql -u %s -p%s --host %s --port %s -e "%s"' % |
| 89 | (user, password, host, port, cmd)) |
| 90 | |
| 91 | |
| 92 | @staticmethod |
| 93 | def execute(dest_server, full_cmd): |
| 94 | """Execute a mysql statement on a remote server by sshing into it. |
| 95 | |
| 96 | @param dest_server: The hostname of the remote mysql server. |
| 97 | @param full_cmd: The full mysql command to execute. |
| 98 | |
| 99 | @raises MySQLCommandError: If the full_cmd failed on dest_server. |
| 100 | """ |
| 101 | try: |
| 102 | return infra.execute_command(dest_server, full_cmd) |
| 103 | except subprocess.CalledProcessError as e: |
| 104 | raise MySQLCommandError('Failed to execute %s against %s' % |
| 105 | (full_cmd, dest_server)) |
| 106 | |
| 107 | |
| 108 | @classmethod |
| 109 | def ping(cls, db_server, user=DEFAULT_USER, password=DEFAULT_PASS, |
| 110 | use_ssh=False): |
| 111 | """Ping the given db server as 'user' using 'password'. |
| 112 | |
| 113 | @param db_server: The host running the mysql server. |
| 114 | @param user: The user to use in the ping. |
| 115 | @param password: The password of the user. |
| 116 | @param use_ssh: If False, the command is executed on localhost |
| 117 | by supplying --host=db_server in the mysql command. Otherwise we |
| 118 | ssh/become into the db_server and execute the command with |
| 119 | --host=localhost. |
| 120 | |
| 121 | @raises MySQLCommandError: If the ping command fails. |
| 122 | """ |
| 123 | if use_ssh: |
| 124 | ssh_dest_server = db_server |
| 125 | mysql_cmd_host = 'localhost' |
| 126 | else: |
| 127 | ssh_dest_server = 'localhost' |
| 128 | mysql_cmd_host = db_server |
| 129 | ping = cls.mysql_cmd( |
| 130 | 'SELECT version();', host=mysql_cmd_host, user=user, |
| 131 | password=password) |
| 132 | cls.execute(ssh_dest_server, ping) |
| 133 | |
| 134 | |
| 135 | def bootstrap(user, password, source_host, dest_host): |
| 136 | """Bootstrap the given user against dest_host. |
| 137 | |
| 138 | Allow a user from source_host to access the db server running on |
| 139 | dest_host. |
| 140 | |
| 141 | @param user: The user to bootstrap. |
| 142 | @param password: The password for the user. |
| 143 | @param source_host: The host from which the new user will access the db. |
| 144 | @param dest_host: The hostname of the remote db server. |
| 145 | |
| 146 | @raises MySQLCommandError: If we can't ping the db server using the default |
| 147 | user/password specified in the shadow_config under default_db_*, or |
| 148 | we can't ping it with the new credentials after bootstrapping. |
| 149 | """ |
| 150 | # Confirm ssh/become access. |
| 151 | try: |
| 152 | infra.execute_command(dest_host, 'echo "hello"') |
| 153 | except subprocess.CalledProcessError as e: |
| 154 | logging.error("Cannot become/ssh into dest host. You need to bootstrap " |
| 155 | "it using fab -H <hostname> bootstrap from the " |
| 156 | "chromeos-admin repo.") |
| 157 | return |
| 158 | # Confirm the default user has at least database read privileges. Note if |
| 159 | # the default user has *only* read privileges everything else will still |
| 160 | # fail. This is a remote enough case given our current setup that we can |
| 161 | # avoid more complicated checking at this level. |
| 162 | MySQLCommandExecutor.ping(dest_host, use_ssh=True) |
| 163 | |
| 164 | # Prepare and execute the grant statement for the new user. |
| 165 | creds = { |
| 166 | 'new_user': user, |
| 167 | 'new_pass': password, |
| 168 | 'new_host': source_host, |
| 169 | } |
| 170 | # TODO(beeps): Restrict these permissions. For now we have a couple of |
| 171 | # databases which may/may-not exist on various roles that need refactoring. |
| 172 | grant_privileges = ( |
| 173 | "GRANT ALL PRIVILEGES ON *.* to '%(new_user)s'@'%(new_host)s' " |
| 174 | "IDENTIFIED BY '%(new_pass)s'; FLUSH PRIVILEGES;") |
| 175 | MySQLCommandExecutor.execute( |
| 176 | dest_host, MySQLCommandExecutor.mysql_cmd(grant_privileges % creds)) |
| 177 | |
| 178 | # Confirm the new user can ping the remote database server from localhost. |
| 179 | MySQLCommandExecutor.ping( |
| 180 | dest_host, user=user, password=password, use_ssh=False) |
| 181 | |
| 182 | |
| 183 | def get_gateway(): |
| 184 | """Return the address of the default gateway. |
| 185 | |
| 186 | @raises: subprocess.CalledProcessError: If the address of the gateway |
| 187 | cannot be determined via netstat. |
| 188 | """ |
| 189 | cmd = 'netstat -rn | grep "^0.0.0.0 " | cut -d " " -f10 | head -1' |
| 190 | try: |
| 191 | return infra.execute_command('localhost', cmd).rstrip('\n') |
| 192 | except subprocess.CalledProcessError as e: |
| 193 | logging.error('Unable to get gateway: %s', e) |
| 194 | raise |
| 195 | |
| 196 | |
| 197 | def _parse_args(args): |
| 198 | parser = argparse.ArgumentParser(description='A script to bootstrap mysql ' |
| 199 | 'with credentials from the shadow_config.') |
| 200 | parser.add_argument( |
| 201 | '--enable_gateway', action='store_true', dest='enable_gateway', |
| 202 | default=False, help='Enable gateway access for vagrant testing.') |
| 203 | return parser.parse_args(args) |
| 204 | |
| 205 | |
| 206 | def main(argv): |
| 207 | """Main bootstrapper method. |
| 208 | |
| 209 | Grants permissions to the appropriate user on localhost, then enables the |
| 210 | access through the gateway if --enable_gateway is specified. |
| 211 | """ |
| 212 | args = _parse_args(argv) |
| 213 | dest_host = global_config.global_config.get_config_value( |
| 214 | 'AUTOTEST_WEB', 'host') |
| 215 | user = global_config.global_config.get_config_value( |
| 216 | 'AUTOTEST_WEB', 'user') |
| 217 | password = global_config.global_config.get_config_value( |
| 218 | 'AUTOTEST_WEB', 'password') |
| 219 | |
| 220 | # For access via localhost, one needs to specify localhost as the hostname. |
| 221 | # Neither the ip or the actual hostname of localhost will suffice in |
| 222 | # mysql version 5.5, without complications. |
| 223 | local_hostname = ('localhost' if utils.is_localhost(dest_host) |
| 224 | else socket.gethostname()) |
| 225 | logging.info('Bootstrapping user %s on host %s against db server %s', |
| 226 | user, local_hostname, dest_host) |
| 227 | bootstrap(user, password, local_hostname, dest_host) |
| 228 | |
| 229 | if args.enable_gateway: |
| 230 | gateway = get_gateway() |
| 231 | logging.info('Enabling access through gateway %s', gateway) |
| 232 | bootstrap(user, password, gateway, dest_host) |
| 233 | |
| 234 | |
| 235 | if __name__ == '__main__': |
| 236 | sys.exit(main(sys.argv[1:])) |