docker-stack 2.1.1__tar.gz → 2.1.2__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.1 → docker_stack-2.1.2}/PKG-INFO +11 -1
  2. {docker_stack-2.1.1 → docker_stack-2.1.2}/README.md +10 -0
  3. {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/cli.py +200 -46
  4. {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/login.py +14 -4
  5. {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/manager_api.py +6 -3
  6. {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack.egg-info/PKG-INFO +11 -1
  7. {docker_stack-2.1.1 → docker_stack-2.1.2}/setup.py +1 -1
  8. {docker_stack-2.1.1 → docker_stack-2.1.2}/tests/test_docker_stack.py +94 -3
  9. {docker_stack-2.1.1 → docker_stack-2.1.2}/tests/test_login.py +27 -0
  10. {docker_stack-2.1.1 → docker_stack-2.1.2}/tests/test_manager_api.py +7 -2
  11. {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/__init__.py +0 -0
  12. {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/command_runner.py +0 -0
  13. {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/compose.py +0 -0
  14. {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/docker_objects.py +0 -0
  15. {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/envsubst.py +0 -0
  16. {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/envsubst_merge.py +0 -0
  17. {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/helpers.py +0 -0
  18. {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/markers.py +0 -0
  19. {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/merge_conf.py +0 -0
  20. {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/registry.py +0 -0
  21. {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/shell_auth.py +0 -0
  22. {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/url_parser.py +0 -0
  23. {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack.egg-info/SOURCES.txt +0 -0
  24. {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack.egg-info/dependency_links.txt +0 -0
  25. {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack.egg-info/entry_points.txt +0 -0
  26. {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack.egg-info/requires.txt +0 -0
  27. {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack.egg-info/top_level.txt +0 -0
  28. {docker_stack-2.1.1 → docker_stack-2.1.2}/pyproject.toml +0 -0
  29. {docker_stack-2.1.1 → docker_stack-2.1.2}/setup.cfg +0 -0
  30. {docker_stack-2.1.1 → docker_stack-2.1.2}/tests/test_docker_objects.py +0 -0
  31. {docker_stack-2.1.1 → docker_stack-2.1.2}/tests/test_load_env.py +0 -0
  32. {docker_stack-2.1.1 → docker_stack-2.1.2}/tests/test_node_ls.py +0 -0
  33. {docker_stack-2.1.1 → docker_stack-2.1.2}/tests/test_shell_auth.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docker-stack
3
- Version: 2.1.1
3
+ Version: 2.1.2
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
@@ -152,6 +154,14 @@ Open an isolated Bash or Zsh session for a manager context:
152
154
  docker-stack shell office
153
155
  ```
154
156
 
157
+ If `office` does not exist yet, the CLI asks for its Docker-Manager URL, creates
158
+ the context, authenticates, and opens the shell. You can also provide everything
159
+ non-interactively with `docker-stack shell --context office <manager-url>`.
160
+
161
+ Stack commands use the `default` namespace unless `-n/--namespace` is supplied.
162
+ Listings print the selected namespace; `docker-stack ls -A` (or
163
+ `--all-namespaces`) lists every visible namespace.
164
+
155
165
  The prompt displays `(docker:office)`, keeps the selected manager context active,
156
166
  and refreshes authentication when needed. The session supports `docker` and
157
167
  `docker compose`; legacy `docker-compose` is not supported. If authentication
@@ -21,6 +21,8 @@ Typical daemon-only workflow:
21
21
  ```bash
22
22
  docker-stack deploy my-stack docker-compose.yml
23
23
  docker-stack ls
24
+ docker-stack ls -n team-a
25
+ docker-stack ls -A
24
26
  docker-stack versions my-stack
25
27
  docker-stack cat my-stack
26
28
  docker-stack checkout my-stack v2
@@ -126,6 +128,14 @@ Open an isolated Bash or Zsh session for a manager context:
126
128
  docker-stack shell office
127
129
  ```
128
130
 
131
+ If `office` does not exist yet, the CLI asks for its Docker-Manager URL, creates
132
+ the context, authenticates, and opens the shell. You can also provide everything
133
+ non-interactively with `docker-stack shell --context office <manager-url>`.
134
+
135
+ Stack commands use the `default` namespace unless `-n/--namespace` is supplied.
136
+ Listings print the selected namespace; `docker-stack ls -A` (or
137
+ `--all-namespaces`) lists every visible namespace.
138
+
129
139
  The prompt displays `(docker:office)`, keeps the selected manager context active,
130
140
  and refreshes authentication when needed. The session supports `docker` and
131
141
  `docker compose`; legacy `docker-compose` is not supported. If authentication
@@ -19,10 +19,12 @@ from docker_stack.login import (
19
19
  ensure_isolated_login,
20
20
  format_expiry,
21
21
  login as docker_manager_login,
22
+ persist_shell_context,
22
23
  resolve_login_config,
23
24
  resolve_shell_login_config,
24
25
  setup_auth as docker_manager_setup_auth,
25
26
  switch_docker_context,
27
+ UnknownShellContextError,
26
28
  )
27
29
  from docker_stack.shell_auth import (
28
30
  SHELL_CONTEXT_ENV,
@@ -48,6 +50,55 @@ DOCKER_SHELL_ENDPOINT_ENV_VARS = (
48
50
  "DOCKER_API_VERSION",
49
51
  "DOCKER_MACHINE_NAME",
50
52
  )
53
+ DEFAULT_NAMESPACE = "default"
54
+
55
+
56
+ def physical_stack_name(namespace: str, stack: str) -> str:
57
+ return stack if namespace == DEFAULT_NAMESPACE else f"{namespace}--{stack}"
58
+
59
+
60
+ def _namespace_default() -> str:
61
+ return os.getenv("DOCKER_STACK_NAMESPACE", DEFAULT_NAMESPACE).strip() or DEFAULT_NAMESPACE
62
+
63
+
64
+ def _add_namespace_argument(parser: argparse.ArgumentParser, help_text: str = "Stack namespace") -> None:
65
+ parser.add_argument(
66
+ "-n",
67
+ "--namespace",
68
+ default=_namespace_default(),
69
+ help=f"{help_text} (default: %(default)s)",
70
+ )
71
+
72
+
73
+ def _add_listing_namespace_arguments(parser: argparse.ArgumentParser) -> None:
74
+ group = parser.add_mutually_exclusive_group()
75
+ group.add_argument(
76
+ "-n",
77
+ "--namespace",
78
+ default=_namespace_default(),
79
+ help="Stack namespace (default: %(default)s)",
80
+ )
81
+ group.add_argument(
82
+ "-A",
83
+ "--all-namespaces",
84
+ "-all-namespaces",
85
+ action="store_true",
86
+ help="List stacks across all namespaces",
87
+ )
88
+
89
+
90
+ def _prompt_for_manager_target(context_name: str) -> str:
91
+ if not sys.stdin.isatty():
92
+ raise RuntimeError(
93
+ f"Unknown shell context '{context_name}'. Run "
94
+ f"'docker-stack shell --context {context_name} <manager-url>' to create it."
95
+ )
96
+ print(f"Docker context '{context_name}' does not exist yet.")
97
+ print("Enter the Docker-Manager URL to create it (for example https://manager.example.com:2376).")
98
+ target = input(f"Manager URL for {context_name}: ").strip()
99
+ if not target:
100
+ raise RuntimeError(f"Manager URL is required to create shell context '{context_name}'")
101
+ return target
51
102
 
52
103
 
53
104
  @dataclass
@@ -382,7 +433,13 @@ class DockerStack:
382
433
  env_sources.append(content.decode("utf-8", errors="ignore"))
383
434
  return x_files, env_sources
384
435
 
385
- def rendered_compose_file(self, compose_file, stack=None, include_build=True) -> RenderedCompose:
436
+ def rendered_compose_file(
437
+ self,
438
+ compose_file,
439
+ stack=None,
440
+ include_build=True,
441
+ namespace: str = DEFAULT_NAMESPACE,
442
+ ) -> RenderedCompose:
386
443
  compose_path = Path(compose_file).resolve()
387
444
  template_bytes = compose_path.read_bytes()
388
445
  template_content = template_bytes.decode("utf-8")
@@ -411,11 +468,19 @@ class DockerStack:
411
468
  if stack and not manager_deploy:
412
469
  if "configs" in compose_data:
413
470
  compose_data["configs"] = self._process_x_content(
414
- compose_data["configs"], self.docker.config, base_dir=str(base_dir), stack=stack
471
+ compose_data["configs"],
472
+ self.docker.config,
473
+ base_dir=str(base_dir),
474
+ stack=stack,
475
+ namespace=namespace,
415
476
  )
416
477
  if "secrets" in compose_data:
417
478
  compose_data["secrets"] = self._process_x_content(
418
- compose_data["secrets"], self.docker.secret, base_dir=str(base_dir), stack=stack
479
+ compose_data["secrets"],
480
+ self.docker.secret,
481
+ base_dir=str(base_dir),
482
+ stack=stack,
483
+ namespace=namespace,
419
484
  )
420
485
 
421
486
  # Define the replacements for '$' to '$$' for env variables in compose files
@@ -437,26 +502,38 @@ class DockerStack:
437
502
  def decode_yaml(self, data: str) -> dict:
438
503
  return yaml.safe_load(data)
439
504
 
440
- def render_compose_file(self, compose_file, stack=None, include_build=True):
505
+ def render_compose_file(self, compose_file, stack=None, include_build=True, namespace: str = DEFAULT_NAMESPACE):
441
506
  """
442
507
  Render the Docker Compose file with environment variables and create Docker configs/secrets.
443
508
  """
444
- return self.rendered_compose_file(compose_file, stack, include_build=include_build)
509
+ return self.rendered_compose_file(
510
+ compose_file,
511
+ stack,
512
+ include_build=include_build,
513
+ namespace=namespace,
514
+ )
445
515
 
446
- def _process_x_content(self, objects, manager: DockerObjectManager, base_dir="", stack=None):
516
+ def _process_x_content(
517
+ self,
518
+ objects,
519
+ manager: DockerObjectManager,
520
+ base_dir="",
521
+ stack=None,
522
+ namespace: str = DEFAULT_NAMESPACE,
523
+ ):
447
524
  """
448
525
  Process configs or secrets with x-content keys.
449
526
  Returns a tuple: (processed_objects, commands)
450
527
  """
451
528
  processed_objects = {}
452
529
  manager_client = self._manager_client_for_feature(FEATURE_STACK_DEPLOY)
453
- namespace = os.getenv("DOCKER_STACK_NAMESPACE", "default")
530
+ object_stack = physical_stack_name(namespace, stack) if stack else None
454
531
 
455
532
  def docker_object_name_for(name, explicit_name=None):
456
533
  if explicit_name:
457
534
  return explicit_name
458
- if stack:
459
- return f"{stack}_{name}"
535
+ if object_stack:
536
+ return f"{object_stack}_{name}"
460
537
  return name
461
538
 
462
539
  def normalize_labels(labels):
@@ -500,7 +577,7 @@ class DockerStack:
500
577
  processed_objects[name] = {"name": response["actual_name"], "external": True}
501
578
  return
502
579
 
503
- object_name, command = manager.create(docker_object_name, data, labels=labels, stack=stack)
580
+ object_name, command = manager.create(docker_object_name, data, labels=labels, stack=object_stack)
504
581
  if not command.isNop():
505
582
  self.commands.append(command)
506
583
  if is_generated_secret:
@@ -604,16 +681,55 @@ class DockerStack:
604
681
  def _version_sort_key(raw: str):
605
682
  return int(raw) if raw.isdigit() else raw
606
683
 
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)
684
+ @staticmethod
685
+ def _compact_versions(versions: List[str]) -> str:
686
+ numeric = sorted({int(version.lstrip("vV")) for version in versions if version.lstrip("vV").isdigit()})
687
+ other = sorted({version for version in versions if not version.lstrip("vV").isdigit()})
688
+ compact = []
689
+ start = end = None
690
+
691
+ def append_range(first: int, last: int) -> None:
692
+ if last - first >= 2:
693
+ compact.append(f"v{first}...v{last}")
694
+ elif last == first:
695
+ compact.append(f"v{first}")
696
+ else:
697
+ compact.extend((f"v{first}", f"v{last}"))
610
698
 
611
- print(f"{header_stack} | Versions")
612
- print("-" * (max_stack_name_length + 12))
699
+ for version in numeric:
700
+ if start is None:
701
+ start = end = version
702
+ elif version == end + 1:
703
+ end = version
704
+ else:
705
+ append_range(start, end)
706
+ start = end = version
707
+ if start is not None:
708
+ append_range(start, end)
709
+ compact.extend(other)
710
+ return ", ".join(compact)
711
+
712
+ def _print_stack_listing(self, stack_versions: Dict[Tuple[str, str], List[str]], namespace: Optional[str]) -> None:
713
+ max_stack_name_length = max((len(stack) for _, stack in stack_versions), default=10)
714
+ header_stack = "Stack Name".ljust(max_stack_name_length)
613
715
 
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}")
716
+ if namespace is not None:
717
+ print(f"Namespace: {namespace}")
718
+ print(f"{header_stack} | Versions")
719
+ print("-" * (max_stack_name_length + 12))
720
+ else:
721
+ max_namespace_length = max((len(item_namespace) for item_namespace, _ in stack_versions), default=9)
722
+ header_namespace = "Namespace".ljust(max_namespace_length)
723
+ print("Namespace: all")
724
+ print(f"{header_namespace} | {header_stack} | Versions")
725
+ print("-" * (max_namespace_length + max_stack_name_length + 15))
726
+
727
+ for (item_namespace, stack), versions in sorted(stack_versions.items()):
728
+ versions_str = self._compact_versions(versions)
729
+ if namespace is None:
730
+ print(f"{item_namespace.ljust(max_namespace_length)} | {stack.ljust(max_stack_name_length)} | {versions_str}")
731
+ else:
732
+ print(f"{stack.ljust(max_stack_name_length)} | {versions_str}")
617
733
 
618
734
  def _print_versions(self, versions_list: List[Tuple[str, str]]) -> None:
619
735
  rows = [("Version", "Tag")] + versions_list
@@ -626,13 +742,13 @@ class DockerStack:
626
742
  for version, tag in sorted(versions_list, key=lambda x: self._version_sort_key(x[0])):
627
743
  print(f"{version.ljust(max_version_length)} | {tag.ljust(max_tag_length)}")
628
744
 
629
- def ls(self):
745
+ def ls(self, namespace: Optional[str] = DEFAULT_NAMESPACE):
630
746
  client = self._manager_client_for_feature(FEATURE_STACK_QUERY)
631
747
  if client:
632
748
  try:
633
- payload = client.list_stacks()
749
+ payload = client.list_stacks(namespace=namespace)
634
750
  stack_versions = {
635
- str(item.get("stack")): [
751
+ (str(item.get("namespace") or namespace or DEFAULT_NAMESPACE), str(item.get("stack"))): [
636
752
  str(version)
637
753
  for version in (
638
754
  item.get("versions") if isinstance(item.get("versions"), list) else item.get("available_versions", [])
@@ -642,7 +758,9 @@ class DockerStack:
642
758
  for item in payload.get("stacks", [])
643
759
  if str(item.get("stack", "")).strip()
644
760
  }
645
- self._print_stack_listing(stack_versions)
761
+ self._print_stack_listing(stack_versions, namespace)
762
+ if namespace is not None:
763
+ return {stack: versions for (_, stack), versions in stack_versions.items()}
646
764
  return stack_versions
647
765
  except RuntimeError as exc:
648
766
  self._raise_if_manager_is_explicit(exc)
@@ -650,7 +768,7 @@ class DockerStack:
650
768
  cmd = ["docker", "config", "ls", "--format", "{{.ID}}\t{{.Name}}\t{{.Labels}}"]
651
769
  raw_output = run_cli_command(cmd, log=False)
652
770
  output = raw_output.split("\n") if raw_output else []
653
- stack_versions: Dict[str, List[str]] = {}
771
+ stack_versions: Dict[Tuple[str, str], List[str]] = {}
654
772
 
655
773
  for line in output:
656
774
  parts = line.split("\t")
@@ -658,11 +776,14 @@ class DockerStack:
658
776
  labels = {k: v for k, v in (label.split("=") for label in parts[2].split(",") if "=" in label)}
659
777
  stack_name = labels.get("mesudip.stack.name")
660
778
  version = labels.get("mesudip.object.version", "unknown")
779
+ object_namespace = labels.get("com.mesudip.namespace", DEFAULT_NAMESPACE)
661
780
 
662
- if stack_name:
663
- stack_versions.setdefault(stack_name, []).append(version)
781
+ if stack_name and (namespace is None or object_namespace == namespace):
782
+ stack_versions.setdefault((object_namespace, stack_name), []).append(version)
664
783
 
665
- self._print_stack_listing(stack_versions)
784
+ self._print_stack_listing(stack_versions, namespace)
785
+ if namespace is not None:
786
+ return {stack: versions for (_, stack), versions in stack_versions.items()}
666
787
  return stack_versions
667
788
 
668
789
  def cat(self, name: str, version: str, namespace: str = "default"):
@@ -681,10 +802,11 @@ class DockerStack:
681
802
  except RuntimeError as exc:
682
803
  self._raise_if_manager_is_explicit(exc)
683
804
 
805
+ history_name = physical_stack_name(namespace, name)
684
806
  if normalized_version == "1":
685
- config_name = f"{name}"
807
+ config_name = history_name
686
808
  else:
687
- config_name = f"{name}_v{normalized_version}"
809
+ config_name = f"{history_name}_v{normalized_version}"
688
810
 
689
811
  cmd = ["docker", "config", "inspect", config_name]
690
812
  output = run_cli_command(cmd, log=False)
@@ -730,8 +852,9 @@ class DockerStack:
730
852
  stack = labels.get("mesudip.stack.name")
731
853
  version = labels.get("mesudip.object.version", "unknown")
732
854
  tag = labels.get("mesudip.stack.tag", "")
855
+ object_namespace = labels.get("com.mesudip.namespace", DEFAULT_NAMESPACE)
733
856
 
734
- if stack == stack_name:
857
+ if stack == stack_name and object_namespace == namespace:
735
858
  versions_list.append((version, tag))
736
859
 
737
860
  if print_output:
@@ -894,11 +1017,17 @@ class DockerStack:
894
1017
  if force_update:
895
1018
  raise RuntimeError("--force-update is supported only by the Docker-Manager stack deploy API")
896
1019
 
897
- _, cmd = self.docker.config.increment(stack_name, stored_content, labels=labels, stack=stack_name)
1020
+ physical_stack = physical_stack_name(namespace, stack_name)
1021
+ _, cmd = self.docker.config.increment(
1022
+ physical_stack,
1023
+ stored_content,
1024
+ labels=labels,
1025
+ stack=physical_stack,
1026
+ )
898
1027
  if not cmd.isNop():
899
1028
  self.commands.append(cmd)
900
1029
 
901
- cmd = ["docker", "stack", "deploy", "-c", "-", stack_name]
1030
+ cmd = ["docker", "stack", "deploy", "-c", "-", physical_stack]
902
1031
  if with_registry_auth:
903
1032
  cmd.insert(3, "--with-registry-auth")
904
1033
  if prune:
@@ -1059,7 +1188,12 @@ class DockerStack:
1059
1188
  force_update: bool = False,
1060
1189
  ):
1061
1190
  self.generated_secrets = {} # Reset for each deployment
1062
- rendered = self.render_compose_file(compose_file, stack=stack_name, include_build=False)
1191
+ rendered = self.render_compose_file(
1192
+ compose_file,
1193
+ stack=stack_name,
1194
+ include_build=False,
1195
+ namespace=namespace,
1196
+ )
1063
1197
  self._deploy(
1064
1198
  stack_name,
1065
1199
  rendered.clean,
@@ -1098,7 +1232,12 @@ class DockerStack:
1098
1232
  )
1099
1233
  )
1100
1234
  return
1101
- self.commands.append(Command(["docker", "stack", "rm", stack_name], give_console=True))
1235
+ self.commands.append(
1236
+ Command(
1237
+ ["docker", "stack", "rm", physical_stack_name(namespace, stack_name)],
1238
+ give_console=True,
1239
+ )
1240
+ )
1102
1241
 
1103
1242
  def push(self, compose_file):
1104
1243
  compose_data = self.read_compose_file(compose_file)
@@ -1405,20 +1544,21 @@ def main(args: List[str] = None):
1405
1544
  action="store_true",
1406
1545
  help="Force service task updates (Docker-Manager deploys only)",
1407
1546
  )
1408
- deploy_parser.add_argument("--namespace", default=os.getenv("DOCKER_STACK_NAMESPACE", "default"), help="Deployment namespace")
1547
+ _add_namespace_argument(deploy_parser, "Deployment namespace")
1409
1548
  deploy_parser.add_argument("-t", "--tag", help="Tag the current deployment for later checkout", required=False)
1410
1549
  deploy_parser.add_argument("--show-generated", action="store_true", default=True, help="Show newly generated secrets after deployment")
1411
1550
 
1412
1551
  # Remove subcommand
1413
1552
  rm_parser = subparsers.add_parser("rm", help="Remove a deployed stack")
1414
1553
  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")
1554
+ _add_namespace_argument(rm_parser)
1416
1555
 
1417
1556
  # Prune command
1418
1557
  subparsers.add_parser("prune", help="Remove old versions of configs and secrets")
1419
1558
 
1420
1559
  # Ls command
1421
- subparsers.add_parser("ls", help="List docker-stacks")
1560
+ ls_parser = subparsers.add_parser("ls", help="List docker-stacks")
1561
+ _add_listing_namespace_arguments(ls_parser)
1422
1562
 
1423
1563
  node_parser = subparsers.add_parser("node", help="Inspect Docker Swarm nodes")
1424
1564
  node_subparsers = node_parser.add_subparsers(dest="node_command", required=True)
@@ -1429,12 +1569,12 @@ def main(args: List[str] = None):
1429
1569
  )
1430
1570
  cat_parser.add_argument("stack_name", help="Name of the stack")
1431
1571
  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")
1572
+ _add_namespace_argument(cat_parser)
1433
1573
 
1434
1574
  checkout_parser = subparsers.add_parser("checkout", help="Deploy specific version of the stack")
1435
1575
  checkout_parser.add_argument("stack_name", help="Name of the stack")
1436
1576
  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")
1577
+ _add_namespace_argument(checkout_parser)
1438
1578
 
1439
1579
  # version_parser = subparsers.add_parser("version",help="Deploy specific version of the stack")
1440
1580
  # version_parser.add_argument("stack_name", help="Name of the stack")
@@ -1442,7 +1582,7 @@ def main(args: List[str] = None):
1442
1582
 
1443
1583
  version_parser = subparsers.add_parser("version", aliases=["versions"], help="Deploy specific version of the stack")
1444
1584
  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")
1585
+ _add_namespace_argument(version_parser)
1446
1586
 
1447
1587
  parser.add_argument(
1448
1588
  "-u", "--user", help="Registry credentials in format hostname:username:password", action="append", required=False, default=[]
@@ -1513,12 +1653,23 @@ def main(args: List[str] = None):
1513
1653
  shell_name = args.target if args.context_name is None else None
1514
1654
  manager_target = args.target if args.context_name is not None else None
1515
1655
  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
- )
1656
+ try:
1657
+ config = resolve_shell_login_config(
1658
+ shell_name=shell_name,
1659
+ manager_target=manager_target,
1660
+ context_name=args.context_name,
1661
+ timeout_secs=args.timeout_secs,
1662
+ )
1663
+ except UnknownShellContextError as exc:
1664
+ manager_target = _prompt_for_manager_target(exc.context_name)
1665
+ print(f"Creating Docker-Manager shell context '{exc.context_name}'...")
1666
+ config = resolve_shell_login_config(
1667
+ shell_name=None,
1668
+ manager_target=manager_target,
1669
+ context_name=exc.context_name,
1670
+ timeout_secs=args.timeout_secs,
1671
+ )
1672
+ persist_shell_context(config)
1522
1673
  if shell_session_active() and os.getenv(SHELL_CONTEXT_ENV) == config.context_name:
1523
1674
  print(f"docker-stack shell context={config.context_name} is already active")
1524
1675
  return
@@ -1586,6 +1737,9 @@ def main(args: List[str] = None):
1586
1737
  docker = Docker(registries=args.user)
1587
1738
  docker.load_env()
1588
1739
 
1740
+ if hasattr(args, "namespace") and args.command != "ls":
1741
+ print(f"Namespace: {args.namespace}", file=sys.stderr)
1742
+
1589
1743
  if args.command == "build":
1590
1744
  docker.stack.build_and_push(args.compose_file, push=args.push)
1591
1745
  elif args.command == "push":
@@ -1604,7 +1758,7 @@ def main(args: List[str] = None):
1604
1758
  force_update=args.force_update,
1605
1759
  )
1606
1760
  elif args.command == "ls":
1607
- docker.stack.ls()
1761
+ docker.stack.ls(namespace=None if args.all_namespaces else (args.namespace or DEFAULT_NAMESPACE))
1608
1762
  elif args.command == "node":
1609
1763
  if args.node_command == "ls":
1610
1764
  docker.node.ls()
@@ -60,6 +60,12 @@ class DockerManagerSetupAuthResult:
60
60
  validation_skipped: bool = False
61
61
 
62
62
 
63
+ class UnknownShellContextError(RuntimeError):
64
+ def __init__(self, context_name: str):
65
+ self.context_name = context_name
66
+ super().__init__(f"Unknown shell context '{context_name}'")
67
+
68
+
63
69
  def normalize_loopback_host(url: str) -> str:
64
70
  if "127.0.0.1" in url:
65
71
  return url.replace("127.0.0.1", "localhost")
@@ -343,10 +349,7 @@ def resolve_shell_login_config(
343
349
  )
344
350
  target = persisted_target or docker_context_target(resolved_context_name)
345
351
  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
- )
352
+ raise UnknownShellContextError(resolved_context_name)
350
353
  resolved_manager_url, skip_tls_verify = detect_manager_url(target)
351
354
  return DockerManagerLoginConfig(
352
355
  manager_url=resolved_manager_url,
@@ -618,6 +621,13 @@ def configure_docker_context_in_store(config: DockerManagerLoginConfig, docker_c
618
621
  raise RuntimeError((result.stderr or result.stdout or "failed to configure docker context").strip())
619
622
 
620
623
 
624
+ def persist_shell_context(config: DockerManagerLoginConfig) -> Path:
625
+ config_dir = isolated_docker_config_dir(config.context_name)
626
+ ensure_isolated_docker_config(config_dir)
627
+ configure_docker_context_in_store(config, config_dir)
628
+ return config_dir
629
+
630
+
621
631
  def ensure_isolated_login(
622
632
  config: DockerManagerLoginConfig,
623
633
  *,
@@ -429,10 +429,13 @@ class ManagerApiClient:
429
429
  return False
430
430
  return True
431
431
 
432
- def list_stacks(self) -> Dict[str, Any]:
432
+ def list_stacks(self, *, namespace: Optional[str] = DEFAULT_NAMESPACE) -> Dict[str, Any]:
433
+ query = urllib.parse.urlencode(
434
+ {"namespace": namespace} if namespace is not None else {"all_namespaces": "true"}
435
+ )
433
436
  if self._detect_manager_backend():
434
- return self._request_json("/api/docker-stack/stacks")
435
- return self._request_json(self._endpoint_path("/inventory/stacks"))
437
+ return self._request_json(f"/api/docker-stack/stacks?{query}")
438
+ return self._request_json(f"{self._endpoint_path('/inventory/stacks')}?{query}")
436
439
 
437
440
  def list_stack_versions(self, stack_name: str, *, namespace: str = DEFAULT_NAMESPACE) -> Dict[str, Any]:
438
441
  stack = urllib.parse.quote(stack_name, safe="")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docker-stack
3
- Version: 2.1.1
3
+ Version: 2.1.2
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
@@ -152,6 +154,14 @@ Open an isolated Bash or Zsh session for a manager context:
152
154
  docker-stack shell office
153
155
  ```
154
156
 
157
+ If `office` does not exist yet, the CLI asks for its Docker-Manager URL, creates
158
+ the context, authenticates, and opens the shell. You can also provide everything
159
+ non-interactively with `docker-stack shell --context office <manager-url>`.
160
+
161
+ Stack commands use the `default` namespace unless `-n/--namespace` is supplied.
162
+ Listings print the selected namespace; `docker-stack ls -A` (or
163
+ `--all-namespaces`) lists every visible namespace.
164
+
155
165
  The prompt displays `(docker:office)`, keeps the selected manager context active,
156
166
  and refreshes authentication when needed. The session supports `docker` and
157
167
  `docker compose`; legacy `docker-compose` is not supported. If authentication
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="docker-stack",
5
- version="2.1.1",
5
+ version="2.1.2",
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",
@@ -10,6 +10,7 @@ import yaml
10
10
 
11
11
  from docker_stack.cli import DOCKER_SHELL_ENDPOINT_ENV_VARS, Docker, active_shell_config_dir, open_context_shell
12
12
  from docker_stack.helpers import Command
13
+ from docker_stack.login import UnknownShellContextError
13
14
  from docker_stack import main
14
15
 
15
16
 
@@ -45,8 +46,8 @@ class FakeManagerClient:
45
46
  def supports(self, feature_name):
46
47
  return feature_name in self.features
47
48
 
48
- def list_stacks(self):
49
- return {"stacks": [{"stack": "team-a", "versions": ["1", "2"]}]}
49
+ def list_stacks(self, *, namespace="default"):
50
+ return {"stacks": [{"namespace": namespace or "default", "stack": "team-a", "versions": ["1", "2"]}]}
50
51
 
51
52
  def list_stack_versions(self, stack_name, *, namespace="default"):
52
53
  assert stack_name == "team-a"
@@ -514,6 +515,61 @@ def test_manager_fast_path_for_list_and_node(monkeypatch, capsys):
514
515
  assert "manager (Leader)" in output
515
516
 
516
517
 
518
+ def test_stack_listing_compacts_consecutive_versions(capsys):
519
+ Docker().stack._print_stack_listing(
520
+ {("default", "api"): [str(version) for version in range(1, 61)]},
521
+ "default",
522
+ )
523
+
524
+ output = capsys.readouterr().out
525
+ assert "Namespace: default" in output
526
+ assert "v1...v60" in output
527
+ assert "v1, v2" not in output
528
+
529
+
530
+ def test_ls_supports_namespace_short_flag_and_all_namespaces(monkeypatch, capsys):
531
+ class NamespaceManager(FakeManagerClient):
532
+ def list_stacks(self, *, namespace="default"):
533
+ if namespace is None:
534
+ return {
535
+ "stacks": [
536
+ {"namespace": "default", "stack": "api", "versions": ["1", "2", "3"]},
537
+ {"namespace": "prod", "stack": "api", "versions": ["1"]},
538
+ ]
539
+ }
540
+ return {"stacks": [{"namespace": namespace, "stack": "api", "versions": ["1"]}]}
541
+
542
+ monkeypatch.setattr("docker_stack.cli.discover_manager_client", lambda *_args, **_kwargs: NamespaceManager())
543
+
544
+ main(["ls", "-n", "prod"])
545
+ namespace_output = capsys.readouterr().out
546
+ assert "Namespace: prod" in namespace_output
547
+
548
+ main(["ls", "-A"])
549
+ all_output = capsys.readouterr().out
550
+ assert "Namespace: all" in all_output
551
+ assert "default" in all_output
552
+ assert "prod" in all_output
553
+ assert "v1...v3" in all_output
554
+
555
+
556
+ def test_raw_daemon_namespace_uses_physical_stack_name(monkeypatch):
557
+ monkeypatch.setattr("docker_stack.cli.discover_manager_client", lambda *_args, **_kwargs: None)
558
+ docker = Docker()
559
+ captured = {}
560
+
561
+ def fake_increment(object_name, content, labels=None, stack=None):
562
+ captured.update(object_name=object_name, content=content, labels=labels, stack=stack)
563
+ return object_name, Command.nop
564
+
565
+ monkeypatch.setattr(docker.config, "increment", fake_increment)
566
+ docker.stack._deploy("api", "services: {}", namespace="prod")
567
+
568
+ assert captured["object_name"] == "prod--api"
569
+ assert captured["stack"] == "prod--api"
570
+ assert docker.stack.commands[-1].command[-1] == "prod--api"
571
+
572
+
517
573
  def test_cat_without_explicit_version_does_not_print_versions_table(monkeypatch, capsys):
518
574
  fake_manager = FakeManagerClient()
519
575
  monkeypatch.setattr("docker_stack.cli.discover_manager_client", lambda *_args, **_kwargs: fake_manager)
@@ -687,7 +743,7 @@ def test_manager_deploy_prints_generated_secret_values(capsys):
687
743
 
688
744
  def test_explicit_manager_query_error_does_not_fall_back_to_daemon(monkeypatch):
689
745
  class FailingManager(FakeManagerClient):
690
- def list_stacks(self):
746
+ def list_stacks(self, *, namespace="default"):
691
747
  raise RuntimeError("Manager request failed (GET /api/docker-stack/stacks): HTTP 503")
692
748
 
693
749
  monkeypatch.setenv("DOCKER_MANAGER_URL", "https://manager.example.test:2378")
@@ -1076,6 +1132,41 @@ def test_shell_authenticates_and_opens_managed_shell(monkeypatch, capsys):
1076
1132
  assert "Shell manager=https://172.31.0.6:2378" in output
1077
1133
 
1078
1134
 
1135
+ def test_shell_unknown_context_prompts_for_manager_url_and_creates_it(monkeypatch, capsys):
1136
+ calls = []
1137
+
1138
+ def fake_resolve_shell_login_config(**kwargs):
1139
+ calls.append(kwargs)
1140
+ if kwargs["manager_target"] is None:
1141
+ raise UnknownShellContextError("prod1")
1142
+ return SimpleNamespace(
1143
+ manager_url="https://manager.example.com:2376",
1144
+ context_name="prod1",
1145
+ docker_context_host="tcp://manager.example.com:2376",
1146
+ skip_tls_verify=False,
1147
+ timeout_secs=30,
1148
+ )
1149
+
1150
+ monkeypatch.setattr("docker_stack.cli.resolve_shell_login_config", fake_resolve_shell_login_config)
1151
+ monkeypatch.setattr("docker_stack.cli.sys.stdin.isatty", lambda: True)
1152
+ monkeypatch.setattr("builtins.input", lambda _prompt: "https://manager.example.com:2376")
1153
+ persisted = []
1154
+ monkeypatch.setattr("docker_stack.cli.persist_shell_context", lambda config: persisted.append(config.context_name))
1155
+ monkeypatch.setattr("docker_stack.cli.browser_login", lambda _config: None)
1156
+ monkeypatch.setattr("docker_stack.cli.run_managed_shell", lambda _config, _result: 0)
1157
+
1158
+ with pytest.raises(SystemExit) as excinfo:
1159
+ main(["shell", "prod1"])
1160
+
1161
+ assert excinfo.value.code == 0
1162
+ assert calls[1]["context_name"] == "prod1"
1163
+ assert calls[1]["manager_target"] == "https://manager.example.com:2376"
1164
+ assert persisted == ["prod1"]
1165
+ output = capsys.readouterr().out
1166
+ assert "Docker context 'prod1' does not exist yet" in output
1167
+ assert "Creating Docker-Manager shell context 'prod1'" in output
1168
+
1169
+
1079
1170
  def test_shell_inside_same_context_reports_already_active(monkeypatch, capsys):
1080
1171
  def fake_resolve_shell_login_config(**_kwargs):
1081
1172
  return SimpleNamespace(
@@ -20,14 +20,34 @@ from docker_stack.login import (
20
20
  isolated_docker_config_dir,
21
21
  is_manager_context,
22
22
  merge_docker_config_header,
23
+ persist_shell_context,
23
24
  resolve_login_config,
24
25
  resolve_shell_login_config,
25
26
  setup_auth,
26
27
  switch_docker_context,
27
28
  token_issuer_from_jwt,
29
+ UnknownShellContextError,
28
30
  )
29
31
 
30
32
 
33
+ def test_persist_shell_context_creates_reusable_context_store(monkeypatch, tmp_path):
34
+ config = DockerManagerLoginConfig(
35
+ manager_url="https://manager.example.com:2376",
36
+ context_name="prod1",
37
+ timeout_secs=30,
38
+ )
39
+ configured = []
40
+ monkeypatch.setattr("docker_stack.login.isolated_docker_config_dir", lambda _name: tmp_path)
41
+ monkeypatch.setattr("docker_stack.login.ensure_isolated_docker_config", lambda path: path / "config.json")
42
+ monkeypatch.setattr(
43
+ "docker_stack.login.configure_docker_context_in_store",
44
+ lambda _config, path: configured.append(path),
45
+ )
46
+
47
+ assert persist_shell_context(config) == tmp_path
48
+ assert configured == [tmp_path]
49
+
50
+
31
51
  def test_merge_docker_config_header_with_empty_config(tmp_path):
32
52
  config_path = tmp_path / "config.json"
33
53
 
@@ -408,6 +428,13 @@ def test_resolve_shell_login_config_requires_context_name_for_first_run():
408
428
  resolve_shell_login_config(shell_name=None, manager_target="172.31.0.6:2378", context_name=None)
409
429
 
410
430
 
431
+ def test_resolve_shell_login_config_identifies_unknown_named_context(monkeypatch):
432
+ monkeypatch.setattr("docker_stack.login.docker_context_target", lambda *_args, **_kwargs: None)
433
+
434
+ with pytest.raises(UnknownShellContextError, match="Unknown shell context 'prod1'"):
435
+ resolve_shell_login_config(shell_name="prod1", manager_target=None, context_name=None)
436
+
437
+
411
438
  def test_ensure_isolated_login_skips_browser_when_token_is_active(monkeypatch, tmp_path):
412
439
  config = DockerManagerLoginConfig(
413
440
  manager_url="https://172.31.0.6:2378",
@@ -387,7 +387,10 @@ def test_enterprise_query_uses_direct_manager_fast_paths(monkeypatch):
387
387
  calls.append((method, path, payload))
388
388
  if path == "/version":
389
389
  return {"MesudipFeatures": [FEATURE_MESUDIP_DOCKER_ENTERPRISE]}
390
- if path == "/api/docker-stack/stacks":
390
+ if path in {
391
+ "/api/docker-stack/stacks?namespace=default",
392
+ "/api/docker-stack/stacks?all_namespaces=true",
393
+ }:
391
394
  return {"stacks": []}
392
395
  if path == "/api/docker-stack/stacks/team-a/versions?namespace=prod":
393
396
  return {"versions": []}
@@ -402,13 +405,15 @@ def test_enterprise_query_uses_direct_manager_fast_paths(monkeypatch):
402
405
  monkeypatch.setattr(client, "_request_json", fake_request)
403
406
 
404
407
  assert client.list_stacks() == {"stacks": []}
408
+ assert client.list_stacks(namespace=None) == {"stacks": []}
405
409
  assert client.list_stack_versions("team-a", namespace="prod") == {"versions": []}
406
410
  assert client.get_stack_compose("team-a", namespace="prod", tag="latest") == {"compose": "services: {}"}
407
411
  assert client.list_nodes() == {"nodes": []}
408
412
  assert client.rollback_stack(stack="team-a", namespace="prod", version="2") == {"warnings": []}
409
413
  assert calls == [
410
414
  ("GET", "/version", None),
411
- ("GET", "/api/docker-stack/stacks", None),
415
+ ("GET", "/api/docker-stack/stacks?namespace=default", None),
416
+ ("GET", "/api/docker-stack/stacks?all_namespaces=true", None),
412
417
  ("GET", "/api/docker-stack/stacks/team-a/versions?namespace=prod", None),
413
418
  ("GET", "/api/docker-stack/stacks/team-a/compose?namespace=prod&tag=latest", None),
414
419
  ("GET", "/api/docker-stack/nodes", None),
File without changes