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.
- {docker_stack-2.1.1 → docker_stack-2.1.2}/PKG-INFO +11 -1
- {docker_stack-2.1.1 → docker_stack-2.1.2}/README.md +10 -0
- {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/cli.py +200 -46
- {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/login.py +14 -4
- {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/manager_api.py +6 -3
- {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack.egg-info/PKG-INFO +11 -1
- {docker_stack-2.1.1 → docker_stack-2.1.2}/setup.py +1 -1
- {docker_stack-2.1.1 → docker_stack-2.1.2}/tests/test_docker_stack.py +94 -3
- {docker_stack-2.1.1 → docker_stack-2.1.2}/tests/test_login.py +27 -0
- {docker_stack-2.1.1 → docker_stack-2.1.2}/tests/test_manager_api.py +7 -2
- {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/__init__.py +0 -0
- {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/command_runner.py +0 -0
- {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/compose.py +0 -0
- {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/docker_objects.py +0 -0
- {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/envsubst.py +0 -0
- {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/envsubst_merge.py +0 -0
- {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/helpers.py +0 -0
- {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/markers.py +0 -0
- {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/merge_conf.py +0 -0
- {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/registry.py +0 -0
- {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/shell_auth.py +0 -0
- {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack/url_parser.py +0 -0
- {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack.egg-info/SOURCES.txt +0 -0
- {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack.egg-info/dependency_links.txt +0 -0
- {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack.egg-info/entry_points.txt +0 -0
- {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack.egg-info/requires.txt +0 -0
- {docker_stack-2.1.1 → docker_stack-2.1.2}/docker_stack.egg-info/top_level.txt +0 -0
- {docker_stack-2.1.1 → docker_stack-2.1.2}/pyproject.toml +0 -0
- {docker_stack-2.1.1 → docker_stack-2.1.2}/setup.cfg +0 -0
- {docker_stack-2.1.1 → docker_stack-2.1.2}/tests/test_docker_objects.py +0 -0
- {docker_stack-2.1.1 → docker_stack-2.1.2}/tests/test_load_env.py +0 -0
- {docker_stack-2.1.1 → docker_stack-2.1.2}/tests/test_node_ls.py +0 -0
- {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.
|
|
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(
|
|
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"],
|
|
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"],
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
459
|
-
return f"{
|
|
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=
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
|
|
612
|
-
|
|
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
|
-
|
|
615
|
-
|
|
616
|
-
print(f"{
|
|
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 =
|
|
807
|
+
config_name = history_name
|
|
686
808
|
else:
|
|
687
|
-
config_name = f"{
|
|
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
|
-
|
|
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", "-",
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
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
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|