docker-stack 2.1.2__tar.gz → 2.2.1__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.1.2 → docker_stack-2.2.1}/PKG-INFO +35 -3
  2. {docker_stack-2.1.2 → docker_stack-2.2.1}/README.md +34 -2
  3. {docker_stack-2.1.2 → docker_stack-2.2.1}/docker_stack/cli.py +380 -0
  4. {docker_stack-2.1.2 → docker_stack-2.2.1}/docker_stack/login.py +11 -1
  5. {docker_stack-2.1.2 → docker_stack-2.2.1}/docker_stack/manager_api.py +35 -0
  6. {docker_stack-2.1.2 → docker_stack-2.2.1}/docker_stack/shell_auth.py +37 -7
  7. {docker_stack-2.1.2 → docker_stack-2.2.1}/docker_stack.egg-info/PKG-INFO +35 -3
  8. {docker_stack-2.1.2 → docker_stack-2.2.1}/setup.py +1 -1
  9. {docker_stack-2.1.2 → docker_stack-2.2.1}/tests/test_docker_stack.py +232 -1
  10. {docker_stack-2.1.2 → docker_stack-2.2.1}/tests/test_login.py +20 -0
  11. {docker_stack-2.1.2 → docker_stack-2.2.1}/tests/test_shell_auth.py +55 -2
  12. {docker_stack-2.1.2 → docker_stack-2.2.1}/docker_stack/__init__.py +0 -0
  13. {docker_stack-2.1.2 → docker_stack-2.2.1}/docker_stack/command_runner.py +0 -0
  14. {docker_stack-2.1.2 → docker_stack-2.2.1}/docker_stack/compose.py +0 -0
  15. {docker_stack-2.1.2 → docker_stack-2.2.1}/docker_stack/docker_objects.py +0 -0
  16. {docker_stack-2.1.2 → docker_stack-2.2.1}/docker_stack/envsubst.py +0 -0
  17. {docker_stack-2.1.2 → docker_stack-2.2.1}/docker_stack/envsubst_merge.py +0 -0
  18. {docker_stack-2.1.2 → docker_stack-2.2.1}/docker_stack/helpers.py +0 -0
  19. {docker_stack-2.1.2 → docker_stack-2.2.1}/docker_stack/markers.py +0 -0
  20. {docker_stack-2.1.2 → docker_stack-2.2.1}/docker_stack/merge_conf.py +0 -0
  21. {docker_stack-2.1.2 → docker_stack-2.2.1}/docker_stack/registry.py +0 -0
  22. {docker_stack-2.1.2 → docker_stack-2.2.1}/docker_stack/url_parser.py +0 -0
  23. {docker_stack-2.1.2 → docker_stack-2.2.1}/docker_stack.egg-info/SOURCES.txt +0 -0
  24. {docker_stack-2.1.2 → docker_stack-2.2.1}/docker_stack.egg-info/dependency_links.txt +0 -0
  25. {docker_stack-2.1.2 → docker_stack-2.2.1}/docker_stack.egg-info/entry_points.txt +0 -0
  26. {docker_stack-2.1.2 → docker_stack-2.2.1}/docker_stack.egg-info/requires.txt +0 -0
  27. {docker_stack-2.1.2 → docker_stack-2.2.1}/docker_stack.egg-info/top_level.txt +0 -0
  28. {docker_stack-2.1.2 → docker_stack-2.2.1}/pyproject.toml +0 -0
  29. {docker_stack-2.1.2 → docker_stack-2.2.1}/setup.cfg +0 -0
  30. {docker_stack-2.1.2 → docker_stack-2.2.1}/tests/test_docker_objects.py +0 -0
  31. {docker_stack-2.1.2 → docker_stack-2.2.1}/tests/test_load_env.py +0 -0
  32. {docker_stack-2.1.2 → docker_stack-2.2.1}/tests/test_manager_api.py +0 -0
  33. {docker_stack-2.1.2 → docker_stack-2.2.1}/tests/test_node_ls.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docker-stack
3
- Version: 2.1.2
3
+ Version: 2.2.1
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:**
@@ -58,7 +58,10 @@ Use this option when CI can connect directly to the target Docker daemon.
58
58
 
59
59
  #### 2. Docker-Manager
60
60
 
61
- Use the bundled action when deploying through Docker-Manager:
61
+ Use the bundled action when deploying through
62
+ [Docker-Manager](https://github.com/mesudip/docker-enterprise). The manager
63
+ repository contains the server source, installation instructions, and deployment
64
+ documentation.
62
65
 
63
66
  For a full compose deployment directly from CI, use the action to configure
64
67
  Docker-Manager authentication and then run the normal `docker-stack deploy`
@@ -136,11 +139,40 @@ Stack commands use the `default` namespace unless `-n/--namespace` is supplied.
136
139
  Listings print the selected namespace; `docker-stack ls -A` (or
137
140
  `--all-namespaces`) lists every visible namespace.
138
141
 
139
- The prompt displays `(docker:office)`, keeps the selected manager context active,
142
+ The prompt displays `(docker:office@cluster)`, keeps the selected manager context active,
140
143
  and refreshes authentication when needed. The session supports `docker` and
141
144
  `docker compose`; legacy `docker-compose` is not supported. If authentication
142
145
  expires, run `docker-stack login` again.
143
146
 
147
+ Container listing is cluster-aware and includes the owning Swarm node. Select a
148
+ node when working with daemon-local resources such as volumes and images:
149
+
150
+ ```bash
151
+ docker ps
152
+ docker-stack node current
153
+ docker-stack node use worker-02
154
+ docker volume ls
155
+ docker image ls
156
+ docker-stack node use cluster
157
+ ```
158
+
159
+ Node selection is stored only in the managed shell's isolated Docker
160
+ configuration and appears in the prompt as `(docker:office@worker-02)`.
161
+ Selected-node requests remain subject to the manager's Docker permissions;
162
+ image management is root-only because the manager does not define delegated
163
+ image permissions.
164
+
165
+ Cluster-aware output is enabled after the manager has observed compatible
166
+ agents on every discovered node. The manager remembers that capability until it
167
+ restarts, so a temporary agent outage does not make the CLI revert to a legacy
168
+ feature decision. A partial cluster listing prints the available containers,
169
+ reports each failed node on stderr with its incident id, and exits non-zero.
170
+
171
+ `docker ps --quiet` and `docker ps --format ...` use Docker's native formatter
172
+ and therefore do not add the `NODE` column. Explicit Docker overrides such as
173
+ `--context`, `--host`/`-H`, and `--config` bypass managed cluster formatting and
174
+ are sent unchanged to the Docker CLI.
175
+
144
176
  ## Core Capabilities
145
177
 
146
178
  - **Advanced Deployments on Plain Docker Daemons:**
@@ -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,356 @@ 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
+ COMMAND_DISPLAY_WIDTH = 20
141
+ SIZE_UNITS = ("B", "kB", "MB", "GB", "TB", "PB")
142
+
143
+
144
+ def _human_duration(timestamp: object) -> str:
145
+ """Mirror docker's units.HumanDuration for the CREATED column."""
146
+ try:
147
+ seconds = time.time() - float(timestamp)
148
+ except (TypeError, ValueError):
149
+ return ""
150
+ if seconds < 1:
151
+ return "Less than a second ago"
152
+ if int(seconds) == 1:
153
+ return "1 second ago"
154
+ if seconds < 60:
155
+ return f"{int(seconds)} seconds ago"
156
+ minutes = int(seconds // 60)
157
+ if minutes == 1:
158
+ return "About a minute ago"
159
+ if minutes < 60:
160
+ return f"{minutes} minutes ago"
161
+ hours = int(seconds / 3600 + 0.5)
162
+ if hours == 1:
163
+ return "About an hour ago"
164
+ if hours < 48:
165
+ return f"{hours} hours ago"
166
+ if hours < 24 * 7 * 2:
167
+ return f"{hours // 24} days ago"
168
+ if hours < 24 * 30 * 2:
169
+ return f"{hours // 24 // 7} weeks ago"
170
+ if hours < 24 * 365 * 2:
171
+ return f"{hours // 24 // 30} months ago"
172
+ return f"{hours // 24 // 365} years ago"
173
+
174
+
175
+ def _human_size(value: object) -> str:
176
+ try:
177
+ size = float(value)
178
+ except (TypeError, ValueError):
179
+ return ""
180
+ index = 0
181
+ while size >= 1000.0 and index < len(SIZE_UNITS) - 1:
182
+ size /= 1000.0
183
+ index += 1
184
+ return f"{size:.3g}{SIZE_UNITS[index]}"
185
+
186
+
187
+ def _quote_command(value: str) -> str:
188
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
189
+ return f'"{escaped}"'
190
+
191
+
192
+ def _ellipsis(value: str, width: int) -> str:
193
+ if width <= 0:
194
+ return ""
195
+ if len(value) <= width:
196
+ return value
197
+ return value[: width - 1] + "…"
198
+
199
+
200
+ def _form_port_group(key: str, first: int, last: int) -> str:
201
+ parts = key.split("/")
202
+ address = ""
203
+ protocol = parts[0]
204
+ if len(parts) > 1:
205
+ address = parts[0]
206
+ protocol = parts[1]
207
+ group = str(first) if first == last else f"{first}-{last}"
208
+ if address:
209
+ group = f"{address}:{group}->{group}"
210
+ return f"{group}/{protocol}"
211
+
212
+
213
+ def _format_ports(ports: object) -> str:
214
+ """Mirror docker's api/types.DisplayablePorts for the PORTS column."""
215
+ if not isinstance(ports, list):
216
+ return ""
217
+ entries = []
218
+ for port in ports:
219
+ if not isinstance(port, dict):
220
+ continue
221
+ try:
222
+ private = int(port.get("PrivatePort"))
223
+ except (TypeError, ValueError):
224
+ continue
225
+ try:
226
+ public = int(port.get("PublicPort"))
227
+ except (TypeError, ValueError):
228
+ public = None
229
+ entries.append((private, str(port.get("Type") or "tcp"), str(port.get("IP") or ""), public))
230
+ entries.sort(key=lambda entry: entry[0])
231
+ groups: Dict[str, List[int]] = {}
232
+ group_keys: List[str] = []
233
+ result: List[str] = []
234
+ host_mappings: List[str] = []
235
+ for private, protocol, address, public in entries:
236
+ key = protocol
237
+ if address and public:
238
+ if public != private:
239
+ host_mappings.append(f"{address}:{public}->{private}/{protocol}")
240
+ continue
241
+ key = f"{address}/{protocol}"
242
+ group = groups.get(key)
243
+ if group is None:
244
+ groups[key] = [private, private]
245
+ group_keys.append(key)
246
+ continue
247
+ if private == group[1] + 1:
248
+ group[1] = private
249
+ continue
250
+ result.append(_form_port_group(key, group[0], group[1]))
251
+ groups[key] = [private, private]
252
+ for key in group_keys:
253
+ first, last = groups[key]
254
+ result.append(_form_port_group(key, first, last))
255
+ result.extend(host_mappings)
256
+ return ", ".join(result)
257
+
258
+
259
+ def _format_names(names: object, *, no_trunc: bool) -> str:
260
+ values = [str(name).lstrip("/") for name in names] if isinstance(names, list) else []
261
+ if not no_trunc:
262
+ for name in values:
263
+ if "/" not in name:
264
+ values = [name]
265
+ break
266
+ return ",".join(values)
267
+
268
+
269
+ def _format_image(image: object, image_id: object, *, no_trunc: bool) -> str:
270
+ value = str(image or "")
271
+ if not value:
272
+ return "<no image>"
273
+ if no_trunc:
274
+ return value
275
+ identifier = str(image_id or "")
276
+ if identifier and _short_id(identifier) == _short_id(value):
277
+ return _short_id(value)
278
+ return value
279
+
280
+
281
+ def _short_id(value: str) -> str:
282
+ return value[len("sha256:") :][:12] if value.startswith("sha256:") else value[:12]
283
+
284
+
285
+ def _print_table(columns: List[str], rows: List[Dict[str, str]]) -> None:
286
+ widths = {column: max(len(column), max((len(row[column]) for row in rows), default=0)) for column in columns}
287
+ for values in [{column: column for column in columns}, *rows]:
288
+ print(" ".join(values[column].ljust(widths[column]) for column in columns).rstrip())
289
+
290
+
291
+ def _print_cluster_containers(payload: Dict[str, object], *, no_trunc: bool = False, show_size: bool = False) -> int:
292
+ rows = []
293
+ for entry in payload.get("containers", []):
294
+ if not isinstance(entry, dict) or not isinstance(entry.get("docker"), dict):
295
+ continue
296
+ item = entry["docker"]
297
+ container_id = str(item.get("Id") or "")
298
+ command = str(item.get("Command") or "")
299
+ row = {
300
+ "CONTAINER ID": container_id if no_trunc else container_id[:12],
301
+ "IMAGE": _format_image(item.get("Image"), item.get("ImageID"), no_trunc=no_trunc),
302
+ "COMMAND": _quote_command(command if no_trunc else _ellipsis(command, COMMAND_DISPLAY_WIDTH)),
303
+ "CREATED": _human_duration(item.get("Created")),
304
+ "STATUS": str(item.get("Status") or item.get("State") or ""),
305
+ "PORTS": _format_ports(item.get("Ports")),
306
+ "NAMES": _format_names(item.get("Names"), no_trunc=no_trunc),
307
+ "NODE": str(entry.get("node_name") or entry.get("node_id") or ""),
308
+ }
309
+ if show_size:
310
+ size = _human_size(item.get("SizeRw") or 0)
311
+ try:
312
+ virtual = float(item.get("SizeRootFs") or 0)
313
+ except (TypeError, ValueError):
314
+ virtual = 0
315
+ row["SIZE"] = f"{size} (virtual {_human_size(virtual)})" if virtual > 0 else size
316
+ rows.append(row)
317
+ columns = ["CONTAINER ID", "IMAGE", "COMMAND", "CREATED", "STATUS", "PORTS", "NAMES"]
318
+ if show_size:
319
+ columns.append("SIZE")
320
+ columns.append("NODE")
321
+ _print_table(columns, rows)
322
+ errors = payload.get("node_errors") if isinstance(payload.get("node_errors"), list) else []
323
+ for error in errors:
324
+ if isinstance(error, dict):
325
+ print(
326
+ f"docker ps: node {error.get('node_name') or error.get('node_id')}: {error.get('message')} "
327
+ f"(incident: {error.get('incident_id')})",
328
+ file=sys.stderr,
329
+ )
330
+ return 1 if errors else 0
331
+
332
+
333
+ def _managed_docker(arguments: List[str]) -> int:
334
+ values = list(arguments)
335
+ if values and values[0] == "--":
336
+ values.pop(0)
337
+ command_index = 0
338
+ while command_index < len(values) and values[command_index].startswith("-"):
339
+ option = values[command_index]
340
+ if option in {"--config", "--context", "--host", "-H", "--log-level"}:
341
+ command_index += 2
342
+ else:
343
+ command_index += 1
344
+ command = values[command_index:]
345
+ global_options = values[:command_index]
346
+ explicit_target = any(
347
+ value in {"--config", "--context", "-c", "--host", "-H"}
348
+ or value.startswith(("--config=", "--context=", "--host=", "-H="))
349
+ or (value.startswith("-H") and value != "-H")
350
+ or (value.startswith("-c") and value != "-c")
351
+ for value in global_options
352
+ )
353
+ is_ps = bool(command) and (
354
+ command[0] == "ps"
355
+ or (len(command) >= 2 and command[0] == "container" and command[1] in {"ls", "list"})
356
+ )
357
+ if explicit_target or not is_ps or any(
358
+ value in {"-q", "--quiet", "--format"} or value.startswith("--format=")
359
+ for value in command
360
+ ):
361
+ return subprocess.run(["docker", *values], check=False).returncode
362
+ try:
363
+ client = discover_manager_client()
364
+ if client is None or not client.supports(FEATURE_CLUSTER_CONTAINER_CLI):
365
+ return subprocess.run(["docker", *values], check=False).returncode
366
+ filters = []
367
+ all_containers = False
368
+ latest = False
369
+ limit = None
370
+ no_trunc = False
371
+ show_size = False
372
+ index = 1 if command[0] == "ps" else 2
373
+ while index < len(command):
374
+ value = command[index]
375
+ if value in {"-a", "--all"}:
376
+ all_containers = True
377
+ elif value in {"-l", "--latest"}:
378
+ latest = True
379
+ elif value == "--no-trunc":
380
+ no_trunc = True
381
+ elif value in {"-s", "--size"}:
382
+ show_size = True
383
+ elif value in {"-f", "--filter"} and index + 1 < len(command):
384
+ index += 1
385
+ filters.append(command[index])
386
+ elif value.startswith("--filter="):
387
+ filters.append(value.split("=", 1)[1])
388
+ elif value in {"-n", "--last"} and index + 1 < len(command):
389
+ index += 1
390
+ limit = int(command[index])
391
+ elif value.startswith("--last="):
392
+ limit = int(value.split("=", 1)[1])
393
+ else:
394
+ return subprocess.run(["docker", *values], check=False).returncode
395
+ index += 1
396
+ payload = client.list_containers(
397
+ all_containers=all_containers,
398
+ filters=filters,
399
+ limit=limit,
400
+ latest=latest,
401
+ size=show_size,
402
+ )
403
+ return _print_cluster_containers(payload, no_trunc=no_trunc, show_size=show_size)
404
+ except (RuntimeError, ValueError) as exc:
405
+ print(f"docker ps: {exc}", file=sys.stderr)
406
+ return 2
54
407
 
55
408
 
56
409
  def physical_stack_name(namespace: str, stack: str) -> str:
@@ -1519,6 +1872,9 @@ def main(args: List[str] = None):
1519
1872
  shell_auth_parser = subparsers.add_parser("shell-auth", help=argparse.SUPPRESS)
1520
1873
  shell_auth_parser.add_argument("operation", choices=("ensure", "status"), help=argparse.SUPPRESS)
1521
1874
 
1875
+ docker_parser = subparsers.add_parser("docker", help=argparse.SUPPRESS)
1876
+ docker_parser.add_argument("docker_args", nargs=argparse.REMAINDER, help=argparse.SUPPRESS)
1877
+
1522
1878
  # Build subcommand
1523
1879
  build_parser = subparsers.add_parser("build", help="Build images using docker-compose")
1524
1880
  build_parser.add_argument("compose_file", help="Path to the compose file")
@@ -1563,6 +1919,9 @@ def main(args: List[str] = None):
1563
1919
  node_parser = subparsers.add_parser("node", help="Inspect Docker Swarm nodes")
1564
1920
  node_subparsers = node_parser.add_subparsers(dest="node_command", required=True)
1565
1921
  node_subparsers.add_parser("ls", help="List Docker Swarm nodes and their labels")
1922
+ node_subparsers.add_parser("current", help="Show the node selected in this managed shell")
1923
+ node_use_parser = node_subparsers.add_parser("use", help="Select a node for node-local Docker commands")
1924
+ node_use_parser.add_argument("node", help="Node id, id prefix, hostname, or 'cluster'")
1566
1925
 
1567
1926
  cat_parser = subparsers.add_parser(
1568
1927
  "cat", help="Print the docker compose of specific version. Defaults to latest version if not specified."
@@ -1611,6 +1970,9 @@ def main(args: List[str] = None):
1611
1970
  return 2
1612
1971
  return 0
1613
1972
 
1973
+ if args.command == "docker":
1974
+ return _managed_docker(args.docker_args)
1975
+
1614
1976
  if args.command == "login":
1615
1977
  try:
1616
1978
  config = resolve_login_config(
@@ -1620,6 +1982,8 @@ def main(args: List[str] = None):
1620
1982
  timeout_secs=args.timeout_secs,
1621
1983
  )
1622
1984
  current_shell_config = active_shell_config_dir(config.context_name)
1985
+ if current_shell_config is None:
1986
+ current_shell_config = getattr(config, "docker_config_dir", None)
1623
1987
  if shell_session_active():
1624
1988
  result = login_for_active_shell(config)
1625
1989
  config_dir = current_shell_config
@@ -1670,6 +2034,9 @@ def main(args: List[str] = None):
1670
2034
  timeout_secs=args.timeout_secs,
1671
2035
  )
1672
2036
  persist_shell_context(config)
2037
+ else:
2038
+ if manager_target is not None:
2039
+ persist_shell_context(config)
1673
2040
  if shell_session_active() and os.getenv(SHELL_CONTEXT_ENV) == config.context_name:
1674
2041
  print(f"docker-stack shell context={config.context_name} is already active")
1675
2042
  return
@@ -1762,6 +2129,19 @@ def main(args: List[str] = None):
1762
2129
  elif args.command == "node":
1763
2130
  if args.node_command == "ls":
1764
2131
  docker.node.ls()
2132
+ elif args.node_command == "current":
2133
+ try:
2134
+ print(_read_selected_node())
2135
+ except RuntimeError as exc:
2136
+ print(f"docker-stack node current: {exc}", file=sys.stderr)
2137
+ return 2
2138
+ elif args.node_command == "use":
2139
+ try:
2140
+ selected = _select_node(args.node)
2141
+ except RuntimeError as exc:
2142
+ print(f"docker-stack node use: {exc}", file=sys.stderr)
2143
+ return 2
2144
+ print(f"Docker node selection: {selected}")
1765
2145
 
1766
2146
  elif args.command == "rm":
1767
2147
  docker.stack.rm(args.stack_name, namespace=args.namespace)
@@ -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.1
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:**
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="docker-stack",
5
- version="2.1.2",
5
+ version="2.2.1",
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",
@@ -2,13 +2,22 @@ import base64
2
2
  import json
3
3
  import os
4
4
  import subprocess
5
+ import time
5
6
  from pathlib import Path
6
7
  from types import SimpleNamespace
7
8
 
8
9
  import pytest
9
10
  import yaml
10
11
 
11
- from docker_stack.cli import DOCKER_SHELL_ENDPOINT_ENV_VARS, Docker, active_shell_config_dir, open_context_shell
12
+ from docker_stack.cli import (
13
+ DOCKER_SHELL_ENDPOINT_ENV_VARS,
14
+ Docker,
15
+ _managed_docker,
16
+ _read_selected_node,
17
+ _select_node,
18
+ active_shell_config_dir,
19
+ open_context_shell,
20
+ )
12
21
  from docker_stack.helpers import Command
13
22
  from docker_stack.login import UnknownShellContextError
14
23
  from docker_stack import main
@@ -19,6 +28,176 @@ def decode_x_files(compose_content):
19
28
  return {item["path"]: base64.b64decode(item["content"]).decode("utf-8") for item in data.get("x-files", [])}
20
29
 
21
30
 
31
+ def test_node_use_updates_only_managed_shell_config(monkeypatch, tmp_path):
32
+ config_path = tmp_path / "config.json"
33
+ config_path.write_text(json.dumps({"HttpHeaders": {"Authorization": "Bearer token"}}), encoding="utf-8")
34
+ node_state = tmp_path / "node-selection"
35
+ monkeypatch.setenv("DOCKER_STACK_SHELL_SOCKET", str(tmp_path / "auth.sock"))
36
+ monkeypatch.setenv("DOCKER_STACK_SHELL_SECRET", "secret")
37
+ monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path))
38
+ monkeypatch.setenv("DOCKER_STACK_SHELL_NODE_STATE", str(node_state))
39
+ client = SimpleNamespace(
40
+ list_nodes=lambda: {
41
+ "nodes": [
42
+ {"id": "node-worker-123", "hostname": "worker-02", "state": "Ready"},
43
+ ]
44
+ }
45
+ )
46
+ monkeypatch.setattr("docker_stack.cli._manager_with_cluster_cli", lambda: client)
47
+
48
+ assert _select_node("worker-02") == "worker-02"
49
+ payload = json.loads(config_path.read_text(encoding="utf-8"))
50
+ assert payload["HttpHeaders"]["Authorization"] == "Bearer token"
51
+ assert payload["HttpHeaders"]["X-Docker-Manager-Node"] == "node-worker-123"
52
+ assert _read_selected_node() == "node-worker-123"
53
+ assert node_state.read_text(encoding="utf-8").strip() == "worker-02"
54
+
55
+ assert _select_node("cluster") == "cluster"
56
+ payload = json.loads(config_path.read_text(encoding="utf-8"))
57
+ assert "X-Docker-Manager-Node" not in payload["HttpHeaders"]
58
+
59
+
60
+ def test_managed_docker_ps_renders_node_column(monkeypatch, capsys):
61
+ client = SimpleNamespace(
62
+ supports=lambda feature: feature == "cluster_container_cli_v1",
63
+ list_containers=lambda **_kwargs: {
64
+ "containers": [
65
+ {
66
+ "node_id": "node-1",
67
+ "node_name": "worker-02",
68
+ "docker": {
69
+ "Id": "abcdef1234567890",
70
+ "Image": "nginx:1.27",
71
+ "Command": "nginx",
72
+ "Created": 1,
73
+ "Status": "Up 1 hour",
74
+ "Ports": [{"PrivatePort": 80, "Type": "tcp"}],
75
+ "Names": ["/web.1.test"],
76
+ },
77
+ }
78
+ ],
79
+ "node_errors": [],
80
+ }
81
+ )
82
+ monkeypatch.setattr("docker_stack.cli.discover_manager_client", lambda: client)
83
+
84
+ assert _managed_docker(["ps"]) == 0
85
+ output = capsys.readouterr().out
86
+ assert "CONTAINER ID" in output
87
+ assert "NODE" in output
88
+ assert "worker-02" in output
89
+ assert "abcdef123456" in output
90
+
91
+
92
+ def test_managed_docker_ps_matches_docker_columns(monkeypatch, capsys):
93
+ client = SimpleNamespace(
94
+ supports=lambda feature: feature == "cluster_container_cli_v1",
95
+ list_containers=lambda **_kwargs: {
96
+ "containers": [
97
+ {
98
+ "node_id": "node-1",
99
+ "node_name": "worker-02",
100
+ "docker": {
101
+ "Id": "467fce208a05dddd",
102
+ "Image": "registry.example.com/app/backend:v1",
103
+ "Command": "sh -c 'java $JAVA_OPTS -jar /app/app.jar'",
104
+ "Created": int(time.time()) - 17 * 60,
105
+ "Status": "Up 17 minutes",
106
+ "Ports": [
107
+ {"PrivatePort": 8080, "Type": "tcp"},
108
+ {"PrivatePort": 8081, "Type": "tcp"},
109
+ {"IP": "0.0.0.0", "PrivatePort": 443, "PublicPort": 8443, "Type": "tcp"},
110
+ ],
111
+ "Names": ["/app_backend.1.qtfngt97xvabnhj0ehk4us6kk"],
112
+ },
113
+ }
114
+ ],
115
+ "node_errors": [],
116
+ },
117
+ )
118
+ monkeypatch.setattr("docker_stack.cli.discover_manager_client", lambda: client)
119
+
120
+ assert _managed_docker(["ps"]) == 0
121
+ header, row = capsys.readouterr().out.splitlines()
122
+
123
+ assert header.split() == [
124
+ "CONTAINER", "ID", "IMAGE", "COMMAND", "CREATED", "STATUS", "PORTS", "NAMES", "NODE",
125
+ ]
126
+ assert row.startswith("467fce208a05 ")
127
+ assert '"sh -c \'java $JAVA_O\u2026"' in row
128
+ assert "17 minutes ago" in row
129
+ assert "8080-8081/tcp, 0.0.0.0:8443->443/tcp" in row
130
+ assert row.endswith("worker-02")
131
+ assert row == row.rstrip()
132
+
133
+
134
+ def test_managed_docker_ps_forwards_global_latest_and_limit(monkeypatch):
135
+ calls = []
136
+ client = SimpleNamespace(
137
+ supports=lambda feature: feature == "cluster_container_cli_v1",
138
+ list_containers=lambda **kwargs: calls.append(kwargs)
139
+ or {"containers": [], "node_errors": []},
140
+ )
141
+ monkeypatch.setattr("docker_stack.cli.discover_manager_client", lambda: client)
142
+
143
+ assert _managed_docker(["ps", "--latest"]) == 0
144
+ assert _managed_docker(["ps", "--last", "3"]) == 0
145
+
146
+ assert calls[0]["latest"] is True
147
+ assert calls[0]["limit"] is None
148
+ assert calls[1]["latest"] is False
149
+ assert calls[1]["limit"] == 3
150
+
151
+
152
+ def test_managed_docker_delegates_format_to_real_docker(monkeypatch):
153
+ calls = []
154
+ monkeypatch.setattr(
155
+ "docker_stack.cli.subprocess.run",
156
+ lambda command, **_kwargs: calls.append(command) or SimpleNamespace(returncode=7),
157
+ )
158
+
159
+ assert _managed_docker(["ps", "--format", "{{.ID}}"]) == 7
160
+ assert calls == [["docker", "ps", "--format", "{{.ID}}"]]
161
+
162
+
163
+ @pytest.mark.parametrize(
164
+ "arguments",
165
+ [
166
+ ["--context", "other", "ps"],
167
+ ["--context=other", "ps"],
168
+ ["-H", "tcp://other:2375", "ps"],
169
+ ["-H=tcp://other:2375", "ps"],
170
+ ["--config", "/tmp/other-docker-config", "ps"],
171
+ ],
172
+ )
173
+ def test_managed_docker_delegates_explicit_target_overrides(monkeypatch, arguments):
174
+ calls = []
175
+ monkeypatch.setattr(
176
+ "docker_stack.cli.subprocess.run",
177
+ lambda command, **_kwargs: calls.append(command) or SimpleNamespace(returncode=6),
178
+ )
179
+ monkeypatch.setattr(
180
+ "docker_stack.cli.discover_manager_client",
181
+ lambda: pytest.fail("explicit Docker targets must bypass managed cluster discovery"),
182
+ )
183
+
184
+ assert _managed_docker(arguments) == 6
185
+ assert calls == [["docker", *arguments]]
186
+
187
+
188
+ def test_managed_docker_ps_delegates_when_manager_lacks_cluster_feature(monkeypatch):
189
+ calls = []
190
+ client = SimpleNamespace(supports=lambda _feature: False)
191
+ monkeypatch.setattr("docker_stack.cli.discover_manager_client", lambda: client)
192
+ monkeypatch.setattr(
193
+ "docker_stack.cli.subprocess.run",
194
+ lambda command, **_kwargs: calls.append(command) or SimpleNamespace(returncode=9),
195
+ )
196
+
197
+ assert _managed_docker(["ps"]) == 9
198
+ assert calls == [["docker", "ps"]]
199
+
200
+
22
201
  class RecordingManager:
23
202
  def __init__(self):
24
203
  self.calls = []
@@ -1023,6 +1202,37 @@ def test_login_prefers_existing_context_for_portless_target(monkeypatch, capsys)
1023
1202
  assert "DOCKER_CONTEXT=office" in output
1024
1203
 
1025
1204
 
1205
+ def test_login_named_context_reuses_persisted_isolated_config(monkeypatch, tmp_path, capsys):
1206
+ (tmp_path / "config.json").write_text("{}\n", encoding="utf-8")
1207
+ captured = {}
1208
+ monkeypatch.setattr(
1209
+ "docker_stack.cli.resolve_login_config",
1210
+ lambda **_kwargs: SimpleNamespace(
1211
+ manager_url="https://172.31.3.3:2376",
1212
+ context_name="saas-a",
1213
+ docker_context_host="tcp://172.31.3.3:2376",
1214
+ skip_tls_verify=False,
1215
+ docker_config_dir=tmp_path,
1216
+ ),
1217
+ )
1218
+
1219
+ def fake_ensure(config, docker_config_dir=None):
1220
+ captured["context"] = config.context_name
1221
+ captured["config_dir"] = docker_config_dir
1222
+ return docker_config_dir, None
1223
+
1224
+ monkeypatch.setattr("docker_stack.cli.ensure_isolated_login", fake_ensure)
1225
+ monkeypatch.setattr(
1226
+ "docker_stack.cli.docker_manager_login",
1227
+ lambda _config: pytest.fail("named persisted login must not use global Docker config"),
1228
+ )
1229
+
1230
+ main(["login", "saas-a"])
1231
+
1232
+ assert captured == {"context": "saas-a", "config_dir": tmp_path}
1233
+ assert f"DOCKER_CONFIG={tmp_path}" in capsys.readouterr().out
1234
+
1235
+
1026
1236
  def test_login_inside_shell_updates_existing_isolated_config(monkeypatch, tmp_path, capsys):
1027
1237
  captured = {}
1028
1238
 
@@ -1167,6 +1377,27 @@ def test_shell_unknown_context_prompts_for_manager_url_and_creates_it(monkeypatc
1167
1377
  assert "Creating Docker-Manager shell context 'prod1'" in output
1168
1378
 
1169
1379
 
1380
+ def test_shell_explicit_target_persists_context_before_opening(monkeypatch):
1381
+ config = SimpleNamespace(
1382
+ manager_url="https://172.31.3.3:2376",
1383
+ context_name="saas-a",
1384
+ docker_context_host="tcp://172.31.3.3:2376",
1385
+ skip_tls_verify=False,
1386
+ timeout_secs=30,
1387
+ )
1388
+ events = []
1389
+ monkeypatch.setattr("docker_stack.cli.resolve_shell_login_config", lambda **_kwargs: config)
1390
+ monkeypatch.setattr("docker_stack.cli.persist_shell_context", lambda value: events.append(("persist", value.context_name)))
1391
+ monkeypatch.setattr("docker_stack.cli.browser_login", lambda _config: events.append(("login", "saas-a")) or SimpleNamespace(expires_at=None))
1392
+ monkeypatch.setattr("docker_stack.cli.run_managed_shell", lambda _config, _result: 0)
1393
+
1394
+ with pytest.raises(SystemExit) as excinfo:
1395
+ main(["shell", "--context", "saas-a", "https://172.31.3.3:2376"])
1396
+
1397
+ assert excinfo.value.code == 0
1398
+ assert events[:2] == [("persist", "saas-a"), ("login", "saas-a")]
1399
+
1400
+
1170
1401
  def test_shell_inside_same_context_reports_already_active(monkeypatch, capsys):
1171
1402
  def fake_resolve_shell_login_config(**_kwargs):
1172
1403
  return SimpleNamespace(
@@ -384,6 +384,26 @@ def test_resolve_login_config_prefers_named_context_for_portless_target(monkeypa
384
384
  assert config.skip_tls_verify is True
385
385
 
386
386
 
387
+ def test_resolve_login_config_prefers_isolated_named_context(monkeypatch, tmp_path):
388
+ monkeypatch.setattr("docker_stack.login.isolated_docker_config_dir", lambda _name: tmp_path)
389
+ monkeypatch.setattr(
390
+ "docker_stack.login.docker_context_target",
391
+ lambda context_name, docker_config_dir=None: (
392
+ "tcp://172.31.3.3:2376" if context_name == "saas-a" and docker_config_dir == tmp_path else None
393
+ ),
394
+ )
395
+ monkeypatch.setattr(
396
+ "docker_stack.login.detect_manager_url",
397
+ lambda value, verify_ssl=False: ("https://172.31.3.3:2376", False),
398
+ )
399
+
400
+ config = resolve_login_config(manager_target="saas-a")
401
+
402
+ assert config.manager_url == "https://172.31.3.3:2376"
403
+ assert config.context_name == "saas-a"
404
+ assert config.docker_config_dir == tmp_path
405
+
406
+
387
407
  def test_resolve_login_config_uses_current_context_target(monkeypatch):
388
408
  monkeypatch.setattr(
389
409
  "docker_stack.login.current_docker_context_target",
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  import os
3
+ import shutil
3
4
  import socket
4
5
  import subprocess
5
6
  import tempfile
@@ -289,8 +290,60 @@ def test_prompt_hooks_only_read_local_status_and_docker_wrapper_preflights(tmp_p
289
290
  wrapper = (tmp_path / "bashrc").read_text(encoding="utf-8")
290
291
  prompt_function = wrapper.split("__docker_stack_prompt_update() {", 1)[1].split("}", 1)[0]
291
292
  assert "shell-auth" not in prompt_function
292
- assert "docker-stack shell-auth ensure || return $?; command docker \"$@\"" in wrapper
293
- assert "(docker:office)" in wrapper
293
+ assert "docker-stack shell-auth ensure || return $?; docker-stack docker -- \"$@\"" in wrapper
294
+ assert "(docker:office@${node})" in wrapper
295
+ assert "(docker:!DOCKER_HOST)" in wrapper
296
+
297
+ zsh_wrapper = (tmp_path / "zsh" / ".zshrc").read_text(encoding="utf-8")
298
+ assert '[[ -n "${DOCKER_HOST:-}" ]]' in zsh_wrapper
299
+ assert "(docker:!DOCKER_HOST)" in zsh_wrapper
300
+ assert f"typeset -g HISTFILE={json.dumps(str(tmp_path / '.zsh_history'))}" in zsh_wrapper
301
+ assert '[[ -r "$HISTFILE" ]] && fc -R "$HISTFILE"' in zsh_wrapper
302
+
303
+
304
+ def test_zsh_wrapper_loads_and_persists_original_history(tmp_path, monkeypatch):
305
+ zsh = shutil.which("zsh")
306
+ if zsh is None:
307
+ pytest.skip("zsh is not installed")
308
+
309
+ original_zdotdir = tmp_path / "original-zdotdir"
310
+ original_zdotdir.mkdir()
311
+ (original_zdotdir / ".zshrc").write_text(
312
+ "HISTSIZE=100\nSAVEHIST=100\nsetopt append_history\n",
313
+ encoding="utf-8",
314
+ )
315
+ history_path = original_zdotdir / ".zsh_history"
316
+ history_path.write_text(": 1:0;existing-managed-shell-history\n", encoding="utf-8")
317
+ status_path = tmp_path / "status"
318
+ status_path.write_text("active 9999999999\n", encoding="utf-8")
319
+
320
+ monkeypatch.setenv("ZDOTDIR", str(original_zdotdir))
321
+ wrapper_env = _write_shell_wrappers(
322
+ tmp_path,
323
+ zsh,
324
+ context_name="office",
325
+ status_path=status_path,
326
+ )
327
+ env = dict(os.environ)
328
+ env.update(wrapper_env)
329
+ env.pop("TERM_SESSION_ID", None)
330
+
331
+ result = subprocess.run(
332
+ [
333
+ zsh,
334
+ "-i",
335
+ "-c",
336
+ 'fc -l 1; print -s "persisted-managed-shell-history"',
337
+ ],
338
+ check=False,
339
+ capture_output=True,
340
+ text=True,
341
+ env=env,
342
+ )
343
+
344
+ assert result.returncode == 0, result.stderr
345
+ assert "existing-managed-shell-history" in result.stdout
346
+ assert "persisted-managed-shell-history" in history_path.read_text(encoding="utf-8")
294
347
 
295
348
 
296
349
  def test_broker_stop_erases_in_memory_credentials_and_socket(tmp_path, monkeypatch):
File without changes