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.
 
 
 
 
 
 

727 regels
19 KiB

  1. # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
  2. # MIT License. See license.txt
  3. # imports - standard imports
  4. import gzip
  5. import os
  6. from calendar import timegm
  7. from datetime import datetime
  8. from glob import glob
  9. from shutil import which
  10. # imports - third party imports
  11. import click
  12. # imports - module imports
  13. import frappe
  14. from frappe import _, conf
  15. from frappe.utils import get_file_size, get_url, now, now_datetime
  16. # backup variable for backwards compatibility
  17. verbose = False
  18. compress = False
  19. _verbose = verbose
  20. base_tables = ["__Auth", "__global_search", "__UserSettings"]
  21. class BackupGenerator:
  22. """
  23. This class contains methods to perform On Demand Backup
  24. To initialize, specify (db_name, user, password, db_file_name=None, db_host="localhost")
  25. If specifying db_file_name, also append ".sql.gz"
  26. """
  27. def __init__(
  28. self,
  29. db_name,
  30. user,
  31. password,
  32. backup_path=None,
  33. backup_path_db=None,
  34. backup_path_files=None,
  35. backup_path_private_files=None,
  36. db_host="localhost",
  37. db_port=None,
  38. db_type="mariadb",
  39. backup_path_conf=None,
  40. ignore_conf=False,
  41. compress_files=False,
  42. include_doctypes="",
  43. exclude_doctypes="",
  44. verbose=False,
  45. ):
  46. global _verbose
  47. self.compress_files = compress_files or compress
  48. self.db_host = db_host
  49. self.db_port = db_port
  50. self.db_name = db_name
  51. self.db_type = db_type
  52. self.user = user
  53. self.password = password
  54. self.backup_path = backup_path
  55. self.backup_path_conf = backup_path_conf
  56. self.backup_path_db = backup_path_db
  57. self.backup_path_files = backup_path_files
  58. self.backup_path_private_files = backup_path_private_files
  59. self.ignore_conf = ignore_conf
  60. self.include_doctypes = include_doctypes
  61. self.exclude_doctypes = exclude_doctypes
  62. self.partial = False
  63. if not self.db_type:
  64. self.db_type = "mariadb"
  65. if not self.db_port:
  66. if self.db_type == "mariadb":
  67. self.db_port = 3306
  68. if self.db_type == "postgres":
  69. self.db_port = 5432
  70. site = frappe.local.site or frappe.generate_hash(length=8)
  71. self.site_slug = site.replace(".", "_")
  72. self.verbose = verbose
  73. self.setup_backup_directory()
  74. self.setup_backup_tables()
  75. _verbose = verbose
  76. def setup_backup_directory(self):
  77. specified = (
  78. self.backup_path
  79. or self.backup_path_db
  80. or self.backup_path_files
  81. or self.backup_path_private_files
  82. or self.backup_path_conf
  83. )
  84. if not specified:
  85. backups_folder = get_backup_path()
  86. if not os.path.exists(backups_folder):
  87. os.makedirs(backups_folder, exist_ok=True)
  88. else:
  89. if self.backup_path:
  90. os.makedirs(self.backup_path, exist_ok=True)
  91. for file_path in set(
  92. [
  93. self.backup_path_files,
  94. self.backup_path_db,
  95. self.backup_path_private_files,
  96. self.backup_path_conf,
  97. ]
  98. ):
  99. if file_path:
  100. dir = os.path.dirname(file_path)
  101. os.makedirs(dir, exist_ok=True)
  102. def setup_backup_tables(self):
  103. """Sets self.backup_includes, self.backup_excludes based on passed args"""
  104. existing_doctypes = set([x.name for x in frappe.get_all("DocType")])
  105. def get_tables(doctypes):
  106. tables = []
  107. for doctype in doctypes:
  108. if doctype and doctype in existing_doctypes:
  109. if doctype.startswith("tab"):
  110. tables.append(doctype)
  111. else:
  112. tables.append("tab" + doctype)
  113. return tables
  114. passed_tables = {
  115. "include": get_tables(self.include_doctypes.strip().split(",")),
  116. "exclude": get_tables(self.exclude_doctypes.strip().split(",")),
  117. }
  118. specified_tables = get_tables(frappe.conf.get("backup", {}).get("includes", []))
  119. include_tables = (specified_tables + base_tables) if specified_tables else []
  120. conf_tables = {
  121. "include": include_tables,
  122. "exclude": get_tables(frappe.conf.get("backup", {}).get("excludes", [])),
  123. }
  124. self.backup_includes = passed_tables["include"]
  125. self.backup_excludes = passed_tables["exclude"]
  126. if not (self.backup_includes or self.backup_excludes) and not self.ignore_conf:
  127. self.backup_includes = self.backup_includes or conf_tables["include"]
  128. self.backup_excludes = self.backup_excludes or conf_tables["exclude"]
  129. self.partial = (self.backup_includes or self.backup_excludes) and not self.ignore_conf
  130. @property
  131. def site_config_backup_path(self):
  132. # For backwards compatibility
  133. click.secho(
  134. "BackupGenerator.site_config_backup_path has been deprecated in favour of"
  135. " BackupGenerator.backup_path_conf",
  136. fg="yellow",
  137. )
  138. return getattr(self, "backup_path_conf", None)
  139. def get_backup(self, older_than=24, ignore_files=False, force=False):
  140. """
  141. Takes a new dump if existing file is old
  142. and sends the link to the file as email
  143. """
  144. # Check if file exists and is less than a day old
  145. # If not Take Dump
  146. if not force:
  147. (
  148. last_db,
  149. last_file,
  150. last_private_file,
  151. site_config_backup_path,
  152. ) = self.get_recent_backup(older_than)
  153. else:
  154. last_db, last_file, last_private_file, site_config_backup_path = (
  155. False,
  156. False,
  157. False,
  158. False,
  159. )
  160. self.todays_date = now_datetime().strftime("%Y%m%d_%H%M%S")
  161. if not (
  162. self.backup_path_conf
  163. and self.backup_path_db
  164. and self.backup_path_files
  165. and self.backup_path_private_files
  166. ):
  167. self.set_backup_file_name()
  168. if not (last_db and last_file and last_private_file and site_config_backup_path):
  169. self.take_dump()
  170. self.copy_site_config()
  171. if not ignore_files:
  172. self.backup_files()
  173. else:
  174. self.backup_path_files = last_file
  175. self.backup_path_db = last_db
  176. self.backup_path_private_files = last_private_file
  177. self.backup_path_conf = site_config_backup_path
  178. def set_backup_file_name(self):
  179. partial = "-partial" if self.partial else ""
  180. ext = "tgz" if self.compress_files else "tar"
  181. for_conf = f"{self.todays_date}-{self.site_slug}-site_config_backup.json"
  182. for_db = f"{self.todays_date}-{self.site_slug}{partial}-database.sql.gz"
  183. for_public_files = f"{self.todays_date}-{self.site_slug}-files.{ext}"
  184. for_private_files = f"{self.todays_date}-{self.site_slug}-private-files.{ext}"
  185. backup_path = self.backup_path or get_backup_path()
  186. if not self.backup_path_conf:
  187. self.backup_path_conf = os.path.join(backup_path, for_conf)
  188. if not self.backup_path_db:
  189. self.backup_path_db = os.path.join(backup_path, for_db)
  190. if not self.backup_path_files:
  191. self.backup_path_files = os.path.join(backup_path, for_public_files)
  192. if not self.backup_path_private_files:
  193. self.backup_path_private_files = os.path.join(backup_path, for_private_files)
  194. def get_recent_backup(self, older_than, partial=False):
  195. backup_path = get_backup_path()
  196. file_type_slugs = {
  197. "database": "*-{{}}-{}database.sql.gz".format('*' if partial else ''),
  198. "public": "*-{}-files.tar",
  199. "private": "*-{}-private-files.tar",
  200. "config": "*-{}-site_config_backup.json",
  201. }
  202. def backup_time(file_path):
  203. file_name = file_path.split(os.sep)[-1]
  204. file_timestamp = file_name.split("-")[0]
  205. return timegm(datetime.strptime(file_timestamp, "%Y%m%d_%H%M%S").utctimetuple())
  206. def get_latest(file_pattern):
  207. file_pattern = os.path.join(backup_path, file_pattern.format(self.site_slug))
  208. file_list = glob(file_pattern)
  209. if file_list:
  210. return max(file_list, key=backup_time)
  211. def old_enough(file_path):
  212. if file_path:
  213. if not os.path.isfile(file_path) or is_file_old(file_path, older_than):
  214. return None
  215. return file_path
  216. latest_backups = {
  217. file_type: get_latest(pattern) for file_type, pattern in file_type_slugs.items()
  218. }
  219. recent_backups = {
  220. file_type: old_enough(file_name) for file_type, file_name in latest_backups.items()
  221. }
  222. return (
  223. recent_backups.get("database"),
  224. recent_backups.get("public"),
  225. recent_backups.get("private"),
  226. recent_backups.get("config"),
  227. )
  228. def zip_files(self):
  229. # For backwards compatibility - pre v13
  230. click.secho(
  231. "BackupGenerator.zip_files has been deprecated in favour of"
  232. " BackupGenerator.backup_files",
  233. fg="yellow",
  234. )
  235. return self.backup_files()
  236. def get_summary(self):
  237. summary = {
  238. "config": {
  239. "path": self.backup_path_conf,
  240. "size": get_file_size(self.backup_path_conf, format=True),
  241. },
  242. "database": {
  243. "path": self.backup_path_db,
  244. "size": get_file_size(self.backup_path_db, format=True),
  245. },
  246. }
  247. if os.path.exists(self.backup_path_files) and os.path.exists(
  248. self.backup_path_private_files
  249. ):
  250. summary.update(
  251. {
  252. "public": {
  253. "path": self.backup_path_files,
  254. "size": get_file_size(self.backup_path_files, format=True),
  255. },
  256. "private": {
  257. "path": self.backup_path_private_files,
  258. "size": get_file_size(self.backup_path_private_files, format=True),
  259. },
  260. }
  261. )
  262. return summary
  263. def print_summary(self):
  264. backup_summary = self.get_summary()
  265. print("Backup Summary for {0} at {1}".format(frappe.local.site, now()))
  266. title = max([len(x) for x in backup_summary])
  267. path = max([len(x["path"]) for x in backup_summary.values()])
  268. for _type, info in backup_summary.items():
  269. template = "{{0:{0}}}: {{1:{1}}} {{2}}".format(title, path)
  270. print(template.format(_type.title(), info["path"], info["size"]))
  271. def backup_files(self):
  272. import subprocess
  273. for folder in ("public", "private"):
  274. files_path = frappe.get_site_path(folder, "files")
  275. backup_path = (
  276. self.backup_path_files if folder == "public" else self.backup_path_private_files
  277. )
  278. if self.compress_files:
  279. cmd_string = "tar cf - {1} | gzip > {0}"
  280. else:
  281. cmd_string = "tar -cf {0} {1}"
  282. output = subprocess.check_output(
  283. cmd_string.format(backup_path, files_path), shell=True
  284. )
  285. if self.verbose and output:
  286. print(output.decode("utf8"))
  287. def copy_site_config(self):
  288. site_config_backup_path = self.backup_path_conf
  289. site_config_path = os.path.join(frappe.get_site_path(), "site_config.json")
  290. with open(site_config_backup_path, "w") as n, open(site_config_path) as c:
  291. n.write(c.read())
  292. def take_dump(self):
  293. import frappe.utils
  294. from frappe.utils.change_log import get_app_branch
  295. db_exc = {
  296. "mariadb": ("mysqldump", which("mysqldump")),
  297. "postgres": ("pg_dump", which("pg_dump")),
  298. }[self.db_type]
  299. gzip_exc = which("gzip")
  300. if not (gzip_exc and db_exc[1]):
  301. _exc = "gzip" if not gzip_exc else db_exc[0]
  302. frappe.throw(
  303. f"{_exc} not found in PATH! This is required to take a backup.",
  304. exc=frappe.ExecutableNotFound
  305. )
  306. db_exc = db_exc[0]
  307. database_header_content = [
  308. f"Backup generated by Frappe {frappe.__version__} on branch {get_app_branch('frappe') or 'N/A'}",
  309. "",
  310. ]
  311. # escape reserved characters
  312. args = frappe._dict(
  313. [item[0], frappe.utils.esc(str(item[1]), "$ ")]
  314. for item in self.__dict__.copy().items()
  315. )
  316. if self.backup_includes:
  317. backup_info = ("Backing Up Tables: ", ", ".join(self.backup_includes))
  318. elif self.backup_excludes:
  319. backup_info = ("Skipping Tables: ", ", ".join(self.backup_excludes))
  320. if self.partial:
  321. print(''.join(backup_info), "\n")
  322. database_header_content.extend([
  323. f"Partial Backup of Frappe Site {frappe.local.site}",
  324. ("Backup contains: " if self.backup_includes else "Backup excludes: ") + backup_info[1],
  325. "",
  326. ])
  327. generated_header = "\n".join([f"-- {x}" for x in database_header_content]) + "\n"
  328. with gzip.open(args.backup_path_db, "wt") as f:
  329. f.write(generated_header)
  330. if self.db_type == "postgres":
  331. if self.backup_includes:
  332. args["include"] = " ".join(
  333. ["--table='public.\"{0}\"'".format(table) for table in self.backup_includes]
  334. )
  335. elif self.backup_excludes:
  336. args["exclude"] = " ".join(
  337. ["--exclude-table-data='public.\"{0}\"'".format(table) for table in self.backup_excludes]
  338. )
  339. cmd_string = (
  340. "{db_exc} postgres://{user}:{password}@{db_host}:{db_port}/{db_name}"
  341. " {include} {exclude} | {gzip} >> {backup_path_db}"
  342. )
  343. else:
  344. if self.backup_includes:
  345. args["include"] = " ".join(["'{0}'".format(x) for x in self.backup_includes])
  346. elif self.backup_excludes:
  347. args["exclude"] = " ".join(
  348. [
  349. "--ignore-table='{0}.{1}'".format(frappe.conf.db_name, table)
  350. for table in self.backup_excludes
  351. ]
  352. )
  353. cmd_string = (
  354. "{db_exc} --single-transaction --quick --lock-tables=false -u {user}"
  355. " -p{password} {db_name} -h {db_host} -P {db_port} {include} {exclude}"
  356. " | {gzip} >> {backup_path_db}"
  357. )
  358. command = cmd_string.format(
  359. user=args.user,
  360. password=args.password,
  361. db_exc=db_exc,
  362. db_host=args.db_host,
  363. db_port=args.db_port,
  364. db_name=args.db_name,
  365. backup_path_db=args.backup_path_db,
  366. exclude=args.get("exclude", ""),
  367. include=args.get("include", ""),
  368. gzip=gzip_exc,
  369. )
  370. if self.verbose:
  371. print(command + "\n")
  372. err, out = frappe.utils.execute_in_shell(command)
  373. def send_email(self):
  374. """
  375. Sends the link to backup file located at erpnext/backups
  376. """
  377. from frappe.email import get_system_managers
  378. recipient_list = get_system_managers()
  379. db_backup_url = get_url(
  380. os.path.join("backups", os.path.basename(self.backup_path_db))
  381. )
  382. files_backup_url = get_url(
  383. os.path.join("backups", os.path.basename(self.backup_path_files))
  384. )
  385. msg = """Hello,
  386. Your backups are ready to be downloaded.
  387. 1. [Click here to download the database backup](%(db_backup_url)s)
  388. 2. [Click here to download the files backup](%(files_backup_url)s)
  389. This link will be valid for 24 hours. A new backup will be available for
  390. download only after 24 hours.""" % {
  391. "db_backup_url": db_backup_url,
  392. "files_backup_url": files_backup_url,
  393. }
  394. datetime_str = datetime.fromtimestamp(os.stat(self.backup_path_db).st_ctime)
  395. subject = (
  396. datetime_str.strftime("%d/%m/%Y %H:%M:%S") + """ - Backup ready to be downloaded"""
  397. )
  398. frappe.sendmail(recipients=recipient_list, msg=msg, subject=subject)
  399. return recipient_list
  400. @frappe.whitelist()
  401. def get_backup():
  402. """
  403. This function is executed when the user clicks on
  404. Toos > Download Backup
  405. """
  406. delete_temp_backups()
  407. odb = BackupGenerator(
  408. frappe.conf.db_name,
  409. frappe.conf.db_name,
  410. frappe.conf.db_password,
  411. db_host=frappe.db.host,
  412. db_type=frappe.conf.db_type,
  413. db_port=frappe.conf.db_port,
  414. )
  415. odb.get_backup()
  416. recipient_list = odb.send_email()
  417. frappe.msgprint(
  418. _(
  419. "Download link for your backup will be emailed on the following email address: {0}"
  420. ).format(", ".join(recipient_list))
  421. )
  422. @frappe.whitelist()
  423. def fetch_latest_backups(partial=False):
  424. """Fetches paths of the latest backup taken in the last 30 days
  425. Only for: System Managers
  426. Returns:
  427. dict: relative Backup Paths
  428. """
  429. frappe.only_for("System Manager")
  430. odb = BackupGenerator(
  431. frappe.conf.db_name,
  432. frappe.conf.db_name,
  433. frappe.conf.db_password,
  434. db_host=frappe.db.host,
  435. db_type=frappe.conf.db_type,
  436. db_port=frappe.conf.db_port,
  437. )
  438. database, public, private, config = odb.get_recent_backup(older_than=24 * 30, partial=partial)
  439. return {"database": database, "public": public, "private": private, "config": config}
  440. def scheduled_backup(
  441. older_than=6,
  442. ignore_files=False,
  443. backup_path=None,
  444. backup_path_db=None,
  445. backup_path_files=None,
  446. backup_path_private_files=None,
  447. backup_path_conf=None,
  448. ignore_conf=False,
  449. include_doctypes="",
  450. exclude_doctypes="",
  451. compress=False,
  452. force=False,
  453. verbose=False,
  454. ):
  455. """this function is called from scheduler
  456. deletes backups older than 7 days
  457. takes backup"""
  458. odb = new_backup(
  459. older_than=older_than,
  460. ignore_files=ignore_files,
  461. backup_path=backup_path,
  462. backup_path_db=backup_path_db,
  463. backup_path_files=backup_path_files,
  464. backup_path_private_files=backup_path_private_files,
  465. backup_path_conf=backup_path_conf,
  466. ignore_conf=ignore_conf,
  467. include_doctypes=include_doctypes,
  468. exclude_doctypes=exclude_doctypes,
  469. compress=compress,
  470. force=force,
  471. verbose=verbose,
  472. )
  473. return odb
  474. def new_backup(
  475. older_than=6,
  476. ignore_files=False,
  477. backup_path=None,
  478. backup_path_db=None,
  479. backup_path_files=None,
  480. backup_path_private_files=None,
  481. backup_path_conf=None,
  482. ignore_conf=False,
  483. include_doctypes="",
  484. exclude_doctypes="",
  485. compress=False,
  486. force=False,
  487. verbose=False,
  488. ):
  489. delete_temp_backups(older_than=frappe.conf.keep_backups_for_hours or 24)
  490. odb = BackupGenerator(
  491. frappe.conf.db_name,
  492. frappe.conf.db_name,
  493. frappe.conf.db_password,
  494. db_host=frappe.db.host,
  495. db_port=frappe.db.port,
  496. db_type=frappe.conf.db_type,
  497. backup_path=backup_path,
  498. backup_path_db=backup_path_db,
  499. backup_path_files=backup_path_files,
  500. backup_path_private_files=backup_path_private_files,
  501. backup_path_conf=backup_path_conf,
  502. ignore_conf=ignore_conf,
  503. include_doctypes=include_doctypes,
  504. exclude_doctypes=exclude_doctypes,
  505. verbose=verbose,
  506. compress_files=compress,
  507. )
  508. odb.get_backup(older_than, ignore_files, force=force)
  509. return odb
  510. def delete_temp_backups(older_than=24):
  511. """
  512. Cleans up the backup_link_path directory by deleting files older than 24 hours
  513. """
  514. backup_path = get_backup_path()
  515. if os.path.exists(backup_path):
  516. file_list = os.listdir(get_backup_path())
  517. for this_file in file_list:
  518. this_file_path = os.path.join(get_backup_path(), this_file)
  519. if is_file_old(this_file_path, older_than):
  520. os.remove(this_file_path)
  521. def is_file_old(file_path, older_than=24):
  522. """
  523. Checks if file exists and is older than specified hours
  524. Returns ->
  525. True: file does not exist or file is old
  526. False: file is new
  527. """
  528. if os.path.isfile(file_path):
  529. from datetime import timedelta
  530. # Get timestamp of the file
  531. file_datetime = datetime.fromtimestamp(os.stat(file_path).st_ctime)
  532. if datetime.today() - file_datetime >= timedelta(hours=older_than):
  533. if _verbose:
  534. print(f"File {file_path} is older than {older_than} hours")
  535. return True
  536. else:
  537. if _verbose:
  538. print(f"File {file_path} is recent")
  539. return False
  540. else:
  541. if _verbose:
  542. print(f"File {file_path} does not exist")
  543. return True
  544. def get_backup_path():
  545. backup_path = frappe.utils.get_site_path(conf.get("backup_path", "private/backups"))
  546. return backup_path
  547. def backup(
  548. with_files=False,
  549. backup_path_db=None,
  550. backup_path_files=None,
  551. backup_path_private_files=None,
  552. backup_path_conf=None,
  553. quiet=False,
  554. ):
  555. "Backup"
  556. odb = scheduled_backup(
  557. ignore_files=not with_files,
  558. backup_path_db=backup_path_db,
  559. backup_path_files=backup_path_files,
  560. backup_path_private_files=backup_path_private_files,
  561. backup_path_conf=backup_path_conf,
  562. force=True,
  563. )
  564. return {
  565. "backup_path_db": odb.backup_path_db,
  566. "backup_path_files": odb.backup_path_files,
  567. "backup_path_private_files": odb.backup_path_private_files,
  568. }
  569. if __name__ == "__main__":
  570. import sys
  571. cmd = sys.argv[1]
  572. db_type = "mariadb"
  573. try:
  574. db_type = sys.argv[6]
  575. except IndexError:
  576. pass
  577. db_port = 3306
  578. try:
  579. db_port = int(sys.argv[7])
  580. except IndexError:
  581. pass
  582. if cmd == "is_file_old":
  583. odb = BackupGenerator(
  584. sys.argv[2],
  585. sys.argv[3],
  586. sys.argv[4],
  587. sys.argv[5] or "localhost",
  588. db_type=db_type,
  589. db_port=db_port,
  590. )
  591. is_file_old(odb.db_file_name)
  592. if cmd == "get_backup":
  593. odb = BackupGenerator(
  594. sys.argv[2],
  595. sys.argv[3],
  596. sys.argv[4],
  597. sys.argv[5] or "localhost",
  598. db_type=db_type,
  599. db_port=db_port,
  600. )
  601. odb.get_backup()
  602. if cmd == "take_dump":
  603. odb = BackupGenerator(
  604. sys.argv[2],
  605. sys.argv[3],
  606. sys.argv[4],
  607. sys.argv[5] or "localhost",
  608. db_type=db_type,
  609. db_port=db_port,
  610. )
  611. odb.take_dump()
  612. if cmd == "send_email":
  613. odb = BackupGenerator(
  614. sys.argv[2],
  615. sys.argv[3],
  616. sys.argv[4],
  617. sys.argv[5] or "localhost",
  618. db_type=db_type,
  619. db_port=db_port,
  620. )
  621. odb.send_email("abc.sql.gz")
  622. if cmd == "delete_temp_backups":
  623. delete_temp_backups()