ptctools 0.1.1__py3-none-any.whl → 0.2.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/cli.py +5 -8
- ptctools/config.py +36 -13
- ptctools/db.py +50 -19
- ptctools/docker.py +29 -0
- ptctools/secret.py +146 -0
- ptctools/stack.py +55 -18
- ptctools/volume.py +128 -64
- ptctools-0.2.0.dist-info/METADATA +162 -0
- {ptctools-0.1.1.dist-info → ptctools-0.2.0.dist-info}/RECORD +12 -10
- ptctools-0.1.1.dist-info/METADATA +0 -131
- {ptctools-0.1.1.dist-info → ptctools-0.2.0.dist-info}/WHEEL +0 -0
- {ptctools-0.1.1.dist-info → ptctools-0.2.0.dist-info}/entry_points.txt +0 -0
- {ptctools-0.1.1.dist-info → ptctools-0.2.0.dist-info}/licenses/LICENSE +0 -0
ptctools/volume.py
CHANGED
|
@@ -31,6 +31,7 @@ logger = logging.getLogger(__name__)
|
|
|
31
31
|
|
|
32
32
|
class VolumeError(PortainerError):
|
|
33
33
|
"""Exception for volume operations."""
|
|
34
|
+
|
|
34
35
|
pass
|
|
35
36
|
|
|
36
37
|
|
|
@@ -47,7 +48,7 @@ def backup_volume(
|
|
|
47
48
|
passphrase: str,
|
|
48
49
|
) -> None:
|
|
49
50
|
"""Backup a single volume to S3 using Duplicati CLI.
|
|
50
|
-
|
|
51
|
+
|
|
51
52
|
Raises:
|
|
52
53
|
VolumeError: If backup fails
|
|
53
54
|
"""
|
|
@@ -56,26 +57,30 @@ def backup_volume(
|
|
|
56
57
|
click.echo(f"Backing up {volume_name} using Duplicati...")
|
|
57
58
|
|
|
58
59
|
# Common options for both repair and backup
|
|
59
|
-
common_opts = " ".join(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
60
|
+
common_opts = " ".join(
|
|
61
|
+
[
|
|
62
|
+
f"--aws-access-key-id={s3_access_key}",
|
|
63
|
+
f"--aws-secret-access-key={s3_secret_key}",
|
|
64
|
+
f"--passphrase={passphrase}" if passphrase else "--no-encryption",
|
|
65
|
+
"--dbpath=/tmp/duplicati.sqlite",
|
|
66
|
+
]
|
|
67
|
+
)
|
|
65
68
|
|
|
66
69
|
# Repair command to rebuild local database from remote
|
|
67
70
|
repair_cmd = f"duplicati-cli repair '{s3_dest}' {common_opts}"
|
|
68
71
|
|
|
69
72
|
# Backup command
|
|
70
|
-
backup_cmd = " ".join(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
73
|
+
backup_cmd = " ".join(
|
|
74
|
+
[
|
|
75
|
+
"duplicati-cli",
|
|
76
|
+
"backup",
|
|
77
|
+
f"'{s3_dest}'",
|
|
78
|
+
"/data",
|
|
79
|
+
common_opts,
|
|
80
|
+
f"--keep-versions={keep_versions}",
|
|
81
|
+
"--retention-policy=",
|
|
82
|
+
]
|
|
83
|
+
)
|
|
79
84
|
|
|
80
85
|
# Run repair first (rebuilds local DB from remote), then backup
|
|
81
86
|
# Use ; instead of && because repair may return non-zero for warnings
|
|
@@ -83,10 +88,10 @@ def backup_volume(
|
|
|
83
88
|
|
|
84
89
|
try:
|
|
85
90
|
client = get_portainer_docker_client(portainer_url, api_key, endpoint_id)
|
|
86
|
-
|
|
91
|
+
|
|
87
92
|
# Pull image
|
|
88
93
|
# check if image unused then remove image handled by run_container
|
|
89
|
-
|
|
94
|
+
|
|
90
95
|
# Run container
|
|
91
96
|
exit_code, logs = run_container(
|
|
92
97
|
client=client,
|
|
@@ -134,10 +139,10 @@ def restore_volume(
|
|
|
134
139
|
restore_time: str | None,
|
|
135
140
|
) -> None:
|
|
136
141
|
"""Restore a single volume from S3 using Duplicati CLI.
|
|
137
|
-
|
|
142
|
+
|
|
138
143
|
Args:
|
|
139
144
|
s3_path: S3 path to restore from (used directly, no modification).
|
|
140
|
-
|
|
145
|
+
|
|
141
146
|
Raises:
|
|
142
147
|
VolumeError: If restore fails
|
|
143
148
|
"""
|
|
@@ -167,7 +172,7 @@ def restore_volume(
|
|
|
167
172
|
|
|
168
173
|
try:
|
|
169
174
|
client = get_portainer_docker_client(portainer_url, api_key, endpoint_id)
|
|
170
|
-
|
|
175
|
+
|
|
171
176
|
# Run container
|
|
172
177
|
exit_code, logs = run_container(
|
|
173
178
|
client=client,
|
|
@@ -181,7 +186,9 @@ def restore_volume(
|
|
|
181
186
|
raise VolumeError(f"Restore failed: {e}") from e
|
|
182
187
|
|
|
183
188
|
# Success if exit_code == 0, or exit_code == 2 with no files needing restore (already up-to-date)
|
|
184
|
-
already_up_to_date =
|
|
189
|
+
already_up_to_date = (
|
|
190
|
+
exit_code == 2 and logs and "0 files need to be restored" in logs
|
|
191
|
+
)
|
|
185
192
|
if exit_code != 0 and not already_up_to_date:
|
|
186
193
|
click.echo(f" ✗ Restore failed with exit code {exit_code}")
|
|
187
194
|
if logs:
|
|
@@ -204,7 +211,13 @@ def cli():
|
|
|
204
211
|
|
|
205
212
|
|
|
206
213
|
@cli.command()
|
|
207
|
-
@click.option(
|
|
214
|
+
@click.option(
|
|
215
|
+
"--url",
|
|
216
|
+
"-u",
|
|
217
|
+
envvar="PORTAINER_URL",
|
|
218
|
+
required=True,
|
|
219
|
+
help="Portainer base URL (or PORTAINER_URL env var)",
|
|
220
|
+
)
|
|
208
221
|
@click.option(
|
|
209
222
|
"--volumes", "-v", required=True, help="Comma-separated list of volume names"
|
|
210
223
|
)
|
|
@@ -216,9 +229,7 @@ def cli():
|
|
|
216
229
|
)
|
|
217
230
|
@click.option("--s3-endpoint", help="S3/MinIO endpoint URL (or S3_ENDPOINT env var)")
|
|
218
231
|
@click.option("--endpoint-id", "-e", type=int, default=1, help="Portainer endpoint ID")
|
|
219
|
-
@click.option(
|
|
220
|
-
"--keep-versions", type=int, default=7, help="Keep N versions of backups"
|
|
221
|
-
)
|
|
232
|
+
@click.option("--keep-versions", type=int, default=7, help="Keep N versions of backups")
|
|
222
233
|
@click.option("--passphrase", type=str, default="", help="Encryption passphrase")
|
|
223
234
|
def backup(
|
|
224
235
|
url: str,
|
|
@@ -279,7 +290,9 @@ def backup(
|
|
|
279
290
|
)
|
|
280
291
|
success_count += 1
|
|
281
292
|
except VolumeError as e:
|
|
282
|
-
logger.error(
|
|
293
|
+
logger.error(
|
|
294
|
+
"Volume backup failed for '%s': %s\n%s", vol, e, traceback.format_exc()
|
|
295
|
+
)
|
|
283
296
|
click.echo()
|
|
284
297
|
|
|
285
298
|
click.echo("=== Volume backup complete ===")
|
|
@@ -297,11 +310,11 @@ def copy_volume(
|
|
|
297
310
|
team_id: int | None = None,
|
|
298
311
|
) -> None:
|
|
299
312
|
"""Copy data from one volume to another.
|
|
300
|
-
|
|
313
|
+
|
|
301
314
|
Args:
|
|
302
315
|
ownership: 'copy' (from source), 'private', 'team', or 'public'
|
|
303
316
|
team_id: Required if ownership is 'team'
|
|
304
|
-
|
|
317
|
+
|
|
305
318
|
Raises:
|
|
306
319
|
VolumeError: If copy fails
|
|
307
320
|
"""
|
|
@@ -309,7 +322,7 @@ def copy_volume(
|
|
|
309
322
|
|
|
310
323
|
try:
|
|
311
324
|
client = get_portainer_docker_client(portainer_url, api_key, endpoint_id)
|
|
312
|
-
|
|
325
|
+
|
|
313
326
|
# Get source volume info for driver/labels
|
|
314
327
|
try:
|
|
315
328
|
source_vol = client.volumes.get(source_volume)
|
|
@@ -317,7 +330,7 @@ def copy_volume(
|
|
|
317
330
|
except docker.errors.NotFound:
|
|
318
331
|
click.echo(f" ✗ Source volume '{source_volume}' not found")
|
|
319
332
|
raise VolumeError(f"Source volume '{source_volume}' not found")
|
|
320
|
-
|
|
333
|
+
|
|
321
334
|
# Check if dest volume exists, create if not
|
|
322
335
|
try:
|
|
323
336
|
client.volumes.get(dest_volume)
|
|
@@ -326,11 +339,13 @@ def copy_volume(
|
|
|
326
339
|
driver = source_info.get("Driver", "local")
|
|
327
340
|
labels = source_info.get("Labels", {})
|
|
328
341
|
try:
|
|
329
|
-
client.volumes.create(
|
|
342
|
+
client.volumes.create(
|
|
343
|
+
name=dest_volume, driver=driver, labels=labels or {}
|
|
344
|
+
)
|
|
330
345
|
except docker.errors.APIError as e:
|
|
331
346
|
click.echo(f" ✗ Failed to create destination volume")
|
|
332
347
|
raise VolumeError(f"Failed to create destination volume: {e}") from e
|
|
333
|
-
|
|
348
|
+
|
|
334
349
|
# Copy data using busybox
|
|
335
350
|
copy_cmd = "cp -a /source/. /dest/"
|
|
336
351
|
exit_code, logs = run_container(
|
|
@@ -366,7 +381,9 @@ def copy_volume(
|
|
|
366
381
|
if action == "copied":
|
|
367
382
|
click.echo(f" ✓ Permissions copied from source")
|
|
368
383
|
elif action == "skipped":
|
|
369
|
-
click.echo(
|
|
384
|
+
click.echo(
|
|
385
|
+
f" ✓ Permissions preserved (destination already has permissions)"
|
|
386
|
+
)
|
|
370
387
|
except ResourceControlError as e:
|
|
371
388
|
click.echo(f" ⚠ Warning: Failed to copy permissions - {e}")
|
|
372
389
|
else:
|
|
@@ -392,7 +409,7 @@ def copy_s3_to_volume(
|
|
|
392
409
|
s3_secret_key: str,
|
|
393
410
|
) -> None:
|
|
394
411
|
"""Copy files from S3 to a volume using mc (MinIO Client).
|
|
395
|
-
|
|
412
|
+
|
|
396
413
|
Raises:
|
|
397
414
|
VolumeError: If copy fails
|
|
398
415
|
"""
|
|
@@ -400,7 +417,7 @@ def copy_s3_to_volume(
|
|
|
400
417
|
|
|
401
418
|
try:
|
|
402
419
|
client = get_portainer_docker_client(portainer_url, api_key, endpoint_id)
|
|
403
|
-
|
|
420
|
+
|
|
404
421
|
# Create destination volume if it doesn't exist
|
|
405
422
|
try:
|
|
406
423
|
client.volumes.get(volume_name)
|
|
@@ -414,10 +431,12 @@ def copy_s3_to_volume(
|
|
|
414
431
|
|
|
415
432
|
# Build mc commands: configure alias and copy
|
|
416
433
|
s3_source = f"s3/{s3_bucket}/{s3_path}" if s3_path else f"s3/{s3_bucket}"
|
|
417
|
-
mc_cmd = " && ".join(
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
434
|
+
mc_cmd = " && ".join(
|
|
435
|
+
[
|
|
436
|
+
f"mc alias set s3 {s3_endpoint} {s3_access_key} {s3_secret_key}",
|
|
437
|
+
f"mc cp --recursive {s3_source}/ /data/",
|
|
438
|
+
]
|
|
439
|
+
)
|
|
421
440
|
|
|
422
441
|
exit_code, logs = run_container(
|
|
423
442
|
client=client,
|
|
@@ -452,7 +471,7 @@ def copy_volume_to_s3(
|
|
|
452
471
|
s3_secret_key: str,
|
|
453
472
|
) -> None:
|
|
454
473
|
"""Copy files from a volume to S3 using mc (MinIO Client).
|
|
455
|
-
|
|
474
|
+
|
|
456
475
|
Raises:
|
|
457
476
|
VolumeError: If copy fails
|
|
458
477
|
"""
|
|
@@ -460,13 +479,15 @@ def copy_volume_to_s3(
|
|
|
460
479
|
|
|
461
480
|
try:
|
|
462
481
|
client = get_portainer_docker_client(portainer_url, api_key, endpoint_id)
|
|
463
|
-
|
|
482
|
+
|
|
464
483
|
# Build mc commands: configure alias and copy
|
|
465
484
|
s3_dest = f"s3/{s3_bucket}/{s3_path}" if s3_path else f"s3/{s3_bucket}"
|
|
466
|
-
mc_cmd = " && ".join(
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
485
|
+
mc_cmd = " && ".join(
|
|
486
|
+
[
|
|
487
|
+
f"mc alias set s3 {s3_endpoint} {s3_access_key} {s3_secret_key}",
|
|
488
|
+
f"mc cp --recursive /data/ {s3_dest}/",
|
|
489
|
+
]
|
|
490
|
+
)
|
|
470
491
|
|
|
471
492
|
exit_code, logs = run_container(
|
|
472
493
|
client=client,
|
|
@@ -490,7 +511,13 @@ def copy_volume_to_s3(
|
|
|
490
511
|
|
|
491
512
|
|
|
492
513
|
@cli.command()
|
|
493
|
-
@click.option(
|
|
514
|
+
@click.option(
|
|
515
|
+
"--url",
|
|
516
|
+
"-u",
|
|
517
|
+
envvar="PORTAINER_URL",
|
|
518
|
+
required=True,
|
|
519
|
+
help="Portainer base URL (or PORTAINER_URL env var)",
|
|
520
|
+
)
|
|
494
521
|
@click.argument("source")
|
|
495
522
|
@click.argument("dest")
|
|
496
523
|
@click.option("--endpoint-id", "-e", type=int, default=1, help="Portainer endpoint ID")
|
|
@@ -516,13 +543,18 @@ def cp(
|
|
|
516
543
|
SOURCE and DEST can be volume names or S3 URIs.
|
|
517
544
|
|
|
518
545
|
Examples:
|
|
519
|
-
ptctools volume cp source dest
|
|
520
|
-
ptctools volume cp source dest --ownership team
|
|
521
|
-
ptctools volume cp source dest --ownership private
|
|
522
|
-
ptctools volume cp s3://bucket/path volume_a
|
|
523
|
-
ptctools volume cp volume_a s3://bucket/path
|
|
546
|
+
ptctools docker volume cp source dest
|
|
547
|
+
ptctools docker volume cp source dest --ownership team
|
|
548
|
+
ptctools docker volume cp source dest --ownership private
|
|
549
|
+
ptctools docker volume cp s3://bucket/path volume_a
|
|
550
|
+
ptctools docker volume cp volume_a s3://bucket/path
|
|
524
551
|
"""
|
|
525
|
-
from ptctools._s3 import
|
|
552
|
+
from ptctools._s3 import (
|
|
553
|
+
is_s3_uri,
|
|
554
|
+
parse_s3_uri,
|
|
555
|
+
get_s3_endpoint,
|
|
556
|
+
get_s3_credentials,
|
|
557
|
+
)
|
|
526
558
|
|
|
527
559
|
access_token = os.environ.get("PORTAINER_ACCESS_TOKEN")
|
|
528
560
|
if not access_token:
|
|
@@ -535,7 +567,10 @@ def cp(
|
|
|
535
567
|
|
|
536
568
|
# Both are S3 - not supported
|
|
537
569
|
if source_is_s3 and dest_is_s3:
|
|
538
|
-
click.echo(
|
|
570
|
+
click.echo(
|
|
571
|
+
"Error: Cannot copy directly between S3 locations. Use volume as intermediate.",
|
|
572
|
+
err=True,
|
|
573
|
+
)
|
|
539
574
|
sys.exit(1)
|
|
540
575
|
|
|
541
576
|
# Volume to Volume copy
|
|
@@ -654,7 +689,13 @@ def cp(
|
|
|
654
689
|
|
|
655
690
|
|
|
656
691
|
@cli.command()
|
|
657
|
-
@click.option(
|
|
692
|
+
@click.option(
|
|
693
|
+
"--url",
|
|
694
|
+
"-u",
|
|
695
|
+
envvar="PORTAINER_URL",
|
|
696
|
+
required=True,
|
|
697
|
+
help="Portainer base URL (or PORTAINER_URL env var)",
|
|
698
|
+
)
|
|
658
699
|
@click.argument("volume")
|
|
659
700
|
@click.option("--endpoint-id", "-e", type=int, default=1, help="Portainer endpoint ID")
|
|
660
701
|
@click.option("--force", "-f", is_flag=True, help="Force removal even if in use")
|
|
@@ -670,7 +711,7 @@ def rm(
|
|
|
670
711
|
|
|
671
712
|
VOLUME is the volume name to remove.
|
|
672
713
|
|
|
673
|
-
Example: ptctools volume rm volume_a
|
|
714
|
+
Example: ptctools docker volume rm volume_a
|
|
674
715
|
"""
|
|
675
716
|
access_token = os.environ.get("PORTAINER_ACCESS_TOKEN")
|
|
676
717
|
if not access_token:
|
|
@@ -702,7 +743,13 @@ def rm(
|
|
|
702
743
|
|
|
703
744
|
|
|
704
745
|
@cli.command()
|
|
705
|
-
@click.option(
|
|
746
|
+
@click.option(
|
|
747
|
+
"--url",
|
|
748
|
+
"-u",
|
|
749
|
+
envvar="PORTAINER_URL",
|
|
750
|
+
required=True,
|
|
751
|
+
help="Portainer base URL (or PORTAINER_URL env var)",
|
|
752
|
+
)
|
|
706
753
|
@click.argument("old_name")
|
|
707
754
|
@click.argument("new_name")
|
|
708
755
|
@click.option("--endpoint-id", "-e", type=int, default=1, help="Portainer endpoint ID")
|
|
@@ -719,7 +766,7 @@ def rename(
|
|
|
719
766
|
OLD_NAME is the current volume name.
|
|
720
767
|
NEW_NAME is the new volume name.
|
|
721
768
|
|
|
722
|
-
Example: ptctools volume rename volume_a volume_b
|
|
769
|
+
Example: ptctools docker volume rename volume_a volume_b
|
|
723
770
|
"""
|
|
724
771
|
access_token = os.environ.get("PORTAINER_ACCESS_TOKEN")
|
|
725
772
|
if not access_token:
|
|
@@ -729,7 +776,9 @@ def rename(
|
|
|
729
776
|
portainer_url = url.rstrip("/")
|
|
730
777
|
|
|
731
778
|
if not yes:
|
|
732
|
-
if not click.confirm(
|
|
779
|
+
if not click.confirm(
|
|
780
|
+
f"Rename '{old_name}' to '{new_name}'? This will copy data and delete the original."
|
|
781
|
+
):
|
|
733
782
|
click.echo("Aborted.")
|
|
734
783
|
sys.exit(0)
|
|
735
784
|
|
|
@@ -741,7 +790,9 @@ def rename(
|
|
|
741
790
|
try:
|
|
742
791
|
copy_volume(portainer_url, access_token, endpoint_id, old_name, new_name)
|
|
743
792
|
except VolumeError as e:
|
|
744
|
-
logger.error(
|
|
793
|
+
logger.error(
|
|
794
|
+
"Volume rename failed during copy: %s\n%s", e, traceback.format_exc()
|
|
795
|
+
)
|
|
745
796
|
click.echo(" ✗ Failed to copy data. Original volume unchanged.", err=True)
|
|
746
797
|
sys.exit(1)
|
|
747
798
|
|
|
@@ -756,9 +807,13 @@ def rename(
|
|
|
756
807
|
except docker.errors.NotFound:
|
|
757
808
|
click.echo(f" ✓ Volume '{old_name}' removed") # Already deleted
|
|
758
809
|
except docker.errors.APIError as e:
|
|
759
|
-
logger.error(
|
|
810
|
+
logger.error(
|
|
811
|
+
"Volume rename failed during delete: %s\n%s", e, traceback.format_exc()
|
|
812
|
+
)
|
|
760
813
|
click.echo(f" ⚠ Warning: Failed to remove old volume '{old_name}'", err=True)
|
|
761
|
-
click.echo(
|
|
814
|
+
click.echo(
|
|
815
|
+
f" Data has been copied to '{new_name}'. Please remove '{old_name}' manually."
|
|
816
|
+
)
|
|
762
817
|
sys.exit(1)
|
|
763
818
|
|
|
764
819
|
click.echo()
|
|
@@ -767,7 +822,13 @@ def rename(
|
|
|
767
822
|
|
|
768
823
|
|
|
769
824
|
@cli.command()
|
|
770
|
-
@click.option(
|
|
825
|
+
@click.option(
|
|
826
|
+
"--url",
|
|
827
|
+
"-u",
|
|
828
|
+
envvar="PORTAINER_URL",
|
|
829
|
+
required=True,
|
|
830
|
+
help="Portainer base URL (or PORTAINER_URL env var)",
|
|
831
|
+
)
|
|
771
832
|
@click.option(
|
|
772
833
|
"--volume",
|
|
773
834
|
"-v",
|
|
@@ -830,7 +891,10 @@ def restore(
|
|
|
830
891
|
# Use the first path segment as volume name
|
|
831
892
|
volume_name = s3_path.split("/")[0]
|
|
832
893
|
else:
|
|
833
|
-
click.echo(
|
|
894
|
+
click.echo(
|
|
895
|
+
"Error: No volume specified. Use --volume or include path in input URI",
|
|
896
|
+
err=True,
|
|
897
|
+
)
|
|
834
898
|
sys.exit(1)
|
|
835
899
|
|
|
836
900
|
passphrase = passphrase or os.environ.get("DUPLICATI_PASSPHRASE", "")
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ptctools
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Portainer Client Tools - CLI for managing Portainer from client
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: click>=8.0
|
|
8
|
+
Requires-Dist: docker==7.1.0
|
|
9
|
+
Requires-Dist: pydantic>=2.12.5
|
|
10
|
+
Requires-Dist: python-dateutil>=2.9.0.post0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# ptctools - Portainer Client Tools
|
|
16
|
+
|
|
17
|
+
CLI for managing Portainer stacks, volume backups, and database operations.
|
|
18
|
+
|
|
19
|
+
> **Note:** Only tested on Portainer 2.33.6
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# From Git repository
|
|
25
|
+
uv tool install git+https://github.com/tamntlib/ptctools.git
|
|
26
|
+
# or
|
|
27
|
+
uv tool install ptctools --from git+https://github.com/tamntlib/ptctools.git
|
|
28
|
+
|
|
29
|
+
# From local path
|
|
30
|
+
uv tool install openapi-generator-cli==7.19.0
|
|
31
|
+
openapi-generator-cli generate \
|
|
32
|
+
-i portainer_openapi.yml \
|
|
33
|
+
-g python \
|
|
34
|
+
-o ./src/ptctools/portainer_client \
|
|
35
|
+
--skip-validate-spec \
|
|
36
|
+
--additional-properties=generateSourceCodeOnly=true
|
|
37
|
+
uv tool install . --no-cache --reinstall
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Environment Variables
|
|
41
|
+
|
|
42
|
+
| Variable | Required | Description |
|
|
43
|
+
|----------|----------|-------------|
|
|
44
|
+
| `PORTAINER_URL` | Yes* | Portainer base URL (can also use `-u` flag) |
|
|
45
|
+
| `PORTAINER_ACCESS_TOKEN` | Yes | Portainer API key |
|
|
46
|
+
| `S3_ACCESS_KEY` | For S3 | S3 access key |
|
|
47
|
+
| `S3_SECRET_KEY` | For S3 | S3 secret key |
|
|
48
|
+
| `S3_ENDPOINT` | For S3 | S3/MinIO endpoint URL |
|
|
49
|
+
| `DUPLICATI_PASSPHRASE` | No | Backup encryption passphrase |
|
|
50
|
+
|
|
51
|
+
\* Required for `docker` commands. Can be provided via `-u` flag instead.
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
# Set environment variables
|
|
57
|
+
export PORTAINER_URL=https://portainer.example.com
|
|
58
|
+
export PORTAINER_ACCESS_TOKEN=your-api-key
|
|
59
|
+
export S3_ACCESS_KEY=your-s3-key
|
|
60
|
+
export S3_SECRET_KEY=your-s3-secret
|
|
61
|
+
export S3_ENDPOINT=https://s3.<region>.amazonaws.com
|
|
62
|
+
|
|
63
|
+
# Stack deployment (Swarm-only)
|
|
64
|
+
ptctools docker stack deploy -n mystack -f compose.yaml
|
|
65
|
+
|
|
66
|
+
# Secret management (Swarm-only)
|
|
67
|
+
echo "postgresql://user:pass@db:5432/mydb" | ptctools docker secret create db_dsn
|
|
68
|
+
ptctools docker secret create -f /path/to/secret.txt my_secret
|
|
69
|
+
ptctools docker secret create -v "secret-value" my_secret
|
|
70
|
+
|
|
71
|
+
# Config management (Swarm-only)
|
|
72
|
+
ptctools docker config set -n my-config -d "config content"
|
|
73
|
+
ptctools docker config set -n nginx.conf -f ./nginx.conf
|
|
74
|
+
ptctools docker config set -n my-config -d "new content" --force
|
|
75
|
+
ptctools docker config list
|
|
76
|
+
ptctools docker config get -n my-config
|
|
77
|
+
ptctools docker config delete -n my-config
|
|
78
|
+
|
|
79
|
+
# Volume backup/restore (uses Duplicati)
|
|
80
|
+
ptctools docker volume backup -v vol1,vol2 -o s3://mybucket
|
|
81
|
+
ptctools docker volume restore -i s3://mybucket/vol1 # volume name derived from URI path
|
|
82
|
+
ptctools docker volume restore -v vol1 -i s3://mybucket/vol1 # explicit volume name
|
|
83
|
+
|
|
84
|
+
# Volume copy (raw copy using mc/busybox)
|
|
85
|
+
ptctools docker volume cp source dest # volume to volume
|
|
86
|
+
ptctools docker volume cp s3://mybucket/path dest # S3 to volume
|
|
87
|
+
ptctools docker volume cp source s3://mybucket/path # volume to S3
|
|
88
|
+
|
|
89
|
+
# Volume management
|
|
90
|
+
ptctools docker volume rm myvolume # remove volume (with confirmation)
|
|
91
|
+
ptctools docker volume rm -y myvolume # remove without confirmation
|
|
92
|
+
ptctools docker volume rename old_name new_name # rename volume (copy + delete)
|
|
93
|
+
|
|
94
|
+
# Database backup/restore (uses minio/mc for S3)
|
|
95
|
+
ptctools docker db backup -c container_id -v db_data \
|
|
96
|
+
--db-user postgres --db-name mydb -o backup.sql.gz
|
|
97
|
+
ptctools docker db backup -c container_id -v db_data \
|
|
98
|
+
--db-user postgres --db-name mydb -o s3://mybucket/backups/db.sql.gz
|
|
99
|
+
|
|
100
|
+
ptctools docker db restore -c container_id -v db_data \
|
|
101
|
+
--db-user postgres --db-name mydb -i backup.sql.gz
|
|
102
|
+
ptctools docker db restore -c container_id -v db_data \
|
|
103
|
+
--db-user postgres --db-name mydb -i s3://mybucket/backups/db.sql.gz
|
|
104
|
+
|
|
105
|
+
# Override PORTAINER_URL with -u flag
|
|
106
|
+
ptctools docker secret create -u https://other.portainer.com -f dsn.txt my_secret
|
|
107
|
+
|
|
108
|
+
# Utils - local Duplicati operations (no Portainer needed)
|
|
109
|
+
ptctools utils backup --input ./data --output s3://backups/mydata
|
|
110
|
+
ptctools utils restore --input s3://backups/mydata --output ./restored
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## CLI Structure
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
ptctools
|
|
118
|
+
├── docker # Docker commands (via Portainer Docker proxy)
|
|
119
|
+
│ ├── stack deploy # Deploy/update stack (Swarm-only)
|
|
120
|
+
│ ├── secret create # Create Docker secret (Swarm-only)
|
|
121
|
+
│ ├── config set/get/list/delete # Manage configs (Swarm-only)
|
|
122
|
+
│ ├── volume backup/restore/cp/rm/rename
|
|
123
|
+
│ └── db backup/restore
|
|
124
|
+
├── utils backup/restore # Local Duplicati operations
|
|
125
|
+
└── k8s ... # Kubernetes commands (future)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Commands
|
|
129
|
+
|
|
130
|
+
### `ptctools docker stack deploy`
|
|
131
|
+
Deploy or update a Docker Swarm stack in Portainer.
|
|
132
|
+
|
|
133
|
+
### `ptctools docker secret create`
|
|
134
|
+
Create a Docker Swarm secret. Value can be provided via stdin, file, or command-line argument.
|
|
135
|
+
|
|
136
|
+
### `ptctools docker config set/get/list/delete`
|
|
137
|
+
Manage Docker Swarm configs via Portainer API.
|
|
138
|
+
|
|
139
|
+
### `ptctools docker volume backup/restore`
|
|
140
|
+
|
|
141
|
+
- **backup**: Backup multiple Docker volumes (comma-separated) to S3 using Duplicati container.
|
|
142
|
+
- **restore**: Restore a single Docker volume from S3. Volume name can be specified via `--volume` or derived from the input URI path.
|
|
143
|
+
|
|
144
|
+
### `ptctools docker volume cp`
|
|
145
|
+
Copy data between volumes and S3 (raw file copy).
|
|
146
|
+
- **volume to volume**: Uses `busybox` with `cp -a`
|
|
147
|
+
- **S3 to volume**: Uses `minio/mc` to download files
|
|
148
|
+
- **volume to S3**: Uses `minio/mc` to upload files
|
|
149
|
+
|
|
150
|
+
### `ptctools docker volume rm`
|
|
151
|
+
Remove a Docker volume. Use `-y` to skip confirmation, `-f` to force removal.
|
|
152
|
+
|
|
153
|
+
### `ptctools docker volume rename`
|
|
154
|
+
Rename a volume by copying data to a new volume and deleting the original.
|
|
155
|
+
|
|
156
|
+
### `ptctools docker db backup/restore`
|
|
157
|
+
Backup/restore PostgreSQL database. Supports both local files and S3 URIs.
|
|
158
|
+
- Uses `pg_dump`/`psql` for database operations
|
|
159
|
+
- Uses `minio/mc` container for S3 transfers
|
|
160
|
+
|
|
161
|
+
### `ptctools utils backup/restore`
|
|
162
|
+
Local backup/restore operations using Duplicati CLI (docker or local). Does not require Portainer.
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
ptctools/__init__.py,sha256=O-s77PdUCagdP3HqaQSXYac1y1AC1EbBLOvTWidI_6k,301
|
|
2
2
|
ptctools/_portainer.py,sha256=XcGDaguPN09SCtQpc_18oS4MvaobPviT1oBEgS3zS2U,19376
|
|
3
3
|
ptctools/_s3.py,sha256=fOz53Y3X6O9XiFbWhb_qY-xHXjf9OBuoWJl--0nXklc,4974
|
|
4
|
-
ptctools/cli.py,sha256=
|
|
5
|
-
ptctools/config.py,sha256=
|
|
6
|
-
ptctools/db.py,sha256=
|
|
7
|
-
ptctools/
|
|
4
|
+
ptctools/cli.py,sha256=DxoCpPwN0a_Bl9WKGEGwvjT-ju95ZI5vkNbV6IOl2nc,522
|
|
5
|
+
ptctools/config.py,sha256=LzNtq9qQOSjisLmVFDdVQCdDLwMBuLQNO1QRW-0v894,7779
|
|
6
|
+
ptctools/db.py,sha256=zW6K32mdAxNQZsiQx7ByjKKUQtOs5yYUbCuJCbD0ECM,19785
|
|
7
|
+
ptctools/docker.py,sha256=njF9Tvwx21FDSjFmzaWHGEo4KcGIt9-4FjHBSPyNiiU,674
|
|
8
|
+
ptctools/secret.py,sha256=vdyFhsdBoVeqViiGlNrC6dTbYLVlt7OHV8yobgqHBbk,3977
|
|
9
|
+
ptctools/stack.py,sha256=YymDsE2eLTt6sed9P1P0VvEyblGvf7-4oBHl8E_RXHU,12301
|
|
8
10
|
ptctools/utils.py,sha256=G9zC4NYRzkPqgkFbsdb18gDiI3ZMSjxz8KnbQ-g3Rgk,13335
|
|
9
|
-
ptctools/volume.py,sha256=
|
|
11
|
+
ptctools/volume.py,sha256=W1PQ4m8cjduu5W3oqomoqO6DjtxczM7uc1lqV-b4_wM,29384
|
|
10
12
|
ptctools/portainer_client/.openapi-generator-ignore,sha256=pu2PTide7pJtJ-DFLzDy0cTYQJRlrB-8RRH3zGLeUds,1040
|
|
11
13
|
ptctools/portainer_client/openapi_client_README.md,sha256=ZfsO03-jNK-G0jqHYzIZQ92XR3-YLHGjOYw-2MouEoA,76219
|
|
12
14
|
ptctools/portainer_client/.openapi-generator/FILES,sha256=K6TK04JjWPF8gKdJu5Be31HZ01Ijo6XN2tQPTmLrpSo,40032
|
|
@@ -1232,8 +1234,8 @@ ptctools/portainer_client/openapi_client/test/test_webhooks_api.py,sha256=pOP2nQ
|
|
|
1232
1234
|
ptctools/portainer_client/openapi_client/test/test_webhooks_webhook_create_payload.py,sha256=wUvfMxCbpXS0EKK7kaoDWbwiniQjtA46ybrnRJTgiZE,4729
|
|
1233
1235
|
ptctools/portainer_client/openapi_client/test/test_webhooks_webhook_update_payload.py,sha256=JQlrXQNDz8AgHkYuxUSAQq_AUPvBdk0fQ7CfIYjgid8,4627
|
|
1234
1236
|
ptctools/portainer_client/openapi_client/test/test_websocket_api.py,sha256=uqLWUKRrzVfeHKpo4ER7HjY7hpKRtMNjblE0W-DJplU,4342
|
|
1235
|
-
ptctools-0.
|
|
1236
|
-
ptctools-0.
|
|
1237
|
-
ptctools-0.
|
|
1238
|
-
ptctools-0.
|
|
1239
|
-
ptctools-0.
|
|
1237
|
+
ptctools-0.2.0.dist-info/METADATA,sha256=kCBfSd_9-RIHJ2sySCE-SzpqLFuDQWS8eg4K85-OGe8,6163
|
|
1238
|
+
ptctools-0.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
1239
|
+
ptctools-0.2.0.dist-info/entry_points.txt,sha256=JkiFaKgNdfAR9eL3E8g72rJg6bxTlNzRUdjRp_N6YpY,47
|
|
1240
|
+
ptctools-0.2.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
1241
|
+
ptctools-0.2.0.dist-info/RECORD,,
|