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/config.py ADDED
@@ -0,0 +1,293 @@
1
+ """Docker Swarm Config management commands via Portainer API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import click
12
+
13
+ from ptctools._portainer import api_request
14
+
15
+
16
+ def list_configs(
17
+ portainer_url: str,
18
+ api_key: str,
19
+ endpoint_id: int,
20
+ ) -> tuple[list | None, int]:
21
+ """List all Docker configs."""
22
+ url = f"{portainer_url}/api/endpoints/{endpoint_id}/docker/configs"
23
+ return api_request(url, api_key)
24
+
25
+
26
+ def get_config(
27
+ portainer_url: str,
28
+ api_key: str,
29
+ endpoint_id: int,
30
+ config_id: str,
31
+ ) -> tuple[dict | None, int]:
32
+ """Get a specific Docker config by ID."""
33
+ url = f"{portainer_url}/api/endpoints/{endpoint_id}/docker/configs/{config_id}"
34
+ return api_request(url, api_key)
35
+
36
+
37
+ def create_config(
38
+ portainer_url: str,
39
+ api_key: str,
40
+ endpoint_id: int,
41
+ name: str,
42
+ data: str,
43
+ labels: dict | None = None,
44
+ ) -> tuple[dict | None, int]:
45
+ """Create a new Docker config."""
46
+ url = f"{portainer_url}/api/endpoints/{endpoint_id}/docker/configs/create"
47
+
48
+ # Docker API requires base64 encoded data
49
+ encoded_data = base64.b64encode(data.encode("utf-8")).decode("utf-8")
50
+
51
+ payload = {
52
+ "Name": name,
53
+ "Data": encoded_data,
54
+ }
55
+
56
+ if labels:
57
+ payload["Labels"] = labels
58
+
59
+ return api_request(url, api_key, method="POST", data=payload)
60
+
61
+
62
+ def delete_config(
63
+ portainer_url: str,
64
+ api_key: str,
65
+ endpoint_id: int,
66
+ config_id: str,
67
+ ) -> tuple[dict | None, int]:
68
+ """Delete a Docker config."""
69
+ url = f"{portainer_url}/api/endpoints/{endpoint_id}/docker/configs/{config_id}"
70
+ return api_request(url, api_key, method="DELETE")
71
+
72
+
73
+ def find_config_by_name(
74
+ portainer_url: str,
75
+ api_key: str,
76
+ endpoint_id: int,
77
+ name: str,
78
+ ) -> dict | None:
79
+ """Find a config by name and return its details."""
80
+ configs, status_code = list_configs(portainer_url, api_key, endpoint_id)
81
+
82
+ if status_code != 200 or not isinstance(configs, list):
83
+ return None
84
+
85
+ for config in configs:
86
+ spec = config.get("Spec", {})
87
+ if spec.get("Name") == name:
88
+ return config
89
+
90
+ return None
91
+
92
+
93
+ @click.group()
94
+ def cli():
95
+ """Docker Swarm Config management commands."""
96
+ pass
97
+
98
+
99
+ @cli.command("set")
100
+ @click.option("--url", "-u", required=True, help="Portainer base URL")
101
+ @click.option("--name", "-n", required=True, help="Config name")
102
+ @click.option("--data", "-d", default=None, help="Config data (string content)")
103
+ @click.option(
104
+ "--file",
105
+ "-f",
106
+ "file_path",
107
+ type=click.Path(exists=True, path_type=Path),
108
+ default=None,
109
+ help="Read config data from file",
110
+ )
111
+ @click.option("--endpoint-id", "-e", type=int, default=1, help="Portainer endpoint ID")
112
+ @click.option(
113
+ "--force", is_flag=True, help="Replace existing config if it exists"
114
+ )
115
+ def set_config(
116
+ url: str,
117
+ name: str,
118
+ data: str | None,
119
+ file_path: Path | None,
120
+ endpoint_id: int,
121
+ force: bool,
122
+ ):
123
+ """Create or update a Docker Swarm config.
124
+
125
+ Examples:
126
+
127
+ # Create from inline data
128
+ ptctools config set -u https://portainer.example.com -n my-config -d "config content"
129
+
130
+ # Create from file
131
+ ptctools config set -u https://portainer.example.com -n nginx.conf -f ./nginx.conf
132
+
133
+ # Replace existing config
134
+ ptctools config set -u https://portainer.example.com -n my-config -d "new content" --force
135
+ """
136
+ # Validate that exactly one of --data or --file is provided
137
+ if data is None and file_path is None:
138
+ click.echo("Error: Either --data or --file must be provided", err=True)
139
+ sys.exit(1)
140
+ if data is not None and file_path is not None:
141
+ click.echo("Error: Cannot use both --data and --file", err=True)
142
+ sys.exit(1)
143
+
144
+ # Read data from file if provided
145
+ if file_path is not None:
146
+ data = file_path.read_text()
147
+
148
+ access_token = os.environ.get("PORTAINER_ACCESS_TOKEN")
149
+ if not access_token:
150
+ click.echo(
151
+ "Error: Missing PORTAINER_ACCESS_TOKEN environment variable", err=True
152
+ )
153
+ sys.exit(1)
154
+
155
+ portainer_url = url.rstrip("/")
156
+
157
+ # Check if config already exists
158
+ existing = find_config_by_name(portainer_url, access_token, endpoint_id, name)
159
+
160
+ if existing:
161
+ if not force:
162
+ click.echo(
163
+ f"Error: Config '{name}' already exists. Use --force to replace.",
164
+ err=True,
165
+ )
166
+ sys.exit(1)
167
+
168
+ # Delete existing config first (Docker doesn't support updating configs)
169
+ # Docker Swarm configs are immutable - no update API exists.
170
+ # The only way to change a config is to delete and recreate it.
171
+ click.echo(f"Removing existing config: {name}")
172
+ config_id = existing.get("ID")
173
+ _, status_code = delete_config(
174
+ portainer_url, access_token, endpoint_id, config_id
175
+ )
176
+ if status_code not in (200, 204):
177
+ click.echo(
178
+ f"Error: Failed to delete existing config (HTTP {status_code})",
179
+ err=True,
180
+ )
181
+ sys.exit(1)
182
+
183
+ click.echo(f"Creating config: {name}")
184
+ response, status_code = create_config(
185
+ portainer_url, access_token, endpoint_id, name, data
186
+ )
187
+
188
+ if 200 <= status_code < 300:
189
+ config_id = response.get("ID") if response else "unknown"
190
+ click.echo(f"✓ Config '{name}' created successfully (ID: {config_id})")
191
+ else:
192
+ click.echo(f"Error: Failed to create config (HTTP {status_code})", err=True)
193
+ if response:
194
+ click.echo(json.dumps(response, indent=2), err=True)
195
+ sys.exit(1)
196
+
197
+
198
+ @cli.command("get")
199
+ @click.option("--url", "-u", required=True, help="Portainer base URL")
200
+ @click.option("--name", "-n", required=True, help="Config name")
201
+ @click.option("--endpoint-id", "-e", type=int, default=1, help="Portainer endpoint ID")
202
+ def get_config_cmd(url: str, name: str, endpoint_id: int):
203
+ """Get details of a Docker Swarm config by name.
204
+
205
+ Note: Docker API does not return the actual config data for security reasons.
206
+ """
207
+ access_token = os.environ.get("PORTAINER_ACCESS_TOKEN")
208
+ if not access_token:
209
+ click.echo(
210
+ "Error: Missing PORTAINER_ACCESS_TOKEN environment variable", err=True
211
+ )
212
+ sys.exit(1)
213
+
214
+ portainer_url = url.rstrip("/")
215
+
216
+ config = find_config_by_name(portainer_url, access_token, endpoint_id, name)
217
+
218
+ if config:
219
+ click.echo(json.dumps(config, indent=2))
220
+ else:
221
+ click.echo(f"Error: Config '{name}' not found", err=True)
222
+ sys.exit(1)
223
+
224
+
225
+ @cli.command("list")
226
+ @click.option("--url", "-u", required=True, help="Portainer base URL")
227
+ @click.option("--endpoint-id", "-e", type=int, default=1, help="Portainer endpoint ID")
228
+ def list_configs_cmd(url: str, endpoint_id: int):
229
+ """List all Docker Swarm configs."""
230
+ access_token = os.environ.get("PORTAINER_ACCESS_TOKEN")
231
+ if not access_token:
232
+ click.echo(
233
+ "Error: Missing PORTAINER_ACCESS_TOKEN environment variable", err=True
234
+ )
235
+ sys.exit(1)
236
+
237
+ portainer_url = url.rstrip("/")
238
+
239
+ configs, status_code = list_configs(portainer_url, access_token, endpoint_id)
240
+
241
+ if status_code != 200:
242
+ click.echo(f"Error: Failed to list configs (HTTP {status_code})", err=True)
243
+ if configs:
244
+ click.echo(json.dumps(configs, indent=2), err=True)
245
+ sys.exit(1)
246
+
247
+ if not configs:
248
+ click.echo("No configs found.")
249
+ return
250
+
251
+ click.echo(f"Found {len(configs)} config(s):\n")
252
+ for config in configs:
253
+ spec = config.get("Spec", {})
254
+ config_id = config.get("ID", "unknown")
255
+ config_name = spec.get("Name", "unknown")
256
+ created = config.get("CreatedAt", "unknown")
257
+ click.echo(f" {config_name}")
258
+ click.echo(f" ID: {config_id}")
259
+ click.echo(f" Created: {created}")
260
+ click.echo()
261
+
262
+
263
+ @cli.command("delete")
264
+ @click.option("--url", "-u", required=True, help="Portainer base URL")
265
+ @click.option("--name", "-n", required=True, help="Config name")
266
+ @click.option("--endpoint-id", "-e", type=int, default=1, help="Portainer endpoint ID")
267
+ def delete_config_cmd(url: str, name: str, endpoint_id: int):
268
+ """Delete a Docker Swarm config by name."""
269
+ access_token = os.environ.get("PORTAINER_ACCESS_TOKEN")
270
+ if not access_token:
271
+ click.echo(
272
+ "Error: Missing PORTAINER_ACCESS_TOKEN environment variable", err=True
273
+ )
274
+ sys.exit(1)
275
+
276
+ portainer_url = url.rstrip("/")
277
+
278
+ config = find_config_by_name(portainer_url, access_token, endpoint_id, name)
279
+
280
+ if not config:
281
+ click.echo(f"Error: Config '{name}' not found", err=True)
282
+ sys.exit(1)
283
+
284
+ config_id = config.get("ID")
285
+ click.echo(f"Deleting config: {name} (ID: {config_id})")
286
+
287
+ _, status_code = delete_config(portainer_url, access_token, endpoint_id, config_id)
288
+
289
+ if status_code in (200, 204):
290
+ click.echo(f"✓ Config '{name}' deleted successfully")
291
+ else:
292
+ click.echo(f"Error: Failed to delete config (HTTP {status_code})", err=True)
293
+ sys.exit(1)