docker-stack 2.2.2__tar.gz → 2.2.4__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.2 → docker_stack-2.2.4}/PKG-INFO +1 -1
  2. {docker_stack-2.2.2 → docker_stack-2.2.4}/docker_stack/cli.py +105 -11
  3. {docker_stack-2.2.2 → docker_stack-2.2.4}/docker_stack.egg-info/PKG-INFO +1 -1
  4. {docker_stack-2.2.2 → docker_stack-2.2.4}/setup.py +1 -1
  5. {docker_stack-2.2.2 → docker_stack-2.2.4}/tests/test_docker_stack.py +136 -11
  6. {docker_stack-2.2.2 → docker_stack-2.2.4}/README.md +0 -0
  7. {docker_stack-2.2.2 → docker_stack-2.2.4}/docker_stack/__init__.py +0 -0
  8. {docker_stack-2.2.2 → docker_stack-2.2.4}/docker_stack/command_runner.py +0 -0
  9. {docker_stack-2.2.2 → docker_stack-2.2.4}/docker_stack/compose.py +0 -0
  10. {docker_stack-2.2.2 → docker_stack-2.2.4}/docker_stack/docker_objects.py +0 -0
  11. {docker_stack-2.2.2 → docker_stack-2.2.4}/docker_stack/envsubst.py +0 -0
  12. {docker_stack-2.2.2 → docker_stack-2.2.4}/docker_stack/envsubst_merge.py +0 -0
  13. {docker_stack-2.2.2 → docker_stack-2.2.4}/docker_stack/helpers.py +0 -0
  14. {docker_stack-2.2.2 → docker_stack-2.2.4}/docker_stack/login.py +0 -0
  15. {docker_stack-2.2.2 → docker_stack-2.2.4}/docker_stack/manager_api.py +0 -0
  16. {docker_stack-2.2.2 → docker_stack-2.2.4}/docker_stack/markers.py +0 -0
  17. {docker_stack-2.2.2 → docker_stack-2.2.4}/docker_stack/merge_conf.py +0 -0
  18. {docker_stack-2.2.2 → docker_stack-2.2.4}/docker_stack/registry.py +0 -0
  19. {docker_stack-2.2.2 → docker_stack-2.2.4}/docker_stack/shell_auth.py +0 -0
  20. {docker_stack-2.2.2 → docker_stack-2.2.4}/docker_stack/url_parser.py +0 -0
  21. {docker_stack-2.2.2 → docker_stack-2.2.4}/docker_stack.egg-info/SOURCES.txt +0 -0
  22. {docker_stack-2.2.2 → docker_stack-2.2.4}/docker_stack.egg-info/dependency_links.txt +0 -0
  23. {docker_stack-2.2.2 → docker_stack-2.2.4}/docker_stack.egg-info/entry_points.txt +0 -0
  24. {docker_stack-2.2.2 → docker_stack-2.2.4}/docker_stack.egg-info/requires.txt +0 -0
  25. {docker_stack-2.2.2 → docker_stack-2.2.4}/docker_stack.egg-info/top_level.txt +0 -0
  26. {docker_stack-2.2.2 → docker_stack-2.2.4}/pyproject.toml +0 -0
  27. {docker_stack-2.2.2 → docker_stack-2.2.4}/setup.cfg +0 -0
  28. {docker_stack-2.2.2 → docker_stack-2.2.4}/tests/test_docker_objects.py +0 -0
  29. {docker_stack-2.2.2 → docker_stack-2.2.4}/tests/test_load_env.py +0 -0
  30. {docker_stack-2.2.2 → docker_stack-2.2.4}/tests/test_login.py +0 -0
  31. {docker_stack-2.2.2 → docker_stack-2.2.4}/tests/test_manager_api.py +0 -0
  32. {docker_stack-2.2.2 → docker_stack-2.2.4}/tests/test_node_ls.py +0 -0
  33. {docker_stack-2.2.2 → docker_stack-2.2.4}/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.2
3
+ Version: 2.2.4
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
 
@@ -330,6 +350,38 @@ def _print_cluster_containers(payload: Dict[str, object], *, no_trunc: bool = Fa
330
350
  return 1 if errors else 0
331
351
 
332
352
 
353
+ def _exec_process(command: List[str], env: Optional[Dict[str, str]] = None) -> int:
354
+ """Replace this process with ``command`` so it owns the terminal, its signals, and its exit code.
355
+
356
+ Proxying through ``subprocess.run`` keeps Python in the foreground process group: Ctrl+C is
357
+ delivered to the proxy as well as to the child, which surfaces as a ``KeyboardInterrupt``
358
+ traceback, and a signal-killed child reports a negative return code that ``sys.exit`` turns into
359
+ a nonsense status (``-2`` becomes ``254`` instead of ``130``). ``exec`` removes the middleman.
360
+ Only POSIX execs: on Windows ``exec`` detaches the child and hands control straight back, which
361
+ would break the caller's wait semantics.
362
+
363
+ Returns only on failure; the caller keeps its ``int`` contract for that case.
364
+ """
365
+ if os.name != "posix":
366
+ return subprocess.run(command, check=False, env=env).returncode
367
+ # exec discards Python's buffers, so anything already printed has to reach the fd first.
368
+ sys.stdout.flush()
369
+ sys.stderr.flush()
370
+ try:
371
+ if env is None:
372
+ os.execvp(command[0], command)
373
+ else:
374
+ os.execvpe(command[0], command, env)
375
+ except OSError as exc:
376
+ print(f"docker-stack: cannot execute {command[0]}: {exc}", file=sys.stderr)
377
+ return 127
378
+
379
+
380
+ def _exec_docker(values: List[str]) -> int:
381
+ """Hand the command to the real Docker CLI, replacing this process."""
382
+ return _exec_process(["docker", *values])
383
+
384
+
333
385
  def _managed_docker(arguments: List[str]) -> int:
334
386
  values = list(arguments)
335
387
  if values and values[0] == "--":
@@ -358,11 +410,11 @@ def _managed_docker(arguments: List[str]) -> int:
358
410
  value in {"-q", "--quiet", "--format"} or value.startswith("--format=")
359
411
  for value in command
360
412
  ):
361
- return subprocess.run(["docker", *values], check=False).returncode
413
+ return _exec_docker(values)
362
414
  try:
363
415
  client = discover_manager_client()
364
416
  if client is None or not client.supports(FEATURE_CLUSTER_CONTAINER_CLI):
365
- return subprocess.run(["docker", *values], check=False).returncode
417
+ return _exec_docker(values)
366
418
  filters = []
367
419
  all_containers = False
368
420
  latest = False
@@ -391,7 +443,7 @@ def _managed_docker(arguments: List[str]) -> int:
391
443
  elif value.startswith("--last="):
392
444
  limit = int(value.split("=", 1)[1])
393
445
  else:
394
- return subprocess.run(["docker", *values], check=False).returncode
446
+ return _exec_docker(values)
395
447
  index += 1
396
448
  payload = client.list_containers(
397
449
  all_containers=all_containers,
@@ -1754,7 +1806,7 @@ def open_context_shell(config_dir: Path, context_name: str) -> int:
1754
1806
  env["DOCKER_CONTEXT"] = context_name
1755
1807
  env["DOCKER_MANAGER_CONTEXT_NAME"] = context_name
1756
1808
  shell = env.get("SHELL", "").strip() or "/bin/bash"
1757
- return subprocess.run([shell, "-i"], check=False, env=env).returncode
1809
+ return _exec_process([shell, "-i"], env=env)
1758
1810
 
1759
1811
 
1760
1812
  def active_shell_config_dir(context_name: str) -> Optional[Path]:
@@ -1785,6 +1837,29 @@ def _error_output(exc: subprocess.CalledProcessError) -> str:
1785
1837
  return "\n".join(value for value in values if value)
1786
1838
 
1787
1839
 
1840
+ _MANAGER_VERSION_PROBE_PATTERN = re.compile(r"manager request failed \(\w+ /version\)", re.IGNORECASE)
1841
+
1842
+
1843
+ def _humanize_manager_error(message: str) -> str:
1844
+ """Render a manager JSON error body as prose instead of dumping the raw payload."""
1845
+ match = re.search(r"^(.*?: HTTP \d+): (\{.*\})$", message, re.DOTALL)
1846
+ if not match:
1847
+ return message
1848
+ try:
1849
+ body = json.loads(match.group(2))
1850
+ except json.JSONDecodeError:
1851
+ return message
1852
+ if not isinstance(body, dict):
1853
+ return message
1854
+ detail = str(body.get("message", "")).strip()
1855
+ if not detail:
1856
+ return message
1857
+ incident = str(body.get("incident_id") or body.get("trace_id") or "").strip()
1858
+ if incident:
1859
+ return f"{match.group(1)}: {detail} (incident {incident})"
1860
+ return f"{match.group(1)}: {detail}"
1861
+
1862
+
1788
1863
  def _manager_deploy_suggestion(message: str) -> Optional[str]:
1789
1864
  if GITHUB_WORKFLOW_RESTRICTED_MESSAGE in message:
1790
1865
  return (
@@ -1804,10 +1879,16 @@ def _manager_deploy_suggestion(message: str) -> Optional[str]:
1804
1879
  "by this stack. Either allow that external network in the Docker-Manager deployment rule, attach the service to "
1805
1880
  "a stack-owned network, or relabel/recreate the existing network with the expected stack ownership before deploying."
1806
1881
  )
1807
- if "manager request failed" in lowered and "/version" in lowered:
1882
+ if "stack not found" in lowered:
1883
+ return (
1884
+ "Suggestion: the manager was reached but has no stack by that name in that namespace. "
1885
+ "Run 'docker-stack ls' to list stacks in the current namespace, or 'docker-stack ls -A' to search every namespace."
1886
+ )
1887
+ if _MANAGER_VERSION_PROBE_PATTERN.search(message):
1808
1888
  return (
1809
- "Suggestion: check that the manager URL is reachable from the runner and that the setup action exported "
1810
- "DOCKER_MANAGER_URL and the matching Docker auth headers."
1889
+ "Suggestion: docker-stack could not read the manager's /version endpoint. Check that DOCKER_MANAGER_URL points "
1890
+ "at a reachable manager and that the Docker auth headers are set - in CI that means running the docker-stack "
1891
+ "setup action before this step."
1811
1892
  )
1812
1893
  if "docker-manager is configured" in lowered and "did not advertise required feature" in lowered:
1813
1894
  return (
@@ -1821,7 +1902,7 @@ def _format_called_process_error(exc: subprocess.CalledProcessError) -> str:
1821
1902
  output = _error_output(exc)
1822
1903
  lines = [f"docker-stack: command failed with exit code {exc.returncode}: {_format_command(exc.cmd)}"]
1823
1904
  if output:
1824
- lines.append(output)
1905
+ lines.append(_humanize_manager_error(output))
1825
1906
  suggestion = _manager_deploy_suggestion(output)
1826
1907
  if suggestion:
1827
1908
  lines.extend(["", suggestion])
@@ -1830,14 +1911,14 @@ def _format_called_process_error(exc: subprocess.CalledProcessError) -> str:
1830
1911
 
1831
1912
  def _format_runtime_error(exc: RuntimeError) -> str:
1832
1913
  message = str(exc).strip() or exc.__class__.__name__
1833
- lines = [f"docker-stack: {message}"]
1914
+ lines = [f"docker-stack: {_humanize_manager_error(message)}"]
1834
1915
  suggestion = _manager_deploy_suggestion(message)
1835
1916
  if suggestion:
1836
1917
  lines.extend(["", suggestion])
1837
1918
  return "\n".join(lines)
1838
1919
 
1839
1920
 
1840
- def main(args: List[str] = None):
1921
+ def _run(args: List[str] = None):
1841
1922
  parser = argparse.ArgumentParser(description="Deploy and manage Docker stacks.")
1842
1923
  subparsers = parser.add_subparsers(dest="command", required=True)
1843
1924
 
@@ -2182,5 +2263,18 @@ def main(args: List[str] = None):
2182
2263
  sys.exit(2)
2183
2264
 
2184
2265
 
2266
+ def main(args: List[str] = None):
2267
+ """Entry point. Ctrl+C has to look like an interrupted shell command, not a Python crash.
2268
+
2269
+ Commands that exec into another binary never reach this handler; it covers the paths that stay
2270
+ in Python (cluster-rendered ``ps``, login, deploy) and any HTTP call they are blocked on.
2271
+ """
2272
+ try:
2273
+ return _run(args)
2274
+ except KeyboardInterrupt:
2275
+ print(file=sys.stderr)
2276
+ return 130
2277
+
2278
+
2185
2279
  if __name__ == "__main__":
2186
- main()
2280
+ sys.exit(main())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docker-stack
3
- Version: 2.2.2
3
+ Version: 2.2.4
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.2",
5
+ version="2.2.4",
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",
@@ -12,6 +12,8 @@ import yaml
12
12
  from docker_stack.cli import (
13
13
  DOCKER_SHELL_ENDPOINT_ENV_VARS,
14
14
  Docker,
15
+ _format_runtime_error,
16
+ _exec_process,
15
17
  _managed_docker,
16
18
  _read_selected_node,
17
19
  _select_node,
@@ -131,6 +133,57 @@ def test_managed_docker_ps_matches_docker_columns(monkeypatch, capsys):
131
133
  assert row == row.rstrip()
132
134
 
133
135
 
136
+ @pytest.mark.parametrize(
137
+ "image,expected,expected_no_trunc",
138
+ [
139
+ (
140
+ "registry.example.com/app/design-system:af037b33@sha256:15d623cfa2f856c8101f9f3c47003d412f9b2bf72c69c95d5dff07a74e5f7b60",
141
+ "registry.example.com/app/design-system:af037b33",
142
+ "registry.example.com/app/design-system:af037b33@sha256:15d623cfa2f856c8101f9f3c47003d412f9b2bf72c69c95d5dff07a74e5f7b60",
143
+ ),
144
+ (
145
+ "docker.io/library/alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b",
146
+ "alpine",
147
+ "docker.io/library/alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b",
148
+ ),
149
+ ("nginx:1.27", "nginx:1.27", "nginx:1.27"),
150
+ (
151
+ "sha256:0e901e68141fd02f237cf63eb842529f8a9500636a9419e3cf4fb986b8fe3d5d",
152
+ "0e901e68141f",
153
+ "sha256:0e901e68141fd02f237cf63eb842529f8a9500636a9419e3cf4fb986b8fe3d5d",
154
+ ),
155
+ ],
156
+ )
157
+ def test_managed_docker_ps_drops_pinned_digest_like_docker(monkeypatch, capsys, image, expected, expected_no_trunc):
158
+ client = SimpleNamespace(
159
+ supports=lambda feature: feature == "cluster_container_cli_v1",
160
+ list_containers=lambda **_kwargs: {
161
+ "containers": [
162
+ {
163
+ "node_name": "worker-02",
164
+ "docker": {
165
+ "Id": "abcdef1234567890",
166
+ "Image": image,
167
+ "Command": "sleep",
168
+ "Created": int(time.time()) - 60,
169
+ "Status": "Up 1 minute",
170
+ "Ports": [],
171
+ "Names": ["/svc.1.abc"],
172
+ },
173
+ }
174
+ ],
175
+ "node_errors": [],
176
+ },
177
+ )
178
+ monkeypatch.setattr("docker_stack.cli.discover_manager_client", lambda: client)
179
+
180
+ assert _managed_docker(["ps"]) == 0
181
+ assert capsys.readouterr().out.splitlines()[1].split()[1] == expected
182
+
183
+ assert _managed_docker(["ps", "--no-trunc"]) == 0
184
+ assert capsys.readouterr().out.splitlines()[1].split()[1] == expected_no_trunc
185
+
186
+
134
187
  def test_managed_docker_ps_forwards_global_latest_and_limit(monkeypatch):
135
188
  calls = []
136
189
  client = SimpleNamespace(
@@ -149,11 +202,65 @@ def test_managed_docker_ps_forwards_global_latest_and_limit(monkeypatch):
149
202
  assert calls[1]["limit"] == 3
150
203
 
151
204
 
152
- def test_managed_docker_delegates_format_to_real_docker(monkeypatch):
205
+ def test_exec_process_replaces_the_python_process(monkeypatch):
153
206
  calls = []
207
+ monkeypatch.setattr(os, "name", "posix")
208
+ monkeypatch.setattr("docker_stack.cli.os.execvp", lambda file, args: calls.append((file, args)))
209
+
210
+ _exec_process(["docker", "service", "logs", "-f", "svc"])
211
+
212
+ assert calls == [("docker", ["docker", "service", "logs", "-f", "svc"])]
213
+
214
+
215
+ def test_exec_process_passes_env_when_given(monkeypatch):
216
+ calls = []
217
+ monkeypatch.setattr(os, "name", "posix")
218
+ monkeypatch.setattr("docker_stack.cli.os.execvpe", lambda file, args, env: calls.append((file, args, env)))
219
+
220
+ _exec_process(["/bin/test-shell", "-i"], env={"DOCKER_CONTEXT": "office"})
221
+
222
+ assert calls == [("/bin/test-shell", ["/bin/test-shell", "-i"], {"DOCKER_CONTEXT": "office"})]
223
+
224
+
225
+ def test_exec_process_reports_missing_binary(monkeypatch, capsys):
226
+ monkeypatch.setattr(os, "name", "posix")
227
+
228
+ def missing(_file, _args):
229
+ raise FileNotFoundError(2, "No such file or directory")
230
+
231
+ monkeypatch.setattr("docker_stack.cli.os.execvp", missing)
232
+
233
+ assert _exec_process(["docker", "ps"]) == 127
234
+ assert "cannot execute docker" in capsys.readouterr().err
235
+
236
+
237
+ def test_exec_process_falls_back_to_subprocess_off_posix(monkeypatch):
238
+ calls = []
239
+ monkeypatch.setattr(os, "name", "nt")
240
+ monkeypatch.setattr("docker_stack.cli.os.execvp", lambda *_args: pytest.fail("exec must not run off POSIX"))
154
241
  monkeypatch.setattr(
155
242
  "docker_stack.cli.subprocess.run",
156
- lambda command, **_kwargs: calls.append(command) or SimpleNamespace(returncode=7),
243
+ lambda command, **_kwargs: calls.append(command) or SimpleNamespace(returncode=3),
244
+ )
245
+
246
+ assert _exec_process(["docker", "ps"]) == 3
247
+ assert calls == [["docker", "ps"]]
248
+
249
+
250
+ def test_main_reports_interrupt_as_shell_exit_code(monkeypatch):
251
+ def interrupted(_args):
252
+ raise KeyboardInterrupt
253
+
254
+ monkeypatch.setattr("docker_stack.cli._run", interrupted)
255
+
256
+ assert main(["ps"]) == 130
257
+
258
+
259
+ def test_managed_docker_delegates_format_to_real_docker(monkeypatch):
260
+ calls = []
261
+ monkeypatch.setattr(
262
+ "docker_stack.cli._exec_process",
263
+ lambda command, **_kwargs: calls.append(command) or 7,
157
264
  )
158
265
 
159
266
  assert _managed_docker(["ps", "--format", "{{.ID}}"]) == 7
@@ -173,8 +280,8 @@ def test_managed_docker_delegates_format_to_real_docker(monkeypatch):
173
280
  def test_managed_docker_delegates_explicit_target_overrides(monkeypatch, arguments):
174
281
  calls = []
175
282
  monkeypatch.setattr(
176
- "docker_stack.cli.subprocess.run",
177
- lambda command, **_kwargs: calls.append(command) or SimpleNamespace(returncode=6),
283
+ "docker_stack.cli._exec_process",
284
+ lambda command, **_kwargs: calls.append(command) or 6,
178
285
  )
179
286
  monkeypatch.setattr(
180
287
  "docker_stack.cli.discover_manager_client",
@@ -190,8 +297,8 @@ def test_managed_docker_ps_delegates_when_manager_lacks_cluster_feature(monkeypa
190
297
  client = SimpleNamespace(supports=lambda _feature: False)
191
298
  monkeypatch.setattr("docker_stack.cli.discover_manager_client", lambda: client)
192
299
  monkeypatch.setattr(
193
- "docker_stack.cli.subprocess.run",
194
- lambda command, **_kwargs: calls.append(command) or SimpleNamespace(returncode=9),
300
+ "docker_stack.cli._exec_process",
301
+ lambda command, **_kwargs: calls.append(command) or 9,
195
302
  )
196
303
 
197
304
  assert _managed_docker(["ps"]) == 9
@@ -1426,18 +1533,16 @@ def test_open_context_shell_clears_endpoint_overrides(monkeypatch, tmp_path):
1426
1533
  monkeypatch.setenv(key, f"parent-{key}")
1427
1534
  monkeypatch.setenv("SHELL", "/bin/test-shell")
1428
1535
 
1429
- def fake_run(cmd, check, env):
1536
+ def fake_exec(cmd, env=None):
1430
1537
  captured["cmd"] = cmd
1431
- captured["check"] = check
1432
1538
  captured["env"] = env
1433
- return SimpleNamespace(returncode=0)
1539
+ return 0
1434
1540
 
1435
- monkeypatch.setattr("docker_stack.cli.subprocess.run", fake_run)
1541
+ monkeypatch.setattr("docker_stack.cli._exec_process", fake_exec)
1436
1542
 
1437
1543
  assert open_context_shell(tmp_path, "office") == 0
1438
1544
 
1439
1545
  assert captured["cmd"] == ["/bin/test-shell", "-i"]
1440
- assert captured["check"] is False
1441
1546
  assert captured["env"]["DOCKER_CONFIG"] == str(tmp_path)
1442
1547
  assert captured["env"]["DOCKER_CONTEXT"] == "office"
1443
1548
  for key in DOCKER_SHELL_ENDPOINT_ENV_VARS:
@@ -1522,3 +1627,23 @@ def test_context_use_preserves_auth_when_switching_to_manager(monkeypatch, capsy
1522
1627
  output = capsys.readouterr().out
1523
1628
  assert "DOCKER_CONTEXT=office" in output
1524
1629
  assert "auth header preserved" in output
1630
+
1631
+
1632
+ def test_stack_not_found_error_does_not_suggest_ci_setup():
1633
+ formatted = _format_runtime_error(
1634
+ RuntimeError(
1635
+ "Manager request failed (GET /api/docker-stack/stacks/docker-manager/versions?namespace=infra): "
1636
+ 'HTTP 404: {"message":"stack not found","incident_id":"d9d372ed","trace_id":"d9d372ed"}'
1637
+ )
1638
+ )
1639
+
1640
+ assert "HTTP 404: stack not found (incident d9d372ed)" in formatted
1641
+ assert '{"message"' not in formatted
1642
+ assert "has no stack by that name in that namespace" in formatted
1643
+ assert "setup action" not in formatted
1644
+
1645
+
1646
+ def test_version_probe_failure_still_suggests_manager_url_check():
1647
+ formatted = _format_runtime_error(RuntimeError("Manager request failed (GET /version): connection refused"))
1648
+
1649
+ assert "DOCKER_MANAGER_URL points at a reachable manager" in formatted
File without changes
File without changes