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/stack.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Stack management commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from ptctools._portainer import api_request
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_stack_env_file(env_path: Path) -> list[dict]:
|
|
16
|
+
"""Parse .env file into Portainer environment variable format."""
|
|
17
|
+
env_vars = []
|
|
18
|
+
|
|
19
|
+
if not env_path.exists():
|
|
20
|
+
return env_vars
|
|
21
|
+
|
|
22
|
+
with open(env_path, "r") as f:
|
|
23
|
+
for line in f:
|
|
24
|
+
line = line.strip()
|
|
25
|
+
if not line or line.startswith("#"):
|
|
26
|
+
continue
|
|
27
|
+
if "=" in line:
|
|
28
|
+
name, value = line.split("=", 1)
|
|
29
|
+
name = name.strip()
|
|
30
|
+
if name:
|
|
31
|
+
env_vars.append({"name": name, "value": value})
|
|
32
|
+
|
|
33
|
+
return env_vars
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_swarm_id(portainer_url: str, api_key: str, endpoint_id: int) -> str | None:
|
|
37
|
+
"""Get swarm ID from the endpoint."""
|
|
38
|
+
url = f"{portainer_url}/api/endpoints/{endpoint_id}/docker/swarm"
|
|
39
|
+
swarm_info, status_code = api_request(url, api_key)
|
|
40
|
+
|
|
41
|
+
if status_code == 200 and isinstance(swarm_info, dict):
|
|
42
|
+
return swarm_info.get("ID")
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_stack_id(portainer_url: str, api_key: str, stack_name: str) -> int | None:
|
|
47
|
+
"""Get stack ID by name, returns None if not found."""
|
|
48
|
+
url = f"{portainer_url}/api/stacks"
|
|
49
|
+
stacks, status_code = api_request(url, api_key)
|
|
50
|
+
|
|
51
|
+
if status_code != 200 or not isinstance(stacks, list):
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
for stack in stacks:
|
|
55
|
+
if stack.get("Name") == stack_name:
|
|
56
|
+
return stack.get("Id")
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_user_teams(portainer_url: str, api_key: str) -> list[dict]:
|
|
61
|
+
"""Get teams that the current user belongs to."""
|
|
62
|
+
url = f"{portainer_url}/api/users/me"
|
|
63
|
+
user_info, status_code = api_request(url, api_key)
|
|
64
|
+
|
|
65
|
+
if status_code != 200 or not user_info:
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
user_id = user_info.get("Id")
|
|
69
|
+
if not user_id:
|
|
70
|
+
return []
|
|
71
|
+
|
|
72
|
+
url = f"{portainer_url}/api/users/{user_id}/memberships"
|
|
73
|
+
memberships, status_code = api_request(url, api_key)
|
|
74
|
+
|
|
75
|
+
if status_code != 200 or not isinstance(memberships, list):
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
teams = []
|
|
79
|
+
for m in memberships:
|
|
80
|
+
team_id = m.get("TeamID")
|
|
81
|
+
if team_id:
|
|
82
|
+
teams.append({"Id": team_id, "Name": f"Team {team_id}"})
|
|
83
|
+
|
|
84
|
+
return teams
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def create_stack(
|
|
88
|
+
portainer_url: str,
|
|
89
|
+
api_key: str,
|
|
90
|
+
endpoint_id: int,
|
|
91
|
+
stack_name: str,
|
|
92
|
+
stack_content: str,
|
|
93
|
+
env_vars: list[dict],
|
|
94
|
+
swarm_id: str,
|
|
95
|
+
) -> bool:
|
|
96
|
+
"""Create a new stack in Portainer."""
|
|
97
|
+
click.echo(f"Creating new stack: {stack_name}...")
|
|
98
|
+
|
|
99
|
+
url = f"{portainer_url}/api/stacks/create/swarm/string?endpointId={endpoint_id}"
|
|
100
|
+
data = {
|
|
101
|
+
"name": stack_name,
|
|
102
|
+
"stackFileContent": stack_content,
|
|
103
|
+
"env": env_vars,
|
|
104
|
+
"swarmID": swarm_id,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
response, status_code = api_request(url, api_key, method="POST", data=data)
|
|
108
|
+
|
|
109
|
+
if 200 <= status_code < 300:
|
|
110
|
+
click.echo("Stack created successfully!")
|
|
111
|
+
click.echo(json.dumps(response, indent=2))
|
|
112
|
+
return True
|
|
113
|
+
else:
|
|
114
|
+
click.echo(f"Error creating stack (HTTP {status_code}):")
|
|
115
|
+
click.echo(json.dumps(response, indent=2))
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def update_stack(
|
|
120
|
+
portainer_url: str,
|
|
121
|
+
api_key: str,
|
|
122
|
+
endpoint_id: int,
|
|
123
|
+
stack_id: int,
|
|
124
|
+
stack_name: str,
|
|
125
|
+
stack_content: str,
|
|
126
|
+
env_vars: list[dict],
|
|
127
|
+
) -> bool:
|
|
128
|
+
"""Update an existing stack in Portainer."""
|
|
129
|
+
click.echo(f"Updating existing stack: {stack_name} (ID: {stack_id})...")
|
|
130
|
+
|
|
131
|
+
url = f"{portainer_url}/api/stacks/{stack_id}?endpointId={endpoint_id}"
|
|
132
|
+
data = {
|
|
133
|
+
"stackFileContent": stack_content,
|
|
134
|
+
"env": env_vars,
|
|
135
|
+
"prune": True,
|
|
136
|
+
"pullImage": True,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
response, status_code = api_request(url, api_key, method="PUT", data=data)
|
|
140
|
+
|
|
141
|
+
if 200 <= status_code < 300:
|
|
142
|
+
click.echo("Stack updated successfully!")
|
|
143
|
+
click.echo(json.dumps(response, indent=2))
|
|
144
|
+
return True
|
|
145
|
+
else:
|
|
146
|
+
click.echo(f"Error updating stack (HTTP {status_code}):")
|
|
147
|
+
click.echo(json.dumps(response, indent=2))
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def update_resource_control(
|
|
152
|
+
portainer_url: str,
|
|
153
|
+
api_key: str,
|
|
154
|
+
resource_control_id: int,
|
|
155
|
+
public: bool = False,
|
|
156
|
+
team_ids: list[int] | None = None,
|
|
157
|
+
user_ids: list[int] | None = None,
|
|
158
|
+
) -> bool:
|
|
159
|
+
"""Update resource control with specified access settings."""
|
|
160
|
+
url = f"{portainer_url}/api/resource_controls/{resource_control_id}"
|
|
161
|
+
data = {
|
|
162
|
+
"Public": public,
|
|
163
|
+
"Teams": team_ids or [],
|
|
164
|
+
"Users": user_ids or [],
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
_, status_code = api_request(url, api_key, method="PUT", data=data)
|
|
168
|
+
return 200 <= status_code < 300
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def create_resource_control(
|
|
172
|
+
portainer_url: str,
|
|
173
|
+
api_key: str,
|
|
174
|
+
stack_id: int,
|
|
175
|
+
public: bool = False,
|
|
176
|
+
team_ids: list[int] | None = None,
|
|
177
|
+
user_ids: list[int] | None = None,
|
|
178
|
+
) -> bool:
|
|
179
|
+
"""Create resource control for a stack with specified access settings."""
|
|
180
|
+
url = f"{portainer_url}/api/resource_controls"
|
|
181
|
+
data = {
|
|
182
|
+
"Type": "stack",
|
|
183
|
+
"ResourceID": str(stack_id),
|
|
184
|
+
"Public": public,
|
|
185
|
+
"Teams": team_ids or [],
|
|
186
|
+
"Users": user_ids or [],
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
_, status_code = api_request(url, api_key, method="POST", data=data)
|
|
190
|
+
return 200 <= status_code < 300
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@click.group()
|
|
194
|
+
def cli():
|
|
195
|
+
"""Stack management commands."""
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@cli.command()
|
|
200
|
+
@click.option("--url", "-u", required=True, help="Portainer base URL")
|
|
201
|
+
@click.option("--stack-name", "-n", required=True, help="Stack name")
|
|
202
|
+
@click.option(
|
|
203
|
+
"--stack-file",
|
|
204
|
+
"-f",
|
|
205
|
+
required=True,
|
|
206
|
+
type=click.Path(exists=True, path_type=Path),
|
|
207
|
+
help="Path to compose file",
|
|
208
|
+
)
|
|
209
|
+
@click.option("--endpoint-id", "-e", type=int, default=1, help="Portainer endpoint ID")
|
|
210
|
+
@click.option(
|
|
211
|
+
"--env-file", type=click.Path(path_type=Path), help="Path to stack .env file"
|
|
212
|
+
)
|
|
213
|
+
@click.option(
|
|
214
|
+
"--ownership",
|
|
215
|
+
type=click.Choice(["private", "team", "public"]),
|
|
216
|
+
help="Access control",
|
|
217
|
+
)
|
|
218
|
+
@click.option("--team-id", "-t", type=int, help="Team ID for team ownership")
|
|
219
|
+
def deploy(
|
|
220
|
+
url: str,
|
|
221
|
+
stack_name: str,
|
|
222
|
+
stack_file: Path,
|
|
223
|
+
endpoint_id: int,
|
|
224
|
+
env_file: Path | None,
|
|
225
|
+
ownership: str | None,
|
|
226
|
+
team_id: int | None,
|
|
227
|
+
):
|
|
228
|
+
"""Deploy or update a stack in Portainer."""
|
|
229
|
+
access_token = os.environ.get("PORTAINER_ACCESS_TOKEN")
|
|
230
|
+
if not access_token:
|
|
231
|
+
click.echo(
|
|
232
|
+
"Error: Missing PORTAINER_ACCESS_TOKEN environment variable", err=True
|
|
233
|
+
)
|
|
234
|
+
sys.exit(1)
|
|
235
|
+
|
|
236
|
+
portainer_url = url.rstrip("/")
|
|
237
|
+
stack_file = stack_file.resolve()
|
|
238
|
+
env_file = env_file.resolve() if env_file else stack_file.parent / ".env"
|
|
239
|
+
|
|
240
|
+
# Auto-detect team if needed
|
|
241
|
+
if ownership == "team" and not team_id:
|
|
242
|
+
user_teams = get_user_teams(portainer_url, access_token)
|
|
243
|
+
if user_teams:
|
|
244
|
+
team_id = user_teams[0]["Id"]
|
|
245
|
+
click.echo(f"Auto-detected team: {user_teams[0]['Name']} (ID: {team_id})")
|
|
246
|
+
else:
|
|
247
|
+
click.echo(
|
|
248
|
+
"Error: ownership=team but no team found and --team-id not specified",
|
|
249
|
+
err=True,
|
|
250
|
+
)
|
|
251
|
+
sys.exit(1)
|
|
252
|
+
|
|
253
|
+
stack_content = stack_file.read_text()
|
|
254
|
+
env_vars = load_stack_env_file(env_file)
|
|
255
|
+
|
|
256
|
+
click.echo(f"Portainer URL: {portainer_url}")
|
|
257
|
+
click.echo(f"Stack Name: {stack_name}")
|
|
258
|
+
click.echo(f"Stack File: {stack_file}")
|
|
259
|
+
click.echo(f"Endpoint ID: {endpoint_id}")
|
|
260
|
+
click.echo(f"Environment Variables: {len(env_vars)} loaded")
|
|
261
|
+
if ownership:
|
|
262
|
+
click.echo(f"Ownership: {ownership}")
|
|
263
|
+
if ownership == "team":
|
|
264
|
+
click.echo(f"Team ID: {team_id}")
|
|
265
|
+
click.echo()
|
|
266
|
+
|
|
267
|
+
# Check if stack exists
|
|
268
|
+
click.echo("Checking if stack already exists...")
|
|
269
|
+
existing_stack_id = get_stack_id(portainer_url, access_token, stack_name)
|
|
270
|
+
|
|
271
|
+
if existing_stack_id is not None:
|
|
272
|
+
success = update_stack(
|
|
273
|
+
portainer_url,
|
|
274
|
+
access_token,
|
|
275
|
+
endpoint_id,
|
|
276
|
+
existing_stack_id,
|
|
277
|
+
stack_name,
|
|
278
|
+
stack_content,
|
|
279
|
+
env_vars,
|
|
280
|
+
)
|
|
281
|
+
else:
|
|
282
|
+
click.echo("Getting swarm ID...")
|
|
283
|
+
swarm_id = get_swarm_id(portainer_url, access_token, endpoint_id)
|
|
284
|
+
if not swarm_id:
|
|
285
|
+
click.echo("Error: Could not get swarm ID from endpoint", err=True)
|
|
286
|
+
sys.exit(1)
|
|
287
|
+
click.echo(f"Swarm ID: {swarm_id}")
|
|
288
|
+
|
|
289
|
+
success = create_stack(
|
|
290
|
+
portainer_url,
|
|
291
|
+
access_token,
|
|
292
|
+
endpoint_id,
|
|
293
|
+
stack_name,
|
|
294
|
+
stack_content,
|
|
295
|
+
env_vars,
|
|
296
|
+
swarm_id,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# Apply access control
|
|
300
|
+
if success and ownership:
|
|
301
|
+
click.echo()
|
|
302
|
+
final_stack_id = get_stack_id(portainer_url, access_token, stack_name)
|
|
303
|
+
if final_stack_id:
|
|
304
|
+
stack_url = f"{portainer_url}/api/stacks/{final_stack_id}"
|
|
305
|
+
stack_info, status_code = api_request(stack_url, access_token)
|
|
306
|
+
if status_code == 200 and stack_info:
|
|
307
|
+
rc = stack_info.get("ResourceControl")
|
|
308
|
+
|
|
309
|
+
if ownership == "private":
|
|
310
|
+
if rc and rc.get("Id"):
|
|
311
|
+
user_url = f"{portainer_url}/api/users/me"
|
|
312
|
+
user_info, _ = api_request(user_url, access_token)
|
|
313
|
+
user_id = user_info.get("Id") if user_info else None
|
|
314
|
+
if user_id:
|
|
315
|
+
click.echo("Setting access control to private...")
|
|
316
|
+
if update_resource_control(
|
|
317
|
+
portainer_url,
|
|
318
|
+
access_token,
|
|
319
|
+
rc["Id"],
|
|
320
|
+
user_ids=[user_id],
|
|
321
|
+
):
|
|
322
|
+
click.echo("✓ Access control set to private")
|
|
323
|
+
else:
|
|
324
|
+
click.echo("Warning: Failed to set private access")
|
|
325
|
+
|
|
326
|
+
elif ownership == "team" and team_id:
|
|
327
|
+
click.echo(f"Setting access control for team {team_id}...")
|
|
328
|
+
if rc and rc.get("Id"):
|
|
329
|
+
if update_resource_control(
|
|
330
|
+
portainer_url, access_token, rc["Id"], team_ids=[team_id]
|
|
331
|
+
):
|
|
332
|
+
click.echo(f"✓ Access control set to team {team_id}")
|
|
333
|
+
else:
|
|
334
|
+
click.echo("Warning: Failed to update access control")
|
|
335
|
+
else:
|
|
336
|
+
if create_resource_control(
|
|
337
|
+
portainer_url,
|
|
338
|
+
access_token,
|
|
339
|
+
final_stack_id,
|
|
340
|
+
team_ids=[team_id],
|
|
341
|
+
):
|
|
342
|
+
click.echo(f"✓ Access control created for team {team_id}")
|
|
343
|
+
else:
|
|
344
|
+
click.echo("Warning: Failed to create access control")
|
|
345
|
+
|
|
346
|
+
elif ownership == "public":
|
|
347
|
+
click.echo("Setting access control to public...")
|
|
348
|
+
if rc and rc.get("Id"):
|
|
349
|
+
if update_resource_control(
|
|
350
|
+
portainer_url, access_token, rc["Id"], public=True
|
|
351
|
+
):
|
|
352
|
+
click.echo("✓ Access control set to public")
|
|
353
|
+
else:
|
|
354
|
+
click.echo("Warning: Failed to set public access")
|
|
355
|
+
else:
|
|
356
|
+
if create_resource_control(
|
|
357
|
+
portainer_url, access_token, final_stack_id, public=True
|
|
358
|
+
):
|
|
359
|
+
click.echo("✓ Access control created as public")
|
|
360
|
+
else:
|
|
361
|
+
click.echo(
|
|
362
|
+
"Warning: Failed to create public access control"
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
click.echo()
|
|
366
|
+
click.echo("Done!" if success else "Failed!")
|
|
367
|
+
sys.exit(0 if success else 1)
|