docker-stack 2.2.0__tar.gz → 2.2.2__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.
Files changed (33) hide show
  1. {docker_stack-2.2.0 → docker_stack-2.2.2}/PKG-INFO +1 -1
  2. {docker_stack-2.2.0 → docker_stack-2.2.2}/docker_stack/cli.py +154 -29
  3. {docker_stack-2.2.0 → docker_stack-2.2.2}/docker_stack/login.py +231 -14
  4. {docker_stack-2.2.0 → docker_stack-2.2.2}/docker_stack/shell_auth.py +5 -0
  5. {docker_stack-2.2.0 → docker_stack-2.2.2}/docker_stack.egg-info/PKG-INFO +1 -1
  6. {docker_stack-2.2.0 → docker_stack-2.2.2}/setup.py +1 -1
  7. {docker_stack-2.2.0 → docker_stack-2.2.2}/tests/test_docker_stack.py +43 -0
  8. {docker_stack-2.2.0 → docker_stack-2.2.2}/tests/test_login.py +149 -8
  9. {docker_stack-2.2.0 → docker_stack-2.2.2}/README.md +0 -0
  10. {docker_stack-2.2.0 → docker_stack-2.2.2}/docker_stack/__init__.py +0 -0
  11. {docker_stack-2.2.0 → docker_stack-2.2.2}/docker_stack/command_runner.py +0 -0
  12. {docker_stack-2.2.0 → docker_stack-2.2.2}/docker_stack/compose.py +0 -0
  13. {docker_stack-2.2.0 → docker_stack-2.2.2}/docker_stack/docker_objects.py +0 -0
  14. {docker_stack-2.2.0 → docker_stack-2.2.2}/docker_stack/envsubst.py +0 -0
  15. {docker_stack-2.2.0 → docker_stack-2.2.2}/docker_stack/envsubst_merge.py +0 -0
  16. {docker_stack-2.2.0 → docker_stack-2.2.2}/docker_stack/helpers.py +0 -0
  17. {docker_stack-2.2.0 → docker_stack-2.2.2}/docker_stack/manager_api.py +0 -0
  18. {docker_stack-2.2.0 → docker_stack-2.2.2}/docker_stack/markers.py +0 -0
  19. {docker_stack-2.2.0 → docker_stack-2.2.2}/docker_stack/merge_conf.py +0 -0
  20. {docker_stack-2.2.0 → docker_stack-2.2.2}/docker_stack/registry.py +0 -0
  21. {docker_stack-2.2.0 → docker_stack-2.2.2}/docker_stack/url_parser.py +0 -0
  22. {docker_stack-2.2.0 → docker_stack-2.2.2}/docker_stack.egg-info/SOURCES.txt +0 -0
  23. {docker_stack-2.2.0 → docker_stack-2.2.2}/docker_stack.egg-info/dependency_links.txt +0 -0
  24. {docker_stack-2.2.0 → docker_stack-2.2.2}/docker_stack.egg-info/entry_points.txt +0 -0
  25. {docker_stack-2.2.0 → docker_stack-2.2.2}/docker_stack.egg-info/requires.txt +0 -0
  26. {docker_stack-2.2.0 → docker_stack-2.2.2}/docker_stack.egg-info/top_level.txt +0 -0
  27. {docker_stack-2.2.0 → docker_stack-2.2.2}/pyproject.toml +0 -0
  28. {docker_stack-2.2.0 → docker_stack-2.2.2}/setup.cfg +0 -0
  29. {docker_stack-2.2.0 → docker_stack-2.2.2}/tests/test_docker_objects.py +0 -0
  30. {docker_stack-2.2.0 → docker_stack-2.2.2}/tests/test_load_env.py +0 -0
  31. {docker_stack-2.2.0 → docker_stack-2.2.2}/tests/test_manager_api.py +0 -0
  32. {docker_stack-2.2.0 → docker_stack-2.2.2}/tests/test_node_ls.py +0 -0
  33. {docker_stack-2.2.0 → docker_stack-2.2.2}/tests/test_shell_auth.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docker-stack
3
- Version: 2.2.0
3
+ Version: 2.2.2
4
4
  Summary: CLI for deploying and managing Docker stacks.
5
5
  Home-page: https://github.com/mesudip/docker-stack
6
6
  Author: Sudip Bhattarai
@@ -137,33 +137,155 @@ def _select_node(value: str) -> str:
137
137
  return hostname
138
138
 
139
139
 
140
- def _human_age(timestamp: object) -> str:
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 = max(0, int(time.time()) - int(timestamp))
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
- if seconds < 3600:
148
- return f"{seconds // 60} minutes ago"
149
- if seconds < 86400:
150
- return f"{seconds // 3600} hours ago"
151
- return f"{seconds // 86400} days ago"
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
- values = []
217
+ entries = []
158
218
  for port in ports:
159
219
  if not isinstance(port, dict):
160
220
  continue
161
- private = port.get("PrivatePort")
162
- protocol = port.get("Type") or "tcp"
163
- public = port.get("PublicPort")
164
- address = port.get("IP") or "0.0.0.0"
165
- values.append(f"{address}:{public}->{private}/{protocol}" if public is not None else f"{private}/{protocol}")
166
- return ", ".join(values)
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
- names = item.get("Names") if isinstance(item.get("Names"), list) else []
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": str(item.get("Image") or "-"),
180
- "COMMAND": str(item.get("Command") or "-"),
181
- "CREATED": _human_age(item.get("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": ",".join(str(name).lstrip("/") for name in names) or "-",
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
- row["SIZE"] = str(item.get("SizeRw") if item.get("SizeRw") is not None else "-")
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
- widths = {column: max(len(column), max((len(row[column]) for row in rows), default=0)) for column in columns}
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):
@@ -1628,6 +1752,7 @@ def open_context_shell(config_dir: Path, context_name: str) -> int:
1628
1752
  env.pop(key, None)
1629
1753
  env["DOCKER_CONFIG"] = str(config_dir)
1630
1754
  env["DOCKER_CONTEXT"] = context_name
1755
+ env["DOCKER_MANAGER_CONTEXT_NAME"] = context_name
1631
1756
  shell = env.get("SHELL", "").strip() or "/bin/bash"
1632
1757
  return subprocess.run([shell, "-i"], check=False, env=env).returncode
1633
1758
 
@@ -14,7 +14,7 @@ import webbrowser
14
14
  from dataclasses import dataclass
15
15
  from http.server import BaseHTTPRequestHandler, HTTPServer
16
16
  from pathlib import Path
17
- from typing import Callable, Dict, Optional
17
+ from typing import Callable, Dict, Optional, Tuple
18
18
 
19
19
  from docker_stack.command_runner import run_command
20
20
 
@@ -111,8 +111,132 @@ def _candidate_urls(target: str, *, verify_ssl: bool) -> list[str]:
111
111
  return [https_candidate, f"http://{normalized_target}:2375"]
112
112
 
113
113
 
114
- def probe_manager_url(url: str, *, timeout: int = 3, verify_tls: bool = True) -> tuple[bool, bool]:
115
- request = urllib.request.Request(f"{url.rstrip('/')}/_ping", method="GET")
114
+ DOCKER_MANAGER_SKIP_TLS_VERIFY_ENV = "DOCKER_MANAGER_SKIP_TLS_VERIFY"
115
+ LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
116
+ _TRUTHY_VALUES = {"1", "true", "yes", "on"}
117
+ _FALSY_VALUES = {"0", "false", "no", "off"}
118
+ _CONTEXT_INSPECT_CACHE: Dict[Tuple[str, str], Optional[Dict[str, object]]] = {}
119
+
120
+
121
+ def _parse_bool(value: Optional[str]) -> Optional[bool]:
122
+ if value is None:
123
+ return None
124
+ normalized = value.strip().lower()
125
+ if normalized in _TRUTHY_VALUES:
126
+ return True
127
+ if normalized in _FALSY_VALUES:
128
+ return False
129
+ return None
130
+
131
+
132
+ def _host_and_port(value: str) -> Optional[Tuple[str, Optional[int]]]:
133
+ target = normalize_manager_target(value)
134
+ parsed = urllib.parse.urlparse(target if "://" in target else f"//{target}")
135
+ if not parsed.hostname:
136
+ return None
137
+ try:
138
+ port = parsed.port
139
+ except ValueError:
140
+ return None
141
+ return parsed.hostname.lower(), port
142
+
143
+
144
+ def docker_config_path(docker_config_dir: Optional[Path] = None) -> Path:
145
+ if docker_config_dir is None:
146
+ configured = os.getenv("DOCKER_CONFIG", "").strip()
147
+ docker_config_dir = Path(configured) if configured else Path.home() / ".docker"
148
+ return Path(docker_config_dir) / "config.json"
149
+
150
+
151
+ def docker_config_http_headers(docker_config_dir: Optional[Path] = None) -> Dict[str, str]:
152
+ """Headers the Docker CLI sends to the manager, including the access token."""
153
+ try:
154
+ payload = read_docker_config(docker_config_path(docker_config_dir))
155
+ except RuntimeError:
156
+ return {}
157
+ headers = payload.get("HttpHeaders")
158
+ if not isinstance(headers, dict):
159
+ return {}
160
+ return {
161
+ str(key): str(value)
162
+ for key, value in headers.items()
163
+ if isinstance(key, str) and isinstance(value, str) and key.strip() and value.strip()
164
+ }
165
+
166
+
167
+ def _probe_headers(url: str, headers: Optional[Dict[str, str]]) -> Dict[str, str]:
168
+ if not headers:
169
+ return {}
170
+ if url.startswith("https://"):
171
+ return dict(headers)
172
+ host = (_host_and_port(url) or ("", None))[0]
173
+ if host in LOOPBACK_HOSTS:
174
+ return dict(headers)
175
+ # Never put the access token on the wire in cleartext towards a remote host.
176
+ return {key: value for key, value in headers.items() if key.lower() != "authorization"}
177
+
178
+
179
+ def _docker_context_skip_tls_verify(context_payload: Optional[Dict[str, object]]) -> Optional[bool]:
180
+ if not isinstance(context_payload, dict):
181
+ return None
182
+ endpoints = context_payload.get("Endpoints") or context_payload.get("endpoints") or {}
183
+ docker_endpoint = {}
184
+ if isinstance(endpoints, dict):
185
+ docker_endpoint = endpoints.get("docker") or endpoints.get("Docker") or {}
186
+ if not isinstance(docker_endpoint, dict):
187
+ return None
188
+ value = docker_endpoint.get("SkipTLSVerify")
189
+ if value is None:
190
+ value = docker_endpoint.get("skipTLSVerify")
191
+ return bool(value) if isinstance(value, bool) else None
192
+
193
+
194
+ def _skip_tls_verify_hint(
195
+ target: str,
196
+ *,
197
+ context_name: Optional[str] = None,
198
+ docker_config_dir: Optional[Path] = None,
199
+ ) -> Optional[bool]:
200
+ """The TLS mode of an endpoint we already configured, so probing it again is unnecessary."""
201
+ env_hint = _parse_bool(os.getenv(DOCKER_MANAGER_SKIP_TLS_VERIFY_ENV))
202
+ if env_hint is not None:
203
+ return env_hint
204
+ target_origin = _host_and_port(target)
205
+ if target_origin is None or target_origin[1] is None:
206
+ return None
207
+ candidate_names = [
208
+ context_name,
209
+ os.getenv("DOCKER_MANAGER_CONTEXT_NAME"),
210
+ os.getenv("DOCKER_CONTEXT"),
211
+ ]
212
+ seen = set()
213
+ for name in candidate_names:
214
+ name = (name or "").strip()
215
+ if not name or name in seen:
216
+ continue
217
+ seen.add(name)
218
+ payload = _inspect_docker_context(name, docker_config_dir)
219
+ host = _docker_context_host(payload)
220
+ if not host or _host_and_port(host) != target_origin:
221
+ continue
222
+ hint = _docker_context_skip_tls_verify(payload)
223
+ if hint is not None:
224
+ return hint
225
+ return None
226
+
227
+
228
+ def probe_manager_url(
229
+ url: str,
230
+ *,
231
+ timeout: int = 3,
232
+ verify_tls: bool = True,
233
+ headers: Optional[Dict[str, str]] = None,
234
+ ) -> tuple[bool, bool]:
235
+ request = urllib.request.Request(
236
+ f"{url.rstrip('/')}/_ping",
237
+ method="GET",
238
+ headers=_probe_headers(url, headers),
239
+ )
116
240
  context = None
117
241
  if url.startswith("https://") and not verify_tls:
118
242
  context = ssl._create_unverified_context()
@@ -132,20 +256,34 @@ def probe_manager_url(url: str, *, timeout: int = 3, verify_tls: bool = True) ->
132
256
  return False, True
133
257
 
134
258
 
135
- def detect_manager_url(target: str, *, verify_ssl: bool = False) -> tuple[str, bool]:
259
+ def detect_manager_url(
260
+ target: str,
261
+ *,
262
+ verify_ssl: bool = False,
263
+ skip_tls_verify: Optional[bool] = None,
264
+ headers: Optional[Dict[str, str]] = None,
265
+ ) -> tuple[str, bool]:
136
266
  normalized_target = normalize_manager_target(target)
137
267
  parsed = urllib.parse.urlparse(normalized_target)
138
268
  explicit_scheme = parsed.scheme in {"http", "https"}
139
269
 
140
270
  candidates = _candidate_urls(normalized_target, verify_ssl=verify_ssl)
141
271
  tls_verification_failed = False
272
+ known_skip_tls_verify = None if verify_ssl else skip_tls_verify
142
273
 
143
274
  for candidate in candidates:
144
- success, cert_failed = probe_manager_url(candidate, verify_tls=True)
275
+ if known_skip_tls_verify and candidate.startswith("https://"):
276
+ # The endpoint is known to serve a certificate this trust store cannot
277
+ # verify. A verified handshake would only abort with an UnknownCA alert.
278
+ insecure_success, _ = probe_manager_url(candidate, verify_tls=False, headers=headers)
279
+ if insecure_success:
280
+ return candidate, True
281
+ continue
282
+ success, cert_failed = probe_manager_url(candidate, verify_tls=True, headers=headers)
145
283
  if success:
146
284
  return candidate, False
147
285
  if candidate.startswith("https://") and cert_failed and not verify_ssl:
148
- insecure_success, _ = probe_manager_url(candidate, verify_tls=False)
286
+ insecure_success, _ = probe_manager_url(candidate, verify_tls=False, headers=headers)
149
287
  if insecure_success:
150
288
  return candidate, True
151
289
  tls_verification_failed = True
@@ -203,6 +341,13 @@ def _docker_env(docker_config_dir: Optional[Path] = None) -> Dict[str, str]:
203
341
 
204
342
 
205
343
  def _inspect_docker_context(context_name: str, docker_config_dir: Optional[Path] = None) -> Optional[Dict[str, object]]:
344
+ cache_key = (context_name, str(docker_config_dir or os.getenv("DOCKER_CONFIG", "")))
345
+ if cache_key not in _CONTEXT_INSPECT_CACHE:
346
+ _CONTEXT_INSPECT_CACHE[cache_key] = _read_docker_context(context_name, docker_config_dir)
347
+ return _CONTEXT_INSPECT_CACHE[cache_key]
348
+
349
+
350
+ def _read_docker_context(context_name: str, docker_config_dir: Optional[Path] = None) -> Optional[Dict[str, object]]:
206
351
  inspect = subprocess.run(
207
352
  ["docker", "context", "inspect", context_name],
208
353
  text=True,
@@ -256,6 +401,42 @@ def docker_context_target(context_name: str, docker_config_dir: Optional[Path] =
256
401
  return _docker_context_host(_inspect_docker_context(context_name, docker_config_dir))
257
402
 
258
403
 
404
+ def _resolve_manager_endpoint(
405
+ target: str,
406
+ *,
407
+ verify_ssl: bool = False,
408
+ context_name: Optional[str] = None,
409
+ docker_config_dir: Optional[Path] = None,
410
+ ) -> tuple[str, bool]:
411
+ """Resolve the manager URL, avoiding probes when the endpoint is already configured."""
412
+ normalized_target = normalize_manager_target(target)
413
+ headers = docker_config_http_headers(docker_config_dir)
414
+ hint = None
415
+ if not verify_ssl:
416
+ hint = _skip_tls_verify_hint(
417
+ normalized_target,
418
+ context_name=context_name,
419
+ docker_config_dir=docker_config_dir,
420
+ )
421
+ if hint is not None:
422
+ candidates = _candidate_urls(normalized_target, verify_ssl=False)
423
+ if len(candidates) == 1:
424
+ candidate = candidates[0]
425
+ return candidate, hint if candidate.startswith("https://") else False
426
+ if hint:
427
+ # Skipping verification only applies to a TLS endpoint, so the
428
+ # scheme is settled too and no candidate probing is needed.
429
+ for candidate in candidates:
430
+ if candidate.startswith("https://"):
431
+ return candidate, True
432
+ return detect_manager_url(
433
+ normalized_target,
434
+ verify_ssl=verify_ssl,
435
+ skip_tls_verify=hint,
436
+ headers=headers,
437
+ )
438
+
439
+
259
440
  def resolve_login_config(
260
441
  *,
261
442
  manager_url: Optional[str] = None,
@@ -285,19 +466,45 @@ def resolve_login_config(
285
466
  inferred_context_name = manager_target
286
467
  if isolated_context_target:
287
468
  resolved_docker_config_dir = isolated_config_dir
469
+ endpoint_context_name = (
470
+ context_name
471
+ or os.getenv("DOCKER_MANAGER_CONTEXT_NAME")
472
+ or inferred_context_name
473
+ or current_context_name
474
+ )
288
475
  if manager_target:
289
- resolved_manager_url, skip_tls_verify = detect_manager_url(raw_manager_value, verify_ssl=verify_ssl)
476
+ resolved_manager_url, skip_tls_verify = _resolve_manager_endpoint(
477
+ raw_manager_value,
478
+ verify_ssl=verify_ssl,
479
+ context_name=endpoint_context_name,
480
+ docker_config_dir=resolved_docker_config_dir,
481
+ )
290
482
  elif manager_url:
291
483
  resolved_manager_url = normalize_loopback_host(raw_manager_value)
292
484
  skip_tls_verify = False
293
485
  if resolved_manager_url.startswith("https://"):
294
- _, skip_tls_verify = detect_manager_url(resolved_manager_url, verify_ssl=verify_ssl)
486
+ _, skip_tls_verify = _resolve_manager_endpoint(
487
+ resolved_manager_url,
488
+ verify_ssl=verify_ssl,
489
+ context_name=endpoint_context_name,
490
+ docker_config_dir=resolved_docker_config_dir,
491
+ )
295
492
  elif verify_ssl:
296
493
  raise RuntimeError("verify_ssl=true requires an HTTPS manager URL")
297
494
  elif env_manager_value and "://" not in env_manager_value:
298
- resolved_manager_url, skip_tls_verify = detect_manager_url(raw_manager_value, verify_ssl=verify_ssl)
495
+ resolved_manager_url, skip_tls_verify = _resolve_manager_endpoint(
496
+ raw_manager_value,
497
+ verify_ssl=verify_ssl,
498
+ context_name=endpoint_context_name,
499
+ docker_config_dir=resolved_docker_config_dir,
500
+ )
299
501
  elif current_context_target:
300
- resolved_manager_url, skip_tls_verify = detect_manager_url(raw_manager_value, verify_ssl=verify_ssl)
502
+ resolved_manager_url, skip_tls_verify = _resolve_manager_endpoint(
503
+ raw_manager_value,
504
+ verify_ssl=verify_ssl,
505
+ context_name=endpoint_context_name,
506
+ docker_config_dir=resolved_docker_config_dir,
507
+ )
301
508
  else:
302
509
  resolved_manager_url = normalize_loopback_host(raw_manager_value)
303
510
  skip_tls_verify = False
@@ -323,7 +530,7 @@ def resolve_context_login_config(
323
530
  target = docker_context_target(context_name)
324
531
  if not target:
325
532
  raise RuntimeError(f"Docker context '{context_name}' does not have a TCP/HTTP(S) Docker endpoint")
326
- resolved_manager_url, skip_tls_verify = detect_manager_url(target)
533
+ resolved_manager_url, skip_tls_verify = _resolve_manager_endpoint(target, context_name=context_name)
327
534
  return DockerManagerLoginConfig(
328
535
  manager_url=resolved_manager_url,
329
536
  context_name=context_name,
@@ -343,7 +550,10 @@ def resolve_shell_login_config(
343
550
  resolved_context_name = context_name or shell_name
344
551
  if not resolved_context_name:
345
552
  raise RuntimeError("Pass --context <name> when providing a manager target")
346
- resolved_manager_url, skip_tls_verify = detect_manager_url(manager_target)
553
+ resolved_manager_url, skip_tls_verify = _resolve_manager_endpoint(
554
+ manager_target,
555
+ context_name=resolved_context_name,
556
+ )
347
557
  return DockerManagerLoginConfig(
348
558
  manager_url=resolved_manager_url,
349
559
  context_name=resolved_context_name,
@@ -360,7 +570,11 @@ def resolve_shell_login_config(
360
570
  target = persisted_target or docker_context_target(resolved_context_name)
361
571
  if not target:
362
572
  raise UnknownShellContextError(resolved_context_name)
363
- resolved_manager_url, skip_tls_verify = detect_manager_url(target)
573
+ resolved_manager_url, skip_tls_verify = _resolve_manager_endpoint(
574
+ target,
575
+ context_name=resolved_context_name,
576
+ docker_config_dir=isolated_docker_config_dir(resolved_context_name) if persisted_target else None,
577
+ )
364
578
  return DockerManagerLoginConfig(
365
579
  manager_url=resolved_manager_url,
366
580
  context_name=resolved_context_name,
@@ -370,7 +584,10 @@ def resolve_shell_login_config(
370
584
 
371
585
  current_context_name, current_target = current_docker_context_target()
372
586
  if current_context_name and current_target:
373
- resolved_manager_url, skip_tls_verify = detect_manager_url(current_target)
587
+ resolved_manager_url, skip_tls_verify = _resolve_manager_endpoint(
588
+ current_target,
589
+ context_name=current_context_name,
590
+ )
374
591
  return DockerManagerLoginConfig(
375
592
  manager_url=resolved_manager_url,
376
593
  context_name=current_context_name,
@@ -16,6 +16,7 @@ from pathlib import Path
16
16
  from typing import Any, Dict, Optional
17
17
 
18
18
  from docker_stack.login import (
19
+ DOCKER_MANAGER_SKIP_TLS_VERIFY_ENV,
19
20
  DockerManagerLoginConfig,
20
21
  DockerManagerLoginResult,
21
22
  browser_login,
@@ -733,6 +734,10 @@ def run_managed_shell(config: DockerManagerLoginConfig, result: DockerManagerLog
733
734
  env["DOCKER_CONTEXT"] = config.context_name
734
735
  env["DOCKER_MANAGER_URL"] = config.manager_url
735
736
  env["DOCKER_MANAGER_CONTEXT_NAME"] = config.context_name
737
+ # The endpoint's TLS mode is already known, so in-shell commands never
738
+ # need to re-probe it (a verified probe against a private CA only
739
+ # produces aborted TLS connections on the manager).
740
+ env[DOCKER_MANAGER_SKIP_TLS_VERIFY_ENV] = "1" if config.skip_tls_verify else "0"
736
741
  env[SHELL_ORIGINAL_ZDOTDIR_ENV] = str(
737
742
  Path(os.getenv(SHELL_ORIGINAL_ZDOTDIR_ENV) or os.getenv("ZDOTDIR", str(Path.home())))
738
743
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docker-stack
3
- Version: 2.2.0
3
+ Version: 2.2.2
4
4
  Summary: CLI for deploying and managing Docker stacks.
5
5
  Home-page: https://github.com/mesudip/docker-stack
6
6
  Author: Sudip Bhattarai
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="docker-stack",
5
- version="2.2.0",
5
+ version="2.2.2",
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(
@@ -9,12 +9,15 @@ import urllib.request
9
9
  import pytest
10
10
 
11
11
  from docker_stack.login import (
12
+ DOCKER_MANAGER_SKIP_TLS_VERIFY_ENV,
12
13
  clear_docker_config_authorization_header,
13
14
  DockerManagerLoginConfig,
14
15
  build_auth_url,
15
16
  browser_login,
16
17
  configure_docker_context,
17
18
  detect_manager_url,
19
+ docker_config_http_headers,
20
+ probe_manager_url,
18
21
  ensure_isolated_login,
19
22
  format_expiry,
20
23
  isolated_docker_config_dir,
@@ -254,7 +257,7 @@ def test_configure_docker_context_uses_skip_tls_verify_for_detected_tls(monkeypa
254
257
 
255
258
 
256
259
  def test_detect_manager_url_prefers_https_when_available(monkeypatch):
257
- def fake_probe(url, *, timeout=3, verify_tls=True):
260
+ def fake_probe(url, *, timeout=3, verify_tls=True, headers=None):
258
261
  if url == "https://172.31.0.6:2376":
259
262
  return True, False
260
263
  return False, False
@@ -268,7 +271,7 @@ def test_detect_manager_url_prefers_https_when_available(monkeypatch):
268
271
 
269
272
 
270
273
  def test_detect_manager_url_marks_skip_verify_for_self_signed_tls(monkeypatch):
271
- def fake_probe(url, *, timeout=3, verify_tls=True):
274
+ def fake_probe(url, *, timeout=3, verify_tls=True, headers=None):
272
275
  if url == "https://172.31.0.6:2376" and verify_tls:
273
276
  return False, True
274
277
  if url == "https://172.31.0.6:2376" and not verify_tls:
@@ -284,7 +287,7 @@ def test_detect_manager_url_marks_skip_verify_for_self_signed_tls(monkeypatch):
284
287
 
285
288
 
286
289
  def test_detect_manager_url_uses_http_default_port_when_https_unavailable(monkeypatch):
287
- def fake_probe(url, *, timeout=3, verify_tls=True):
290
+ def fake_probe(url, *, timeout=3, verify_tls=True, headers=None):
288
291
  if url == "http://172.31.0.6:2375":
289
292
  return True, False
290
293
  return False, False
@@ -297,6 +300,144 @@ def test_detect_manager_url_uses_http_default_port_when_https_unavailable(monkey
297
300
  assert skip_tls_verify is False
298
301
 
299
302
 
303
+ def test_detect_manager_url_skips_verified_probe_when_tls_mode_is_known(monkeypatch):
304
+ attempts = []
305
+
306
+ def fake_probe(url, *, timeout=3, verify_tls=True, headers=None):
307
+ attempts.append((url, verify_tls))
308
+ return (not verify_tls), False
309
+
310
+ monkeypatch.setattr("docker_stack.login.probe_manager_url", fake_probe)
311
+
312
+ url, skip_tls_verify = detect_manager_url("https://localhost:2376", skip_tls_verify=True)
313
+
314
+ assert url == "https://localhost:2376"
315
+ assert skip_tls_verify is True
316
+ # A verified handshake against a private CA is what produced UnknownCA alerts.
317
+ assert attempts == [("https://localhost:2376", False)]
318
+
319
+
320
+ def test_detect_manager_url_ignores_skip_hint_when_verification_is_required(monkeypatch):
321
+ attempts = []
322
+
323
+ def fake_probe(url, *, timeout=3, verify_tls=True, headers=None):
324
+ attempts.append((url, verify_tls))
325
+ return verify_tls, False
326
+
327
+ monkeypatch.setattr("docker_stack.login.probe_manager_url", fake_probe)
328
+
329
+ url, skip_tls_verify = detect_manager_url(
330
+ "https://localhost:2376",
331
+ verify_ssl=True,
332
+ skip_tls_verify=True,
333
+ )
334
+
335
+ assert url == "https://localhost:2376"
336
+ assert skip_tls_verify is False
337
+ assert attempts == [("https://localhost:2376", True)]
338
+
339
+
340
+ def test_probe_manager_url_sends_docker_config_headers(monkeypatch):
341
+ captured = {}
342
+
343
+ class FakeResponse:
344
+ def __enter__(self):
345
+ return self
346
+
347
+ def __exit__(self, *_args):
348
+ return False
349
+
350
+ def read(self, _size=None):
351
+ return b""
352
+
353
+ def fake_urlopen(request, timeout=None, context=None):
354
+ captured["url"] = request.full_url
355
+ captured["headers"] = dict(request.header_items())
356
+ return FakeResponse()
357
+
358
+ monkeypatch.setattr("docker_stack.login.urllib.request.urlopen", fake_urlopen)
359
+
360
+ assert probe_manager_url(
361
+ "https://localhost:2376",
362
+ headers={"Authorization": "Bearer token-value"},
363
+ ) == (True, False)
364
+ assert captured["url"] == "https://localhost:2376/_ping"
365
+ assert captured["headers"]["Authorization"] == "Bearer token-value"
366
+
367
+
368
+ def test_probe_manager_url_withholds_token_over_remote_cleartext(monkeypatch):
369
+ captured = {}
370
+
371
+ class FakeResponse:
372
+ def __enter__(self):
373
+ return self
374
+
375
+ def __exit__(self, *_args):
376
+ return False
377
+
378
+ def read(self, _size=None):
379
+ return b""
380
+
381
+ def fake_urlopen(request, timeout=None, context=None):
382
+ captured["headers"] = dict(request.header_items())
383
+ return FakeResponse()
384
+
385
+ monkeypatch.setattr("docker_stack.login.urllib.request.urlopen", fake_urlopen)
386
+
387
+ probe_manager_url("http://manager.example.com:2375", headers={"Authorization": "Bearer token-value"})
388
+
389
+ assert "Authorization" not in captured["headers"]
390
+
391
+ probe_manager_url("http://localhost:2375", headers={"Authorization": "Bearer token-value"})
392
+
393
+ assert captured["headers"]["Authorization"] == "Bearer token-value"
394
+
395
+
396
+ def test_docker_config_http_headers_reads_active_config(monkeypatch, tmp_path):
397
+ (tmp_path / "config.json").write_text(
398
+ json.dumps({"HttpHeaders": {"Authorization": "Bearer token-value", "X-Empty": " "}}),
399
+ encoding="utf-8",
400
+ )
401
+ monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path))
402
+
403
+ assert docker_config_http_headers() == {"Authorization": "Bearer token-value"}
404
+
405
+
406
+ def test_resolve_login_config_skips_probing_when_shell_exports_tls_mode(monkeypatch):
407
+ def fail_probe(*_args, **_kwargs):
408
+ raise AssertionError("the manager must not be probed when its TLS mode is known")
409
+
410
+ monkeypatch.setattr("docker_stack.login.probe_manager_url", fail_probe)
411
+ monkeypatch.setenv("DOCKER_MANAGER_URL", "https://localhost:2376")
412
+ monkeypatch.setenv(DOCKER_MANAGER_SKIP_TLS_VERIFY_ENV, "1")
413
+
414
+ config = resolve_login_config(manager_target="https://localhost:2376")
415
+
416
+ assert config.manager_url == "https://localhost:2376"
417
+ assert config.skip_tls_verify is True
418
+
419
+
420
+ def test_resolve_login_config_reuses_context_tls_mode(monkeypatch):
421
+ def fail_probe(*_args, **_kwargs):
422
+ raise AssertionError("the manager must not be probed when its TLS mode is known")
423
+
424
+ monkeypatch.setattr("docker_stack.login.probe_manager_url", fail_probe)
425
+ monkeypatch.setattr(
426
+ "docker_stack.login._inspect_docker_context",
427
+ lambda name, docker_config_dir=None: {
428
+ "Name": name,
429
+ "Endpoints": {"docker": {"Host": "tcp://localhost:2376", "SkipTLSVerify": True}},
430
+ },
431
+ )
432
+ monkeypatch.delenv(DOCKER_MANAGER_SKIP_TLS_VERIFY_ENV, raising=False)
433
+ monkeypatch.setenv("DOCKER_CONTEXT", "local")
434
+
435
+ config = resolve_login_config(manager_target="tcp://localhost:2376")
436
+
437
+ assert config.manager_url == "https://localhost:2376"
438
+ assert config.skip_tls_verify is True
439
+
440
+
300
441
  def test_detect_manager_url_verify_ssl_requires_https(monkeypatch):
301
442
  with pytest.raises(RuntimeError, match="requires an HTTPS manager URL"):
302
443
  detect_manager_url("http://172.31.0.6", verify_ssl=True)
@@ -361,7 +502,7 @@ def test_resolve_login_config_ignores_malformed_context_host(monkeypatch):
361
502
 
362
503
 
363
504
  def test_resolve_login_config_detects_tls_for_positional_target(monkeypatch):
364
- monkeypatch.setattr("docker_stack.login.detect_manager_url", lambda value, verify_ssl=False: ("https://172.31.0.6:2378", True))
505
+ monkeypatch.setattr("docker_stack.login.detect_manager_url", lambda value, verify_ssl=False, **_kwargs: ("https://172.31.0.6:2378", True))
365
506
 
366
507
  config = resolve_login_config(manager_target="172.31.0.6:2378", context_name="office")
367
508
 
@@ -375,7 +516,7 @@ def test_resolve_login_config_prefers_named_context_for_portless_target(monkeypa
375
516
  "docker_stack.login.docker_context_target",
376
517
  lambda context_name, docker_config_dir=None: "tcp://172.31.0.6:2378" if context_name == "office" else None,
377
518
  )
378
- monkeypatch.setattr("docker_stack.login.detect_manager_url", lambda value, verify_ssl=False: ("https://172.31.0.6:2378", True))
519
+ monkeypatch.setattr("docker_stack.login.detect_manager_url", lambda value, verify_ssl=False, **_kwargs: ("https://172.31.0.6:2378", True))
379
520
 
380
521
  config = resolve_login_config(manager_target="office")
381
522
 
@@ -394,7 +535,7 @@ def test_resolve_login_config_prefers_isolated_named_context(monkeypatch, tmp_pa
394
535
  )
395
536
  monkeypatch.setattr(
396
537
  "docker_stack.login.detect_manager_url",
397
- lambda value, verify_ssl=False: ("https://172.31.3.3:2376", False),
538
+ lambda value, verify_ssl=False, **_kwargs: ("https://172.31.3.3:2376", False),
398
539
  )
399
540
 
400
541
  config = resolve_login_config(manager_target="saas-a")
@@ -409,7 +550,7 @@ def test_resolve_login_config_uses_current_context_target(monkeypatch):
409
550
  "docker_stack.login.current_docker_context_target",
410
551
  lambda: ("office", "tcp://172.31.0.6:2378"),
411
552
  )
412
- monkeypatch.setattr("docker_stack.login.detect_manager_url", lambda value, verify_ssl=False: ("https://172.31.0.6:2378", True))
553
+ monkeypatch.setattr("docker_stack.login.detect_manager_url", lambda value, verify_ssl=False, **_kwargs: ("https://172.31.0.6:2378", True))
413
554
 
414
555
  config = resolve_login_config()
415
556
 
@@ -434,7 +575,7 @@ def test_resolve_shell_login_config_uses_persisted_context(monkeypatch):
434
575
  "tcp://172.31.0.6:2378" if docker_config_dir == isolated_docker_config_dir("office") else None
435
576
  ),
436
577
  )
437
- monkeypatch.setattr("docker_stack.login.detect_manager_url", lambda value: ("https://172.31.0.6:2378", True))
578
+ monkeypatch.setattr("docker_stack.login.detect_manager_url", lambda value, **_kwargs: ("https://172.31.0.6:2378", True))
438
579
 
439
580
  config = resolve_shell_login_config(shell_name="office", manager_target=None, context_name=None)
440
581
 
File without changes
File without changes