rossigee-backups 2.9.0__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.
Files changed (63) hide show
  1. rossigee_backups-2.9.0/MANIFEST.in +2 -0
  2. rossigee_backups-2.9.0/PKG-INFO +24 -0
  3. rossigee_backups-2.9.0/README.md +160 -0
  4. rossigee_backups-2.9.0/backups/__init__.py +0 -0
  5. rossigee_backups-2.9.0/backups/compress.py +19 -0
  6. rossigee_backups-2.9.0/backups/destinations/__init__.py +12 -0
  7. rossigee_backups-2.9.0/backups/destinations/b2.py +66 -0
  8. rossigee_backups-2.9.0/backups/destinations/destination.py +14 -0
  9. rossigee_backups-2.9.0/backups/destinations/dropbox.py +72 -0
  10. rossigee_backups-2.9.0/backups/destinations/gdrive.py +133 -0
  11. rossigee_backups-2.9.0/backups/destinations/gs.py +74 -0
  12. rossigee_backups-2.9.0/backups/destinations/local.py +55 -0
  13. rossigee_backups-2.9.0/backups/destinations/minio.py +107 -0
  14. rossigee_backups-2.9.0/backups/destinations/s3.py +130 -0
  15. rossigee_backups-2.9.0/backups/destinations/samba.py +58 -0
  16. rossigee_backups-2.9.0/backups/encrypt.py +34 -0
  17. rossigee_backups-2.9.0/backups/exceptions.py +3 -0
  18. rossigee_backups-2.9.0/backups/main.py +305 -0
  19. rossigee_backups-2.9.0/backups/notifications/__init__.py +12 -0
  20. rossigee_backups-2.9.0/backups/notifications/cloudflare_backup_registry.py +96 -0
  21. rossigee_backups-2.9.0/backups/notifications/discord.py +40 -0
  22. rossigee_backups-2.9.0/backups/notifications/elasticsearch.py +77 -0
  23. rossigee_backups-2.9.0/backups/notifications/flagfile.py +21 -0
  24. rossigee_backups-2.9.0/backups/notifications/matrix.py +52 -0
  25. rossigee_backups-2.9.0/backups/notifications/notification.py +27 -0
  26. rossigee_backups-2.9.0/backups/notifications/prometheus.py +61 -0
  27. rossigee_backups-2.9.0/backups/notifications/slack.py +57 -0
  28. rossigee_backups-2.9.0/backups/notifications/smtp.py +75 -0
  29. rossigee_backups-2.9.0/backups/notifications/telegram.py +46 -0
  30. rossigee_backups-2.9.0/backups/sources/__init__.py +12 -0
  31. rossigee_backups-2.9.0/backups/sources/azure_managed_disk.py +122 -0
  32. rossigee_backups-2.9.0/backups/sources/folder.py +55 -0
  33. rossigee_backups-2.9.0/backups/sources/folderssh.py +49 -0
  34. rossigee_backups-2.9.0/backups/sources/lvmssh.py +128 -0
  35. rossigee_backups-2.9.0/backups/sources/mysql.py +98 -0
  36. rossigee_backups-2.9.0/backups/sources/mysqlssh.py +61 -0
  37. rossigee_backups-2.9.0/backups/sources/postgresql.py +76 -0
  38. rossigee_backups-2.9.0/backups/sources/rds.py +106 -0
  39. rossigee_backups-2.9.0/backups/sources/rdspostgresql.py +88 -0
  40. rossigee_backups-2.9.0/backups/sources/sftpfolder.py +109 -0
  41. rossigee_backups-2.9.0/backups/sources/snapshot.py +74 -0
  42. rossigee_backups-2.9.0/backups/sources/source.py +82 -0
  43. rossigee_backups-2.9.0/backups/stats.py +30 -0
  44. rossigee_backups-2.9.0/rossigee_backups.egg-info/PKG-INFO +24 -0
  45. rossigee_backups-2.9.0/rossigee_backups.egg-info/SOURCES.txt +61 -0
  46. rossigee_backups-2.9.0/rossigee_backups.egg-info/dependency_links.txt +1 -0
  47. rossigee_backups-2.9.0/rossigee_backups.egg-info/entry_points.txt +2 -0
  48. rossigee_backups-2.9.0/rossigee_backups.egg-info/requires.txt +9 -0
  49. rossigee_backups-2.9.0/rossigee_backups.egg-info/top_level.txt +1 -0
  50. rossigee_backups-2.9.0/setup.cfg +4 -0
  51. rossigee_backups-2.9.0/setup.py +41 -0
  52. rossigee_backups-2.9.0/stdeb.cfg +2 -0
  53. rossigee_backups-2.9.0/tests/test_b2.py +83 -0
  54. rossigee_backups-2.9.0/tests/test_core.py +270 -0
  55. rossigee_backups-2.9.0/tests/test_destinations.py +89 -0
  56. rossigee_backups-2.9.0/tests/test_dropbox.py +61 -0
  57. rossigee_backups-2.9.0/tests/test_edge_cases.py +65 -0
  58. rossigee_backups-2.9.0/tests/test_gdrive.py +74 -0
  59. rossigee_backups-2.9.0/tests/test_local.py +71 -0
  60. rossigee_backups-2.9.0/tests/test_minio.py +90 -0
  61. rossigee_backups-2.9.0/tests/test_notifications.py +624 -0
  62. rossigee_backups-2.9.0/tests/test_rds.py +149 -0
  63. rossigee_backups-2.9.0/tests/test_sources.py +932 -0
@@ -0,0 +1,2 @@
1
+ include README.md
2
+ include stdeb.cfg
@@ -0,0 +1,24 @@
1
+ Metadata-Version: 2.4
2
+ Name: rossigee-backups
3
+ Version: 2.9.0
4
+ Summary: Backup orchestration tool for databases, folders and cloud snapshots
5
+ Home-page: https://github.com/rossigee/backups
6
+ Author: Ross Golder
7
+ Author-email: ross@golder.org
8
+ Requires-Dist: requests
9
+ Requires-Dist: python-dateutil
10
+ Requires-Dist: paramiko
11
+ Provides-Extra: tracing
12
+ Requires-Dist: opentelemetry-api; extra == "tracing"
13
+ Requires-Dist: opentelemetry-sdk; extra == "tracing"
14
+ Requires-Dist: opentelemetry-exporter-otlp-proto-grpc; extra == "tracing"
15
+ Requires-Dist: grpcio>=1.80.0; extra == "tracing"
16
+ Dynamic: author
17
+ Dynamic: author-email
18
+ Dynamic: description
19
+ Dynamic: home-page
20
+ Dynamic: provides-extra
21
+ Dynamic: requires-dist
22
+ Dynamic: summary
23
+
24
+ A Python backup orchestration tool that cycles through sources (databases, folders, cloud snapshots), compresses and optionally encrypts each backup, uploads to one or more destinations (S3, GCS, B2, Dropbox, local, and more), and reports results via notifications (email, Slack, Discord, Telegram, Prometheus, and more). Supports OpenTelemetry OTLP tracing for observability.
@@ -0,0 +1,160 @@
1
+ Backup scripts
2
+ ==============
3
+
4
+ A Python backup orchestration tool. Given a JSON configuration file, it cycles through **sources** (databases, folders, cloud snapshots), compresses and optionally encrypts each backup, uploads to one or more **destinations**, and reports via **notifications**.
5
+
6
+ ## Quick start
7
+
8
+ ```bash
9
+ pip install backups
10
+ backups /etc/backups/production.json
11
+ ```
12
+
13
+ See [docs/scheduling.md](docs/scheduling.md) for how to run as a systemd timer or cron job.
14
+
15
+ ## Configuration
16
+
17
+ Configuration is a single JSON file:
18
+
19
+ ```json
20
+ {
21
+ "sources": [ ... ],
22
+ "destinations": [ ... ],
23
+ "notifications": [ ... ]
24
+ }
25
+ ```
26
+
27
+ ### Encryption (optional)
28
+
29
+ Encryption can be specified at the top level (applies to all sources) or per-source. Per-source settings override top-level.
30
+
31
+ Top-level (applies to all sources):
32
+ ```json
33
+ {
34
+ "encryption": {
35
+ "passphrase": "YOUR_PASSPHRASE"
36
+ },
37
+ "sources": [...]
38
+ }
39
+ ```
40
+
41
+ Or per-source:
42
+ ```json
43
+ {
44
+ "sources": [
45
+ {
46
+ "name": "mysql-db",
47
+ "type": "mysql",
48
+ "passphrase": "YOUR_PASSPHRASE"
49
+ },
50
+ {
51
+ "name": "pg-db",
52
+ "type": "postgresql",
53
+ "recipients": ["ops@example.com"]
54
+ }
55
+ ]
56
+ }
57
+ ```
58
+
59
+ Use `passphrase` for symmetric GPG encryption, or `recipients` (array) for asymmetric/GPG public key encryption.
60
+
61
+ > **Override semantics**: if a source defines either `passphrase` or `recipients`, the top-level `encryption` block is ignored entirely for that source.
62
+
63
+ > **Note**: older configs containing `"type": "symmetric"` or `"type": "asymmetric"` fields in the encryption block should be updated — those fields were never implemented and are ignored.
64
+
65
+ ## Tracing Configuration
66
+
67
+ OpenTelemetry OTLP tracing is supported for observability. Tracing is disabled by default (zero performance impact).
68
+
69
+ Install the optional tracing dependencies:
70
+
71
+ ```bash
72
+ pip install backups[tracing]
73
+ ```
74
+
75
+ To enable tracing:
76
+ - Set `OTEL_EXPORTER_OTLP_ENDPOINT` to your OTLP collector URL (e.g., `http://localhost:4317` for gRPC).
77
+ - Optional: Set `OTEL_EXPORTER_OTLP_HEADERS` for authentication (e.g., `authorization=Bearer token`).
78
+ - Optional: Set `OTEL_EXPORTER_OTLP_INSECURE=true` to disable TLS (default: TLS enabled).
79
+ - Optional: Set `OTEL_TRACES_SAMPLER` and `OTEL_TRACES_SAMPLER_ARG` to control sampling (e.g., `OTEL_TRACES_SAMPLER=traceidratio` and `OTEL_TRACES_SAMPLER_ARG=0.1` for 10%).
80
+
81
+ Example:
82
+ ```bash
83
+ export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
84
+ backups /etc/backups/production.json
85
+ ```
86
+
87
+ Traces include comprehensive spans for:
88
+ - **Backup run** - version, hostname
89
+ - **Per-source processing** - source ID, type, name
90
+ - **Dump operations** - database host/name, file paths, operation timing
91
+ - **Compression/encryption** - operation type, input/output files
92
+ - **Destination uploads** - per-destination spans with upload locations (S3, GDrive, Minio, etc.)
93
+ - **Retention cleanup** - object counts, retained/removed counts
94
+ - **Notifications** - notification type, source ID
95
+
96
+ All spans include relevant metadata (file paths, database names, bucket names, object counts, timing, errors). The `cloudflare-backup-registry` notification automatically includes the current trace ID in its metadata for end-to-end correlation.
97
+
98
+ ## Sources
99
+
100
+ | Type | Description | Docs |
101
+ |------|-------------|------|
102
+ | `folder` | Local directory via `tar` | [docs/sources/folder.md](docs/sources/folder.md) |
103
+ | `folder-ssh` | Remote directory via SSH + `tar` | [docs/sources/folderssh.md](docs/sources/folderssh.md) |
104
+ | `sftp-folder` | Remote directory via SFTP | [docs/sources/sftpfolder.md](docs/sources/sftpfolder.md) |
105
+ | `mysql` | MySQL/MariaDB via `mysqldump` | [docs/sources/mysql.md](docs/sources/mysql.md) |
106
+ | `mysql-ssh` | MySQL via SSH tunnel | [docs/sources/mysqlssh.md](docs/sources/mysqlssh.md) |
107
+ | `postgresql` | PostgreSQL via `pg_dump` | [docs/sources/postgresql.md](docs/sources/postgresql.md) |
108
+ | `rds` | AWS RDS MySQL snapshot | [docs/sources/rds.md](docs/sources/rds.md) |
109
+ | `rds-pgsql` | AWS RDS PostgreSQL snapshot | [docs/sources/rdspostgresql.md](docs/sources/rdspostgresql.md) |
110
+ | `snapshot` | Azure Managed Disk snapshot | [docs/sources/snapshot.md](docs/sources/snapshot.md) |
111
+ | `lvm-ssh` | LVM snapshot over SSH | [docs/sources/lvm-ssh.md](docs/sources/lvm-ssh.md) |
112
+
113
+ ## Destinations
114
+
115
+ | Type | Description | Docs |
116
+ |------|-------------|------|
117
+ | `s3` | AWS S3 bucket | [docs/destinations/s3.md](docs/destinations/s3.md) |
118
+ | `gs` | Google Cloud Storage | [docs/destinations/gs.md](docs/destinations/gs.md) |
119
+ | `b2` | Backblaze B2 | [docs/destinations/b2.md](docs/destinations/b2.md) |
120
+ | `minio` | Minio / S3-compatible (DO Spaces, Wasabi, etc.) | [docs/destinations/minio.md](docs/destinations/minio.md) |
121
+ | `dropbox` | Dropbox | [docs/destinations/dropbox.md](docs/destinations/dropbox.md) |
122
+ | `gdrive` | Google Drive | [docs/destinations/gdrive.md](docs/destinations/gdrive.md) |
123
+ | `local` | Local filesystem path (NFS, USB, etc.) | [docs/destinations/local.md](docs/destinations/local.md) |
124
+ | `samba` | Samba/CIFS share | [docs/destinations/samba.md](docs/destinations/samba.md) |
125
+
126
+ All destinations support `retention_copies` and/or `retention_days` to automatically prune old backups.
127
+
128
+ ## Notifications
129
+
130
+ | Type | Description | Docs |
131
+ |------|-------------|------|
132
+ | `smtp` | Email via SMTP | [docs/notifications/smtp.md](docs/notifications/smtp.md) |
133
+ | `slack` | Slack Incoming Webhook | [docs/notifications/slack.md](docs/notifications/slack.md) |
134
+ | `discord` | Discord webhook | [docs/notifications/discord.md](docs/notifications/discord.md) |
135
+ | `telegram` | Telegram Bot API | [docs/notifications/telegram.md](docs/notifications/telegram.md) |
136
+ | `matrix` | Matrix room message | [docs/notifications/matrix.md](docs/notifications/matrix.md) |
137
+ | `flagfile` | Write a flag file for monitoring | [docs/notifications/flagfile.md](docs/notifications/flagfile.md) |
138
+ | `prometheus` | Push metrics to Prometheus Pushgateway | [docs/notifications/prometheus.md](docs/notifications/prometheus.md) |
139
+ | `elasticsearch` | Write stats documents to Elasticsearch | [docs/notifications/elasticsearch.md](docs/notifications/elasticsearch.md) |
140
+ | `cloudflare-backup-registry` | Backup run reports to Cloudflare Backup Registry | [docs/notifications/cloudflare-backup-registry.md](docs/notifications/cloudflare-backup-registry.md) |
141
+
142
+ ## Scheduling
143
+
144
+ See [docs/scheduling.md](docs/scheduling.md) for setup with:
145
+ - **systemd timer** (recommended) — structured logging, dependency management, easy monitoring
146
+ - **cron** — simple alternative
147
+
148
+ ## Development
149
+
150
+ ```bash
151
+ pip install -r requirements-dev.txt
152
+ pytest tests/ -v
153
+ ```
154
+
155
+ ## Docker
156
+
157
+ ```bash
158
+ docker pull ghcr.io/rossigee/backups:latest
159
+ docker run --rm -v /etc/backups:/etc/backups ghcr.io/rossigee/backups:latest /etc/backups/production.json
160
+ ```
File without changes
@@ -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