1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38 """
39 Provides an extension to back up MySQL databases.
40
41 This is a Cedar Backup extension used to back up MySQL databases via the Cedar
42 Backup command line. It requires a new configuration section <mysql> and is
43 intended to be run either immediately before or immediately after the standard
44 collect action. Aside from its own configuration, it requires the options and
45 collect configuration sections in the standard Cedar Backup configuration file.
46
47 The backup is done via the C{mysqldump} command included with the MySQL
48 product. Output can be compressed using C{gzip} or C{bzip2}. Administrators
49 can configure the extension either to back up all databases or to back up only
50 specific databases. Note that this code always produces a full backup. There
51 is currently no facility for making incremental backups. If/when someone has a
52 need for this and can describe how to do it, I'll update this extension or
53 provide another.
54
55 The extension assumes that all configured databases can be backed up by a
56 single user. Often, the "root" database user will be used. An alternative is
57 to create a separate MySQL "backup" user and grant that user rights to read
58 (but not write) various databases as needed. This second option is probably
59 the best choice.
60
61 The extension accepts a username and password in configuration. However, you
62 probably do not want to provide those values in Cedar Backup configuration.
63 This is because Cedar Backup will provide these values to C{mysqldump} via the
64 command-line C{--user} and C{--password} switches, which will be visible to
65 other users in the process listing.
66
67 Instead, you should configure the username and password in one of MySQL's
68 configuration files. Typically, that would be done by putting a stanza like
69 this in C{/root/.my.cnf}::
70
71 [mysqldump]
72 user = root
73 password = <secret>
74
75 Regardless of whether you are using C{~/.my.cnf} or C{/etc/cback3.conf} to store
76 database login and password information, you should be careful about who is
77 allowed to view that information. Typically, this means locking down
78 permissions so that only the file owner can read the file contents (i.e. use
79 mode C{0600}).
80
81 @author: Kenneth J. Pronovici <pronovic@ieee.org>
82 """
83
84
85
86
87
88
89 import os
90 import logging
91 from gzip import GzipFile
92 from bz2 import BZ2File
93 from functools import total_ordering
94
95
96 from CedarBackup3.xmlutil import createInputDom, addContainerNode, addStringNode, addBooleanNode
97 from CedarBackup3.xmlutil import readFirstChild, readString, readStringList, readBoolean
98 from CedarBackup3.config import VALID_COMPRESS_MODES
99 from CedarBackup3.util import resolveCommand, executeCommand
100 from CedarBackup3.util import ObjectTypeList, changeOwnership
101
102
103
104
105
106
107 logger = logging.getLogger("CedarBackup3.log.extend.mysql")
108 MYSQLDUMP_COMMAND = [ "mysqldump", ]
109
110
111
112
113
114
115 @total_ordering
116 -class MysqlConfig(object):
117
118 """
119 Class representing MySQL configuration.
120
121 The MySQL configuration information is used for backing up MySQL databases.
122
123 The following restrictions exist on data in this class:
124
125 - The compress mode must be one of the values in L{VALID_COMPRESS_MODES}.
126 - The 'all' flag must be 'Y' if no databases are defined.
127 - The 'all' flag must be 'N' if any databases are defined.
128 - Any values in the databases list must be strings.
129
130 @sort: __init__, __repr__, __str__, __cmp__, __eq__, __lt__, __gt__, user,
131 password, all, databases
132 """
133
134 - def __init__(self, user=None, password=None, compressMode=None, all=None, databases=None):
135 """
136 Constructor for the C{MysqlConfig} class.
137
138 @param user: User to execute backup as.
139 @param password: Password associated with user.
140 @param compressMode: Compress mode for backed-up files.
141 @param all: Indicates whether to back up all databases.
142 @param databases: List of databases to back up.
143 """
144 self._user = None
145 self._password = None
146 self._compressMode = None
147 self._all = None
148 self._databases = None
149 self.user = user
150 self.password = password
151 self.compressMode = compressMode
152 self.all = all
153 self.databases = databases
154
156 """
157 Official string representation for class instance.
158 """
159 return "MysqlConfig(%s, %s, %s, %s)" % (self.user, self.password, self.all, self.databases)
160
162 """
163 Informal string representation for class instance.
164 """
165 return self.__repr__()
166
168 """Equals operator, iplemented in terms of original Python 2 compare operator."""
169 return self.__cmp__(other) == 0
170
172 """Less-than operator, iplemented in terms of original Python 2 compare operator."""
173 return self.__cmp__(other) < 0
174
176 """Greater-than operator, iplemented in terms of original Python 2 compare operator."""
177 return self.__cmp__(other) > 0
178
180 """
181 Original Python 2 comparison operator.
182 @param other: Other object to compare to.
183 @return: -1/0/1 depending on whether self is C{<}, C{=} or C{>} other.
184 """
185 if other is None:
186 return 1
187 if self.user != other.user:
188 if str(self.user or "") < str(other.user or ""):
189 return -1
190 else:
191 return 1
192 if self.password != other.password:
193 if str(self.password or "") < str(other.password or ""):
194 return -1
195 else:
196 return 1
197 if self.compressMode != other.compressMode:
198 if str(self.compressMode or "") < str(other.compressMode or ""):
199 return -1
200 else:
201 return 1
202 if self.all != other.all:
203 if self.all < other.all:
204 return -1
205 else:
206 return 1
207 if self.databases != other.databases:
208 if self.databases < other.databases:
209 return -1
210 else:
211 return 1
212 return 0
213
215 """
216 Property target used to set the user value.
217 """
218 if value is not None:
219 if len(value) < 1:
220 raise ValueError("User must be non-empty string.")
221 self._user = value
222
224 """
225 Property target used to get the user value.
226 """
227 return self._user
228
230 """
231 Property target used to set the password value.
232 """
233 if value is not None:
234 if len(value) < 1:
235 raise ValueError("Password must be non-empty string.")
236 self._password = value
237
239 """
240 Property target used to get the password value.
241 """
242 return self._password
243
245 """
246 Property target used to set the compress mode.
247 If not C{None}, the mode must be one of the values in L{VALID_COMPRESS_MODES}.
248 @raise ValueError: If the value is not valid.
249 """
250 if value is not None:
251 if value not in VALID_COMPRESS_MODES:
252 raise ValueError("Compress mode must be one of %s." % VALID_COMPRESS_MODES)
253 self._compressMode = value
254
256 """
257 Property target used to get the compress mode.
258 """
259 return self._compressMode
260
262 """
263 Property target used to set the 'all' flag.
264 No validations, but we normalize the value to C{True} or C{False}.
265 """
266 if value:
267 self._all = True
268 else:
269 self._all = False
270
272 """
273 Property target used to get the 'all' flag.
274 """
275 return self._all
276
278 """
279 Property target used to set the databases list.
280 Either the value must be C{None} or each element must be a string.
281 @raise ValueError: If the value is not a string.
282 """
283 if value is None:
284 self._databases = None
285 else:
286 for database in value:
287 if len(database) < 1:
288 raise ValueError("Each database must be a non-empty string.")
289 try:
290 saved = self._databases
291 self._databases = ObjectTypeList(str, "string")
292 self._databases.extend(value)
293 except Exception as e:
294 self._databases = saved
295 raise e
296
298 """
299 Property target used to get the databases list.
300 """
301 return self._databases
302
303 user = property(_getUser, _setUser, None, "User to execute backup as.")
304 password = property(_getPassword, _setPassword, None, "Password associated with user.")
305 compressMode = property(_getCompressMode, _setCompressMode, None, "Compress mode to be used for backed-up files.")
306 all = property(_getAll, _setAll, None, "Indicates whether to back up all databases.")
307 databases = property(_getDatabases, _setDatabases, None, "List of databases to back up.")
308
309
310
311
312
313
314 @total_ordering
315 -class LocalConfig(object):
316
317 """
318 Class representing this extension's configuration document.
319
320 This is not a general-purpose configuration object like the main Cedar
321 Backup configuration object. Instead, it just knows how to parse and emit
322 MySQL-specific configuration values. Third parties who need to read and
323 write configuration related to this extension should access it through the
324 constructor, C{validate} and C{addConfig} methods.
325
326 @note: Lists within this class are "unordered" for equality comparisons.
327
328 @sort: __init__, __repr__, __str__, __cmp__, __eq__, __lt__, __gt__, mysql,
329 validate, addConfig
330 """
331
332 - def __init__(self, xmlData=None, xmlPath=None, validate=True):
333 """
334 Initializes a configuration object.
335
336 If you initialize the object without passing either C{xmlData} or
337 C{xmlPath} then configuration will be empty and will be invalid until it
338 is filled in properly.
339
340 No reference to the original XML data or original path is saved off by
341 this class. Once the data has been parsed (successfully or not) this
342 original information is discarded.
343
344 Unless the C{validate} argument is C{False}, the L{LocalConfig.validate}
345 method will be called (with its default arguments) against configuration
346 after successfully parsing any passed-in XML. Keep in mind that even if
347 C{validate} is C{False}, it might not be possible to parse the passed-in
348 XML document if lower-level validations fail.
349
350 @note: It is strongly suggested that the C{validate} option always be set
351 to C{True} (the default) unless there is a specific need to read in
352 invalid configuration from disk.
353
354 @param xmlData: XML data representing configuration.
355 @type xmlData: String data.
356
357 @param xmlPath: Path to an XML file on disk.
358 @type xmlPath: Absolute path to a file on disk.
359
360 @param validate: Validate the document after parsing it.
361 @type validate: Boolean true/false.
362
363 @raise ValueError: If both C{xmlData} and C{xmlPath} are passed-in.
364 @raise ValueError: If the XML data in C{xmlData} or C{xmlPath} cannot be parsed.
365 @raise ValueError: If the parsed configuration document is not valid.
366 """
367 self._mysql = None
368 self.mysql = None
369 if xmlData is not None and xmlPath is not None:
370 raise ValueError("Use either xmlData or xmlPath, but not both.")
371 if xmlData is not None:
372 self._parseXmlData(xmlData)
373 if validate:
374 self.validate()
375 elif xmlPath is not None:
376 xmlData = open(xmlPath).read()
377 self._parseXmlData(xmlData)
378 if validate:
379 self.validate()
380
382 """
383 Official string representation for class instance.
384 """
385 return "LocalConfig(%s)" % (self.mysql)
386
388 """
389 Informal string representation for class instance.
390 """
391 return self.__repr__()
392
394 """Equals operator, iplemented in terms of original Python 2 compare operator."""
395 return self.__cmp__(other) == 0
396
398 """Less-than operator, iplemented in terms of original Python 2 compare operator."""
399 return self.__cmp__(other) < 0
400
402 """Greater-than operator, iplemented in terms of original Python 2 compare operator."""
403 return self.__cmp__(other) > 0
404
406 """
407 Original Python 2 comparison operator.
408 Lists within this class are "unordered" for equality comparisons.
409 @param other: Other object to compare to.
410 @return: -1/0/1 depending on whether self is C{<}, C{=} or C{>} other.
411 """
412 if other is None:
413 return 1
414 if self.mysql != other.mysql:
415 if self.mysql < other.mysql:
416 return -1
417 else:
418 return 1
419 return 0
420
422 """
423 Property target used to set the mysql configuration value.
424 If not C{None}, the value must be a C{MysqlConfig} object.
425 @raise ValueError: If the value is not a C{MysqlConfig}
426 """
427 if value is None:
428 self._mysql = None
429 else:
430 if not isinstance(value, MysqlConfig):
431 raise ValueError("Value must be a C{MysqlConfig} object.")
432 self._mysql = value
433
435 """
436 Property target used to get the mysql configuration value.
437 """
438 return self._mysql
439
440 mysql = property(_getMysql, _setMysql, None, "Mysql configuration in terms of a C{MysqlConfig} object.")
441
443 """
444 Validates configuration represented by the object.
445
446 The compress mode must be filled in. Then, if the 'all' flag I{is} set,
447 no databases are allowed, and if the 'all' flag is I{not} set, at least
448 one database is required.
449
450 @raise ValueError: If one of the validations fails.
451 """
452 if self.mysql is None:
453 raise ValueError("Mysql section is required.")
454 if self.mysql.compressMode is None:
455 raise ValueError("Compress mode value is required.")
456 if self.mysql.all:
457 if self.mysql.databases is not None and self.mysql.databases != []:
458 raise ValueError("Databases cannot be specified if 'all' flag is set.")
459 else:
460 if self.mysql.databases is None or len(self.mysql.databases) < 1:
461 raise ValueError("At least one MySQL database must be indicated if 'all' flag is not set.")
462
464 """
465 Adds a <mysql> configuration section as the next child of a parent.
466
467 Third parties should use this function to write configuration related to
468 this extension.
469
470 We add the following fields to the document::
471
472 user //cb_config/mysql/user
473 password //cb_config/mysql/password
474 compressMode //cb_config/mysql/compress_mode
475 all //cb_config/mysql/all
476
477 We also add groups of the following items, one list element per
478 item::
479
480 database //cb_config/mysql/database
481
482 @param xmlDom: DOM tree as from C{impl.createDocument()}.
483 @param parentNode: Parent that the section should be appended to.
484 """
485 if self.mysql is not None:
486 sectionNode = addContainerNode(xmlDom, parentNode, "mysql")
487 addStringNode(xmlDom, sectionNode, "user", self.mysql.user)
488 addStringNode(xmlDom, sectionNode, "password", self.mysql.password)
489 addStringNode(xmlDom, sectionNode, "compress_mode", self.mysql.compressMode)
490 addBooleanNode(xmlDom, sectionNode, "all", self.mysql.all)
491 if self.mysql.databases is not None:
492 for database in self.mysql.databases:
493 addStringNode(xmlDom, sectionNode, "database", database)
494
496 """
497 Internal method to parse an XML string into the object.
498
499 This method parses the XML document into a DOM tree (C{xmlDom}) and then
500 calls a static method to parse the mysql configuration section.
501
502 @param xmlData: XML data to be parsed
503 @type xmlData: String data
504
505 @raise ValueError: If the XML cannot be successfully parsed.
506 """
507 (xmlDom, parentNode) = createInputDom(xmlData)
508 self._mysql = LocalConfig._parseMysql(parentNode)
509
510 @staticmethod
512 """
513 Parses a mysql configuration section.
514
515 We read the following fields::
516
517 user //cb_config/mysql/user
518 password //cb_config/mysql/password
519 compressMode //cb_config/mysql/compress_mode
520 all //cb_config/mysql/all
521
522 We also read groups of the following item, one list element per
523 item::
524
525 databases //cb_config/mysql/database
526
527 @param parentNode: Parent node to search beneath.
528
529 @return: C{MysqlConfig} object or C{None} if the section does not exist.
530 @raise ValueError: If some filled-in value is invalid.
531 """
532 mysql = None
533 section = readFirstChild(parentNode, "mysql")
534 if section is not None:
535 mysql = MysqlConfig()
536 mysql.user = readString(section, "user")
537 mysql.password = readString(section, "password")
538 mysql.compressMode = readString(section, "compress_mode")
539 mysql.all = readBoolean(section, "all")
540 mysql.databases = readStringList(section, "database")
541 return mysql
542
543
544
545
546
547
548
549
550
551
552 -def executeAction(configPath, options, config):
553 """
554 Executes the MySQL backup action.
555
556 @param configPath: Path to configuration file on disk.
557 @type configPath: String representing a path on disk.
558
559 @param options: Program command-line options.
560 @type options: Options object.
561
562 @param config: Program configuration.
563 @type config: Config object.
564
565 @raise ValueError: Under many generic error conditions
566 @raise IOError: If a backup could not be written for some reason.
567 """
568 logger.debug("Executing MySQL extended action.")
569 if config.options is None or config.collect is None:
570 raise ValueError("Cedar Backup configuration is not properly filled in.")
571 local = LocalConfig(xmlPath=configPath)
572 if local.mysql.all:
573 logger.info("Backing up all databases.")
574 _backupDatabase(config.collect.targetDir, local.mysql.compressMode, local.mysql.user, local.mysql.password,
575 config.options.backupUser, config.options.backupGroup, None)
576 else:
577 logger.debug("Backing up %d individual databases.", len(local.mysql.databases))
578 for database in local.mysql.databases:
579 logger.info("Backing up database [%s].", database)
580 _backupDatabase(config.collect.targetDir, local.mysql.compressMode, local.mysql.user, local.mysql.password,
581 config.options.backupUser, config.options.backupGroup, database)
582 logger.info("Executed the MySQL extended action successfully.")
583
584 -def _backupDatabase(targetDir, compressMode, user, password, backupUser, backupGroup, database=None):
585 """
586 Backs up an individual MySQL database, or all databases.
587
588 This internal method wraps the public method and adds some functionality,
589 like figuring out a filename, etc.
590
591 @param targetDir: Directory into which backups should be written.
592 @param compressMode: Compress mode to be used for backed-up files.
593 @param user: User to use for connecting to the database (if any).
594 @param password: Password associated with user (if any).
595 @param backupUser: User to own resulting file.
596 @param backupGroup: Group to own resulting file.
597 @param database: Name of database, or C{None} for all databases.
598
599 @return: Name of the generated backup file.
600
601 @raise ValueError: If some value is missing or invalid.
602 @raise IOError: If there is a problem executing the MySQL dump.
603 """
604 (outputFile, filename) = _getOutputFile(targetDir, database, compressMode)
605 try:
606 backupDatabase(user, password, outputFile, database)
607 finally:
608 outputFile.close()
609 if not os.path.exists(filename):
610 raise IOError("Dump file [%s] does not seem to exist after backup completed." % filename)
611 changeOwnership(filename, backupUser, backupGroup)
612
614 """
615 Opens the output file used for saving the MySQL dump.
616
617 The filename is either C{"mysqldump.txt"} or C{"mysqldump-<database>.txt"}. The
618 C{".bz2"} extension is added if C{compress} is C{True}.
619
620 @param targetDir: Target directory to write file in.
621 @param database: Name of the database (if any)
622 @param compressMode: Compress mode to be used for backed-up files.
623
624 @return: Tuple of (Output file object, filename)
625 """
626 if database is None:
627 filename = os.path.join(targetDir, "mysqldump.txt")
628 else:
629 filename = os.path.join(targetDir, "mysqldump-%s.txt" % database)
630 if compressMode == "gzip":
631 filename = "%s.gz" % filename
632 outputFile = GzipFile(filename, "w")
633 elif compressMode == "bzip2":
634 filename = "%s.bz2" % filename
635 outputFile = BZ2File(filename, "w")
636 else:
637 outputFile = open(filename, "wb")
638 logger.debug("MySQL dump file will be [%s].", filename)
639 return (outputFile, filename)
640
641
642
643
644
645
646 -def backupDatabase(user, password, backupFile, database=None):
647 """
648 Backs up an individual MySQL database, or all databases.
649
650 This function backs up either a named local MySQL database or all local
651 MySQL databases, using the passed-in user and password (if provided) for
652 connectivity. This function call I{always} results a full backup. There is
653 no facility for incremental backups.
654
655 The backup data will be written into the passed-in backup file. Normally,
656 this would be an object as returned from C{open()}, but it is possible to
657 use something like a C{GzipFile} to write compressed output. The caller is
658 responsible for closing the passed-in backup file.
659
660 Often, the "root" database user will be used when backing up all databases.
661 An alternative is to create a separate MySQL "backup" user and grant that
662 user rights to read (but not write) all of the databases that will be backed
663 up.
664
665 This function accepts a username and password. However, you probably do not
666 want to pass those values in. This is because they will be provided to
667 C{mysqldump} via the command-line C{--user} and C{--password} switches,
668 which will be visible to other users in the process listing.
669
670 Instead, you should configure the username and password in one of MySQL's
671 configuration files. Typically, this would be done by putting a stanza like
672 this in C{/root/.my.cnf}, to provide C{mysqldump} with the root database
673 username and its password::
674
675 [mysqldump]
676 user = root
677 password = <secret>
678
679 If you are executing this function as some system user other than root, then
680 the C{.my.cnf} file would be placed in the home directory of that user. In
681 either case, make sure to set restrictive permissions (typically, mode
682 C{0600}) on C{.my.cnf} to make sure that other users cannot read the file.
683
684 @param user: User to use for connecting to the database (if any)
685 @type user: String representing MySQL username, or C{None}
686
687 @param password: Password associated with user (if any)
688 @type password: String representing MySQL password, or C{None}
689
690 @param backupFile: File use for writing backup.
691 @type backupFile: Python file object as from C{open()} or C{file()}.
692
693 @param database: Name of the database to be backed up.
694 @type database: String representing database name, or C{None} for all databases.
695
696 @raise ValueError: If some value is missing or invalid.
697 @raise IOError: If there is a problem executing the MySQL dump.
698 """
699 args = [ "-all", "--flush-logs", "--opt", ]
700 if user is not None:
701 logger.warn("Warning: MySQL username will be visible in process listing (consider using ~/.my.cnf).")
702 args.append("--user=%s" % user)
703 if password is not None:
704 logger.warn("Warning: MySQL password will be visible in process listing (consider using ~/.my.cnf).")
705 args.append("--password=%s" % password)
706 if database is None:
707 args.insert(0, "--all-databases")
708 else:
709 args.insert(0, "--databases")
710 args.append(database)
711 command = resolveCommand(MYSQLDUMP_COMMAND)
712 result = executeCommand(command, args, returnOutput=False, ignoreStderr=True, doNotLog=True, outputFile=backupFile)[0]
713 if result != 0:
714 if database is None:
715 raise IOError("Error [%d] executing MySQL database dump for all databases." % result)
716 else:
717 raise IOError("Error [%d] executing MySQL database dump for database [%s]." % (result, database))
718