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/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)