PageRenderTime 44ms CodeModel.GetById 12ms RepoModel.GetById 1ms app.codeStats 0ms

/instance/models/mixins/database.py

https://gitlab.com/opencraft/opencraft
Python | 385 lines | 345 code | 10 blank | 30 comment | 0 complexity | 82c90c7175473d0f2cae0051831f288e MD5 | raw file
Possible License(s): AGPL-3.0
  1. # -*- coding: utf-8 -*-
  2. #
  3. # OpenCraft -- tools to aid developing and hosting free software projects
  4. # Copyright (C) 2015-2019 OpenCraft <contact@opencraft.com>
  5. #
  6. # This program is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU Affero General Public License as
  8. # published by the Free Software Foundation, either version 3 of the
  9. # License, or (at your option) any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU Affero General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU Affero General Public License
  17. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. #
  19. """
  20. Instance app model mixins - Database
  21. """
  22. # Imports #####################################################################
  23. import functools
  24. import inspect
  25. import string
  26. import warnings
  27. import logging
  28. from django.conf import settings
  29. from django.db import models
  30. from django.utils.crypto import get_random_string
  31. import MySQLdb as mysql
  32. from MySQLdb import Error as MySQLError
  33. import pymongo
  34. from pymongo.errors import PyMongoError
  35. from instance.models.database_server import MySQLServer, MongoDBServer, MongoDBReplicaSet
  36. # Logging #####################################################################
  37. logger = logging.getLogger(__name__)
  38. # Functions ###################################################################
  39. def database_name_escaped(func):
  40. """
  41. Decorator for functions that require name of MySQL database to be escaped.
  42. Escaping is necessary if a function uses the database name in a parameterized query;
  43. the driver doesn't escape it properly.
  44. """
  45. def wrapper(*args, **kwargs):
  46. """ Escape database name, then call func """
  47. signature = inspect.signature(func)
  48. bound_arguments = signature.bind(*args, **kwargs)
  49. # Obtain connection from cursor passed to func.
  50. # This allows us to simplify the signature of func (we don't have to add a "connection" parameter).
  51. connection = bound_arguments.arguments["cursor"].connection
  52. database = bound_arguments.arguments["database"]
  53. bound_arguments.arguments["database"] = connection.escape_string(database).decode()
  54. func(*bound_arguments.args, **bound_arguments.kwargs)
  55. return wrapper
  56. def _get_mysql_cursor(mysql_server):
  57. """
  58. Get a database cursor.
  59. """
  60. try:
  61. connection = mysql.connect(
  62. host=mysql_server.hostname,
  63. user=mysql_server.username,
  64. passwd=mysql_server.password,
  65. port=mysql_server.port,
  66. )
  67. except MySQLError as exc:
  68. logger.exception('Cannot get MySQL cursor: %s. %s', mysql_server, exc)
  69. raise
  70. return connection.cursor()
  71. @database_name_escaped
  72. def _create_database(cursor, database):
  73. """
  74. Create MySQL database, if it doesn't already exist.
  75. """
  76. logger.info('Creating MySQL database: %s', database)
  77. with warnings.catch_warnings():
  78. warnings.simplefilter("ignore")
  79. cursor.execute('CREATE DATABASE IF NOT EXISTS `{db}` DEFAULT CHARACTER SET utf8'.format(db=database))
  80. def _create_user(cursor, user, password):
  81. """
  82. Create MySQL user identified by password if it doesn't exist
  83. """
  84. # Newer versions of MySQL support "CREATE USER IF NOT EXISTS"
  85. # but at this point we can't be sure that all target hosts run one of these,
  86. # so we need to use a different approach for now:
  87. user_exists = cursor.execute("SELECT 1 FROM mysql.user WHERE user = %s", (user,))
  88. if not user_exists:
  89. logger.info('Creating mysql user: %s', user)
  90. cursor.execute('CREATE USER %s IDENTIFIED BY %s', (user, password,))
  91. def _grant_privileges(cursor, database, user, privileges):
  92. """
  93. Grant privileges for databases to MySQL user
  94. """
  95. if database == "*":
  96. tables = "*.*"
  97. else:
  98. tables = "`{database}`.*".format(database=database)
  99. cursor.execute('GRANT {privileges} ON {tables} TO %s'.format(privileges=privileges, tables=tables), (user,))
  100. @database_name_escaped
  101. def _drop_database(cursor, database):
  102. """
  103. Drop MySQL database
  104. """
  105. with warnings.catch_warnings():
  106. warnings.simplefilter("ignore")
  107. logger.info('Dropping mysql db: %s', database)
  108. try:
  109. cursor.execute('DROP DATABASE IF EXISTS `{db}`'.format(db=database))
  110. except MySQLError as exc:
  111. logger.exception('Cannot drop MySQL database: %s. %s', database, exc)
  112. raise
  113. def _drop_user(cursor, user):
  114. """
  115. Drop MySQL user if it exists
  116. """
  117. # Newer versions of MySQL support "DROP USER IF EXISTS"
  118. # but at this point we can't be sure that all target hosts run one of these,
  119. # so we need to use a different approach for now:
  120. user_exists = cursor.execute("SELECT 1 FROM mysql.user WHERE user = %s", (user,))
  121. if user_exists:
  122. logger.info('Dropping mysql user: %s.', user)
  123. try:
  124. cursor.execute('DROP USER %s', (user,))
  125. except MySQLError as exc:
  126. logger.exception('Cannot drop MySQL user: %s. %s', user, exc)
  127. raise
  128. def select_random_mysql_server():
  129. """
  130. Helper for the field default of `mysql_server`.
  131. """
  132. return MySQLServer.objects.select_random().pk
  133. def select_random_mongodb_server():
  134. """
  135. Helper for the field default of `mongodb_server`.
  136. """
  137. if getattr(settings, MongoDBServer.DEFAULT_SETTINGS_NAME, None):
  138. return MongoDBServer.objects.select_random().pk
  139. return None
  140. def select_random_mongodb_replica_set():
  141. """
  142. Helper for the field default of `mongodb_server`.
  143. """
  144. if getattr(settings, MongoDBServer.DEFAULT_SETTINGS_NAME, None):
  145. return None
  146. return MongoDBReplicaSet.objects.select_random().pk
  147. # Classes #####################################################################
  148. class MySQLInstanceMixin(models.Model):
  149. """
  150. An instance that uses mysql databases
  151. """
  152. mysql_server = models.ForeignKey(
  153. MySQLServer,
  154. null=True,
  155. blank=True,
  156. default=select_random_mysql_server,
  157. on_delete=models.PROTECT,
  158. )
  159. mysql_user = models.CharField(
  160. max_length=16, # 16 chars is mysql maximum
  161. # Note that the maximum length for the name of a MySQL user is 16 characters.
  162. # But since we add suffixes to mysql_user to generate unique user names
  163. # for different services (e.g. xqueue) we don't want to use the maximum length here.
  164. default=functools.partial(get_random_string, length=6, allowed_chars=string.ascii_lowercase),
  165. blank=True,
  166. )
  167. mysql_pass = models.CharField(
  168. max_length=32,
  169. blank=True,
  170. default=functools.partial(get_random_string, length=32),
  171. )
  172. mysql_provisioned = models.BooleanField(default=False)
  173. class Meta:
  174. abstract = True
  175. @property
  176. def mysql_databases(self):
  177. """
  178. An iterable of databases
  179. """
  180. return NotImplementedError
  181. def provision_mysql(self):
  182. """
  183. Create mysql user and databases
  184. """
  185. if self.mysql_server:
  186. cursor = _get_mysql_cursor(self.mysql_server)
  187. # Create migration and read_only users
  188. _create_user(cursor, self.migrate_user, self._get_mysql_pass(self.migrate_user))
  189. _create_user(cursor, self.read_only_user, self._get_mysql_pass(self.read_only_user))
  190. # Create default databases and users, and grant privileges
  191. for database in self.mysql_databases:
  192. database_name = database["name"]
  193. _create_database(cursor, database_name)
  194. user = database["user"]
  195. _create_user(cursor, user, self._get_mysql_pass(user))
  196. privileges = database.get("priv", "ALL")
  197. _grant_privileges(cursor, database_name, user, privileges)
  198. _grant_privileges(cursor, database_name, self.migrate_user, "ALL")
  199. _grant_privileges(cursor, database_name, self.read_only_user, "ALL")
  200. additional_users = database.get("additional_users", [])
  201. for additional_user in additional_users:
  202. _grant_privileges(cursor, database_name, additional_user["name"], additional_user["priv"])
  203. # Create admin user with appropriate privileges
  204. _create_user(cursor, self.admin_user, self._get_mysql_pass(self.admin_user))
  205. _grant_privileges(cursor, "*", self.admin_user, "CREATE USER")
  206. cursor.close()
  207. self.mysql_provisioned = True
  208. self.save()
  209. def deprovision_mysql(self, ignore_errors=False):
  210. """
  211. Drop all MySQL databases and users.
  212. """
  213. self.logger.info('Deprovisioning MySQL started.')
  214. if self.mysql_server and self.mysql_provisioned:
  215. try:
  216. cursor = _get_mysql_cursor(self.mysql_server)
  217. # Drop default databases and users
  218. for database in self.mysql_databases:
  219. database_name = database["name"]
  220. _drop_database(cursor, database_name)
  221. _drop_user(cursor, database["user"])
  222. # Drop users with global privileges
  223. for user in self.global_users:
  224. _drop_user(cursor, user)
  225. except MySQLError:
  226. if not ignore_errors:
  227. raise
  228. self.mysql_provisioned = False
  229. self.save()
  230. self.logger.info('Deprovisioning MySQL finished.')
  231. class MongoDBInstanceMixin(models.Model):
  232. """
  233. An instance that uses mongo databases
  234. """
  235. mongodb_server = models.ForeignKey(
  236. MongoDBServer,
  237. null=True,
  238. blank=True,
  239. default=select_random_mongodb_server,
  240. on_delete=models.PROTECT
  241. )
  242. mongodb_replica_set = models.ForeignKey(
  243. MongoDBReplicaSet,
  244. null=True,
  245. blank=True,
  246. default=select_random_mongodb_replica_set,
  247. on_delete=models.PROTECT
  248. )
  249. mongo_user = models.CharField(
  250. max_length=16,
  251. blank=True,
  252. default=functools.partial(get_random_string, length=16, allowed_chars=string.ascii_lowercase),
  253. )
  254. mongo_pass = models.CharField(
  255. max_length=32,
  256. blank=True,
  257. default=functools.partial(get_random_string, length=32),
  258. )
  259. mongo_provisioned = models.BooleanField(default=False)
  260. class Meta:
  261. abstract = True
  262. @property
  263. def mongo_database_names(self):
  264. """
  265. An iterable of database names
  266. """
  267. return NotImplementedError
  268. @property
  269. def mongodb_servers(self):
  270. """
  271. Return all mongodb servers, or just the primary(s) if requested.
  272. If no replicaset configured, this is just the single mongodb server.
  273. """
  274. if self.mongodb_replica_set:
  275. mongodb_servers = MongoDBServer.objects.filter(
  276. replica_set=self.mongodb_replica_set,
  277. )
  278. else:
  279. mongodb_servers = [self.mongodb_server]
  280. return mongodb_servers
  281. @property
  282. def primary_mongodb_server(self):
  283. """
  284. Returns the primary (or single) mongodb server.
  285. """
  286. mongodb_servers = self.mongodb_servers
  287. if self.mongodb_replica_set:
  288. mongodb_servers = mongodb_servers.filter(primary=True)
  289. return mongodb_servers[0]
  290. def _get_main_database_url(self):
  291. """
  292. Returns main database url from replica set, or url from single server
  293. """
  294. try:
  295. return self.primary_mongodb_server.url
  296. except AttributeError:
  297. return None
  298. def provision_mongo(self):
  299. """
  300. Create mongo user and databases
  301. """
  302. database_url = self._get_main_database_url()
  303. if database_url:
  304. mongo = pymongo.MongoClient(database_url)
  305. for database in self.mongo_database_names:
  306. # May update the password if the user already exists
  307. self.logger.info('Creating mongo db: %s', database)
  308. mongo[database].add_user(self.mongo_user, self.mongo_pass)
  309. self.mongo_provisioned = True
  310. self.save()
  311. def deprovision_mongo(self, ignore_errors=False):
  312. """
  313. Drop Mongo databases.
  314. """
  315. self.logger.info('Deprovisioning Mongo started.')
  316. database_url = self._get_main_database_url()
  317. if database_url and self.mongo_provisioned:
  318. mongo = pymongo.MongoClient(database_url)
  319. for database in self.mongo_database_names:
  320. # Dropping a non-existing database is a no-op. Users are dropped together with the DB.
  321. self.logger.info('Dropping mongo db: %s.', database)
  322. try:
  323. mongo.drop_database(database)
  324. except PyMongoError as exc:
  325. self.logger.exception('Cannot drop Mongo database: %s. %s', database, exc)
  326. if not ignore_errors:
  327. raise
  328. self.mongo_provisioned = False
  329. self.save()
  330. self.logger.info('Deprovisioning Mongo finished.')