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