docker-stack 2.1.0__py3-none-any.whl → 2.1.2__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 +566 -134
- docker_stack/helpers.py +2 -6
- docker_stack/login.py +31 -5
- docker_stack/manager_api.py +198 -10
- docker_stack/shell_auth.py +742 -0
- {docker_stack-2.1.0.dist-info → docker_stack-2.1.2.dist-info}/METADATA +78 -8
- {docker_stack-2.1.0.dist-info → docker_stack-2.1.2.dist-info}/RECORD +10 -9
- {docker_stack-2.1.0.dist-info → docker_stack-2.1.2.dist-info}/WHEEL +0 -0
- {docker_stack-2.1.0.dist-info → docker_stack-2.1.2.dist-info}/entry_points.txt +0 -0
- {docker_stack-2.1.0.dist-info → docker_stack-2.1.2.dist-info}/top_level.txt +0 -0
docker_stack/cli.py
CHANGED
|
@@ -15,13 +15,24 @@ import json
|
|
|
15
15
|
from docker_stack.docker_objects import DockerConfig, DockerObjectManager, DockerSecret
|
|
16
16
|
from docker_stack.helpers import CallbackCommand, Command, generate_secret, run_cli_command
|
|
17
17
|
from docker_stack.login import (
|
|
18
|
+
browser_login,
|
|
18
19
|
ensure_isolated_login,
|
|
19
20
|
format_expiry,
|
|
20
21
|
login as docker_manager_login,
|
|
22
|
+
persist_shell_context,
|
|
21
23
|
resolve_login_config,
|
|
22
24
|
resolve_shell_login_config,
|
|
23
25
|
setup_auth as docker_manager_setup_auth,
|
|
24
26
|
switch_docker_context,
|
|
27
|
+
UnknownShellContextError,
|
|
28
|
+
)
|
|
29
|
+
from docker_stack.shell_auth import (
|
|
30
|
+
SHELL_CONTEXT_ENV,
|
|
31
|
+
ShellAuthError,
|
|
32
|
+
ensure_shell_access,
|
|
33
|
+
login_for_active_shell,
|
|
34
|
+
run_managed_shell,
|
|
35
|
+
shell_session_active,
|
|
25
36
|
)
|
|
26
37
|
from docker_stack.manager_api import (
|
|
27
38
|
FEATURE_STACK_DEPLOY,
|
|
@@ -39,6 +50,55 @@ DOCKER_SHELL_ENDPOINT_ENV_VARS = (
|
|
|
39
50
|
"DOCKER_API_VERSION",
|
|
40
51
|
"DOCKER_MACHINE_NAME",
|
|
41
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
|
|
42
102
|
|
|
43
103
|
|
|
44
104
|
@dataclass
|
|
@@ -323,13 +383,36 @@ class DockerStack:
|
|
|
323
383
|
content = ("\n".join(lines) + "\n").encode("utf-8")
|
|
324
384
|
return {"path": ".env", "content": self._b64_content(content)}
|
|
325
385
|
|
|
386
|
+
def _prepare_manager_secret_sources(self, compose_data: dict, base_dir: Path) -> None:
|
|
387
|
+
secrets = compose_data.get("secrets", {})
|
|
388
|
+
if not isinstance(secrets, dict):
|
|
389
|
+
return
|
|
390
|
+
for name, details in secrets.items():
|
|
391
|
+
if not isinstance(details, dict):
|
|
392
|
+
continue
|
|
393
|
+
if "x-template-file" in details:
|
|
394
|
+
filename = base_dir / details["x-template-file"]
|
|
395
|
+
details["x-content"] = envsubst_load_file(str(filename), os.environ)
|
|
396
|
+
details.pop("x-template-file", None)
|
|
397
|
+
elif "file" in details:
|
|
398
|
+
filename = base_dir / details["file"]
|
|
399
|
+
details["x-content"] = filename.read_text(encoding="utf-8")
|
|
400
|
+
details.pop("file", None)
|
|
401
|
+
elif "environment" in details:
|
|
402
|
+
env_name = str(details["environment"]).strip()
|
|
403
|
+
env_value = os.environ.get(env_name)
|
|
404
|
+
if env_value in (None, ""):
|
|
405
|
+
raise ValueError(f"Secret {name} references environment variable {env_name}, but it is not set.")
|
|
406
|
+
details["x-content"] = env_value
|
|
407
|
+
details.pop("environment", None)
|
|
408
|
+
|
|
326
409
|
def _collect_config_source_files(self, compose_data: dict, base_dir: Path) -> Tuple[List[Dict[str, str]], List[str]]:
|
|
327
410
|
x_files = []
|
|
328
411
|
env_sources = []
|
|
412
|
+
seen_paths = set()
|
|
329
413
|
configs = compose_data.get("configs", {})
|
|
330
414
|
if not isinstance(configs, dict):
|
|
331
415
|
return x_files, env_sources
|
|
332
|
-
|
|
333
416
|
for details in configs.values():
|
|
334
417
|
if not isinstance(details, dict):
|
|
335
418
|
continue
|
|
@@ -337,17 +420,26 @@ class DockerStack:
|
|
|
337
420
|
if not source_path:
|
|
338
421
|
continue
|
|
339
422
|
filename = (base_dir / source_path).resolve()
|
|
423
|
+
relative_path = self._relative_source_name(filename, base_dir)
|
|
340
424
|
content = filename.read_bytes()
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
425
|
+
if relative_path not in seen_paths:
|
|
426
|
+
x_files.append(
|
|
427
|
+
{
|
|
428
|
+
"path": relative_path,
|
|
429
|
+
"content": self._b64_content(content),
|
|
430
|
+
}
|
|
431
|
+
)
|
|
432
|
+
seen_paths.add(relative_path)
|
|
347
433
|
env_sources.append(content.decode("utf-8", errors="ignore"))
|
|
348
434
|
return x_files, env_sources
|
|
349
435
|
|
|
350
|
-
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:
|
|
351
443
|
compose_path = Path(compose_file).resolve()
|
|
352
444
|
template_bytes = compose_path.read_bytes()
|
|
353
445
|
template_content = template_bytes.decode("utf-8")
|
|
@@ -357,6 +449,9 @@ class DockerStack:
|
|
|
357
449
|
source_x_files: List[Dict[str, str]] = []
|
|
358
450
|
env_sources = [template_content]
|
|
359
451
|
secret_env_vars = self._secret_environment_vars(compose_data)
|
|
452
|
+
manager_deploy = self._manager_client_for_feature(FEATURE_STACK_DEPLOY) if stack else None
|
|
453
|
+
if manager_deploy:
|
|
454
|
+
self._prepare_manager_secret_sources(compose_data, base_dir)
|
|
360
455
|
if stack:
|
|
361
456
|
source_x_files, config_env_sources = self._collect_config_source_files(compose_data, base_dir)
|
|
362
457
|
env_sources.extend(config_env_sources)
|
|
@@ -365,14 +460,27 @@ class DockerStack:
|
|
|
365
460
|
for k, v in services.items():
|
|
366
461
|
if "build" in v:
|
|
367
462
|
del v["build"]
|
|
368
|
-
|
|
463
|
+
# A manager-backed deploy must send portable source definitions to the
|
|
464
|
+
# stack API. The manager owns validation, config/secret versioning and
|
|
465
|
+
# materialization; resolving those objects here can mutate a dry-run and
|
|
466
|
+
# can pin a service to an old physical object name. Raw daemon deploys
|
|
467
|
+
# keep the legacy client-side materialization path.
|
|
468
|
+
if stack and not manager_deploy:
|
|
369
469
|
if "configs" in compose_data:
|
|
370
470
|
compose_data["configs"] = self._process_x_content(
|
|
371
|
-
compose_data["configs"],
|
|
471
|
+
compose_data["configs"],
|
|
472
|
+
self.docker.config,
|
|
473
|
+
base_dir=str(base_dir),
|
|
474
|
+
stack=stack,
|
|
475
|
+
namespace=namespace,
|
|
372
476
|
)
|
|
373
477
|
if "secrets" in compose_data:
|
|
374
478
|
compose_data["secrets"] = self._process_x_content(
|
|
375
|
-
compose_data["secrets"],
|
|
479
|
+
compose_data["secrets"],
|
|
480
|
+
self.docker.secret,
|
|
481
|
+
base_dir=str(base_dir),
|
|
482
|
+
stack=stack,
|
|
483
|
+
namespace=namespace,
|
|
376
484
|
)
|
|
377
485
|
|
|
378
486
|
# Define the replacements for '$' to '$$' for env variables in compose files
|
|
@@ -394,26 +502,38 @@ class DockerStack:
|
|
|
394
502
|
def decode_yaml(self, data: str) -> dict:
|
|
395
503
|
return yaml.safe_load(data)
|
|
396
504
|
|
|
397
|
-
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):
|
|
398
506
|
"""
|
|
399
507
|
Render the Docker Compose file with environment variables and create Docker configs/secrets.
|
|
400
508
|
"""
|
|
401
|
-
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
|
+
)
|
|
402
515
|
|
|
403
|
-
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
|
+
):
|
|
404
524
|
"""
|
|
405
525
|
Process configs or secrets with x-content keys.
|
|
406
526
|
Returns a tuple: (processed_objects, commands)
|
|
407
527
|
"""
|
|
408
528
|
processed_objects = {}
|
|
409
529
|
manager_client = self._manager_client_for_feature(FEATURE_STACK_DEPLOY)
|
|
410
|
-
|
|
530
|
+
object_stack = physical_stack_name(namespace, stack) if stack else None
|
|
411
531
|
|
|
412
532
|
def docker_object_name_for(name, explicit_name=None):
|
|
413
533
|
if explicit_name:
|
|
414
534
|
return explicit_name
|
|
415
|
-
if
|
|
416
|
-
return f"{
|
|
535
|
+
if object_stack:
|
|
536
|
+
return f"{object_stack}_{name}"
|
|
417
537
|
return name
|
|
418
538
|
|
|
419
539
|
def normalize_labels(labels):
|
|
@@ -457,7 +577,7 @@ class DockerStack:
|
|
|
457
577
|
processed_objects[name] = {"name": response["actual_name"], "external": True}
|
|
458
578
|
return
|
|
459
579
|
|
|
460
|
-
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)
|
|
461
581
|
if not command.isNop():
|
|
462
582
|
self.commands.append(command)
|
|
463
583
|
if is_generated_secret:
|
|
@@ -528,14 +648,29 @@ class DockerStack:
|
|
|
528
648
|
def _manager_client_for_feature(self, feature_name: str) -> Optional[ManagerApiClient]:
|
|
529
649
|
client = self.docker.manager_client()
|
|
530
650
|
if not client:
|
|
651
|
+
if os.getenv("DOCKER_MANAGER_URL", "").strip():
|
|
652
|
+
raise RuntimeError(
|
|
653
|
+
"Docker-Manager is configured via DOCKER_MANAGER_URL, but docker-stack could not initialize a manager client"
|
|
654
|
+
)
|
|
531
655
|
return None
|
|
532
656
|
try:
|
|
533
657
|
if client.supports(feature_name):
|
|
534
658
|
return client
|
|
535
|
-
except RuntimeError:
|
|
659
|
+
except RuntimeError as exc:
|
|
660
|
+
if os.getenv("DOCKER_MANAGER_URL", "").strip():
|
|
661
|
+
raise RuntimeError(f"Docker-Manager feature detection failed for {feature_name}: {exc}") from exc
|
|
536
662
|
return None
|
|
663
|
+
if os.getenv("DOCKER_MANAGER_URL", "").strip():
|
|
664
|
+
raise RuntimeError(
|
|
665
|
+
f"Docker-Manager is configured via DOCKER_MANAGER_URL, but /version did not advertise required feature {feature_name}"
|
|
666
|
+
)
|
|
537
667
|
return None
|
|
538
668
|
|
|
669
|
+
@staticmethod
|
|
670
|
+
def _raise_if_manager_is_explicit(exc: RuntimeError) -> None:
|
|
671
|
+
if os.getenv("DOCKER_MANAGER_URL", "").strip():
|
|
672
|
+
raise exc
|
|
673
|
+
|
|
539
674
|
@staticmethod
|
|
540
675
|
def _normalize_version(value: str) -> str:
|
|
541
676
|
if value.startswith("v") or value.startswith("V"):
|
|
@@ -546,16 +681,55 @@ class DockerStack:
|
|
|
546
681
|
def _version_sort_key(raw: str):
|
|
547
682
|
return int(raw) if raw.isdigit() else raw
|
|
548
683
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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}"))
|
|
552
698
|
|
|
553
|
-
|
|
554
|
-
|
|
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)
|
|
555
715
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
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}")
|
|
559
733
|
|
|
560
734
|
def _print_versions(self, versions_list: List[Tuple[str, str]]) -> None:
|
|
561
735
|
rows = [("Version", "Tag")] + versions_list
|
|
@@ -568,13 +742,13 @@ class DockerStack:
|
|
|
568
742
|
for version, tag in sorted(versions_list, key=lambda x: self._version_sort_key(x[0])):
|
|
569
743
|
print(f"{version.ljust(max_version_length)} | {tag.ljust(max_tag_length)}")
|
|
570
744
|
|
|
571
|
-
def ls(self):
|
|
745
|
+
def ls(self, namespace: Optional[str] = DEFAULT_NAMESPACE):
|
|
572
746
|
client = self._manager_client_for_feature(FEATURE_STACK_QUERY)
|
|
573
747
|
if client:
|
|
574
748
|
try:
|
|
575
|
-
payload = client.list_stacks()
|
|
749
|
+
payload = client.list_stacks(namespace=namespace)
|
|
576
750
|
stack_versions = {
|
|
577
|
-
str(item.get("stack")): [
|
|
751
|
+
(str(item.get("namespace") or namespace or DEFAULT_NAMESPACE), str(item.get("stack"))): [
|
|
578
752
|
str(version)
|
|
579
753
|
for version in (
|
|
580
754
|
item.get("versions") if isinstance(item.get("versions"), list) else item.get("available_versions", [])
|
|
@@ -584,15 +758,17 @@ class DockerStack:
|
|
|
584
758
|
for item in payload.get("stacks", [])
|
|
585
759
|
if str(item.get("stack", "")).strip()
|
|
586
760
|
}
|
|
587
|
-
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()}
|
|
588
764
|
return stack_versions
|
|
589
|
-
except RuntimeError:
|
|
590
|
-
|
|
765
|
+
except RuntimeError as exc:
|
|
766
|
+
self._raise_if_manager_is_explicit(exc)
|
|
591
767
|
|
|
592
768
|
cmd = ["docker", "config", "ls", "--format", "{{.ID}}\t{{.Name}}\t{{.Labels}}"]
|
|
593
769
|
raw_output = run_cli_command(cmd, log=False)
|
|
594
770
|
output = raw_output.split("\n") if raw_output else []
|
|
595
|
-
stack_versions: Dict[str, List[str]] = {}
|
|
771
|
+
stack_versions: Dict[Tuple[str, str], List[str]] = {}
|
|
596
772
|
|
|
597
773
|
for line in output:
|
|
598
774
|
parts = line.split("\t")
|
|
@@ -600,11 +776,14 @@ class DockerStack:
|
|
|
600
776
|
labels = {k: v for k, v in (label.split("=") for label in parts[2].split(",") if "=" in label)}
|
|
601
777
|
stack_name = labels.get("mesudip.stack.name")
|
|
602
778
|
version = labels.get("mesudip.object.version", "unknown")
|
|
779
|
+
object_namespace = labels.get("com.mesudip.namespace", DEFAULT_NAMESPACE)
|
|
603
780
|
|
|
604
|
-
if stack_name:
|
|
605
|
-
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)
|
|
606
783
|
|
|
607
|
-
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()}
|
|
608
787
|
return stack_versions
|
|
609
788
|
|
|
610
789
|
def cat(self, name: str, version: str, namespace: str = "default"):
|
|
@@ -620,13 +799,14 @@ class DockerStack:
|
|
|
620
799
|
compose = payload.get("compose")
|
|
621
800
|
if isinstance(compose, str):
|
|
622
801
|
return compose
|
|
623
|
-
except RuntimeError:
|
|
624
|
-
|
|
802
|
+
except RuntimeError as exc:
|
|
803
|
+
self._raise_if_manager_is_explicit(exc)
|
|
625
804
|
|
|
805
|
+
history_name = physical_stack_name(namespace, name)
|
|
626
806
|
if normalized_version == "1":
|
|
627
|
-
config_name =
|
|
807
|
+
config_name = history_name
|
|
628
808
|
else:
|
|
629
|
-
config_name = f"{
|
|
809
|
+
config_name = f"{history_name}_v{normalized_version}"
|
|
630
810
|
|
|
631
811
|
cmd = ["docker", "config", "inspect", config_name]
|
|
632
812
|
output = run_cli_command(cmd, log=False)
|
|
@@ -657,8 +837,8 @@ class DockerStack:
|
|
|
657
837
|
if print_output:
|
|
658
838
|
self._print_versions(versions_list)
|
|
659
839
|
return versions_list
|
|
660
|
-
except RuntimeError:
|
|
661
|
-
|
|
840
|
+
except RuntimeError as exc:
|
|
841
|
+
self._raise_if_manager_is_explicit(exc)
|
|
662
842
|
|
|
663
843
|
cmd = ["docker", "config", "ls", "--format", "{{.Name}}\t{{.Labels}}"]
|
|
664
844
|
raw_output = run_cli_command(cmd, log=False)
|
|
@@ -672,8 +852,9 @@ class DockerStack:
|
|
|
672
852
|
stack = labels.get("mesudip.stack.name")
|
|
673
853
|
version = labels.get("mesudip.object.version", "unknown")
|
|
674
854
|
tag = labels.get("mesudip.stack.tag", "")
|
|
855
|
+
object_namespace = labels.get("com.mesudip.namespace", DEFAULT_NAMESPACE)
|
|
675
856
|
|
|
676
|
-
if stack == stack_name:
|
|
857
|
+
if stack == stack_name and object_namespace == namespace:
|
|
677
858
|
versions_list.append((version, tag))
|
|
678
859
|
|
|
679
860
|
if print_output:
|
|
@@ -706,7 +887,8 @@ class DockerStack:
|
|
|
706
887
|
if not rollback_version and tag:
|
|
707
888
|
try:
|
|
708
889
|
versions_payload = manager_query.list_stack_versions(stack_name, namespace=namespace) if manager_query else {}
|
|
709
|
-
except RuntimeError:
|
|
890
|
+
except RuntimeError as exc:
|
|
891
|
+
self._raise_if_manager_is_explicit(exc)
|
|
710
892
|
versions_payload = {}
|
|
711
893
|
for item in versions_payload.get("versions", []):
|
|
712
894
|
if str(item.get("tag", "")) == tag:
|
|
@@ -743,7 +925,8 @@ class DockerStack:
|
|
|
743
925
|
resolved = payload.get("version")
|
|
744
926
|
if isinstance(resolved, str) and resolved.strip():
|
|
745
927
|
version = resolved
|
|
746
|
-
except RuntimeError:
|
|
928
|
+
except RuntimeError as exc:
|
|
929
|
+
self._raise_if_manager_is_explicit(exc)
|
|
747
930
|
compose_content = None
|
|
748
931
|
|
|
749
932
|
if compose_content is None:
|
|
@@ -776,6 +959,9 @@ class DockerStack:
|
|
|
776
959
|
tag=None,
|
|
777
960
|
namespace: str = "default",
|
|
778
961
|
dry_run: bool = False,
|
|
962
|
+
prune: bool = False,
|
|
963
|
+
resolve_image: Optional[str] = None,
|
|
964
|
+
force_update: bool = False,
|
|
779
965
|
):
|
|
780
966
|
stored_content = stored_content if stored_content is not None else rendered_content
|
|
781
967
|
labels = [
|
|
@@ -787,7 +973,19 @@ class DockerStack:
|
|
|
787
973
|
labels.append(f"mesudip.stack.tag={tag}")
|
|
788
974
|
|
|
789
975
|
manager_deploy = self._manager_client_for_feature(FEATURE_STACK_DEPLOY)
|
|
790
|
-
|
|
976
|
+
if manager_deploy and tag:
|
|
977
|
+
raise RuntimeError(
|
|
978
|
+
"Docker-Manager stack deploy does not expose deployment tags; refusing to silently ignore --tag"
|
|
979
|
+
)
|
|
980
|
+
manager_options = {}
|
|
981
|
+
if with_registry_auth:
|
|
982
|
+
manager_options["with_registry_auth"] = True
|
|
983
|
+
if prune:
|
|
984
|
+
manager_options["prune"] = True
|
|
985
|
+
if resolve_image:
|
|
986
|
+
manager_options["resolve_image"] = resolve_image
|
|
987
|
+
if force_update:
|
|
988
|
+
manager_options["force_update"] = True
|
|
791
989
|
if manager_deploy and dry_run:
|
|
792
990
|
self._validate_via_manager(
|
|
793
991
|
manager_deploy,
|
|
@@ -816,13 +1014,26 @@ class DockerStack:
|
|
|
816
1014
|
)
|
|
817
1015
|
return
|
|
818
1016
|
|
|
819
|
-
|
|
1017
|
+
if force_update:
|
|
1018
|
+
raise RuntimeError("--force-update is supported only by the Docker-Manager stack deploy API")
|
|
1019
|
+
|
|
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
|
+
)
|
|
820
1027
|
if not cmd.isNop():
|
|
821
1028
|
self.commands.append(cmd)
|
|
822
1029
|
|
|
823
|
-
cmd = ["docker", "stack", "deploy", "-c", "-",
|
|
1030
|
+
cmd = ["docker", "stack", "deploy", "-c", "-", physical_stack]
|
|
824
1031
|
if with_registry_auth:
|
|
825
1032
|
cmd.insert(3, "--with-registry-auth")
|
|
1033
|
+
if prune:
|
|
1034
|
+
cmd.insert(3, "--prune")
|
|
1035
|
+
if resolve_image:
|
|
1036
|
+
cmd[3:3] = ["--resolve-image", resolve_image]
|
|
826
1037
|
self.commands.append(Command(cmd, stdin=rendered_content, give_console=True))
|
|
827
1038
|
|
|
828
1039
|
@staticmethod
|
|
@@ -859,12 +1070,32 @@ class DockerStack:
|
|
|
859
1070
|
rendered_content: str,
|
|
860
1071
|
options: Dict[str, object],
|
|
861
1072
|
) -> Optional[str]:
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
1073
|
+
if not hasattr(manager_client, "deploy_stack_stream"):
|
|
1074
|
+
payload = manager_client.deploy_stack(
|
|
1075
|
+
stack=stack_name,
|
|
1076
|
+
namespace=namespace,
|
|
1077
|
+
compose=rendered_content,
|
|
1078
|
+
options=options,
|
|
1079
|
+
)
|
|
1080
|
+
else:
|
|
1081
|
+
try:
|
|
1082
|
+
payload = manager_client.deploy_stack_stream(
|
|
1083
|
+
stack=stack_name,
|
|
1084
|
+
namespace=namespace,
|
|
1085
|
+
compose=rendered_content,
|
|
1086
|
+
options=options,
|
|
1087
|
+
on_event=DockerStack._print_manager_deploy_event,
|
|
1088
|
+
)
|
|
1089
|
+
except RuntimeError as exc:
|
|
1090
|
+
message = str(exc)
|
|
1091
|
+
if "POST /api/stacks/deploy/stream" not in message or "HTTP 404" not in message:
|
|
1092
|
+
raise
|
|
1093
|
+
payload = manager_client.deploy_stack(
|
|
1094
|
+
stack=stack_name,
|
|
1095
|
+
namespace=namespace,
|
|
1096
|
+
compose=rendered_content,
|
|
1097
|
+
options=options,
|
|
1098
|
+
)
|
|
868
1099
|
warnings = payload.get("warnings") or []
|
|
869
1100
|
for warning in warnings:
|
|
870
1101
|
print(f"[manager] {warning}")
|
|
@@ -874,8 +1105,51 @@ class DockerStack:
|
|
|
874
1105
|
print(stdout.rstrip())
|
|
875
1106
|
if isinstance(stderr, str) and stderr.strip():
|
|
876
1107
|
print(stderr.rstrip())
|
|
1108
|
+
generated_secrets = payload.get("generated_secrets") or []
|
|
1109
|
+
if generated_secrets:
|
|
1110
|
+
print("\n----- Newly Generated Secrets -----")
|
|
1111
|
+
for item in generated_secrets:
|
|
1112
|
+
if not isinstance(item, dict):
|
|
1113
|
+
continue
|
|
1114
|
+
logical_name = str(item.get("logical_name") or item.get("actual_name") or "secret")
|
|
1115
|
+
value = item.get("value")
|
|
1116
|
+
if value is not None:
|
|
1117
|
+
print(f"{logical_name}: {value}")
|
|
1118
|
+
print("---------------------------------\n")
|
|
877
1119
|
return None
|
|
878
1120
|
|
|
1121
|
+
@staticmethod
|
|
1122
|
+
def _print_manager_deploy_event(event: Dict[str, object]) -> None:
|
|
1123
|
+
event_name = event.get("event")
|
|
1124
|
+
data = event.get("data")
|
|
1125
|
+
if not isinstance(data, dict):
|
|
1126
|
+
data = {}
|
|
1127
|
+
if event_name == "stdout":
|
|
1128
|
+
line = str(data.get("line") or "").rstrip()
|
|
1129
|
+
if line:
|
|
1130
|
+
print(line, flush=True)
|
|
1131
|
+
elif event_name == "stderr":
|
|
1132
|
+
line = str(data.get("line") or "").rstrip()
|
|
1133
|
+
if line:
|
|
1134
|
+
print(line, file=sys.stderr, flush=True)
|
|
1135
|
+
elif event_name == "command":
|
|
1136
|
+
program = data.get("program") or "manager"
|
|
1137
|
+
stack = data.get("stack")
|
|
1138
|
+
suffix = f" {stack}" if stack else ""
|
|
1139
|
+
print(f"[manager] {program}{suffix}", flush=True)
|
|
1140
|
+
elif event_name == "resource":
|
|
1141
|
+
kind = data.get("kind")
|
|
1142
|
+
logical_name = data.get("logical_name")
|
|
1143
|
+
actual_name = data.get("actual_name")
|
|
1144
|
+
if kind and logical_name and actual_name:
|
|
1145
|
+
print(f"[manager] {kind} {logical_name} -> {actual_name}", flush=True)
|
|
1146
|
+
elif event_name == "service_progress":
|
|
1147
|
+
service = data.get("service") or "service"
|
|
1148
|
+
status = data.get("status") or "progress"
|
|
1149
|
+
message = data.get("message") or data.get("error") or ""
|
|
1150
|
+
if message:
|
|
1151
|
+
print(f"[manager] {service}: {status}: {message}", flush=True)
|
|
1152
|
+
|
|
879
1153
|
@staticmethod
|
|
880
1154
|
def _rollback_via_manager(
|
|
881
1155
|
manager_client: ManagerApiClient,
|
|
@@ -909,9 +1183,17 @@ class DockerStack:
|
|
|
909
1183
|
show_generated=True,
|
|
910
1184
|
namespace: str = "default",
|
|
911
1185
|
dry_run: bool = False,
|
|
1186
|
+
prune: bool = False,
|
|
1187
|
+
resolve_image: Optional[str] = None,
|
|
1188
|
+
force_update: bool = False,
|
|
912
1189
|
):
|
|
913
1190
|
self.generated_secrets = {} # Reset for each deployment
|
|
914
|
-
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
|
+
)
|
|
915
1197
|
self._deploy(
|
|
916
1198
|
stack_name,
|
|
917
1199
|
rendered.clean,
|
|
@@ -920,6 +1202,9 @@ class DockerStack:
|
|
|
920
1202
|
tag=tag,
|
|
921
1203
|
namespace=namespace,
|
|
922
1204
|
dry_run=dry_run,
|
|
1205
|
+
prune=prune,
|
|
1206
|
+
resolve_image=resolve_image,
|
|
1207
|
+
force_update=force_update,
|
|
923
1208
|
)
|
|
924
1209
|
|
|
925
1210
|
if show_generated and self.generated_secrets:
|
|
@@ -937,8 +1222,22 @@ class DockerStack:
|
|
|
937
1222
|
if self.docker.secret:
|
|
938
1223
|
self.commands.extend(self.docker.secret.prune(keep=5))
|
|
939
1224
|
|
|
940
|
-
def rm(self, stack_name):
|
|
941
|
-
self.
|
|
1225
|
+
def rm(self, stack_name, namespace: str = "default"):
|
|
1226
|
+
manager = self._manager_client_for_feature(FEATURE_STACK_DEPLOY)
|
|
1227
|
+
if manager:
|
|
1228
|
+
self.commands.append(
|
|
1229
|
+
CallbackCommand(
|
|
1230
|
+
f"docker-manager stack rm {stack_name}",
|
|
1231
|
+
lambda: manager.remove_stack(stack=stack_name, namespace=namespace),
|
|
1232
|
+
)
|
|
1233
|
+
)
|
|
1234
|
+
return
|
|
1235
|
+
self.commands.append(
|
|
1236
|
+
Command(
|
|
1237
|
+
["docker", "stack", "rm", physical_stack_name(namespace, stack_name)],
|
|
1238
|
+
give_console=True,
|
|
1239
|
+
)
|
|
1240
|
+
)
|
|
942
1241
|
|
|
943
1242
|
def push(self, compose_file):
|
|
944
1243
|
compose_data = self.read_compose_file(compose_file)
|
|
@@ -1064,8 +1363,8 @@ class DockerNode:
|
|
|
1064
1363
|
)
|
|
1065
1364
|
DockerNode._print_rows(rows)
|
|
1066
1365
|
return rows
|
|
1067
|
-
except RuntimeError:
|
|
1068
|
-
|
|
1366
|
+
except RuntimeError as exc:
|
|
1367
|
+
self.docker.stack._raise_if_manager_is_explicit(exc)
|
|
1069
1368
|
|
|
1070
1369
|
nodes_output = run_cli_command(["docker", "node", "ls", "--format", "{{json .}}"], log=False)
|
|
1071
1370
|
rows = []
|
|
@@ -1112,6 +1411,78 @@ def active_shell_config_dir(context_name: str) -> Optional[Path]:
|
|
|
1112
1411
|
return Path(docker_config)
|
|
1113
1412
|
|
|
1114
1413
|
|
|
1414
|
+
GITHUB_WORKFLOW_RESTRICTED_MESSAGE = "github workflow tokens are restricted to stack APIs and service image updates"
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
def _format_command(command) -> str:
|
|
1418
|
+
if isinstance(command, (list, tuple)):
|
|
1419
|
+
return " ".join(str(part) for part in command)
|
|
1420
|
+
return str(command)
|
|
1421
|
+
|
|
1422
|
+
|
|
1423
|
+
def _error_output(exc: subprocess.CalledProcessError) -> str:
|
|
1424
|
+
values = []
|
|
1425
|
+
stdout = getattr(exc, "stdout", None) or getattr(exc, "output", None)
|
|
1426
|
+
stderr = getattr(exc, "stderr", None)
|
|
1427
|
+
if stdout:
|
|
1428
|
+
values.append(str(stdout).strip())
|
|
1429
|
+
if stderr:
|
|
1430
|
+
values.append(str(stderr).strip())
|
|
1431
|
+
return "\n".join(value for value in values if value)
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
def _manager_deploy_suggestion(message: str) -> Optional[str]:
|
|
1435
|
+
if GITHUB_WORKFLOW_RESTRICTED_MESSAGE in message:
|
|
1436
|
+
return (
|
|
1437
|
+
"Suggestion: this workflow is using a Docker-Manager GitHub token, but docker-stack fell back to a raw Docker CLI API. "
|
|
1438
|
+
"Run the mesudip/docker-stack@v2 setup action before deploy, keep DOCKER_MANAGER_URL/DOCKER_CONTEXT from that step, "
|
|
1439
|
+
"and make sure the manager /version endpoint advertises docker-stack deploy support."
|
|
1440
|
+
)
|
|
1441
|
+
lowered = message.lower()
|
|
1442
|
+
if "github workflow" in lowered and ("trusted" in lowered or "not allowed" in lowered or "forbidden" in lowered):
|
|
1443
|
+
return (
|
|
1444
|
+
"Suggestion: configure a trusted workflow/deployment rule on Docker-Manager for this repository, workflow file, "
|
|
1445
|
+
"stack, namespace, and service scope, then rerun the deploy."
|
|
1446
|
+
)
|
|
1447
|
+
if "external network" in lowered and "outside the stack scope" in lowered:
|
|
1448
|
+
return (
|
|
1449
|
+
"Suggestion: the compose file references an external Docker network that Docker-Manager does not consider owned "
|
|
1450
|
+
"by this stack. Either allow that external network in the Docker-Manager deployment rule, attach the service to "
|
|
1451
|
+
"a stack-owned network, or relabel/recreate the existing network with the expected stack ownership before deploying."
|
|
1452
|
+
)
|
|
1453
|
+
if "manager request failed" in lowered and "/version" in lowered:
|
|
1454
|
+
return (
|
|
1455
|
+
"Suggestion: check that the manager URL is reachable from the runner and that the setup action exported "
|
|
1456
|
+
"DOCKER_MANAGER_URL and the matching Docker auth headers."
|
|
1457
|
+
)
|
|
1458
|
+
if "docker-manager is configured" in lowered and "did not advertise required feature" in lowered:
|
|
1459
|
+
return (
|
|
1460
|
+
"Suggestion: deploy the current Docker-Manager server code and verify GET /version returns MesudipFeatures "
|
|
1461
|
+
"including mesudip-docker-enterprise-v1 or docker_stack_deploy_v1 for this manager endpoint."
|
|
1462
|
+
)
|
|
1463
|
+
return None
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
def _format_called_process_error(exc: subprocess.CalledProcessError) -> str:
|
|
1467
|
+
output = _error_output(exc)
|
|
1468
|
+
lines = [f"docker-stack: command failed with exit code {exc.returncode}: {_format_command(exc.cmd)}"]
|
|
1469
|
+
if output:
|
|
1470
|
+
lines.append(output)
|
|
1471
|
+
suggestion = _manager_deploy_suggestion(output)
|
|
1472
|
+
if suggestion:
|
|
1473
|
+
lines.extend(["", suggestion])
|
|
1474
|
+
return "\n".join(lines)
|
|
1475
|
+
|
|
1476
|
+
|
|
1477
|
+
def _format_runtime_error(exc: RuntimeError) -> str:
|
|
1478
|
+
message = str(exc).strip() or exc.__class__.__name__
|
|
1479
|
+
lines = [f"docker-stack: {message}"]
|
|
1480
|
+
suggestion = _manager_deploy_suggestion(message)
|
|
1481
|
+
if suggestion:
|
|
1482
|
+
lines.extend(["", suggestion])
|
|
1483
|
+
return "\n".join(lines)
|
|
1484
|
+
|
|
1485
|
+
|
|
1115
1486
|
def main(args: List[str] = None):
|
|
1116
1487
|
parser = argparse.ArgumentParser(description="Deploy and manage Docker stacks.")
|
|
1117
1488
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
@@ -1122,7 +1493,7 @@ def main(args: List[str] = None):
|
|
|
1122
1493
|
login_parser.add_argument("--context", "--context-name", dest="context_name", help="Docker context name to create or update")
|
|
1123
1494
|
login_parser.add_argument("--timeout-secs", type=int, help="Login timeout in seconds")
|
|
1124
1495
|
|
|
1125
|
-
shell_parser = subparsers.add_parser("shell", help="Open an
|
|
1496
|
+
shell_parser = subparsers.add_parser("shell", help="Open an authenticated Bash/Zsh shell for a Docker-Manager context")
|
|
1126
1497
|
shell_parser.add_argument("target", nargs="?", help="Context name, or manager host/URL when used with --context")
|
|
1127
1498
|
shell_parser.add_argument("--context", "--context-name", dest="context_name", help="Shell context name to create or reuse")
|
|
1128
1499
|
shell_parser.add_argument("--timeout-secs", type=int, help="Login timeout in seconds")
|
|
@@ -1145,6 +1516,9 @@ def main(args: List[str] = None):
|
|
|
1145
1516
|
setup_auth_parser.add_argument("--github-oidc-token", help="GitHub OIDC token for trusted publishing")
|
|
1146
1517
|
setup_auth_parser.add_argument("--verify-ssl", action="store_true", help=argparse.SUPPRESS)
|
|
1147
1518
|
|
|
1519
|
+
shell_auth_parser = subparsers.add_parser("shell-auth", help=argparse.SUPPRESS)
|
|
1520
|
+
shell_auth_parser.add_argument("operation", choices=("ensure", "status"), help=argparse.SUPPRESS)
|
|
1521
|
+
|
|
1148
1522
|
# Build subcommand
|
|
1149
1523
|
build_parser = subparsers.add_parser("build", help="Build images using docker-compose")
|
|
1150
1524
|
build_parser.add_argument("compose_file", help="Path to the compose file")
|
|
@@ -1159,19 +1533,32 @@ def main(args: List[str] = None):
|
|
|
1159
1533
|
deploy_parser.add_argument("stack_name", help="Name of the stack")
|
|
1160
1534
|
deploy_parser.add_argument("compose_file", help="Path to the compose file")
|
|
1161
1535
|
deploy_parser.add_argument("--with-registry-auth", action="store_true", help="Use registry authentication")
|
|
1162
|
-
deploy_parser.add_argument("--
|
|
1536
|
+
deploy_parser.add_argument("--prune", action="store_true", help="Remove services no longer referenced by the compose file")
|
|
1537
|
+
deploy_parser.add_argument(
|
|
1538
|
+
"--resolve-image",
|
|
1539
|
+
choices=("always", "changed", "never"),
|
|
1540
|
+
help="Control image digest resolution during deploy",
|
|
1541
|
+
)
|
|
1542
|
+
deploy_parser.add_argument(
|
|
1543
|
+
"--force-update",
|
|
1544
|
+
action="store_true",
|
|
1545
|
+
help="Force service task updates (Docker-Manager deploys only)",
|
|
1546
|
+
)
|
|
1547
|
+
_add_namespace_argument(deploy_parser, "Deployment namespace")
|
|
1163
1548
|
deploy_parser.add_argument("-t", "--tag", help="Tag the current deployment for later checkout", required=False)
|
|
1164
1549
|
deploy_parser.add_argument("--show-generated", action="store_true", default=True, help="Show newly generated secrets after deployment")
|
|
1165
1550
|
|
|
1166
1551
|
# Remove subcommand
|
|
1167
1552
|
rm_parser = subparsers.add_parser("rm", help="Remove a deployed stack")
|
|
1168
1553
|
rm_parser.add_argument("stack_name", help="Name of the stack")
|
|
1554
|
+
_add_namespace_argument(rm_parser)
|
|
1169
1555
|
|
|
1170
1556
|
# Prune command
|
|
1171
1557
|
subparsers.add_parser("prune", help="Remove old versions of configs and secrets")
|
|
1172
1558
|
|
|
1173
1559
|
# Ls command
|
|
1174
|
-
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)
|
|
1175
1562
|
|
|
1176
1563
|
node_parser = subparsers.add_parser("node", help="Inspect Docker Swarm nodes")
|
|
1177
1564
|
node_subparsers = node_parser.add_subparsers(dest="node_command", required=True)
|
|
@@ -1182,12 +1569,12 @@ def main(args: List[str] = None):
|
|
|
1182
1569
|
)
|
|
1183
1570
|
cat_parser.add_argument("stack_name", help="Name of the stack")
|
|
1184
1571
|
cat_parser.add_argument("version", nargs="?", help="Stack version to cat. Defaults to latest if omitted.")
|
|
1185
|
-
cat_parser
|
|
1572
|
+
_add_namespace_argument(cat_parser)
|
|
1186
1573
|
|
|
1187
1574
|
checkout_parser = subparsers.add_parser("checkout", help="Deploy specific version of the stack")
|
|
1188
1575
|
checkout_parser.add_argument("stack_name", help="Name of the stack")
|
|
1189
1576
|
checkout_parser.add_argument("version", help="Stack version to cat")
|
|
1190
|
-
checkout_parser
|
|
1577
|
+
_add_namespace_argument(checkout_parser)
|
|
1191
1578
|
|
|
1192
1579
|
# version_parser = subparsers.add_parser("version",help="Deploy specific version of the stack")
|
|
1193
1580
|
# version_parser.add_argument("stack_name", help="Name of the stack")
|
|
@@ -1195,7 +1582,7 @@ def main(args: List[str] = None):
|
|
|
1195
1582
|
|
|
1196
1583
|
version_parser = subparsers.add_parser("version", aliases=["versions"], help="Deploy specific version of the stack")
|
|
1197
1584
|
version_parser.add_argument("stack_name", help="Name of the stack")
|
|
1198
|
-
version_parser
|
|
1585
|
+
_add_namespace_argument(version_parser)
|
|
1199
1586
|
|
|
1200
1587
|
parser.add_argument(
|
|
1201
1588
|
"-u", "--user", help="Registry credentials in format hostname:username:password", action="append", required=False, default=[]
|
|
@@ -1208,6 +1595,22 @@ def main(args: List[str] = None):
|
|
|
1208
1595
|
|
|
1209
1596
|
args = parser.parse_args(args if args else sys.argv[1:])
|
|
1210
1597
|
|
|
1598
|
+
if args.command == "shell-auth":
|
|
1599
|
+
try:
|
|
1600
|
+
if args.operation == "ensure":
|
|
1601
|
+
ensure_shell_access(required=True)
|
|
1602
|
+
else:
|
|
1603
|
+
from docker_stack.shell_auth import shell_auth_request
|
|
1604
|
+
|
|
1605
|
+
payload = shell_auth_request("status", required=True)
|
|
1606
|
+
print(json.dumps(payload))
|
|
1607
|
+
except ShellAuthError as exc:
|
|
1608
|
+
print(f"docker-stack shell-auth: {exc}", file=sys.stderr)
|
|
1609
|
+
if exc.requires_login:
|
|
1610
|
+
print("Run: docker-stack login", file=sys.stderr)
|
|
1611
|
+
return 2
|
|
1612
|
+
return 0
|
|
1613
|
+
|
|
1211
1614
|
if args.command == "login":
|
|
1212
1615
|
try:
|
|
1213
1616
|
config = resolve_login_config(
|
|
@@ -1217,7 +1620,10 @@ def main(args: List[str] = None):
|
|
|
1217
1620
|
timeout_secs=args.timeout_secs,
|
|
1218
1621
|
)
|
|
1219
1622
|
current_shell_config = active_shell_config_dir(config.context_name)
|
|
1220
|
-
if
|
|
1623
|
+
if shell_session_active():
|
|
1624
|
+
result = login_for_active_shell(config)
|
|
1625
|
+
config_dir = current_shell_config
|
|
1626
|
+
elif current_shell_config:
|
|
1221
1627
|
config_dir, result = ensure_isolated_login(config, docker_config_dir=current_shell_config)
|
|
1222
1628
|
else:
|
|
1223
1629
|
config_dir = None
|
|
@@ -1247,30 +1653,43 @@ def main(args: List[str] = None):
|
|
|
1247
1653
|
shell_name = args.target if args.context_name is None else None
|
|
1248
1654
|
manager_target = args.target if args.context_name is not None else None
|
|
1249
1655
|
try:
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
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)
|
|
1673
|
+
if shell_session_active() and os.getenv(SHELL_CONTEXT_ENV) == config.context_name:
|
|
1674
|
+
print(f"docker-stack shell context={config.context_name} is already active")
|
|
1675
|
+
return
|
|
1676
|
+
result = browser_login(config)
|
|
1258
1677
|
except RuntimeError as exc:
|
|
1259
1678
|
print(f"docker-stack shell: {exc}", file=sys.stderr)
|
|
1260
1679
|
sys.exit(2)
|
|
1261
|
-
|
|
1262
|
-
print(f"Refreshing current shell context={config.context_name}")
|
|
1263
|
-
else:
|
|
1264
|
-
print(f"Opening shell context={config.context_name}")
|
|
1680
|
+
print(f"Opening shell context={config.context_name}")
|
|
1265
1681
|
print(f"Shell manager={config.manager_url}")
|
|
1266
|
-
print(f"DOCKER_CONFIG={config_dir}")
|
|
1267
1682
|
if result is not None:
|
|
1268
1683
|
expiry = format_expiry(result.expires_at)
|
|
1269
1684
|
if expiry:
|
|
1270
1685
|
print(f"Access token expires in {expiry}")
|
|
1271
1686
|
else:
|
|
1272
1687
|
print("Access token already active.")
|
|
1273
|
-
|
|
1688
|
+
try:
|
|
1689
|
+
sys.exit(run_managed_shell(config, result))
|
|
1690
|
+
except ShellAuthError as exc:
|
|
1691
|
+
print(f"docker-stack shell: {exc}", file=sys.stderr)
|
|
1692
|
+
sys.exit(2)
|
|
1274
1693
|
|
|
1275
1694
|
if args.command == "setup-auth":
|
|
1276
1695
|
try:
|
|
@@ -1314,59 +1733,72 @@ def main(args: List[str] = None):
|
|
|
1314
1733
|
print("Docker-Manager auth header cleared from ~/.docker/config.json.")
|
|
1315
1734
|
return
|
|
1316
1735
|
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
args.compose_file
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1736
|
+
try:
|
|
1737
|
+
docker = Docker(registries=args.user)
|
|
1738
|
+
docker.load_env()
|
|
1739
|
+
|
|
1740
|
+
if hasattr(args, "namespace") and args.command != "ls":
|
|
1741
|
+
print(f"Namespace: {args.namespace}", file=sys.stderr)
|
|
1742
|
+
|
|
1743
|
+
if args.command == "build":
|
|
1744
|
+
docker.stack.build_and_push(args.compose_file, push=args.push)
|
|
1745
|
+
elif args.command == "push":
|
|
1746
|
+
docker.stack.push(args.compose_file)
|
|
1747
|
+
elif args.command == "deploy":
|
|
1748
|
+
docker.stack.deploy(
|
|
1749
|
+
args.stack_name,
|
|
1750
|
+
args.compose_file,
|
|
1751
|
+
args.with_registry_auth,
|
|
1752
|
+
tag=args.tag,
|
|
1753
|
+
show_generated=args.show_generated,
|
|
1754
|
+
namespace=args.namespace,
|
|
1755
|
+
dry_run=args.ro,
|
|
1756
|
+
prune=args.prune,
|
|
1757
|
+
resolve_image=args.resolve_image,
|
|
1758
|
+
force_update=args.force_update,
|
|
1759
|
+
)
|
|
1760
|
+
elif args.command == "ls":
|
|
1761
|
+
docker.stack.ls(namespace=None if args.all_namespaces else (args.namespace or DEFAULT_NAMESPACE))
|
|
1762
|
+
elif args.command == "node":
|
|
1763
|
+
if args.node_command == "ls":
|
|
1764
|
+
docker.node.ls()
|
|
1765
|
+
|
|
1766
|
+
elif args.command == "rm":
|
|
1767
|
+
docker.stack.rm(args.stack_name, namespace=args.namespace)
|
|
1768
|
+
elif args.command == "prune":
|
|
1769
|
+
docker.stack.prune()
|
|
1770
|
+
elif args.command == "cat":
|
|
1771
|
+
version_to_cat = args.version
|
|
1772
|
+
if version_to_cat is None:
|
|
1773
|
+
versions_list = docker.stack.versions(args.stack_name, namespace=args.namespace, print_output=False)
|
|
1774
|
+
if versions_list:
|
|
1775
|
+
# Assuming versions are integers, find the maximum
|
|
1776
|
+
latest_version = max(int(v[0]) for v in versions_list if v[0].isdigit())
|
|
1777
|
+
version_to_cat = str(latest_version)
|
|
1778
|
+
else:
|
|
1779
|
+
print(f"No versions found for stack '{args.stack_name}'.")
|
|
1780
|
+
sys.exit(1)
|
|
1781
|
+
print(docker.stack.cat(args.stack_name, version_to_cat, namespace=args.namespace))
|
|
1782
|
+
elif args.command == "checkout":
|
|
1783
|
+
docker.stack.checkout(
|
|
1784
|
+
args.stack_name,
|
|
1785
|
+
args.version,
|
|
1786
|
+
namespace=args.namespace,
|
|
1787
|
+
dry_run=args.ro,
|
|
1788
|
+
)
|
|
1789
|
+
elif args.command == "versions" or args.command == "version":
|
|
1790
|
+
docker.stack.versions(args.stack_name, namespace=args.namespace)
|
|
1791
|
+
if args.ro:
|
|
1792
|
+
print("Following commands were not executed:")
|
|
1793
|
+
[print(" >> " + str(x)) for x in docker.stack.commands if x]
|
|
1794
|
+
else:
|
|
1795
|
+
[x.execute() for x in docker.stack.commands]
|
|
1796
|
+
except subprocess.CalledProcessError as exc:
|
|
1797
|
+
print(_format_called_process_error(exc), file=sys.stderr)
|
|
1798
|
+
sys.exit(exc.returncode or 2)
|
|
1799
|
+
except RuntimeError as exc:
|
|
1800
|
+
print(_format_runtime_error(exc), file=sys.stderr)
|
|
1801
|
+
sys.exit(2)
|
|
1370
1802
|
|
|
1371
1803
|
|
|
1372
1804
|
if __name__ == "__main__":
|