docker-stack 2.2.3__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.3 → docker_stack-2.2.4}/PKG-INFO +1 -1
  2. {docker_stack-2.2.3 → docker_stack-2.2.4}/docker_stack/cli.py +85 -11
  3. {docker_stack-2.2.3 → docker_stack-2.2.4}/docker_stack.egg-info/PKG-INFO +1 -1
  4. {docker_stack-2.2.3 → docker_stack-2.2.4}/setup.py +1 -1
  5. {docker_stack-2.2.3 → docker_stack-2.2.4}/tests/test_docker_stack.py +85 -11
  6. {docker_stack-2.2.3 → docker_stack-2.2.4}/README.md +0 -0
  7. {docker_stack-2.2.3 → docker_stack-2.2.4}/docker_stack/__init__.py +0 -0
  8. {docker_stack-2.2.3 → docker_stack-2.2.4}/docker_stack/command_runner.py +0 -0
  9. {docker_stack-2.2.3 → docker_stack-2.2.4}/docker_stack/compose.py +0 -0
  10. {docker_stack-2.2.3 → docker_stack-2.2.4}/docker_stack/docker_objects.py +0 -0
  11. {docker_stack-2.2.3 → docker_stack-2.2.4}/docker_stack/envsubst.py +0 -0
  12. {docker_stack-2.2.3 → docker_stack-2.2.4}/docker_stack/envsubst_merge.py +0 -0
  13. {docker_stack-2.2.3 → docker_stack-2.2.4}/docker_stack/helpers.py +0 -0
  14. {docker_stack-2.2.3 → docker_stack-2.2.4}/docker_stack/login.py +0 -0
  15. {docker_stack-2.2.3 → docker_stack-2.2.4}/docker_stack/manager_api.py +0 -0
  16. {docker_stack-2.2.3 → docker_stack-2.2.4}/docker_stack/markers.py +0 -0
  17. {docker_stack-2.2.3 → docker_stack-2.2.4}/docker_stack/merge_conf.py +0 -0
  18. {docker_stack-2.2.3 → docker_stack-2.2.4}/docker_stack/registry.py +0 -0
  19. {docker_stack-2.2.3 → docker_stack-2.2.4}/docker_stack/shell_auth.py +0 -0
  20. {docker_stack-2.2.3 → docker_stack-2.2.4}/docker_stack/url_parser.py +0 -0
  21. {docker_stack-2.2.3 → docker_stack-2.2.4}/docker_stack.egg-info/SOURCES.txt +0 -0
  22. {docker_stack-2.2.3 → docker_stack-2.2.4}/docker_stack.egg-info/dependency_links.txt +0 -0
  23. {docker_stack-2.2.3 → docker_stack-2.2.4}/docker_stack.egg-info/entry_points.txt +0 -0
  24. {docker_stack-2.2.3 → docker_stack-2.2.4}/docker_stack.egg-info/requires.txt +0 -0
  25. {docker_stack-2.2.3 → docker_stack-2.2.4}/docker_stack.egg-info/top_level.txt +0 -0
  26. {docker_stack-2.2.3 → docker_stack-2.2.4}/pyproject.toml +0 -0
  27. {docker_stack-2.2.3 → docker_stack-2.2.4}/setup.cfg +0 -0
  28. {docker_stack-2.2.3 → docker_stack-2.2.4}/tests/test_docker_objects.py +0 -0
  29. {docker_stack-2.2.3 → docker_stack-2.2.4}/tests/test_load_env.py +0 -0
  30. {docker_stack-2.2.3 → docker_stack-2.2.4}/tests/test_login.py +0 -0
  31. {docker_stack-2.2.3 → docker_stack-2.2.4}/tests/test_manager_api.py +0 -0
  32. {docker_stack-2.2.3 → docker_stack-2.2.4}/tests/test_node_ls.py +0 -0
  33. {docker_stack-2.2.3 → 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.3
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
@@ -350,6 +350,38 @@ def _print_cluster_containers(payload: Dict[str, object], *, no_trunc: bool = Fa
350
350
  return 1 if errors else 0
351
351
 
352
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
+
353
385
  def _managed_docker(arguments: List[str]) -> int:
354
386
  values = list(arguments)
355
387
  if values and values[0] == "--":
@@ -378,11 +410,11 @@ def _managed_docker(arguments: List[str]) -> int:
378
410
  value in {"-q", "--quiet", "--format"} or value.startswith("--format=")
379
411
  for value in command
380
412
  ):
381
- return subprocess.run(["docker", *values], check=False).returncode
413
+ return _exec_docker(values)
382
414
  try:
383
415
  client = discover_manager_client()
384
416
  if client is None or not client.supports(FEATURE_CLUSTER_CONTAINER_CLI):
385
- return subprocess.run(["docker", *values], check=False).returncode
417
+ return _exec_docker(values)
386
418
  filters = []
387
419
  all_containers = False
388
420
  latest = False
@@ -411,7 +443,7 @@ def _managed_docker(arguments: List[str]) -> int:
411
443
  elif value.startswith("--last="):
412
444
  limit = int(value.split("=", 1)[1])
413
445
  else:
414
- return subprocess.run(["docker", *values], check=False).returncode
446
+ return _exec_docker(values)
415
447
  index += 1
416
448
  payload = client.list_containers(
417
449
  all_containers=all_containers,
@@ -1774,7 +1806,7 @@ def open_context_shell(config_dir: Path, context_name: str) -> int:
1774
1806
  env["DOCKER_CONTEXT"] = context_name
1775
1807
  env["DOCKER_MANAGER_CONTEXT_NAME"] = context_name
1776
1808
  shell = env.get("SHELL", "").strip() or "/bin/bash"
1777
- return subprocess.run([shell, "-i"], check=False, env=env).returncode
1809
+ return _exec_process([shell, "-i"], env=env)
1778
1810
 
1779
1811
 
1780
1812
  def active_shell_config_dir(context_name: str) -> Optional[Path]:
@@ -1805,6 +1837,29 @@ def _error_output(exc: subprocess.CalledProcessError) -> str:
1805
1837
  return "\n".join(value for value in values if value)
1806
1838
 
1807
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
+
1808
1863
  def _manager_deploy_suggestion(message: str) -> Optional[str]:
1809
1864
  if GITHUB_WORKFLOW_RESTRICTED_MESSAGE in message:
1810
1865
  return (
@@ -1824,10 +1879,16 @@ def _manager_deploy_suggestion(message: str) -> Optional[str]:
1824
1879
  "by this stack. Either allow that external network in the Docker-Manager deployment rule, attach the service to "
1825
1880
  "a stack-owned network, or relabel/recreate the existing network with the expected stack ownership before deploying."
1826
1881
  )
1827
- 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):
1828
1888
  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."
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."
1831
1892
  )
1832
1893
  if "docker-manager is configured" in lowered and "did not advertise required feature" in lowered:
1833
1894
  return (
@@ -1841,7 +1902,7 @@ def _format_called_process_error(exc: subprocess.CalledProcessError) -> str:
1841
1902
  output = _error_output(exc)
1842
1903
  lines = [f"docker-stack: command failed with exit code {exc.returncode}: {_format_command(exc.cmd)}"]
1843
1904
  if output:
1844
- lines.append(output)
1905
+ lines.append(_humanize_manager_error(output))
1845
1906
  suggestion = _manager_deploy_suggestion(output)
1846
1907
  if suggestion:
1847
1908
  lines.extend(["", suggestion])
@@ -1850,14 +1911,14 @@ def _format_called_process_error(exc: subprocess.CalledProcessError) -> str:
1850
1911
 
1851
1912
  def _format_runtime_error(exc: RuntimeError) -> str:
1852
1913
  message = str(exc).strip() or exc.__class__.__name__
1853
- lines = [f"docker-stack: {message}"]
1914
+ lines = [f"docker-stack: {_humanize_manager_error(message)}"]
1854
1915
  suggestion = _manager_deploy_suggestion(message)
1855
1916
  if suggestion:
1856
1917
  lines.extend(["", suggestion])
1857
1918
  return "\n".join(lines)
1858
1919
 
1859
1920
 
1860
- def main(args: List[str] = None):
1921
+ def _run(args: List[str] = None):
1861
1922
  parser = argparse.ArgumentParser(description="Deploy and manage Docker stacks.")
1862
1923
  subparsers = parser.add_subparsers(dest="command", required=True)
1863
1924
 
@@ -2202,5 +2263,18 @@ def main(args: List[str] = None):
2202
2263
  sys.exit(2)
2203
2264
 
2204
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
+
2205
2279
  if __name__ == "__main__":
2206
- main()
2280
+ sys.exit(main())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docker-stack
3
- Version: 2.2.3
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.3",
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,
@@ -200,11 +202,65 @@ def test_managed_docker_ps_forwards_global_latest_and_limit(monkeypatch):
200
202
  assert calls[1]["limit"] == 3
201
203
 
202
204
 
203
- def test_managed_docker_delegates_format_to_real_docker(monkeypatch):
205
+ def test_exec_process_replaces_the_python_process(monkeypatch):
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):
204
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"))
205
241
  monkeypatch.setattr(
206
242
  "docker_stack.cli.subprocess.run",
207
- 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,
208
264
  )
209
265
 
210
266
  assert _managed_docker(["ps", "--format", "{{.ID}}"]) == 7
@@ -224,8 +280,8 @@ def test_managed_docker_delegates_format_to_real_docker(monkeypatch):
224
280
  def test_managed_docker_delegates_explicit_target_overrides(monkeypatch, arguments):
225
281
  calls = []
226
282
  monkeypatch.setattr(
227
- "docker_stack.cli.subprocess.run",
228
- 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,
229
285
  )
230
286
  monkeypatch.setattr(
231
287
  "docker_stack.cli.discover_manager_client",
@@ -241,8 +297,8 @@ def test_managed_docker_ps_delegates_when_manager_lacks_cluster_feature(monkeypa
241
297
  client = SimpleNamespace(supports=lambda _feature: False)
242
298
  monkeypatch.setattr("docker_stack.cli.discover_manager_client", lambda: client)
243
299
  monkeypatch.setattr(
244
- "docker_stack.cli.subprocess.run",
245
- 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,
246
302
  )
247
303
 
248
304
  assert _managed_docker(["ps"]) == 9
@@ -1477,18 +1533,16 @@ def test_open_context_shell_clears_endpoint_overrides(monkeypatch, tmp_path):
1477
1533
  monkeypatch.setenv(key, f"parent-{key}")
1478
1534
  monkeypatch.setenv("SHELL", "/bin/test-shell")
1479
1535
 
1480
- def fake_run(cmd, check, env):
1536
+ def fake_exec(cmd, env=None):
1481
1537
  captured["cmd"] = cmd
1482
- captured["check"] = check
1483
1538
  captured["env"] = env
1484
- return SimpleNamespace(returncode=0)
1539
+ return 0
1485
1540
 
1486
- monkeypatch.setattr("docker_stack.cli.subprocess.run", fake_run)
1541
+ monkeypatch.setattr("docker_stack.cli._exec_process", fake_exec)
1487
1542
 
1488
1543
  assert open_context_shell(tmp_path, "office") == 0
1489
1544
 
1490
1545
  assert captured["cmd"] == ["/bin/test-shell", "-i"]
1491
- assert captured["check"] is False
1492
1546
  assert captured["env"]["DOCKER_CONFIG"] == str(tmp_path)
1493
1547
  assert captured["env"]["DOCKER_CONTEXT"] == "office"
1494
1548
  for key in DOCKER_SHELL_ENDPOINT_ENV_VARS:
@@ -1573,3 +1627,23 @@ def test_context_use_preserves_auth_when_switching_to_manager(monkeypatch, capsy
1573
1627
  output = capsys.readouterr().out
1574
1628
  assert "DOCKER_CONTEXT=office" in output
1575
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