ptctools 0.1.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.
ptctools/utils.py ADDED
@@ -0,0 +1,416 @@
1
+ """Backup and restore commands using Duplicati."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import click
12
+
13
+ from ptctools._s3 import (
14
+ parse_s3_uri,
15
+ is_s3_uri,
16
+ get_s3_credentials,
17
+ get_s3_endpoint,
18
+ build_duplicati_s3_url,
19
+ )
20
+
21
+
22
+ def is_docker_available() -> bool:
23
+ """Check if Docker is available."""
24
+ return shutil.which("docker") is not None
25
+
26
+
27
+ def is_duplicati_available() -> bool:
28
+ """Check if local duplicati-cli is available."""
29
+ return shutil.which("duplicati-cli") is not None
30
+
31
+
32
+ def detect_runner() -> str:
33
+ """Auto-detect the best runner: docker if available, otherwise local."""
34
+ if is_docker_available():
35
+ return "docker"
36
+ if is_duplicati_available():
37
+ return "local"
38
+ return "docker" # Default, will fail with helpful message
39
+
40
+
41
+ def validate_runner(runner: str) -> str:
42
+ """Validate and resolve runner, exit on error."""
43
+ if runner == "auto":
44
+ runner = detect_runner()
45
+ click.echo(f"Auto-detected runner: {runner}")
46
+
47
+ if runner == "docker" and not is_docker_available():
48
+ click.echo(
49
+ "Error: Docker is not available. Install Docker or use --runner local",
50
+ err=True,
51
+ )
52
+ sys.exit(1)
53
+ if runner == "local" and not is_duplicati_available():
54
+ click.echo(
55
+ "Error: duplicati-cli is not available. Install Duplicati or use --runner docker",
56
+ err=True,
57
+ )
58
+ sys.exit(1)
59
+ return runner
60
+
61
+
62
+ def run_duplicati_command(
63
+ runner: str,
64
+ command: str,
65
+ remote_location: str,
66
+ local_path: str,
67
+ passphrase: str,
68
+ extra_args: list[str] | None = None,
69
+ volume_mounts: list[tuple[str, str]] | None = None,
70
+ ) -> subprocess.CompletedProcess:
71
+ """Run duplicati-cli command using the specified runner."""
72
+ base_args = [command, remote_location]
73
+
74
+ if command == "backup":
75
+ base_args.append(local_path if runner == "local" else "/data")
76
+
77
+ if passphrase:
78
+ base_args.append(f"--passphrase={passphrase}")
79
+ else:
80
+ base_args.append("--no-encryption")
81
+
82
+ if extra_args:
83
+ base_args.extend(extra_args)
84
+
85
+ if runner == "docker":
86
+ cmd = ["docker", "run", "--rm"]
87
+ # Add volume mounts based on command
88
+ if command == "backup":
89
+ cmd.extend(["-v", f"{local_path}:/data:ro"])
90
+ elif command == "restore":
91
+ cmd.extend(["-v", f"{local_path}:/restore"])
92
+ if volume_mounts:
93
+ for host_path, container_path in volume_mounts:
94
+ cmd.extend(["-v", f"{host_path}:{container_path}"])
95
+ # Add temp directory for Duplicati database
96
+ cmd.extend(["--tmpfs", "/tmp:rw,noexec,nosuid"])
97
+ cmd.extend(["-e", "TMPDIR=/tmp"])
98
+ cmd.extend(["duplicati/duplicati", "duplicati-cli"])
99
+ cmd.extend(["--dbpath=/tmp/duplicati.sqlite"])
100
+ cmd.extend(base_args)
101
+ else:
102
+ cmd = ["duplicati-cli"]
103
+ cmd.extend(base_args)
104
+
105
+ return subprocess.run(cmd, capture_output=True, text=True)
106
+
107
+
108
+ def resolve_s3_destination(
109
+ uri: str, s3_endpoint: str | None
110
+ ) -> tuple[str, str, str, str]:
111
+ """Resolve S3 URI to destination URL and display info.
112
+
113
+ Returns: (destination_url, bucket, path, endpoint)
114
+ """
115
+ try:
116
+ uri_endpoint, bucket, s3_path = parse_s3_uri(uri)
117
+ except click.ClickException as e:
118
+ click.echo(f"Error: {e.message}", err=True)
119
+ sys.exit(1)
120
+
121
+ try:
122
+ endpoint = get_s3_endpoint(uri_endpoint, s3_endpoint)
123
+ except click.ClickException as e:
124
+ click.echo(f"Error: {e.message}", err=True)
125
+ sys.exit(1)
126
+
127
+ destination = build_duplicati_s3_url(bucket, s3_path, endpoint)
128
+ return destination, bucket, s3_path, endpoint
129
+
130
+
131
+ # Common CLI options
132
+ runner_option = click.option(
133
+ "--runner",
134
+ type=click.Choice(["docker", "local", "auto"]),
135
+ default="auto",
136
+ help="Runner: docker, local duplicati-cli, or auto-detect (default)",
137
+ )
138
+ s3_endpoint_option = click.option(
139
+ "--s3-endpoint",
140
+ help="S3/MinIO endpoint URL (can also use S3_ENDPOINT env var)",
141
+ )
142
+ passphrase_option = click.option(
143
+ "--passphrase",
144
+ type=str,
145
+ default="",
146
+ help="Encryption passphrase (can also use DUPLICATI_PASSPHRASE env var)",
147
+ )
148
+
149
+
150
+ @click.group()
151
+ def cli():
152
+ """Utility commands for backup and restore."""
153
+ pass
154
+
155
+
156
+ @cli.command()
157
+ @click.option(
158
+ "--input",
159
+ "input_dir",
160
+ required=True,
161
+ type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
162
+ help="Input directory to backup",
163
+ )
164
+ @click.option(
165
+ "--output",
166
+ "output_path",
167
+ required=True,
168
+ type=str,
169
+ help="Output destination: local directory or S3 URI (s3://bucket/path)",
170
+ )
171
+ @s3_endpoint_option
172
+ @click.option(
173
+ "--keep-versions",
174
+ type=int,
175
+ default=7,
176
+ help="Keep N versions (default: 7)",
177
+ )
178
+ @passphrase_option
179
+ @runner_option
180
+ def backup(
181
+ input_dir: Path,
182
+ output_path: str,
183
+ s3_endpoint: str | None,
184
+ keep_versions: int,
185
+ passphrase: str,
186
+ runner: str,
187
+ ):
188
+ """Backup a directory to local or S3 destination.
189
+
190
+ Examples:
191
+ ptctools backup --input ./data --output ./backups
192
+ ptctools backup --input ./data --output s3://mybucket/backups
193
+ ptctools backup --input ./data --output s3://mybucket/backups --s3-endpoint https://s3.<region>.amazonaws.com
194
+ """
195
+ input_dir = input_dir.resolve()
196
+ runner = validate_runner(runner)
197
+ passphrase = passphrase or os.environ.get("DUPLICATI_PASSPHRASE", "")
198
+
199
+ if is_s3_uri(output_path):
200
+ destination, bucket, s3_path, endpoint = resolve_s3_destination(
201
+ output_path, s3_endpoint
202
+ )
203
+ try:
204
+ s3_access_key, s3_secret_key = get_s3_credentials()
205
+ except click.ClickException as e:
206
+ click.echo(f"Error: {e.message}", err=True)
207
+ sys.exit(1)
208
+
209
+ extra_args = [
210
+ f"--aws-access-key-id={s3_access_key}",
211
+ f"--aws-secret-access-key={s3_secret_key}",
212
+ f"--keep-versions={keep_versions}",
213
+ "--retention-policy=",
214
+ ]
215
+
216
+ click.echo(f"Input: {input_dir}")
217
+ click.echo(f"Output: s3://{bucket}/{s3_path}")
218
+ click.echo(f"Endpoint: {endpoint}")
219
+ click.echo(f"Runner: {runner}")
220
+ click.echo(f"Encryption: {'Enabled' if passphrase else 'Disabled'}")
221
+ click.echo()
222
+
223
+ result = run_duplicati_command(
224
+ runner=runner,
225
+ command="backup",
226
+ remote_location=destination,
227
+ local_path=str(input_dir),
228
+ passphrase=passphrase,
229
+ extra_args=extra_args,
230
+ )
231
+ else:
232
+ output_dir = Path(output_path).resolve()
233
+ output_dir.mkdir(parents=True, exist_ok=True)
234
+
235
+ click.echo(f"Input: {input_dir}")
236
+ click.echo(f"Output: {output_dir}")
237
+ click.echo(f"Runner: {runner}")
238
+ click.echo(f"Encryption: {'Enabled' if passphrase else 'Disabled'}")
239
+ click.echo()
240
+
241
+ extra_args = [f"--keep-versions={keep_versions}", "--retention-policy="]
242
+
243
+ if runner == "docker":
244
+ result = run_duplicati_command(
245
+ runner=runner,
246
+ command="backup",
247
+ remote_location="file:///backup",
248
+ local_path=str(input_dir),
249
+ passphrase=passphrase,
250
+ extra_args=extra_args,
251
+ volume_mounts=[(str(output_dir), "/backup")],
252
+ )
253
+ else:
254
+ result = run_duplicati_command(
255
+ runner=runner,
256
+ command="backup",
257
+ remote_location=f"file://{output_dir}",
258
+ local_path=str(input_dir),
259
+ passphrase=passphrase,
260
+ extra_args=extra_args,
261
+ )
262
+
263
+ if result.returncode != 0:
264
+ click.echo(f"✗ Backup failed with exit code {result.returncode}")
265
+ if result.stderr:
266
+ click.echo(f"Error: {result.stderr}")
267
+ if result.stdout:
268
+ click.echo(f"Output: {result.stdout}")
269
+ sys.exit(1)
270
+
271
+ click.echo("✓ Backup completed successfully")
272
+ for line in result.stdout.split("\n"):
273
+ if any(k in line.lower() for k in ["uploaded", "added", "bytes", "file"]):
274
+ click.echo(f" {line.strip()}")
275
+
276
+
277
+ @cli.command()
278
+ @click.option(
279
+ "--input",
280
+ "input_path",
281
+ required=True,
282
+ type=str,
283
+ help="Input source: local Duplicati backup directory or S3 URI",
284
+ )
285
+ @click.option(
286
+ "--output",
287
+ "output_dir",
288
+ required=True,
289
+ type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
290
+ help="Output directory to restore files to",
291
+ )
292
+ @s3_endpoint_option
293
+ @passphrase_option
294
+ @runner_option
295
+ @click.option(
296
+ "--restore-path",
297
+ type=str,
298
+ default="",
299
+ help="Specific path within backup to restore (default: all files)",
300
+ )
301
+ def restore(
302
+ input_path: str,
303
+ output_dir: Path,
304
+ s3_endpoint: str | None,
305
+ passphrase: str,
306
+ runner: str,
307
+ restore_path: str,
308
+ ):
309
+ """Restore files from a Duplicati backup.
310
+
311
+ Examples:
312
+ ptctools restore --input ./backups --output ./restored
313
+ ptctools restore --input s3://mybucket/backups --output ./restored
314
+ ptctools restore --input s3://mybucket/backups --output ./restored --restore-path "config.yaml"
315
+ """
316
+ output_dir = output_dir.resolve()
317
+ output_dir.mkdir(parents=True, exist_ok=True)
318
+ runner = validate_runner(runner)
319
+ passphrase = passphrase or os.environ.get("DUPLICATI_PASSPHRASE", "")
320
+
321
+ extra_args = ["--overwrite=true", "--all-versions=false"]
322
+ if restore_path:
323
+ extra_args.append(f"--restore-path={restore_path}")
324
+ else:
325
+ extra_args.append("*") # Restore all files
326
+
327
+ if is_s3_uri(input_path):
328
+ source, bucket, s3_path, endpoint = resolve_s3_destination(
329
+ input_path, s3_endpoint
330
+ )
331
+ try:
332
+ s3_access_key, s3_secret_key = get_s3_credentials()
333
+ except click.ClickException as e:
334
+ click.echo(f"Error: {e.message}", err=True)
335
+ sys.exit(1)
336
+
337
+ extra_args.extend(
338
+ [
339
+ f"--aws-access-key-id={s3_access_key}",
340
+ f"--aws-secret-access-key={s3_secret_key}",
341
+ ]
342
+ )
343
+
344
+ click.echo(f"Input: s3://{bucket}/{s3_path}")
345
+ click.echo(f"Output: {output_dir}")
346
+ click.echo(f"Endpoint: {endpoint}")
347
+ click.echo(f"Runner: {runner}")
348
+ click.echo(f"Encryption: {'Enabled' if passphrase else 'Disabled'}")
349
+ click.echo()
350
+
351
+ if runner == "docker":
352
+ extra_args.append("--restore-path=/restore")
353
+ result = run_duplicati_command(
354
+ runner=runner,
355
+ command="restore",
356
+ remote_location=source,
357
+ local_path=str(output_dir),
358
+ passphrase=passphrase,
359
+ extra_args=extra_args,
360
+ )
361
+ else:
362
+ extra_args.append(f"--restore-path={output_dir}")
363
+ result = run_duplicati_command(
364
+ runner=runner,
365
+ command="restore",
366
+ remote_location=source,
367
+ local_path=str(output_dir),
368
+ passphrase=passphrase,
369
+ extra_args=extra_args,
370
+ )
371
+ else:
372
+ input_dir = Path(input_path).resolve()
373
+ if not input_dir.exists():
374
+ click.echo(f"Error: Input directory does not exist: {input_dir}", err=True)
375
+ sys.exit(1)
376
+
377
+ click.echo(f"Input: {input_dir}")
378
+ click.echo(f"Output: {output_dir}")
379
+ click.echo(f"Runner: {runner}")
380
+ click.echo(f"Encryption: {'Enabled' if passphrase else 'Disabled'}")
381
+ click.echo()
382
+
383
+ if runner == "docker":
384
+ extra_args.append("--restore-path=/restore")
385
+ result = run_duplicati_command(
386
+ runner=runner,
387
+ command="restore",
388
+ remote_location="file:///backup",
389
+ local_path=str(output_dir),
390
+ passphrase=passphrase,
391
+ extra_args=extra_args,
392
+ volume_mounts=[(str(input_dir), "/backup:ro")],
393
+ )
394
+ else:
395
+ extra_args.append(f"--restore-path={output_dir}")
396
+ result = run_duplicati_command(
397
+ runner=runner,
398
+ command="restore",
399
+ remote_location=f"file://{input_dir}",
400
+ local_path=str(output_dir),
401
+ passphrase=passphrase,
402
+ extra_args=extra_args,
403
+ )
404
+
405
+ if result.returncode != 0:
406
+ click.echo(f"✗ Restore failed with exit code {result.returncode}")
407
+ if result.stderr:
408
+ click.echo(f"Error: {result.stderr}")
409
+ if result.stdout:
410
+ click.echo(f"Output: {result.stdout}")
411
+ sys.exit(1)
412
+
413
+ click.echo("✓ Restore completed successfully")
414
+ for line in result.stdout.split("\n"):
415
+ if any(k in line.lower() for k in ["restored", "files", "bytes"]):
416
+ click.echo(f" {line.strip()}")