docker-stack 2.1.2__py3-none-any.whl → 2.2.0__py3-none-any.whl

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/cli.py CHANGED
@@ -12,6 +12,7 @@ from typing import Dict, List, Optional, Tuple
12
12
  import os
13
13
  import yaml
14
14
  import json
15
+ import time
15
16
  from docker_stack.docker_objects import DockerConfig, DockerObjectManager, DockerSecret
16
17
  from docker_stack.helpers import CallbackCommand, Command, generate_secret, run_cli_command
17
18
  from docker_stack.login import (
@@ -28,6 +29,7 @@ from docker_stack.login import (
28
29
  )
29
30
  from docker_stack.shell_auth import (
30
31
  SHELL_CONTEXT_ENV,
32
+ SHELL_NODE_STATE_ENV,
31
33
  ShellAuthError,
32
34
  ensure_shell_access,
33
35
  login_for_active_shell,
@@ -35,6 +37,7 @@ from docker_stack.shell_auth import (
35
37
  shell_session_active,
36
38
  )
37
39
  from docker_stack.manager_api import (
40
+ FEATURE_CLUSTER_CONTAINER_CLI,
38
41
  FEATURE_STACK_DEPLOY,
39
42
  FEATURE_STACK_QUERY,
40
43
  ManagerApiClient,
@@ -51,6 +54,232 @@ DOCKER_SHELL_ENDPOINT_ENV_VARS = (
51
54
  "DOCKER_MACHINE_NAME",
52
55
  )
53
56
  DEFAULT_NAMESPACE = "default"
57
+ NODE_HEADER = "X-Docker-Manager-Node"
58
+
59
+
60
+ def _active_docker_config_path() -> Path:
61
+ if not shell_session_active():
62
+ raise RuntimeError("node selection is available only inside docker-stack shell")
63
+ config_root = os.getenv("DOCKER_CONFIG", "").strip()
64
+ if not config_root:
65
+ raise RuntimeError("managed shell Docker configuration is unavailable")
66
+ return Path(config_root) / "config.json"
67
+
68
+
69
+ def _read_selected_node() -> str:
70
+ config_path = _active_docker_config_path()
71
+ try:
72
+ payload = json.loads(config_path.read_text(encoding="utf-8"))
73
+ except (OSError, json.JSONDecodeError) as exc:
74
+ raise RuntimeError(f"failed reading managed Docker configuration: {exc}") from exc
75
+ headers = payload.get("HttpHeaders")
76
+ value = headers.get(NODE_HEADER) if isinstance(headers, dict) else None
77
+ return str(value).strip() if value else "cluster"
78
+
79
+
80
+ def _write_selected_node(value: Optional[str], display_name: str) -> None:
81
+ config_path = _active_docker_config_path()
82
+ try:
83
+ payload = json.loads(config_path.read_text(encoding="utf-8"))
84
+ except (OSError, json.JSONDecodeError) as exc:
85
+ raise RuntimeError(f"failed reading managed Docker configuration: {exc}") from exc
86
+ headers = payload.get("HttpHeaders")
87
+ headers = dict(headers) if isinstance(headers, dict) else {}
88
+ if value:
89
+ headers[NODE_HEADER] = value
90
+ else:
91
+ headers.pop(NODE_HEADER, None)
92
+ if headers:
93
+ payload["HttpHeaders"] = headers
94
+ else:
95
+ payload.pop("HttpHeaders", None)
96
+ temporary = config_path.with_suffix(".tmp")
97
+ temporary.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
98
+ os.chmod(temporary, 0o600)
99
+ os.replace(temporary, config_path)
100
+ node_state = os.getenv(SHELL_NODE_STATE_ENV, "").strip()
101
+ if node_state:
102
+ Path(node_state).write_text(f"{display_name}\n", encoding="utf-8")
103
+
104
+
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")
109
+ return client
110
+
111
+
112
+ def _select_node(value: str) -> str:
113
+ client = _manager_with_cluster_cli()
114
+ requested = value.strip()
115
+ if requested.lower() == "cluster":
116
+ _write_selected_node(None, "cluster")
117
+ return "cluster"
118
+ nodes = client.list_nodes().get("nodes", [])
119
+ exact = [node for node in nodes if str(node.get("id") or "") == requested]
120
+ candidates = exact or [
121
+ node
122
+ for node in nodes
123
+ if str(node.get("id") or "").startswith(requested)
124
+ or str(node.get("hostname") or "") == requested
125
+ ]
126
+ if not candidates:
127
+ raise RuntimeError(f"node '{requested}' is not visible or does not exist")
128
+ if len(candidates) != 1:
129
+ names = ", ".join(str(node.get("hostname") or node.get("id")) for node in candidates)
130
+ raise RuntimeError(f"node '{requested}' is ambiguous: {names}")
131
+ node = candidates[0]
132
+ if str(node.get("state") or "").lower() != "ready":
133
+ raise RuntimeError(f"node '{requested}' is not ready")
134
+ node_id = str(node.get("id") or "").strip()
135
+ hostname = str(node.get("hostname") or node_id).strip()
136
+ _write_selected_node(node_id, hostname)
137
+ return hostname
138
+
139
+
140
+ def _human_age(timestamp: object) -> str:
141
+ try:
142
+ seconds = max(0, int(time.time()) - int(timestamp))
143
+ except (TypeError, ValueError):
144
+ return "-"
145
+ if seconds < 60:
146
+ return f"{seconds} seconds ago"
147
+ if seconds < 3600:
148
+ return f"{seconds // 60} minutes ago"
149
+ if seconds < 86400:
150
+ return f"{seconds // 3600} hours ago"
151
+ return f"{seconds // 86400} days ago"
152
+
153
+
154
+ def _format_ports(ports: object) -> str:
155
+ if not isinstance(ports, list):
156
+ return ""
157
+ values = []
158
+ for port in ports:
159
+ if not isinstance(port, dict):
160
+ continue
161
+ private = port.get("PrivatePort")
162
+ protocol = port.get("Type") or "tcp"
163
+ public = port.get("PublicPort")
164
+ address = port.get("IP") or "0.0.0.0"
165
+ values.append(f"{address}:{public}->{private}/{protocol}" if public is not None else f"{private}/{protocol}")
166
+ return ", ".join(values)
167
+
168
+
169
+ def _print_cluster_containers(payload: Dict[str, object], *, no_trunc: bool = False, show_size: bool = False) -> int:
170
+ rows = []
171
+ for entry in payload.get("containers", []):
172
+ if not isinstance(entry, dict) or not isinstance(entry.get("docker"), dict):
173
+ continue
174
+ item = entry["docker"]
175
+ container_id = str(item.get("Id") or "-")
176
+ names = item.get("Names") if isinstance(item.get("Names"), list) else []
177
+ row = {
178
+ "CONTAINER ID": container_id if no_trunc else container_id[:12],
179
+ "IMAGE": str(item.get("Image") or "-"),
180
+ "COMMAND": str(item.get("Command") or "-"),
181
+ "CREATED": _human_age(item.get("Created")),
182
+ "STATUS": str(item.get("Status") or item.get("State") or "-"),
183
+ "PORTS": _format_ports(item.get("Ports")),
184
+ "NAMES": ",".join(str(name).lstrip("/") for name in names) or "-",
185
+ "NODE": str(entry.get("node_name") or entry.get("node_id") or "-"),
186
+ }
187
+ if show_size:
188
+ row["SIZE"] = str(item.get("SizeRw") if item.get("SizeRw") is not None else "-")
189
+ rows.append(row)
190
+ columns = ["CONTAINER ID", "IMAGE", "COMMAND", "CREATED", "STATUS", "PORTS", "NAMES"]
191
+ if show_size:
192
+ columns.append("SIZE")
193
+ columns.append("NODE")
194
+ widths = {column: max(len(column), max((len(row[column]) for row in rows), default=0)) for column in columns}
195
+ print(" ".join(column.ljust(widths[column]) for column in columns))
196
+ for row in rows:
197
+ print(" ".join(row[column].ljust(widths[column]) for column in columns))
198
+ errors = payload.get("node_errors") if isinstance(payload.get("node_errors"), list) else []
199
+ for error in errors:
200
+ if isinstance(error, dict):
201
+ print(
202
+ f"docker ps: node {error.get('node_name') or error.get('node_id')}: {error.get('message')} "
203
+ f"(incident: {error.get('incident_id')})",
204
+ file=sys.stderr,
205
+ )
206
+ return 1 if errors else 0
207
+
208
+
209
+ def _managed_docker(arguments: List[str]) -> int:
210
+ values = list(arguments)
211
+ if values and values[0] == "--":
212
+ values.pop(0)
213
+ command_index = 0
214
+ while command_index < len(values) and values[command_index].startswith("-"):
215
+ option = values[command_index]
216
+ if option in {"--config", "--context", "--host", "-H", "--log-level"}:
217
+ command_index += 2
218
+ else:
219
+ command_index += 1
220
+ command = values[command_index:]
221
+ global_options = values[:command_index]
222
+ explicit_target = any(
223
+ value in {"--config", "--context", "-c", "--host", "-H"}
224
+ or value.startswith(("--config=", "--context=", "--host=", "-H="))
225
+ or (value.startswith("-H") and value != "-H")
226
+ or (value.startswith("-c") and value != "-c")
227
+ for value in global_options
228
+ )
229
+ is_ps = bool(command) and (
230
+ command[0] == "ps"
231
+ or (len(command) >= 2 and command[0] == "container" and command[1] in {"ls", "list"})
232
+ )
233
+ if explicit_target or not is_ps or any(
234
+ value in {"-q", "--quiet", "--format"} or value.startswith("--format=")
235
+ for value in command
236
+ ):
237
+ return subprocess.run(["docker", *values], check=False).returncode
238
+ try:
239
+ client = discover_manager_client()
240
+ if client is None or not client.supports(FEATURE_CLUSTER_CONTAINER_CLI):
241
+ return subprocess.run(["docker", *values], check=False).returncode
242
+ filters = []
243
+ all_containers = False
244
+ latest = False
245
+ limit = None
246
+ no_trunc = False
247
+ show_size = False
248
+ index = 1 if command[0] == "ps" else 2
249
+ while index < len(command):
250
+ value = command[index]
251
+ if value in {"-a", "--all"}:
252
+ all_containers = True
253
+ elif value in {"-l", "--latest"}:
254
+ latest = True
255
+ elif value == "--no-trunc":
256
+ no_trunc = True
257
+ elif value in {"-s", "--size"}:
258
+ show_size = True
259
+ elif value in {"-f", "--filter"} and index + 1 < len(command):
260
+ index += 1
261
+ filters.append(command[index])
262
+ elif value.startswith("--filter="):
263
+ filters.append(value.split("=", 1)[1])
264
+ elif value in {"-n", "--last"} and index + 1 < len(command):
265
+ index += 1
266
+ limit = int(command[index])
267
+ elif value.startswith("--last="):
268
+ limit = int(value.split("=", 1)[1])
269
+ else:
270
+ return subprocess.run(["docker", *values], check=False).returncode
271
+ index += 1
272
+ payload = client.list_containers(
273
+ all_containers=all_containers,
274
+ filters=filters,
275
+ limit=limit,
276
+ latest=latest,
277
+ size=show_size,
278
+ )
279
+ return _print_cluster_containers(payload, no_trunc=no_trunc, show_size=show_size)
280
+ except (RuntimeError, ValueError) as exc:
281
+ print(f"docker ps: {exc}", file=sys.stderr)
282
+ return 2
54
283
 
55
284
 
56
285
  def physical_stack_name(namespace: str, stack: str) -> str:
@@ -1519,6 +1748,9 @@ def main(args: List[str] = None):
1519
1748
  shell_auth_parser = subparsers.add_parser("shell-auth", help=argparse.SUPPRESS)
1520
1749
  shell_auth_parser.add_argument("operation", choices=("ensure", "status"), help=argparse.SUPPRESS)
1521
1750
 
1751
+ docker_parser = subparsers.add_parser("docker", help=argparse.SUPPRESS)
1752
+ docker_parser.add_argument("docker_args", nargs=argparse.REMAINDER, help=argparse.SUPPRESS)
1753
+
1522
1754
  # Build subcommand
1523
1755
  build_parser = subparsers.add_parser("build", help="Build images using docker-compose")
1524
1756
  build_parser.add_argument("compose_file", help="Path to the compose file")
@@ -1563,6 +1795,9 @@ def main(args: List[str] = None):
1563
1795
  node_parser = subparsers.add_parser("node", help="Inspect Docker Swarm nodes")
1564
1796
  node_subparsers = node_parser.add_subparsers(dest="node_command", required=True)
1565
1797
  node_subparsers.add_parser("ls", help="List Docker Swarm nodes and their labels")
1798
+ node_subparsers.add_parser("current", help="Show the node selected in this managed shell")
1799
+ node_use_parser = node_subparsers.add_parser("use", help="Select a node for node-local Docker commands")
1800
+ node_use_parser.add_argument("node", help="Node id, id prefix, hostname, or 'cluster'")
1566
1801
 
1567
1802
  cat_parser = subparsers.add_parser(
1568
1803
  "cat", help="Print the docker compose of specific version. Defaults to latest version if not specified."
@@ -1611,6 +1846,9 @@ def main(args: List[str] = None):
1611
1846
  return 2
1612
1847
  return 0
1613
1848
 
1849
+ if args.command == "docker":
1850
+ return _managed_docker(args.docker_args)
1851
+
1614
1852
  if args.command == "login":
1615
1853
  try:
1616
1854
  config = resolve_login_config(
@@ -1620,6 +1858,8 @@ def main(args: List[str] = None):
1620
1858
  timeout_secs=args.timeout_secs,
1621
1859
  )
1622
1860
  current_shell_config = active_shell_config_dir(config.context_name)
1861
+ if current_shell_config is None:
1862
+ current_shell_config = getattr(config, "docker_config_dir", None)
1623
1863
  if shell_session_active():
1624
1864
  result = login_for_active_shell(config)
1625
1865
  config_dir = current_shell_config
@@ -1670,6 +1910,9 @@ def main(args: List[str] = None):
1670
1910
  timeout_secs=args.timeout_secs,
1671
1911
  )
1672
1912
  persist_shell_context(config)
1913
+ else:
1914
+ if manager_target is not None:
1915
+ persist_shell_context(config)
1673
1916
  if shell_session_active() and os.getenv(SHELL_CONTEXT_ENV) == config.context_name:
1674
1917
  print(f"docker-stack shell context={config.context_name} is already active")
1675
1918
  return
@@ -1762,6 +2005,19 @@ def main(args: List[str] = None):
1762
2005
  elif args.command == "node":
1763
2006
  if args.node_command == "ls":
1764
2007
  docker.node.ls()
2008
+ elif args.node_command == "current":
2009
+ try:
2010
+ print(_read_selected_node())
2011
+ except RuntimeError as exc:
2012
+ print(f"docker-stack node current: {exc}", file=sys.stderr)
2013
+ return 2
2014
+ elif args.node_command == "use":
2015
+ try:
2016
+ selected = _select_node(args.node)
2017
+ except RuntimeError as exc:
2018
+ print(f"docker-stack node use: {exc}", file=sys.stderr)
2019
+ return 2
2020
+ print(f"Docker node selection: {selected}")
1765
2021
 
1766
2022
  elif args.command == "rm":
1767
2023
  docker.stack.rm(args.stack_name, namespace=args.namespace)
docker_stack/login.py CHANGED
@@ -25,6 +25,7 @@ class DockerManagerLoginConfig:
25
25
  context_name: str
26
26
  timeout_secs: int
27
27
  skip_tls_verify: bool = False
28
+ docker_config_dir: Optional[Path] = None
28
29
 
29
30
  @property
30
31
  def docker_context_host(self) -> str:
@@ -267,15 +268,23 @@ def resolve_login_config(
267
268
  current_context_name = None
268
269
  current_context_target = None
269
270
  inferred_context_name = None
271
+ resolved_docker_config_dir = None
270
272
  if not manager_url and not manager_target and not env_manager_value:
271
273
  current_context_name, current_context_target = current_docker_context_target()
272
274
 
273
275
  raw_manager_value = manager_url or manager_target or env_manager_value or current_context_target or "http://localhost:8080"
274
276
  if manager_target and "://" not in manager_target and ":" not in manager_target:
275
- context_target = docker_context_target(manager_target)
277
+ isolated_config_dir = isolated_docker_config_dir(manager_target)
278
+ isolated_context_target = docker_context_target(
279
+ manager_target,
280
+ isolated_config_dir,
281
+ )
282
+ context_target = isolated_context_target or docker_context_target(manager_target)
276
283
  if context_target:
277
284
  raw_manager_value = context_target
278
285
  inferred_context_name = manager_target
286
+ if isolated_context_target:
287
+ resolved_docker_config_dir = isolated_config_dir
279
288
  if manager_target:
280
289
  resolved_manager_url, skip_tls_verify = detect_manager_url(raw_manager_value, verify_ssl=verify_ssl)
281
290
  elif manager_url:
@@ -302,6 +311,7 @@ def resolve_login_config(
302
311
  or "dm-proxy",
303
312
  timeout_secs=int(timeout_secs or os.getenv("DOCKER_MANAGER_LOGIN_TIMEOUT_SECS", "300")),
304
313
  skip_tls_verify=skip_tls_verify,
314
+ docker_config_dir=resolved_docker_config_dir,
305
315
  )
306
316
 
307
317
 
@@ -18,6 +18,7 @@ from docker_stack.shell_auth import ensure_shell_access
18
18
  FEATURE_MESUDIP_DOCKER_ENTERPRISE = "mesudip-docker-enterprise-v1"
19
19
  FEATURE_STACK_QUERY = "docker_stack_query_v1"
20
20
  FEATURE_STACK_DEPLOY = "docker_stack_deploy_v1"
21
+ FEATURE_CLUSTER_CONTAINER_CLI = "cluster_container_cli_v1"
21
22
  DEFAULT_NAMESPACE = "default"
22
23
 
23
24
 
@@ -490,6 +491,7 @@ class ManagerApiClient:
490
491
 
491
492
  nodes.append(
492
493
  {
494
+ "id": str(item.get("ID") or ""),
493
495
  "hostname": str(description.get("Hostname") or spec.get("Name") or item.get("ID") or "-"),
494
496
  "role": role,
495
497
  "manager_status": manager_status,
@@ -502,6 +504,39 @@ class ManagerApiClient:
502
504
 
503
505
  return {"nodes": nodes}
504
506
 
507
+ def list_containers(
508
+ self,
509
+ *,
510
+ all_containers: bool = False,
511
+ filters: Optional[list[str]] = None,
512
+ limit: Optional[int] = None,
513
+ latest: bool = False,
514
+ size: bool = False,
515
+ ) -> Dict[str, Any]:
516
+ docker_filters: Dict[str, list[str]] = {}
517
+ for raw_filter in filters or []:
518
+ key, separator, value = raw_filter.partition("=")
519
+ key = key.strip()
520
+ if not key or not separator:
521
+ raise RuntimeError(f"Invalid Docker filter: {raw_filter}")
522
+ docker_filters.setdefault(key, []).append(value)
523
+ params: Dict[str, Any] = {
524
+ "all": "1" if all_containers else "0",
525
+ "size": "1" if size else "0",
526
+ }
527
+ if docker_filters:
528
+ params["filters"] = json.dumps(docker_filters, separators=(",", ":"))
529
+ if limit is not None:
530
+ params["limit"] = str(limit)
531
+ if latest:
532
+ params["latest"] = "1"
533
+ payload = self._request_json(
534
+ f"/api/docker-stack/containers?{urllib.parse.urlencode(params)}"
535
+ )
536
+ if not isinstance(payload, dict) or not isinstance(payload.get("containers"), list):
537
+ raise RuntimeError("Docker-Manager containers response is invalid")
538
+ return payload
539
+
505
540
  def resolve_config(
506
541
  self,
507
542
  *,
@@ -32,6 +32,7 @@ SHELL_SOCKET_ENV = "DOCKER_STACK_SHELL_SOCKET"
32
32
  SHELL_SECRET_ENV = "DOCKER_STACK_SHELL_SECRET"
33
33
  SHELL_CONTEXT_ENV = "DOCKER_STACK_SHELL_CONTEXT"
34
34
  SHELL_STATUS_ENV = "DOCKER_STACK_SHELL_STATUS"
35
+ SHELL_NODE_STATE_ENV = "DOCKER_STACK_SHELL_NODE_STATE"
35
36
  SHELL_ORIGINAL_ZDOTDIR_ENV = "DOCKER_STACK_SHELL_ORIGINAL_ZDOTDIR"
36
37
  SHELL_AUTH_TIMEOUT_ENV = "DOCKER_STACK_SHELL_AUTH_TIMEOUT_SECS"
37
38
 
@@ -530,9 +531,11 @@ def _write_shell_wrappers(
530
531
  *,
531
532
  context_name: str,
532
533
  status_path: Path,
534
+ node_state_path: Optional[Path] = None,
533
535
  ) -> Dict[str, str]:
534
536
  context = _prompt_context(context_name)
535
537
  status_path_value = str(status_path)
538
+ node_state_path_value = str(node_state_path or status_path.with_name("node-selection"))
536
539
  no_color = bool(os.getenv("NO_COLOR"))
537
540
  bash_rc = session_dir / "bashrc"
538
541
  zsh_dir = session_dir / "zsh"
@@ -542,11 +545,18 @@ def _write_shell_wrappers(
542
545
  os.getenv(SHELL_ORIGINAL_ZDOTDIR_ENV) or os.getenv("ZDOTDIR", str(Path.home()))
543
546
  )
544
547
  original_zshrc = original_zshrc_root / ".zshrc"
548
+ original_zsh_history = original_zshrc_root / ".zsh_history"
545
549
  bash_rc.write_text(
546
550
  f'''[ -f {json.dumps(str(original_bashrc))} ] && source {json.dumps(str(original_bashrc))}
547
551
  __docker_stack_base_ps1="$PS1"
548
552
  __docker_stack_prompt_update() {{
549
- local state expires now color reset
553
+ local state expires now color reset node
554
+ if [ -n "${{DOCKER_HOST:-}}" ]; then
555
+ color='\\[\\e[31m\\]'; reset='\\[\\e[0m\\]'
556
+ [ {str(no_color).lower()} = true ] && color= && reset=
557
+ PS1="${{color}}(docker:!DOCKER_HOST)${{reset}} ${{__docker_stack_base_ps1}}"
558
+ return
559
+ fi
550
560
  read -r state expires < {json.dumps(status_path_value)} 2>/dev/null || state=error
551
561
  now=$(date +%s)
552
562
  if [ "$state" = active ] && [ "${{expires:-0}}" -gt $((now + {REFRESH_WINDOW_SECS})) ]; then color='\\[\\e[32m\\]';
@@ -554,21 +564,35 @@ __docker_stack_prompt_update() {{
554
564
  else color='\\[\\e[31m\\]'; fi
555
565
  reset='\\[\\e[0m\\]'
556
566
  [ {str(no_color).lower()} = true ] && color= && reset=
557
- PS1="${{color}}(docker:{context})${{reset}} ${{__docker_stack_base_ps1}}"
567
+ node=$(cat {json.dumps(node_state_path_value)} 2>/dev/null || printf cluster)
568
+ PS1="${{color}}(docker:{context}@${{node}})${{reset}} ${{__docker_stack_base_ps1}}"
558
569
  }}
559
570
  case ";${{PROMPT_COMMAND:-}};" in *';__docker_stack_prompt_update;'*) ;; *) PROMPT_COMMAND="__docker_stack_prompt_update${{PROMPT_COMMAND:+;$PROMPT_COMMAND}}";; esac
560
- docker() {{ docker-stack shell-auth ensure || return $?; command docker "$@"; }}
571
+ docker() {{ docker-stack shell-auth ensure || return $?; docker-stack docker -- "$@"; }}
561
572
  ''',
562
573
  encoding="utf-8",
563
574
  )
564
575
  os.chmod(bash_rc, 0o600)
565
576
  zsh_rc = zsh_dir / ".zshrc"
566
577
  zsh_rc.write_text(
567
- f'''[ -f {json.dumps(str(original_zshrc))} ] && source {json.dumps(str(original_zshrc))}
578
+ f'''typeset -g __docker_stack_session_zdotdir="$ZDOTDIR"
579
+ typeset -g ZDOTDIR={json.dumps(str(original_zshrc_root))}
580
+ typeset -g HISTFILE={json.dumps(str(original_zsh_history))}
581
+ typeset -g SHELL_SESSION_HISTORY=0
582
+ [ -f {json.dumps(str(original_zshrc))} ] && source {json.dumps(str(original_zshrc))}
583
+ [[ -r "$HISTFILE" ]] && fc -R "$HISTFILE"
584
+ typeset -g ZDOTDIR="$__docker_stack_session_zdotdir"
585
+ unset __docker_stack_session_zdotdir
568
586
  autoload -Uz add-zsh-hook
569
587
  typeset -g __docker_stack_base_prompt="$PROMPT"
570
588
  __docker_stack_prompt_update() {{
571
- local state expires now color reset
589
+ local state expires now color reset node
590
+ if [[ -n "${{DOCKER_HOST:-}}" ]]; then
591
+ color='%F{{red}}'; reset='%f'
592
+ [[ -n "${{NO_COLOR:-}}" ]] && color= && reset=
593
+ PROMPT="${{color}}(docker:!DOCKER_HOST)${{reset}} ${{__docker_stack_base_prompt}}"
594
+ return
595
+ fi
572
596
  read -r state expires < {json.dumps(status_path_value)} 2>/dev/null || state=error
573
597
  now=$(date +%s)
574
598
  if [[ "$state" = active && "${{expires:-0}}" -gt $((now + {REFRESH_WINDOW_SECS})) ]]; then color='%F{{green}}';
@@ -576,10 +600,11 @@ __docker_stack_prompt_update() {{
576
600
  else color='%F{{red}}'; fi
577
601
  reset='%f'
578
602
  [[ -n "${{NO_COLOR:-}}" ]] && color= && reset=
579
- PROMPT="${{color}}(docker:{context})${{reset}} ${{__docker_stack_base_prompt}}"
603
+ node=$(cat {json.dumps(node_state_path_value)} 2>/dev/null || printf cluster)
604
+ PROMPT="${{color}}(docker:{context}@${{node}})${{reset}} ${{__docker_stack_base_prompt}}"
580
605
  }}
581
606
  add-zsh-hook precmd __docker_stack_prompt_update
582
- docker() {{ docker-stack shell-auth ensure || return $?; command docker "$@"; }}
607
+ docker() {{ docker-stack shell-auth ensure || return $?; docker-stack docker -- "$@"; }}
583
608
  ''',
584
609
  encoding="utf-8",
585
610
  )
@@ -638,6 +663,9 @@ def run_managed_shell(config: DockerManagerLoginConfig, result: DockerManagerLog
638
663
  update_access_header(config_path, result.access_token)
639
664
  socket_path = session_dir / "auth.sock"
640
665
  status_path = session_dir / "status"
666
+ node_state_path = session_dir / "node-selection"
667
+ node_state_path.write_text("cluster\n", encoding="utf-8")
668
+ os.chmod(node_state_path, 0o600)
641
669
  shell_secret = os.urandom(32).hex()
642
670
  broker_env = dict(os.environ)
643
671
  for key in (SHELL_SOCKET_ENV, SHELL_SECRET_ENV, SHELL_CONTEXT_ENV, SHELL_STATUS_ENV):
@@ -689,12 +717,14 @@ def run_managed_shell(config: DockerManagerLoginConfig, result: DockerManagerLog
689
717
  shell,
690
718
  context_name=config.context_name,
691
719
  status_path=status_path,
720
+ node_state_path=node_state_path,
692
721
  )
693
722
  env = dict(os.environ)
694
723
  env[SHELL_SOCKET_ENV] = str(socket_path)
695
724
  env[SHELL_SECRET_ENV] = shell_secret
696
725
  env[SHELL_CONTEXT_ENV] = config.context_name
697
726
  env[SHELL_STATUS_ENV] = str(status_path)
727
+ env[SHELL_NODE_STATE_ENV] = str(node_state_path)
698
728
  env[SHELL_AUTH_TIMEOUT_ENV] = str(max(1, config.timeout_secs) + 5)
699
729
  env.update(wrapper_env)
700
730
  for key in ("DOCKER_HOST", "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH", "DOCKER_API_VERSION", "DOCKER_MACHINE_NAME"):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docker-stack
3
- Version: 2.1.2
3
+ Version: 2.2.0
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
@@ -84,7 +84,10 @@ Use this option when CI can connect directly to the target Docker daemon.
84
84
 
85
85
  #### 2. Docker-Manager
86
86
 
87
- Use the bundled action when deploying through Docker-Manager:
87
+ Use the bundled action when deploying through
88
+ [Docker-Manager](https://github.com/mesudip/docker-enterprise). The manager
89
+ repository contains the server source, installation instructions, and deployment
90
+ documentation.
88
91
 
89
92
  For a full compose deployment directly from CI, use the action to configure
90
93
  Docker-Manager authentication and then run the normal `docker-stack deploy`
@@ -162,11 +165,40 @@ Stack commands use the `default` namespace unless `-n/--namespace` is supplied.
162
165
  Listings print the selected namespace; `docker-stack ls -A` (or
163
166
  `--all-namespaces`) lists every visible namespace.
164
167
 
165
- The prompt displays `(docker:office)`, keeps the selected manager context active,
168
+ The prompt displays `(docker:office@cluster)`, keeps the selected manager context active,
166
169
  and refreshes authentication when needed. The session supports `docker` and
167
170
  `docker compose`; legacy `docker-compose` is not supported. If authentication
168
171
  expires, run `docker-stack login` again.
169
172
 
173
+ Container listing is cluster-aware and includes the owning Swarm node. Select a
174
+ node when working with daemon-local resources such as volumes and images:
175
+
176
+ ```bash
177
+ docker ps
178
+ docker-stack node current
179
+ docker-stack node use worker-02
180
+ docker volume ls
181
+ docker image ls
182
+ docker-stack node use cluster
183
+ ```
184
+
185
+ Node selection is stored only in the managed shell's isolated Docker
186
+ configuration and appears in the prompt as `(docker:office@worker-02)`.
187
+ Selected-node requests remain subject to the manager's Docker permissions;
188
+ image management is root-only because the manager does not define delegated
189
+ image permissions.
190
+
191
+ Cluster-aware output is enabled after the manager has observed compatible
192
+ agents on every discovered node. The manager remembers that capability until it
193
+ restarts, so a temporary agent outage does not make the CLI revert to a legacy
194
+ feature decision. A partial cluster listing prints the available containers,
195
+ reports each failed node on stderr with its incident id, and exits non-zero.
196
+
197
+ `docker ps --quiet` and `docker ps --format ...` use Docker's native formatter
198
+ and therefore do not add the `NODE` column. Explicit Docker overrides such as
199
+ `--context`, `--host`/`-H`, and `--config` bypass managed cluster formatting and
200
+ are sent unchanged to the Docker CLI.
201
+
170
202
  ## Core Capabilities
171
203
 
172
204
  - **Advanced Deployments on Plain Docker Daemons:**
@@ -1,20 +1,20 @@
1
1
  docker_stack/__init__.py,sha256=qgHdW8ZDBlyk6FzIf8Mld_cJD1SjOfqo2dELg4pK668,880
2
- docker_stack/cli.py,sha256=i5Sk1JXlLaCn6OfPfBLpDlHXM3EiozicOKJfhiQEVf8,77672
2
+ docker_stack/cli.py,sha256=yvBl8rI6GjpmgFyNBhP1shEDQUTTLjRJI2uzo8Q6xdg,88496
3
3
  docker_stack/command_runner.py,sha256=mNaUAVKtrJbji_5ARVPLKhc92avqllNKrVQj1A6S0dA,1252
4
4
  docker_stack/compose.py,sha256=_fAVesjyW5ecid46sYYhcu02yF1vqknDSCFYflJPDOE,534
5
5
  docker_stack/docker_objects.py,sha256=U6szytv5gKrmBYKqThSeMMXdiJ2u4STIQLkeqQ-Fn4Q,12103
6
6
  docker_stack/envsubst.py,sha256=gdEUpsjWJeNbbahySk56vUdQh1nns85o3LbU6SUBo3I,8337
7
7
  docker_stack/envsubst_merge.py,sha256=TwOu8BducG0rWnSreJGwesNi-VghCetljcf-aWceE8M,4971
8
8
  docker_stack/helpers.py,sha256=nJHaIS1vtFyPYclXVl3-Qd-1h55jnSy1LwdATDrF4hk,5953
9
- docker_stack/login.py,sha256=0Ow89JUQcuD-n7YEKeKs9YG2UNXaDfm8iLxUmBZCLNo,33507
10
- docker_stack/manager_api.py,sha256=OQkbbnP9oXOyRObzNZppmxgEuzs7mI65OIaPD8N3FlI,26942
9
+ docker_stack/login.py,sha256=SNHL2S3InQ7Qc3ITV1VySazix1f7H0OD7IvTPBTgF4Y,33977
10
+ docker_stack/manager_api.py,sha256=xb8RwY4LEn1usg9qhG9AfiCvBJ2tpQawVMLRwF3vYHY,28361
11
11
  docker_stack/markers.py,sha256=gqStnr0tNWZ1kzqvREc2jVQVQrevheShgJtL5QzSx4g,2388
12
12
  docker_stack/merge_conf.py,sha256=Pmsabcgf3SDyaWvf2OLsBPodzEqsvOIjj9xzn_qWXH0,1917
13
13
  docker_stack/registry.py,sha256=sWC1J9JDIrcYyhei1POYCZHJ0xUCKC2pveisSHMgYsQ,9036
14
- docker_stack/shell_auth.py,sha256=waJgM4TdewzZw4XGQcWQzESRvC3xkT6p3NJFdpSuWao,31545
14
+ docker_stack/shell_auth.py,sha256=em0_lo1qnLIB4KHYqn4vQvqwKe7HkV9NM_I0VQTDDQ4,33082
15
15
  docker_stack/url_parser.py,sha256=Sk8GQE0nEiwCkijp1OltP2AfgtKEmM2hybcH_rBhfiI,6824
16
- docker_stack-2.1.2.dist-info/METADATA,sha256=oXqlKiNG9dn2Q40oDxPFCkSfYPSUUBrn4aks_b7P_50,12381
17
- docker_stack-2.1.2.dist-info/WHEEL,sha256=SmOxYU7pzNKBqASvQJ7DjX3XGUF92lrGhMb3R6_iiqI,91
18
- docker_stack-2.1.2.dist-info/entry_points.txt,sha256=mpe2RwIguARsosXIUBQEN2pKU53URqZCh2S4ATwDFL4,55
19
- docker_stack-2.1.2.dist-info/top_level.txt,sha256=zT6TPL54cLrt9LO_MNkhEpGGOmsoe2HV6Na5Ohy3_2c,13
20
- docker_stack-2.1.2.dist-info/RECORD,,
16
+ docker_stack-2.2.0.dist-info/METADATA,sha256=xxDwMGLFu2AitS8ZFn0nOAVdTG-Z5yLxlfdW70qMkbg,13821
17
+ docker_stack-2.2.0.dist-info/WHEEL,sha256=SmOxYU7pzNKBqASvQJ7DjX3XGUF92lrGhMb3R6_iiqI,91
18
+ docker_stack-2.2.0.dist-info/entry_points.txt,sha256=mpe2RwIguARsosXIUBQEN2pKU53URqZCh2S4ATwDFL4,55
19
+ docker_stack-2.2.0.dist-info/top_level.txt,sha256=zT6TPL54cLrt9LO_MNkhEpGGOmsoe2HV6Na5Ohy3_2c,13
20
+ docker_stack-2.2.0.dist-info/RECORD,,