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/cli.py
CHANGED
|
@@ -3,11 +3,8 @@
|
|
|
3
3
|
import click
|
|
4
4
|
|
|
5
5
|
from ptctools import __version__
|
|
6
|
-
from ptctools import
|
|
7
|
-
from ptctools import volume
|
|
8
|
-
from ptctools import db
|
|
6
|
+
from ptctools import docker
|
|
9
7
|
from ptctools import utils
|
|
10
|
-
from ptctools import config
|
|
11
8
|
|
|
12
9
|
|
|
13
10
|
@click.group()
|
|
@@ -17,11 +14,11 @@ def main():
|
|
|
17
14
|
pass
|
|
18
15
|
|
|
19
16
|
|
|
20
|
-
|
|
21
|
-
main.add_command(
|
|
22
|
-
|
|
17
|
+
# Docker commands (via Portainer Docker proxy)
|
|
18
|
+
main.add_command(docker.cli, name="docker")
|
|
19
|
+
|
|
20
|
+
# Local utility commands (no Portainer needed)
|
|
23
21
|
main.add_command(utils.cli, name="utils")
|
|
24
|
-
main.add_command(config.cli, name="config")
|
|
25
22
|
|
|
26
23
|
|
|
27
24
|
if __name__ == "__main__":
|
ptctools/config.py
CHANGED
|
@@ -19,6 +19,7 @@ logger = logging.getLogger(__name__)
|
|
|
19
19
|
|
|
20
20
|
class ConfigError(PortainerError):
|
|
21
21
|
"""Exception for Docker config operations."""
|
|
22
|
+
|
|
22
23
|
pass
|
|
23
24
|
|
|
24
25
|
|
|
@@ -29,7 +30,13 @@ def cli():
|
|
|
29
30
|
|
|
30
31
|
|
|
31
32
|
@cli.command("set")
|
|
32
|
-
@click.option(
|
|
33
|
+
@click.option(
|
|
34
|
+
"--url",
|
|
35
|
+
"-u",
|
|
36
|
+
envvar="PORTAINER_URL",
|
|
37
|
+
required=True,
|
|
38
|
+
help="Portainer base URL (or PORTAINER_URL env var)",
|
|
39
|
+
)
|
|
33
40
|
@click.option("--name", "-n", required=True, help="Config name")
|
|
34
41
|
@click.option("--data", "-d", default=None, help="Config data (string content)")
|
|
35
42
|
@click.option(
|
|
@@ -41,9 +48,7 @@ def cli():
|
|
|
41
48
|
help="Read config data from file",
|
|
42
49
|
)
|
|
43
50
|
@click.option("--endpoint-id", "-e", type=int, default=1, help="Portainer endpoint ID")
|
|
44
|
-
@click.option(
|
|
45
|
-
"--force", is_flag=True, help="Replace existing config if it exists"
|
|
46
|
-
)
|
|
51
|
+
@click.option("--force", is_flag=True, help="Replace existing config if it exists")
|
|
47
52
|
def set_config(
|
|
48
53
|
url: str,
|
|
49
54
|
name: str,
|
|
@@ -57,13 +62,13 @@ def set_config(
|
|
|
57
62
|
Examples:
|
|
58
63
|
|
|
59
64
|
# Create from inline data
|
|
60
|
-
ptctools config set -u https://portainer.example.com -n my-config -d "config content"
|
|
65
|
+
ptctools docker config set -u https://portainer.example.com -n my-config -d "config content"
|
|
61
66
|
|
|
62
67
|
# Create from file
|
|
63
|
-
ptctools config set -u https://portainer.example.com -n nginx.conf -f ./nginx.conf
|
|
68
|
+
ptctools docker config set -u https://portainer.example.com -n nginx.conf -f ./nginx.conf
|
|
64
69
|
|
|
65
70
|
# Replace existing config
|
|
66
|
-
ptctools config set -u https://portainer.example.com -n my-config -d "new content" --force
|
|
71
|
+
ptctools docker config set -u https://portainer.example.com -n my-config -d "new content" --force
|
|
67
72
|
"""
|
|
68
73
|
# Validate that exactly one of --data or --file is provided
|
|
69
74
|
if data is None and file_path is None:
|
|
@@ -88,7 +93,7 @@ def set_config(
|
|
|
88
93
|
|
|
89
94
|
try:
|
|
90
95
|
client = get_portainer_docker_client(portainer_url, access_token, endpoint_id)
|
|
91
|
-
|
|
96
|
+
|
|
92
97
|
# Check if config already exists
|
|
93
98
|
existing = None
|
|
94
99
|
for config in client.configs.list():
|
|
@@ -119,7 +124,13 @@ def set_config(
|
|
|
119
124
|
|
|
120
125
|
|
|
121
126
|
@cli.command("get")
|
|
122
|
-
@click.option(
|
|
127
|
+
@click.option(
|
|
128
|
+
"--url",
|
|
129
|
+
"-u",
|
|
130
|
+
envvar="PORTAINER_URL",
|
|
131
|
+
required=True,
|
|
132
|
+
help="Portainer base URL (or PORTAINER_URL env var)",
|
|
133
|
+
)
|
|
123
134
|
@click.option("--name", "-n", required=True, help="Config name")
|
|
124
135
|
@click.option("--endpoint-id", "-e", type=int, default=1, help="Portainer endpoint ID")
|
|
125
136
|
def get_config_cmd(url: str, name: str, endpoint_id: int):
|
|
@@ -138,7 +149,7 @@ def get_config_cmd(url: str, name: str, endpoint_id: int):
|
|
|
138
149
|
|
|
139
150
|
try:
|
|
140
151
|
client = get_portainer_docker_client(portainer_url, access_token, endpoint_id)
|
|
141
|
-
|
|
152
|
+
|
|
142
153
|
config = None
|
|
143
154
|
for c in client.configs.list():
|
|
144
155
|
if c.name == name:
|
|
@@ -158,7 +169,13 @@ def get_config_cmd(url: str, name: str, endpoint_id: int):
|
|
|
158
169
|
|
|
159
170
|
|
|
160
171
|
@cli.command("list")
|
|
161
|
-
@click.option(
|
|
172
|
+
@click.option(
|
|
173
|
+
"--url",
|
|
174
|
+
"-u",
|
|
175
|
+
envvar="PORTAINER_URL",
|
|
176
|
+
required=True,
|
|
177
|
+
help="Portainer base URL (or PORTAINER_URL env var)",
|
|
178
|
+
)
|
|
162
179
|
@click.option("--endpoint-id", "-e", type=int, default=1, help="Portainer endpoint ID")
|
|
163
180
|
def list_configs_cmd(url: str, endpoint_id: int):
|
|
164
181
|
"""List all Docker Swarm configs."""
|
|
@@ -195,7 +212,13 @@ def list_configs_cmd(url: str, endpoint_id: int):
|
|
|
195
212
|
|
|
196
213
|
|
|
197
214
|
@cli.command("delete")
|
|
198
|
-
@click.option(
|
|
215
|
+
@click.option(
|
|
216
|
+
"--url",
|
|
217
|
+
"-u",
|
|
218
|
+
envvar="PORTAINER_URL",
|
|
219
|
+
required=True,
|
|
220
|
+
help="Portainer base URL (or PORTAINER_URL env var)",
|
|
221
|
+
)
|
|
199
222
|
@click.option("--name", "-n", required=True, help="Config name")
|
|
200
223
|
@click.option("--endpoint-id", "-e", type=int, default=1, help="Portainer endpoint ID")
|
|
201
224
|
def delete_config_cmd(url: str, name: str, endpoint_id: int):
|
|
@@ -211,7 +234,7 @@ def delete_config_cmd(url: str, name: str, endpoint_id: int):
|
|
|
211
234
|
|
|
212
235
|
try:
|
|
213
236
|
client = get_portainer_docker_client(portainer_url, access_token, endpoint_id)
|
|
214
|
-
|
|
237
|
+
|
|
215
238
|
config = None
|
|
216
239
|
for c in client.configs.list():
|
|
217
240
|
if c.name == name:
|
ptctools/db.py
CHANGED
|
@@ -10,7 +10,11 @@ import traceback
|
|
|
10
10
|
import click
|
|
11
11
|
import docker
|
|
12
12
|
|
|
13
|
-
from ptctools._portainer import
|
|
13
|
+
from ptctools._portainer import (
|
|
14
|
+
get_portainer_docker_client,
|
|
15
|
+
run_container,
|
|
16
|
+
PortainerError,
|
|
17
|
+
)
|
|
14
18
|
from ptctools._s3 import parse_s3_uri, is_s3_uri, get_s3_endpoint, get_s3_credentials
|
|
15
19
|
|
|
16
20
|
logger = logging.getLogger(__name__)
|
|
@@ -18,6 +22,7 @@ logger = logging.getLogger(__name__)
|
|
|
18
22
|
|
|
19
23
|
class DatabaseError(PortainerError):
|
|
20
24
|
"""Exception for database operations."""
|
|
25
|
+
|
|
21
26
|
pass
|
|
22
27
|
|
|
23
28
|
|
|
@@ -42,7 +47,7 @@ def run_mc_command(
|
|
|
42
47
|
"""Run minio/mc command in ephemeral container.
|
|
43
48
|
|
|
44
49
|
The volume is mounted at /data in the container.
|
|
45
|
-
|
|
50
|
+
|
|
46
51
|
Raises:
|
|
47
52
|
DatabaseError: If Docker operation fails
|
|
48
53
|
"""
|
|
@@ -54,7 +59,7 @@ def run_mc_command(
|
|
|
54
59
|
|
|
55
60
|
try:
|
|
56
61
|
client = get_portainer_docker_client(portainer_url, access_token, endpoint_id)
|
|
57
|
-
|
|
62
|
+
|
|
58
63
|
return run_container(
|
|
59
64
|
client=client,
|
|
60
65
|
image="minio/mc:latest",
|
|
@@ -82,7 +87,7 @@ def backup_database(
|
|
|
82
87
|
s3_secret_key: str | None,
|
|
83
88
|
) -> None:
|
|
84
89
|
"""Backup database using pg_dump via exec, then optionally upload to S3 with mc.
|
|
85
|
-
|
|
90
|
+
|
|
86
91
|
Raises:
|
|
87
92
|
DatabaseError: If backup fails
|
|
88
93
|
"""
|
|
@@ -101,13 +106,15 @@ def backup_database(
|
|
|
101
106
|
# Step 1: Run pg_dump inside the database container
|
|
102
107
|
click.echo(" Running pg_dump...")
|
|
103
108
|
cmd = f"pg_dump -U {db_user} {db_name} | gzip > {backup_file} && stat -c %s {backup_file}"
|
|
104
|
-
|
|
109
|
+
|
|
105
110
|
try:
|
|
106
111
|
exit_code, output_stream = container.exec_run(
|
|
107
112
|
["sh", "-c", cmd],
|
|
108
113
|
demux=False,
|
|
109
114
|
)
|
|
110
|
-
output_text =
|
|
115
|
+
output_text = (
|
|
116
|
+
output_stream.decode("utf-8", errors="replace") if output_stream else ""
|
|
117
|
+
)
|
|
111
118
|
except docker.errors.APIError as e:
|
|
112
119
|
click.echo(f" ✗ pg_dump failed: {e}")
|
|
113
120
|
raise DatabaseError(f"pg_dump failed: {e}") from e
|
|
@@ -142,7 +149,9 @@ def backup_database(
|
|
|
142
149
|
|
|
143
150
|
if not s3_access_key or not s3_secret_key:
|
|
144
151
|
click.echo(" ✗ S3 credentials required (S3_ACCESS_KEY, S3_SECRET_KEY)")
|
|
145
|
-
raise DatabaseError(
|
|
152
|
+
raise DatabaseError(
|
|
153
|
+
"S3 credentials required (S3_ACCESS_KEY, S3_SECRET_KEY)"
|
|
154
|
+
)
|
|
146
155
|
|
|
147
156
|
click.echo(f" Uploading to S3: s3://{bucket}/{s3_path}...")
|
|
148
157
|
|
|
@@ -179,14 +188,18 @@ def backup_database(
|
|
|
179
188
|
["sh", "-c", f"base64 {backup_file}"],
|
|
180
189
|
demux=False,
|
|
181
190
|
)
|
|
182
|
-
b64_content =
|
|
191
|
+
b64_content = (
|
|
192
|
+
b64_output.decode("utf-8", errors="replace") if b64_output else ""
|
|
193
|
+
)
|
|
183
194
|
except docker.errors.APIError as e:
|
|
184
195
|
click.echo(" ✗ Failed to read backup file from container")
|
|
185
196
|
raise DatabaseError("Failed to read backup file from container") from e
|
|
186
197
|
|
|
187
198
|
if read_exit_code != 0:
|
|
188
199
|
click.echo(f" ✗ Failed to read backup file: exit code {read_exit_code}")
|
|
189
|
-
raise DatabaseError(
|
|
200
|
+
raise DatabaseError(
|
|
201
|
+
f"Failed to read backup file: exit code {read_exit_code}"
|
|
202
|
+
)
|
|
190
203
|
|
|
191
204
|
# Decode and save to output file
|
|
192
205
|
import base64
|
|
@@ -227,7 +240,7 @@ def restore_database(
|
|
|
227
240
|
s3_secret_key: str | None,
|
|
228
241
|
) -> None:
|
|
229
242
|
"""Restore database from local file or S3.
|
|
230
|
-
|
|
243
|
+
|
|
231
244
|
Raises:
|
|
232
245
|
DatabaseError: If restore fails
|
|
233
246
|
"""
|
|
@@ -260,7 +273,9 @@ def restore_database(
|
|
|
260
273
|
|
|
261
274
|
if not s3_access_key or not s3_secret_key:
|
|
262
275
|
click.echo(" ✗ S3 credentials required (S3_ACCESS_KEY, S3_SECRET_KEY)")
|
|
263
|
-
raise DatabaseError(
|
|
276
|
+
raise DatabaseError(
|
|
277
|
+
"S3 credentials required (S3_ACCESS_KEY, S3_SECRET_KEY)"
|
|
278
|
+
)
|
|
264
279
|
|
|
265
280
|
click.echo(f" Downloading from S3: s3://{bucket}/{s3_path}...")
|
|
266
281
|
|
|
@@ -322,7 +337,9 @@ def restore_database(
|
|
|
322
337
|
|
|
323
338
|
if write_exit_code != 0:
|
|
324
339
|
click.echo(f" ✗ Failed to write backup file: exit code {write_exit_code}")
|
|
325
|
-
raise DatabaseError(
|
|
340
|
+
raise DatabaseError(
|
|
341
|
+
f"Failed to write backup file: exit code {write_exit_code}"
|
|
342
|
+
)
|
|
326
343
|
|
|
327
344
|
# Append remaining chunks
|
|
328
345
|
for chunk in chunks[1:]:
|
|
@@ -350,7 +367,9 @@ def restore_database(
|
|
|
350
367
|
["sh", "-c", psql_cmd],
|
|
351
368
|
demux=False,
|
|
352
369
|
)
|
|
353
|
-
output =
|
|
370
|
+
output = (
|
|
371
|
+
output_stream.decode("utf-8", errors="replace") if output_stream else ""
|
|
372
|
+
)
|
|
354
373
|
except docker.errors.APIError as e:
|
|
355
374
|
click.echo(f" ✗ Restore failed: {e}")
|
|
356
375
|
raise DatabaseError(f"Restore failed: {e}") from e
|
|
@@ -381,7 +400,13 @@ def cli():
|
|
|
381
400
|
|
|
382
401
|
|
|
383
402
|
@cli.command()
|
|
384
|
-
@click.option(
|
|
403
|
+
@click.option(
|
|
404
|
+
"--url",
|
|
405
|
+
"-u",
|
|
406
|
+
envvar="PORTAINER_URL",
|
|
407
|
+
required=True,
|
|
408
|
+
help="Portainer base URL (or PORTAINER_URL env var)",
|
|
409
|
+
)
|
|
385
410
|
@click.option("--container-id", "-c", required=True, help="Database container ID")
|
|
386
411
|
@click.option(
|
|
387
412
|
"--volume-name", "-v", required=True, help="Volume name where database stores data"
|
|
@@ -417,9 +442,9 @@ def backup(
|
|
|
417
442
|
"""Backup PostgreSQL database to local file or S3.
|
|
418
443
|
|
|
419
444
|
Examples:
|
|
420
|
-
ptctools db backup -u https://portainer.example.com -c abc123 -v db_data --db-user postgres --db-name mydb -o /tmp/backup.sql.gz
|
|
445
|
+
ptctools docker db backup -u https://portainer.example.com -c abc123 -v db_data --db-user postgres --db-name mydb -o /tmp/backup.sql.gz
|
|
421
446
|
|
|
422
|
-
ptctools db backup -u https://portainer.example.com -c abc123 -v db_data --db-user postgres --db-name mydb -o s3://mybucket/backups/backup.sql.gz --s3-endpoint https://s3.<region>.amazonaws.com
|
|
447
|
+
ptctools docker db backup -u https://portainer.example.com -c abc123 -v db_data --db-user postgres --db-name mydb -o s3://mybucket/backups/backup.sql.gz --s3-endpoint https://s3.<region>.amazonaws.com
|
|
423
448
|
"""
|
|
424
449
|
access_token = os.environ.get("PORTAINER_ACCESS_TOKEN")
|
|
425
450
|
if not access_token:
|
|
@@ -477,7 +502,13 @@ def backup(
|
|
|
477
502
|
|
|
478
503
|
|
|
479
504
|
@cli.command()
|
|
480
|
-
@click.option(
|
|
505
|
+
@click.option(
|
|
506
|
+
"--url",
|
|
507
|
+
"-u",
|
|
508
|
+
envvar="PORTAINER_URL",
|
|
509
|
+
required=True,
|
|
510
|
+
help="Portainer base URL (or PORTAINER_URL env var)",
|
|
511
|
+
)
|
|
481
512
|
@click.option("--container-id", "-c", required=True, help="Database container ID")
|
|
482
513
|
@click.option(
|
|
483
514
|
"--volume-name", "-v", required=True, help="Volume name where database stores data"
|
|
@@ -514,9 +545,9 @@ def restore(
|
|
|
514
545
|
"""Restore PostgreSQL database from local file or S3.
|
|
515
546
|
|
|
516
547
|
Examples:
|
|
517
|
-
ptctools db restore -u https://portainer.example.com -c abc123 -v db_data --db-user postgres --db-name mydb -i /tmp/backup.sql.gz
|
|
548
|
+
ptctools docker db restore -u https://portainer.example.com -c abc123 -v db_data --db-user postgres --db-name mydb -i /tmp/backup.sql.gz
|
|
518
549
|
|
|
519
|
-
ptctools db restore -u https://portainer.example.com -c abc123 -v db_data --db-user postgres --db-name mydb -i s3://mybucket/backups/backup.sql.gz --s3-endpoint https://s3.<region>.amazonaws.com
|
|
550
|
+
ptctools docker db restore -u https://portainer.example.com -c abc123 -v db_data --db-user postgres --db-name mydb -i s3://mybucket/backups/backup.sql.gz --s3-endpoint https://s3.<region>.amazonaws.com
|
|
520
551
|
"""
|
|
521
552
|
access_token = os.environ.get("PORTAINER_ACCESS_TOKEN")
|
|
522
553
|
if not access_token:
|
ptctools/docker.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Docker management commands (via Portainer Docker proxy)."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from ptctools import stack
|
|
6
|
+
from ptctools import secret
|
|
7
|
+
from ptctools import config
|
|
8
|
+
from ptctools import volume
|
|
9
|
+
from ptctools import db
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.group()
|
|
13
|
+
def cli():
|
|
14
|
+
"""Docker management commands.
|
|
15
|
+
|
|
16
|
+
These commands work with Docker environments via Portainer.
|
|
17
|
+
Some commands (stack, secret, config) require Docker Swarm.
|
|
18
|
+
"""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Swarm-only commands
|
|
23
|
+
cli.add_command(stack.cli, name="stack")
|
|
24
|
+
cli.add_command(secret.cli, name="secret")
|
|
25
|
+
cli.add_command(config.cli, name="config")
|
|
26
|
+
|
|
27
|
+
# Any Docker commands
|
|
28
|
+
cli.add_command(volume.cli, name="volume")
|
|
29
|
+
cli.add_command(db.cli, name="db")
|
ptctools/secret.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Secret management commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
import docker.errors
|
|
12
|
+
|
|
13
|
+
from ptctools._portainer import (
|
|
14
|
+
get_portainer_docker_client,
|
|
15
|
+
PortainerError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SecretError(PortainerError):
|
|
20
|
+
"""Exception for secret operations."""
|
|
21
|
+
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@click.group()
|
|
26
|
+
def cli():
|
|
27
|
+
"""Secret management commands."""
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@cli.command()
|
|
32
|
+
@click.option(
|
|
33
|
+
"--url",
|
|
34
|
+
"-u",
|
|
35
|
+
envvar="PORTAINER_URL",
|
|
36
|
+
required=True,
|
|
37
|
+
help="Portainer base URL (or PORTAINER_URL env var)",
|
|
38
|
+
)
|
|
39
|
+
@click.option("--endpoint-id", "-e", type=int, default=1, help="Portainer endpoint ID")
|
|
40
|
+
@click.option(
|
|
41
|
+
"--file",
|
|
42
|
+
"-f",
|
|
43
|
+
"file_path",
|
|
44
|
+
type=click.Path(exists=True, path_type=Path),
|
|
45
|
+
help="Read secret from file",
|
|
46
|
+
)
|
|
47
|
+
@click.option(
|
|
48
|
+
"--value", "-v", help="Secret value (use stdin or --file for sensitive data)"
|
|
49
|
+
)
|
|
50
|
+
@click.argument("name")
|
|
51
|
+
def create(
|
|
52
|
+
url: str,
|
|
53
|
+
endpoint_id: int,
|
|
54
|
+
file_path: Path | None,
|
|
55
|
+
value: str | None,
|
|
56
|
+
name: str,
|
|
57
|
+
):
|
|
58
|
+
"""Create a Docker Swarm secret.
|
|
59
|
+
|
|
60
|
+
NAME is the name of the secret to create.
|
|
61
|
+
|
|
62
|
+
The secret value can be provided via:
|
|
63
|
+
- stdin: echo "secret" | ptctools docker secret create -u URL my_secret
|
|
64
|
+
- file: ptctools docker secret create -u URL -f /path/to/file my_secret
|
|
65
|
+
- value: ptctools docker secret create -u URL -v "secret" my_secret
|
|
66
|
+
|
|
67
|
+
Example:
|
|
68
|
+
echo "postgresql://user:pass@db:5432/mydb" | ptctools docker secret create -u https://portainer.example.com litellm_db_dsn
|
|
69
|
+
"""
|
|
70
|
+
access_token = os.environ.get("PORTAINER_ACCESS_TOKEN")
|
|
71
|
+
if not access_token:
|
|
72
|
+
click.echo(
|
|
73
|
+
"Error: Missing PORTAINER_ACCESS_TOKEN environment variable", err=True
|
|
74
|
+
)
|
|
75
|
+
sys.exit(1)
|
|
76
|
+
|
|
77
|
+
# Determine secret data source
|
|
78
|
+
secret_data: bytes | None = None
|
|
79
|
+
|
|
80
|
+
if file_path:
|
|
81
|
+
# Read from file
|
|
82
|
+
secret_data = file_path.read_bytes()
|
|
83
|
+
elif value:
|
|
84
|
+
# Use provided value
|
|
85
|
+
secret_data = value.encode("utf-8")
|
|
86
|
+
elif not sys.stdin.isatty():
|
|
87
|
+
# Read from stdin
|
|
88
|
+
secret_data = sys.stdin.read().encode("utf-8")
|
|
89
|
+
else:
|
|
90
|
+
click.echo(
|
|
91
|
+
"Error: Secret value required. Use --file, --value, or pipe via stdin.",
|
|
92
|
+
err=True,
|
|
93
|
+
)
|
|
94
|
+
sys.exit(1)
|
|
95
|
+
|
|
96
|
+
# Strip trailing newline if present (common when piping from echo)
|
|
97
|
+
if secret_data.endswith(b"\n"):
|
|
98
|
+
secret_data = secret_data.rstrip(b"\n")
|
|
99
|
+
|
|
100
|
+
portainer_url = url.rstrip("/")
|
|
101
|
+
|
|
102
|
+
click.echo(f"Portainer URL: {portainer_url}")
|
|
103
|
+
click.echo(f"Endpoint ID: {endpoint_id}")
|
|
104
|
+
click.echo(f"Secret Name: {name}")
|
|
105
|
+
click.echo()
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
docker_client = get_portainer_docker_client(
|
|
109
|
+
portainer_url, access_token, endpoint_id
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Check if secret already exists
|
|
113
|
+
try:
|
|
114
|
+
existing = docker_client.secrets.get(name)
|
|
115
|
+
click.echo(
|
|
116
|
+
f"Error: Secret '{name}' already exists (ID: {existing.id[:12]})",
|
|
117
|
+
err=True,
|
|
118
|
+
)
|
|
119
|
+
click.echo(
|
|
120
|
+
"Use 'docker secret rm' to remove it first, or choose a different name.",
|
|
121
|
+
err=True,
|
|
122
|
+
)
|
|
123
|
+
sys.exit(1)
|
|
124
|
+
except docker.errors.NotFound:
|
|
125
|
+
pass # Good, secret doesn't exist
|
|
126
|
+
|
|
127
|
+
# Create the secret
|
|
128
|
+
click.echo(f"Creating secret '{name}'...")
|
|
129
|
+
secret = docker_client.secrets.create(name=name, data=secret_data)
|
|
130
|
+
click.echo(f"Secret created successfully!")
|
|
131
|
+
click.echo(f" ID: {secret.id}")
|
|
132
|
+
click.echo(f" Name: {name}")
|
|
133
|
+
click.echo()
|
|
134
|
+
click.echo("Done!")
|
|
135
|
+
sys.exit(0)
|
|
136
|
+
|
|
137
|
+
except docker.errors.APIError as e:
|
|
138
|
+
click.echo(f"Error: {e.explanation or str(e)}", err=True)
|
|
139
|
+
click.echo()
|
|
140
|
+
click.echo("Failed!")
|
|
141
|
+
sys.exit(1)
|
|
142
|
+
except SecretError as e:
|
|
143
|
+
click.echo(f"Error: {e}", err=True)
|
|
144
|
+
click.echo()
|
|
145
|
+
click.echo("Failed!")
|
|
146
|
+
sys.exit(1)
|
ptctools/stack.py
CHANGED
|
@@ -19,12 +19,24 @@ from ptctools._portainer import (
|
|
|
19
19
|
)
|
|
20
20
|
from ptctools.portainer_client.openapi_client.api.stacks_api import StacksApi
|
|
21
21
|
from ptctools.portainer_client.openapi_client.api.users_api import UsersApi
|
|
22
|
-
from ptctools.portainer_client.openapi_client.api.resource_controls_api import
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
from ptctools.portainer_client.openapi_client.models.
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
from ptctools.portainer_client.openapi_client.api.resource_controls_api import (
|
|
23
|
+
ResourceControlsApi,
|
|
24
|
+
)
|
|
25
|
+
from ptctools.portainer_client.openapi_client.models.stacks_swarm_stack_from_file_content_payload import (
|
|
26
|
+
StacksSwarmStackFromFileContentPayload,
|
|
27
|
+
)
|
|
28
|
+
from ptctools.portainer_client.openapi_client.models.stacks_update_swarm_stack_payload import (
|
|
29
|
+
StacksUpdateSwarmStackPayload,
|
|
30
|
+
)
|
|
31
|
+
from ptctools.portainer_client.openapi_client.models.resourcecontrols_resource_control_create_payload import (
|
|
32
|
+
ResourcecontrolsResourceControlCreatePayload,
|
|
33
|
+
)
|
|
34
|
+
from ptctools.portainer_client.openapi_client.models.resourcecontrols_resource_control_update_payload import (
|
|
35
|
+
ResourcecontrolsResourceControlUpdatePayload,
|
|
36
|
+
)
|
|
37
|
+
from ptctools.portainer_client.openapi_client.models.portainer_resource_control_type import (
|
|
38
|
+
PortainerResourceControlType,
|
|
39
|
+
)
|
|
28
40
|
from ptctools.portainer_client.openapi_client.exceptions import ApiException
|
|
29
41
|
|
|
30
42
|
logger = logging.getLogger(__name__)
|
|
@@ -32,6 +44,7 @@ logger = logging.getLogger(__name__)
|
|
|
32
44
|
|
|
33
45
|
class StackError(PortainerError):
|
|
34
46
|
"""Exception for stack operations."""
|
|
47
|
+
|
|
35
48
|
pass
|
|
36
49
|
|
|
37
50
|
|
|
@@ -63,7 +76,13 @@ def cli():
|
|
|
63
76
|
|
|
64
77
|
|
|
65
78
|
@cli.command()
|
|
66
|
-
@click.option(
|
|
79
|
+
@click.option(
|
|
80
|
+
"--url",
|
|
81
|
+
"-u",
|
|
82
|
+
envvar="PORTAINER_URL",
|
|
83
|
+
required=True,
|
|
84
|
+
help="Portainer base URL (or PORTAINER_URL env var)",
|
|
85
|
+
)
|
|
67
86
|
@click.option("--stack-name", "-n", required=True, help="Stack name")
|
|
68
87
|
@click.option(
|
|
69
88
|
"--stack-file",
|
|
@@ -113,11 +132,15 @@ def deploy(
|
|
|
113
132
|
user_teams = []
|
|
114
133
|
for m in memberships:
|
|
115
134
|
if m.team_id:
|
|
116
|
-
user_teams.append(
|
|
117
|
-
|
|
135
|
+
user_teams.append(
|
|
136
|
+
{"Id": m.team_id, "Name": f"Team {m.team_id}"}
|
|
137
|
+
)
|
|
138
|
+
|
|
118
139
|
if user_teams:
|
|
119
140
|
team_id = user_teams[0]["Id"]
|
|
120
|
-
click.echo(
|
|
141
|
+
click.echo(
|
|
142
|
+
f"Auto-detected team: {user_teams[0]['Name']} (ID: {team_id})"
|
|
143
|
+
)
|
|
121
144
|
else:
|
|
122
145
|
click.echo(
|
|
123
146
|
"Error: ownership=team but no team found and --team-id not specified",
|
|
@@ -160,7 +183,9 @@ def deploy(
|
|
|
160
183
|
|
|
161
184
|
if existing_stack_id is not None:
|
|
162
185
|
# Update existing stack
|
|
163
|
-
click.echo(
|
|
186
|
+
click.echo(
|
|
187
|
+
f"Updating existing stack: {stack_name} (ID: {existing_stack_id})..."
|
|
188
|
+
)
|
|
164
189
|
payload = StacksUpdateSwarmStackPayload(
|
|
165
190
|
stack_file_content=stack_content,
|
|
166
191
|
env=env_vars,
|
|
@@ -168,14 +193,18 @@ def deploy(
|
|
|
168
193
|
pull_image=True,
|
|
169
194
|
)
|
|
170
195
|
response = stacks_api.stack_update(
|
|
171
|
-
id=existing_stack_id,
|
|
196
|
+
id=existing_stack_id,
|
|
197
|
+
endpoint_id=endpoint_id,
|
|
198
|
+
body=payload.to_dict(),
|
|
172
199
|
)
|
|
173
200
|
click.echo("Stack updated successfully!")
|
|
174
201
|
click.echo(json.dumps(response.to_dict(), indent=2))
|
|
175
202
|
else:
|
|
176
203
|
# Create new stack - need swarm ID first
|
|
177
204
|
click.echo("Getting swarm ID...")
|
|
178
|
-
docker_client = get_portainer_docker_client(
|
|
205
|
+
docker_client = get_portainer_docker_client(
|
|
206
|
+
portainer_url, access_token, endpoint_id
|
|
207
|
+
)
|
|
179
208
|
swarm_info = docker_client.swarm.attrs
|
|
180
209
|
swarm_id = swarm_info.get("ID") if swarm_info else None
|
|
181
210
|
if not swarm_id:
|
|
@@ -198,7 +227,7 @@ def deploy(
|
|
|
198
227
|
# Apply access control
|
|
199
228
|
if ownership:
|
|
200
229
|
click.echo()
|
|
201
|
-
|
|
230
|
+
|
|
202
231
|
# Get final stack ID
|
|
203
232
|
final_stack_id = None
|
|
204
233
|
stacks = stacks_api.stack_list()
|
|
@@ -221,7 +250,9 @@ def deploy(
|
|
|
221
250
|
teams=[],
|
|
222
251
|
users=[user_id],
|
|
223
252
|
)
|
|
224
|
-
rc_api.resource_control_update(
|
|
253
|
+
rc_api.resource_control_update(
|
|
254
|
+
id=rc.id, body=rc_payload.to_dict()
|
|
255
|
+
)
|
|
225
256
|
click.echo("✓ Access control set to private")
|
|
226
257
|
|
|
227
258
|
elif ownership == "team" and team_id:
|
|
@@ -232,7 +263,9 @@ def deploy(
|
|
|
232
263
|
teams=[team_id],
|
|
233
264
|
users=[],
|
|
234
265
|
)
|
|
235
|
-
rc_api.resource_control_update(
|
|
266
|
+
rc_api.resource_control_update(
|
|
267
|
+
id=rc.id, body=rc_payload.to_dict()
|
|
268
|
+
)
|
|
236
269
|
click.echo(f"✓ Access control set to team {team_id}")
|
|
237
270
|
else:
|
|
238
271
|
rc_payload = ResourcecontrolsResourceControlCreatePayload(
|
|
@@ -253,7 +286,9 @@ def deploy(
|
|
|
253
286
|
teams=[],
|
|
254
287
|
users=[],
|
|
255
288
|
)
|
|
256
|
-
rc_api.resource_control_update(
|
|
289
|
+
rc_api.resource_control_update(
|
|
290
|
+
id=rc.id, body=rc_payload.to_dict()
|
|
291
|
+
)
|
|
257
292
|
click.echo("✓ Access control set to public")
|
|
258
293
|
else:
|
|
259
294
|
rc_payload = ResourcecontrolsResourceControlCreatePayload(
|
|
@@ -272,7 +307,9 @@ def deploy(
|
|
|
272
307
|
|
|
273
308
|
except ApiException as e:
|
|
274
309
|
error_msg = str(e.body) if e.body else str(e.reason)
|
|
275
|
-
logger.error(
|
|
310
|
+
logger.error(
|
|
311
|
+
"Stack operation failed: %s\n%s", error_msg, traceback.format_exc()
|
|
312
|
+
)
|
|
276
313
|
click.echo(f"Error: {error_msg}", err=True)
|
|
277
314
|
click.echo()
|
|
278
315
|
click.echo("Failed!")
|