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 +252 -12
- docker_stack/conflict_prompt.py +134 -0
- docker_stack/helpers.py +8 -4
- docker_stack/manager_api.py +384 -41
- docker_stack-2.3.1.dist-info/METADATA +392 -0
- {docker_stack-2.2.5.dist-info → docker_stack-2.3.1.dist-info}/RECORD +9 -8
- docker_stack-2.2.5.dist-info/METADATA +0 -399
- {docker_stack-2.2.5.dist-info → docker_stack-2.3.1.dist-info}/WHEEL +0 -0
- {docker_stack-2.2.5.dist-info → docker_stack-2.3.1.dist-info}/entry_points.txt +0 -0
- {docker_stack-2.2.5.dist-info → docker_stack-2.3.1.dist-info}/top_level.txt +0 -0
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1923
|
-
|
|
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
|
-
|
|
1932
|
-
|
|
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
|
|
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="
|
|
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
|
|
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 =
|
|
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 =
|
|
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] + [
|
|
54
|
+
secret = [first_char] + [_secrets.choice(characters) for _ in range(length - 1)]
|
|
51
55
|
|
|
52
56
|
return "".join(secret)
|
|
53
57
|
|