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.
- {docker_stack-2.2.4 → docker_stack-2.2.5}/PKG-INFO +1 -1
- {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/cli.py +24 -7
- {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/manager_api.py +41 -2
- {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack.egg-info/PKG-INFO +1 -1
- {docker_stack-2.2.4 → docker_stack-2.2.5}/setup.py +1 -1
- {docker_stack-2.2.4 → docker_stack-2.2.5}/tests/test_docker_stack.py +13 -8
- {docker_stack-2.2.4 → docker_stack-2.2.5}/README.md +0 -0
- {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/__init__.py +0 -0
- {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/command_runner.py +0 -0
- {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/compose.py +0 -0
- {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/docker_objects.py +0 -0
- {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/envsubst.py +0 -0
- {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/envsubst_merge.py +0 -0
- {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/helpers.py +0 -0
- {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/login.py +0 -0
- {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/markers.py +0 -0
- {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/merge_conf.py +0 -0
- {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/registry.py +0 -0
- {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/shell_auth.py +0 -0
- {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack/url_parser.py +0 -0
- {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack.egg-info/SOURCES.txt +0 -0
- {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack.egg-info/dependency_links.txt +0 -0
- {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack.egg-info/entry_points.txt +0 -0
- {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack.egg-info/requires.txt +0 -0
- {docker_stack-2.2.4 → docker_stack-2.2.5}/docker_stack.egg-info/top_level.txt +0 -0
- {docker_stack-2.2.4 → docker_stack-2.2.5}/pyproject.toml +0 -0
- {docker_stack-2.2.4 → docker_stack-2.2.5}/setup.cfg +0 -0
- {docker_stack-2.2.4 → docker_stack-2.2.5}/tests/test_docker_objects.py +0 -0
- {docker_stack-2.2.4 → docker_stack-2.2.5}/tests/test_load_env.py +0 -0
- {docker_stack-2.2.4 → docker_stack-2.2.5}/tests/test_login.py +0 -0
- {docker_stack-2.2.4 → docker_stack-2.2.5}/tests/test_manager_api.py +0 -0
- {docker_stack-2.2.4 → docker_stack-2.2.5}/tests/test_node_ls.py +0 -0
- {docker_stack-2.2.4 → docker_stack-2.2.5}/tests/test_shell_auth.py +0 -0
|
@@ -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
|
|
106
|
-
client
|
|
107
|
-
|
|
108
|
-
|
|
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 =
|
|
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.
|
|
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(
|
|
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
|
|
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(
|
|
@@ -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.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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
300
|
+
def test_managed_docker_ps_delegates_when_target_is_not_a_manager(monkeypatch):
|
|
296
301
|
calls = []
|
|
297
|
-
client = SimpleNamespace(
|
|
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
|
|
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
|