-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Expand file tree
/
Copy pathdb_backup.py
More file actions
314 lines (282 loc) · 11.6 KB
/
Copy pathdb_backup.py
File metadata and controls
314 lines (282 loc) · 11.6 KB
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
# Copyright 2004-2009 Tiny SPRL (<http://tiny.be>).
# Copyright 2015 Agile Business Group <http://www.agilebg.com>
# Copyright 2016 Grupo ESOC Ingenieria de Servicios, S.L.U. - Jairo Llopis
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import logging
import os
import shutil
import traceback
from contextlib import contextmanager
from datetime import datetime, timedelta
from glob import iglob
import pysftp
from odoo import _, api, exceptions, fields, models, tools
from odoo.exceptions import UserError
from odoo.service import db
_logger = logging.getLogger(__name__)
class DbBackup(models.Model):
_description = "Database Backup"
_name = "db.backup"
_inherit = "mail.thread"
_sql_constraints = [
("name_unique", "UNIQUE(name)", "Cannot duplicate a configuration."),
(
"days_to_keep_positive",
"CHECK(days_to_keep >= 0)",
"I cannot remove backups from the future. Ask Doc for that.",
),
]
name = fields.Char(
compute="_compute_name",
store=True,
help="Summary of this backup process",
)
folder = fields.Char(
default=lambda self: self._default_folder(),
help="Absolute path for storing the backups",
required=True,
)
days_to_keep = fields.Integer(
required=True,
default=0,
help="Backups older than this will be deleted automatically. "
"Set 0 to disable autodeletion.",
)
method = fields.Selection(
[("local", "Local disk"), ("sftp", "Remote SFTP server")],
default="local",
help="Choose the storage method for this backup.",
)
sftp_host = fields.Char(
"SFTP Server",
help=(
"The host name or IP address from your remote"
" server. For example 192.168.0.1"
),
)
sftp_port = fields.Integer(
"SFTP Port",
default=22,
help="The port on the FTP server that accepts SSH/SFTP calls.",
)
sftp_user = fields.Char(
"Username in the SFTP Server",
help=(
"The username where the SFTP connection "
"should be made with. This is the user on the external server."
),
)
sftp_password = fields.Char(
"SFTP Password",
help="The password for the SFTP connection. If you specify a private "
"key file, then this is the password to decrypt it.",
)
sftp_private_key = fields.Char(
"Private key location",
help="Path to the private key file. Only the Odoo user should have "
"read permissions for that file.",
)
backup_format = fields.Selection(
[
("zip", "zip (includes filestore)"),
("dump", "pg_dump custom format (without filestore)"),
],
default="zip",
help="Choose the format for this backup.",
)
@api.model
def _default_folder(self):
"""Default to ``backups`` folder inside current server datadir."""
return os.path.join(tools.config["data_dir"], "backups", self.env.cr.dbname)
@api.depends("folder", "method", "sftp_host", "sftp_port", "sftp_user")
def _compute_name(self):
"""Get the right summary for this job."""
for rec in self:
if rec.method == "local":
rec.name = f"{rec.folder} @ localhost"
elif rec.method == "sftp":
rec.name = f"sftp://{rec.sftp_user}@{rec.sftp_host}:{rec.sftp_port}{rec.folder}"
@api.constrains("folder", "method")
def _check_folder(self):
"""Do not use the filestore or you will backup your backups."""
for record in self:
if record.method == "local" and record.folder.startswith(
tools.config.filestore(self.env.cr.dbname)
):
raise exceptions.ValidationError(
self.env._(
"Do not save backups on your filestore, or you will "
"backup your backups too!"
)
)
def action_sftp_test_connection(self):
"""Check if the SFTP settings are correct."""
try:
# Just open and close the connection
with self.sftp_connection():
raise UserError(_("Connection Test Succeeded!"))
except (
pysftp.CredentialException,
pysftp.ConnectionException,
pysftp.SSHException,
) as exc:
_logger.info("Connection Test Failed!", exc_info=True)
raise UserError(self.env._("Connection Test Failed!")) from exc
def action_backup(self):
"""Run selected backups."""
backup = None
successful = self.browse()
# Start with local storage
for rec in self.filtered(lambda r: r.method == "local"):
filename = self.filename(datetime.now(), ext=rec.backup_format)
with rec.backup_log():
# Directory must exist
try:
os.makedirs(rec.folder, exist_ok=True)
except OSError as exc:
_logger.exception(f"Action backup - OSError: {exc}")
with open(os.path.join(rec.folder, filename), "wb") as destiny:
# Copy the cached backup
if backup:
with open(backup) as cached:
shutil.copyfileobj(cached, destiny)
# Generate new backup
else:
with self._db_management_enabled():
db.dump_db(
self.env.cr.dbname,
destiny,
backup_format=rec.backup_format,
)
backup = backup or destiny.name
successful |= rec
# Ensure a local backup exists if we are going to write it remotely
sftp = self.filtered(lambda r: r.method == "sftp")
if sftp:
for rec in sftp:
filename = self.filename(datetime.now(), ext=rec.backup_format)
with rec.backup_log():
with self._db_management_enabled():
cached = db.dump_db(
self.env.cr.dbname, None, backup_format=rec.backup_format
)
with cached:
with rec.sftp_connection() as remote:
try:
remote.makedirs(rec.folder)
except pysftp.ConnectionException as exc:
_logger.exception(f"pysftp ConnectionException: {exc}")
# Copy cached backup to remote server
with remote.open(
os.path.join(rec.folder, filename), "wb"
) as destiny:
shutil.copyfileobj(cached, destiny)
successful |= rec
# Remove old files for successful backups
successful.cleanup()
@api.model
def action_backup_all(self):
"""Run all scheduled backups."""
return self.search([]).action_backup()
@contextmanager
def _db_management_enabled(self):
"""Temporarily allow database management functions during a backup.
``odoo.service.db.dump_db`` is protected by
``check_db_management_enabled``, which raises ``AccessDenied`` when
``list_db = False`` is set in the Odoo configuration. That option only
aims at hiding database management from the web interface; a scheduled
backup is a trusted server-side operation, so we re-enable the flag for
the duration of the dump and always restore its original value
afterwards.
"""
list_db = tools.config["list_db"]
tools.config["list_db"] = True
try:
yield
finally:
tools.config["list_db"] = list_db
@contextmanager
def backup_log(self):
"""Log a backup result."""
try:
_logger.info(f"Starting database backup: {self.name}")
yield
except Exception:
_logger.exception(f"Database backup failed: {self.name}")
escaped_tb = tools.html_escape(traceback.format_exc())
self.message_post( # pylint: disable=translation-required
body=f"<p>{_('Database backup failed.')}</p><pre>{escaped_tb}</pre>",
subtype_id=self.env.ref("auto_backup.mail_message_subtype_failure").id,
)
else:
_logger.info(f"Database backup succeeded: {self.name}")
self.message_post(body=_("Database backup succeeded."))
def cleanup(self):
"""Clean up old backups."""
now = datetime.now()
for rec in self.filtered("days_to_keep"):
with rec.cleanup_log():
bu_format = rec.backup_format
file_extension = "dump.zip" if bu_format == "zip" else bu_format
oldest = self.filename(
now - timedelta(days=rec.days_to_keep), bu_format
)
if rec.method == "local":
for name in iglob(os.path.join(rec.folder, f"*.{file_extension}")):
if os.path.basename(name) < oldest:
os.unlink(name)
elif rec.method == "sftp":
with rec.sftp_connection() as remote:
for name in remote.listdir(rec.folder):
if (
name.endswith(f".{file_extension}")
and os.path.basename(name) < oldest
):
remote.unlink(f"{rec.folder}/{name}")
@contextmanager
def cleanup_log(self):
"""Log a possible cleanup failure."""
self.ensure_one()
try:
_logger.info(f"Starting cleanup process after database backup: {self.name}")
yield
except Exception:
_logger.exception(f"Cleanup of old database backups failed: {self.name}")
escaped_tb = tools.html_escape(traceback.format_exc())
self.message_post( # pylint: disable=translation-required
body=(
f"<p>{_('Cleanup of old database backups failed.')}</p>"
f"<pre>{escaped_tb}</pre>"
),
subtype_id=self.env.ref("auto_backup.failure").id,
)
else:
_logger.info(f"Cleanup of old database backups succeeded: {self.name}")
@staticmethod
def filename(when, ext="zip"):
"""Generate a file name for a backup.
:param datetime.datetime when:
Use this datetime instead of :meth:`datetime.datetime.now`.
:param str ext: Extension of the file. Default: dump.zip
"""
return "{:%Y_%m_%d_%H_%M_%S}.{ext}".format(
when, ext="dump.zip" if ext == "zip" else ext
)
def sftp_connection(self):
"""Return a new SFTP connection with found parameters."""
self.ensure_one()
params = {
"host": self.sftp_host,
"username": self.sftp_user,
"port": self.sftp_port,
}
_logger.debug(
"Trying to connect to sftp://%(username)s@%(host)s:%(port)d", extra=params
)
if self.sftp_private_key:
params["private_key"] = self.sftp_private_key
if self.sftp_password:
params["private_key_pass"] = self.sftp_password
else:
params["password"] = self.sftp_password
return pysftp.Connection(**params)