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/__init__.py +3 -0
- ptctools/_portainer.py +279 -0
- ptctools/_s3.py +150 -0
- ptctools/cli.py +28 -0
- ptctools/config.py +293 -0
- ptctools/db.py +544 -0
- ptctools/stack.py +367 -0
- ptctools/utils.py +416 -0
- ptctools/volume.py +359 -0
- ptctools-0.1.0.dist-info/METADATA +99 -0
- ptctools-0.1.0.dist-info/RECORD +14 -0
- ptctools-0.1.0.dist-info/WHEEL +4 -0
- ptctools-0.1.0.dist-info/entry_points.txt +2 -0
- ptctools-0.1.0.dist-info/licenses/LICENSE +201 -0
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()}")
|