rossigee-backups 2.9.0__py3-none-any.whl

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.
Files changed (45) hide show
  1. backups/__init__.py +0 -0
  2. backups/compress.py +19 -0
  3. backups/destinations/__init__.py +12 -0
  4. backups/destinations/b2.py +66 -0
  5. backups/destinations/destination.py +14 -0
  6. backups/destinations/dropbox.py +72 -0
  7. backups/destinations/gdrive.py +133 -0
  8. backups/destinations/gs.py +74 -0
  9. backups/destinations/local.py +55 -0
  10. backups/destinations/minio.py +107 -0
  11. backups/destinations/s3.py +130 -0
  12. backups/destinations/samba.py +58 -0
  13. backups/encrypt.py +34 -0
  14. backups/exceptions.py +3 -0
  15. backups/main.py +305 -0
  16. backups/notifications/__init__.py +12 -0
  17. backups/notifications/cloudflare_backup_registry.py +96 -0
  18. backups/notifications/discord.py +40 -0
  19. backups/notifications/elasticsearch.py +77 -0
  20. backups/notifications/flagfile.py +21 -0
  21. backups/notifications/matrix.py +52 -0
  22. backups/notifications/notification.py +27 -0
  23. backups/notifications/prometheus.py +61 -0
  24. backups/notifications/slack.py +57 -0
  25. backups/notifications/smtp.py +75 -0
  26. backups/notifications/telegram.py +46 -0
  27. backups/sources/__init__.py +12 -0
  28. backups/sources/azure_managed_disk.py +122 -0
  29. backups/sources/folder.py +55 -0
  30. backups/sources/folderssh.py +49 -0
  31. backups/sources/lvmssh.py +128 -0
  32. backups/sources/mysql.py +98 -0
  33. backups/sources/mysqlssh.py +61 -0
  34. backups/sources/postgresql.py +76 -0
  35. backups/sources/rds.py +106 -0
  36. backups/sources/rdspostgresql.py +88 -0
  37. backups/sources/sftpfolder.py +109 -0
  38. backups/sources/snapshot.py +74 -0
  39. backups/sources/source.py +82 -0
  40. backups/stats.py +30 -0
  41. rossigee_backups-2.9.0.dist-info/METADATA +24 -0
  42. rossigee_backups-2.9.0.dist-info/RECORD +45 -0
  43. rossigee_backups-2.9.0.dist-info/WHEEL +5 -0
  44. rossigee_backups-2.9.0.dist-info/entry_points.txt +2 -0
  45. rossigee_backups-2.9.0.dist-info/top_level.txt +1 -0
backups/__init__.py ADDED
File without changes
backups/compress.py ADDED
@@ -0,0 +1,19 @@
1
+ import subprocess
2
+ import logging
3
+
4
+ from backups.exceptions import BackupException
5
+
6
+ def compress(filename, tracer=None):
7
+ logging.info("Compressing '%s'..." % filename)
8
+ compfilename = '%s.gz' % filename
9
+ comperrsname = '%s.err' % filename
10
+ comperrs = open(comperrsname, 'wb')
11
+ compargs = ['gzip', '-f', '--fast', filename]
12
+ compproc1 = subprocess.Popen(compargs, stdin=subprocess.PIPE, stderr=comperrs)
13
+ compproc1.wait()
14
+ comperrs.close()
15
+ exitcode = compproc1.returncode
16
+ if exitcode != 0:
17
+ errmsg = open(comperrsname, 'rb').read()
18
+ raise BackupException("Error while compressing: %s" % errmsg)
19
+ return compfilename
@@ -0,0 +1,12 @@
1
+ # Manage list of destinations as they are initialised
2
+ handlers = {}
3
+
4
+ import logging
5
+
6
+ def backupdestination(id):
7
+ def register(handler_class):
8
+ logging.debug("Adding destination '%s' as '%s'" % (handler_class, id))
9
+ handlers[id] = handler_class
10
+ return handler_class
11
+
12
+ return register
@@ -0,0 +1,66 @@
1
+ import os, os.path
2
+ import datetime
3
+ import logging
4
+
5
+ from b2sdk.v2 import InMemoryAccountInfo, B2Api
6
+
7
+ from backups.exceptions import BackupException
8
+ from backups.destinations import backupdestination
9
+ from backups.destinations.destination import BackupDestination
10
+
11
+ @backupdestination('b2')
12
+ class B2(BackupDestination):
13
+ def __init__(self, config):
14
+ BackupDestination.__init__(self, config)
15
+ self.bucket_name = config['bucket']
16
+ self.application_key_id = config['credentials']['application_key_id']
17
+ self.application_key = config['credentials']['application_key']
18
+
19
+ def _get_bucket(self):
20
+ info = InMemoryAccountInfo()
21
+ api = B2Api(info)
22
+ api.authorize_account("production", self.application_key_id, self.application_key)
23
+ return api.get_bucket_by_name(self.bucket_name)
24
+
25
+ def send(self, id, name, filename):
26
+ remote_name = "%s/%s/%s" % (id, self.runtime.strftime("%Y%m%d%H%M%S"), os.path.basename(filename))
27
+ logging.info("Uploading '%s' backup for '%s' to B2 (%s/%s)..." % (name, self.id, self.bucket_name, remote_name))
28
+ try:
29
+ bucket = self._get_bucket()
30
+ bucket.upload_local_file(local_file=filename, file_name=remote_name)
31
+ except Exception as e:
32
+ raise BackupException("Error while uploading (%s): %s" % (self.id, str(e)))
33
+ return "b2://%s/%s" % (self.bucket_name, remote_name)
34
+
35
+ def cleanup(self, id, name):
36
+ logging.info("Clearing down older '%s' backups for '%s' from B2..." % (name, self.id))
37
+ try:
38
+ bucket = self._get_bucket()
39
+ candidates = []
40
+ for file_version, _ in bucket.ls(folder_to_list="%s/" % id, recursive=True):
41
+ candidates.append([file_version.upload_timestamp, file_version])
42
+ candidates.sort(key=lambda x: x[0])
43
+ except Exception as e:
44
+ raise BackupException("Error listing B2 bucket (%s): %s" % (self.id, str(e)))
45
+
46
+ removable = []
47
+ retained = []
48
+ if self.retention_copies > 0:
49
+ items = [f for d, f in candidates]
50
+ if len(items) > self.retention_copies:
51
+ removable = items[0:(len(items) - self.retention_copies)]
52
+ retained = [f.file_name for f in items[(len(items) - self.retention_copies):]]
53
+ if self.retention_days > 0:
54
+ now_ms = datetime.datetime.now(datetime.timezone.utc).timestamp() * 1000
55
+ for ts, file_version in candidates:
56
+ age_days = (now_ms - ts) / (1000 * 86400)
57
+ if age_days > self.retention_days:
58
+ removable.append(file_version)
59
+ for file_version in removable:
60
+ logging.info("Removing '%s'..." % file_version.file_name)
61
+ try:
62
+ file_version.delete()
63
+ except Exception as e:
64
+ logging.warning("Failed to remove '%s': %s" % (file_version.file_name, str(e)))
65
+
66
+ return retained
@@ -0,0 +1,14 @@
1
+ import datetime
2
+
3
+ # Abstract
4
+ class BackupDestination:
5
+ def __init__(self, config):
6
+ self.id = config['id']
7
+ self.runtime = datetime.datetime.now()
8
+
9
+ self.retention_copies = 0
10
+ if 'retention_copies' in config:
11
+ self.retention_copies = int(config['retention_copies'])
12
+ self.retention_days = 0
13
+ if 'retention_days' in config:
14
+ self.retention_days = int(config['retention_days'])
@@ -0,0 +1,72 @@
1
+ import os, os.path
2
+ import datetime
3
+ import logging
4
+
5
+ import dropbox
6
+ from dropbox.exceptions import ApiError
7
+
8
+ from backups.exceptions import BackupException
9
+ from backups.destinations import backupdestination
10
+ from backups.destinations.destination import BackupDestination
11
+
12
+ @backupdestination('dropbox')
13
+ class Dropbox(BackupDestination):
14
+ def __init__(self, config):
15
+ BackupDestination.__init__(self, config)
16
+ self.access_token = config['access_token']
17
+ self.folder = config.get('folder', '/backups')
18
+
19
+ def _get_client(self):
20
+ return dropbox.Dropbox(self.access_token)
21
+
22
+ def send(self, id, name, filename):
23
+ remote_path = "%s/%s/%s/%s" % (
24
+ self.folder.rstrip('/'),
25
+ id,
26
+ self.runtime.strftime("%Y%m%d%H%M%S"),
27
+ os.path.basename(filename))
28
+ logging.info("Uploading '%s' backup for '%s' to Dropbox (%s)..." % (name, self.id, remote_path))
29
+ try:
30
+ dbx = self._get_client()
31
+ with open(filename, 'rb') as f:
32
+ dbx.files_upload(f.read(), remote_path)
33
+ except ApiError as e:
34
+ raise BackupException("Error while uploading (%s): %s" % (self.id, str(e)))
35
+ return remote_path
36
+
37
+ def cleanup(self, id, name):
38
+ folder_path = "%s/%s" % (self.folder.rstrip('/'), id)
39
+ logging.info("Clearing down older '%s' backups for '%s' from Dropbox (%s)..." % (name, self.id, folder_path))
40
+ try:
41
+ dbx = self._get_client()
42
+ result = dbx.files_list_folder(folder_path)
43
+ entries = result.entries
44
+ while result.has_more:
45
+ result = dbx.files_list_folder_continue(result.cursor)
46
+ entries.extend(result.entries)
47
+ except ApiError as e:
48
+ if e.error.is_path() and e.error.get_path().is_not_found():
49
+ return []
50
+ raise BackupException("Error listing Dropbox folder (%s): %s" % (self.id, str(e)))
51
+
52
+ candidates = []
53
+ for entry in entries:
54
+ if isinstance(entry, dropbox.files.FolderMetadata):
55
+ candidates.append([entry.name, entry.path_lower])
56
+ candidates.sort()
57
+
58
+ removable = []
59
+ retained = []
60
+ if self.retention_copies > 0:
61
+ paths = [p for n, p in candidates]
62
+ if len(paths) > self.retention_copies:
63
+ removable = paths[0:(len(paths) - self.retention_copies)]
64
+ retained = paths[(len(paths) - self.retention_copies):]
65
+ for path in removable:
66
+ logging.info("Removing '%s'..." % path)
67
+ try:
68
+ dbx.files_delete_v2(path)
69
+ except ApiError as e:
70
+ logging.warning("Failed to remove '%s': %s" % (path, str(e)))
71
+
72
+ return retained
@@ -0,0 +1,133 @@
1
+ import os, os.path
2
+ import datetime
3
+ import logging
4
+ import contextlib
5
+
6
+ from google.oauth2 import service_account
7
+ from googleapiclient.discovery import build
8
+ from googleapiclient.http import MediaFileUpload
9
+ from googleapiclient.errors import HttpError
10
+
11
+ from backups.exceptions import BackupException
12
+ from backups.destinations import backupdestination
13
+ from backups.destinations.destination import BackupDestination
14
+
15
+ SCOPES = ['https://www.googleapis.com/auth/drive']
16
+
17
+ @backupdestination('gdrive')
18
+ class GDrive(BackupDestination):
19
+ def __init__(self, config):
20
+ BackupDestination.__init__(self, config)
21
+ self.creds_file = config['creds_file']
22
+ self.folder_id = config['folder_id']
23
+
24
+ def _get_tracer(self):
25
+ if hasattr(self, 'tracer') and self.tracer is not None:
26
+ return self.tracer
27
+ class _NoOpTracer:
28
+ @contextlib.contextmanager
29
+ def start_as_current_span(self, name, **kwargs):
30
+ class _NoOpSpan:
31
+ def set_attribute(self, *a, **k): pass
32
+ def record_exception(self, *a, **k): pass
33
+ def set_status(self, *a, **k): pass
34
+ yield _NoOpSpan()
35
+ return _NoOpTracer()
36
+
37
+ def _get_service(self):
38
+ credentials = service_account.Credentials.from_service_account_file(
39
+ self.creds_file, scopes=SCOPES)
40
+ return build('drive', 'v3', credentials=credentials)
41
+
42
+ def _get_or_create_folder(self, service, parent_id, folder_name):
43
+ query = "name='%s' and '%s' in parents and mimeType='application/vnd.google-apps.folder' and trashed=false" % (folder_name, parent_id)
44
+ results = service.files().list(q=query, fields="files(id)").execute()
45
+ files = results.get('files', [])
46
+ if files:
47
+ return files[0]['id']
48
+ metadata = {
49
+ 'name': folder_name,
50
+ 'mimeType': 'application/vnd.google-apps.folder',
51
+ 'parents': [parent_id]
52
+ }
53
+ folder = service.files().create(body=metadata, fields='id').execute()
54
+ return folder['id']
55
+
56
+ def send(self, id, name, filename):
57
+ with self._get_tracer().start_as_current_span("gdrive_upload") as span:
58
+ span.set_attribute("destination.id", self.id)
59
+ span.set_attribute("gdrive.folder_id", self.folder_id)
60
+ span.set_attribute("source.id", id)
61
+ span.set_attribute("upload.file", filename)
62
+
63
+ logging.info("Uploading '%s' backup for '%s' to Google Drive..." % (name, self.id))
64
+ try:
65
+ service = self._get_service()
66
+ source_folder_id = self._get_or_create_folder(service, self.folder_id, id)
67
+ ts_folder_id = self._get_or_create_folder(service, source_folder_id, self.runtime.strftime("%Y%m%d%H%M%S"))
68
+ file_metadata = {
69
+ 'name': os.path.basename(filename),
70
+ 'parents': [ts_folder_id]
71
+ }
72
+ media = MediaFileUpload(filename, resumable=True)
73
+ uploaded = service.files().create(
74
+ body=file_metadata, media_body=media, fields='id,name').execute()
75
+ span.set_attribute("gdrive.file_id", uploaded.get('id'))
76
+ span.set_attribute("gdrive.file_name", uploaded.get('name'))
77
+ except HttpError as e:
78
+ span.record_exception(BackupException("Error while uploading (%s): %s" % (self.id, str(e))))
79
+ raise BackupException("Error while uploading (%s): %s" % (self.id, str(e)))
80
+ return "gdrive://%s/%s" % (self.folder_id, uploaded.get('name'))
81
+
82
+ def cleanup(self, id, name):
83
+ with self._get_tracer().start_as_current_span("gdrive_cleanup") as span:
84
+ span.set_attribute("destination.id", self.id)
85
+ span.set_attribute("gdrive.folder_id", self.folder_id)
86
+ span.set_attribute("source.id", id)
87
+ span.set_attribute("retention.copies", self.retention_copies)
88
+ span.set_attribute("retention.days", self.retention_days)
89
+
90
+ logging.info("Clearing down older '%s' backups for '%s' from Google Drive..." % (name, self.id))
91
+ try:
92
+ service = self._get_service()
93
+ query = "name='%s' and '%s' in parents and mimeType='application/vnd.google-apps.folder' and trashed=false" % (id, self.folder_id)
94
+ results = service.files().list(q=query, fields="files(id)").execute()
95
+ source_folders = results.get('files', [])
96
+ if not source_folders:
97
+ span.set_attribute("source_folder.count", 0)
98
+ return []
99
+ source_folder_id = source_folders[0]['id']
100
+
101
+ query = "'%s' in parents and mimeType='application/vnd.google-apps.folder' and trashed=false" % source_folder_id
102
+ results = service.files().list(q=query, fields="files(id,name,createdTime)", orderBy="createdTime").execute()
103
+ candidates = [(f['createdTime'], f['id'], f['name']) for f in results.get('files', [])]
104
+ candidates.sort()
105
+ span.set_attribute("object.count", len(candidates))
106
+ except HttpError as e:
107
+ span.record_exception(BackupException("Error listing Google Drive folder (%s): %s" % (self.id, str(e))))
108
+ raise BackupException("Error listing Google Drive folder (%s): %s" % (self.id, str(e)))
109
+
110
+ removable = []
111
+ retained = []
112
+ if self.retention_copies > 0:
113
+ items = candidates
114
+ if len(items) > self.retention_copies:
115
+ removable = items[0:(len(items) - self.retention_copies)]
116
+ retained = [n for d, fid, n in items[(len(items) - self.retention_copies):]]
117
+ if self.retention_days > 0:
118
+ now = datetime.datetime.now(datetime.timezone.utc)
119
+ for created, fid, fname in candidates:
120
+ from dateutil import parser as dtparser
121
+ age = (now - dtparser.parse(created)).days
122
+ if age > self.retention_days:
123
+ removable.append((created, fid, fname))
124
+ for created, fid, fname in removable:
125
+ logging.info("Removing '%s'..." % fname)
126
+ try:
127
+ service.files().delete(fileId=fid).execute()
128
+ except HttpError as e:
129
+ logging.warning("Failed to remove '%s': %s" % (fname, str(e)))
130
+ span.set_attribute("removed.count", len(removable))
131
+ span.set_attribute("retained.count", len(retained))
132
+
133
+ return retained
@@ -0,0 +1,74 @@
1
+ import os, os.path
2
+ import subprocess
3
+ import logging
4
+ import datetime
5
+
6
+ from google.cloud import storage
7
+ from google.api_core.exceptions import BadRequest
8
+
9
+ from dateutil import parser, tz
10
+
11
+ from backups.exceptions import BackupException
12
+ from backups.destinations import backupdestination
13
+ from backups.destinations.destination import BackupDestination
14
+
15
+ @backupdestination('gs')
16
+ class GS(BackupDestination):
17
+ def __init__(self, config):
18
+ BackupDestination.__init__(self, config)
19
+ # TODO: Finish this off...
20
+ self.gcs_creds_path = config['gcs_creds_path']
21
+ self.bucket = config['bucket']
22
+
23
+ def send(self, id, name, filename):
24
+ gslocation = "gs://%s/%s/%s/%s" % (
25
+ self.bucket,
26
+ id,
27
+ self.runtime.strftime("%Y%m%d%H%M%S"),
28
+ os.path.basename(filename))
29
+ logging.info("Uploading '%s' backup for '%s' to GS (%s)..." % (name, self.id, gslocation))
30
+ uploadargs = ['gsutil', '-q', 'cp', filename, gslocation]
31
+ uploadenv = os.environ.copy()
32
+ uploadproc = subprocess.Popen(uploadargs, stderr=subprocess.PIPE, env=uploadenv)
33
+ uploadproc.wait()
34
+ exitcode = uploadproc.returncode
35
+ errmsg = uploadproc.stderr.read()
36
+ if exitcode != 0:
37
+ raise BackupException("Error while uploading (%s): %s" % (self.id, errmsg))
38
+
39
+ return gslocation
40
+
41
+ def cleanup(self, id, name):
42
+ gslocation = "gs://%s/%s" % (self.bucket, id)
43
+ logging.info("Clearing down older '%s' backups for '%s' from GS (%s)..." % (name, self.id, gslocation))
44
+
45
+ client = storage.Client.from_service_account_json(self.gcs_creds_path)
46
+ bucket = client.get_bucket(self.bucket)
47
+
48
+ # Gather list of potentials first
49
+ candidates = []
50
+ for key in bucket.list(prefix="%s/" % id):
51
+ parsed_date = parser.parse(key.last_modified)
52
+ candidates.append([parsed_date, key.name])
53
+ candidates.sort()
54
+
55
+ # Loop and purge unretainable copies
56
+ removable_names = []
57
+ retained_copies = []
58
+ if self.retention_copies > 0:
59
+ names = [name for d, name in candidates]
60
+ if len(names) > self.retention_copies:
61
+ removable_names = names[0:(len(names) - self.retention_copies)]
62
+ else:
63
+ retained_copies = names[(len(names) - self.retention_copies):]
64
+ if self.retention_days > 0:
65
+ for d, name in candidates:
66
+ days = (datetime.datetime.now(tz.tzutc()) - d).days
67
+ if days > self.retention_days:
68
+ removable_names.append(name)
69
+ for name in removable_names:
70
+ logging.info("Removing '%s'..." % name)
71
+ bucket.get_key(name).delete()
72
+
73
+ # Return list of retained copies
74
+ return retained_copies
@@ -0,0 +1,55 @@
1
+ import os, os.path
2
+ import shutil
3
+ import datetime
4
+ import logging
5
+
6
+ from backups.exceptions import BackupException
7
+ from backups.destinations import backupdestination
8
+ from backups.destinations.destination import BackupDestination
9
+
10
+ @backupdestination('local')
11
+ class Local(BackupDestination):
12
+ def __init__(self, config):
13
+ BackupDestination.__init__(self, config)
14
+ self.path = config['path']
15
+
16
+ def send(self, id, name, filename):
17
+ destdir = os.path.join(self.path, id, self.runtime.strftime("%Y%m%d%H%M%S"))
18
+ os.makedirs(destdir, exist_ok=True)
19
+ destfile = os.path.join(destdir, os.path.basename(filename))
20
+ logging.info("Copying '%s' backup for '%s' to local path (%s)..." % (name, self.id, destfile))
21
+ try:
22
+ shutil.copy2(filename, destfile)
23
+ except Exception as e:
24
+ raise BackupException("Error while copying (%s): %s" % (self.id, str(e)))
25
+ return destfile
26
+
27
+ def cleanup(self, id, name):
28
+ basedir = os.path.join(self.path, id)
29
+ if not os.path.isdir(basedir):
30
+ return []
31
+
32
+ candidates = []
33
+ for entry in os.scandir(basedir):
34
+ if entry.is_dir():
35
+ candidates.append([entry.stat().st_mtime, entry.path])
36
+ candidates.sort()
37
+
38
+ removable = []
39
+ retained = []
40
+ if self.retention_copies > 0:
41
+ paths = [p for d, p in candidates]
42
+ if len(paths) > self.retention_copies:
43
+ removable = paths[0:(len(paths) - self.retention_copies)]
44
+ retained = paths[(len(paths) - self.retention_copies):]
45
+ if self.retention_days > 0:
46
+ now = datetime.datetime.now()
47
+ for d, path in candidates:
48
+ age = (now - datetime.datetime.fromtimestamp(d)).days
49
+ if age > self.retention_days:
50
+ removable.append(path)
51
+ for path in removable:
52
+ logging.info("Removing '%s'..." % path)
53
+ shutil.rmtree(path)
54
+
55
+ return retained
@@ -0,0 +1,107 @@
1
+ import os, os.path
2
+ import datetime
3
+ import logging
4
+ import contextlib
5
+
6
+ from minio import Minio
7
+ from minio.error import S3Error
8
+
9
+ from backups.exceptions import BackupException
10
+ from backups.destinations import backupdestination
11
+ from backups.destinations.destination import BackupDestination
12
+
13
+ @backupdestination('minio')
14
+ class MinioDestination(BackupDestination):
15
+ def __init__(self, config):
16
+ BackupDestination.__init__(self, config)
17
+ self.bucket = config['bucket']
18
+ self.endpoint = config['endpoint']
19
+ self.access_key = config['credentials']['access_key']
20
+ self.secret_key = config['credentials']['secret_key']
21
+ self.secure = bool(config.get('secure', True))
22
+
23
+ def _get_tracer(self):
24
+ if hasattr(self, 'tracer') and self.tracer is not None:
25
+ return self.tracer
26
+ class _NoOpTracer:
27
+ @contextlib.contextmanager
28
+ def start_as_current_span(self, name, **kwargs):
29
+ class _NoOpSpan:
30
+ def set_attribute(self, *a, **k): pass
31
+ def record_exception(self, *a, **k): pass
32
+ def set_status(self, *a, **k): pass
33
+ yield _NoOpSpan()
34
+ return _NoOpTracer()
35
+
36
+ def _get_client(self):
37
+ return Minio(
38
+ self.endpoint,
39
+ access_key=self.access_key,
40
+ secret_key=self.secret_key,
41
+ secure=self.secure
42
+ )
43
+
44
+ def send(self, id, name, filename):
45
+ with self._get_tracer().start_as_current_span("minio_upload") as span:
46
+ span.set_attribute("destination.id", self.id)
47
+ span.set_attribute("minio.endpoint", self.endpoint)
48
+ span.set_attribute("minio.bucket", self.bucket)
49
+ span.set_attribute("source.id", id)
50
+ span.set_attribute("upload.file", filename)
51
+
52
+ object_name = "%s/%s/%s" % (id, self.runtime.strftime("%Y%m%d%H%M%S"), os.path.basename(filename))
53
+ location = "%s/%s/%s" % (self.endpoint, self.bucket, object_name)
54
+ logging.info("Uploading '%s' backup for '%s' to Minio (%s)..." % (name, self.id, location))
55
+ try:
56
+ client = self._get_client()
57
+ client.fput_object(self.bucket, object_name, filename)
58
+ span.set_attribute("minio.object", object_name)
59
+ except S3Error as e:
60
+ span.record_exception(BackupException("Error while uploading (%s): %s" % (self.id, str(e))))
61
+ raise BackupException("Error while uploading (%s): %s" % (self.id, str(e)))
62
+ return location
63
+
64
+ def cleanup(self, id, name):
65
+ with self._get_tracer().start_as_current_span("minio_cleanup") as span:
66
+ span.set_attribute("destination.id", self.id)
67
+ span.set_attribute("minio.endpoint", self.endpoint)
68
+ span.set_attribute("minio.bucket", self.bucket)
69
+ span.set_attribute("source.id", id)
70
+ span.set_attribute("retention.copies", self.retention_copies)
71
+ span.set_attribute("retention.days", self.retention_days)
72
+
73
+ logging.info("Clearing down older '%s' backups for '%s' from Minio..." % (name, self.id))
74
+ try:
75
+ client = self._get_client()
76
+ objects = list(client.list_objects(self.bucket, prefix="%s/" % id, recursive=True))
77
+ except S3Error as e:
78
+ span.record_exception(BackupException("Error listing Minio bucket (%s): %s" % (self.id, str(e))))
79
+ raise BackupException("Error listing Minio bucket (%s): %s" % (self.id, str(e)))
80
+
81
+ candidates = [[obj.last_modified, obj.object_name] for obj in objects]
82
+ candidates.sort()
83
+ span.set_attribute("object.count", len(candidates))
84
+
85
+ removable = []
86
+ retained = []
87
+ if self.retention_copies > 0:
88
+ names = [n for d, n in candidates]
89
+ if len(names) > self.retention_copies:
90
+ removable = names[0:(len(names) - self.retention_copies)]
91
+ retained = names[(len(names) - self.retention_copies):]
92
+ if self.retention_days > 0:
93
+ now = datetime.datetime.now(datetime.timezone.utc)
94
+ for d, n in candidates:
95
+ age = (now - d).days
96
+ if age > self.retention_days:
97
+ removable.append(n)
98
+ for object_name in removable:
99
+ logging.info("Removing '%s'..." % object_name)
100
+ try:
101
+ client.remove_object(self.bucket, object_name)
102
+ except S3Error as e:
103
+ logging.warning("Failed to remove '%s': %s" % (object_name, str(e)))
104
+ span.set_attribute("removed.count", len(removable))
105
+ span.set_attribute("retained.count", len(retained))
106
+
107
+ return retained