docker-stack 2.2.4__tar.gz → 2.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {docker_stack-2.2.4 → docker_stack-2.3.0}/PKG-INFO +13 -1
  2. {docker_stack-2.2.4 → docker_stack-2.3.0}/README.md +12 -0
  3. {docker_stack-2.2.4 → docker_stack-2.3.0}/docker_stack/cli.py +98 -13
  4. docker_stack-2.3.0/docker_stack/conflict_prompt.py +134 -0
  5. {docker_stack-2.2.4 → docker_stack-2.3.0}/docker_stack/manager_api.py +425 -43
  6. {docker_stack-2.2.4 → docker_stack-2.3.0}/docker_stack.egg-info/PKG-INFO +13 -1
  7. {docker_stack-2.2.4 → docker_stack-2.3.0}/docker_stack.egg-info/SOURCES.txt +2 -0
  8. {docker_stack-2.2.4 → docker_stack-2.3.0}/setup.py +1 -1
  9. docker_stack-2.3.0/tests/test_conflict_prompt.py +218 -0
  10. {docker_stack-2.2.4 → docker_stack-2.3.0}/tests/test_docker_stack.py +116 -9
  11. docker_stack-2.3.0/tests/test_manager_api.py +965 -0
  12. docker_stack-2.2.4/tests/test_manager_api.py +0 -467
  13. {docker_stack-2.2.4 → docker_stack-2.3.0}/docker_stack/__init__.py +0 -0
  14. {docker_stack-2.2.4 → docker_stack-2.3.0}/docker_stack/command_runner.py +0 -0
  15. {docker_stack-2.2.4 → docker_stack-2.3.0}/docker_stack/compose.py +0 -0
  16. {docker_stack-2.2.4 → docker_stack-2.3.0}/docker_stack/docker_objects.py +0 -0
  17. {docker_stack-2.2.4 → docker_stack-2.3.0}/docker_stack/envsubst.py +0 -0
  18. {docker_stack-2.2.4 → docker_stack-2.3.0}/docker_stack/envsubst_merge.py +0 -0
  19. {docker_stack-2.2.4 → docker_stack-2.3.0}/docker_stack/helpers.py +0 -0
  20. {docker_stack-2.2.4 → docker_stack-2.3.0}/docker_stack/login.py +0 -0
  21. {docker_stack-2.2.4 → docker_stack-2.3.0}/docker_stack/markers.py +0 -0
  22. {docker_stack-2.2.4 → docker_stack-2.3.0}/docker_stack/merge_conf.py +0 -0
  23. {docker_stack-2.2.4 → docker_stack-2.3.0}/docker_stack/registry.py +0 -0
  24. {docker_stack-2.2.4 → docker_stack-2.3.0}/docker_stack/shell_auth.py +0 -0
  25. {docker_stack-2.2.4 → docker_stack-2.3.0}/docker_stack/url_parser.py +0 -0
  26. {docker_stack-2.2.4 → docker_stack-2.3.0}/docker_stack.egg-info/dependency_links.txt +0 -0
  27. {docker_stack-2.2.4 → docker_stack-2.3.0}/docker_stack.egg-info/entry_points.txt +0 -0
  28. {docker_stack-2.2.4 → docker_stack-2.3.0}/docker_stack.egg-info/requires.txt +0 -0
  29. {docker_stack-2.2.4 → docker_stack-2.3.0}/docker_stack.egg-info/top_level.txt +0 -0
  30. {docker_stack-2.2.4 → docker_stack-2.3.0}/pyproject.toml +0 -0
  31. {docker_stack-2.2.4 → docker_stack-2.3.0}/setup.cfg +0 -0
  32. {docker_stack-2.2.4 → docker_stack-2.3.0}/tests/test_docker_objects.py +0 -0
  33. {docker_stack-2.2.4 → docker_stack-2.3.0}/tests/test_load_env.py +0 -0
  34. {docker_stack-2.2.4 → docker_stack-2.3.0}/tests/test_login.py +0 -0
  35. {docker_stack-2.2.4 → docker_stack-2.3.0}/tests/test_node_ls.py +0 -0
  36. {docker_stack-2.2.4 → docker_stack-2.3.0}/tests/test_shell_auth.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docker-stack
3
- Version: 2.2.4
3
+ Version: 2.3.0
4
4
  Summary: CLI for deploying and managing Docker stacks.
5
5
  Home-page: https://github.com/mesudip/docker-stack
6
6
  Author: Sudip Bhattarai
@@ -213,6 +213,18 @@ are sent unchanged to the Docker CLI.
213
213
  - **Docker Stack Versioning and Config Backup for Rollback:**
214
214
  The utility automatically versions your Docker configs and secrets, allowing for easy tracking of changes and seamless rollbacks to previous states. This provides a safety net for your deployments, ensuring you can always revert to a stable configuration.
215
215
 
216
+ - **Waiting for another deploy of the same stack (Docker-Manager):**
217
+ Docker-Manager runs one apply per stack at a time. When `docker-stack deploy` or `docker-stack checkout` finds another run in progress (a UI deploy, a CI image bump, another operator), it waits and prints who started it:
218
+
219
+ ```
220
+ [manager] waiting: a deploy started by alice 42s ago is still running (waiting up to 300s, set DOCKER_MANAGER_DEPLOY_WAIT_SECS to change)
221
+ [manager] press Enter twice to force your deploy (aborts that run; changes it already made to the daemon stay), Ctrl+C to quit
222
+ ```
223
+
224
+ - It retries every 5 seconds until the stack is free or `DOCKER_MANAGER_DEPLOY_WAIT_SECS` runs out (default: the deploy timeout; `0` fails immediately). CI runs wait the same way, without the prompt.
225
+ - At a terminal, pressing Enter twice within 3 seconds asks the manager to abort the running deployment and deploys as soon as the stack is released. This needs stack deploy permission and is offered once per run. The aborted run stops orchestrating, but daemon requests it already made still complete; your deploy then applies over that state.
226
+ - Ctrl+C exits without touching the other run.
227
+
216
228
  ## Why Use It?
217
229
 
218
230
  Vanilla Docker Stack deployments can sometimes lack the flexibility needed for dynamic environments or robust secret management. This utility bridges those gaps by:
@@ -187,6 +187,18 @@ are sent unchanged to the Docker CLI.
187
187
  - **Docker Stack Versioning and Config Backup for Rollback:**
188
188
  The utility automatically versions your Docker configs and secrets, allowing for easy tracking of changes and seamless rollbacks to previous states. This provides a safety net for your deployments, ensuring you can always revert to a stable configuration.
189
189
 
190
+ - **Waiting for another deploy of the same stack (Docker-Manager):**
191
+ Docker-Manager runs one apply per stack at a time. When `docker-stack deploy` or `docker-stack checkout` finds another run in progress (a UI deploy, a CI image bump, another operator), it waits and prints who started it:
192
+
193
+ ```
194
+ [manager] waiting: a deploy started by alice 42s ago is still running (waiting up to 300s, set DOCKER_MANAGER_DEPLOY_WAIT_SECS to change)
195
+ [manager] press Enter twice to force your deploy (aborts that run; changes it already made to the daemon stay), Ctrl+C to quit
196
+ ```
197
+
198
+ - It retries every 5 seconds until the stack is free or `DOCKER_MANAGER_DEPLOY_WAIT_SECS` runs out (default: the deploy timeout; `0` fails immediately). CI runs wait the same way, without the prompt.
199
+ - At a terminal, pressing Enter twice within 3 seconds asks the manager to abort the running deployment and deploys as soon as the stack is released. This needs stack deploy permission and is offered once per run. The aborted run stops orchestrating, but daemon requests it already made still complete; your deploy then applies over that state.
200
+ - Ctrl+C exits without touching the other run.
201
+
190
202
  ## Why Use It?
191
203
 
192
204
  Vanilla Docker Stack deployments can sometimes lack the flexibility needed for dynamic environments or robust secret management. This utility bridges those gaps by:
@@ -36,12 +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 (
40
- FEATURE_CLUSTER_CONTAINER_CLI,
41
+ DEPLOY_WAIT_ENV,
41
42
  FEATURE_STACK_DEPLOY,
42
43
  FEATURE_STACK_QUERY,
43
44
  ManagerApiClient,
45
+ describe_active_deployment,
46
+ describe_run,
44
47
  discover_manager_client,
48
+ format_deploy_error_body,
45
49
  )
46
50
  from docker_stack.registry import DockerRegistry
47
51
  from .envsubst import LineCheckResult, SubstitutionError, envsubst, envsubst_load_file
@@ -102,15 +106,23 @@ def _write_selected_node(value: Optional[str], display_name: str) -> None:
102
106
  Path(node_state).write_text(f"{display_name}\n", encoding="utf-8")
103
107
 
104
108
 
105
- def _manager_with_cluster_cli() -> ManagerApiClient:
106
- client = discover_manager_client()
107
- if client is None or not client.supports(FEATURE_CLUSTER_CONTAINER_CLI):
108
- raise RuntimeError("Docker-Manager does not support cluster container CLI; upgrade the manager and all node agents")
109
+ def _require_manager() -> ManagerApiClient:
110
+ """Return a client for the Docker-Manager behind this shell, or explain why not.
111
+
112
+ Only the current manager is supported, so there is no capability negotiation here -
113
+ either the target is a Docker-Manager or it is a plain Docker daemon.
114
+ """
115
+ client = discover_manager_client(strict=True)
116
+ if client is None or not client.is_manager_backend():
117
+ raise RuntimeError(
118
+ f"{client.manager_url if client else 'the current Docker endpoint'} is not a "
119
+ "Docker-Manager; node-local commands need one"
120
+ )
109
121
  return client
110
122
 
111
123
 
112
124
  def _select_node(value: str) -> str:
113
- client = _manager_with_cluster_cli()
125
+ client = _require_manager()
114
126
  requested = value.strip()
115
127
  if requested.lower() == "cluster":
116
128
  _write_selected_node(None, "cluster")
@@ -133,6 +145,9 @@ def _select_node(value: str) -> str:
133
145
  raise RuntimeError(f"node '{requested}' is not ready")
134
146
  node_id = str(node.get("id") or "").strip()
135
147
  hostname = str(node.get("hostname") or node_id).strip()
148
+ # Only this node has to be usable. The health of every other node - drained, down, or
149
+ # missing its agent - is none of this command's business.
150
+ client.check_node_agent(node_id or hostname)
136
151
  _write_selected_node(node_id, hostname)
137
152
  return hostname
138
153
 
@@ -339,6 +354,13 @@ def _print_cluster_containers(payload: Dict[str, object], *, no_trunc: bool = Fa
339
354
  columns.append("SIZE")
340
355
  columns.append("NODE")
341
356
  _print_table(columns, rows)
357
+ skipped = payload.get("skipped_nodes") if isinstance(payload.get("skipped_nodes"), list) else []
358
+ for node in skipped:
359
+ if isinstance(node, dict):
360
+ print(
361
+ f"docker ps: skipped node {node.get('node_name') or node.get('node_id')}: {node.get('reason')}",
362
+ file=sys.stderr,
363
+ )
342
364
  errors = payload.get("node_errors") if isinstance(payload.get("node_errors"), list) else []
343
365
  for error in errors:
344
366
  if isinstance(error, dict):
@@ -413,7 +435,7 @@ def _managed_docker(arguments: List[str]) -> int:
413
435
  return _exec_docker(values)
414
436
  try:
415
437
  client = discover_manager_client()
416
- if client is None or not client.supports(FEATURE_CLUSTER_CONTAINER_CLI):
438
+ if client is None or not client.is_manager_backend():
417
439
  return _exec_docker(values)
418
440
  filters = []
419
441
  all_containers = False
@@ -1483,6 +1505,7 @@ class DockerStack:
1483
1505
  options=options,
1484
1506
  )
1485
1507
  else:
1508
+ prompt = ForceDeployPrompt.create()
1486
1509
  try:
1487
1510
  payload = manager_client.deploy_stack_stream(
1488
1511
  stack=stack_name,
@@ -1490,6 +1513,7 @@ class DockerStack:
1490
1513
  compose=rendered_content,
1491
1514
  options=options,
1492
1515
  on_event=DockerStack._print_manager_deploy_event,
1516
+ wait_for_poll=prompt.wait if prompt else None,
1493
1517
  )
1494
1518
  except RuntimeError as exc:
1495
1519
  message = str(exc)
@@ -1554,6 +1578,51 @@ class DockerStack:
1554
1578
  message = data.get("message") or data.get("error") or ""
1555
1579
  if message:
1556
1580
  print(f"[manager] {service}: {status}: {message}", flush=True)
1581
+ elif event_name == "deployment":
1582
+ deployment_id = data.get("deployment_id")
1583
+ if deployment_id:
1584
+ mode = data.get("mode") or "deployment"
1585
+ print(f"[manager] {mode} {deployment_id}", flush=True)
1586
+ elif event_name == "waiting":
1587
+ DockerStack._print_manager_wait(data)
1588
+
1589
+ @staticmethod
1590
+ def _print_manager_wait(active: Dict[str, object]) -> None:
1591
+ """Narrate the wait for another run of this stack, and any attempt to force past it.
1592
+
1593
+ The manager will not queue an interactive deploy for us, so the CLI polls
1594
+ and reports each phase: waiting, forcing (asking the manager to abort the
1595
+ holder), and how that abort ended.
1596
+ """
1597
+ if not isinstance(active, dict):
1598
+ active = {}
1599
+ phase = str(active.get("phase") or "waiting")
1600
+ run = describe_run(active)
1601
+ if phase == "waiting":
1602
+ budget = active.get("wait_budget_secs")
1603
+ suffix = f" (waiting up to {budget}s, set {DEPLOY_WAIT_ENV} to change)" if isinstance(budget, int) else ""
1604
+ message = f"waiting: {describe_active_deployment(active)}{suffix}"
1605
+ elif phase == "forcing":
1606
+ message = f"forcing: asking the manager to abort {run}"
1607
+ elif phase == "aborted":
1608
+ message = f"aborted {run}; deploying now"
1609
+ elif phase == "aborted_other":
1610
+ message = f"aborted a different run than shown: {run}; deploying now"
1611
+ elif phase == "free":
1612
+ message = "the stack is free; deploying now"
1613
+ elif phase == "not_released":
1614
+ message = f"asked the manager to abort {run}, but it has not released the stack yet; waiting for it to finish"
1615
+ elif phase == "forbidden":
1616
+ message = f"you do not have permission to abort {run}; still waiting"
1617
+ elif phase == "still_held":
1618
+ message = f"the stack is still held by {run}; waiting"
1619
+ elif phase == "force_used":
1620
+ message = "force was already used once in this run; waiting for the stack to free up"
1621
+ elif phase == "interrupted":
1622
+ message = "interrupted while asking the manager to abort; that request may still complete"
1623
+ else:
1624
+ message = f"{phase}: {run}"
1625
+ print(f"[manager] {message}", file=sys.stderr, flush=True)
1557
1626
 
1558
1627
  @staticmethod
1559
1628
  def _rollback_via_manager(
@@ -1563,10 +1632,13 @@ class DockerStack:
1563
1632
  namespace: str,
1564
1633
  version: str,
1565
1634
  ) -> Optional[str]:
1635
+ prompt = ForceDeployPrompt.create()
1566
1636
  payload = manager_client.rollback_stack(
1567
1637
  stack=stack_name,
1568
1638
  namespace=namespace,
1569
1639
  version=version,
1640
+ on_wait=DockerStack._print_manager_wait,
1641
+ wait_for_poll=prompt.wait if prompt else None,
1570
1642
  )
1571
1643
  warnings = payload.get("warnings") or []
1572
1644
  for warning in warnings:
@@ -1851,7 +1923,7 @@ def _humanize_manager_error(message: str) -> str:
1851
1923
  return message
1852
1924
  if not isinstance(body, dict):
1853
1925
  return message
1854
- detail = str(body.get("message", "")).strip()
1926
+ detail = format_deploy_error_body(body) if body.get("code") else str(body.get("message", "")).strip()
1855
1927
  if not detail:
1856
1928
  return message
1857
1929
  incident = str(body.get("incident_id") or body.get("trace_id") or "").strip()
@@ -1879,6 +1951,17 @@ def _manager_deploy_suggestion(message: str) -> Optional[str]:
1879
1951
  "by this stack. Either allow that external network in the Docker-Manager deployment rule, attach the service to "
1880
1952
  "a stack-owned network, or relabel/recreate the existing network with the expected stack ownership before deploying."
1881
1953
  )
1954
+ if "is still running" in lowered and ("gave up after waiting" in lowered or "http 409" in lowered):
1955
+ return (
1956
+ "Suggestion: another run holds this stack's deploy lock. Rerun and let it wait, raise "
1957
+ f"{DEPLOY_WAIT_ENV} to queue longer, press Enter twice while waiting at a terminal to abort that run "
1958
+ "and force your deploy, or abort it from the Docker-Manager stack page."
1959
+ )
1960
+ if "failed services:" in lowered:
1961
+ return (
1962
+ "Suggestion: every service operation has finished; the failed ones are listed above with their incident ids. "
1963
+ "Fix the cause and redeploy, or use the rollback offered on the Docker-Manager stack page to restore the previous specs."
1964
+ )
1882
1965
  if "stack not found" in lowered:
1883
1966
  return (
1884
1967
  "Suggestion: the manager was reached but has no stack by that name in that namespace. "
@@ -1901,9 +1984,10 @@ def _manager_deploy_suggestion(message: str) -> Optional[str]:
1901
1984
  def _format_called_process_error(exc: subprocess.CalledProcessError) -> str:
1902
1985
  output = _error_output(exc)
1903
1986
  lines = [f"docker-stack: command failed with exit code {exc.returncode}: {_format_command(exc.cmd)}"]
1904
- if output:
1905
- lines.append(_humanize_manager_error(output))
1906
- suggestion = _manager_deploy_suggestion(output)
1987
+ humanized = _humanize_manager_error(output) if output else ""
1988
+ if humanized:
1989
+ lines.append(humanized)
1990
+ suggestion = _manager_deploy_suggestion(humanized or output)
1907
1991
  if suggestion:
1908
1992
  lines.extend(["", suggestion])
1909
1993
  return "\n".join(lines)
@@ -1911,8 +1995,9 @@ def _format_called_process_error(exc: subprocess.CalledProcessError) -> str:
1911
1995
 
1912
1996
  def _format_runtime_error(exc: RuntimeError) -> str:
1913
1997
  message = str(exc).strip() or exc.__class__.__name__
1914
- lines = [f"docker-stack: {_humanize_manager_error(message)}"]
1915
- suggestion = _manager_deploy_suggestion(message)
1998
+ humanized = _humanize_manager_error(message)
1999
+ lines = [f"docker-stack: {humanized}"]
2000
+ suggestion = _manager_deploy_suggestion(humanized)
1916
2001
  if suggestion:
1917
2002
  lines.extend(["", suggestion])
1918
2003
  return "\n".join(lines)
@@ -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)