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 ADDED
@@ -0,0 +1,3 @@
1
+ """Portainer Client Tools - CLI for managing Portainer stacks and backups."""
2
+
3
+ __version__ = "0.1.0"
ptctools/_portainer.py ADDED
@@ -0,0 +1,279 @@
1
+ """
2
+ Common utility functions for Portainer API scripts.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from urllib.request import Request, urlopen
9
+ from urllib.error import HTTPError, URLError
10
+
11
+
12
+ def api_request(
13
+ url: str,
14
+ api_key: str,
15
+ method: str = "GET",
16
+ data: dict | None = None,
17
+ ) -> tuple[dict | list | None, int]:
18
+ """Make an API request to Portainer."""
19
+ headers = {
20
+ "X-API-Key": api_key,
21
+ "Content-Type": "application/json",
22
+ }
23
+
24
+ body = json.dumps(data).encode("utf-8") if data else None
25
+ req = Request(url, data=body, headers=headers, method=method)
26
+
27
+ try:
28
+ with urlopen(req) as response:
29
+ response_body = response.read().decode("utf-8")
30
+ return json.loads(response_body) if response_body else None, response.status
31
+ except HTTPError as e:
32
+ response_body = e.read().decode("utf-8")
33
+ try:
34
+ return json.loads(response_body), e.code
35
+ except json.JSONDecodeError:
36
+ return {"error": response_body}, e.code
37
+ except URLError as e:
38
+ return {"error": str(e.reason)}, 0
39
+
40
+
41
+ def create_container(
42
+ portainer_url: str,
43
+ api_key: str,
44
+ endpoint_id: int,
45
+ config: dict,
46
+ ) -> str | None:
47
+ """Create a container and return its ID."""
48
+ url = f"{portainer_url}/api/endpoints/{endpoint_id}/docker/containers/create"
49
+ response, status_code = api_request(url, api_key, method="POST", data=config)
50
+
51
+ if 200 <= status_code < 300 and response:
52
+ return response.get("Id")
53
+ else:
54
+ print(f"Error creating container (HTTP {status_code}):")
55
+ print(json.dumps(response, indent=2))
56
+ return None
57
+
58
+
59
+ def pull_image(
60
+ portainer_url: str,
61
+ api_key: str,
62
+ endpoint_id: int,
63
+ image: str,
64
+ ) -> bool:
65
+ """Pull a Docker image."""
66
+ from urllib.request import Request, urlopen
67
+ from urllib.parse import quote
68
+
69
+ url = f"{portainer_url}/api/endpoints/{endpoint_id}/docker/images/create?fromImage={quote(image)}"
70
+ headers = {"X-API-Key": api_key}
71
+ req = Request(url, data=b"", headers=headers, method="POST")
72
+
73
+ try:
74
+ with urlopen(req) as response:
75
+ # Read streaming response (Docker sends progress updates)
76
+ response.read()
77
+ return response.status == 200
78
+ except Exception:
79
+ return False
80
+
81
+
82
+ def start_container(
83
+ portainer_url: str,
84
+ api_key: str,
85
+ endpoint_id: int,
86
+ container_id: str,
87
+ ) -> bool:
88
+ """Start a container."""
89
+ url = f"{portainer_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}/start"
90
+ _, status_code = api_request(url, api_key, method="POST")
91
+ return 200 <= status_code < 300
92
+
93
+
94
+ def wait_container(
95
+ portainer_url: str,
96
+ api_key: str,
97
+ endpoint_id: int,
98
+ container_id: str,
99
+ ) -> int:
100
+ """Wait for a container to finish and return exit code."""
101
+ url = f"{portainer_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}/wait"
102
+ response, status_code = api_request(url, api_key, method="POST")
103
+
104
+ if 200 <= status_code < 300 and response:
105
+ return response.get("StatusCode", -1)
106
+ return -1
107
+
108
+
109
+ def get_container_logs(
110
+ portainer_url: str,
111
+ api_key: str,
112
+ endpoint_id: int,
113
+ container_id: str,
114
+ ) -> str:
115
+ """Get container logs."""
116
+ url = f"{portainer_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}/logs?stdout=true&stderr=true"
117
+
118
+ headers = {"X-API-Key": api_key}
119
+ req = Request(url, headers=headers, method="GET")
120
+
121
+ try:
122
+ with urlopen(req) as response:
123
+ # Docker logs have stream headers, strip them
124
+ raw_logs = response.read()
125
+ # Simple cleanup - remove Docker stream headers (8 bytes each)
126
+ logs = ""
127
+ i = 0
128
+ while i < len(raw_logs):
129
+ if i + 8 <= len(raw_logs):
130
+ size = int.from_bytes(raw_logs[i + 4 : i + 8], "big")
131
+ if i + 8 + size <= len(raw_logs):
132
+ logs += raw_logs[i + 8 : i + 8 + size].decode(
133
+ "utf-8", errors="replace"
134
+ )
135
+ i += 8 + size
136
+ continue
137
+ break
138
+ return logs
139
+ except Exception as e:
140
+ return f"Error getting logs: {e}"
141
+
142
+
143
+ def remove_container(
144
+ portainer_url: str,
145
+ api_key: str,
146
+ endpoint_id: int,
147
+ container_id: str,
148
+ ) -> bool:
149
+ """Remove a container."""
150
+ url = f"{portainer_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}?force=true"
151
+ _, status_code = api_request(url, api_key, method="DELETE")
152
+ return 200 <= status_code < 300
153
+
154
+
155
+ def find_container_by_name(
156
+ portainer_url: str,
157
+ api_key: str,
158
+ endpoint_id: int,
159
+ name_filter: str,
160
+ ) -> str | None:
161
+ """Find a running container by name filter and return its ID."""
162
+ url = f'{portainer_url}/api/endpoints/{endpoint_id}/docker/containers/json?filters={{"name":["{name_filter}"]}}'
163
+ response, status_code = api_request(url, api_key)
164
+
165
+ if 200 <= status_code < 300 and isinstance(response, list) and len(response) > 0:
166
+ return response[0].get("Id")
167
+ return None
168
+
169
+
170
+ def get_network_id(
171
+ portainer_url: str,
172
+ api_key: str,
173
+ endpoint_id: int,
174
+ network_name: str,
175
+ ) -> str | None:
176
+ """Find a network by name and return its ID."""
177
+ url = f'{portainer_url}/api/endpoints/{endpoint_id}/docker/networks?filters={{"name":["{network_name}"]}}'
178
+ response, status_code = api_request(url, api_key)
179
+
180
+ if 200 <= status_code < 300 and isinstance(response, list) and len(response) > 0:
181
+ return response[0].get("Id")
182
+ return None
183
+
184
+
185
+ def create_exec(
186
+ portainer_url: str,
187
+ api_key: str,
188
+ endpoint_id: int,
189
+ container_id: str,
190
+ cmd: list[str],
191
+ ) -> str | None:
192
+ """Create an exec instance in a container and return the exec ID."""
193
+ url = f"{portainer_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}/exec"
194
+ config = {
195
+ "AttachStdout": True,
196
+ "AttachStderr": True,
197
+ "Cmd": cmd,
198
+ }
199
+ response, status_code = api_request(url, api_key, method="POST", data=config)
200
+
201
+ if 200 <= status_code < 300 and response:
202
+ return response.get("Id")
203
+ return None
204
+
205
+
206
+ def start_exec(
207
+ portainer_url: str,
208
+ api_key: str,
209
+ endpoint_id: int,
210
+ exec_id: str,
211
+ ) -> tuple[int, str]:
212
+ """Start an exec instance and return (exit_code, output)."""
213
+ from urllib.request import Request, urlopen
214
+
215
+ url = f"{portainer_url}/api/endpoints/{endpoint_id}/docker/exec/{exec_id}/start"
216
+ headers = {
217
+ "X-API-Key": api_key,
218
+ "Content-Type": "application/json",
219
+ }
220
+ body = json.dumps({"Detach": False, "Tty": False}).encode("utf-8")
221
+ req = Request(url, data=body, headers=headers, method="POST")
222
+
223
+ try:
224
+ with urlopen(req) as response:
225
+ # Read raw output (has Docker stream headers like logs)
226
+ raw_output = response.read()
227
+ # Strip Docker stream headers
228
+ output = ""
229
+ i = 0
230
+ while i < len(raw_output):
231
+ if i + 8 <= len(raw_output):
232
+ size = int.from_bytes(raw_output[i + 4 : i + 8], "big")
233
+ if i + 8 + size <= len(raw_output):
234
+ output += raw_output[i + 8 : i + 8 + size].decode(
235
+ "utf-8", errors="replace"
236
+ )
237
+ i += 8 + size
238
+ continue
239
+ break
240
+ except Exception as e:
241
+ return -1, f"Error: {e}"
242
+
243
+ # Get exec exit code
244
+ inspect_url = (
245
+ f"{portainer_url}/api/endpoints/{endpoint_id}/docker/exec/{exec_id}/json"
246
+ )
247
+ inspect_resp, _ = api_request(inspect_url, api_key)
248
+ exit_code = inspect_resp.get("ExitCode", -1) if inspect_resp else -1
249
+
250
+ return exit_code, output
251
+
252
+
253
+ def run_ephemeral_container(
254
+ portainer_url: str,
255
+ api_key: str,
256
+ endpoint_id: int,
257
+ config: dict,
258
+ pull: bool = True,
259
+ ) -> tuple[int, str]:
260
+ """Run an ephemeral container and return (exit_code, logs)."""
261
+ image = config.get("Image")
262
+ if pull and image:
263
+ if not pull_image(portainer_url, api_key, endpoint_id, image):
264
+ print(f"Warning: Failed to pull image {image}, trying to run anyway...")
265
+
266
+ container_id = create_container(portainer_url, api_key, endpoint_id, config)
267
+ if not container_id:
268
+ return -1, "Failed to create container"
269
+
270
+ try:
271
+ if not start_container(portainer_url, api_key, endpoint_id, container_id):
272
+ return -1, "Failed to start container"
273
+
274
+ exit_code = wait_container(portainer_url, api_key, endpoint_id, container_id)
275
+ logs = get_container_logs(portainer_url, api_key, endpoint_id, container_id)
276
+
277
+ return exit_code, logs
278
+ finally:
279
+ remove_container(portainer_url, api_key, endpoint_id, container_id)
ptctools/_s3.py ADDED
@@ -0,0 +1,150 @@
1
+ """S3 utility functions for URI parsing and operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from urllib.parse import urlparse
8
+
9
+ import click
10
+
11
+
12
+ def parse_s3_uri(uri: str) -> tuple[str | None, str, str]:
13
+ """Parse S3 URI into (endpoint, bucket, path).
14
+
15
+ Supports formats:
16
+ - s3://bucket/path
17
+ - s3://bucket/path?s3-server-name=endpoint
18
+ - s3://endpoint.com/bucket/path (endpoint detected by dots in first segment)
19
+ - https://endpoint.com/bucket/path
20
+
21
+ Returns:
22
+ Tuple of (endpoint or None, bucket, path)
23
+ - endpoint: The S3 endpoint URL or None if not specified
24
+ - bucket: The bucket name
25
+ - path: The object path within the bucket (may be empty)
26
+ """
27
+ if not uri:
28
+ raise click.ClickException("Empty S3 URI")
29
+
30
+ # Handle https:// or http:// scheme
31
+ if uri.startswith("https://") or uri.startswith("http://"):
32
+ parsed = urlparse(uri)
33
+ endpoint = f"{parsed.scheme}://{parsed.netloc}"
34
+ path_parts = parsed.path.strip("/").split("/", 1)
35
+ bucket = path_parts[0] if path_parts else ""
36
+ path = path_parts[1] if len(path_parts) > 1 else ""
37
+
38
+ if not bucket:
39
+ raise click.ClickException(f"Could not parse bucket from URI: {uri}")
40
+
41
+ return endpoint, bucket, path
42
+
43
+ # Handle s3:// scheme
44
+ if not uri.startswith("s3://"):
45
+ raise click.ClickException(
46
+ f"Invalid S3 URI format: {uri}. Expected s3://bucket/path or https://endpoint/bucket/path"
47
+ )
48
+
49
+ rest = uri[5:] # Remove "s3://"
50
+
51
+ # Check for query parameter format: s3://bucket/path?s3-server-name=endpoint
52
+ if "?s3-server-name=" in rest:
53
+ match = re.match(r"([^/]+)/(.+?)\?s3-server-name=(.+)$", rest)
54
+ if match:
55
+ bucket = match.group(1)
56
+ path = match.group(2)
57
+ endpoint = match.group(3)
58
+ # Add https:// if not present
59
+ if not endpoint.startswith("http://") and not endpoint.startswith("https://"):
60
+ endpoint = f"https://{endpoint}"
61
+ return endpoint, bucket, path
62
+
63
+ # Split by first slash
64
+ parts = rest.split("/", 1)
65
+ first_part = parts[0]
66
+ remaining = parts[1] if len(parts) > 1 else ""
67
+
68
+ # Check if first part looks like an endpoint (has dots) or just bucket name
69
+ if "." in first_part:
70
+ # It's an endpoint like s3://minio.example.com/bucket/path
71
+ endpoint = f"https://{first_part}"
72
+ # remaining is now bucket/path
73
+ sub_parts = remaining.split("/", 1)
74
+ bucket = sub_parts[0] if sub_parts else ""
75
+ path = sub_parts[1] if len(sub_parts) > 1 else ""
76
+ else:
77
+ # It's just a bucket name like s3://bucket/path
78
+ endpoint = None
79
+ bucket = first_part
80
+ path = remaining
81
+
82
+ if not bucket:
83
+ raise click.ClickException(f"Could not parse bucket from S3 URI: {uri}")
84
+
85
+ return endpoint, bucket, path
86
+
87
+
88
+ def is_s3_uri(path: str) -> bool:
89
+ """Check if path is an S3 URI."""
90
+ return path.startswith("s3://") or path.startswith("https://") or path.startswith("http://")
91
+
92
+
93
+ def get_s3_endpoint(uri_endpoint: str | None, cli_endpoint: str | None = None) -> str:
94
+ """Get S3 endpoint from various sources.
95
+
96
+ Priority: CLI argument > URI embedded > environment variable
97
+
98
+ Args:
99
+ uri_endpoint: Endpoint parsed from URI (may be None)
100
+ cli_endpoint: Endpoint from CLI argument (may be None)
101
+
102
+ Returns:
103
+ The resolved endpoint URL
104
+
105
+ Raises:
106
+ ClickException if no endpoint can be resolved
107
+ """
108
+ endpoint = cli_endpoint or uri_endpoint or os.environ.get("S3_ENDPOINT")
109
+ if not endpoint:
110
+ raise click.ClickException(
111
+ "S3 endpoint required. Use --s3-endpoint, embed in URI (s3://endpoint/bucket), "
112
+ "or set S3_ENDPOINT environment variable"
113
+ )
114
+ return endpoint
115
+
116
+
117
+ def get_s3_credentials() -> tuple[str, str]:
118
+ """Get S3 credentials from environment variables.
119
+
120
+ Returns:
121
+ Tuple of (access_key, secret_key)
122
+
123
+ Raises:
124
+ ClickException if credentials are missing
125
+ """
126
+ s3_access_key = os.environ.get("S3_ACCESS_KEY")
127
+ s3_secret_key = os.environ.get("S3_SECRET_KEY")
128
+ if not s3_access_key or not s3_secret_key:
129
+ raise click.ClickException(
130
+ "Missing S3_ACCESS_KEY or S3_SECRET_KEY environment variables"
131
+ )
132
+ return s3_access_key, s3_secret_key
133
+
134
+
135
+ def build_duplicati_s3_url(bucket: str, path: str, endpoint: str) -> str:
136
+ """Build Duplicati-compatible S3 URL with query parameter.
137
+
138
+ Args:
139
+ bucket: S3 bucket name
140
+ path: Object path within bucket
141
+ endpoint: S3 endpoint URL
142
+
143
+ Returns:
144
+ Duplicati-compatible S3 URL like: s3://bucket/path?s3-server-name=host
145
+ """
146
+ s3_host = endpoint.replace("https://", "").replace("http://", "").rstrip("/")
147
+ if path:
148
+ return f"s3://{bucket}/{path}?s3-server-name={s3_host}"
149
+ else:
150
+ return f"s3://{bucket}?s3-server-name={s3_host}"
ptctools/cli.py ADDED
@@ -0,0 +1,28 @@
1
+ """Main CLI entry point for ptctools."""
2
+
3
+ import click
4
+
5
+ from ptctools import __version__
6
+ from ptctools import stack
7
+ from ptctools import volume
8
+ from ptctools import db
9
+ from ptctools import utils
10
+ from ptctools import config
11
+
12
+
13
+ @click.group()
14
+ @click.version_option(version=__version__)
15
+ def main():
16
+ """Portainer Client Tools - manage stacks, volumes, and databases."""
17
+ pass
18
+
19
+
20
+ main.add_command(stack.cli, name="stack")
21
+ main.add_command(volume.cli, name="volume")
22
+ main.add_command(db.cli, name="db")
23
+ main.add_command(utils.cli, name="utils")
24
+ main.add_command(config.cli, name="config")
25
+
26
+
27
+ if __name__ == "__main__":
28
+ main()