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.
- {docker_stack-2.2.3 → docker_stack-2.2.5}/PKG-INFO +1 -1
- {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/cli.py +109 -18
- {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/manager_api.py +41 -2
- {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack.egg-info/PKG-INFO +1 -1
- {docker_stack-2.2.3 → docker_stack-2.2.5}/setup.py +1 -1
- {docker_stack-2.2.3 → docker_stack-2.2.5}/tests/test_docker_stack.py +98 -19
- {docker_stack-2.2.3 → docker_stack-2.2.5}/README.md +0 -0
- {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/__init__.py +0 -0
- {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/command_runner.py +0 -0
- {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/compose.py +0 -0
- {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/docker_objects.py +0 -0
- {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/envsubst.py +0 -0
- {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/envsubst_merge.py +0 -0
- {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/helpers.py +0 -0
- {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/login.py +0 -0
- {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/markers.py +0 -0
- {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/merge_conf.py +0 -0
- {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/registry.py +0 -0
- {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/shell_auth.py +0 -0
- {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack/url_parser.py +0 -0
- {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack.egg-info/SOURCES.txt +0 -0
- {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack.egg-info/dependency_links.txt +0 -0
- {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack.egg-info/entry_points.txt +0 -0
- {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack.egg-info/requires.txt +0 -0
- {docker_stack-2.2.3 → docker_stack-2.2.5}/docker_stack.egg-info/top_level.txt +0 -0
- {docker_stack-2.2.3 → docker_stack-2.2.5}/pyproject.toml +0 -0
- {docker_stack-2.2.3 → docker_stack-2.2.5}/setup.cfg +0 -0
- {docker_stack-2.2.3 → docker_stack-2.2.5}/tests/test_docker_objects.py +0 -0
- {docker_stack-2.2.3 → docker_stack-2.2.5}/tests/test_load_env.py +0 -0
- {docker_stack-2.2.3 → docker_stack-2.2.5}/tests/test_login.py +0 -0
- {docker_stack-2.2.3 → docker_stack-2.2.5}/tests/test_manager_api.py +0 -0
- {docker_stack-2.2.3 → docker_stack-2.2.5}/tests/test_node_ls.py +0 -0
- {docker_stack-2.2.3 → 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):
|
|
@@ -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
|
|
430
|
+
return _exec_docker(values)
|
|
382
431
|
try:
|
|
383
432
|
client = discover_manager_client()
|
|
384
|
-
if client is None or not client.
|
|
385
|
-
return
|
|
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
|
|
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
|
|
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 "
|
|
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:
|
|
1830
|
-
"
|
|
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
|
|
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(
|
|
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",
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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=
|
|
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.
|
|
228
|
-
lambda command, **_kwargs: calls.append(command) or
|
|
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
|
|
300
|
+
def test_managed_docker_ps_delegates_when_target_is_not_a_manager(monkeypatch):
|
|
240
301
|
calls = []
|
|
241
|
-
client = SimpleNamespace(
|
|
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.
|
|
245
|
-
lambda command, **_kwargs: calls.append(command) or
|
|
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
|
|
1541
|
+
def fake_exec(cmd, env=None):
|
|
1481
1542
|
captured["cmd"] = cmd
|
|
1482
|
-
captured["check"] = check
|
|
1483
1543
|
captured["env"] = env
|
|
1484
|
-
return
|
|
1544
|
+
return 0
|
|
1485
1545
|
|
|
1486
|
-
monkeypatch.setattr("docker_stack.cli.
|
|
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
|
|
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
|