docker-stack 2.1.1__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 (
@@ -19,13 +20,16 @@ from docker_stack.login import (
19
20
  ensure_isolated_login,
20
21
  format_expiry,
21
22
  login as docker_manager_login,
23
+ persist_shell_context,
22
24
  resolve_login_config,
23
25
  resolve_shell_login_config,
24
26
  setup_auth as docker_manager_setup_auth,
25
27
  switch_docker_context,
28
+ UnknownShellContextError,
26
29
  )
27
30
  from docker_stack.shell_auth import (
28
31
  SHELL_CONTEXT_ENV,
32
+ SHELL_NODE_STATE_ENV,
29
33
  ShellAuthError,
30
34
  ensure_shell_access,
31
35
  login_for_active_shell,
@@ -33,6 +37,7 @@ from docker_stack.shell_auth import (
33
37
  shell_session_active,
34
38
  )
35
39
  from docker_stack.manager_api import (
40
+ FEATURE_CLUSTER_CONTAINER_CLI,
36
41
  FEATURE_STACK_DEPLOY,
37
42
  FEATURE_STACK_QUERY,
38
43
  ManagerApiClient,
@@ -48,6 +53,281 @@ DOCKER_SHELL_ENDPOINT_ENV_VARS = (
48
53
  "DOCKER_API_VERSION",
49
54
  "DOCKER_MACHINE_NAME",
50
55
  )
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
283
+
284
+
285
+ def physical_stack_name(namespace: str, stack: str) -> str:
286
+ return stack if namespace == DEFAULT_NAMESPACE else f"{namespace}--{stack}"
287
+
288
+
289
+ def _namespace_default() -> str:
290
+ return os.getenv("DOCKER_STACK_NAMESPACE", DEFAULT_NAMESPACE).strip() or DEFAULT_NAMESPACE
291
+
292
+
293
+ def _add_namespace_argument(parser: argparse.ArgumentParser, help_text: str = "Stack namespace") -> None:
294
+ parser.add_argument(
295
+ "-n",
296
+ "--namespace",
297
+ default=_namespace_default(),
298
+ help=f"{help_text} (default: %(default)s)",
299
+ )
300
+
301
+
302
+ def _add_listing_namespace_arguments(parser: argparse.ArgumentParser) -> None:
303
+ group = parser.add_mutually_exclusive_group()
304
+ group.add_argument(
305
+ "-n",
306
+ "--namespace",
307
+ default=_namespace_default(),
308
+ help="Stack namespace (default: %(default)s)",
309
+ )
310
+ group.add_argument(
311
+ "-A",
312
+ "--all-namespaces",
313
+ "-all-namespaces",
314
+ action="store_true",
315
+ help="List stacks across all namespaces",
316
+ )
317
+
318
+
319
+ def _prompt_for_manager_target(context_name: str) -> str:
320
+ if not sys.stdin.isatty():
321
+ raise RuntimeError(
322
+ f"Unknown shell context '{context_name}'. Run "
323
+ f"'docker-stack shell --context {context_name} <manager-url>' to create it."
324
+ )
325
+ print(f"Docker context '{context_name}' does not exist yet.")
326
+ print("Enter the Docker-Manager URL to create it (for example https://manager.example.com:2376).")
327
+ target = input(f"Manager URL for {context_name}: ").strip()
328
+ if not target:
329
+ raise RuntimeError(f"Manager URL is required to create shell context '{context_name}'")
330
+ return target
51
331
 
52
332
 
53
333
  @dataclass
@@ -382,7 +662,13 @@ class DockerStack:
382
662
  env_sources.append(content.decode("utf-8", errors="ignore"))
383
663
  return x_files, env_sources
384
664
 
385
- def rendered_compose_file(self, compose_file, stack=None, include_build=True) -> RenderedCompose:
665
+ def rendered_compose_file(
666
+ self,
667
+ compose_file,
668
+ stack=None,
669
+ include_build=True,
670
+ namespace: str = DEFAULT_NAMESPACE,
671
+ ) -> RenderedCompose:
386
672
  compose_path = Path(compose_file).resolve()
387
673
  template_bytes = compose_path.read_bytes()
388
674
  template_content = template_bytes.decode("utf-8")
@@ -411,11 +697,19 @@ class DockerStack:
411
697
  if stack and not manager_deploy:
412
698
  if "configs" in compose_data:
413
699
  compose_data["configs"] = self._process_x_content(
414
- compose_data["configs"], self.docker.config, base_dir=str(base_dir), stack=stack
700
+ compose_data["configs"],
701
+ self.docker.config,
702
+ base_dir=str(base_dir),
703
+ stack=stack,
704
+ namespace=namespace,
415
705
  )
416
706
  if "secrets" in compose_data:
417
707
  compose_data["secrets"] = self._process_x_content(
418
- compose_data["secrets"], self.docker.secret, base_dir=str(base_dir), stack=stack
708
+ compose_data["secrets"],
709
+ self.docker.secret,
710
+ base_dir=str(base_dir),
711
+ stack=stack,
712
+ namespace=namespace,
419
713
  )
420
714
 
421
715
  # Define the replacements for '$' to '$$' for env variables in compose files
@@ -437,26 +731,38 @@ class DockerStack:
437
731
  def decode_yaml(self, data: str) -> dict:
438
732
  return yaml.safe_load(data)
439
733
 
440
- def render_compose_file(self, compose_file, stack=None, include_build=True):
734
+ def render_compose_file(self, compose_file, stack=None, include_build=True, namespace: str = DEFAULT_NAMESPACE):
441
735
  """
442
736
  Render the Docker Compose file with environment variables and create Docker configs/secrets.
443
737
  """
444
- return self.rendered_compose_file(compose_file, stack, include_build=include_build)
738
+ return self.rendered_compose_file(
739
+ compose_file,
740
+ stack,
741
+ include_build=include_build,
742
+ namespace=namespace,
743
+ )
445
744
 
446
- def _process_x_content(self, objects, manager: DockerObjectManager, base_dir="", stack=None):
745
+ def _process_x_content(
746
+ self,
747
+ objects,
748
+ manager: DockerObjectManager,
749
+ base_dir="",
750
+ stack=None,
751
+ namespace: str = DEFAULT_NAMESPACE,
752
+ ):
447
753
  """
448
754
  Process configs or secrets with x-content keys.
449
755
  Returns a tuple: (processed_objects, commands)
450
756
  """
451
757
  processed_objects = {}
452
758
  manager_client = self._manager_client_for_feature(FEATURE_STACK_DEPLOY)
453
- namespace = os.getenv("DOCKER_STACK_NAMESPACE", "default")
759
+ object_stack = physical_stack_name(namespace, stack) if stack else None
454
760
 
455
761
  def docker_object_name_for(name, explicit_name=None):
456
762
  if explicit_name:
457
763
  return explicit_name
458
- if stack:
459
- return f"{stack}_{name}"
764
+ if object_stack:
765
+ return f"{object_stack}_{name}"
460
766
  return name
461
767
 
462
768
  def normalize_labels(labels):
@@ -500,7 +806,7 @@ class DockerStack:
500
806
  processed_objects[name] = {"name": response["actual_name"], "external": True}
501
807
  return
502
808
 
503
- object_name, command = manager.create(docker_object_name, data, labels=labels, stack=stack)
809
+ object_name, command = manager.create(docker_object_name, data, labels=labels, stack=object_stack)
504
810
  if not command.isNop():
505
811
  self.commands.append(command)
506
812
  if is_generated_secret:
@@ -604,16 +910,55 @@ class DockerStack:
604
910
  def _version_sort_key(raw: str):
605
911
  return int(raw) if raw.isdigit() else raw
606
912
 
607
- def _print_stack_listing(self, stack_versions: Dict[str, List[str]]) -> None:
608
- max_stack_name_length = max(len(stack) for stack in stack_versions) if stack_versions else 10
609
- header_stack = "Stack Name".ljust(max_stack_name_length)
913
+ @staticmethod
914
+ def _compact_versions(versions: List[str]) -> str:
915
+ numeric = sorted({int(version.lstrip("vV")) for version in versions if version.lstrip("vV").isdigit()})
916
+ other = sorted({version for version in versions if not version.lstrip("vV").isdigit()})
917
+ compact = []
918
+ start = end = None
919
+
920
+ def append_range(first: int, last: int) -> None:
921
+ if last - first >= 2:
922
+ compact.append(f"v{first}...v{last}")
923
+ elif last == first:
924
+ compact.append(f"v{first}")
925
+ else:
926
+ compact.extend((f"v{first}", f"v{last}"))
610
927
 
611
- print(f"{header_stack} | Versions")
612
- print("-" * (max_stack_name_length + 12))
928
+ for version in numeric:
929
+ if start is None:
930
+ start = end = version
931
+ elif version == end + 1:
932
+ end = version
933
+ else:
934
+ append_range(start, end)
935
+ start = end = version
936
+ if start is not None:
937
+ append_range(start, end)
938
+ compact.extend(other)
939
+ return ", ".join(compact)
940
+
941
+ def _print_stack_listing(self, stack_versions: Dict[Tuple[str, str], List[str]], namespace: Optional[str]) -> None:
942
+ max_stack_name_length = max((len(stack) for _, stack in stack_versions), default=10)
943
+ header_stack = "Stack Name".ljust(max_stack_name_length)
613
944
 
614
- for stack, versions in sorted(stack_versions.items()):
615
- versions_str = ", ".join(sorted(versions, key=self._version_sort_key))
616
- print(f"{stack.ljust(max_stack_name_length)} | {versions_str}")
945
+ if namespace is not None:
946
+ print(f"Namespace: {namespace}")
947
+ print(f"{header_stack} | Versions")
948
+ print("-" * (max_stack_name_length + 12))
949
+ else:
950
+ max_namespace_length = max((len(item_namespace) for item_namespace, _ in stack_versions), default=9)
951
+ header_namespace = "Namespace".ljust(max_namespace_length)
952
+ print("Namespace: all")
953
+ print(f"{header_namespace} | {header_stack} | Versions")
954
+ print("-" * (max_namespace_length + max_stack_name_length + 15))
955
+
956
+ for (item_namespace, stack), versions in sorted(stack_versions.items()):
957
+ versions_str = self._compact_versions(versions)
958
+ if namespace is None:
959
+ print(f"{item_namespace.ljust(max_namespace_length)} | {stack.ljust(max_stack_name_length)} | {versions_str}")
960
+ else:
961
+ print(f"{stack.ljust(max_stack_name_length)} | {versions_str}")
617
962
 
618
963
  def _print_versions(self, versions_list: List[Tuple[str, str]]) -> None:
619
964
  rows = [("Version", "Tag")] + versions_list
@@ -626,13 +971,13 @@ class DockerStack:
626
971
  for version, tag in sorted(versions_list, key=lambda x: self._version_sort_key(x[0])):
627
972
  print(f"{version.ljust(max_version_length)} | {tag.ljust(max_tag_length)}")
628
973
 
629
- def ls(self):
974
+ def ls(self, namespace: Optional[str] = DEFAULT_NAMESPACE):
630
975
  client = self._manager_client_for_feature(FEATURE_STACK_QUERY)
631
976
  if client:
632
977
  try:
633
- payload = client.list_stacks()
978
+ payload = client.list_stacks(namespace=namespace)
634
979
  stack_versions = {
635
- str(item.get("stack")): [
980
+ (str(item.get("namespace") or namespace or DEFAULT_NAMESPACE), str(item.get("stack"))): [
636
981
  str(version)
637
982
  for version in (
638
983
  item.get("versions") if isinstance(item.get("versions"), list) else item.get("available_versions", [])
@@ -642,7 +987,9 @@ class DockerStack:
642
987
  for item in payload.get("stacks", [])
643
988
  if str(item.get("stack", "")).strip()
644
989
  }
645
- self._print_stack_listing(stack_versions)
990
+ self._print_stack_listing(stack_versions, namespace)
991
+ if namespace is not None:
992
+ return {stack: versions for (_, stack), versions in stack_versions.items()}
646
993
  return stack_versions
647
994
  except RuntimeError as exc:
648
995
  self._raise_if_manager_is_explicit(exc)
@@ -650,7 +997,7 @@ class DockerStack:
650
997
  cmd = ["docker", "config", "ls", "--format", "{{.ID}}\t{{.Name}}\t{{.Labels}}"]
651
998
  raw_output = run_cli_command(cmd, log=False)
652
999
  output = raw_output.split("\n") if raw_output else []
653
- stack_versions: Dict[str, List[str]] = {}
1000
+ stack_versions: Dict[Tuple[str, str], List[str]] = {}
654
1001
 
655
1002
  for line in output:
656
1003
  parts = line.split("\t")
@@ -658,11 +1005,14 @@ class DockerStack:
658
1005
  labels = {k: v for k, v in (label.split("=") for label in parts[2].split(",") if "=" in label)}
659
1006
  stack_name = labels.get("mesudip.stack.name")
660
1007
  version = labels.get("mesudip.object.version", "unknown")
1008
+ object_namespace = labels.get("com.mesudip.namespace", DEFAULT_NAMESPACE)
661
1009
 
662
- if stack_name:
663
- stack_versions.setdefault(stack_name, []).append(version)
1010
+ if stack_name and (namespace is None or object_namespace == namespace):
1011
+ stack_versions.setdefault((object_namespace, stack_name), []).append(version)
664
1012
 
665
- self._print_stack_listing(stack_versions)
1013
+ self._print_stack_listing(stack_versions, namespace)
1014
+ if namespace is not None:
1015
+ return {stack: versions for (_, stack), versions in stack_versions.items()}
666
1016
  return stack_versions
667
1017
 
668
1018
  def cat(self, name: str, version: str, namespace: str = "default"):
@@ -681,10 +1031,11 @@ class DockerStack:
681
1031
  except RuntimeError as exc:
682
1032
  self._raise_if_manager_is_explicit(exc)
683
1033
 
1034
+ history_name = physical_stack_name(namespace, name)
684
1035
  if normalized_version == "1":
685
- config_name = f"{name}"
1036
+ config_name = history_name
686
1037
  else:
687
- config_name = f"{name}_v{normalized_version}"
1038
+ config_name = f"{history_name}_v{normalized_version}"
688
1039
 
689
1040
  cmd = ["docker", "config", "inspect", config_name]
690
1041
  output = run_cli_command(cmd, log=False)
@@ -730,8 +1081,9 @@ class DockerStack:
730
1081
  stack = labels.get("mesudip.stack.name")
731
1082
  version = labels.get("mesudip.object.version", "unknown")
732
1083
  tag = labels.get("mesudip.stack.tag", "")
1084
+ object_namespace = labels.get("com.mesudip.namespace", DEFAULT_NAMESPACE)
733
1085
 
734
- if stack == stack_name:
1086
+ if stack == stack_name and object_namespace == namespace:
735
1087
  versions_list.append((version, tag))
736
1088
 
737
1089
  if print_output:
@@ -894,11 +1246,17 @@ class DockerStack:
894
1246
  if force_update:
895
1247
  raise RuntimeError("--force-update is supported only by the Docker-Manager stack deploy API")
896
1248
 
897
- _, cmd = self.docker.config.increment(stack_name, stored_content, labels=labels, stack=stack_name)
1249
+ physical_stack = physical_stack_name(namespace, stack_name)
1250
+ _, cmd = self.docker.config.increment(
1251
+ physical_stack,
1252
+ stored_content,
1253
+ labels=labels,
1254
+ stack=physical_stack,
1255
+ )
898
1256
  if not cmd.isNop():
899
1257
  self.commands.append(cmd)
900
1258
 
901
- cmd = ["docker", "stack", "deploy", "-c", "-", stack_name]
1259
+ cmd = ["docker", "stack", "deploy", "-c", "-", physical_stack]
902
1260
  if with_registry_auth:
903
1261
  cmd.insert(3, "--with-registry-auth")
904
1262
  if prune:
@@ -1059,7 +1417,12 @@ class DockerStack:
1059
1417
  force_update: bool = False,
1060
1418
  ):
1061
1419
  self.generated_secrets = {} # Reset for each deployment
1062
- rendered = self.render_compose_file(compose_file, stack=stack_name, include_build=False)
1420
+ rendered = self.render_compose_file(
1421
+ compose_file,
1422
+ stack=stack_name,
1423
+ include_build=False,
1424
+ namespace=namespace,
1425
+ )
1063
1426
  self._deploy(
1064
1427
  stack_name,
1065
1428
  rendered.clean,
@@ -1098,7 +1461,12 @@ class DockerStack:
1098
1461
  )
1099
1462
  )
1100
1463
  return
1101
- self.commands.append(Command(["docker", "stack", "rm", stack_name], give_console=True))
1464
+ self.commands.append(
1465
+ Command(
1466
+ ["docker", "stack", "rm", physical_stack_name(namespace, stack_name)],
1467
+ give_console=True,
1468
+ )
1469
+ )
1102
1470
 
1103
1471
  def push(self, compose_file):
1104
1472
  compose_data = self.read_compose_file(compose_file)
@@ -1380,6 +1748,9 @@ def main(args: List[str] = None):
1380
1748
  shell_auth_parser = subparsers.add_parser("shell-auth", help=argparse.SUPPRESS)
1381
1749
  shell_auth_parser.add_argument("operation", choices=("ensure", "status"), help=argparse.SUPPRESS)
1382
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
+
1383
1754
  # Build subcommand
1384
1755
  build_parser = subparsers.add_parser("build", help="Build images using docker-compose")
1385
1756
  build_parser.add_argument("compose_file", help="Path to the compose file")
@@ -1405,36 +1776,40 @@ def main(args: List[str] = None):
1405
1776
  action="store_true",
1406
1777
  help="Force service task updates (Docker-Manager deploys only)",
1407
1778
  )
1408
- deploy_parser.add_argument("--namespace", default=os.getenv("DOCKER_STACK_NAMESPACE", "default"), help="Deployment namespace")
1779
+ _add_namespace_argument(deploy_parser, "Deployment namespace")
1409
1780
  deploy_parser.add_argument("-t", "--tag", help="Tag the current deployment for later checkout", required=False)
1410
1781
  deploy_parser.add_argument("--show-generated", action="store_true", default=True, help="Show newly generated secrets after deployment")
1411
1782
 
1412
1783
  # Remove subcommand
1413
1784
  rm_parser = subparsers.add_parser("rm", help="Remove a deployed stack")
1414
1785
  rm_parser.add_argument("stack_name", help="Name of the stack")
1415
- rm_parser.add_argument("--namespace", default=os.getenv("DOCKER_STACK_NAMESPACE", "default"), help="Stack namespace")
1786
+ _add_namespace_argument(rm_parser)
1416
1787
 
1417
1788
  # Prune command
1418
1789
  subparsers.add_parser("prune", help="Remove old versions of configs and secrets")
1419
1790
 
1420
1791
  # Ls command
1421
- subparsers.add_parser("ls", help="List docker-stacks")
1792
+ ls_parser = subparsers.add_parser("ls", help="List docker-stacks")
1793
+ _add_listing_namespace_arguments(ls_parser)
1422
1794
 
1423
1795
  node_parser = subparsers.add_parser("node", help="Inspect Docker Swarm nodes")
1424
1796
  node_subparsers = node_parser.add_subparsers(dest="node_command", required=True)
1425
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'")
1426
1801
 
1427
1802
  cat_parser = subparsers.add_parser(
1428
1803
  "cat", help="Print the docker compose of specific version. Defaults to latest version if not specified."
1429
1804
  )
1430
1805
  cat_parser.add_argument("stack_name", help="Name of the stack")
1431
1806
  cat_parser.add_argument("version", nargs="?", help="Stack version to cat. Defaults to latest if omitted.")
1432
- cat_parser.add_argument("--namespace", default=os.getenv("DOCKER_STACK_NAMESPACE", "default"), help="Stack namespace")
1807
+ _add_namespace_argument(cat_parser)
1433
1808
 
1434
1809
  checkout_parser = subparsers.add_parser("checkout", help="Deploy specific version of the stack")
1435
1810
  checkout_parser.add_argument("stack_name", help="Name of the stack")
1436
1811
  checkout_parser.add_argument("version", help="Stack version to cat")
1437
- checkout_parser.add_argument("--namespace", default=os.getenv("DOCKER_STACK_NAMESPACE", "default"), help="Stack namespace")
1812
+ _add_namespace_argument(checkout_parser)
1438
1813
 
1439
1814
  # version_parser = subparsers.add_parser("version",help="Deploy specific version of the stack")
1440
1815
  # version_parser.add_argument("stack_name", help="Name of the stack")
@@ -1442,7 +1817,7 @@ def main(args: List[str] = None):
1442
1817
 
1443
1818
  version_parser = subparsers.add_parser("version", aliases=["versions"], help="Deploy specific version of the stack")
1444
1819
  version_parser.add_argument("stack_name", help="Name of the stack")
1445
- version_parser.add_argument("--namespace", default=os.getenv("DOCKER_STACK_NAMESPACE", "default"), help="Stack namespace")
1820
+ _add_namespace_argument(version_parser)
1446
1821
 
1447
1822
  parser.add_argument(
1448
1823
  "-u", "--user", help="Registry credentials in format hostname:username:password", action="append", required=False, default=[]
@@ -1471,6 +1846,9 @@ def main(args: List[str] = None):
1471
1846
  return 2
1472
1847
  return 0
1473
1848
 
1849
+ if args.command == "docker":
1850
+ return _managed_docker(args.docker_args)
1851
+
1474
1852
  if args.command == "login":
1475
1853
  try:
1476
1854
  config = resolve_login_config(
@@ -1480,6 +1858,8 @@ def main(args: List[str] = None):
1480
1858
  timeout_secs=args.timeout_secs,
1481
1859
  )
1482
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)
1483
1863
  if shell_session_active():
1484
1864
  result = login_for_active_shell(config)
1485
1865
  config_dir = current_shell_config
@@ -1513,12 +1893,26 @@ def main(args: List[str] = None):
1513
1893
  shell_name = args.target if args.context_name is None else None
1514
1894
  manager_target = args.target if args.context_name is not None else None
1515
1895
  try:
1516
- config = resolve_shell_login_config(
1517
- shell_name=shell_name,
1518
- manager_target=manager_target,
1519
- context_name=args.context_name,
1520
- timeout_secs=args.timeout_secs,
1521
- )
1896
+ try:
1897
+ config = resolve_shell_login_config(
1898
+ shell_name=shell_name,
1899
+ manager_target=manager_target,
1900
+ context_name=args.context_name,
1901
+ timeout_secs=args.timeout_secs,
1902
+ )
1903
+ except UnknownShellContextError as exc:
1904
+ manager_target = _prompt_for_manager_target(exc.context_name)
1905
+ print(f"Creating Docker-Manager shell context '{exc.context_name}'...")
1906
+ config = resolve_shell_login_config(
1907
+ shell_name=None,
1908
+ manager_target=manager_target,
1909
+ context_name=exc.context_name,
1910
+ timeout_secs=args.timeout_secs,
1911
+ )
1912
+ persist_shell_context(config)
1913
+ else:
1914
+ if manager_target is not None:
1915
+ persist_shell_context(config)
1522
1916
  if shell_session_active() and os.getenv(SHELL_CONTEXT_ENV) == config.context_name:
1523
1917
  print(f"docker-stack shell context={config.context_name} is already active")
1524
1918
  return
@@ -1586,6 +1980,9 @@ def main(args: List[str] = None):
1586
1980
  docker = Docker(registries=args.user)
1587
1981
  docker.load_env()
1588
1982
 
1983
+ if hasattr(args, "namespace") and args.command != "ls":
1984
+ print(f"Namespace: {args.namespace}", file=sys.stderr)
1985
+
1589
1986
  if args.command == "build":
1590
1987
  docker.stack.build_and_push(args.compose_file, push=args.push)
1591
1988
  elif args.command == "push":
@@ -1604,10 +2001,23 @@ def main(args: List[str] = None):
1604
2001
  force_update=args.force_update,
1605
2002
  )
1606
2003
  elif args.command == "ls":
1607
- docker.stack.ls()
2004
+ docker.stack.ls(namespace=None if args.all_namespaces else (args.namespace or DEFAULT_NAMESPACE))
1608
2005
  elif args.command == "node":
1609
2006
  if args.node_command == "ls":
1610
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}")
1611
2021
 
1612
2022
  elif args.command == "rm":
1613
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:
@@ -60,6 +61,12 @@ class DockerManagerSetupAuthResult:
60
61
  validation_skipped: bool = False
61
62
 
62
63
 
64
+ class UnknownShellContextError(RuntimeError):
65
+ def __init__(self, context_name: str):
66
+ self.context_name = context_name
67
+ super().__init__(f"Unknown shell context '{context_name}'")
68
+
69
+
63
70
  def normalize_loopback_host(url: str) -> str:
64
71
  if "127.0.0.1" in url:
65
72
  return url.replace("127.0.0.1", "localhost")
@@ -261,15 +268,23 @@ def resolve_login_config(
261
268
  current_context_name = None
262
269
  current_context_target = None
263
270
  inferred_context_name = None
271
+ resolved_docker_config_dir = None
264
272
  if not manager_url and not manager_target and not env_manager_value:
265
273
  current_context_name, current_context_target = current_docker_context_target()
266
274
 
267
275
  raw_manager_value = manager_url or manager_target or env_manager_value or current_context_target or "http://localhost:8080"
268
276
  if manager_target and "://" not in manager_target and ":" not in manager_target:
269
- 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)
270
283
  if context_target:
271
284
  raw_manager_value = context_target
272
285
  inferred_context_name = manager_target
286
+ if isolated_context_target:
287
+ resolved_docker_config_dir = isolated_config_dir
273
288
  if manager_target:
274
289
  resolved_manager_url, skip_tls_verify = detect_manager_url(raw_manager_value, verify_ssl=verify_ssl)
275
290
  elif manager_url:
@@ -296,6 +311,7 @@ def resolve_login_config(
296
311
  or "dm-proxy",
297
312
  timeout_secs=int(timeout_secs or os.getenv("DOCKER_MANAGER_LOGIN_TIMEOUT_SECS", "300")),
298
313
  skip_tls_verify=skip_tls_verify,
314
+ docker_config_dir=resolved_docker_config_dir,
299
315
  )
300
316
 
301
317
 
@@ -343,10 +359,7 @@ def resolve_shell_login_config(
343
359
  )
344
360
  target = persisted_target or docker_context_target(resolved_context_name)
345
361
  if not target:
346
- raise RuntimeError(
347
- f"Unknown shell context '{resolved_context_name}'. First run "
348
- f"'docker-stack shell --context {resolved_context_name} <manager-host>'."
349
- )
362
+ raise UnknownShellContextError(resolved_context_name)
350
363
  resolved_manager_url, skip_tls_verify = detect_manager_url(target)
351
364
  return DockerManagerLoginConfig(
352
365
  manager_url=resolved_manager_url,
@@ -618,6 +631,13 @@ def configure_docker_context_in_store(config: DockerManagerLoginConfig, docker_c
618
631
  raise RuntimeError((result.stderr or result.stdout or "failed to configure docker context").strip())
619
632
 
620
633
 
634
+ def persist_shell_context(config: DockerManagerLoginConfig) -> Path:
635
+ config_dir = isolated_docker_config_dir(config.context_name)
636
+ ensure_isolated_docker_config(config_dir)
637
+ configure_docker_context_in_store(config, config_dir)
638
+ return config_dir
639
+
640
+
621
641
  def ensure_isolated_login(
622
642
  config: DockerManagerLoginConfig,
623
643
  *,
@@ -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
 
@@ -429,10 +430,13 @@ class ManagerApiClient:
429
430
  return False
430
431
  return True
431
432
 
432
- def list_stacks(self) -> Dict[str, Any]:
433
+ def list_stacks(self, *, namespace: Optional[str] = DEFAULT_NAMESPACE) -> Dict[str, Any]:
434
+ query = urllib.parse.urlencode(
435
+ {"namespace": namespace} if namespace is not None else {"all_namespaces": "true"}
436
+ )
433
437
  if self._detect_manager_backend():
434
- return self._request_json("/api/docker-stack/stacks")
435
- return self._request_json(self._endpoint_path("/inventory/stacks"))
438
+ return self._request_json(f"/api/docker-stack/stacks?{query}")
439
+ return self._request_json(f"{self._endpoint_path('/inventory/stacks')}?{query}")
436
440
 
437
441
  def list_stack_versions(self, stack_name: str, *, namespace: str = DEFAULT_NAMESPACE) -> Dict[str, Any]:
438
442
  stack = urllib.parse.quote(stack_name, safe="")
@@ -487,6 +491,7 @@ class ManagerApiClient:
487
491
 
488
492
  nodes.append(
489
493
  {
494
+ "id": str(item.get("ID") or ""),
490
495
  "hostname": str(description.get("Hostname") or spec.get("Name") or item.get("ID") or "-"),
491
496
  "role": role,
492
497
  "manager_status": manager_status,
@@ -499,6 +504,39 @@ class ManagerApiClient:
499
504
 
500
505
  return {"nodes": nodes}
501
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
+
502
540
  def resolve_config(
503
541
  self,
504
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.1
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
@@ -47,6 +47,8 @@ Typical daemon-only workflow:
47
47
  ```bash
48
48
  docker-stack deploy my-stack docker-compose.yml
49
49
  docker-stack ls
50
+ docker-stack ls -n team-a
51
+ docker-stack ls -A
50
52
  docker-stack versions my-stack
51
53
  docker-stack cat my-stack
52
54
  docker-stack checkout my-stack v2
@@ -82,7 +84,10 @@ Use this option when CI can connect directly to the target Docker daemon.
82
84
 
83
85
  #### 2. Docker-Manager
84
86
 
85
- 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.
86
91
 
87
92
  For a full compose deployment directly from CI, use the action to configure
88
93
  Docker-Manager authentication and then run the normal `docker-stack deploy`
@@ -152,11 +157,48 @@ Open an isolated Bash or Zsh session for a manager context:
152
157
  docker-stack shell office
153
158
  ```
154
159
 
155
- The prompt displays `(docker:office)`, keeps the selected manager context active,
160
+ If `office` does not exist yet, the CLI asks for its Docker-Manager URL, creates
161
+ the context, authenticates, and opens the shell. You can also provide everything
162
+ non-interactively with `docker-stack shell --context office <manager-url>`.
163
+
164
+ Stack commands use the `default` namespace unless `-n/--namespace` is supplied.
165
+ Listings print the selected namespace; `docker-stack ls -A` (or
166
+ `--all-namespaces`) lists every visible namespace.
167
+
168
+ The prompt displays `(docker:office@cluster)`, keeps the selected manager context active,
156
169
  and refreshes authentication when needed. The session supports `docker` and
157
170
  `docker compose`; legacy `docker-compose` is not supported. If authentication
158
171
  expires, run `docker-stack login` again.
159
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
+
160
202
  ## Core Capabilities
161
203
 
162
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=FknrFO67Xfp502XY_e_B3NzhSU3UFJFUo-6R-7ZBQPs,72160
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=oueosAd_a_OP79L-f_Jmo6IW6BkG3q60p3vC-JcubBw,33194
10
- docker_stack/manager_api.py,sha256=VIwxpLjC6y9IliIguDYFxeKoJSuJ48KuNL_Z1fIZad4,26727
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.1.dist-info/METADATA,sha256=mqvsGzjJ2uwXf9KbrLVJ3r4iD-mn2I9o3zJeT_pKBMk,11901
17
- docker_stack-2.1.1.dist-info/WHEEL,sha256=SmOxYU7pzNKBqASvQJ7DjX3XGUF92lrGhMb3R6_iiqI,91
18
- docker_stack-2.1.1.dist-info/entry_points.txt,sha256=mpe2RwIguARsosXIUBQEN2pKU53URqZCh2S4ATwDFL4,55
19
- docker_stack-2.1.1.dist-info/top_level.txt,sha256=zT6TPL54cLrt9LO_MNkhEpGGOmsoe2HV6Na5Ohy3_2c,13
20
- docker_stack-2.1.1.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,,