docker-stack 2.2.3__tar.gz → 2.2.5__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.3 → docker_stack-2.2.5}/PKG-INFO +1 -1
  2. {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/cli.py +109 -18
  3. {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/manager_api.py +41 -2
  4. {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack.egg-info/PKG-INFO +1 -1
  5. {docker_stack-2.2.3 → docker_stack-2.2.5}/setup.py +1 -1
  6. {docker_stack-2.2.3 → docker_stack-2.2.5}/tests/test_docker_stack.py +98 -19
  7. {docker_stack-2.2.3 → docker_stack-2.2.5}/README.md +0 -0
  8. {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/__init__.py +0 -0
  9. {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/command_runner.py +0 -0
  10. {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/compose.py +0 -0
  11. {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/docker_objects.py +0 -0
  12. {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/envsubst.py +0 -0
  13. {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/envsubst_merge.py +0 -0
  14. {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/helpers.py +0 -0
  15. {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/login.py +0 -0
  16. {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/markers.py +0 -0
  17. {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/merge_conf.py +0 -0
  18. {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/registry.py +0 -0
  19. {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/shell_auth.py +0 -0
  20. {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/url_parser.py +0 -0
  21. {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack.egg-info/SOURCES.txt +0 -0
  22. {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack.egg-info/dependency_links.txt +0 -0
  23. {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack.egg-info/entry_points.txt +0 -0
  24. {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack.egg-info/requires.txt +0 -0
  25. {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack.egg-info/top_level.txt +0 -0
  26. {docker_stack-2.2.3 → docker_stack-2.2.5}/pyproject.toml +0 -0
  27. {docker_stack-2.2.3 → docker_stack-2.2.5}/setup.cfg +0 -0
  28. {docker_stack-2.2.3 → docker_stack-2.2.5}/tests/test_docker_objects.py +0 -0
  29. {docker_stack-2.2.3 → docker_stack-2.2.5}/tests/test_load_env.py +0 -0
  30. {docker_stack-2.2.3 → docker_stack-2.2.5}/tests/test_login.py +0 -0
  31. {docker_stack-2.2.3 → docker_stack-2.2.5}/tests/test_manager_api.py +0 -0
  32. {docker_stack-2.2.3 → docker_stack-2.2.5}/tests/test_node_ls.py +0 -0
  33. {docker_stack-2.2.3 → docker_stack-2.2.5}/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.3
3
+ Version: 2.2.5
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
@@ -37,7 +37,6 @@ from docker_stack.shell_auth import (
37
37
  shell_session_active,
38
38
  )
39
39
  from docker_stack.manager_api import (
40
- FEATURE_CLUSTER_CONTAINER_CLI,
41
40
  FEATURE_STACK_DEPLOY,
42
41
  FEATURE_STACK_QUERY,
43
42
  ManagerApiClient,
@@ -102,15 +101,23 @@ def _write_selected_node(value: Optional[str], display_name: str) -> None:
102
101
  Path(node_state).write_text(f"{display_name}\n", encoding="utf-8")
103
102
 
104
103
 
105
- def _manager_with_cluster_cli() -> ManagerApiClient:
106
- client = discover_manager_client()
107
- if client is None or not client.supports(FEATURE_CLUSTER_CONTAINER_CLI):
108
- raise RuntimeError("Docker-Manager does not support cluster container CLI; upgrade the manager and all node agents")
104
+ def _require_manager() -> ManagerApiClient:
105
+ """Return a client for the Docker-Manager behind this shell, or explain why not.
106
+
107
+ Only the current manager is supported, so there is no capability negotiation here -
108
+ either the target is a Docker-Manager or it is a plain Docker daemon.
109
+ """
110
+ client = discover_manager_client(strict=True)
111
+ if client is None or not client.is_manager_backend():
112
+ raise RuntimeError(
113
+ f"{client.manager_url if client else 'the current Docker endpoint'} is not a "
114
+ "Docker-Manager; node-local commands need one"
115
+ )
109
116
  return client
110
117
 
111
118
 
112
119
  def _select_node(value: str) -> str:
113
- client = _manager_with_cluster_cli()
120
+ client = _require_manager()
114
121
  requested = value.strip()
115
122
  if requested.lower() == "cluster":
116
123
  _write_selected_node(None, "cluster")
@@ -133,6 +140,9 @@ def _select_node(value: str) -> str:
133
140
  raise RuntimeError(f"node '{requested}' is not ready")
134
141
  node_id = str(node.get("id") or "").strip()
135
142
  hostname = str(node.get("hostname") or node_id).strip()
143
+ # Only this node has to be usable. The health of every other node - drained, down, or
144
+ # missing its agent - is none of this command's business.
145
+ client.check_node_agent(node_id or hostname)
136
146
  _write_selected_node(node_id, hostname)
137
147
  return hostname
138
148
 
@@ -339,6 +349,13 @@ def _print_cluster_containers(payload: Dict[str, object], *, no_trunc: bool = Fa
339
349
  columns.append("SIZE")
340
350
  columns.append("NODE")
341
351
  _print_table(columns, rows)
352
+ skipped = payload.get("skipped_nodes") if isinstance(payload.get("skipped_nodes"), list) else []
353
+ for node in skipped:
354
+ if isinstance(node, dict):
355
+ print(
356
+ f"docker ps: skipped node {node.get('node_name') or node.get('node_id')}: {node.get('reason')}",
357
+ file=sys.stderr,
358
+ )
342
359
  errors = payload.get("node_errors") if isinstance(payload.get("node_errors"), list) else []
343
360
  for error in errors:
344
361
  if isinstance(error, dict):
@@ -350,6 +367,38 @@ def _print_cluster_containers(payload: Dict[str, object], *, no_trunc: bool = Fa
350
367
  return 1 if errors else 0
351
368
 
352
369
 
370
+ def _exec_process(command: List[str], env: Optional[Dict[str, str]] = None) -> int:
371
+ """Replace this process with ``command`` so it owns the terminal, its signals, and its exit code.
372
+
373
+ Proxying through ``subprocess.run`` keeps Python in the foreground process group: Ctrl+C is
374
+ delivered to the proxy as well as to the child, which surfaces as a ``KeyboardInterrupt``
375
+ traceback, and a signal-killed child reports a negative return code that ``sys.exit`` turns into
376
+ a nonsense status (``-2`` becomes ``254`` instead of ``130``). ``exec`` removes the middleman.
377
+ Only POSIX execs: on Windows ``exec`` detaches the child and hands control straight back, which
378
+ would break the caller's wait semantics.
379
+
380
+ Returns only on failure; the caller keeps its ``int`` contract for that case.
381
+ """
382
+ if os.name != "posix":
383
+ return subprocess.run(command, check=False, env=env).returncode
384
+ # exec discards Python's buffers, so anything already printed has to reach the fd first.
385
+ sys.stdout.flush()
386
+ sys.stderr.flush()
387
+ try:
388
+ if env is None:
389
+ os.execvp(command[0], command)
390
+ else:
391
+ os.execvpe(command[0], command, env)
392
+ except OSError as exc:
393
+ print(f"docker-stack: cannot execute {command[0]}: {exc}", file=sys.stderr)
394
+ return 127
395
+
396
+
397
+ def _exec_docker(values: List[str]) -> int:
398
+ """Hand the command to the real Docker CLI, replacing this process."""
399
+ return _exec_process(["docker", *values])
400
+
401
+
353
402
  def _managed_docker(arguments: List[str]) -> int:
354
403
  values = list(arguments)
355
404
  if values and values[0] == "--":
@@ -378,11 +427,11 @@ def _managed_docker(arguments: List[str]) -> int:
378
427
  value in {"-q", "--quiet", "--format"} or value.startswith("--format=")
379
428
  for value in command
380
429
  ):
381
- return subprocess.run(["docker", *values], check=False).returncode
430
+ return _exec_docker(values)
382
431
  try:
383
432
  client = discover_manager_client()
384
- if client is None or not client.supports(FEATURE_CLUSTER_CONTAINER_CLI):
385
- return subprocess.run(["docker", *values], check=False).returncode
433
+ if client is None or not client.is_manager_backend():
434
+ return _exec_docker(values)
386
435
  filters = []
387
436
  all_containers = False
388
437
  latest = False
@@ -411,7 +460,7 @@ def _managed_docker(arguments: List[str]) -> int:
411
460
  elif value.startswith("--last="):
412
461
  limit = int(value.split("=", 1)[1])
413
462
  else:
414
- return subprocess.run(["docker", *values], check=False).returncode
463
+ return _exec_docker(values)
415
464
  index += 1
416
465
  payload = client.list_containers(
417
466
  all_containers=all_containers,
@@ -1774,7 +1823,7 @@ def open_context_shell(config_dir: Path, context_name: str) -> int:
1774
1823
  env["DOCKER_CONTEXT"] = context_name
1775
1824
  env["DOCKER_MANAGER_CONTEXT_NAME"] = context_name
1776
1825
  shell = env.get("SHELL", "").strip() or "/bin/bash"
1777
- return subprocess.run([shell, "-i"], check=False, env=env).returncode
1826
+ return _exec_process([shell, "-i"], env=env)
1778
1827
 
1779
1828
 
1780
1829
  def active_shell_config_dir(context_name: str) -> Optional[Path]:
@@ -1805,6 +1854,29 @@ def _error_output(exc: subprocess.CalledProcessError) -> str:
1805
1854
  return "\n".join(value for value in values if value)
1806
1855
 
1807
1856
 
1857
+ _MANAGER_VERSION_PROBE_PATTERN = re.compile(r"manager request failed \(\w+ /version\)", re.IGNORECASE)
1858
+
1859
+
1860
+ def _humanize_manager_error(message: str) -> str:
1861
+ """Render a manager JSON error body as prose instead of dumping the raw payload."""
1862
+ match = re.search(r"^(.*?: HTTP \d+): (\{.*\})$", message, re.DOTALL)
1863
+ if not match:
1864
+ return message
1865
+ try:
1866
+ body = json.loads(match.group(2))
1867
+ except json.JSONDecodeError:
1868
+ return message
1869
+ if not isinstance(body, dict):
1870
+ return message
1871
+ detail = str(body.get("message", "")).strip()
1872
+ if not detail:
1873
+ return message
1874
+ incident = str(body.get("incident_id") or body.get("trace_id") or "").strip()
1875
+ if incident:
1876
+ return f"{match.group(1)}: {detail} (incident {incident})"
1877
+ return f"{match.group(1)}: {detail}"
1878
+
1879
+
1808
1880
  def _manager_deploy_suggestion(message: str) -> Optional[str]:
1809
1881
  if GITHUB_WORKFLOW_RESTRICTED_MESSAGE in message:
1810
1882
  return (
@@ -1824,10 +1896,16 @@ def _manager_deploy_suggestion(message: str) -> Optional[str]:
1824
1896
  "by this stack. Either allow that external network in the Docker-Manager deployment rule, attach the service to "
1825
1897
  "a stack-owned network, or relabel/recreate the existing network with the expected stack ownership before deploying."
1826
1898
  )
1827
- if "manager request failed" in lowered and "/version" in lowered:
1899
+ if "stack not found" in lowered:
1900
+ return (
1901
+ "Suggestion: the manager was reached but has no stack by that name in that namespace. "
1902
+ "Run 'docker-stack ls' to list stacks in the current namespace, or 'docker-stack ls -A' to search every namespace."
1903
+ )
1904
+ if _MANAGER_VERSION_PROBE_PATTERN.search(message):
1828
1905
  return (
1829
- "Suggestion: check that the manager URL is reachable from the runner and that the setup action exported "
1830
- "DOCKER_MANAGER_URL and the matching Docker auth headers."
1906
+ "Suggestion: docker-stack could not read the manager's /version endpoint. Check that DOCKER_MANAGER_URL points "
1907
+ "at a reachable manager and that the Docker auth headers are set - in CI that means running the docker-stack "
1908
+ "setup action before this step."
1831
1909
  )
1832
1910
  if "docker-manager is configured" in lowered and "did not advertise required feature" in lowered:
1833
1911
  return (
@@ -1841,7 +1919,7 @@ def _format_called_process_error(exc: subprocess.CalledProcessError) -> str:
1841
1919
  output = _error_output(exc)
1842
1920
  lines = [f"docker-stack: command failed with exit code {exc.returncode}: {_format_command(exc.cmd)}"]
1843
1921
  if output:
1844
- lines.append(output)
1922
+ lines.append(_humanize_manager_error(output))
1845
1923
  suggestion = _manager_deploy_suggestion(output)
1846
1924
  if suggestion:
1847
1925
  lines.extend(["", suggestion])
@@ -1850,14 +1928,14 @@ def _format_called_process_error(exc: subprocess.CalledProcessError) -> str:
1850
1928
 
1851
1929
  def _format_runtime_error(exc: RuntimeError) -> str:
1852
1930
  message = str(exc).strip() or exc.__class__.__name__
1853
- lines = [f"docker-stack: {message}"]
1931
+ lines = [f"docker-stack: {_humanize_manager_error(message)}"]
1854
1932
  suggestion = _manager_deploy_suggestion(message)
1855
1933
  if suggestion:
1856
1934
  lines.extend(["", suggestion])
1857
1935
  return "\n".join(lines)
1858
1936
 
1859
1937
 
1860
- def main(args: List[str] = None):
1938
+ def _run(args: List[str] = None):
1861
1939
  parser = argparse.ArgumentParser(description="Deploy and manage Docker stacks.")
1862
1940
  subparsers = parser.add_subparsers(dest="command", required=True)
1863
1941
 
@@ -2202,5 +2280,18 @@ def main(args: List[str] = None):
2202
2280
  sys.exit(2)
2203
2281
 
2204
2282
 
2283
+ def main(args: List[str] = None):
2284
+ """Entry point. Ctrl+C has to look like an interrupted shell command, not a Python crash.
2285
+
2286
+ Commands that exec into another binary never reach this handler; it covers the paths that stay
2287
+ in Python (cluster-rendered ``ps``, login, deploy) and any HTTP call they are blocked on.
2288
+ """
2289
+ try:
2290
+ return _run(args)
2291
+ except KeyboardInterrupt:
2292
+ print(file=sys.stderr)
2293
+ return 130
2294
+
2295
+
2205
2296
  if __name__ == "__main__":
2206
- main()
2297
+ sys.exit(main())
@@ -416,6 +416,22 @@ class ManagerApiClient:
416
416
  endpoint_id = self._resolve_endpoint_id()
417
417
  return f"/api/endpoints/{endpoint_id}{normalized}"
418
418
 
419
+ def is_manager_backend(self) -> bool:
420
+ """True when the target is a Docker-Manager stack API rather than a raw daemon."""
421
+ return self._detect_manager_backend()
422
+
423
+ def check_node_agent(self, selector: str) -> Dict[str, Any]:
424
+ """Report whether one node can serve node-local Docker commands.
425
+
426
+ Scoped to ``selector`` on purpose: an unrelated node that is drained, down, or
427
+ missing its agent must never make a usable node look unusable.
428
+ """
429
+ node = urllib.parse.quote(selector, safe="")
430
+ payload = self._request_json(f"/api/docker-stack/nodes/{node}/agent")
431
+ if not isinstance(payload, dict):
432
+ raise RuntimeError("Docker-Manager node agent response is invalid")
433
+ return payload
434
+
419
435
  def supports(self, feature_name: str) -> bool:
420
436
  features = self.detect_features()
421
437
  if FEATURE_MESUDIP_DOCKER_ENTERPRISE in features and feature_name in {
@@ -721,7 +737,15 @@ class ManagerApiClient:
721
737
  )
722
738
 
723
739
 
724
- def discover_manager_client(timeout_secs: int = 5) -> Optional[ManagerApiClient]:
740
+ def discover_manager_client(
741
+ timeout_secs: int = 5, *, strict: bool = False
742
+ ) -> Optional[ManagerApiClient]:
743
+ """Build a client for the manager behind DOCKER_MANAGER_URL or the Docker context.
744
+
745
+ ``strict`` raises the concrete reason instead of returning ``None``. Callers that
746
+ fall back to the plain Docker CLI want the quiet form; callers that are about to
747
+ report a failure to the user must not discard why discovery failed.
748
+ """
725
749
  try:
726
750
  target = _manager_target_from_env()
727
751
  if target:
@@ -729,11 +753,26 @@ def discover_manager_client(timeout_secs: int = 5) -> Optional[ManagerApiClient]
729
753
  elif shutil.which("docker"):
730
754
  _, context_target = current_docker_context_target()
731
755
  if not context_target or not context_target.startswith(("tcp://", "http://", "https://")):
756
+ if strict:
757
+ raise RuntimeError(
758
+ "no Docker-Manager endpoint found: set DOCKER_MANAGER_URL, or select a "
759
+ f"docker context with a tcp:// endpoint (current target: {context_target or 'none'})"
760
+ )
732
761
  return None
733
762
  config = resolve_login_config(manager_target=context_target)
734
763
  else:
764
+ if strict:
765
+ raise RuntimeError(
766
+ "no Docker-Manager endpoint found: DOCKER_MANAGER_URL is unset and the docker CLI is not installed"
767
+ )
735
768
  return None
736
- except Exception:
769
+ except RuntimeError:
770
+ if strict:
771
+ raise
772
+ return None
773
+ except Exception as exc:
774
+ if strict:
775
+ raise RuntimeError(f"failed resolving the Docker-Manager endpoint: {exc}") from exc
737
776
  return None
738
777
 
739
778
  return ManagerApiClient(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docker-stack
3
- Version: 2.2.3
3
+ Version: 2.2.5
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.3",
5
+ version="2.2.5",
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,
@@ -36,14 +38,17 @@ def test_node_use_updates_only_managed_shell_config(monkeypatch, tmp_path):
36
38
  monkeypatch.setenv("DOCKER_STACK_SHELL_SECRET", "secret")
37
39
  monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path))
38
40
  monkeypatch.setenv("DOCKER_STACK_SHELL_NODE_STATE", str(node_state))
41
+ checked = []
39
42
  client = SimpleNamespace(
40
43
  list_nodes=lambda: {
41
44
  "nodes": [
42
45
  {"id": "node-worker-123", "hostname": "worker-02", "state": "Ready"},
43
46
  ]
44
- }
47
+ },
48
+ check_node_agent=lambda selector: checked.append(selector)
49
+ or {"node_id": selector, "node_name": "worker-02", "agent": "ready"},
45
50
  )
46
- monkeypatch.setattr("docker_stack.cli._manager_with_cluster_cli", lambda: client)
51
+ monkeypatch.setattr("docker_stack.cli._require_manager", lambda: client)
47
52
 
48
53
  assert _select_node("worker-02") == "worker-02"
49
54
  payload = json.loads(config_path.read_text(encoding="utf-8"))
@@ -52,6 +57,8 @@ def test_node_use_updates_only_managed_shell_config(monkeypatch, tmp_path):
52
57
  assert _read_selected_node() == "node-worker-123"
53
58
  assert node_state.read_text(encoding="utf-8").strip() == "worker-02"
54
59
 
60
+ assert checked == ["node-worker-123"]
61
+
55
62
  assert _select_node("cluster") == "cluster"
56
63
  payload = json.loads(config_path.read_text(encoding="utf-8"))
57
64
  assert "X-Docker-Manager-Node" not in payload["HttpHeaders"]
@@ -59,7 +66,7 @@ def test_node_use_updates_only_managed_shell_config(monkeypatch, tmp_path):
59
66
 
60
67
  def test_managed_docker_ps_renders_node_column(monkeypatch, capsys):
61
68
  client = SimpleNamespace(
62
- supports=lambda feature: feature == "cluster_container_cli_v1",
69
+ is_manager_backend=lambda: True,
63
70
  list_containers=lambda **_kwargs: {
64
71
  "containers": [
65
72
  {
@@ -91,7 +98,7 @@ def test_managed_docker_ps_renders_node_column(monkeypatch, capsys):
91
98
 
92
99
  def test_managed_docker_ps_matches_docker_columns(monkeypatch, capsys):
93
100
  client = SimpleNamespace(
94
- supports=lambda feature: feature == "cluster_container_cli_v1",
101
+ is_manager_backend=lambda: True,
95
102
  list_containers=lambda **_kwargs: {
96
103
  "containers": [
97
104
  {
@@ -154,7 +161,7 @@ def test_managed_docker_ps_matches_docker_columns(monkeypatch, capsys):
154
161
  )
155
162
  def test_managed_docker_ps_drops_pinned_digest_like_docker(monkeypatch, capsys, image, expected, expected_no_trunc):
156
163
  client = SimpleNamespace(
157
- supports=lambda feature: feature == "cluster_container_cli_v1",
164
+ is_manager_backend=lambda: True,
158
165
  list_containers=lambda **_kwargs: {
159
166
  "containers": [
160
167
  {
@@ -185,7 +192,7 @@ def test_managed_docker_ps_drops_pinned_digest_like_docker(monkeypatch, capsys,
185
192
  def test_managed_docker_ps_forwards_global_latest_and_limit(monkeypatch):
186
193
  calls = []
187
194
  client = SimpleNamespace(
188
- supports=lambda feature: feature == "cluster_container_cli_v1",
195
+ is_manager_backend=lambda: True,
189
196
  list_containers=lambda **kwargs: calls.append(kwargs)
190
197
  or {"containers": [], "node_errors": []},
191
198
  )
@@ -200,11 +207,65 @@ def test_managed_docker_ps_forwards_global_latest_and_limit(monkeypatch):
200
207
  assert calls[1]["limit"] == 3
201
208
 
202
209
 
203
- def test_managed_docker_delegates_format_to_real_docker(monkeypatch):
210
+ def test_exec_process_replaces_the_python_process(monkeypatch):
204
211
  calls = []
212
+ monkeypatch.setattr(os, "name", "posix")
213
+ monkeypatch.setattr("docker_stack.cli.os.execvp", lambda file, args: calls.append((file, args)))
214
+
215
+ _exec_process(["docker", "service", "logs", "-f", "svc"])
216
+
217
+ assert calls == [("docker", ["docker", "service", "logs", "-f", "svc"])]
218
+
219
+
220
+ def test_exec_process_passes_env_when_given(monkeypatch):
221
+ calls = []
222
+ monkeypatch.setattr(os, "name", "posix")
223
+ monkeypatch.setattr("docker_stack.cli.os.execvpe", lambda file, args, env: calls.append((file, args, env)))
224
+
225
+ _exec_process(["/bin/test-shell", "-i"], env={"DOCKER_CONTEXT": "office"})
226
+
227
+ assert calls == [("/bin/test-shell", ["/bin/test-shell", "-i"], {"DOCKER_CONTEXT": "office"})]
228
+
229
+
230
+ def test_exec_process_reports_missing_binary(monkeypatch, capsys):
231
+ monkeypatch.setattr(os, "name", "posix")
232
+
233
+ def missing(_file, _args):
234
+ raise FileNotFoundError(2, "No such file or directory")
235
+
236
+ monkeypatch.setattr("docker_stack.cli.os.execvp", missing)
237
+
238
+ assert _exec_process(["docker", "ps"]) == 127
239
+ assert "cannot execute docker" in capsys.readouterr().err
240
+
241
+
242
+ def test_exec_process_falls_back_to_subprocess_off_posix(monkeypatch):
243
+ calls = []
244
+ monkeypatch.setattr(os, "name", "nt")
245
+ monkeypatch.setattr("docker_stack.cli.os.execvp", lambda *_args: pytest.fail("exec must not run off POSIX"))
205
246
  monkeypatch.setattr(
206
247
  "docker_stack.cli.subprocess.run",
207
- lambda command, **_kwargs: calls.append(command) or SimpleNamespace(returncode=7),
248
+ lambda command, **_kwargs: calls.append(command) or SimpleNamespace(returncode=3),
249
+ )
250
+
251
+ assert _exec_process(["docker", "ps"]) == 3
252
+ assert calls == [["docker", "ps"]]
253
+
254
+
255
+ def test_main_reports_interrupt_as_shell_exit_code(monkeypatch):
256
+ def interrupted(_args):
257
+ raise KeyboardInterrupt
258
+
259
+ monkeypatch.setattr("docker_stack.cli._run", interrupted)
260
+
261
+ assert main(["ps"]) == 130
262
+
263
+
264
+ def test_managed_docker_delegates_format_to_real_docker(monkeypatch):
265
+ calls = []
266
+ monkeypatch.setattr(
267
+ "docker_stack.cli._exec_process",
268
+ lambda command, **_kwargs: calls.append(command) or 7,
208
269
  )
209
270
 
210
271
  assert _managed_docker(["ps", "--format", "{{.ID}}"]) == 7
@@ -224,8 +285,8 @@ def test_managed_docker_delegates_format_to_real_docker(monkeypatch):
224
285
  def test_managed_docker_delegates_explicit_target_overrides(monkeypatch, arguments):
225
286
  calls = []
226
287
  monkeypatch.setattr(
227
- "docker_stack.cli.subprocess.run",
228
- lambda command, **_kwargs: calls.append(command) or SimpleNamespace(returncode=6),
288
+ "docker_stack.cli._exec_process",
289
+ lambda command, **_kwargs: calls.append(command) or 6,
229
290
  )
230
291
  monkeypatch.setattr(
231
292
  "docker_stack.cli.discover_manager_client",
@@ -236,13 +297,13 @@ def test_managed_docker_delegates_explicit_target_overrides(monkeypatch, argumen
236
297
  assert calls == [["docker", *arguments]]
237
298
 
238
299
 
239
- def test_managed_docker_ps_delegates_when_manager_lacks_cluster_feature(monkeypatch):
300
+ def test_managed_docker_ps_delegates_when_target_is_not_a_manager(monkeypatch):
240
301
  calls = []
241
- client = SimpleNamespace(supports=lambda _feature: False)
302
+ client = SimpleNamespace(is_manager_backend=lambda: False)
242
303
  monkeypatch.setattr("docker_stack.cli.discover_manager_client", lambda: client)
243
304
  monkeypatch.setattr(
244
- "docker_stack.cli.subprocess.run",
245
- lambda command, **_kwargs: calls.append(command) or SimpleNamespace(returncode=9),
305
+ "docker_stack.cli._exec_process",
306
+ lambda command, **_kwargs: calls.append(command) or 9,
246
307
  )
247
308
 
248
309
  assert _managed_docker(["ps"]) == 9
@@ -1477,18 +1538,16 @@ def test_open_context_shell_clears_endpoint_overrides(monkeypatch, tmp_path):
1477
1538
  monkeypatch.setenv(key, f"parent-{key}")
1478
1539
  monkeypatch.setenv("SHELL", "/bin/test-shell")
1479
1540
 
1480
- def fake_run(cmd, check, env):
1541
+ def fake_exec(cmd, env=None):
1481
1542
  captured["cmd"] = cmd
1482
- captured["check"] = check
1483
1543
  captured["env"] = env
1484
- return SimpleNamespace(returncode=0)
1544
+ return 0
1485
1545
 
1486
- monkeypatch.setattr("docker_stack.cli.subprocess.run", fake_run)
1546
+ monkeypatch.setattr("docker_stack.cli._exec_process", fake_exec)
1487
1547
 
1488
1548
  assert open_context_shell(tmp_path, "office") == 0
1489
1549
 
1490
1550
  assert captured["cmd"] == ["/bin/test-shell", "-i"]
1491
- assert captured["check"] is False
1492
1551
  assert captured["env"]["DOCKER_CONFIG"] == str(tmp_path)
1493
1552
  assert captured["env"]["DOCKER_CONTEXT"] == "office"
1494
1553
  for key in DOCKER_SHELL_ENDPOINT_ENV_VARS:
@@ -1573,3 +1632,23 @@ def test_context_use_preserves_auth_when_switching_to_manager(monkeypatch, capsy
1573
1632
  output = capsys.readouterr().out
1574
1633
  assert "DOCKER_CONTEXT=office" in output
1575
1634
  assert "auth header preserved" in output
1635
+
1636
+
1637
+ def test_stack_not_found_error_does_not_suggest_ci_setup():
1638
+ formatted = _format_runtime_error(
1639
+ RuntimeError(
1640
+ "Manager request failed (GET /api/docker-stack/stacks/docker-manager/versions?namespace=infra): "
1641
+ 'HTTP 404: {"message":"stack not found","incident_id":"d9d372ed","trace_id":"d9d372ed"}'
1642
+ )
1643
+ )
1644
+
1645
+ assert "HTTP 404: stack not found (incident d9d372ed)" in formatted
1646
+ assert '{"message"' not in formatted
1647
+ assert "has no stack by that name in that namespace" in formatted
1648
+ assert "setup action" not in formatted
1649
+
1650
+
1651
+ def test_version_probe_failure_still_suggests_manager_url_check():
1652
+ formatted = _format_runtime_error(RuntimeError("Manager request failed (GET /version): connection refused"))
1653
+
1654
+ assert "DOCKER_MANAGER_URL points at a reachable manager" in formatted
File without changes
File without changes