yeboyebo-backup-tools 1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- yeboyebo_backup_tools-1.1/PKG-INFO +33 -0
- yeboyebo_backup_tools-1.1/backup_tools.py +1036 -0
- yeboyebo_backup_tools-1.1/setup.cfg +4 -0
- yeboyebo_backup_tools-1.1/setup.py +41 -0
- yeboyebo_backup_tools-1.1/tests/test_backup_tools.py +121 -0
- yeboyebo_backup_tools-1.1/yeboyebo_backup_tools.egg-info/PKG-INFO +33 -0
- yeboyebo_backup_tools-1.1/yeboyebo_backup_tools.egg-info/SOURCES.txt +9 -0
- yeboyebo_backup_tools-1.1/yeboyebo_backup_tools.egg-info/dependency_links.txt +1 -0
- yeboyebo_backup_tools-1.1/yeboyebo_backup_tools.egg-info/entry_points.txt +2 -0
- yeboyebo_backup_tools-1.1/yeboyebo_backup_tools.egg-info/requires.txt +4 -0
- yeboyebo_backup_tools-1.1/yeboyebo_backup_tools.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: yeboyebo-backup-tools
|
|
3
|
+
Version: 1.1
|
|
4
|
+
Summary: Herramienta de backup PostgreSQL multiplataforma
|
|
5
|
+
Home-page: https://github.com/yeboyebo/utils
|
|
6
|
+
Author: Jose A. Fernandez
|
|
7
|
+
Author-email: aullasistemas@gmail.com
|
|
8
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: System Administrators
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: System :: Archiving :: Backup
|
|
16
|
+
Classifier: Topic :: Database
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/plain
|
|
19
|
+
Requires-Dist: fernet
|
|
20
|
+
Provides-Extra: loki
|
|
21
|
+
Requires-Dist: python-logging-loki-v2; extra == "loki"
|
|
22
|
+
Dynamic: author
|
|
23
|
+
Dynamic: author-email
|
|
24
|
+
Dynamic: classifier
|
|
25
|
+
Dynamic: description
|
|
26
|
+
Dynamic: description-content-type
|
|
27
|
+
Dynamic: home-page
|
|
28
|
+
Dynamic: provides-extra
|
|
29
|
+
Dynamic: requires-dist
|
|
30
|
+
Dynamic: requires-python
|
|
31
|
+
Dynamic: summary
|
|
32
|
+
|
|
33
|
+
Herramienta de backup PostgreSQL con montaje remoto, replicacion, test de integridad y alertas por email. Compatible Linux y Windows.
|
|
@@ -0,0 +1,1036 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
""" BackupTools package. """
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import shutil
|
|
7
|
+
import smtplib
|
|
8
|
+
import datetime
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
import uuid
|
|
12
|
+
import tempfile
|
|
13
|
+
import subprocess
|
|
14
|
+
import tarfile
|
|
15
|
+
import platform
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import fernet
|
|
19
|
+
|
|
20
|
+
from email.mime.text import MIMEText
|
|
21
|
+
from email.mime.multipart import MIMEMultipart
|
|
22
|
+
|
|
23
|
+
from typing import Optional, List, Tuple
|
|
24
|
+
|
|
25
|
+
__VERSION__ = "1.1"
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
LOGGER: "logging.Logger" = logging.getLogger("backup_tools")
|
|
29
|
+
LOGGER.addHandler(logging.StreamHandler())
|
|
30
|
+
LOGGER.setLevel(logging.WARNING)
|
|
31
|
+
|
|
32
|
+
def check_command(name: str) -> None:
|
|
33
|
+
"""Check command."""
|
|
34
|
+
if not SysUtils.which(name):
|
|
35
|
+
raise Exception("Comando %s no encontrado" % name)
|
|
36
|
+
|
|
37
|
+
def run_command(original_command: str, level: int = 19) -> bool:
|
|
38
|
+
"""Run command."""
|
|
39
|
+
# -20. Prioridad alta.
|
|
40
|
+
# 0. Prioridad normal.
|
|
41
|
+
# 19 Prioridad baja.
|
|
42
|
+
LOGGER.debug("\t* Ejecutando: %s" % original_command)
|
|
43
|
+
command = SysUtils.nice_command(original_command, level)
|
|
44
|
+
return subprocess.run(command, shell=True).returncode == 0
|
|
45
|
+
|
|
46
|
+
def init_logger() -> "logging.Logger":
|
|
47
|
+
"""Configure LOGGER with level from CLI args and optional Loki handler."""
|
|
48
|
+
global LOGGER
|
|
49
|
+
|
|
50
|
+
level = logging.WARNING
|
|
51
|
+
for argv in sys.argv[:]:
|
|
52
|
+
if argv.startswith("-vv"):
|
|
53
|
+
level = logging.DEBUG
|
|
54
|
+
sys.argv.remove(argv)
|
|
55
|
+
break
|
|
56
|
+
elif argv.startswith("-v"):
|
|
57
|
+
level = logging.INFO
|
|
58
|
+
sys.argv.remove(argv)
|
|
59
|
+
break
|
|
60
|
+
|
|
61
|
+
LOGGER.handlers.clear()
|
|
62
|
+
sh = logging.StreamHandler()
|
|
63
|
+
sh.setFormatter(logging.Formatter(
|
|
64
|
+
'%(levelname)-6s %(name)-10s %(filename)s:%(lineno)-10s %(message)s'
|
|
65
|
+
))
|
|
66
|
+
LOGGER.addHandler(sh)
|
|
67
|
+
LOGGER.setLevel(level)
|
|
68
|
+
|
|
69
|
+
# Loki handler (opt-in via loki_url)
|
|
70
|
+
try:
|
|
71
|
+
_cfg = configTools(create=False)
|
|
72
|
+
_loki_url = _cfg.getParam("loki_url", default="https://monitor.yeboyebo.es/loki/api/v1/push")
|
|
73
|
+
except Exception:
|
|
74
|
+
_loki_url = ""
|
|
75
|
+
|
|
76
|
+
if _loki_url:
|
|
77
|
+
try:
|
|
78
|
+
from logging_loki import LokiHandler
|
|
79
|
+
_loki_tags = {
|
|
80
|
+
"app": _cfg.getParam("loki_app", default="backup_tools"),
|
|
81
|
+
"env": _cfg.getParam("loki_env", default="produccion"),
|
|
82
|
+
}
|
|
83
|
+
_loki_kwargs: dict = {
|
|
84
|
+
"url": _loki_url,
|
|
85
|
+
"tags": _loki_tags,
|
|
86
|
+
"version": "1",
|
|
87
|
+
}
|
|
88
|
+
try:
|
|
89
|
+
_loki_user = _cfg.getParam("loki_user", ofs=True)
|
|
90
|
+
except Exception:
|
|
91
|
+
_loki_user = ""
|
|
92
|
+
try:
|
|
93
|
+
_loki_pass = _cfg.getParam("loki_pass", ofs=True)
|
|
94
|
+
except Exception:
|
|
95
|
+
_loki_pass = ""
|
|
96
|
+
if _loki_user and _loki_pass:
|
|
97
|
+
_loki_kwargs["auth"] = (_loki_user, _loki_pass)
|
|
98
|
+
_loki_handler = LokiHandler(**_loki_kwargs)
|
|
99
|
+
LOGGER.addHandler(_loki_handler)
|
|
100
|
+
LOGGER.info("Loki logging enabled: %s", _loki_url)
|
|
101
|
+
except ImportError:
|
|
102
|
+
LOGGER.warning("logging-loki not installed. Loki logging disabled.")
|
|
103
|
+
except Exception as _loki_err:
|
|
104
|
+
LOGGER.warning("Loki setup failed: %s", _loki_err)
|
|
105
|
+
|
|
106
|
+
return LOGGER
|
|
107
|
+
class Cifrado:
|
|
108
|
+
|
|
109
|
+
def __init__(self, key: str = None):
|
|
110
|
+
"""Init."""
|
|
111
|
+
|
|
112
|
+
self.fernet : "fernet.Fernet" = None
|
|
113
|
+
self.key: str = key
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def ofuscar(self, texto: str) -> bool:
|
|
117
|
+
"""Save password."""
|
|
118
|
+
|
|
119
|
+
# save_password("password","CIF_EMPRESA")
|
|
120
|
+
self.init_fernet()
|
|
121
|
+
if len(texto) < 64:
|
|
122
|
+
texto = texto.ljust(64, "\n")
|
|
123
|
+
|
|
124
|
+
encoded_bytes = self.fernet.encrypt(texto.encode())
|
|
125
|
+
return base64.b64encode(encoded_bytes).decode()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def resolver(self, cipher_text: str) -> str:
|
|
130
|
+
"""Resuelve password."""
|
|
131
|
+
|
|
132
|
+
self.init_fernet()
|
|
133
|
+
|
|
134
|
+
cipher_bytes = base64.b64decode(cipher_text.encode())
|
|
135
|
+
text = self.fernet.decrypt(cipher_bytes).decode()
|
|
136
|
+
text = text.rstrip("\n")
|
|
137
|
+
return text
|
|
138
|
+
|
|
139
|
+
def init_fernet(self) -> None:
|
|
140
|
+
"""Initialize fernet."""
|
|
141
|
+
|
|
142
|
+
if not self.fernet:
|
|
143
|
+
if not self.key:
|
|
144
|
+
raise Exception("No se ha especificado key")
|
|
145
|
+
key = self.key
|
|
146
|
+
# salt = os.urandom(9)
|
|
147
|
+
salt = b'\xa1\x8c\x8f\xdb\x8fi?_\xde'
|
|
148
|
+
key_encoded = key.encode()
|
|
149
|
+
|
|
150
|
+
hmac = hashlib.pbkdf2_hmac("sha256", key_encoded , salt, 10000)
|
|
151
|
+
dict_passwd = {
|
|
152
|
+
# Algorithm:
|
|
153
|
+
# .. pbkdf2: short algorithm name
|
|
154
|
+
# .. sha256: hash function used
|
|
155
|
+
# .. 4: number of zeroes used on iterations
|
|
156
|
+
"algorithm": "pbkdf2-sha256-4",
|
|
157
|
+
"salt": base64.b64encode(salt).decode(),
|
|
158
|
+
"hash": base64.b64encode(hmac).decode(),
|
|
159
|
+
}
|
|
160
|
+
hashed_password = "%(algorithm)s:%(salt)s:%(hash)s" % dict_passwd
|
|
161
|
+
|
|
162
|
+
key_salt = hashlib.sha256(hashed_password.encode()).digest()
|
|
163
|
+
new_key = hashlib.pbkdf2_hmac("sha256", key_encoded, key_salt, 10000)
|
|
164
|
+
key64 = base64.urlsafe_b64encode(new_key)
|
|
165
|
+
self.fernet = fernet.Fernet(key64)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class configTools:
|
|
169
|
+
|
|
170
|
+
config_file: str
|
|
171
|
+
|
|
172
|
+
def __init__(self, create: bool = True):
|
|
173
|
+
"""Init."""
|
|
174
|
+
|
|
175
|
+
env_config = os.environ.get("BACKUP_TOOLS_CONFIG", "")
|
|
176
|
+
if env_config:
|
|
177
|
+
self.config_file = env_config
|
|
178
|
+
else:
|
|
179
|
+
self.config_file = os.path.join(os.path.dirname(__file__), "config.ini")
|
|
180
|
+
|
|
181
|
+
if create:
|
|
182
|
+
self.create_file()
|
|
183
|
+
|
|
184
|
+
def create_file(self):
|
|
185
|
+
if not os.path.exists(self.config_file):
|
|
186
|
+
SysUtils.touch(self.config_file)
|
|
187
|
+
|
|
188
|
+
def getParam(self, param: str, ofs: bool = False, default= None) -> str:
|
|
189
|
+
"""Get param."""
|
|
190
|
+
|
|
191
|
+
result : Optional[str] = None
|
|
192
|
+
|
|
193
|
+
with open(self.config_file, "r") as f:
|
|
194
|
+
for line in f:
|
|
195
|
+
if line.startswith(param):
|
|
196
|
+
result = "=".join(line.split("=")[1:])
|
|
197
|
+
if not ofs:
|
|
198
|
+
result = result.strip("\n")
|
|
199
|
+
break
|
|
200
|
+
if result is not None:
|
|
201
|
+
token = self.getParam("token") if ofs else None
|
|
202
|
+
return result if not ofs else Cifrado(token).resolver(result)
|
|
203
|
+
elif default is not None:
|
|
204
|
+
self.setParam(param, str(default), ofs)
|
|
205
|
+
return default
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
raise Exception("Parametro no encontrado: %s" % param)
|
|
209
|
+
|
|
210
|
+
def setParam(self, param: str, orig_value: str, ofs: bool = False):
|
|
211
|
+
"""Set param."""
|
|
212
|
+
|
|
213
|
+
with open(self.config_file, "r", encoding="UTF-8") as f:
|
|
214
|
+
lines = f.readlines()
|
|
215
|
+
|
|
216
|
+
token = self.getParam("token") if ofs else None
|
|
217
|
+
value = orig_value if not ofs else Cifrado(token).ofuscar(orig_value)
|
|
218
|
+
|
|
219
|
+
found = False
|
|
220
|
+
for pos, line in enumerate(lines):
|
|
221
|
+
if line.startswith(param):
|
|
222
|
+
lines[pos] = "%s=%s\n" % (param, value)
|
|
223
|
+
with open(self.config_file, "w", encoding="UTF-8") as f:
|
|
224
|
+
for line in lines:
|
|
225
|
+
f.write(line)
|
|
226
|
+
found = True
|
|
227
|
+
break
|
|
228
|
+
if not found:
|
|
229
|
+
with open(self.config_file, "a", encoding="UTF-8") as f:
|
|
230
|
+
line = "%s=%s\n" % (param, value)
|
|
231
|
+
f.write(line)
|
|
232
|
+
|
|
233
|
+
LOGGER.debug("\t(CONFIG) Establecido el valor %s para %s" % (orig_value, param))
|
|
234
|
+
|
|
235
|
+
return True
|
|
236
|
+
|
|
237
|
+
class FilesActions:
|
|
238
|
+
|
|
239
|
+
config : 'configTools' = configTools(False)
|
|
240
|
+
flag_file : str = "no_tocar.txt"
|
|
241
|
+
|
|
242
|
+
def __init__(self):
|
|
243
|
+
"""Initialice"""
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
def move_files(self, local_folder:str, remote_device: str, horas: int = 12) -> bool:
|
|
247
|
+
"""Move files."""
|
|
248
|
+
local_folder_backups = self.config.getParam("local_folder_backups")
|
|
249
|
+
LOGGER.info("Moviendo archivos de %s a %s modificados en las últimas %d horas" % (local_folder_backups, local_folder, horas))
|
|
250
|
+
self.mount_local_folder_backups()
|
|
251
|
+
self.mount_external(remote_device, local_folder)
|
|
252
|
+
minutos = horas * 60
|
|
253
|
+
files = SysUtils.find_recent_files(local_folder_backups, "*", minutos)
|
|
254
|
+
SysUtils.copy_files(files, local_folder)
|
|
255
|
+
self.umount_external(local_folder)
|
|
256
|
+
self.umount_local_folder_backups()
|
|
257
|
+
return True
|
|
258
|
+
|
|
259
|
+
def mount_local_folder_backups(self) -> bool:
|
|
260
|
+
"""Mount local folder backup."""
|
|
261
|
+
local_folder = self.config.getParam("local_folder_backups")
|
|
262
|
+
device = self.config.getParam("local_folder_backups_device")
|
|
263
|
+
if device != "0":
|
|
264
|
+
return self.mount(device, local_folder)
|
|
265
|
+
return True
|
|
266
|
+
|
|
267
|
+
def umount_local_folder_backups(self) -> bool:
|
|
268
|
+
"""Umount local folder backup."""
|
|
269
|
+
local_folder = self.config.getParam("local_folder_backups")
|
|
270
|
+
device = self.config.getParam("local_folder_backups_device")
|
|
271
|
+
if device != "0":
|
|
272
|
+
self.umount(local_folder)
|
|
273
|
+
return True
|
|
274
|
+
return True
|
|
275
|
+
|
|
276
|
+
def mount_external(self, remote_device: str, local_folder: str) -> bool:
|
|
277
|
+
"""Mount external device."""
|
|
278
|
+
return self.mount(remote_device, local_folder, True)
|
|
279
|
+
|
|
280
|
+
def umount_external(self, local_folder: str) -> bool:
|
|
281
|
+
"""Umount external device."""
|
|
282
|
+
return self.umount(local_folder)
|
|
283
|
+
|
|
284
|
+
def mount(self, device: str, local_folder: str, use_cifs: bool = False) -> bool:
|
|
285
|
+
"""Mount."""
|
|
286
|
+
flag_file_path = os.path.join(local_folder, self.flag_file)
|
|
287
|
+
result = SysUtils.mount_device(device, local_folder, use_cifs)
|
|
288
|
+
|
|
289
|
+
if not os.path.exists(flag_file_path):
|
|
290
|
+
if result:
|
|
291
|
+
LOGGER.warning("Creando flag remoto ( %s )" % flag_file_path)
|
|
292
|
+
SysUtils.touch(flag_file_path)
|
|
293
|
+
else:
|
|
294
|
+
raise Exception("No se ha podido montar el dispositivo %s" % device)
|
|
295
|
+
|
|
296
|
+
return True
|
|
297
|
+
|
|
298
|
+
def umount(self, local_folder: str) -> bool:
|
|
299
|
+
"""Umount"""
|
|
300
|
+
flag_file_path = os.path.join(local_folder, self.flag_file)
|
|
301
|
+
SysUtils.umount_device(local_folder)
|
|
302
|
+
return not os.path.exists(flag_file_path)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class SendEmail:
|
|
306
|
+
|
|
307
|
+
gmail_user : str
|
|
308
|
+
gmail_password : str
|
|
309
|
+
|
|
310
|
+
def __init__(self, user_, pass_):
|
|
311
|
+
|
|
312
|
+
self.gmail_user = user_
|
|
313
|
+
self.gmail_password = pass_
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def sendEmail(self, email, subject, body_file) -> bool:
|
|
318
|
+
|
|
319
|
+
sent_from = self.gmail_user
|
|
320
|
+
to = email.split(',')
|
|
321
|
+
|
|
322
|
+
LOGGER.debug("Enviando email a %s" % email)
|
|
323
|
+
message = MIMEMultipart("alternative")
|
|
324
|
+
message["Subject"] = subject
|
|
325
|
+
message["From"] = sent_from
|
|
326
|
+
message["To"] = email
|
|
327
|
+
|
|
328
|
+
file = open(body_file, "r")
|
|
329
|
+
text = file.read()
|
|
330
|
+
# Turn these into plain/html MIMEText objects
|
|
331
|
+
part1 = MIMEText(text, "html")
|
|
332
|
+
#part2 = MIMEText(html, "html")
|
|
333
|
+
|
|
334
|
+
# Add HTML/plain-text parts to MIMEMultipart message
|
|
335
|
+
# The email client will try to render the last part first
|
|
336
|
+
message.attach(part1)
|
|
337
|
+
#message.attach(part2)
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
server = smtplib.SMTP('smtp.gmail.com')
|
|
341
|
+
#server.ehlo()
|
|
342
|
+
server.starttls()
|
|
343
|
+
server.login(self.gmail_user, self.gmail_password)
|
|
344
|
+
server.sendmail(sent_from, to, message.as_string())
|
|
345
|
+
server.quit()
|
|
346
|
+
LOGGER.debug("Email sent!")
|
|
347
|
+
return True
|
|
348
|
+
|
|
349
|
+
except Exception as inst:
|
|
350
|
+
LOGGER.error('Something went wrong...')
|
|
351
|
+
LOGGER.error(type(inst)) # the exception instance
|
|
352
|
+
LOGGER.error(inst.args) # arguments stored in .args
|
|
353
|
+
LOGGER.error(inst)
|
|
354
|
+
return False
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class backupTools:
|
|
358
|
+
|
|
359
|
+
action_name: Optional[str] = None
|
|
360
|
+
arguments: List[str] = []
|
|
361
|
+
config: 'configTools' = configTools(False)
|
|
362
|
+
file_acts: 'FilesActions' = FilesActions()
|
|
363
|
+
|
|
364
|
+
def __init__(self):
|
|
365
|
+
"""Init."""
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
self.check_config()
|
|
369
|
+
self.load_arguments()
|
|
370
|
+
|
|
371
|
+
def check_config(self):
|
|
372
|
+
"""Check config."""
|
|
373
|
+
|
|
374
|
+
if not os.path.exists(self.config.config_file):
|
|
375
|
+
LOGGER.debug("\t(CONFIG) Inicializando %s" % self.config.config_file)
|
|
376
|
+
self.config.create_file()
|
|
377
|
+
|
|
378
|
+
params = {
|
|
379
|
+
'days_alive': '20',
|
|
380
|
+
'local_folder_backups' : '/backups',
|
|
381
|
+
'token' : uuid.uuid4().hex,
|
|
382
|
+
'local_folder_backups_device': '0',
|
|
383
|
+
'current_copies':'1',
|
|
384
|
+
'current_folder':'currents',
|
|
385
|
+
'min_size': '5000', # en bytes 5kb
|
|
386
|
+
'loki_url': 'https://monitor.yeboyebo.es/loki/api/v1/push',
|
|
387
|
+
'loki_app': 'backup_tools',
|
|
388
|
+
'loki_env': 'produccion',
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
for name, value in params.items():
|
|
392
|
+
self.config.getParam(name, default=value)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def load_arguments(self):
|
|
397
|
+
"""Load arguments."""
|
|
398
|
+
|
|
399
|
+
if len(sys.argv) > 1:
|
|
400
|
+
self.action_name = sys.argv[1]
|
|
401
|
+
self.arguments = sys.argv[2:]
|
|
402
|
+
else:
|
|
403
|
+
raise Exception("No se ha especificado la acción")
|
|
404
|
+
|
|
405
|
+
def run(self) -> bool:
|
|
406
|
+
"""Run."""
|
|
407
|
+
result = False
|
|
408
|
+
SysUtils.clear()
|
|
409
|
+
LOGGER.warning("Backup tools v%s acción %s" % (__VERSION__, self.action_name))
|
|
410
|
+
|
|
411
|
+
if self.action_name == "db":
|
|
412
|
+
result = self.db_actions()
|
|
413
|
+
elif self.action_name == "file":
|
|
414
|
+
result = self.file_actions()
|
|
415
|
+
elif self.action_name == "config":
|
|
416
|
+
result = self.config_actions()
|
|
417
|
+
elif self.action_name == "resume":
|
|
418
|
+
result = self.resume_actions()
|
|
419
|
+
elif self.action_name == "alive":
|
|
420
|
+
result = self.alive_actions()
|
|
421
|
+
elif self.action_name == "version":
|
|
422
|
+
print(__VERSION__)
|
|
423
|
+
result = True
|
|
424
|
+
else:
|
|
425
|
+
raise Exception("Acción no reconocida")
|
|
426
|
+
return result
|
|
427
|
+
|
|
428
|
+
def config_actions(self) -> bool:
|
|
429
|
+
"""Config actions."""
|
|
430
|
+
|
|
431
|
+
if len(self.arguments) < 2:
|
|
432
|
+
raise Exception("Se necesitan 2 argumentos aparte de la acción")
|
|
433
|
+
|
|
434
|
+
param = self.arguments[0]
|
|
435
|
+
value = self.arguments[1]
|
|
436
|
+
obf = len(self.arguments) > 2 and str(self.arguments[2]).lower() == "true"
|
|
437
|
+
self.config.setParam(param, value, obf)
|
|
438
|
+
|
|
439
|
+
return True
|
|
440
|
+
|
|
441
|
+
def alive_actions(self) -> bool:
|
|
442
|
+
"""Alive actions."""
|
|
443
|
+
|
|
444
|
+
if len(self.arguments) < 1:
|
|
445
|
+
raise Exception("Se necesita 1 argumento aparte de la acción")
|
|
446
|
+
|
|
447
|
+
host_url = self.arguments[0]
|
|
448
|
+
result = SysUtils.ping_host(host_url)
|
|
449
|
+
if not result:
|
|
450
|
+
timestamp = datetime.datetime.now().strftime("%d/%m/%Y %H:%M:%S")
|
|
451
|
+
server_name = self.config.getParam("server_name", default="unbutu-server")
|
|
452
|
+
tittle = "Servidor %s no es accesible desde %s" % (host_url, server_name)
|
|
453
|
+
data = [
|
|
454
|
+
"<a>El servidor %s no ha respondido a la solicitud (ping)</a>" % host_url,
|
|
455
|
+
"Esta comprobación fué realizada a las %s desde %s" % (timestamp, server_name),
|
|
456
|
+
]
|
|
457
|
+
|
|
458
|
+
return self.send_email("<br>\n".join(data), tittle)
|
|
459
|
+
|
|
460
|
+
return True
|
|
461
|
+
|
|
462
|
+
def resume_actions(self) -> bool:
|
|
463
|
+
"""Resume actions."""
|
|
464
|
+
|
|
465
|
+
# Recolectar info de ficheros creados en las últimas 24 horas en local_folder_backups
|
|
466
|
+
local_folder_backups = self.config.getParam("local_folder_backups")
|
|
467
|
+
if not self.file_acts.mount_local_folder_backups():
|
|
468
|
+
raise Exception("No se ha montado el directorio %s" % local_folder_backups)
|
|
469
|
+
yesterday = datetime.datetime.now() - datetime.timedelta(days=1) # 24 horas atrás
|
|
470
|
+
|
|
471
|
+
# Lista de fichero creados en las últimas 24 horas en la carpeta local_folder_backup
|
|
472
|
+
files = os.listdir(local_folder_backups)
|
|
473
|
+
files = [os.path.join(local_folder_backups, f) for f in files]
|
|
474
|
+
result_files = []
|
|
475
|
+
for file in files:
|
|
476
|
+
if file.endswith(".tar.gz"):
|
|
477
|
+
file_timestamp = os.path.getmtime(file)
|
|
478
|
+
file_size = os.path.getsize(file)
|
|
479
|
+
if datetime.datetime.fromtimestamp(file_timestamp) > yesterday:
|
|
480
|
+
result_files.append([file, file_size])
|
|
481
|
+
|
|
482
|
+
# result_files.sort(key=lambda x: os.path.getmtime(x[0])) # ordena por nombre
|
|
483
|
+
self.file_acts.umount_local_folder_backups()
|
|
484
|
+
result_files.sort(key=lambda x: x[1]) # Ordena por tamaño
|
|
485
|
+
result_files.reverse()
|
|
486
|
+
|
|
487
|
+
# Enviar email informando ...
|
|
488
|
+
data = []
|
|
489
|
+
cnt_result = len(result_files)
|
|
490
|
+
if cnt_result:
|
|
491
|
+
data.append("<a>Los siguientes %d ficheros se han generado en la últimas 24 horas en %s:</a>" % (cnt_result, local_folder_backups))
|
|
492
|
+
data.append("<table><tr><th>Nombre</th><th>Tamaño</tr>")
|
|
493
|
+
for file_name, file_size in result_files:
|
|
494
|
+
file_size_txt = ""
|
|
495
|
+
if file_size > 1024 * 1024:
|
|
496
|
+
file_size_txt = "%.2f MB" % (file_size / (1024 * 1024))
|
|
497
|
+
elif file_size > 1024:
|
|
498
|
+
file_size_txt = "%.2f KB" % (file_size / 1024)
|
|
499
|
+
else:
|
|
500
|
+
file_size_txt = "%d Bytes" % file_size
|
|
501
|
+
|
|
502
|
+
data.append("<tr><td>%s</td><td>%s</td></tr>" % (os.path.basename(file_name), file_size_txt))
|
|
503
|
+
data.append("</table>")
|
|
504
|
+
else:
|
|
505
|
+
data.append("<a>No se han generado ficheros en la última 24 horas en %s</a>" % local_folder_backups)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
tittle = "Backup servidor %s" % self.config.getParam("server_name", default="unbutu-server")
|
|
509
|
+
return self.send_email("\n".join(data), tittle)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def file_actions(self) -> bool:
|
|
513
|
+
"""File actions."""
|
|
514
|
+
|
|
515
|
+
if len(self.arguments) < 3:
|
|
516
|
+
raise Exception("Se necesitan 3 argumentos aparte de la acción")
|
|
517
|
+
|
|
518
|
+
mode = self.arguments[0] # remote
|
|
519
|
+
local_folder = self.arguments[1] # /mnt/sinology
|
|
520
|
+
remote_folder = self.arguments[2] # //smb/loquesea
|
|
521
|
+
horas = self.arguments[3] if len(self.arguments) > 3 else 12
|
|
522
|
+
|
|
523
|
+
if mode == "remote":
|
|
524
|
+
return self.file_acts.move_files(local_folder, remote_folder, horas)
|
|
525
|
+
|
|
526
|
+
raise Exception("No implementado")
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def db_actions(self) -> bool:
|
|
531
|
+
"""DB actions."""
|
|
532
|
+
|
|
533
|
+
if len(self.arguments) < 2:
|
|
534
|
+
raise Exception("Se necesitan 2 argumentos aparte de la acción")
|
|
535
|
+
|
|
536
|
+
mode = self.arguments[0]
|
|
537
|
+
files_list = self.arguments[1].split(",")
|
|
538
|
+
|
|
539
|
+
check_command("pg_dump")
|
|
540
|
+
check_command("psql")
|
|
541
|
+
check_command("tar")
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
dbs_names = []
|
|
545
|
+
for file_name_list in files_list:
|
|
546
|
+
file_path = self.resuelve_fichero(file_name_list)
|
|
547
|
+
|
|
548
|
+
with open(file_path, "r") as f:
|
|
549
|
+
lines = f.readlines()
|
|
550
|
+
for line in lines:
|
|
551
|
+
dbs_names.append(line.strip("\n"))
|
|
552
|
+
|
|
553
|
+
error = False
|
|
554
|
+
is_replicate = False
|
|
555
|
+
check_older_files = True
|
|
556
|
+
if mode == "replicate":
|
|
557
|
+
mode = "dump"
|
|
558
|
+
is_replicate = True
|
|
559
|
+
if mode in ["dump", "restore"]:
|
|
560
|
+
ini_time = datetime.datetime.now()
|
|
561
|
+
print("* Inicio tarea %s a las %s" % (mode, ini_time.strftime("%d/%m/%Y %H:%M:%S")))
|
|
562
|
+
for db_name in dbs_names:
|
|
563
|
+
try:
|
|
564
|
+
|
|
565
|
+
if not self.backup_db(mode, db_name, is_replicate):
|
|
566
|
+
error = True
|
|
567
|
+
except Exception as inst:
|
|
568
|
+
LOGGER.error(inst)
|
|
569
|
+
error = True
|
|
570
|
+
|
|
571
|
+
fin_time = datetime.datetime.now()
|
|
572
|
+
print("* Fin tarea %s a las %s" % (mode, fin_time.strftime("%d/%m/%Y %H:%M:%S")))
|
|
573
|
+
minutos = (fin_time - ini_time).seconds / 60
|
|
574
|
+
segundos = (fin_time - ini_time).seconds % 60
|
|
575
|
+
print("* Duración: %d minutos y %d segundos" % (minutos, segundos))
|
|
576
|
+
elif mode == "test":
|
|
577
|
+
data = []
|
|
578
|
+
check_older_files = False
|
|
579
|
+
mail_tittle = "CHECK BACKUPS %s (%s)" % (self.config.getParam("server_name", default="unbutu-server"), ",".join(files_list))
|
|
580
|
+
ini_time = datetime.datetime.now()
|
|
581
|
+
print("* Inicio tarea %s a las %s" % (mode, ini_time.strftime("%d/%m/%Y %H:%M:%S")))
|
|
582
|
+
try:
|
|
583
|
+
for db_name in dbs_names:
|
|
584
|
+
self.backup_db_test(db_name)
|
|
585
|
+
except Exception as inst:
|
|
586
|
+
LOGGER.error(inst)
|
|
587
|
+
data.append("<a>Se ha producido un error en el test de backups</a>")
|
|
588
|
+
data.append("<a>ERROR: %s</a>" % inst)
|
|
589
|
+
error = True
|
|
590
|
+
else:
|
|
591
|
+
data.append("<a>Test de backups realizado correctamente</a>")
|
|
592
|
+
|
|
593
|
+
fin_time = datetime.datetime.now()
|
|
594
|
+
print("* Fin tarea %s a las %s" % (mode, fin_time.strftime("%d/%m/%Y %H:%M:%S")))
|
|
595
|
+
minutos = (fin_time - ini_time).seconds / 60
|
|
596
|
+
segundos = (fin_time - ini_time).seconds % 60
|
|
597
|
+
print("* Duración: %d minutos y %d segundos" % (minutos, segundos))
|
|
598
|
+
self.send_email("\n".join(data), mail_tittle)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
else:
|
|
602
|
+
raise Exception("Modo %s no reconocido" % (mode))
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
if not error:
|
|
606
|
+
if check_older_files:
|
|
607
|
+
return self.remove_old_backups(dbs_names)
|
|
608
|
+
return True
|
|
609
|
+
else:
|
|
610
|
+
return False
|
|
611
|
+
|
|
612
|
+
def resuelve_fichero(self, file_name: str) -> str:
|
|
613
|
+
"""Resuelve fichero."""
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
result : str = file_name if file_name.startswith("/") else ""
|
|
617
|
+
if not result:
|
|
618
|
+
# Busqueda en local
|
|
619
|
+
result = os.path.join(os.path.dirname(__file__), file_name)
|
|
620
|
+
if not os.path.exists(result):
|
|
621
|
+
file_name_home = "~/%s" % file_name
|
|
622
|
+
result = os.path.expanduser(file_name_home)
|
|
623
|
+
|
|
624
|
+
result = os.path.abspath(result)
|
|
625
|
+
|
|
626
|
+
if not os.path.exists(result):
|
|
627
|
+
raise Exception("Fichero %s no encontrado" % file_name)
|
|
628
|
+
|
|
629
|
+
return result
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def remove_old_backups(self, files_names: List[str] = []) -> bool:
|
|
634
|
+
"""Check backups."""
|
|
635
|
+
days_alive = int(self.config.getParam("days_alive"))
|
|
636
|
+
local_folder_backups = self.config.getParam("local_folder_backups")
|
|
637
|
+
if not self.file_acts.mount_local_folder_backups():
|
|
638
|
+
raise Exception("No se ha montado el directorio %s" % local_folder_backups)
|
|
639
|
+
LOGGER.info("\t Comprobando backups viejos... ( más de %d días en carpeta %s)" % (days_alive, local_folder_backups))
|
|
640
|
+
cutoff = datetime.datetime.now().timestamp() - (days_alive * 86400)
|
|
641
|
+
for file_name in files_names:
|
|
642
|
+
LOGGER.info("\t\t* Comprobando %s" % file_name)
|
|
643
|
+
for p in Path(local_folder_backups).glob("%s_*.tar.gz" % file_name):
|
|
644
|
+
if p.is_file() and p.stat().st_mtime < cutoff:
|
|
645
|
+
LOGGER.info("\t\t\t- Borrando %s" % p)
|
|
646
|
+
p.unlink()
|
|
647
|
+
self.check_current_backups(file_name)
|
|
648
|
+
|
|
649
|
+
self.file_acts.umount_local_folder_backups()
|
|
650
|
+
return True
|
|
651
|
+
|
|
652
|
+
def check_current_backups(self, file_name: str) -> bool:
|
|
653
|
+
"""Check current backups."""
|
|
654
|
+
cnt_files = int(self.config.getParam("current_copies"))
|
|
655
|
+
if cnt_files < 1:
|
|
656
|
+
return False
|
|
657
|
+
|
|
658
|
+
local_folder_backups = self.config.getParam("local_folder_backups")
|
|
659
|
+
current_folder = self.config.getParam("current_folder")
|
|
660
|
+
current_path = os.path.join(local_folder_backups, current_folder)
|
|
661
|
+
if not os.path.exists(current_path):
|
|
662
|
+
LOGGER.info("\t\t* Creando directorio %s" % current_path)
|
|
663
|
+
os.mkdir(current_path)
|
|
664
|
+
|
|
665
|
+
# Mover los cnt_files más recientes desde local_folder_backups a currents
|
|
666
|
+
recent = SysUtils.newest_files(local_folder_backups, "%s_*.tar.gz" % file_name, cnt_files)
|
|
667
|
+
selected_files_path: List[str] = []
|
|
668
|
+
for f in recent:
|
|
669
|
+
target_file = os.path.join(current_path, f.name)
|
|
670
|
+
selected_files_path.append(target_file)
|
|
671
|
+
if not os.path.exists(target_file):
|
|
672
|
+
LOGGER.info("\t\t* Moviendo %s a %s" % (f, current_path))
|
|
673
|
+
shutil.copy2(str(f), target_file)
|
|
674
|
+
else:
|
|
675
|
+
LOGGER.info("\t\t* %s ya existe en %s" % (f, current_path))
|
|
676
|
+
|
|
677
|
+
# Borrar los ficheros que no están en selected_files_path
|
|
678
|
+
for f in SysUtils.glob_files(current_path, "%s_*.tar.gz" % file_name):
|
|
679
|
+
if str(f) not in selected_files_path:
|
|
680
|
+
LOGGER.info("\t\t* Borrando %s" % f)
|
|
681
|
+
f.unlink()
|
|
682
|
+
|
|
683
|
+
return True
|
|
684
|
+
|
|
685
|
+
def resolve_newset_file_name(self, db_name: str, newest: bool = False) -> str:
|
|
686
|
+
"""Resolve newset file name."""
|
|
687
|
+
local_folder_backups = self.config.getParam("local_folder_backups")
|
|
688
|
+
if newest:
|
|
689
|
+
return "%s_%s.sql" % (db_name, datetime.datetime.now().strftime("%Y%m%d_%H%M%S"))
|
|
690
|
+
# Buscar fichero mas reciente con el nombre que comience por db_name
|
|
691
|
+
newest_file = SysUtils.newest_file(local_folder_backups, "%s*.tar.gz" % db_name)
|
|
692
|
+
return str(newest_file) if newest_file else ""
|
|
693
|
+
|
|
694
|
+
def backup_db_test(self, orig_db_name: str) -> bool:
|
|
695
|
+
"""Backup DB test."""
|
|
696
|
+
|
|
697
|
+
user_db = self.config.getParam("user_db", True)
|
|
698
|
+
pass_db = self.config.getParam("pass_db", True)
|
|
699
|
+
port_db = self.config.getParam("port_db", default="5432")
|
|
700
|
+
local_folder_backups = self.config.getParam("local_folder_backups")
|
|
701
|
+
if not self.file_acts.mount_local_folder_backups():
|
|
702
|
+
raise Exception("No se ha montado el directorio %s" % local_folder_backups)
|
|
703
|
+
time_ini = datetime.datetime.now()
|
|
704
|
+
print("* %s Realizando TEST de %s" % (time_ini.strftime("%Y/%m/%d %H:%M:%S"), orig_db_name))
|
|
705
|
+
host_db = self.config.getParam("host_db", default="127.0.0.1" )
|
|
706
|
+
|
|
707
|
+
current_dir = os.getcwd()
|
|
708
|
+
|
|
709
|
+
dest_file_tar_gz = self.resolve_newset_file_name(orig_db_name)
|
|
710
|
+
if not dest_file_tar_gz:
|
|
711
|
+
raise Exception("No se ha encontrado fichero para %s en %s" % (orig_db_name, local_folder_backups))
|
|
712
|
+
dest_file = dest_file_tar_gz.replace(".tar.gz", "")
|
|
713
|
+
os.chdir(local_folder_backups)
|
|
714
|
+
# Comprueba si el fichero existe
|
|
715
|
+
if not os.path.exists(dest_file_tar_gz):
|
|
716
|
+
raise Exception("Fichero %s no encontrado" % dest_file_tar_gz)
|
|
717
|
+
|
|
718
|
+
LOGGER.info("Descromprimiendo %s ...." % (dest_file_tar_gz))
|
|
719
|
+
with tarfile.open(dest_file_tar_gz, "r:gz") as tar:
|
|
720
|
+
tar.extractall(path=local_folder_backups)
|
|
721
|
+
if not os.path.exists(dest_file):
|
|
722
|
+
raise Exception("Fichero %s no encontrado" % dest_file)
|
|
723
|
+
|
|
724
|
+
if orig_db_name == "roles":
|
|
725
|
+
print("TEST de roles. El fichero existe. No se va a comprobar si es restaurable")
|
|
726
|
+
Path(dest_file).unlink()
|
|
727
|
+
return True
|
|
728
|
+
|
|
729
|
+
db_name = "%s_test" % (orig_db_name)
|
|
730
|
+
|
|
731
|
+
psql_cmd = ["psql", "--dbname=postgresql://%s:%s@%s:%s/" % (user_db, pass_db, host_db, port_db)]
|
|
732
|
+
subprocess.run(psql_cmd + ["-c", "DROP DATABASE IF EXISTS %s" % db_name], check=False)
|
|
733
|
+
subprocess.run(psql_cmd + ["-c", "CREATE DATABASE %s" % db_name], check=True)
|
|
734
|
+
|
|
735
|
+
# Restaurar dump
|
|
736
|
+
has_error: Optional[str] = None
|
|
737
|
+
restore_cmd = SysUtils.nice_command(
|
|
738
|
+
"psql --dbname=postgresql://%s:%s@%s:%s/%s" % (user_db, pass_db, host_db, port_db, db_name), -20
|
|
739
|
+
).split()
|
|
740
|
+
with open(dest_file, "r") as f:
|
|
741
|
+
if subprocess.run(restore_cmd, stdin=f).returncode != 0:
|
|
742
|
+
has_error = "No se ha restaurado el dump"
|
|
743
|
+
|
|
744
|
+
if not has_error:
|
|
745
|
+
sql = "SELECT pg_size_pretty(pg_database_size(pg_database.datname)) AS SIZE FROM pg_database where pg_database.datname = '%s'" % db_name
|
|
746
|
+
cmd = ["psql", "--dbname=postgresql://%s:%s@%s:%s/" % (user_db, pass_db, host_db, port_db), "-c", sql]
|
|
747
|
+
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
748
|
+
result_list = proc.stdout.split("\n")
|
|
749
|
+
if len(result_list) > 3:
|
|
750
|
+
size_orig = result_list[2]
|
|
751
|
+
size_datatable = int(size_orig.strip(" ").split(" ")[0])
|
|
752
|
+
if size_datatable < 1:
|
|
753
|
+
has_error = "Tamaño datatable inválido (%s)" % size_orig
|
|
754
|
+
else:
|
|
755
|
+
has_error = "Tamaño datatable inválido. Error de consulta"
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
subprocess.run(psql_cmd + ["-c", "DROP DATABASE IF EXISTS %s" % db_name], check=False)
|
|
759
|
+
|
|
760
|
+
if has_error:
|
|
761
|
+
raise Exception("Error al restaurar %s:%s" % (dest_file, has_error))
|
|
762
|
+
|
|
763
|
+
Path(dest_file).unlink()
|
|
764
|
+
|
|
765
|
+
self.file_acts.umount_local_folder_backups()
|
|
766
|
+
os.chdir(current_dir)
|
|
767
|
+
time_fin = datetime.datetime.now()
|
|
768
|
+
# Convertir la diferencia de time_fin y time_ini a minutos/segundos
|
|
769
|
+
minutos_total = (time_fin - time_ini).total_seconds() / 60
|
|
770
|
+
minutos_total = int(minutos_total)
|
|
771
|
+
segundos_total = (time_fin - time_ini).total_seconds() % 60
|
|
772
|
+
segundos_total = int(segundos_total)
|
|
773
|
+
tiempo_total = "%02d:%02d" % (minutos_total, segundos_total)
|
|
774
|
+
print("* %s Finalizado test de %s. Tiempo backup: %s minutos" % (time_fin.strftime("%Y/%m/%d %H:%M:%S"), db_name, tiempo_total))
|
|
775
|
+
|
|
776
|
+
return True
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def backup_db(self, mode: str, db_name: str, replicate: bool = False) -> bool:
|
|
780
|
+
"""Backup DB."""
|
|
781
|
+
user_db = self.config.getParam("user_db", True)
|
|
782
|
+
pass_db = self.config.getParam("pass_db", True)
|
|
783
|
+
port_db = self.config.getParam("port_db", default="5432")
|
|
784
|
+
local_folder_backups = self.config.getParam("local_folder_backups")
|
|
785
|
+
if not self.file_acts.mount_local_folder_backups():
|
|
786
|
+
raise Exception("No se ha montado el directorio %s" % local_folder_backups)
|
|
787
|
+
time_ini = datetime.datetime.now()
|
|
788
|
+
print("* %s Realizando %s de %s" % (time_ini.strftime("%Y/%m/%d %H:%M:%S"), mode, db_name))
|
|
789
|
+
orig_host_db = self.config.getParam("host_db", default="127.0.0.1")
|
|
790
|
+
|
|
791
|
+
current_dir = os.getcwd()
|
|
792
|
+
|
|
793
|
+
try:
|
|
794
|
+
if mode == "dump":
|
|
795
|
+
dest_file = self.resolve_newset_file_name(db_name, True)
|
|
796
|
+
dest_file_tar_gz = "%s.tar.gz" % (dest_file)
|
|
797
|
+
os.chdir(local_folder_backups)
|
|
798
|
+
if os.path.exists(dest_file):
|
|
799
|
+
os.remove(dest_file)
|
|
800
|
+
# Crear dump
|
|
801
|
+
conn_str = "postgresql://%s:%s@%s:%s" % (user_db, pass_db, orig_host_db, port_db)
|
|
802
|
+
if db_name == "roles":
|
|
803
|
+
cmd = ["pg_dumpall", "--dbname=%s" % conn_str, "--globals-only"]
|
|
804
|
+
else:
|
|
805
|
+
cmd = ["pg_dump", "--dbname=%s/%s" % (conn_str, db_name)]
|
|
806
|
+
with open(dest_file, "w") as f:
|
|
807
|
+
subprocess.run(cmd, stdout=f, check=True)
|
|
808
|
+
LOGGER.debug("\t* Ejecutando: %s" % " ".join(cmd))
|
|
809
|
+
if not os.path.exists(dest_file):
|
|
810
|
+
raise Exception("Fichero %s no encontrado" % dest_file)
|
|
811
|
+
|
|
812
|
+
LOGGER.info("\t* Comprimiendo %s ...." % (dest_file))
|
|
813
|
+
with tarfile.open(dest_file_tar_gz, "w:gz") as tar:
|
|
814
|
+
tar.add(dest_file, arcname=os.path.basename(dest_file))
|
|
815
|
+
Path(dest_file).unlink()
|
|
816
|
+
|
|
817
|
+
self.check_file_size(dest_file_tar_gz)
|
|
818
|
+
|
|
819
|
+
result = self.backup_db("restore", db_name, True) if replicate else os.path.exists(dest_file_tar_gz)
|
|
820
|
+
if not result:
|
|
821
|
+
raise Exception("Error al realizar el backup de %s" % db_name)
|
|
822
|
+
os.chdir(current_dir)
|
|
823
|
+
|
|
824
|
+
elif mode == "restore":
|
|
825
|
+
dest_file_tar_gz = self.resolve_newset_file_name(db_name)
|
|
826
|
+
dest_file = dest_file_tar_gz.replace(".tar.gz", "")
|
|
827
|
+
os.chdir(local_folder_backups)
|
|
828
|
+
if not os.path.exists(dest_file_tar_gz):
|
|
829
|
+
raise Exception("Fichero %s no encontrado" % dest_file_tar_gz)
|
|
830
|
+
|
|
831
|
+
LOGGER.info("Descromprimiendo %s ...." % (dest_file_tar_gz))
|
|
832
|
+
with tarfile.open(dest_file_tar_gz, "r:gz") as tar:
|
|
833
|
+
tar.extractall(path=local_folder_backups)
|
|
834
|
+
if not os.path.exists(dest_file):
|
|
835
|
+
raise Exception("Fichero %s no encontrado" % dest_file)
|
|
836
|
+
|
|
837
|
+
host_db = self.config.getParam("replicate_host_db") if replicate else orig_host_db
|
|
838
|
+
conn_str = "postgresql://%s:%s@%s:%s" % (user_db, pass_db, host_db, port_db)
|
|
839
|
+
|
|
840
|
+
psql_cmd = ["psql", "--dbname=%s/" % conn_str]
|
|
841
|
+
subprocess.run(psql_cmd + ["-c", "DROP DATABASE %s" % db_name], check=False)
|
|
842
|
+
subprocess.run(psql_cmd + ["-c", "CREATE DATABASE %s" % db_name], check=True)
|
|
843
|
+
|
|
844
|
+
# Restaurar dump
|
|
845
|
+
restore_cmd = SysUtils.nice_command(
|
|
846
|
+
"psql --dbname=%s/%s" % (conn_str, db_name), -20
|
|
847
|
+
).split()
|
|
848
|
+
with open(dest_file, "r") as f:
|
|
849
|
+
subprocess.run(restore_cmd, stdin=f, check=True)
|
|
850
|
+
|
|
851
|
+
Path(dest_file).unlink()
|
|
852
|
+
|
|
853
|
+
self.file_acts.umount_local_folder_backups()
|
|
854
|
+
os.chdir(current_dir)
|
|
855
|
+
time_fin = datetime.datetime.now()
|
|
856
|
+
minutos_total = int((time_fin - time_ini).total_seconds() / 60)
|
|
857
|
+
segundos_total = int((time_fin - time_ini).total_seconds() % 60)
|
|
858
|
+
tiempo_total = "%02d:%02d" % (minutos_total, segundos_total)
|
|
859
|
+
print("* %s Finalizada %s de %s. Tiempo backup: %s minutos" % (time_fin.strftime("%Y/%m/%d %H:%M:%S"), mode, db_name, tiempo_total))
|
|
860
|
+
except Exception as inst:
|
|
861
|
+
self.file_acts.umount_local_folder_backups()
|
|
862
|
+
os.chdir(current_dir)
|
|
863
|
+
LOGGER.error(type(inst))
|
|
864
|
+
LOGGER.error(inst.args)
|
|
865
|
+
LOGGER.error(inst)
|
|
866
|
+
return False
|
|
867
|
+
|
|
868
|
+
return True
|
|
869
|
+
|
|
870
|
+
def check_file_size(self, file_name: str) -> bool:
|
|
871
|
+
"""Check file size."""
|
|
872
|
+
|
|
873
|
+
min_size = int(self.config.getParam("min_size"))
|
|
874
|
+
file_size = os.path.getsize(file_name)
|
|
875
|
+
# print("Check file size %s %s > %s" % (file_name, file_size, min_size))
|
|
876
|
+
|
|
877
|
+
if file_size < min_size:
|
|
878
|
+
tittle = "Problema en fichero de backup detectado"
|
|
879
|
+
data = [
|
|
880
|
+
"<a>El fichero %s se ha generado con un tamaño inferior a %sBytes (%s)</a>" % (file_name, min_size, file_size),
|
|
881
|
+
]
|
|
882
|
+
return self.send_email("\n".join(data), tittle)
|
|
883
|
+
|
|
884
|
+
return True
|
|
885
|
+
|
|
886
|
+
def send_email(self, payload: str, tittle: str) -> bool:
|
|
887
|
+
"""Enviar email."""
|
|
888
|
+
|
|
889
|
+
user_ = self.config.getParam("user_email")
|
|
890
|
+
pass_ = self.config.getParam("pass_email", True)
|
|
891
|
+
payload += "<br><br>Yeboyebo backup tools v%s" % __VERSION__
|
|
892
|
+
|
|
893
|
+
timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
|
|
894
|
+
file_name = os.path.join(SysUtils.temp_dir(), "%s.txt" % timestamp)
|
|
895
|
+
with open(file_name, "wb") as f:
|
|
896
|
+
f.write(payload.encode("utf-8"))
|
|
897
|
+
|
|
898
|
+
emails_list = self.config.getParam("list_mails").split(",")
|
|
899
|
+
result = None
|
|
900
|
+
s = SendEmail(user_, pass_)
|
|
901
|
+
for email in emails_list:
|
|
902
|
+
if s.sendEmail(email,tittle,file_name):
|
|
903
|
+
result = True
|
|
904
|
+
else:
|
|
905
|
+
result = False
|
|
906
|
+
break
|
|
907
|
+
|
|
908
|
+
if result is None:
|
|
909
|
+
LOGGER.error("No se han especificado emails en list_mails")
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
return result
|
|
913
|
+
|
|
914
|
+
class SysUtils:
|
|
915
|
+
"""Platform abstraction for OS-specific operations."""
|
|
916
|
+
|
|
917
|
+
IS_WIN: bool = os.name == "nt"
|
|
918
|
+
|
|
919
|
+
@staticmethod
|
|
920
|
+
def clear() -> None:
|
|
921
|
+
"""Clear terminal screen."""
|
|
922
|
+
os.system("cls" if SysUtils.IS_WIN else "clear")
|
|
923
|
+
|
|
924
|
+
@staticmethod
|
|
925
|
+
def nice_command(command: str, level: int) -> str:
|
|
926
|
+
"""Wrap command with nice on Linux. No-op on Windows."""
|
|
927
|
+
if SysUtils.IS_WIN:
|
|
928
|
+
return command
|
|
929
|
+
return "nice -n %d %s" % (level, command)
|
|
930
|
+
|
|
931
|
+
@staticmethod
|
|
932
|
+
def ping_host(host: str, count: int = 3) -> bool:
|
|
933
|
+
"""Ping a host. Returns True if reachable."""
|
|
934
|
+
flag = "-n" if SysUtils.IS_WIN else "-c"
|
|
935
|
+
cmd = ["ping", flag, str(count), host]
|
|
936
|
+
return subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0
|
|
937
|
+
|
|
938
|
+
@staticmethod
|
|
939
|
+
def mount_device(device: str, mount_point: str, use_cifs: bool = False) -> bool:
|
|
940
|
+
"""Mount a device. CIFS on Linux uses mount.cifs; on Windows uses net use."""
|
|
941
|
+
if SysUtils.IS_WIN:
|
|
942
|
+
# net use <drive>: \\server\share
|
|
943
|
+
cmd = ["net", "use", mount_point, device]
|
|
944
|
+
if use_cifs:
|
|
945
|
+
cmd.extend(["/user:guest", ""])
|
|
946
|
+
else:
|
|
947
|
+
if use_cifs:
|
|
948
|
+
cmd = ["mount.cifs", device, mount_point, "-o", "rw,guest,vers=2.0"]
|
|
949
|
+
else:
|
|
950
|
+
cmd = ["mount", device, mount_point]
|
|
951
|
+
return subprocess.run(cmd).returncode == 0
|
|
952
|
+
|
|
953
|
+
@staticmethod
|
|
954
|
+
def umount_device(mount_point: str) -> bool:
|
|
955
|
+
"""Unmount a device. Returns True if flag file no longer exists."""
|
|
956
|
+
if SysUtils.IS_WIN:
|
|
957
|
+
cmd = ["net", "use", mount_point, "/delete"]
|
|
958
|
+
else:
|
|
959
|
+
cmd = ["umount", mount_point]
|
|
960
|
+
subprocess.run(cmd)
|
|
961
|
+
return True
|
|
962
|
+
|
|
963
|
+
@staticmethod
|
|
964
|
+
def touch(path: str) -> None:
|
|
965
|
+
"""Create an empty file."""
|
|
966
|
+
Path(path).touch()
|
|
967
|
+
|
|
968
|
+
@staticmethod
|
|
969
|
+
def which(name: str) -> Optional[str]:
|
|
970
|
+
"""Locate a command. Returns path or None."""
|
|
971
|
+
return shutil.which(name)
|
|
972
|
+
|
|
973
|
+
@staticmethod
|
|
974
|
+
def find_recent_files(directory: str, pattern: str, minutes: int) -> List[Path]:
|
|
975
|
+
"""Find files matching glob pattern modified in the last N minutes."""
|
|
976
|
+
cutoff = datetime.datetime.now().timestamp() - (minutes * 60)
|
|
977
|
+
results = []
|
|
978
|
+
for p in Path(directory).rglob(pattern):
|
|
979
|
+
if p.is_file() and p.stat().st_mtime >= cutoff:
|
|
980
|
+
results.append(p)
|
|
981
|
+
return results
|
|
982
|
+
|
|
983
|
+
@staticmethod
|
|
984
|
+
def copy_files(files: List[Path], dest_dir: str) -> None:
|
|
985
|
+
"""Copy files preserving metadata."""
|
|
986
|
+
dest = Path(dest_dir)
|
|
987
|
+
for f in files:
|
|
988
|
+
shutil.copy2(str(f), str(dest / f.name))
|
|
989
|
+
|
|
990
|
+
@staticmethod
|
|
991
|
+
def newest_file(directory: str, glob_pattern: str) -> Optional[Path]:
|
|
992
|
+
"""Return the most recently modified file matching glob pattern, or None."""
|
|
993
|
+
files = sorted(Path(directory).glob(glob_pattern), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
994
|
+
return files[0] if files else None
|
|
995
|
+
|
|
996
|
+
@staticmethod
|
|
997
|
+
def newest_files(directory: str, glob_pattern: str, count: int) -> List[Path]:
|
|
998
|
+
"""Return the N most recently modified files matching glob pattern."""
|
|
999
|
+
files = sorted(Path(directory).glob(glob_pattern), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
1000
|
+
return files[:count]
|
|
1001
|
+
|
|
1002
|
+
@staticmethod
|
|
1003
|
+
def glob_files(directory: str, glob_pattern: str) -> List[Path]:
|
|
1004
|
+
"""Return all files matching glob pattern."""
|
|
1005
|
+
return [p for p in Path(directory).glob(glob_pattern) if p.is_file()]
|
|
1006
|
+
|
|
1007
|
+
@staticmethod
|
|
1008
|
+
def temp_dir() -> str:
|
|
1009
|
+
"""Return OS-appropriate temp directory."""
|
|
1010
|
+
return tempfile.gettempdir()
|
|
1011
|
+
|
|
1012
|
+
@staticmethod
|
|
1013
|
+
def run(cmd: List[str], **kwargs) -> subprocess.CompletedProcess:
|
|
1014
|
+
"""Run a command via subprocess. Returns CompletedProcess."""
|
|
1015
|
+
return subprocess.run(cmd, **kwargs)
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def main() -> None:
|
|
1019
|
+
"""Entry point for console_scripts."""
|
|
1020
|
+
global LOGGER
|
|
1021
|
+
LOGGER = init_logger()
|
|
1022
|
+
obj = backupTools()
|
|
1023
|
+
if not obj.run():
|
|
1024
|
+
LOGGER.error("(ERROR) Error al ejecutar la acción %s" % obj.action_name)
|
|
1025
|
+
sys.exit(1)
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
if __name__ == "__main__":
|
|
1029
|
+
main()
|
|
1030
|
+
|
|
1031
|
+
# python3 backup_tools.py db restore lista.txt
|
|
1032
|
+
# python3 backup_tools.py db dump lista.txt
|
|
1033
|
+
# python3 backup_tools.py file remote local_folder remote_device horas? 12
|
|
1034
|
+
# python3 backup_tools.py config current_copies 3
|
|
1035
|
+
# python3 backup_tools.py resume
|
|
1036
|
+
# python3 backup_tools.py alive 192.168.1.30
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Setup for yeboyebo-backup-tools."""
|
|
3
|
+
|
|
4
|
+
from setuptools import setup
|
|
5
|
+
|
|
6
|
+
long_description = "Herramienta de backup PostgreSQL con montaje remoto, replicacion, test de integridad y alertas por email. Compatible Linux y Windows."
|
|
7
|
+
|
|
8
|
+
setup(
|
|
9
|
+
name="yeboyebo-backup-tools",
|
|
10
|
+
version="1.1",
|
|
11
|
+
description="Herramienta de backup PostgreSQL multiplataforma",
|
|
12
|
+
long_description=long_description,
|
|
13
|
+
long_description_content_type="text/plain",
|
|
14
|
+
author="Jose A. Fernandez",
|
|
15
|
+
author_email="aullasistemas@gmail.com",
|
|
16
|
+
url="https://github.com/yeboyebo/utils",
|
|
17
|
+
py_modules=["backup_tools"],
|
|
18
|
+
install_requires=[
|
|
19
|
+
"fernet",
|
|
20
|
+
],
|
|
21
|
+
extras_require={
|
|
22
|
+
"loki": ["python-logging-loki-v2"],
|
|
23
|
+
},
|
|
24
|
+
entry_points={
|
|
25
|
+
"console_scripts": [
|
|
26
|
+
"backup-tools=backup_tools:main",
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
classifiers=[
|
|
30
|
+
"Development Status :: 5 - Production/Stable",
|
|
31
|
+
"Environment :: Console",
|
|
32
|
+
"Intended Audience :: System Administrators",
|
|
33
|
+
"License :: OSI Approved :: MIT License",
|
|
34
|
+
"Operating System :: POSIX :: Linux",
|
|
35
|
+
"Operating System :: Microsoft :: Windows",
|
|
36
|
+
"Programming Language :: Python :: 3",
|
|
37
|
+
"Topic :: System :: Archiving :: Backup",
|
|
38
|
+
"Topic :: Database",
|
|
39
|
+
],
|
|
40
|
+
python_requires=">=3.8",
|
|
41
|
+
)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Tests for backup_tools."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import uuid
|
|
7
|
+
import tempfile
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
11
|
+
|
|
12
|
+
from backup_tools import Cifrado, configTools, SysUtils
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestCifrado:
|
|
16
|
+
"""Tests for Cifrado (Fernet-based obfuscation)."""
|
|
17
|
+
|
|
18
|
+
def test_ofuscar_resolver_roundtrip(self):
|
|
19
|
+
key = uuid.uuid4().hex
|
|
20
|
+
c = Cifrado(key)
|
|
21
|
+
plain = "mi_contraseña_secreta"
|
|
22
|
+
obf = c.ofuscar(plain)
|
|
23
|
+
assert obf != plain
|
|
24
|
+
assert c.resolver(obf) == plain
|
|
25
|
+
|
|
26
|
+
def test_ofuscar_short_text_pads_to_64(self):
|
|
27
|
+
key = uuid.uuid4().hex
|
|
28
|
+
c = Cifrado(key)
|
|
29
|
+
obf = c.ofuscar("short")
|
|
30
|
+
resolved = c.resolver(obf)
|
|
31
|
+
assert resolved == "short"
|
|
32
|
+
|
|
33
|
+
def test_different_keys_produce_different_ciphertext(self):
|
|
34
|
+
c1 = Cifrado(uuid.uuid4().hex)
|
|
35
|
+
c2 = Cifrado(uuid.uuid4().hex)
|
|
36
|
+
obf1 = c1.ofuscar("test")
|
|
37
|
+
obf2 = c2.ofuscar("test")
|
|
38
|
+
assert obf1 != obf2
|
|
39
|
+
|
|
40
|
+
def test_no_key_raises(self):
|
|
41
|
+
c = Cifrado()
|
|
42
|
+
with pytest.raises(Exception):
|
|
43
|
+
c.ofuscar("test")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TestConfigTools:
|
|
47
|
+
"""Tests for configTools (INI file persistence)."""
|
|
48
|
+
|
|
49
|
+
def test_set_and_get_plain_param(self):
|
|
50
|
+
tmp = os.path.join(tempfile.gettempdir(), "test_config_%s.ini" % uuid.uuid4().hex[:8])
|
|
51
|
+
os.environ["BACKUP_TOOLS_CONFIG"] = tmp
|
|
52
|
+
try:
|
|
53
|
+
cfg = configTools(create=True)
|
|
54
|
+
cfg.setParam("test_key", "test_value")
|
|
55
|
+
assert cfg.getParam("test_key") == "test_value"
|
|
56
|
+
finally:
|
|
57
|
+
del os.environ["BACKUP_TOOLS_CONFIG"]
|
|
58
|
+
if os.path.exists(tmp):
|
|
59
|
+
os.remove(tmp)
|
|
60
|
+
|
|
61
|
+
def test_get_with_default_creates_param(self):
|
|
62
|
+
tmp = os.path.join(tempfile.gettempdir(), "test_config_%s.ini" % uuid.uuid4().hex[:8])
|
|
63
|
+
os.environ["BACKUP_TOOLS_CONFIG"] = tmp
|
|
64
|
+
try:
|
|
65
|
+
cfg = configTools(create=True)
|
|
66
|
+
val = cfg.getParam("new_param", default="default_val")
|
|
67
|
+
assert val == "default_val"
|
|
68
|
+
assert cfg.getParam("new_param") == "default_val"
|
|
69
|
+
finally:
|
|
70
|
+
del os.environ["BACKUP_TOOLS_CONFIG"]
|
|
71
|
+
if os.path.exists(tmp):
|
|
72
|
+
os.remove(tmp)
|
|
73
|
+
|
|
74
|
+
def test_missing_param_raises(self):
|
|
75
|
+
tmp = os.path.join(tempfile.gettempdir(), "test_config_%s.ini" % uuid.uuid4().hex[:8])
|
|
76
|
+
os.environ["BACKUP_TOOLS_CONFIG"] = tmp
|
|
77
|
+
try:
|
|
78
|
+
cfg = configTools(create=True)
|
|
79
|
+
with pytest.raises(Exception):
|
|
80
|
+
cfg.getParam("nonexistent")
|
|
81
|
+
finally:
|
|
82
|
+
del os.environ["BACKUP_TOOLS_CONFIG"]
|
|
83
|
+
if os.path.exists(tmp):
|
|
84
|
+
os.remove(tmp)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TestSysUtils:
|
|
88
|
+
"""Tests for SysUtils platform abstraction."""
|
|
89
|
+
|
|
90
|
+
def test_which_existing_command(self):
|
|
91
|
+
assert SysUtils.which("python") is not None or SysUtils.which("python3") is not None
|
|
92
|
+
|
|
93
|
+
def test_which_nonexistent_command(self):
|
|
94
|
+
assert SysUtils.which("xyznonexistentcmd123") is None
|
|
95
|
+
|
|
96
|
+
def test_touch_creates_file(self):
|
|
97
|
+
tmp = os.path.join(tempfile.gettempdir(), "test_touch_%s.txt" % uuid.uuid4().hex[:8])
|
|
98
|
+
try:
|
|
99
|
+
SysUtils.touch(tmp)
|
|
100
|
+
assert os.path.exists(tmp)
|
|
101
|
+
finally:
|
|
102
|
+
if os.path.exists(tmp):
|
|
103
|
+
os.remove(tmp)
|
|
104
|
+
|
|
105
|
+
def test_temp_dir(self):
|
|
106
|
+
d = SysUtils.temp_dir()
|
|
107
|
+
assert os.path.isdir(d)
|
|
108
|
+
|
|
109
|
+
def test_find_recent_files(self):
|
|
110
|
+
tmpdir = tempfile.mkdtemp()
|
|
111
|
+
try:
|
|
112
|
+
p = os.path.join(tmpdir, "recent.txt")
|
|
113
|
+
SysUtils.touch(p)
|
|
114
|
+
files = SysUtils.find_recent_files(tmpdir, "*.txt", 1)
|
|
115
|
+
assert len(files) >= 1
|
|
116
|
+
finally:
|
|
117
|
+
import shutil
|
|
118
|
+
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
119
|
+
|
|
120
|
+
def test_is_win(self):
|
|
121
|
+
assert isinstance(SysUtils.IS_WIN, bool)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: yeboyebo-backup-tools
|
|
3
|
+
Version: 1.1
|
|
4
|
+
Summary: Herramienta de backup PostgreSQL multiplataforma
|
|
5
|
+
Home-page: https://github.com/yeboyebo/utils
|
|
6
|
+
Author: Jose A. Fernandez
|
|
7
|
+
Author-email: aullasistemas@gmail.com
|
|
8
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: System Administrators
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: System :: Archiving :: Backup
|
|
16
|
+
Classifier: Topic :: Database
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/plain
|
|
19
|
+
Requires-Dist: fernet
|
|
20
|
+
Provides-Extra: loki
|
|
21
|
+
Requires-Dist: python-logging-loki-v2; extra == "loki"
|
|
22
|
+
Dynamic: author
|
|
23
|
+
Dynamic: author-email
|
|
24
|
+
Dynamic: classifier
|
|
25
|
+
Dynamic: description
|
|
26
|
+
Dynamic: description-content-type
|
|
27
|
+
Dynamic: home-page
|
|
28
|
+
Dynamic: provides-extra
|
|
29
|
+
Dynamic: requires-dist
|
|
30
|
+
Dynamic: requires-python
|
|
31
|
+
Dynamic: summary
|
|
32
|
+
|
|
33
|
+
Herramienta de backup PostgreSQL con montaje remoto, replicacion, test de integridad y alertas por email. Compatible Linux y Windows.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
backup_tools.py
|
|
2
|
+
setup.py
|
|
3
|
+
tests/test_backup_tools.py
|
|
4
|
+
yeboyebo_backup_tools.egg-info/PKG-INFO
|
|
5
|
+
yeboyebo_backup_tools.egg-info/SOURCES.txt
|
|
6
|
+
yeboyebo_backup_tools.egg-info/dependency_links.txt
|
|
7
|
+
yeboyebo_backup_tools.egg-info/entry_points.txt
|
|
8
|
+
yeboyebo_backup_tools.egg-info/requires.txt
|
|
9
|
+
yeboyebo_backup_tools.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
backup_tools
|