docker-stack 2.2.1__tar.gz → 2.2.3__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.1 → docker_stack-2.2.3}/PKG-INFO +1 -1
  2. {docker_stack-2.2.1 → docker_stack-2.2.3}/docker_stack/cli.py +21 -0
  3. {docker_stack-2.2.1 → docker_stack-2.2.3}/docker_stack/login.py +231 -14
  4. {docker_stack-2.2.1 → docker_stack-2.2.3}/docker_stack/shell_auth.py +5 -0
  5. {docker_stack-2.2.1 → docker_stack-2.2.3}/docker_stack.egg-info/PKG-INFO +1 -1
  6. {docker_stack-2.2.1 → docker_stack-2.2.3}/setup.py +1 -1
  7. {docker_stack-2.2.1 → docker_stack-2.2.3}/tests/test_docker_stack.py +51 -0
  8. {docker_stack-2.2.1 → docker_stack-2.2.3}/tests/test_login.py +149 -8
  9. {docker_stack-2.2.1 → docker_stack-2.2.3}/README.md +0 -0
  10. {docker_stack-2.2.1 → docker_stack-2.2.3}/docker_stack/__init__.py +0 -0
  11. {docker_stack-2.2.1 → docker_stack-2.2.3}/docker_stack/command_runner.py +0 -0
  12. {docker_stack-2.2.1 → docker_stack-2.2.3}/docker_stack/compose.py +0 -0
  13. {docker_stack-2.2.1 → docker_stack-2.2.3}/docker_stack/docker_objects.py +0 -0
  14. {docker_stack-2.2.1 → docker_stack-2.2.3}/docker_stack/envsubst.py +0 -0
  15. {docker_stack-2.2.1 → docker_stack-2.2.3}/docker_stack/envsubst_merge.py +0 -0
  16. {docker_stack-2.2.1 → docker_stack-2.2.3}/docker_stack/helpers.py +0 -0
  17. {docker_stack-2.2.1 → docker_stack-2.2.3}/docker_stack/manager_api.py +0 -0
  18. {docker_stack-2.2.1 → docker_stack-2.2.3}/docker_stack/markers.py +0 -0
  19. {docker_stack-2.2.1 → docker_stack-2.2.3}/docker_stack/merge_conf.py +0 -0
  20. {docker_stack-2.2.1 → docker_stack-2.2.3}/docker_stack/registry.py +0 -0
  21. {docker_stack-2.2.1 → docker_stack-2.2.3}/docker_stack/url_parser.py +0 -0
  22. {docker_stack-2.2.1 → docker_stack-2.2.3}/docker_stack.egg-info/SOURCES.txt +0 -0
  23. {docker_stack-2.2.1 → docker_stack-2.2.3}/docker_stack.egg-info/dependency_links.txt +0 -0
  24. {docker_stack-2.2.1 → docker_stack-2.2.3}/docker_stack.egg-info/entry_points.txt +0 -0
  25. {docker_stack-2.2.1 → docker_stack-2.2.3}/docker_stack.egg-info/requires.txt +0 -0
  26. {docker_stack-2.2.1 → docker_stack-2.2.3}/docker_stack.egg-info/top_level.txt +0 -0
  27. {docker_stack-2.2.1 → docker_stack-2.2.3}/pyproject.toml +0 -0
  28. {docker_stack-2.2.1 → docker_stack-2.2.3}/setup.cfg +0 -0
  29. {docker_stack-2.2.1 → docker_stack-2.2.3}/tests/test_docker_objects.py +0 -0
  30. {docker_stack-2.2.1 → docker_stack-2.2.3}/tests/test_load_env.py +0 -0
  31. {docker_stack-2.2.1 → docker_stack-2.2.3}/tests/test_manager_api.py +0 -0
  32. {docker_stack-2.2.1 → docker_stack-2.2.3}/tests/test_node_ls.py +0 -0
  33. {docker_stack-2.2.1 → docker_stack-2.2.3}/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.1
3
+ Version: 2.2.3
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
@@ -266,15 +266,35 @@ def _format_names(names: object, *, no_trunc: bool) -> str:
266
266
  return ",".join(values)
267
267
 
268
268
 
269
+ DOCKER_HUB_NAME_PREFIXES = (
270
+ "docker.io/library/",
271
+ "index.docker.io/library/",
272
+ "docker.io/",
273
+ "index.docker.io/",
274
+ )
275
+
276
+
277
+ def _familiar_image_name(value: str) -> str:
278
+ for prefix in DOCKER_HUB_NAME_PREFIXES:
279
+ if value.startswith(prefix):
280
+ return value[len(prefix) :]
281
+ return value
282
+
283
+
269
284
  def _format_image(image: object, image_id: object, *, no_trunc: bool) -> str:
270
285
  value = str(image or "")
271
286
  if not value:
272
287
  return "<no image>"
273
288
  if no_trunc:
274
289
  return value
290
+ if value.startswith("sha256:"):
291
+ return _short_id(value)
275
292
  identifier = str(image_id or "")
276
293
  if identifier and _short_id(identifier) == _short_id(value):
277
294
  return _short_id(value)
295
+ if "@" in value:
296
+ # docker ps keeps `name[:tag]` and drops the pinned digest unless --no-trunc.
297
+ return _familiar_image_name(value.split("@", 1)[0])
278
298
  return value
279
299
 
280
300
 
@@ -1752,6 +1772,7 @@ def open_context_shell(config_dir: Path, context_name: str) -> int:
1752
1772
  env.pop(key, None)
1753
1773
  env["DOCKER_CONFIG"] = str(config_dir)
1754
1774
  env["DOCKER_CONTEXT"] = context_name
1775
+ env["DOCKER_MANAGER_CONTEXT_NAME"] = context_name
1755
1776
  shell = env.get("SHELL", "").strip() or "/bin/bash"
1756
1777
  return subprocess.run([shell, "-i"], check=False, env=env).returncode
1757
1778
 
@@ -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.1
3
+ Version: 2.2.3
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.1",
5
+ version="2.2.3",
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",
@@ -131,6 +131,57 @@ def test_managed_docker_ps_matches_docker_columns(monkeypatch, capsys):
131
131
  assert row == row.rstrip()
132
132
 
133
133
 
134
+ @pytest.mark.parametrize(
135
+ "image,expected,expected_no_trunc",
136
+ [
137
+ (
138
+ "registry.example.com/app/design-system:af037b33@sha256:15d623cfa2f856c8101f9f3c47003d412f9b2bf72c69c95d5dff07a74e5f7b60",
139
+ "registry.example.com/app/design-system:af037b33",
140
+ "registry.example.com/app/design-system:af037b33@sha256:15d623cfa2f856c8101f9f3c47003d412f9b2bf72c69c95d5dff07a74e5f7b60",
141
+ ),
142
+ (
143
+ "docker.io/library/alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b",
144
+ "alpine",
145
+ "docker.io/library/alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b",
146
+ ),
147
+ ("nginx:1.27", "nginx:1.27", "nginx:1.27"),
148
+ (
149
+ "sha256:0e901e68141fd02f237cf63eb842529f8a9500636a9419e3cf4fb986b8fe3d5d",
150
+ "0e901e68141f",
151
+ "sha256:0e901e68141fd02f237cf63eb842529f8a9500636a9419e3cf4fb986b8fe3d5d",
152
+ ),
153
+ ],
154
+ )
155
+ def test_managed_docker_ps_drops_pinned_digest_like_docker(monkeypatch, capsys, image, expected, expected_no_trunc):
156
+ client = SimpleNamespace(
157
+ supports=lambda feature: feature == "cluster_container_cli_v1",
158
+ list_containers=lambda **_kwargs: {
159
+ "containers": [
160
+ {
161
+ "node_name": "worker-02",
162
+ "docker": {
163
+ "Id": "abcdef1234567890",
164
+ "Image": image,
165
+ "Command": "sleep",
166
+ "Created": int(time.time()) - 60,
167
+ "Status": "Up 1 minute",
168
+ "Ports": [],
169
+ "Names": ["/svc.1.abc"],
170
+ },
171
+ }
172
+ ],
173
+ "node_errors": [],
174
+ },
175
+ )
176
+ monkeypatch.setattr("docker_stack.cli.discover_manager_client", lambda: client)
177
+
178
+ assert _managed_docker(["ps"]) == 0
179
+ assert capsys.readouterr().out.splitlines()[1].split()[1] == expected
180
+
181
+ assert _managed_docker(["ps", "--no-trunc"]) == 0
182
+ assert capsys.readouterr().out.splitlines()[1].split()[1] == expected_no_trunc
183
+
184
+
134
185
  def test_managed_docker_ps_forwards_global_latest_and_limit(monkeypatch):
135
186
  calls = []
136
187
  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