docker-stack 2.2.1__py3-none-any.whl → 2.2.2__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.
docker_stack/cli.py CHANGED
@@ -1752,6 +1752,7 @@ def open_context_shell(config_dir: Path, context_name: str) -> int:
1752
1752
  env.pop(key, None)
1753
1753
  env["DOCKER_CONFIG"] = str(config_dir)
1754
1754
  env["DOCKER_CONTEXT"] = context_name
1755
+ env["DOCKER_MANAGER_CONTEXT_NAME"] = context_name
1755
1756
  shell = env.get("SHELL", "").strip() or "/bin/bash"
1756
1757
  return subprocess.run([shell, "-i"], check=False, env=env).returncode
1757
1758
 
docker_stack/login.py CHANGED
@@ -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.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
@@ -1,20 +1,20 @@
1
1
  docker_stack/__init__.py,sha256=qgHdW8ZDBlyk6FzIf8Mld_cJD1SjOfqo2dELg4pK668,880
2
- docker_stack/cli.py,sha256=km17ekSagplOukDPG0CPfDeMXmWnue8nKKYIcaIx5gE,92351
2
+ docker_stack/cli.py,sha256=SyIReri1H3VGZ_07dJ6hfVvodtYSH5t5FA36KzvjyJo,92405
3
3
  docker_stack/command_runner.py,sha256=mNaUAVKtrJbji_5ARVPLKhc92avqllNKrVQj1A6S0dA,1252
4
4
  docker_stack/compose.py,sha256=_fAVesjyW5ecid46sYYhcu02yF1vqknDSCFYflJPDOE,534
5
5
  docker_stack/docker_objects.py,sha256=U6szytv5gKrmBYKqThSeMMXdiJ2u4STIQLkeqQ-Fn4Q,12103
6
6
  docker_stack/envsubst.py,sha256=gdEUpsjWJeNbbahySk56vUdQh1nns85o3LbU6SUBo3I,8337
7
7
  docker_stack/envsubst_merge.py,sha256=TwOu8BducG0rWnSreJGwesNi-VghCetljcf-aWceE8M,4971
8
8
  docker_stack/helpers.py,sha256=nJHaIS1vtFyPYclXVl3-Qd-1h55jnSy1LwdATDrF4hk,5953
9
- docker_stack/login.py,sha256=SNHL2S3InQ7Qc3ITV1VySazix1f7H0OD7IvTPBTgF4Y,33977
9
+ docker_stack/login.py,sha256=jnrmrDh79Brlzj89YdZ1sGpg-ex16yb3ZN47eFpcW5g,41754
10
10
  docker_stack/manager_api.py,sha256=xb8RwY4LEn1usg9qhG9AfiCvBJ2tpQawVMLRwF3vYHY,28361
11
11
  docker_stack/markers.py,sha256=gqStnr0tNWZ1kzqvREc2jVQVQrevheShgJtL5QzSx4g,2388
12
12
  docker_stack/merge_conf.py,sha256=Pmsabcgf3SDyaWvf2OLsBPodzEqsvOIjj9xzn_qWXH0,1917
13
13
  docker_stack/registry.py,sha256=sWC1J9JDIrcYyhei1POYCZHJ0xUCKC2pveisSHMgYsQ,9036
14
- docker_stack/shell_auth.py,sha256=em0_lo1qnLIB4KHYqn4vQvqwKe7HkV9NM_I0VQTDDQ4,33082
14
+ docker_stack/shell_auth.py,sha256=UUu9y4wMFKfc_j86yGcasvS_TLZ2uV4kv0T4XKTMQBQ,33424
15
15
  docker_stack/url_parser.py,sha256=Sk8GQE0nEiwCkijp1OltP2AfgtKEmM2hybcH_rBhfiI,6824
16
- docker_stack-2.2.1.dist-info/METADATA,sha256=ElAaB4cyaLC7qo0tLL3Zp-PkcnOCuUB_gX15FRdxMLE,13821
17
- docker_stack-2.2.1.dist-info/WHEEL,sha256=SmOxYU7pzNKBqASvQJ7DjX3XGUF92lrGhMb3R6_iiqI,91
18
- docker_stack-2.2.1.dist-info/entry_points.txt,sha256=mpe2RwIguARsosXIUBQEN2pKU53URqZCh2S4ATwDFL4,55
19
- docker_stack-2.2.1.dist-info/top_level.txt,sha256=zT6TPL54cLrt9LO_MNkhEpGGOmsoe2HV6Na5Ohy3_2c,13
20
- docker_stack-2.2.1.dist-info/RECORD,,
16
+ docker_stack-2.2.2.dist-info/METADATA,sha256=NlKnxEWXo3HPuUOTTxzawm3xhYePJPDK3pgfQmK3u6c,13821
17
+ docker_stack-2.2.2.dist-info/WHEEL,sha256=SmOxYU7pzNKBqASvQJ7DjX3XGUF92lrGhMb3R6_iiqI,91
18
+ docker_stack-2.2.2.dist-info/entry_points.txt,sha256=mpe2RwIguARsosXIUBQEN2pKU53URqZCh2S4ATwDFL4,55
19
+ docker_stack-2.2.2.dist-info/top_level.txt,sha256=zT6TPL54cLrt9LO_MNkhEpGGOmsoe2HV6Na5Ohy3_2c,13
20
+ docker_stack-2.2.2.dist-info/RECORD,,