~canonical-ci-engineering/charms/trusty/core-image-publisher/trunk

« back to all changes in this revision

Viewing changes to hooks/charmhelpers/contrib/database/mysql.py

  • Committer: Celso Providelo
  • Date: 2015-03-25 04:13:43 UTC
  • Revision ID: celso.providelo@canonical.com-20150325041343-jw05jaz6jscs3c8f
fork of core-image-watcher

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
"""Helper for working with a MySQL database"""
 
2
import json
 
3
import socket
 
4
import re
 
5
import sys
 
6
import platform
 
7
import os
 
8
import glob
 
9
 
 
10
from string import upper
 
11
 
 
12
from charmhelpers.core.host import (
 
13
    mkdir,
 
14
    pwgen,
 
15
    write_file
 
16
)
 
17
from charmhelpers.core.hookenv import (
 
18
    relation_get,
 
19
    related_units,
 
20
    unit_get,
 
21
    log,
 
22
    DEBUG,
 
23
    INFO,
 
24
)
 
25
from charmhelpers.core.hookenv import config as config_get
 
26
from charmhelpers.fetch import (
 
27
    apt_install,
 
28
    apt_update,
 
29
    filter_installed_packages,
 
30
)
 
31
from charmhelpers.contrib.peerstorage import (
 
32
    peer_store,
 
33
    peer_retrieve,
 
34
)
 
35
 
 
36
try:
 
37
    import MySQLdb
 
38
except ImportError:
 
39
    apt_update(fatal=True)
 
40
    apt_install(filter_installed_packages(['python-mysqldb']), fatal=True)
 
41
    import MySQLdb
 
42
 
 
43
 
 
44
class MySQLHelper(object):
 
45
 
 
46
    def __init__(self, rpasswdf_template, upasswdf_template, host='localhost'):
 
47
        self.host = host
 
48
        # Password file path templates
 
49
        self.root_passwd_file_template = rpasswdf_template
 
50
        self.user_passwd_file_template = upasswdf_template
 
51
 
 
52
    def connect(self, user='root', password=None):
 
53
        self.connection = MySQLdb.connect(user=user, host=self.host,
 
54
                                          passwd=password)
 
55
 
 
56
    def database_exists(self, db_name):
 
57
        cursor = self.connection.cursor()
 
58
        try:
 
59
            cursor.execute("SHOW DATABASES")
 
60
            databases = [i[0] for i in cursor.fetchall()]
 
61
        finally:
 
62
            cursor.close()
 
63
 
 
64
        return db_name in databases
 
65
 
 
66
    def create_database(self, db_name):
 
67
        cursor = self.connection.cursor()
 
68
        try:
 
69
            cursor.execute("CREATE DATABASE {} CHARACTER SET UTF8"
 
70
                           .format(db_name))
 
71
        finally:
 
72
            cursor.close()
 
73
 
 
74
    def grant_exists(self, db_name, db_user, remote_ip):
 
75
        cursor = self.connection.cursor()
 
76
        priv_string = "GRANT ALL PRIVILEGES ON `{}`.* " \
 
77
                      "TO '{}'@'{}'".format(db_name, db_user, remote_ip)
 
78
        try:
 
79
            cursor.execute("SHOW GRANTS for '{}'@'{}'".format(db_user,
 
80
                                                              remote_ip))
 
81
            grants = [i[0] for i in cursor.fetchall()]
 
82
        except MySQLdb.OperationalError:
 
83
            return False
 
84
        finally:
 
85
            cursor.close()
 
86
 
 
87
        # TODO: review for different grants
 
88
        return priv_string in grants
 
89
 
 
90
    def create_grant(self, db_name, db_user, remote_ip, password):
 
91
        cursor = self.connection.cursor()
 
92
        try:
 
93
            # TODO: review for different grants
 
94
            cursor.execute("GRANT ALL PRIVILEGES ON {}.* TO '{}'@'{}' "
 
95
                           "IDENTIFIED BY '{}'".format(db_name,
 
96
                                                       db_user,
 
97
                                                       remote_ip,
 
98
                                                       password))
 
99
        finally:
 
100
            cursor.close()
 
101
 
 
102
    def create_admin_grant(self, db_user, remote_ip, password):
 
103
        cursor = self.connection.cursor()
 
104
        try:
 
105
            cursor.execute("GRANT ALL PRIVILEGES ON *.* TO '{}'@'{}' "
 
106
                           "IDENTIFIED BY '{}'".format(db_user,
 
107
                                                       remote_ip,
 
108
                                                       password))
 
109
        finally:
 
110
            cursor.close()
 
111
 
 
112
    def cleanup_grant(self, db_user, remote_ip):
 
113
        cursor = self.connection.cursor()
 
114
        try:
 
115
            cursor.execute("DROP FROM mysql.user WHERE user='{}' "
 
116
                           "AND HOST='{}'".format(db_user,
 
117
                                                  remote_ip))
 
118
        finally:
 
119
            cursor.close()
 
120
 
 
121
    def execute(self, sql):
 
122
        """Execute arbitary SQL against the database."""
 
123
        cursor = self.connection.cursor()
 
124
        try:
 
125
            cursor.execute(sql)
 
126
        finally:
 
127
            cursor.close()
 
128
 
 
129
    def migrate_passwords_to_peer_relation(self):
 
130
        """Migrate any passwords storage on disk to cluster peer relation."""
 
131
        dirname = os.path.dirname(self.root_passwd_file_template)
 
132
        path = os.path.join(dirname, '*.passwd')
 
133
        for f in glob.glob(path):
 
134
            _key = os.path.basename(f)
 
135
            with open(f, 'r') as passwd:
 
136
                _value = passwd.read().strip()
 
137
 
 
138
            try:
 
139
                peer_store(_key, _value)
 
140
                os.unlink(f)
 
141
            except ValueError:
 
142
                # NOTE cluster relation not yet ready - skip for now
 
143
                pass
 
144
 
 
145
    def get_mysql_password_on_disk(self, username=None, password=None):
 
146
        """Retrieve, generate or store a mysql password for the provided
 
147
        username on disk."""
 
148
        if username:
 
149
            template = self.user_passwd_file_template
 
150
            passwd_file = template.format(username)
 
151
        else:
 
152
            passwd_file = self.root_passwd_file_template
 
153
 
 
154
        _password = None
 
155
        if os.path.exists(passwd_file):
 
156
            with open(passwd_file, 'r') as passwd:
 
157
                _password = passwd.read().strip()
 
158
        else:
 
159
            mkdir(os.path.dirname(passwd_file), owner='root', group='root',
 
160
                  perms=0o770)
 
161
            # Force permissions - for some reason the chmod in makedirs fails
 
162
            os.chmod(os.path.dirname(passwd_file), 0o770)
 
163
            _password = password or pwgen(length=32)
 
164
            write_file(passwd_file, _password, owner='root', group='root',
 
165
                       perms=0o660)
 
166
 
 
167
        return _password
 
168
 
 
169
    def get_mysql_password(self, username=None, password=None):
 
170
        """Retrieve, generate or store a mysql password for the provided
 
171
        username using peer relation cluster."""
 
172
        self.migrate_passwords_to_peer_relation()
 
173
        if username:
 
174
            _key = 'mysql-{}.passwd'.format(username)
 
175
        else:
 
176
            _key = 'mysql.passwd'
 
177
 
 
178
        try:
 
179
            _password = peer_retrieve(_key)
 
180
            if _password is None:
 
181
                _password = password or pwgen(length=32)
 
182
                peer_store(_key, _password)
 
183
        except ValueError:
 
184
            # cluster relation is not yet started; use on-disk
 
185
            _password = self.get_mysql_password_on_disk(username, password)
 
186
 
 
187
        return _password
 
188
 
 
189
    def get_mysql_root_password(self, password=None):
 
190
        """Retrieve or generate mysql root password for service units."""
 
191
        return self.get_mysql_password(username=None, password=password)
 
192
 
 
193
    def get_allowed_units(self, database, username, relation_id=None):
 
194
        """Get list of units with access grants for database with username.
 
195
 
 
196
        This is typically used to provide shared-db relations with a list of
 
197
        which units have been granted access to the given database.
 
198
        """
 
199
        self.connect(password=self.get_mysql_root_password())
 
200
        allowed_units = set()
 
201
        for unit in related_units(relation_id):
 
202
            settings = relation_get(rid=relation_id, unit=unit)
 
203
            # First check for setting with prefix, then without
 
204
            for attr in ["%s_hostname" % (database), 'hostname']:
 
205
                hosts = settings.get(attr, None)
 
206
                if hosts:
 
207
                    break
 
208
 
 
209
            if hosts:
 
210
                # hostname can be json-encoded list of hostnames
 
211
                try:
 
212
                    hosts = json.loads(hosts)
 
213
                except ValueError:
 
214
                    hosts = [hosts]
 
215
            else:
 
216
                hosts = [settings['private-address']]
 
217
 
 
218
            if hosts:
 
219
                for host in hosts:
 
220
                    if self.grant_exists(database, username, host):
 
221
                        log("Grant exists for host '%s' on db '%s'" %
 
222
                            (host, database), level=DEBUG)
 
223
                        if unit not in allowed_units:
 
224
                            allowed_units.add(unit)
 
225
                    else:
 
226
                        log("Grant does NOT exist for host '%s' on db '%s'" %
 
227
                            (host, database), level=DEBUG)
 
228
            else:
 
229
                log("No hosts found for grant check", level=INFO)
 
230
 
 
231
        return allowed_units
 
232
 
 
233
    def configure_db(self, hostname, database, username, admin=False):
 
234
        """Configure access to database for username from hostname."""
 
235
        if config_get('prefer-ipv6'):
 
236
            remote_ip = hostname
 
237
        elif hostname != unit_get('private-address'):
 
238
            try:
 
239
                remote_ip = socket.gethostbyname(hostname)
 
240
            except Exception:
 
241
                # socket.gethostbyname doesn't support ipv6
 
242
                remote_ip = hostname
 
243
        else:
 
244
            remote_ip = '127.0.0.1'
 
245
 
 
246
        self.connect(password=self.get_mysql_root_password())
 
247
        if not self.database_exists(database):
 
248
            self.create_database(database)
 
249
 
 
250
        password = self.get_mysql_password(username)
 
251
        if not self.grant_exists(database, username, remote_ip):
 
252
            if not admin:
 
253
                self.create_grant(database, username, remote_ip, password)
 
254
            else:
 
255
                self.create_admin_grant(username, remote_ip, password)
 
256
 
 
257
        return password
 
258
 
 
259
 
 
260
class PerconaClusterHelper(object):
 
261
 
 
262
    # Going for the biggest page size to avoid wasted bytes. InnoDB page size is
 
263
    # 16MB
 
264
    DEFAULT_PAGE_SIZE = 16 * 1024 * 1024
 
265
 
 
266
    def human_to_bytes(self, human):
 
267
        """Convert human readable configuration options to bytes."""
 
268
        num_re = re.compile('^[0-9]+$')
 
269
        if num_re.match(human):
 
270
            return human
 
271
 
 
272
        factors = {
 
273
            'K': 1024,
 
274
            'M': 1048576,
 
275
            'G': 1073741824,
 
276
            'T': 1099511627776
 
277
        }
 
278
        modifier = human[-1]
 
279
        if modifier in factors:
 
280
            return int(human[:-1]) * factors[modifier]
 
281
 
 
282
        if modifier == '%':
 
283
            total_ram = self.human_to_bytes(self.get_mem_total())
 
284
            if self.is_32bit_system() and total_ram > self.sys_mem_limit():
 
285
                total_ram = self.sys_mem_limit()
 
286
            factor = int(human[:-1]) * 0.01
 
287
            pctram = total_ram * factor
 
288
            return int(pctram - (pctram % self.DEFAULT_PAGE_SIZE))
 
289
 
 
290
        raise ValueError("Can only convert K,M,G, or T")
 
291
 
 
292
    def is_32bit_system(self):
 
293
        """Determine whether system is 32 or 64 bit."""
 
294
        try:
 
295
            return sys.maxsize < 2 ** 32
 
296
        except OverflowError:
 
297
            return False
 
298
 
 
299
    def sys_mem_limit(self):
 
300
        """Determine the default memory limit for the current service unit."""
 
301
        if platform.machine() in ['armv7l']:
 
302
            _mem_limit = self.human_to_bytes('2700M')  # experimentally determined
 
303
        else:
 
304
            # Limit for x86 based 32bit systems
 
305
            _mem_limit = self.human_to_bytes('4G')
 
306
 
 
307
        return _mem_limit
 
308
 
 
309
    def get_mem_total(self):
 
310
        """Calculate the total memory in the current service unit."""
 
311
        with open('/proc/meminfo') as meminfo_file:
 
312
            for line in meminfo_file:
 
313
                key, mem = line.split(':', 2)
 
314
                if key == 'MemTotal':
 
315
                    mtot, modifier = mem.strip().split(' ')
 
316
                    return '%s%s' % (mtot, upper(modifier[0]))
 
317
 
 
318
    def parse_config(self):
 
319
        """Parse charm configuration and calculate values for config files."""
 
320
        config = config_get()
 
321
        mysql_config = {}
 
322
        if 'max-connections' in config:
 
323
            mysql_config['max_connections'] = config['max-connections']
 
324
 
 
325
        # Total memory available for dataset
 
326
        dataset_bytes = self.human_to_bytes(config['dataset-size'])
 
327
        mysql_config['dataset_bytes'] = dataset_bytes
 
328
 
 
329
        if 'query-cache-type' in config:
 
330
            # Query Cache Configuration
 
331
            mysql_config['query_cache_size'] = config['query-cache-size']
 
332
            if (config['query-cache-size'] == -1 and
 
333
                    config['query-cache-type'] in ['ON', 'DEMAND']):
 
334
                # Calculate the query cache size automatically
 
335
                qcache_bytes = (dataset_bytes * 0.20)
 
336
                qcache_bytes = int(qcache_bytes -
 
337
                                   (qcache_bytes % self.DEFAULT_PAGE_SIZE))
 
338
                mysql_config['query_cache_size'] = qcache_bytes
 
339
                dataset_bytes -= qcache_bytes
 
340
 
 
341
            # 5.5 allows the words, but not 5.1
 
342
            if config['query-cache-type'] == 'ON':
 
343
                mysql_config['query_cache_type'] = 1
 
344
            elif config['query-cache-type'] == 'DEMAND':
 
345
                mysql_config['query_cache_type'] = 2
 
346
            else:
 
347
                mysql_config['query_cache_type'] = 0
 
348
 
 
349
        # Set a sane default key_buffer size
 
350
        mysql_config['key_buffer'] = self.human_to_bytes('32M')
 
351
 
 
352
        if 'preferred-storage-engine' in config:
 
353
            # Storage engine configuration
 
354
            preferred_engines = config['preferred-storage-engine'].split(',')
 
355
            chunk_size = int(dataset_bytes / len(preferred_engines))
 
356
            mysql_config['innodb_flush_log_at_trx_commit'] = 1
 
357
            mysql_config['sync_binlog'] = 1
 
358
            if 'InnoDB' in preferred_engines:
 
359
                mysql_config['innodb_buffer_pool_size'] = chunk_size
 
360
                if config['tuning-level'] == 'fast':
 
361
                    mysql_config['innodb_flush_log_at_trx_commit'] = 2
 
362
            else:
 
363
                mysql_config['innodb_buffer_pool_size'] = 0
 
364
 
 
365
            mysql_config['default_storage_engine'] = preferred_engines[0]
 
366
            if 'MyISAM' in preferred_engines:
 
367
                mysql_config['key_buffer'] = chunk_size
 
368
 
 
369
            if config['tuning-level'] == 'fast':
 
370
                mysql_config['sync_binlog'] = 0
 
371
 
 
372
        return mysql_config