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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ backup-tools = backup_tools:main
@@ -0,0 +1,4 @@
1
+ fernet
2
+
3
+ [loki]
4
+ python-logging-loki-v2