docker-stack 2.2.0__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 +154 -29
- docker_stack/login.py +231 -14
- docker_stack/shell_auth.py +5 -0
- {docker_stack-2.2.0.dist-info → docker_stack-2.2.2.dist-info}/METADATA +1 -1
- {docker_stack-2.2.0.dist-info → docker_stack-2.2.2.dist-info}/RECORD +8 -8
- {docker_stack-2.2.0.dist-info → docker_stack-2.2.2.dist-info}/WHEEL +0 -0
- {docker_stack-2.2.0.dist-info → docker_stack-2.2.2.dist-info}/entry_points.txt +0 -0
- {docker_stack-2.2.0.dist-info → docker_stack-2.2.2.dist-info}/top_level.txt +0 -0
docker_stack/cli.py
CHANGED
|
@@ -137,33 +137,155 @@ def _select_node(value: str) -> str:
|
|
|
137
137
|
return hostname
|
|
138
138
|
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
COMMAND_DISPLAY_WIDTH = 20
|
|
141
|
+
SIZE_UNITS = ("B", "kB", "MB", "GB", "TB", "PB")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _human_duration(timestamp: object) -> str:
|
|
145
|
+
"""Mirror docker's units.HumanDuration for the CREATED column."""
|
|
141
146
|
try:
|
|
142
|
-
seconds =
|
|
147
|
+
seconds = time.time() - float(timestamp)
|
|
143
148
|
except (TypeError, ValueError):
|
|
144
|
-
return "
|
|
149
|
+
return ""
|
|
150
|
+
if seconds < 1:
|
|
151
|
+
return "Less than a second ago"
|
|
152
|
+
if int(seconds) == 1:
|
|
153
|
+
return "1 second ago"
|
|
145
154
|
if seconds < 60:
|
|
146
|
-
return f"{seconds} seconds ago"
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
155
|
+
return f"{int(seconds)} seconds ago"
|
|
156
|
+
minutes = int(seconds // 60)
|
|
157
|
+
if minutes == 1:
|
|
158
|
+
return "About a minute ago"
|
|
159
|
+
if minutes < 60:
|
|
160
|
+
return f"{minutes} minutes ago"
|
|
161
|
+
hours = int(seconds / 3600 + 0.5)
|
|
162
|
+
if hours == 1:
|
|
163
|
+
return "About an hour ago"
|
|
164
|
+
if hours < 48:
|
|
165
|
+
return f"{hours} hours ago"
|
|
166
|
+
if hours < 24 * 7 * 2:
|
|
167
|
+
return f"{hours // 24} days ago"
|
|
168
|
+
if hours < 24 * 30 * 2:
|
|
169
|
+
return f"{hours // 24 // 7} weeks ago"
|
|
170
|
+
if hours < 24 * 365 * 2:
|
|
171
|
+
return f"{hours // 24 // 30} months ago"
|
|
172
|
+
return f"{hours // 24 // 365} years ago"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _human_size(value: object) -> str:
|
|
176
|
+
try:
|
|
177
|
+
size = float(value)
|
|
178
|
+
except (TypeError, ValueError):
|
|
179
|
+
return ""
|
|
180
|
+
index = 0
|
|
181
|
+
while size >= 1000.0 and index < len(SIZE_UNITS) - 1:
|
|
182
|
+
size /= 1000.0
|
|
183
|
+
index += 1
|
|
184
|
+
return f"{size:.3g}{SIZE_UNITS[index]}"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _quote_command(value: str) -> str:
|
|
188
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
189
|
+
return f'"{escaped}"'
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _ellipsis(value: str, width: int) -> str:
|
|
193
|
+
if width <= 0:
|
|
194
|
+
return ""
|
|
195
|
+
if len(value) <= width:
|
|
196
|
+
return value
|
|
197
|
+
return value[: width - 1] + "…"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _form_port_group(key: str, first: int, last: int) -> str:
|
|
201
|
+
parts = key.split("/")
|
|
202
|
+
address = ""
|
|
203
|
+
protocol = parts[0]
|
|
204
|
+
if len(parts) > 1:
|
|
205
|
+
address = parts[0]
|
|
206
|
+
protocol = parts[1]
|
|
207
|
+
group = str(first) if first == last else f"{first}-{last}"
|
|
208
|
+
if address:
|
|
209
|
+
group = f"{address}:{group}->{group}"
|
|
210
|
+
return f"{group}/{protocol}"
|
|
152
211
|
|
|
153
212
|
|
|
154
213
|
def _format_ports(ports: object) -> str:
|
|
214
|
+
"""Mirror docker's api/types.DisplayablePorts for the PORTS column."""
|
|
155
215
|
if not isinstance(ports, list):
|
|
156
216
|
return ""
|
|
157
|
-
|
|
217
|
+
entries = []
|
|
158
218
|
for port in ports:
|
|
159
219
|
if not isinstance(port, dict):
|
|
160
220
|
continue
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
221
|
+
try:
|
|
222
|
+
private = int(port.get("PrivatePort"))
|
|
223
|
+
except (TypeError, ValueError):
|
|
224
|
+
continue
|
|
225
|
+
try:
|
|
226
|
+
public = int(port.get("PublicPort"))
|
|
227
|
+
except (TypeError, ValueError):
|
|
228
|
+
public = None
|
|
229
|
+
entries.append((private, str(port.get("Type") or "tcp"), str(port.get("IP") or ""), public))
|
|
230
|
+
entries.sort(key=lambda entry: entry[0])
|
|
231
|
+
groups: Dict[str, List[int]] = {}
|
|
232
|
+
group_keys: List[str] = []
|
|
233
|
+
result: List[str] = []
|
|
234
|
+
host_mappings: List[str] = []
|
|
235
|
+
for private, protocol, address, public in entries:
|
|
236
|
+
key = protocol
|
|
237
|
+
if address and public:
|
|
238
|
+
if public != private:
|
|
239
|
+
host_mappings.append(f"{address}:{public}->{private}/{protocol}")
|
|
240
|
+
continue
|
|
241
|
+
key = f"{address}/{protocol}"
|
|
242
|
+
group = groups.get(key)
|
|
243
|
+
if group is None:
|
|
244
|
+
groups[key] = [private, private]
|
|
245
|
+
group_keys.append(key)
|
|
246
|
+
continue
|
|
247
|
+
if private == group[1] + 1:
|
|
248
|
+
group[1] = private
|
|
249
|
+
continue
|
|
250
|
+
result.append(_form_port_group(key, group[0], group[1]))
|
|
251
|
+
groups[key] = [private, private]
|
|
252
|
+
for key in group_keys:
|
|
253
|
+
first, last = groups[key]
|
|
254
|
+
result.append(_form_port_group(key, first, last))
|
|
255
|
+
result.extend(host_mappings)
|
|
256
|
+
return ", ".join(result)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _format_names(names: object, *, no_trunc: bool) -> str:
|
|
260
|
+
values = [str(name).lstrip("/") for name in names] if isinstance(names, list) else []
|
|
261
|
+
if not no_trunc:
|
|
262
|
+
for name in values:
|
|
263
|
+
if "/" not in name:
|
|
264
|
+
values = [name]
|
|
265
|
+
break
|
|
266
|
+
return ",".join(values)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _format_image(image: object, image_id: object, *, no_trunc: bool) -> str:
|
|
270
|
+
value = str(image or "")
|
|
271
|
+
if not value:
|
|
272
|
+
return "<no image>"
|
|
273
|
+
if no_trunc:
|
|
274
|
+
return value
|
|
275
|
+
identifier = str(image_id or "")
|
|
276
|
+
if identifier and _short_id(identifier) == _short_id(value):
|
|
277
|
+
return _short_id(value)
|
|
278
|
+
return value
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _short_id(value: str) -> str:
|
|
282
|
+
return value[len("sha256:") :][:12] if value.startswith("sha256:") else value[:12]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _print_table(columns: List[str], rows: List[Dict[str, str]]) -> None:
|
|
286
|
+
widths = {column: max(len(column), max((len(row[column]) for row in rows), default=0)) for column in columns}
|
|
287
|
+
for values in [{column: column for column in columns}, *rows]:
|
|
288
|
+
print(" ".join(values[column].ljust(widths[column]) for column in columns).rstrip())
|
|
167
289
|
|
|
168
290
|
|
|
169
291
|
def _print_cluster_containers(payload: Dict[str, object], *, no_trunc: bool = False, show_size: bool = False) -> int:
|
|
@@ -172,29 +294,31 @@ def _print_cluster_containers(payload: Dict[str, object], *, no_trunc: bool = Fa
|
|
|
172
294
|
if not isinstance(entry, dict) or not isinstance(entry.get("docker"), dict):
|
|
173
295
|
continue
|
|
174
296
|
item = entry["docker"]
|
|
175
|
-
container_id = str(item.get("Id") or "
|
|
176
|
-
|
|
297
|
+
container_id = str(item.get("Id") or "")
|
|
298
|
+
command = str(item.get("Command") or "")
|
|
177
299
|
row = {
|
|
178
300
|
"CONTAINER ID": container_id if no_trunc else container_id[:12],
|
|
179
|
-
"IMAGE":
|
|
180
|
-
"COMMAND":
|
|
181
|
-
"CREATED":
|
|
182
|
-
"STATUS": str(item.get("Status") or item.get("State") or "
|
|
301
|
+
"IMAGE": _format_image(item.get("Image"), item.get("ImageID"), no_trunc=no_trunc),
|
|
302
|
+
"COMMAND": _quote_command(command if no_trunc else _ellipsis(command, COMMAND_DISPLAY_WIDTH)),
|
|
303
|
+
"CREATED": _human_duration(item.get("Created")),
|
|
304
|
+
"STATUS": str(item.get("Status") or item.get("State") or ""),
|
|
183
305
|
"PORTS": _format_ports(item.get("Ports")),
|
|
184
|
-
"NAMES":
|
|
185
|
-
"NODE": str(entry.get("node_name") or entry.get("node_id") or "
|
|
306
|
+
"NAMES": _format_names(item.get("Names"), no_trunc=no_trunc),
|
|
307
|
+
"NODE": str(entry.get("node_name") or entry.get("node_id") or ""),
|
|
186
308
|
}
|
|
187
309
|
if show_size:
|
|
188
|
-
|
|
310
|
+
size = _human_size(item.get("SizeRw") or 0)
|
|
311
|
+
try:
|
|
312
|
+
virtual = float(item.get("SizeRootFs") or 0)
|
|
313
|
+
except (TypeError, ValueError):
|
|
314
|
+
virtual = 0
|
|
315
|
+
row["SIZE"] = f"{size} (virtual {_human_size(virtual)})" if virtual > 0 else size
|
|
189
316
|
rows.append(row)
|
|
190
317
|
columns = ["CONTAINER ID", "IMAGE", "COMMAND", "CREATED", "STATUS", "PORTS", "NAMES"]
|
|
191
318
|
if show_size:
|
|
192
319
|
columns.append("SIZE")
|
|
193
320
|
columns.append("NODE")
|
|
194
|
-
|
|
195
|
-
print(" ".join(column.ljust(widths[column]) for column in columns))
|
|
196
|
-
for row in rows:
|
|
197
|
-
print(" ".join(row[column].ljust(widths[column]) for column in columns))
|
|
321
|
+
_print_table(columns, rows)
|
|
198
322
|
errors = payload.get("node_errors") if isinstance(payload.get("node_errors"), list) else []
|
|
199
323
|
for error in errors:
|
|
200
324
|
if isinstance(error, dict):
|
|
@@ -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
|
|
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
|
-
|
|
115
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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,
|
docker_stack/shell_auth.py
CHANGED
|
@@ -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,20 +1,20 @@
|
|
|
1
1
|
docker_stack/__init__.py,sha256=qgHdW8ZDBlyk6FzIf8Mld_cJD1SjOfqo2dELg4pK668,880
|
|
2
|
-
docker_stack/cli.py,sha256=
|
|
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=
|
|
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=
|
|
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.
|
|
17
|
-
docker_stack-2.2.
|
|
18
|
-
docker_stack-2.2.
|
|
19
|
-
docker_stack-2.2.
|
|
20
|
-
docker_stack-2.2.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|