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.
- rossigee_backups-2.9.0/MANIFEST.in +2 -0
- rossigee_backups-2.9.0/PKG-INFO +24 -0
- rossigee_backups-2.9.0/README.md +160 -0
- rossigee_backups-2.9.0/backups/__init__.py +0 -0
- rossigee_backups-2.9.0/backups/compress.py +19 -0
- rossigee_backups-2.9.0/backups/destinations/__init__.py +12 -0
- rossigee_backups-2.9.0/backups/destinations/b2.py +66 -0
- rossigee_backups-2.9.0/backups/destinations/destination.py +14 -0
- rossigee_backups-2.9.0/backups/destinations/dropbox.py +72 -0
- rossigee_backups-2.9.0/backups/destinations/gdrive.py +133 -0
- rossigee_backups-2.9.0/backups/destinations/gs.py +74 -0
- rossigee_backups-2.9.0/backups/destinations/local.py +55 -0
- rossigee_backups-2.9.0/backups/destinations/minio.py +107 -0
- rossigee_backups-2.9.0/backups/destinations/s3.py +130 -0
- rossigee_backups-2.9.0/backups/destinations/samba.py +58 -0
- rossigee_backups-2.9.0/backups/encrypt.py +34 -0
- rossigee_backups-2.9.0/backups/exceptions.py +3 -0
- rossigee_backups-2.9.0/backups/main.py +305 -0
- rossigee_backups-2.9.0/backups/notifications/__init__.py +12 -0
- rossigee_backups-2.9.0/backups/notifications/cloudflare_backup_registry.py +96 -0
- rossigee_backups-2.9.0/backups/notifications/discord.py +40 -0
- rossigee_backups-2.9.0/backups/notifications/elasticsearch.py +77 -0
- rossigee_backups-2.9.0/backups/notifications/flagfile.py +21 -0
- rossigee_backups-2.9.0/backups/notifications/matrix.py +52 -0
- rossigee_backups-2.9.0/backups/notifications/notification.py +27 -0
- rossigee_backups-2.9.0/backups/notifications/prometheus.py +61 -0
- rossigee_backups-2.9.0/backups/notifications/slack.py +57 -0
- rossigee_backups-2.9.0/backups/notifications/smtp.py +75 -0
- rossigee_backups-2.9.0/backups/notifications/telegram.py +46 -0
- rossigee_backups-2.9.0/backups/sources/__init__.py +12 -0
- rossigee_backups-2.9.0/backups/sources/azure_managed_disk.py +122 -0
- rossigee_backups-2.9.0/backups/sources/folder.py +55 -0
- rossigee_backups-2.9.0/backups/sources/folderssh.py +49 -0
- rossigee_backups-2.9.0/backups/sources/lvmssh.py +128 -0
- rossigee_backups-2.9.0/backups/sources/mysql.py +98 -0
- rossigee_backups-2.9.0/backups/sources/mysqlssh.py +61 -0
- rossigee_backups-2.9.0/backups/sources/postgresql.py +76 -0
- rossigee_backups-2.9.0/backups/sources/rds.py +106 -0
- rossigee_backups-2.9.0/backups/sources/rdspostgresql.py +88 -0
- rossigee_backups-2.9.0/backups/sources/sftpfolder.py +109 -0
- rossigee_backups-2.9.0/backups/sources/snapshot.py +74 -0
- rossigee_backups-2.9.0/backups/sources/source.py +82 -0
- rossigee_backups-2.9.0/backups/stats.py +30 -0
- rossigee_backups-2.9.0/rossigee_backups.egg-info/PKG-INFO +24 -0
- rossigee_backups-2.9.0/rossigee_backups.egg-info/SOURCES.txt +61 -0
- rossigee_backups-2.9.0/rossigee_backups.egg-info/dependency_links.txt +1 -0
- rossigee_backups-2.9.0/rossigee_backups.egg-info/entry_points.txt +2 -0
- rossigee_backups-2.9.0/rossigee_backups.egg-info/requires.txt +9 -0
- rossigee_backups-2.9.0/rossigee_backups.egg-info/top_level.txt +1 -0
- rossigee_backups-2.9.0/setup.cfg +4 -0
- rossigee_backups-2.9.0/setup.py +41 -0
- rossigee_backups-2.9.0/stdeb.cfg +2 -0
- rossigee_backups-2.9.0/tests/test_b2.py +83 -0
- rossigee_backups-2.9.0/tests/test_core.py +270 -0
- rossigee_backups-2.9.0/tests/test_destinations.py +89 -0
- rossigee_backups-2.9.0/tests/test_dropbox.py +61 -0
- rossigee_backups-2.9.0/tests/test_edge_cases.py +65 -0
- rossigee_backups-2.9.0/tests/test_gdrive.py +74 -0
- rossigee_backups-2.9.0/tests/test_local.py +71 -0
- rossigee_backups-2.9.0/tests/test_minio.py +90 -0
- rossigee_backups-2.9.0/tests/test_notifications.py +624 -0
- rossigee_backups-2.9.0/tests/test_rds.py +149 -0
- rossigee_backups-2.9.0/tests/test_sources.py +932 -0
|
@@ -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
|