docker-stack 2.2.0__tar.gz → 2.2.1__tar.gz
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.
- {docker_stack-2.2.0 → docker_stack-2.2.1}/PKG-INFO +1 -1
- {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/cli.py +153 -29
- {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack.egg-info/PKG-INFO +1 -1
- {docker_stack-2.2.0 → docker_stack-2.2.1}/setup.py +1 -1
- {docker_stack-2.2.0 → docker_stack-2.2.1}/tests/test_docker_stack.py +43 -0
- {docker_stack-2.2.0 → docker_stack-2.2.1}/README.md +0 -0
- {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/__init__.py +0 -0
- {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/command_runner.py +0 -0
- {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/compose.py +0 -0
- {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/docker_objects.py +0 -0
- {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/envsubst.py +0 -0
- {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/envsubst_merge.py +0 -0
- {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/helpers.py +0 -0
- {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/login.py +0 -0
- {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/manager_api.py +0 -0
- {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/markers.py +0 -0
- {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/merge_conf.py +0 -0
- {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/registry.py +0 -0
- {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/shell_auth.py +0 -0
- {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/url_parser.py +0 -0
- {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack.egg-info/SOURCES.txt +0 -0
- {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack.egg-info/dependency_links.txt +0 -0
- {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack.egg-info/entry_points.txt +0 -0
- {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack.egg-info/requires.txt +0 -0
- {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack.egg-info/top_level.txt +0 -0
- {docker_stack-2.2.0 → docker_stack-2.2.1}/pyproject.toml +0 -0
- {docker_stack-2.2.0 → docker_stack-2.2.1}/setup.cfg +0 -0
- {docker_stack-2.2.0 → docker_stack-2.2.1}/tests/test_docker_objects.py +0 -0
- {docker_stack-2.2.0 → docker_stack-2.2.1}/tests/test_load_env.py +0 -0
- {docker_stack-2.2.0 → docker_stack-2.2.1}/tests/test_login.py +0 -0
- {docker_stack-2.2.0 → docker_stack-2.2.1}/tests/test_manager_api.py +0 -0
- {docker_stack-2.2.0 → docker_stack-2.2.1}/tests/test_node_ls.py +0 -0
- {docker_stack-2.2.0 → docker_stack-2.2.1}/tests/test_shell_auth.py +0 -0
|
@@ -137,33 +137,155 @@ def _select_node(value: str) -> str:
|
|
|
137
137
|
return hostname
|
|
138
138
|
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
COMMAND_DISPLAY_WIDTH = 20
|
|
141
|
+
SIZE_UNITS = ("B", "kB", "MB", "GB", "TB", "PB")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _human_duration(timestamp: object) -> str:
|
|
145
|
+
"""Mirror docker's units.HumanDuration for the CREATED column."""
|
|
141
146
|
try:
|
|
142
|
-
seconds =
|
|
147
|
+
seconds = time.time() - float(timestamp)
|
|
143
148
|
except (TypeError, ValueError):
|
|
144
|
-
return "
|
|
149
|
+
return ""
|
|
150
|
+
if seconds < 1:
|
|
151
|
+
return "Less than a second ago"
|
|
152
|
+
if int(seconds) == 1:
|
|
153
|
+
return "1 second ago"
|
|
145
154
|
if seconds < 60:
|
|
146
|
-
return f"{seconds} seconds ago"
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
155
|
+
return f"{int(seconds)} seconds ago"
|
|
156
|
+
minutes = int(seconds // 60)
|
|
157
|
+
if minutes == 1:
|
|
158
|
+
return "About a minute ago"
|
|
159
|
+
if minutes < 60:
|
|
160
|
+
return f"{minutes} minutes ago"
|
|
161
|
+
hours = int(seconds / 3600 + 0.5)
|
|
162
|
+
if hours == 1:
|
|
163
|
+
return "About an hour ago"
|
|
164
|
+
if hours < 48:
|
|
165
|
+
return f"{hours} hours ago"
|
|
166
|
+
if hours < 24 * 7 * 2:
|
|
167
|
+
return f"{hours // 24} days ago"
|
|
168
|
+
if hours < 24 * 30 * 2:
|
|
169
|
+
return f"{hours // 24 // 7} weeks ago"
|
|
170
|
+
if hours < 24 * 365 * 2:
|
|
171
|
+
return f"{hours // 24 // 30} months ago"
|
|
172
|
+
return f"{hours // 24 // 365} years ago"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _human_size(value: object) -> str:
|
|
176
|
+
try:
|
|
177
|
+
size = float(value)
|
|
178
|
+
except (TypeError, ValueError):
|
|
179
|
+
return ""
|
|
180
|
+
index = 0
|
|
181
|
+
while size >= 1000.0 and index < len(SIZE_UNITS) - 1:
|
|
182
|
+
size /= 1000.0
|
|
183
|
+
index += 1
|
|
184
|
+
return f"{size:.3g}{SIZE_UNITS[index]}"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _quote_command(value: str) -> str:
|
|
188
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
189
|
+
return f'"{escaped}"'
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _ellipsis(value: str, width: int) -> str:
|
|
193
|
+
if width <= 0:
|
|
194
|
+
return ""
|
|
195
|
+
if len(value) <= width:
|
|
196
|
+
return value
|
|
197
|
+
return value[: width - 1] + "…"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _form_port_group(key: str, first: int, last: int) -> str:
|
|
201
|
+
parts = key.split("/")
|
|
202
|
+
address = ""
|
|
203
|
+
protocol = parts[0]
|
|
204
|
+
if len(parts) > 1:
|
|
205
|
+
address = parts[0]
|
|
206
|
+
protocol = parts[1]
|
|
207
|
+
group = str(first) if first == last else f"{first}-{last}"
|
|
208
|
+
if address:
|
|
209
|
+
group = f"{address}:{group}->{group}"
|
|
210
|
+
return f"{group}/{protocol}"
|
|
152
211
|
|
|
153
212
|
|
|
154
213
|
def _format_ports(ports: object) -> str:
|
|
214
|
+
"""Mirror docker's api/types.DisplayablePorts for the PORTS column."""
|
|
155
215
|
if not isinstance(ports, list):
|
|
156
216
|
return ""
|
|
157
|
-
|
|
217
|
+
entries = []
|
|
158
218
|
for port in ports:
|
|
159
219
|
if not isinstance(port, dict):
|
|
160
220
|
continue
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
221
|
+
try:
|
|
222
|
+
private = int(port.get("PrivatePort"))
|
|
223
|
+
except (TypeError, ValueError):
|
|
224
|
+
continue
|
|
225
|
+
try:
|
|
226
|
+
public = int(port.get("PublicPort"))
|
|
227
|
+
except (TypeError, ValueError):
|
|
228
|
+
public = None
|
|
229
|
+
entries.append((private, str(port.get("Type") or "tcp"), str(port.get("IP") or ""), public))
|
|
230
|
+
entries.sort(key=lambda entry: entry[0])
|
|
231
|
+
groups: Dict[str, List[int]] = {}
|
|
232
|
+
group_keys: List[str] = []
|
|
233
|
+
result: List[str] = []
|
|
234
|
+
host_mappings: List[str] = []
|
|
235
|
+
for private, protocol, address, public in entries:
|
|
236
|
+
key = protocol
|
|
237
|
+
if address and public:
|
|
238
|
+
if public != private:
|
|
239
|
+
host_mappings.append(f"{address}:{public}->{private}/{protocol}")
|
|
240
|
+
continue
|
|
241
|
+
key = f"{address}/{protocol}"
|
|
242
|
+
group = groups.get(key)
|
|
243
|
+
if group is None:
|
|
244
|
+
groups[key] = [private, private]
|
|
245
|
+
group_keys.append(key)
|
|
246
|
+
continue
|
|
247
|
+
if private == group[1] + 1:
|
|
248
|
+
group[1] = private
|
|
249
|
+
continue
|
|
250
|
+
result.append(_form_port_group(key, group[0], group[1]))
|
|
251
|
+
groups[key] = [private, private]
|
|
252
|
+
for key in group_keys:
|
|
253
|
+
first, last = groups[key]
|
|
254
|
+
result.append(_form_port_group(key, first, last))
|
|
255
|
+
result.extend(host_mappings)
|
|
256
|
+
return ", ".join(result)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _format_names(names: object, *, no_trunc: bool) -> str:
|
|
260
|
+
values = [str(name).lstrip("/") for name in names] if isinstance(names, list) else []
|
|
261
|
+
if not no_trunc:
|
|
262
|
+
for name in values:
|
|
263
|
+
if "/" not in name:
|
|
264
|
+
values = [name]
|
|
265
|
+
break
|
|
266
|
+
return ",".join(values)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _format_image(image: object, image_id: object, *, no_trunc: bool) -> str:
|
|
270
|
+
value = str(image or "")
|
|
271
|
+
if not value:
|
|
272
|
+
return "<no image>"
|
|
273
|
+
if no_trunc:
|
|
274
|
+
return value
|
|
275
|
+
identifier = str(image_id or "")
|
|
276
|
+
if identifier and _short_id(identifier) == _short_id(value):
|
|
277
|
+
return _short_id(value)
|
|
278
|
+
return value
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _short_id(value: str) -> str:
|
|
282
|
+
return value[len("sha256:") :][:12] if value.startswith("sha256:") else value[:12]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _print_table(columns: List[str], rows: List[Dict[str, str]]) -> None:
|
|
286
|
+
widths = {column: max(len(column), max((len(row[column]) for row in rows), default=0)) for column in columns}
|
|
287
|
+
for values in [{column: column for column in columns}, *rows]:
|
|
288
|
+
print(" ".join(values[column].ljust(widths[column]) for column in columns).rstrip())
|
|
167
289
|
|
|
168
290
|
|
|
169
291
|
def _print_cluster_containers(payload: Dict[str, object], *, no_trunc: bool = False, show_size: bool = False) -> int:
|
|
@@ -172,29 +294,31 @@ def _print_cluster_containers(payload: Dict[str, object], *, no_trunc: bool = Fa
|
|
|
172
294
|
if not isinstance(entry, dict) or not isinstance(entry.get("docker"), dict):
|
|
173
295
|
continue
|
|
174
296
|
item = entry["docker"]
|
|
175
|
-
container_id = str(item.get("Id") or "
|
|
176
|
-
|
|
297
|
+
container_id = str(item.get("Id") or "")
|
|
298
|
+
command = str(item.get("Command") or "")
|
|
177
299
|
row = {
|
|
178
300
|
"CONTAINER ID": container_id if no_trunc else container_id[:12],
|
|
179
|
-
"IMAGE":
|
|
180
|
-
"COMMAND":
|
|
181
|
-
"CREATED":
|
|
182
|
-
"STATUS": str(item.get("Status") or item.get("State") or "
|
|
301
|
+
"IMAGE": _format_image(item.get("Image"), item.get("ImageID"), no_trunc=no_trunc),
|
|
302
|
+
"COMMAND": _quote_command(command if no_trunc else _ellipsis(command, COMMAND_DISPLAY_WIDTH)),
|
|
303
|
+
"CREATED": _human_duration(item.get("Created")),
|
|
304
|
+
"STATUS": str(item.get("Status") or item.get("State") or ""),
|
|
183
305
|
"PORTS": _format_ports(item.get("Ports")),
|
|
184
|
-
"NAMES":
|
|
185
|
-
"NODE": str(entry.get("node_name") or entry.get("node_id") or "
|
|
306
|
+
"NAMES": _format_names(item.get("Names"), no_trunc=no_trunc),
|
|
307
|
+
"NODE": str(entry.get("node_name") or entry.get("node_id") or ""),
|
|
186
308
|
}
|
|
187
309
|
if show_size:
|
|
188
|
-
|
|
310
|
+
size = _human_size(item.get("SizeRw") or 0)
|
|
311
|
+
try:
|
|
312
|
+
virtual = float(item.get("SizeRootFs") or 0)
|
|
313
|
+
except (TypeError, ValueError):
|
|
314
|
+
virtual = 0
|
|
315
|
+
row["SIZE"] = f"{size} (virtual {_human_size(virtual)})" if virtual > 0 else size
|
|
189
316
|
rows.append(row)
|
|
190
317
|
columns = ["CONTAINER ID", "IMAGE", "COMMAND", "CREATED", "STATUS", "PORTS", "NAMES"]
|
|
191
318
|
if show_size:
|
|
192
319
|
columns.append("SIZE")
|
|
193
320
|
columns.append("NODE")
|
|
194
|
-
|
|
195
|
-
print(" ".join(column.ljust(widths[column]) for column in columns))
|
|
196
|
-
for row in rows:
|
|
197
|
-
print(" ".join(row[column].ljust(widths[column]) for column in columns))
|
|
321
|
+
_print_table(columns, rows)
|
|
198
322
|
errors = payload.get("node_errors") if isinstance(payload.get("node_errors"), list) else []
|
|
199
323
|
for error in errors:
|
|
200
324
|
if isinstance(error, dict):
|
|
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
|
|
|
2
2
|
|
|
3
3
|
setup(
|
|
4
4
|
name="docker-stack",
|
|
5
|
-
version="2.2.
|
|
5
|
+
version="2.2.1",
|
|
6
6
|
description="CLI for deploying and managing Docker stacks.",
|
|
7
7
|
long_description=open("README.md").read(), # You can include a README file to describe your package
|
|
8
8
|
long_description_content_type="text/markdown",
|
|
@@ -2,6 +2,7 @@ import base64
|
|
|
2
2
|
import json
|
|
3
3
|
import os
|
|
4
4
|
import subprocess
|
|
5
|
+
import time
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from types import SimpleNamespace
|
|
7
8
|
|
|
@@ -88,6 +89,48 @@ def test_managed_docker_ps_renders_node_column(monkeypatch, capsys):
|
|
|
88
89
|
assert "abcdef123456" in output
|
|
89
90
|
|
|
90
91
|
|
|
92
|
+
def test_managed_docker_ps_matches_docker_columns(monkeypatch, capsys):
|
|
93
|
+
client = SimpleNamespace(
|
|
94
|
+
supports=lambda feature: feature == "cluster_container_cli_v1",
|
|
95
|
+
list_containers=lambda **_kwargs: {
|
|
96
|
+
"containers": [
|
|
97
|
+
{
|
|
98
|
+
"node_id": "node-1",
|
|
99
|
+
"node_name": "worker-02",
|
|
100
|
+
"docker": {
|
|
101
|
+
"Id": "467fce208a05dddd",
|
|
102
|
+
"Image": "registry.example.com/app/backend:v1",
|
|
103
|
+
"Command": "sh -c 'java $JAVA_OPTS -jar /app/app.jar'",
|
|
104
|
+
"Created": int(time.time()) - 17 * 60,
|
|
105
|
+
"Status": "Up 17 minutes",
|
|
106
|
+
"Ports": [
|
|
107
|
+
{"PrivatePort": 8080, "Type": "tcp"},
|
|
108
|
+
{"PrivatePort": 8081, "Type": "tcp"},
|
|
109
|
+
{"IP": "0.0.0.0", "PrivatePort": 443, "PublicPort": 8443, "Type": "tcp"},
|
|
110
|
+
],
|
|
111
|
+
"Names": ["/app_backend.1.qtfngt97xvabnhj0ehk4us6kk"],
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
],
|
|
115
|
+
"node_errors": [],
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
monkeypatch.setattr("docker_stack.cli.discover_manager_client", lambda: client)
|
|
119
|
+
|
|
120
|
+
assert _managed_docker(["ps"]) == 0
|
|
121
|
+
header, row = capsys.readouterr().out.splitlines()
|
|
122
|
+
|
|
123
|
+
assert header.split() == [
|
|
124
|
+
"CONTAINER", "ID", "IMAGE", "COMMAND", "CREATED", "STATUS", "PORTS", "NAMES", "NODE",
|
|
125
|
+
]
|
|
126
|
+
assert row.startswith("467fce208a05 ")
|
|
127
|
+
assert '"sh -c \'java $JAVA_O\u2026"' in row
|
|
128
|
+
assert "17 minutes ago" in row
|
|
129
|
+
assert "8080-8081/tcp, 0.0.0.0:8443->443/tcp" in row
|
|
130
|
+
assert row.endswith("worker-02")
|
|
131
|
+
assert row == row.rstrip()
|
|
132
|
+
|
|
133
|
+
|
|
91
134
|
def test_managed_docker_ps_forwards_global_latest_and_limit(monkeypatch):
|
|
92
135
|
calls = []
|
|
93
136
|
client = SimpleNamespace(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|