docker-stack 2.2.4__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.4 → docker_stack-2.2.5}/PKG-INFO +1 -1
  2. {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/cli.py +24 -7
  3. {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/manager_api.py +41 -2
  4. {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack.egg-info/PKG-INFO +1 -1
  5. {docker_stack-2.2.4 → docker_stack-2.2.5}/setup.py +1 -1
  6. {docker_stack-2.2.4 → docker_stack-2.2.5}/tests/test_docker_stack.py +13 -8
  7. {docker_stack-2.2.4 → docker_stack-2.2.5}/README.md +0 -0
  8. {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/__init__.py +0 -0
  9. {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/command_runner.py +0 -0
  10. {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/compose.py +0 -0
  11. {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/docker_objects.py +0 -0
  12. {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/envsubst.py +0 -0
  13. {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/envsubst_merge.py +0 -0
  14. {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/helpers.py +0 -0
  15. {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/login.py +0 -0
  16. {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/markers.py +0 -0
  17. {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/merge_conf.py +0 -0
  18. {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/registry.py +0 -0
  19. {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/shell_auth.py +0 -0
  20. {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/url_parser.py +0 -0
  21. {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack.egg-info/SOURCES.txt +0 -0
  22. {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack.egg-info/dependency_links.txt +0 -0
  23. {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack.egg-info/entry_points.txt +0 -0
  24. {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack.egg-info/requires.txt +0 -0
  25. {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack.egg-info/top_level.txt +0 -0
  26. {docker_stack-2.2.4 → docker_stack-2.2.5}/pyproject.toml +0 -0
  27. {docker_stack-2.2.4 → docker_stack-2.2.5}/setup.cfg +0 -0
  28. {docker_stack-2.2.4 → docker_stack-2.2.5}/tests/test_docker_objects.py +0 -0
  29. {docker_stack-2.2.4 → docker_stack-2.2.5}/tests/test_load_env.py +0 -0
  30. {docker_stack-2.2.4 → docker_stack-2.2.5}/tests/test_login.py +0 -0
  31. {docker_stack-2.2.4 → docker_stack-2.2.5}/tests/test_manager_api.py +0 -0
  32. {docker_stack-2.2.4 → docker_stack-2.2.5}/tests/test_node_ls.py +0 -0
  33. {docker_stack-2.2.4 → 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.4
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):
@@ -413,7 +430,7 @@ def _managed_docker(arguments: List[str]) -> int:
413
430
  return _exec_docker(values)
414
431
  try:
415
432
  client = discover_manager_client()
416
- if client is None or not client.supports(FEATURE_CLUSTER_CONTAINER_CLI):
433
+ if client is None or not client.is_manager_backend():
417
434
  return _exec_docker(values)
418
435
  filters = []
419
436
  all_containers = False
@@ -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.4
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.4",
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",
@@ -38,14 +38,17 @@ def test_node_use_updates_only_managed_shell_config(monkeypatch, tmp_path):
38
38
  monkeypatch.setenv("DOCKER_STACK_SHELL_SECRET", "secret")
39
39
  monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path))
40
40
  monkeypatch.setenv("DOCKER_STACK_SHELL_NODE_STATE", str(node_state))
41
+ checked = []
41
42
  client = SimpleNamespace(
42
43
  list_nodes=lambda: {
43
44
  "nodes": [
44
45
  {"id": "node-worker-123", "hostname": "worker-02", "state": "Ready"},
45
46
  ]
46
- }
47
+ },
48
+ check_node_agent=lambda selector: checked.append(selector)
49
+ or {"node_id": selector, "node_name": "worker-02", "agent": "ready"},
47
50
  )
48
- monkeypatch.setattr("docker_stack.cli._manager_with_cluster_cli", lambda: client)
51
+ monkeypatch.setattr("docker_stack.cli._require_manager", lambda: client)
49
52
 
50
53
  assert _select_node("worker-02") == "worker-02"
51
54
  payload = json.loads(config_path.read_text(encoding="utf-8"))
@@ -54,6 +57,8 @@ def test_node_use_updates_only_managed_shell_config(monkeypatch, tmp_path):
54
57
  assert _read_selected_node() == "node-worker-123"
55
58
  assert node_state.read_text(encoding="utf-8").strip() == "worker-02"
56
59
 
60
+ assert checked == ["node-worker-123"]
61
+
57
62
  assert _select_node("cluster") == "cluster"
58
63
  payload = json.loads(config_path.read_text(encoding="utf-8"))
59
64
  assert "X-Docker-Manager-Node" not in payload["HttpHeaders"]
@@ -61,7 +66,7 @@ def test_node_use_updates_only_managed_shell_config(monkeypatch, tmp_path):
61
66
 
62
67
  def test_managed_docker_ps_renders_node_column(monkeypatch, capsys):
63
68
  client = SimpleNamespace(
64
- supports=lambda feature: feature == "cluster_container_cli_v1",
69
+ is_manager_backend=lambda: True,
65
70
  list_containers=lambda **_kwargs: {
66
71
  "containers": [
67
72
  {
@@ -93,7 +98,7 @@ def test_managed_docker_ps_renders_node_column(monkeypatch, capsys):
93
98
 
94
99
  def test_managed_docker_ps_matches_docker_columns(monkeypatch, capsys):
95
100
  client = SimpleNamespace(
96
- supports=lambda feature: feature == "cluster_container_cli_v1",
101
+ is_manager_backend=lambda: True,
97
102
  list_containers=lambda **_kwargs: {
98
103
  "containers": [
99
104
  {
@@ -156,7 +161,7 @@ def test_managed_docker_ps_matches_docker_columns(monkeypatch, capsys):
156
161
  )
157
162
  def test_managed_docker_ps_drops_pinned_digest_like_docker(monkeypatch, capsys, image, expected, expected_no_trunc):
158
163
  client = SimpleNamespace(
159
- supports=lambda feature: feature == "cluster_container_cli_v1",
164
+ is_manager_backend=lambda: True,
160
165
  list_containers=lambda **_kwargs: {
161
166
  "containers": [
162
167
  {
@@ -187,7 +192,7 @@ def test_managed_docker_ps_drops_pinned_digest_like_docker(monkeypatch, capsys,
187
192
  def test_managed_docker_ps_forwards_global_latest_and_limit(monkeypatch):
188
193
  calls = []
189
194
  client = SimpleNamespace(
190
- supports=lambda feature: feature == "cluster_container_cli_v1",
195
+ is_manager_backend=lambda: True,
191
196
  list_containers=lambda **kwargs: calls.append(kwargs)
192
197
  or {"containers": [], "node_errors": []},
193
198
  )
@@ -292,9 +297,9 @@ def test_managed_docker_delegates_explicit_target_overrides(monkeypatch, argumen
292
297
  assert calls == [["docker", *arguments]]
293
298
 
294
299
 
295
- 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):
296
301
  calls = []
297
- client = SimpleNamespace(supports=lambda _feature: False)
302
+ client = SimpleNamespace(is_manager_backend=lambda: False)
298
303
  monkeypatch.setattr("docker_stack.cli.discover_manager_client", lambda: client)
299
304
  monkeypatch.setattr(
300
305
  "docker_stack.cli._exec_process",
File without changes
File without changes