You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

304 lines
11 KiB

  1. # © 2004-2009 Tiny SPRL (<http://tiny.be>).
  2. # © 2015 Agile Business Group <http://www.agilebg.com>
  3. # © 2016 Grupo ESOC Ingeniería de Servicios, S.L.U. - Jairo Llopis
  4. # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
  5. import logging
  6. import os
  7. import shutil
  8. import traceback
  9. from contextlib import contextmanager
  10. from datetime import datetime, timedelta
  11. from glob import iglob
  12. from odoo import _, api, exceptions, fields, models, tools
  13. from odoo.service import db
  14. _logger = logging.getLogger(__name__)
  15. try:
  16. import pysftp
  17. except ImportError: # pragma: no cover
  18. _logger.debug('Cannot import pysftp')
  19. class DbBackup(models.Model):
  20. _description = 'Database Backup'
  21. _name = 'db.backup'
  22. _inherit = "mail.thread"
  23. _sql_constraints = [
  24. ("name_unique", "UNIQUE(name)", "Cannot duplicate a configuration."),
  25. ("days_to_keep_positive", "CHECK(days_to_keep >= 0)",
  26. "I cannot remove backups from the future. Ask Doc for that."),
  27. ]
  28. name = fields.Char(
  29. compute="_compute_name",
  30. store=True,
  31. help="Summary of this backup process",
  32. )
  33. folder = fields.Char(
  34. default=lambda self: self._default_folder(),
  35. help='Absolute path for storing the backups',
  36. required=True
  37. )
  38. days_to_keep = fields.Integer(
  39. required=True,
  40. default=0,
  41. help="Backups older than this will be deleted automatically. "
  42. "Set 0 to disable autodeletion.",
  43. )
  44. method = fields.Selection(
  45. [("local", "Local disk"), ("sftp", "Remote SFTP server")],
  46. default="local",
  47. help="Choose the storage method for this backup.",
  48. )
  49. sftp_host = fields.Char(
  50. 'SFTP Server',
  51. help=(
  52. "The host name or IP address from your remote"
  53. " server. For example 192.168.0.1"
  54. )
  55. )
  56. sftp_port = fields.Integer(
  57. "SFTP Port",
  58. default=22,
  59. help="The port on the FTP server that accepts SSH/SFTP calls."
  60. )
  61. sftp_user = fields.Char(
  62. 'Username in the SFTP Server',
  63. help=(
  64. "The username where the SFTP connection "
  65. "should be made with. This is the user on the external server."
  66. )
  67. )
  68. sftp_password = fields.Char(
  69. "SFTP Password",
  70. help="The password for the SFTP connection. If you specify a private "
  71. "key file, then this is the password to decrypt it.",
  72. )
  73. sftp_private_key = fields.Char(
  74. "Private key location",
  75. help="Path to the private key file. Only the Odoo user should have "
  76. "read permissions for that file.",
  77. )
  78. backup_format = fields.Selection(
  79. [
  80. ("zip", "zip (includes filestore)"),
  81. ("dump", "pg_dump custom format (without filestore)")
  82. ],
  83. default='zip',
  84. help="Choose the format for this backup."
  85. )
  86. @api.model
  87. def _default_folder(self):
  88. """Default to ``backups`` folder inside current server datadir."""
  89. return os.path.join(
  90. tools.config["data_dir"],
  91. "backups",
  92. self.env.cr.dbname)
  93. @api.multi
  94. @api.depends("folder", "method", "sftp_host", "sftp_port", "sftp_user")
  95. def _compute_name(self):
  96. """Get the right summary for this job."""
  97. for rec in self:
  98. if rec.method == "local":
  99. rec.name = "%s @ localhost" % rec.folder
  100. elif rec.method == "sftp":
  101. rec.name = "sftp://%s@%s:%d%s" % (
  102. rec.sftp_user, rec.sftp_host, rec.sftp_port, rec.folder)
  103. @api.multi
  104. @api.constrains("folder", "method")
  105. def _check_folder(self):
  106. """Do not use the filestore or you will backup your backups."""
  107. for record in self:
  108. if (record.method == "local" and
  109. record.folder.startswith(
  110. tools.config.filestore(self.env.cr.dbname))):
  111. raise exceptions.ValidationError(
  112. _("Do not save backups on your filestore, or you will "
  113. "backup your backups too!"))
  114. @api.multi
  115. def action_sftp_test_connection(self):
  116. """Check if the SFTP settings are correct."""
  117. try:
  118. # Just open and close the connection
  119. with self.sftp_connection():
  120. raise exceptions.Warning(_("Connection Test Succeeded!"))
  121. except (pysftp.CredentialException,
  122. pysftp.ConnectionException,
  123. pysftp.SSHException):
  124. _logger.info("Connection Test Failed!", exc_info=True)
  125. raise exceptions.Warning(_("Connection Test Failed!"))
  126. @api.multi
  127. def action_backup(self):
  128. """Run selected backups."""
  129. backup = None
  130. successful = self.browse()
  131. # Start with local storage
  132. for rec in self.filtered(lambda r: r.method == "local"):
  133. filename = self.filename(datetime.now(), ext=rec.backup_format)
  134. with rec.backup_log():
  135. # Directory must exist
  136. try:
  137. os.makedirs(rec.folder)
  138. except OSError:
  139. pass
  140. with open(os.path.join(rec.folder, filename),
  141. 'wb') as destiny:
  142. # Copy the cached backup
  143. if backup:
  144. with open(backup) as cached:
  145. shutil.copyfileobj(cached, destiny)
  146. # Generate new backup
  147. else:
  148. db.dump_db(
  149. self.env.cr.dbname,
  150. destiny,
  151. backup_format=rec.backup_format
  152. )
  153. backup = backup or destiny.name
  154. successful |= rec
  155. # Ensure a local backup exists if we are going to write it remotely
  156. sftp = self.filtered(lambda r: r.method == "sftp")
  157. if sftp:
  158. for rec in sftp:
  159. filename = self.filename(datetime.now(), ext=rec.backup_format)
  160. with rec.backup_log():
  161. cached = db.dump_db(
  162. self.env.cr.dbname,
  163. None,
  164. backup_format=rec.backup_format
  165. )
  166. with cached:
  167. with rec.sftp_connection() as remote:
  168. # Directory must exist
  169. try:
  170. remote.makedirs(rec.folder)
  171. except pysftp.ConnectionException:
  172. pass
  173. # Copy cached backup to remote server
  174. with remote.open(
  175. os.path.join(rec.folder, filename),
  176. "wb") as destiny:
  177. shutil.copyfileobj(cached, destiny)
  178. successful |= rec
  179. # Remove old files for successful backups
  180. successful.cleanup()
  181. @api.model
  182. def action_backup_all(self):
  183. """Run all scheduled backups."""
  184. return self.search([]).action_backup()
  185. @api.multi
  186. @contextmanager
  187. def backup_log(self):
  188. """Log a backup result."""
  189. try:
  190. _logger.info("Starting database backup: %s", self.name)
  191. yield
  192. except Exception:
  193. _logger.exception("Database backup failed: %s", self.name)
  194. escaped_tb = tools.html_escape(traceback.format_exc())
  195. self.message_post( # pylint: disable=translation-required
  196. "<p>%s</p><pre>%s</pre>" % (
  197. _("Database backup failed."),
  198. escaped_tb),
  199. subtype=self.env.ref(
  200. "auto_backup.mail_message_subtype_failure"
  201. ),
  202. )
  203. else:
  204. _logger.info("Database backup succeeded: %s", self.name)
  205. self.message_post(_("Database backup succeeded."))
  206. @api.multi
  207. def cleanup(self):
  208. """Clean up old backups."""
  209. now = datetime.now()
  210. for rec in self.filtered("days_to_keep"):
  211. with rec.cleanup_log():
  212. oldest = self.filename(now - timedelta(days=rec.days_to_keep))
  213. if rec.method == "local":
  214. for name in iglob(os.path.join(rec.folder,
  215. "*.dump.zip")):
  216. if os.path.basename(name) < oldest:
  217. os.unlink(name)
  218. elif rec.method == "sftp":
  219. with rec.sftp_connection() as remote:
  220. for name in remote.listdir(rec.folder):
  221. if (name.endswith(".dump.zip") and
  222. os.path.basename(name) < oldest):
  223. remote.unlink('%s/%s' % (rec.folder, name))
  224. @api.multi
  225. @contextmanager
  226. def cleanup_log(self):
  227. """Log a possible cleanup failure."""
  228. self.ensure_one()
  229. try:
  230. _logger.info(
  231. "Starting cleanup process after database backup: %s",
  232. self.name)
  233. yield
  234. except Exception:
  235. _logger.exception("Cleanup of old database backups failed: %s")
  236. escaped_tb = tools.html_escape(traceback.format_exc())
  237. self.message_post( # pylint: disable=translation-required
  238. "<p>%s</p><pre>%s</pre>" % (
  239. _("Cleanup of old database backups failed."),
  240. escaped_tb),
  241. subtype=self.env.ref("auto_backup.failure"))
  242. else:
  243. _logger.info(
  244. "Cleanup of old database backups succeeded: %s",
  245. self.name)
  246. @staticmethod
  247. def filename(when, ext='zip'):
  248. """Generate a file name for a backup.
  249. :param datetime.datetime when:
  250. Use this datetime instead of :meth:`datetime.datetime.now`.
  251. :param str ext: Extension of the file. Default: dump.zip
  252. """
  253. return "{:%Y_%m_%d_%H_%M_%S}.{ext}".format(
  254. when, ext='dump.zip' if ext == 'zip' else ext
  255. )
  256. @api.multi
  257. def sftp_connection(self):
  258. """Return a new SFTP connection with found parameters."""
  259. self.ensure_one()
  260. params = {
  261. "host": self.sftp_host,
  262. "username": self.sftp_user,
  263. "port": self.sftp_port,
  264. }
  265. _logger.debug(
  266. "Trying to connect to sftp://%(username)s@%(host)s:%(port)d",
  267. extra=params)
  268. if self.sftp_private_key:
  269. params["private_key"] = self.sftp_private_key
  270. if self.sftp_password:
  271. params["private_key_pass"] = self.sftp_password
  272. else:
  273. params["password"] = self.sftp_password
  274. return pysftp.Connection(**params)