Coverage for app/venv/lib/python3.14/site-packages/weblate/utils/backup.py: 31%
71 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
1# Copyright © Michal Čihař <michal@weblate.org>
2#
3# SPDX-License-Identifier: GPL-3.0-or-later
5"""Backup automation based on borg."""
7from __future__ import annotations
9import os
10import string
11import subprocess
12from random import SystemRandom
13from urllib.parse import urlparse
15from django.conf import settings
17from weblate.trans.util import get_clean_env
18from weblate.utils.data import data_dir
19from weblate.utils.errors import add_breadcrumb, report_error
20from weblate.utils.files import cleanup_error_message
21from weblate.utils.lock import WeblateLock
22from weblate.vcs.ssh import SSH_WRAPPER, add_host_key
24CACHEDIR = """Signature: 8a477f597d28d172789f06886806bc55
25# This file is a cache directory tag created by Weblate
26# For information about cache directory tags, see:
27# https://bford.info/cachedir/spec.html
28"""
31def ensure_backup_dir():
32 backup_dir = data_dir("backups")
33 if not os.path.exists(backup_dir):
34 os.makedirs(backup_dir)
35 return backup_dir
38def backup_lock():
39 backup_dir = ensure_backup_dir()
40 return WeblateLock(
41 lock_path=backup_dir,
42 scope="backuplock",
43 key=0,
44 slug="",
45 cache_template="lock:{scope}",
46 file_template=".{scope}",
47 timeout=120,
48 )
51class BackupError(Exception):
52 pass
55def make_password(length: int = 50):
56 generator = SystemRandom()
57 chars = string.ascii_letters + string.digits + "!@#$%^&*()"
58 return "".join(generator.choice(chars) for i in range(length))
61def tag_cache_dirs() -> None:
62 """Create CACHEDIR.TAG in our cache dirs to exclude from backups."""
63 dirs = [
64 # Fontconfig cache
65 data_dir("cache", "fonts"),
66 # Static files (default is inside data)
67 settings.STATIC_ROOT,
68 # Project backups
69 data_dir("projectbackups"),
70 ]
71 # Django file based caches
72 dirs.extend(
73 cache["LOCATION"]
74 for cache in settings.CACHES.values()
75 if cache["BACKEND"] == "django.core.cache.backends.filebased.FileBasedCache"
76 )
78 # Create CACHEDIR.TAG in each cache dir
79 for name in dirs:
80 tagfile = os.path.join(name, "CACHEDIR.TAG")
81 if os.path.exists(name) and not os.path.exists(tagfile):
82 with open(tagfile, "w") as handle:
83 handle.write(CACHEDIR)
86def run_borg(cmd: list[str], env: dict[str, str] | None = None) -> str:
87 """Execute borgbackup."""
88 with backup_lock():
89 SSH_WRAPPER.create()
90 try:
91 return subprocess.check_output(
92 ["borg", "--rsh", SSH_WRAPPER.filename, *cmd],
93 stderr=subprocess.STDOUT,
94 env=get_clean_env(env),
95 text=True,
96 )
97 except OSError as error:
98 report_error("Borg could not be executed")
99 msg = f"Could not execute borg program: {error}"
100 raise BackupError(msg) from error
101 except subprocess.CalledProcessError as error:
102 add_breadcrumb(
103 category="backup", message="borg output", stdout=error.stdout
104 )
105 report_error("Borg failed")
106 raise BackupError(cleanup_error_message(error.stdout)) from error
109def initialize(location: str, passphrase: str) -> str:
110 """Initialize repository."""
111 parsed = urlparse(location)
112 if parsed.hostname:
113 add_host_key(None, parsed.hostname, parsed.port)
114 return run_borg(
115 ["init", "--encryption", "repokey-blake2", location],
116 {"BORG_NEW_PASSPHRASE": passphrase},
117 )
120def get_paper_key(location: str) -> str:
121 """Get paper key for recovery."""
122 return run_borg(["key", "export", "--paper", location])
125def backup(location: str, passphrase: str) -> str:
126 """Perform DATA_DIR backup."""
127 tag_cache_dirs()
128 command = [
129 "create",
130 "--verbose",
131 "--list",
132 "--filter",
133 "AME",
134 "--stats",
135 "--exclude-caches",
136 "--exclude",
137 "*/.config/borg",
138 "--exclude",
139 "lost+found",
140 "--compression",
141 "auto,zstd",
142 ]
143 if settings.BORG_EXTRA_ARGS:
144 command.extend(settings.BORG_EXTRA_ARGS)
145 command.extend(
146 [
147 f"{location}::{{now}}",
148 settings.DATA_DIR,
149 ],
150 )
151 return run_borg(
152 command,
153 {"BORG_PASSPHRASE": passphrase},
154 )
157def prune(location: str, passphrase: str) -> str:
158 """Prune past backups."""
159 return run_borg(
160 [
161 "prune",
162 "--list",
163 "--keep-within",
164 "2d",
165 "--keep-daily",
166 "14",
167 "--keep-weekly",
168 "8",
169 "--keep-monthly",
170 "6",
171 location,
172 ],
173 {"BORG_PASSPHRASE": passphrase},
174 )
177def cleanup(location: str, passphrase: str, initial: bool) -> str:
178 cmd = ["compact"]
179 if initial:
180 cmd.append("--cleanup-commits")
181 cmd.append(location)
182 return run_borg(cmd, {"BORG_PASSPHRASE": passphrase})