docker-stack 2.2.5__py3-none-any.whl → 2.3.1__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
@@ -8,7 +8,7 @@ import shutil
8
8
  import sys
9
9
  import textwrap
10
10
  from pathlib import Path
11
- from typing import Dict, List, Optional, Tuple
11
+ from typing import Dict, List, Optional, Set, Tuple
12
12
  import os
13
13
  import yaml
14
14
  import json
@@ -36,11 +36,16 @@ from docker_stack.shell_auth import (
36
36
  run_managed_shell,
37
37
  shell_session_active,
38
38
  )
39
+ from docker_stack.conflict_prompt import ForceDeployPrompt
39
40
  from docker_stack.manager_api import (
41
+ DEPLOY_WAIT_ENV,
40
42
  FEATURE_STACK_DEPLOY,
41
43
  FEATURE_STACK_QUERY,
42
44
  ManagerApiClient,
45
+ describe_active_deployment,
46
+ describe_run,
43
47
  discover_manager_client,
48
+ format_deploy_error_body,
44
49
  )
45
50
  from docker_stack.registry import DockerRegistry
46
51
  from .envsubst import LineCheckResult, SubstitutionError, envsubst, envsubst_load_file
@@ -698,6 +703,143 @@ def load_env_file(env_file: str, base_env: Optional[Dict[str, str]] = None, max_
698
703
  return _resolve_env_entries(entries, env_file, base_env=base_env, max_cycles=max_cycles)
699
704
 
700
705
 
706
+
707
+
708
+ _ENV_REF_PATTERN = re.compile(r"\$\{([^}:\s]+)(?::-(.*?))?\}|\$([a-zA-Z_][a-zA-Z0-9_]*)")
709
+ _ESCAPED_DOLLAR = "$_ESCAPED_DOLLAR_"
710
+
711
+
712
+ def _escape_dollars(text: str) -> str:
713
+ """Marks text as a literal payload for the manager, which collapses `$$`."""
714
+ return text.replace("$", "$$")
715
+
716
+
717
+ def _substitute_selected_vars(text: str, names: Set[str], replacements: Dict[str, str]) -> str:
718
+ """Expands only `names`, leaving every other `${VAR}` for the manager."""
719
+ if not names:
720
+ return text
721
+ guarded = text.replace("$$", _ESCAPED_DOLLAR)
722
+
723
+ def replace(match: "re.Match[str]") -> str:
724
+ var = match.group(1) if match.group(1) is not None else match.group(3)
725
+ if var not in names:
726
+ return match.group(0)
727
+ value = os.environ.get(var)
728
+ if value in (None, "") and match.group(2) is not None:
729
+ value = match.group(2)
730
+ if value in (None, ""):
731
+ return match.group(0)
732
+ for old, new in replacements.items():
733
+ value = value.replace(old, new)
734
+ return value
735
+
736
+ return _ENV_REF_PATTERN.sub(replace, guarded).replace(_ESCAPED_DOLLAR, "$$")
737
+
738
+
739
+ def _substitute_selected_in_yaml_tree(value, names: Set[str], replacements: Dict[str, str]):
740
+ if isinstance(value, str):
741
+ return _substitute_selected_vars(value, names, replacements)
742
+ if isinstance(value, dict):
743
+ return {
744
+ _substitute_selected_in_yaml_tree(key, names, replacements): _substitute_selected_in_yaml_tree(item, names, replacements)
745
+ for key, item in value.items()
746
+ }
747
+ if isinstance(value, list):
748
+ return [_substitute_selected_in_yaml_tree(item, names, replacements) for item in value]
749
+ return value
750
+
751
+
752
+ def _mark_inline_secret_content_literal(compose_data) -> None:
753
+ """Protects inlined secret payloads from the manager's interpolation.
754
+
755
+ `x-content` is a payload, not a template: the file it came from was already
756
+ rendered locally when it was declared as `x-template-file`, and a `file:` or
757
+ `environment:` secret is verbatim by definition. Without this a `$` inside a
758
+ secret is read as a variable reference and the secret is silently corrupted
759
+ or the deploy fails on a variable the payload never meant to name.
760
+ """
761
+ if not isinstance(compose_data, dict):
762
+ return
763
+ secrets = compose_data.get("secrets")
764
+ if not isinstance(secrets, dict):
765
+ return
766
+ for details in secrets.values():
767
+ if isinstance(details, dict) and isinstance(details.get("x-content"), str):
768
+ details["x-content"] = _escape_dollars(details["x-content"])
769
+
770
+
771
+ def _render_manager_compose(compose_data, secret_env_vars: Set[str], replacements: Dict[str, str]) -> str:
772
+ """Renders the compose a manager-backed deploy sends.
773
+
774
+ The manager owns interpolation: it receives the authored compose and the
775
+ `.env` holding the values, and it substitutes in the parsed document. So
776
+ variables are left standing here rather than rendered twice, which is what
777
+ the `$` doubling in `replacements` exists to undo. Only the variables the
778
+ manager cannot see are resolved locally - a secret sourced from the
779
+ environment is deliberately kept out of `.env` so its value is not stored
780
+ with the stack.
781
+
782
+ References are still checked here so a missing variable is reported against
783
+ the authored file, with its line and surrounding context, instead of coming
784
+ back as a deploy failure.
785
+ """
786
+ _mark_inline_secret_content_literal(compose_data)
787
+ missing: List[str] = []
788
+ _substitute_in_yaml_tree(compose_data, replacements, missing)
789
+ if missing:
790
+ _report_missing_variables(compose_data, replacements, missing)
791
+ return yaml.dump(_substitute_selected_in_yaml_tree(compose_data, secret_env_vars, replacements), sort_keys=False)
792
+
793
+
794
+ def _substitute_in_yaml_tree(value, replacements: Dict[str, str], missing: List[str]):
795
+ """Expands `${VAR}` inside the scalars of a parsed compose document.
796
+
797
+ Substituting in the serialized text instead would let a variable's value
798
+ change the structure of the document. `KEY: ${JSON}` holding `{"a":"b"}`
799
+ reparses as a nested mapping, which the manager rejects as a non-scalar
800
+ environment value, and a value containing `: ` makes the document fail to
801
+ parse at all. Walking the tree keeps every substituted value a scalar,
802
+ whatever characters it contains.
803
+ """
804
+ if isinstance(value, str):
805
+ try:
806
+ return envsubst(value, replacements=replacements, on_error="throw")
807
+ except SubstitutionError as error:
808
+ missing.extend(result.variable_name for result in error.results if result.has_error)
809
+ return value
810
+ if isinstance(value, dict):
811
+ return {
812
+ _substitute_in_yaml_tree(key, replacements, missing): _substitute_in_yaml_tree(item, replacements, missing)
813
+ for key, item in value.items()
814
+ }
815
+ if isinstance(value, list):
816
+ return [_substitute_in_yaml_tree(item, replacements, missing) for item in value]
817
+ return value
818
+
819
+
820
+ def _render_compose_substitutions(compose_data, replacements: Dict[str, str]) -> str:
821
+ missing: List[str] = []
822
+ substituted = _substitute_in_yaml_tree(compose_data, replacements, missing)
823
+ if missing:
824
+ _report_missing_variables(compose_data, replacements, missing)
825
+ return yaml.dump(substituted, sort_keys=False)
826
+
827
+
828
+ def _report_missing_variables(compose_data, replacements: Dict[str, str], missing: List[str]) -> None:
829
+ """Reports unresolved variables against the whole document.
830
+
831
+ Substitution happens scalar by scalar, which has no line numbers to report.
832
+ Re-running over the serialized document restores the line and context in the
833
+ error, and exits the same way it always has.
834
+ """
835
+ document = yaml.dump(compose_data, sort_keys=False)
836
+ envsubst(document, replacements=replacements)
837
+ raise SubstitutionError(
838
+ [LineCheckResult(line_no=1, line_content="", variable_name=name, start_index=0) for name in missing],
839
+ document,
840
+ )
841
+
842
+
701
843
  class Docker:
702
844
  def __init__(self, registries: List[str] = []):
703
845
  self.stack = DockerStack(self)
@@ -786,7 +928,27 @@ class DockerStack:
786
928
  def _relative_source_name(path: Path, base_dir: Path) -> str:
787
929
  return os.path.relpath(path, base_dir).replace(os.sep, "/")
788
930
 
789
- def _build_env_xfile(self, sources: List[str], excluded_names: set) -> Optional[Dict[str, str]]:
931
+ @staticmethod
932
+ def _config_environment_vars(compose_data: dict) -> set:
933
+ """Variables named by `configs.<name>.environment`.
934
+
935
+ These are not `${VAR}` references, so scanning the compose text does not
936
+ find them, yet the manager resolves them from the stack `.env` like any
937
+ other variable. Secrets are deliberately absent: their value is inlined
938
+ locally so it is never stored alongside the stack.
939
+ """
940
+ config_env_vars = set()
941
+ configs = compose_data.get("configs", {})
942
+ if not isinstance(configs, dict):
943
+ return config_env_vars
944
+ for details in configs.values():
945
+ if isinstance(details, dict) and "environment" in details:
946
+ env_name = str(details["environment"]).strip()
947
+ if env_name:
948
+ config_env_vars.add(env_name)
949
+ return config_env_vars
950
+
951
+ def _build_env_xfile(self, sources: List[str], excluded_names: set, extra_names: Optional[Set[str]] = None) -> Optional[Dict[str, str]]:
790
952
  seen = set()
791
953
  lines = []
792
954
  for source in sources:
@@ -800,6 +962,14 @@ class DockerStack:
800
962
  continue
801
963
  seen.add(name)
802
964
  lines.append(f"{name}={value}")
965
+ for name in sorted(extra_names or ()):
966
+ if name in seen or name in excluded_names:
967
+ continue
968
+ value = os.environ.get(name)
969
+ if value in (None, ""):
970
+ continue
971
+ seen.add(name)
972
+ lines.append(f"{name}={value}")
803
973
  if not lines:
804
974
  return None
805
975
  content = ("\n".join(lines) + "\n").encode("utf-8")
@@ -907,12 +1077,19 @@ class DockerStack:
907
1077
 
908
1078
  # Define the replacements for '$' to '$$' for env variables in compose files
909
1079
  replacements_map = {"$": "$$"}
910
- clean_content = envsubst(yaml.dump(compose_data, sort_keys=False), replacements=replacements_map)
1080
+ if manager_deploy:
1081
+ clean_content = _render_manager_compose(compose_data, secret_env_vars, replacements_map)
1082
+ else:
1083
+ clean_content = _render_compose_substitutions(compose_data, replacements_map)
911
1084
  enriched_data = yaml.safe_load(clean_content) or {}
912
1085
  x_files = [
913
1086
  {"path": "compose.yml", "content": self._b64_content(template_bytes)},
914
1087
  ]
915
- env_xfile = self._build_env_xfile(env_sources, secret_env_vars)
1088
+ env_xfile = self._build_env_xfile(
1089
+ env_sources,
1090
+ secret_env_vars,
1091
+ extra_names=self._config_environment_vars(compose_data) if manager_deploy else None,
1092
+ )
916
1093
  if env_xfile:
917
1094
  x_files.append(env_xfile)
918
1095
  x_files.extend(source_x_files)
@@ -1500,6 +1677,7 @@ class DockerStack:
1500
1677
  options=options,
1501
1678
  )
1502
1679
  else:
1680
+ prompt = ForceDeployPrompt.create()
1503
1681
  try:
1504
1682
  payload = manager_client.deploy_stack_stream(
1505
1683
  stack=stack_name,
@@ -1507,6 +1685,7 @@ class DockerStack:
1507
1685
  compose=rendered_content,
1508
1686
  options=options,
1509
1687
  on_event=DockerStack._print_manager_deploy_event,
1688
+ wait_for_poll=prompt.wait if prompt else None,
1510
1689
  )
1511
1690
  except RuntimeError as exc:
1512
1691
  message = str(exc)
@@ -1571,6 +1750,51 @@ class DockerStack:
1571
1750
  message = data.get("message") or data.get("error") or ""
1572
1751
  if message:
1573
1752
  print(f"[manager] {service}: {status}: {message}", flush=True)
1753
+ elif event_name == "deployment":
1754
+ deployment_id = data.get("deployment_id")
1755
+ if deployment_id:
1756
+ mode = data.get("mode") or "deployment"
1757
+ print(f"[manager] {mode} {deployment_id}", flush=True)
1758
+ elif event_name == "waiting":
1759
+ DockerStack._print_manager_wait(data)
1760
+
1761
+ @staticmethod
1762
+ def _print_manager_wait(active: Dict[str, object]) -> None:
1763
+ """Narrate the wait for another run of this stack, and any attempt to force past it.
1764
+
1765
+ The manager will not queue an interactive deploy for us, so the CLI polls
1766
+ and reports each phase: waiting, forcing (asking the manager to abort the
1767
+ holder), and how that abort ended.
1768
+ """
1769
+ if not isinstance(active, dict):
1770
+ active = {}
1771
+ phase = str(active.get("phase") or "waiting")
1772
+ run = describe_run(active)
1773
+ if phase == "waiting":
1774
+ budget = active.get("wait_budget_secs")
1775
+ suffix = f" (waiting up to {budget}s, set {DEPLOY_WAIT_ENV} to change)" if isinstance(budget, int) else ""
1776
+ message = f"waiting: {describe_active_deployment(active)}{suffix}"
1777
+ elif phase == "forcing":
1778
+ message = f"forcing: asking the manager to abort {run}"
1779
+ elif phase == "aborted":
1780
+ message = f"aborted {run}; deploying now"
1781
+ elif phase == "aborted_other":
1782
+ message = f"aborted a different run than shown: {run}; deploying now"
1783
+ elif phase == "free":
1784
+ message = "the stack is free; deploying now"
1785
+ elif phase == "not_released":
1786
+ message = f"asked the manager to abort {run}, but it has not released the stack yet; waiting for it to finish"
1787
+ elif phase == "forbidden":
1788
+ message = f"you do not have permission to abort {run}; still waiting"
1789
+ elif phase == "still_held":
1790
+ message = f"the stack is still held by {run}; waiting"
1791
+ elif phase == "force_used":
1792
+ message = "force was already used once in this run; waiting for the stack to free up"
1793
+ elif phase == "interrupted":
1794
+ message = "interrupted while asking the manager to abort; that request may still complete"
1795
+ else:
1796
+ message = f"{phase}: {run}"
1797
+ print(f"[manager] {message}", file=sys.stderr, flush=True)
1574
1798
 
1575
1799
  @staticmethod
1576
1800
  def _rollback_via_manager(
@@ -1580,10 +1804,13 @@ class DockerStack:
1580
1804
  namespace: str,
1581
1805
  version: str,
1582
1806
  ) -> Optional[str]:
1807
+ prompt = ForceDeployPrompt.create()
1583
1808
  payload = manager_client.rollback_stack(
1584
1809
  stack=stack_name,
1585
1810
  namespace=namespace,
1586
1811
  version=version,
1812
+ on_wait=DockerStack._print_manager_wait,
1813
+ wait_for_poll=prompt.wait if prompt else None,
1587
1814
  )
1588
1815
  warnings = payload.get("warnings") or []
1589
1816
  for warning in warnings:
@@ -1868,7 +2095,7 @@ def _humanize_manager_error(message: str) -> str:
1868
2095
  return message
1869
2096
  if not isinstance(body, dict):
1870
2097
  return message
1871
- detail = str(body.get("message", "")).strip()
2098
+ detail = format_deploy_error_body(body) if body.get("code") else str(body.get("message", "")).strip()
1872
2099
  if not detail:
1873
2100
  return message
1874
2101
  incident = str(body.get("incident_id") or body.get("trace_id") or "").strip()
@@ -1896,6 +2123,17 @@ def _manager_deploy_suggestion(message: str) -> Optional[str]:
1896
2123
  "by this stack. Either allow that external network in the Docker-Manager deployment rule, attach the service to "
1897
2124
  "a stack-owned network, or relabel/recreate the existing network with the expected stack ownership before deploying."
1898
2125
  )
2126
+ if "is still running" in lowered and ("gave up after waiting" in lowered or "http 409" in lowered):
2127
+ return (
2128
+ "Suggestion: another run holds this stack's deploy lock. Rerun and let it wait, raise "
2129
+ f"{DEPLOY_WAIT_ENV} to queue longer, press Enter twice while waiting at a terminal to abort that run "
2130
+ "and force your deploy, or abort it from the Docker-Manager stack page."
2131
+ )
2132
+ if "failed services:" in lowered:
2133
+ return (
2134
+ "Suggestion: every service operation has finished; the failed ones are listed above with their incident ids. "
2135
+ "Fix the cause and redeploy, or use the rollback offered on the Docker-Manager stack page to restore the previous specs."
2136
+ )
1899
2137
  if "stack not found" in lowered:
1900
2138
  return (
1901
2139
  "Suggestion: the manager was reached but has no stack by that name in that namespace. "
@@ -1918,9 +2156,10 @@ def _manager_deploy_suggestion(message: str) -> Optional[str]:
1918
2156
  def _format_called_process_error(exc: subprocess.CalledProcessError) -> str:
1919
2157
  output = _error_output(exc)
1920
2158
  lines = [f"docker-stack: command failed with exit code {exc.returncode}: {_format_command(exc.cmd)}"]
1921
- if output:
1922
- lines.append(_humanize_manager_error(output))
1923
- suggestion = _manager_deploy_suggestion(output)
2159
+ humanized = _humanize_manager_error(output) if output else ""
2160
+ if humanized:
2161
+ lines.append(humanized)
2162
+ suggestion = _manager_deploy_suggestion(humanized or output)
1924
2163
  if suggestion:
1925
2164
  lines.extend(["", suggestion])
1926
2165
  return "\n".join(lines)
@@ -1928,8 +2167,9 @@ def _format_called_process_error(exc: subprocess.CalledProcessError) -> str:
1928
2167
 
1929
2168
  def _format_runtime_error(exc: RuntimeError) -> str:
1930
2169
  message = str(exc).strip() or exc.__class__.__name__
1931
- lines = [f"docker-stack: {_humanize_manager_error(message)}"]
1932
- suggestion = _manager_deploy_suggestion(message)
2170
+ humanized = _humanize_manager_error(message)
2171
+ lines = [f"docker-stack: {humanized}"]
2172
+ suggestion = _manager_deploy_suggestion(humanized)
1933
2173
  if suggestion:
1934
2174
  lines.extend(["", suggestion])
1935
2175
  return "\n".join(lines)
@@ -2031,14 +2271,14 @@ def _run(args: List[str] = None):
2031
2271
 
2032
2272
  checkout_parser = subparsers.add_parser("checkout", help="Deploy specific version of the stack")
2033
2273
  checkout_parser.add_argument("stack_name", help="Name of the stack")
2034
- checkout_parser.add_argument("version", help="Stack version to cat")
2274
+ checkout_parser.add_argument("version", help="Stack version to deploy")
2035
2275
  _add_namespace_argument(checkout_parser)
2036
2276
 
2037
2277
  # version_parser = subparsers.add_parser("version",help="Deploy specific version of the stack")
2038
2278
  # version_parser.add_argument("stack_name", help="Name of the stack")
2039
2279
  # version_parser.add_argument("version","versions", help="Stack version to cat")
2040
2280
 
2041
- version_parser = subparsers.add_parser("version", aliases=["versions"], help="Deploy specific version of the stack")
2281
+ version_parser = subparsers.add_parser("version", aliases=["versions"], help="List recorded versions of the stack")
2042
2282
  version_parser.add_argument("stack_name", help="Name of the stack")
2043
2283
  _add_namespace_argument(version_parser)
2044
2284
 
@@ -0,0 +1,134 @@
1
+ """Terminal prompt shown while a deploy waits for another run of the same stack.
2
+
3
+ Docker-Manager refuses to queue interactive applies: it answers ``409
4
+ deployment_in_progress`` and leaves the choice between waiting and aborting to
5
+ whoever is on the other end. On a CLI that is the person at the terminal, so
6
+ while the client polls for the stack to free up this prompt listens for two
7
+ Enter presses in quick succession and reports ``"force"``; the caller then asks
8
+ the manager to abort the running deployment. Ctrl+C is left alone and keeps
9
+ exiting the CLI.
10
+
11
+ Enter is line-oriented, so no terminal mode is changed and there is nothing to
12
+ restore on the way out. The prompt is only created when both stdin and stderr
13
+ are terminals and the process is in the foreground; anywhere else (CI, pipes,
14
+ the GitHub Action's heredoc) the caller simply sleeps between polls.
15
+ """
16
+
17
+ import io
18
+ import os
19
+ import select
20
+ import sys
21
+ import time
22
+ from typing import Any, Optional
23
+
24
+ DOUBLE_ENTER_SECS = 3.0
25
+ WINDOWS_POLL_SECS = 0.1
26
+ HINT = "[manager] press Enter twice to force your deploy (aborts that run; changes it already made to the daemon stay), Ctrl+C to quit"
27
+ SECOND_ENTER_HINT = f"[manager] press Enter again within {int(DOUBLE_ENTER_SECS)}s to force the deploy"
28
+
29
+ ENTER = "enter"
30
+ OTHER = "other"
31
+ TIMEOUT = "timeout"
32
+ EOF = "eof"
33
+
34
+
35
+ class _PosixEnterReader:
36
+ def __init__(self, stdin, fd: int):
37
+ self._stdin = stdin
38
+ self._fd = fd
39
+
40
+ def wait_for_enter(self, timeout: float) -> str:
41
+ try:
42
+ readable, _, _ = select.select([self._fd], [], [], max(0.0, timeout))
43
+ except (OSError, ValueError):
44
+ return EOF
45
+ if not readable:
46
+ return TIMEOUT
47
+ line = self._stdin.readline()
48
+ if line == "":
49
+ return EOF
50
+ return ENTER if line.strip() == "" else OTHER
51
+
52
+
53
+ class _WindowsEnterReader:
54
+ def __init__(self, msvcrt_module):
55
+ self._msvcrt = msvcrt_module
56
+
57
+ def wait_for_enter(self, timeout: float) -> str:
58
+ deadline = time.monotonic() + max(0.0, timeout)
59
+ while True:
60
+ if self._msvcrt.kbhit():
61
+ char = self._msvcrt.getwch()
62
+ return ENTER if char in ("\r", "\n") else OTHER
63
+ remaining = deadline - time.monotonic()
64
+ if remaining <= 0:
65
+ return TIMEOUT
66
+ time.sleep(min(WINDOWS_POLL_SECS, remaining))
67
+
68
+
69
+ class ForceDeployPrompt:
70
+ """Listens for a double Enter between polls; see the module docstring."""
71
+
72
+ def __init__(self, stderr, reader):
73
+ self._stderr = stderr
74
+ self._reader = reader
75
+ self._hinted = False
76
+ self._dead = False
77
+ self._first_enter_at: Optional[float] = None
78
+
79
+ @classmethod
80
+ def create(cls) -> Optional["ForceDeployPrompt"]:
81
+ """Build a prompt when a person can see and answer it, else ``None``."""
82
+ stdin, stderr = sys.stdin, sys.stderr
83
+ if stdin is None or stderr is None:
84
+ return None
85
+ try:
86
+ if not stdin.isatty() or not stderr.isatty():
87
+ return None
88
+ fd = stdin.fileno()
89
+ except (AttributeError, ValueError, OSError, io.UnsupportedOperation):
90
+ return None
91
+ if os.name == "nt":
92
+ try:
93
+ import msvcrt # type: ignore[import-not-found]
94
+ except ImportError:
95
+ return None
96
+ return cls(stderr, _WindowsEnterReader(msvcrt))
97
+ try:
98
+ # Reading a terminal from a background job stops the process (SIGTTIN).
99
+ if os.tcgetpgrp(fd) != os.getpgrp():
100
+ return None
101
+ except (AttributeError, OSError):
102
+ return None
103
+ return cls(stderr, _PosixEnterReader(stdin, fd))
104
+
105
+ def _say(self, message: str) -> None:
106
+ print(message, file=self._stderr, flush=True)
107
+
108
+ def wait(self, active: Any, seconds: float) -> Optional[str]:
109
+ """Wait up to ``seconds`` for the next poll; ``"force"`` if Enter was pressed twice."""
110
+ deadline = time.monotonic() + max(0.0, seconds)
111
+ if not self._hinted:
112
+ self._say(HINT)
113
+ self._hinted = True
114
+ while True:
115
+ remaining = deadline - time.monotonic()
116
+ if remaining <= 0:
117
+ return None
118
+ if self._dead:
119
+ time.sleep(remaining)
120
+ return None
121
+ result = self._reader.wait_for_enter(remaining)
122
+ if result == TIMEOUT:
123
+ return None
124
+ if result == EOF:
125
+ self._dead = True
126
+ continue
127
+ if result != ENTER:
128
+ continue
129
+ now = time.monotonic()
130
+ if self._first_enter_at is not None and now - self._first_enter_at <= DOUBLE_ENTER_SECS:
131
+ self._first_enter_at = None
132
+ return "force"
133
+ self._first_enter_at = now
134
+ self._say(SECOND_ENTER_HINT)
docker_stack/helpers.py CHANGED
@@ -1,4 +1,4 @@
1
- import random
1
+ import secrets as _secrets
2
2
  import string
3
3
  import sys
4
4
  from typing import Callable, Dict, List, Optional, Union
@@ -23,13 +23,17 @@ def generate_secret(
23
23
 
24
24
  Returns:
25
25
  A randomly generated secret string.
26
+
27
+ Note:
28
+ Uses the ``secrets`` module rather than ``random``, since these values
29
+ are used as passwords, API keys and tokens.
26
30
  """
27
31
  if length is None:
28
- length = random.randint(12, 20)
32
+ length = 12 + _secrets.randbelow(9) # 12..20 inclusive
29
33
 
30
34
  # Always start with a letter (uppercase or lowercase)
31
35
  first_char_pool = string.ascii_lowercase + string.ascii_uppercase
32
- first_char = random.choice(first_char_pool)
36
+ first_char = _secrets.choice(first_char_pool)
33
37
 
34
38
  # Prepare the character pool for the rest of the secret
35
39
  characters = string.ascii_lowercase
@@ -47,7 +51,7 @@ def generate_secret(
47
51
  raise ValueError("No character types selected for secret generation.")
48
52
 
49
53
  # Generate the rest of the string
50
- secret = [first_char] + [random.choice(characters) for _ in range(length - 1)]
54
+ secret = [first_char] + [_secrets.choice(characters) for _ in range(length - 1)]
51
55
 
52
56
  return "".join(secret)
53
57