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 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
- x_files.append(
342
- {
343
- "path": self._relative_source_name(filename, base_dir),
344
- "content": self._b64_content(content),
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(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:
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
- if stack:
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"], 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,
372
476
  )
373
477
  if "secrets" in compose_data:
374
478
  compose_data["secrets"] = self._process_x_content(
375
- 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,
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(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
+ )
402
515
 
403
- 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
+ ):
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
- namespace = os.getenv("DOCKER_STACK_NAMESPACE", "default")
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 stack:
416
- return f"{stack}_{name}"
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=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
- def _print_stack_listing(self, stack_versions: Dict[str, List[str]]) -> None:
550
- max_stack_name_length = max(len(stack) for stack in stack_versions) if stack_versions else 10
551
- 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}"))
552
698
 
553
- print(f"{header_stack} | Versions")
554
- 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)
555
715
 
556
- for stack, versions in sorted(stack_versions.items()):
557
- versions_str = ", ".join(sorted(versions, key=self._version_sort_key))
558
- 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}")
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
- pass
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
- pass
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 = f"{name}"
807
+ config_name = history_name
628
808
  else:
629
- config_name = f"{name}_v{normalized_version}"
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
- pass
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
- manager_options = {"with_registry_auth": with_registry_auth} if with_registry_auth else {}
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
- _, cmd = self.docker.config.increment(stack_name, stored_content, labels=labels, stack=stack_name)
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", "-", stack_name]
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
- payload = manager_client.deploy_stack(
863
- stack=stack_name,
864
- namespace=namespace,
865
- compose=rendered_content,
866
- options=options,
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(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
+ )
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.commands.append(Command(["docker", "stack", "rm", stack_name], give_console=True))
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
- pass
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 isolated bash shell for a Docker-Manager context")
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("--namespace", default=os.getenv("DOCKER_STACK_NAMESPACE", "default"), help="Deployment namespace")
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.add_argument("--namespace", default=os.getenv("DOCKER_STACK_NAMESPACE", "default"), help="Stack namespace")
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.add_argument("--namespace", default=os.getenv("DOCKER_STACK_NAMESPACE", "default"), help="Stack namespace")
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.add_argument("--namespace", default=os.getenv("DOCKER_STACK_NAMESPACE", "default"), help="Stack namespace")
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 current_shell_config:
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
- config = resolve_shell_login_config(
1251
- shell_name=shell_name,
1252
- manager_target=manager_target,
1253
- context_name=args.context_name,
1254
- timeout_secs=args.timeout_secs,
1255
- )
1256
- existing_shell_config = active_shell_config_dir(config.context_name)
1257
- config_dir, result = ensure_isolated_login(config)
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
- if existing_shell_config:
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
- sys.exit(open_context_shell(config_dir, config.context_name))
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
- docker = Docker(registries=args.user)
1318
- docker.load_env()
1319
-
1320
- if args.command == "build":
1321
- docker.stack.build_and_push(args.compose_file, push=args.push)
1322
- elif args.command == "push":
1323
- docker.stack.push(args.compose_file)
1324
- elif args.command == "deploy":
1325
- docker.stack.deploy(
1326
- args.stack_name,
1327
- args.compose_file,
1328
- args.with_registry_auth,
1329
- tag=args.tag,
1330
- show_generated=args.show_generated,
1331
- namespace=args.namespace,
1332
- dry_run=args.ro,
1333
- )
1334
- elif args.command == "ls":
1335
- docker.stack.ls()
1336
- elif args.command == "node":
1337
- if args.node_command == "ls":
1338
- docker.node.ls()
1339
-
1340
- elif args.command == "rm":
1341
- docker.stack.rm(args.stack_name)
1342
- elif args.command == "prune":
1343
- docker.stack.prune()
1344
- elif args.command == "cat":
1345
- version_to_cat = args.version
1346
- if version_to_cat is None:
1347
- versions_list = docker.stack.versions(args.stack_name, namespace=args.namespace, print_output=False)
1348
- if versions_list:
1349
- # Assuming versions are integers, find the maximum
1350
- latest_version = max(int(v[0]) for v in versions_list if v[0].isdigit())
1351
- version_to_cat = str(latest_version)
1352
- else:
1353
- print(f"No versions found for stack '{args.stack_name}'.")
1354
- sys.exit(1)
1355
- print(docker.stack.cat(args.stack_name, version_to_cat, namespace=args.namespace))
1356
- elif args.command == "checkout":
1357
- docker.stack.checkout(
1358
- args.stack_name,
1359
- args.version,
1360
- namespace=args.namespace,
1361
- dry_run=args.ro,
1362
- )
1363
- elif args.command == "versions" or args.command == "version":
1364
- docker.stack.versions(args.stack_name, namespace=args.namespace)
1365
- if args.ro:
1366
- print("Following commands were not executed:")
1367
- [print(" >> " + str(x)) for x in docker.stack.commands if x]
1368
- else:
1369
- [x.execute() for x in docker.stack.commands]
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__":