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.
- backups/__init__.py +0 -0
- backups/compress.py +19 -0
- backups/destinations/__init__.py +12 -0
- backups/destinations/b2.py +66 -0
- backups/destinations/destination.py +14 -0
- backups/destinations/dropbox.py +72 -0
- backups/destinations/gdrive.py +133 -0
- backups/destinations/gs.py +74 -0
- backups/destinations/local.py +55 -0
- backups/destinations/minio.py +107 -0
- backups/destinations/s3.py +130 -0
- backups/destinations/samba.py +58 -0
- backups/encrypt.py +34 -0
- backups/exceptions.py +3 -0
- backups/main.py +305 -0
- backups/notifications/__init__.py +12 -0
- backups/notifications/cloudflare_backup_registry.py +96 -0
- backups/notifications/discord.py +40 -0
- backups/notifications/elasticsearch.py +77 -0
- backups/notifications/flagfile.py +21 -0
- backups/notifications/matrix.py +52 -0
- backups/notifications/notification.py +27 -0
- backups/notifications/prometheus.py +61 -0
- backups/notifications/slack.py +57 -0
- backups/notifications/smtp.py +75 -0
- backups/notifications/telegram.py +46 -0
- backups/sources/__init__.py +12 -0
- backups/sources/azure_managed_disk.py +122 -0
- backups/sources/folder.py +55 -0
- backups/sources/folderssh.py +49 -0
- backups/sources/lvmssh.py +128 -0
- backups/sources/mysql.py +98 -0
- backups/sources/mysqlssh.py +61 -0
- backups/sources/postgresql.py +76 -0
- backups/sources/rds.py +106 -0
- backups/sources/rdspostgresql.py +88 -0
- backups/sources/sftpfolder.py +109 -0
- backups/sources/snapshot.py +74 -0
- backups/sources/source.py +82 -0
- backups/stats.py +30 -0
- rossigee_backups-2.9.0.dist-info/METADATA +24 -0
- rossigee_backups-2.9.0.dist-info/RECORD +45 -0
- rossigee_backups-2.9.0.dist-info/WHEEL +5 -0
- rossigee_backups-2.9.0.dist-info/entry_points.txt +2 -0
- 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
|