docker-stack 2.3.2__tar.gz → 2.3.3__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 (35) hide show
  1. {docker_stack-2.3.2 → docker_stack-2.3.3}/PKG-INFO +1 -1
  2. {docker_stack-2.3.2 → docker_stack-2.3.3}/docker_stack/cli.py +31 -33
  3. {docker_stack-2.3.2 → docker_stack-2.3.3}/docker_stack/envsubst.py +69 -21
  4. {docker_stack-2.3.2 → docker_stack-2.3.3}/docker_stack/login.py +2 -9
  5. {docker_stack-2.3.2 → docker_stack-2.3.3}/docker_stack/manager_api.py +6 -20
  6. {docker_stack-2.3.2 → docker_stack-2.3.3}/docker_stack/shell_auth.py +6 -10
  7. {docker_stack-2.3.2 → docker_stack-2.3.3}/docker_stack.egg-info/PKG-INFO +1 -1
  8. {docker_stack-2.3.2 → docker_stack-2.3.3}/setup.py +1 -1
  9. {docker_stack-2.3.2 → docker_stack-2.3.3}/tests/test_docker_stack.py +87 -12
  10. {docker_stack-2.3.2 → docker_stack-2.3.3}/tests/test_login.py +9 -3
  11. {docker_stack-2.3.2 → docker_stack-2.3.3}/tests/test_manager_api.py +8 -5
  12. {docker_stack-2.3.2 → docker_stack-2.3.3}/tests/test_shell_auth.py +13 -7
  13. {docker_stack-2.3.2 → docker_stack-2.3.3}/README.md +0 -0
  14. {docker_stack-2.3.2 → docker_stack-2.3.3}/docker_stack/__init__.py +0 -0
  15. {docker_stack-2.3.2 → docker_stack-2.3.3}/docker_stack/command_runner.py +0 -0
  16. {docker_stack-2.3.2 → docker_stack-2.3.3}/docker_stack/compose.py +0 -0
  17. {docker_stack-2.3.2 → docker_stack-2.3.3}/docker_stack/conflict_prompt.py +0 -0
  18. {docker_stack-2.3.2 → docker_stack-2.3.3}/docker_stack/docker_objects.py +0 -0
  19. {docker_stack-2.3.2 → docker_stack-2.3.3}/docker_stack/envsubst_merge.py +0 -0
  20. {docker_stack-2.3.2 → docker_stack-2.3.3}/docker_stack/helpers.py +0 -0
  21. {docker_stack-2.3.2 → docker_stack-2.3.3}/docker_stack/markers.py +0 -0
  22. {docker_stack-2.3.2 → docker_stack-2.3.3}/docker_stack/merge_conf.py +0 -0
  23. {docker_stack-2.3.2 → docker_stack-2.3.3}/docker_stack/registry.py +0 -0
  24. {docker_stack-2.3.2 → docker_stack-2.3.3}/docker_stack/url_parser.py +0 -0
  25. {docker_stack-2.3.2 → docker_stack-2.3.3}/docker_stack.egg-info/SOURCES.txt +0 -0
  26. {docker_stack-2.3.2 → docker_stack-2.3.3}/docker_stack.egg-info/dependency_links.txt +0 -0
  27. {docker_stack-2.3.2 → docker_stack-2.3.3}/docker_stack.egg-info/entry_points.txt +0 -0
  28. {docker_stack-2.3.2 → docker_stack-2.3.3}/docker_stack.egg-info/requires.txt +0 -0
  29. {docker_stack-2.3.2 → docker_stack-2.3.3}/docker_stack.egg-info/top_level.txt +0 -0
  30. {docker_stack-2.3.2 → docker_stack-2.3.3}/pyproject.toml +0 -0
  31. {docker_stack-2.3.2 → docker_stack-2.3.3}/setup.cfg +0 -0
  32. {docker_stack-2.3.2 → docker_stack-2.3.3}/tests/test_conflict_prompt.py +0 -0
  33. {docker_stack-2.3.2 → docker_stack-2.3.3}/tests/test_docker_objects.py +0 -0
  34. {docker_stack-2.3.2 → docker_stack-2.3.3}/tests/test_load_env.py +0 -0
  35. {docker_stack-2.3.2 → docker_stack-2.3.3}/tests/test_node_ls.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docker-stack
3
- Version: 2.3.2
3
+ Version: 2.3.3
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
@@ -48,7 +48,16 @@ from docker_stack.manager_api import (
48
48
  format_deploy_error_body,
49
49
  )
50
50
  from docker_stack.registry import DockerRegistry
51
- from .envsubst import LineCheckResult, SubstitutionError, envsubst, envsubst_load_file
51
+ from .envsubst import (
52
+ ENV_DEFAULT_OPERATORS,
53
+ ENV_VAR_PATTERN,
54
+ LineCheckResult,
55
+ SubstitutionError,
56
+ envsubst,
57
+ envsubst_load_file,
58
+ ref_parts,
59
+ resolve_env_ref,
60
+ )
52
61
 
53
62
  DOCKER_SHELL_ENDPOINT_ENV_VARS = (
54
63
  "DOCKER_HOST",
@@ -115,8 +124,7 @@ def _require_manager() -> ManagerApiClient:
115
124
  client = discover_manager_client(strict=True)
116
125
  if client is None or not client.is_manager_backend():
117
126
  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"
127
+ f"{client.manager_url if client else 'the current Docker endpoint'} is not a " "Docker-Manager; node-local commands need one"
120
128
  )
121
129
  return client
122
130
 
@@ -130,10 +138,7 @@ def _select_node(value: str) -> str:
130
138
  nodes = client.list_nodes().get("nodes", [])
131
139
  exact = [node for node in nodes if str(node.get("id") or "") == requested]
132
140
  candidates = exact or [
133
- node
134
- for node in nodes
135
- if str(node.get("id") or "").startswith(requested)
136
- or str(node.get("hostname") or "") == requested
141
+ node for node in nodes if str(node.get("id") or "").startswith(requested) or str(node.get("hostname") or "") == requested
137
142
  ]
138
143
  if not candidates:
139
144
  raise RuntimeError(f"node '{requested}' is not visible or does not exist")
@@ -424,14 +429,8 @@ def _managed_docker(arguments: List[str]) -> int:
424
429
  or (value.startswith("-c") and value != "-c")
425
430
  for value in global_options
426
431
  )
427
- is_ps = bool(command) and (
428
- command[0] == "ps"
429
- or (len(command) >= 2 and command[0] == "container" and command[1] in {"ls", "list"})
430
- )
431
- if explicit_target or not is_ps or any(
432
- value in {"-q", "--quiet", "--format"} or value.startswith("--format=")
433
- for value in command
434
- ):
432
+ is_ps = bool(command) and (command[0] == "ps" or (len(command) >= 2 and command[0] == "container" and command[1] in {"ls", "list"}))
433
+ if explicit_target or not is_ps or any(value in {"-q", "--quiet", "--format"} or value.startswith("--format=") for value in command):
435
434
  return _exec_docker(values)
436
435
  try:
437
436
  client = discover_manager_client()
@@ -517,8 +516,7 @@ def _add_listing_namespace_arguments(parser: argparse.ArgumentParser) -> None:
517
516
  def _prompt_for_manager_target(context_name: str) -> str:
518
517
  if not sys.stdin.isatty():
519
518
  raise RuntimeError(
520
- f"Unknown shell context '{context_name}'. Run "
521
- f"'docker-stack shell --context {context_name} <manager-url>' to create it."
519
+ f"Unknown shell context '{context_name}'. Run " f"'docker-stack shell --context {context_name} <manager-url>' to create it."
522
520
  )
523
521
  print(f"Docker context '{context_name}' does not exist yet.")
524
522
  print("Enter the Docker-Manager URL to create it (for example https://manager.example.com:2376).")
@@ -571,7 +569,9 @@ class EnvFileResolutionError(Exception):
571
569
  return "\n".join(formatted_lines)
572
570
 
573
571
 
574
- ENV_VAR_PATTERN = re.compile(r"\$\{([^}:\s]+)(?::-(.*?))?\}|\$([a-zA-Z_][a-zA-Z0-9_]*)")
572
+ # The compose interpolation grammar is defined once in envsubst.py and
573
+ # imported above; see the note there for why a second copy is a deploy bug.
574
+ _ref_name_operator_operand = ref_parts
575
575
 
576
576
 
577
577
  def _strip_matching_quotes(value: str) -> Tuple[str, int]:
@@ -614,8 +614,8 @@ def _parse_env_file(env_file: str) -> Tuple[List[EnvFileEntry], List[str]]:
614
614
  def _extract_refs(value: str) -> List[Tuple[str, int]]:
615
615
  refs = []
616
616
  for match in ENV_VAR_PATTERN.finditer(value):
617
- var = match.group(1) if match.group(1) is not None else match.group(3)
618
- start = match.start(1) if match.group(1) is not None else match.start(3)
617
+ var, _operator, _operand = _ref_name_operator_operand(match)
618
+ start = match.start(1) if match.group(1) is not None else match.start(4)
619
619
  refs.append((var, start))
620
620
  return refs
621
621
 
@@ -703,9 +703,9 @@ def load_env_file(env_file: str, base_env: Optional[Dict[str, str]] = None, max_
703
703
  return _resolve_env_entries(entries, env_file, base_env=base_env, max_cycles=max_cycles)
704
704
 
705
705
 
706
-
707
-
708
- _ENV_REF_PATTERN = re.compile(r"\$\{([^}:\s]+)(?::-(.*?))?\}|\$([a-zA-Z_][a-zA-Z0-9_]*)")
706
+ # One pattern, not a second copy: a reference form recognised here but not by
707
+ # the `.env` collector expands locally while its variable is never shipped.
708
+ _ENV_REF_PATTERN = ENV_VAR_PATTERN
709
709
  _ESCAPED_DOLLAR = "$_ESCAPED_DOLLAR_"
710
710
 
711
711
 
@@ -721,13 +721,13 @@ def _substitute_selected_vars(text: str, names: Set[str], replacements: Dict[str
721
721
  guarded = text.replace("$$", _ESCAPED_DOLLAR)
722
722
 
723
723
  def replace(match: "re.Match[str]") -> str:
724
- var = match.group(1) if match.group(1) is not None else match.group(3)
724
+ var, operator, operand = ref_parts(match)
725
725
  if var not in names:
726
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, ""):
727
+ value = resolve_env_ref(var, operator, operand, os.environ)
728
+ # Unresolvable: left standing so the manager reports it against the
729
+ # stack, rather than silently deploying an empty value.
730
+ if value is None:
731
731
  return match.group(0)
732
732
  for old, new in replacements.items():
733
733
  value = value.replace(old, new)
@@ -907,8 +907,8 @@ class DockerStack:
907
907
  @staticmethod
908
908
  def _iter_env_refs(value: str):
909
909
  for match in ENV_VAR_PATTERN.finditer(value):
910
- name = match.group(1) if match.group(1) is not None else match.group(3)
911
- default = match.group(2) if match.group(1) is not None else None
910
+ name, operator, operand = _ref_name_operator_operand(match)
911
+ default = operand if operator in ENV_DEFAULT_OPERATORS else None
912
912
  yield name, default
913
913
 
914
914
  @staticmethod
@@ -1573,9 +1573,7 @@ class DockerStack:
1573
1573
 
1574
1574
  manager_deploy = self._manager_client_for_feature(FEATURE_STACK_DEPLOY)
1575
1575
  if manager_deploy and tag:
1576
- raise RuntimeError(
1577
- "Docker-Manager stack deploy does not expose deployment tags; refusing to silently ignore --tag"
1578
- )
1576
+ raise RuntimeError("Docker-Manager stack deploy does not expose deployment tags; refusing to silently ignore --tag")
1579
1577
  manager_options = {}
1580
1578
  if with_registry_auth:
1581
1579
  manager_options["with_registry_auth"] = True
@@ -8,7 +8,9 @@ DESCRIPTION
8
8
 
9
9
  supported syntax:
10
10
  normal - ${VARIABLE1} or $VARIABLE1
11
- with default - ${VARIABLE1:-somevalue}
11
+ with default - ${VARIABLE1:-somevalue} or ${VARIABLE1-somevalue}
12
+ required - ${VARIABLE1:?message} or ${VARIABLE1?message}
13
+ alternate - ${VARIABLE1:+somevalue} or ${VARIABLE1+somevalue}
12
14
  """
13
15
 
14
16
  from dataclasses import dataclass
@@ -18,6 +20,62 @@ import sys
18
20
  from typing import Dict, List, Literal, Optional
19
21
 
20
22
 
23
+ # The compose interpolation grammar, defined once.
24
+ #
25
+ # Docker-Manager resolves these same forms server-side, and the CLI leaves
26
+ # references standing for it to resolve, so three places have to agree on what
27
+ # a reference is: this substituter, the stack `.env` collector, and local
28
+ # expansion of secret-sourced variables. A form one of them fails to recognise
29
+ # is collected by nobody and the deploy dies with `missing template variable`
30
+ # naming a variable that was set the whole time.
31
+ #
32
+ # The name is restricted to `[A-Za-z0-9_]` on purpose. A looser class swallows
33
+ # the operator: `${VAR-default}` under `[^}:\s]+` parses as a variable
34
+ # literally named `VAR-default`, which is never set.
35
+ #
36
+ # Groups: 1 name, 2 operator, 3 operand, 4 bare `$NAME`. Colon forms lead the
37
+ # alternation so `:-` is never read as a bare `-`.
38
+ ENV_REF_RE = r"\$\{([A-Za-z0-9_]+)(?:(:-|:\?|:\+|-|\?|\+)(.*?))?\}|\$([a-zA-Z_][a-zA-Z0-9_]*)"
39
+
40
+ ENV_VAR_PATTERN = re.compile(ENV_REF_RE)
41
+
42
+ # Operators whose operand stands in for the variable's own value. `:?`/`?` take
43
+ # an error message and `:+`/`+` an alternate used only when the variable IS set,
44
+ # so treating either as a default ships a value the author never wrote.
45
+ ENV_DEFAULT_OPERATORS = frozenset({":-", "-"})
46
+
47
+
48
+ def ref_parts(match: "re.Match[str]"):
49
+ """Splits an ENV_VAR_PATTERN match into (name, operator, operand)."""
50
+ if match.group(1) is not None:
51
+ return match.group(1), match.group(2), match.group(3)
52
+ return match.group(4), None, None
53
+
54
+
55
+ def resolve_env_ref(name: str, operator: Optional[str], operand: Optional[str], env) -> Optional[str]:
56
+ """Resolves one reference, mirroring the manager's `substitute_template`.
57
+
58
+ Returns None when the reference cannot be resolved, which the caller
59
+ reports rather than substituting an empty value.
60
+ """
61
+ existing = env.get(name, None)
62
+ is_set = existing is not None
63
+ is_nonempty = bool(existing)
64
+ if operator == ":-":
65
+ return existing if is_nonempty else operand
66
+ if operator == "-":
67
+ return existing if is_set else operand
68
+ if operator == ":+":
69
+ return operand if is_nonempty else ""
70
+ if operator == "+":
71
+ return operand if is_set else ""
72
+ if operator == ":?":
73
+ return existing if is_nonempty else None
74
+ if operator == "?":
75
+ return existing if is_set else None
76
+ return existing if is_nonempty else None
77
+
78
+
21
79
  @dataclass
22
80
  class LineCheckResult:
23
81
  line_no: int
@@ -102,8 +160,10 @@ class SubstitutionError(Exception):
102
160
  def envsubst(template_str, env=os.environ, replacements: Dict[str, str] = None, on_error: Literal["exit", "throw"] = "exit"):
103
161
  """Substitute environment variables in the template string, supporting default values."""
104
162
 
105
- # Combined regex for ${VAR:-default} and $VAR, and also $$
106
- pattern = re.compile(r"\$\_ESCAPED_DOLLAR_|\$\{([^}:\s]+)(?::-(.*?))?\}|\$([a-zA-Z_][a-zA-Z0-9_]*)")
163
+ # The shared grammar, with the escaped-dollar placeholder in front. That
164
+ # alternative captures nothing, so the group numbers stay as ENV_REF_RE
165
+ # defines them.
166
+ pattern = re.compile(r"\$\_ESCAPED_DOLLAR_|" + ENV_REF_RE)
107
167
 
108
168
  # Handle escaped dollars
109
169
  template_str = template_str.replace("$$", "$_ESCAPED_DOLLAR_")
@@ -120,24 +180,12 @@ def envsubst(template_str, env=os.environ, replacements: Dict[str, str] = None,
120
180
  def replacer(match: re.Match[str]):
121
181
  if match.group(0) == "$_ESCAPED_DOLLAR_":
122
182
  return "$$"
123
- # Group 1, 2 for ${VAR:-default}
124
- if match.group(1) is not None:
125
- var = match.group(1)
126
- has_default = match.group(2) is not None
127
- default_value = match.group(2) if has_default else None
128
- result = env.get(var, None)
129
- if result in (None, "") and has_default:
130
- result = default_value
131
- if result in (None, "") and not has_default:
132
- line_errors_raw.append((var, match.start(1))) # Use match.start(1) for ${VAR}
133
- return match.group(0) # Keep original if variable not found
134
- # Group 3 for $VAR
135
- else:
136
- var = match.group(3)
137
- result = env.get(var, None)
138
- if result in (None, ""):
139
- line_errors_raw.append((var, match.start(3))) # Use match.start(3) for $VAR
140
- return match.group(0) # Keep original if variable not found
183
+ var, operator, operand = ref_parts(match)
184
+ start = match.start(1) if match.group(1) is not None else match.start(4)
185
+ result = resolve_env_ref(var, operator, operand, env)
186
+ if result is None:
187
+ line_errors_raw.append((var, start))
188
+ return match.group(0) # Keep original if variable not found
141
189
 
142
190
  if replacements:
143
191
  for old, new in replacements.items():
@@ -466,12 +466,7 @@ def resolve_login_config(
466
466
  inferred_context_name = manager_target
467
467
  if isolated_context_target:
468
468
  resolved_docker_config_dir = isolated_config_dir
469
- endpoint_context_name = (
470
- context_name
471
- or os.getenv("DOCKER_MANAGER_CONTEXT_NAME")
472
- or inferred_context_name
473
- or current_context_name
474
- )
469
+ endpoint_context_name = context_name or os.getenv("DOCKER_MANAGER_CONTEXT_NAME") or inferred_context_name or current_context_name
475
470
  if manager_target:
476
471
  resolved_manager_url, skip_tls_verify = _resolve_manager_endpoint(
477
472
  raw_manager_value,
@@ -1038,9 +1033,7 @@ def browser_login(
1038
1033
  refresh_token = str(refresh_token) if isinstance(refresh_token, str) and refresh_token.strip() else None
1039
1034
  refresh_expires_in = token_response.get("refresh_expires_in")
1040
1035
  refresh_expires_at = (
1041
- int(time.time()) + int(refresh_expires_in)
1042
- if isinstance(refresh_expires_in, int) and refresh_expires_in > 0
1043
- else None
1036
+ int(time.time()) + int(refresh_expires_in) if isinstance(refresh_expires_in, int) and refresh_expires_in > 0 else None
1044
1037
  )
1045
1038
  return DockerManagerLoginResult(
1046
1039
  access_token=access_token,
@@ -189,11 +189,7 @@ def _public_deploy_options(options: Optional[Dict[str, Any]], compose: str) -> O
189
189
  def _public_image_deploy_options(options: Optional[Dict[str, Any]], images: Dict[str, str]) -> Optional[Dict[str, Any]]:
190
190
  prepared = dict(options or {})
191
191
  if prepared.get("with_registry_auth"):
192
- registries = {
193
- _image_registry(image.strip())
194
- for image in images.values()
195
- if isinstance(image, str) and image.strip()
196
- }
192
+ registries = {_image_registry(image.strip()) for image in images.values() if isinstance(image, str) and image.strip()}
197
193
  registry_auth = _docker_config_registry_auths(registries)
198
194
  if registry_auth:
199
195
  prepared["registry_auth"] = registry_auth
@@ -711,9 +707,7 @@ class ManagerApiClient:
711
707
  raise
712
708
  now = time.monotonic()
713
709
  if on_wait and (last_notice is None or now - last_notice >= DEPLOY_WAIT_NOTICE_SECS):
714
- on_wait(
715
- {**exc.active_deployment, "phase": "waiting", "waited_secs": waited, "wait_budget_secs": budget}
716
- )
710
+ on_wait({**exc.active_deployment, "phase": "waiting", "waited_secs": waited, "wait_budget_secs": budget})
717
711
  last_notice = now
718
712
  if wait_for_poll is None:
719
713
  time.sleep(DEPLOY_WAIT_POLL_SECS)
@@ -759,9 +753,7 @@ class ManagerApiClient:
759
753
  return True
760
754
 
761
755
  def list_stacks(self, *, namespace: Optional[str] = DEFAULT_NAMESPACE) -> Dict[str, Any]:
762
- query = urllib.parse.urlencode(
763
- {"namespace": namespace} if namespace is not None else {"all_namespaces": "true"}
764
- )
756
+ query = urllib.parse.urlencode({"namespace": namespace} if namespace is not None else {"all_namespaces": "true"})
765
757
  if self._detect_manager_backend():
766
758
  return self._request_json(f"/api/docker-stack/stacks?{query}")
767
759
  return self._request_json(f"{self._endpoint_path('/inventory/stacks')}?{query}")
@@ -858,9 +850,7 @@ class ManagerApiClient:
858
850
  params["limit"] = str(limit)
859
851
  if latest:
860
852
  params["latest"] = "1"
861
- payload = self._request_json(
862
- f"/api/docker-stack/containers?{urllib.parse.urlencode(params)}"
863
- )
853
+ payload = self._request_json(f"/api/docker-stack/containers?{urllib.parse.urlencode(params)}")
864
854
  if not isinstance(payload, dict) or not isinstance(payload.get("containers"), list):
865
855
  raise RuntimeError("Docker-Manager containers response is invalid")
866
856
  return payload
@@ -1080,9 +1070,7 @@ class ManagerApiClient:
1080
1070
  )
1081
1071
 
1082
1072
 
1083
- def discover_manager_client(
1084
- timeout_secs: int = 5, *, strict: bool = False
1085
- ) -> Optional[ManagerApiClient]:
1073
+ def discover_manager_client(timeout_secs: int = 5, *, strict: bool = False) -> Optional[ManagerApiClient]:
1086
1074
  """Build a client for the manager behind DOCKER_MANAGER_URL or the Docker context.
1087
1075
 
1088
1076
  ``strict`` raises the concrete reason instead of returning ``None``. Callers that
@@ -1105,9 +1093,7 @@ def discover_manager_client(
1105
1093
  config = resolve_login_config(manager_target=context_target)
1106
1094
  else:
1107
1095
  if strict:
1108
- raise RuntimeError(
1109
- "no Docker-Manager endpoint found: DOCKER_MANAGER_URL is unset and the docker CLI is not installed"
1110
- )
1096
+ raise RuntimeError("no Docker-Manager endpoint found: DOCKER_MANAGER_URL is unset and the docker CLI is not installed")
1111
1097
  return None
1112
1098
  except RuntimeError:
1113
1099
  if strict:
@@ -542,13 +542,11 @@ def _write_shell_wrappers(
542
542
  zsh_dir = session_dir / "zsh"
543
543
  zsh_dir.mkdir(mode=0o700)
544
544
  original_bashrc = Path.home() / ".bashrc"
545
- original_zshrc_root = Path(
546
- os.getenv(SHELL_ORIGINAL_ZDOTDIR_ENV) or os.getenv("ZDOTDIR", str(Path.home()))
547
- )
545
+ original_zshrc_root = Path(os.getenv(SHELL_ORIGINAL_ZDOTDIR_ENV) or os.getenv("ZDOTDIR", str(Path.home())))
548
546
  original_zshrc = original_zshrc_root / ".zshrc"
549
547
  original_zsh_history = original_zshrc_root / ".zsh_history"
550
548
  bash_rc.write_text(
551
- f'''[ -f {json.dumps(str(original_bashrc))} ] && source {json.dumps(str(original_bashrc))}
549
+ f"""[ -f {json.dumps(str(original_bashrc))} ] && source {json.dumps(str(original_bashrc))}
552
550
  __docker_stack_base_ps1="$PS1"
553
551
  __docker_stack_prompt_update() {{
554
552
  local state expires now color reset node
@@ -570,13 +568,13 @@ __docker_stack_prompt_update() {{
570
568
  }}
571
569
  case ";${{PROMPT_COMMAND:-}};" in *';__docker_stack_prompt_update;'*) ;; *) PROMPT_COMMAND="__docker_stack_prompt_update${{PROMPT_COMMAND:+;$PROMPT_COMMAND}}";; esac
572
570
  docker() {{ docker-stack shell-auth ensure || return $?; docker-stack docker -- "$@"; }}
573
- ''',
571
+ """,
574
572
  encoding="utf-8",
575
573
  )
576
574
  os.chmod(bash_rc, 0o600)
577
575
  zsh_rc = zsh_dir / ".zshrc"
578
576
  zsh_rc.write_text(
579
- f'''typeset -g __docker_stack_session_zdotdir="$ZDOTDIR"
577
+ f"""typeset -g __docker_stack_session_zdotdir="$ZDOTDIR"
580
578
  typeset -g ZDOTDIR={json.dumps(str(original_zshrc_root))}
581
579
  typeset -g HISTFILE={json.dumps(str(original_zsh_history))}
582
580
  typeset -g SHELL_SESSION_HISTORY=0
@@ -606,7 +604,7 @@ __docker_stack_prompt_update() {{
606
604
  }}
607
605
  add-zsh-hook precmd __docker_stack_prompt_update
608
606
  docker() {{ docker-stack shell-auth ensure || return $?; docker-stack docker -- "$@"; }}
609
- ''',
607
+ """,
610
608
  encoding="utf-8",
611
609
  )
612
610
  os.chmod(zsh_rc, 0o600)
@@ -738,9 +736,7 @@ def run_managed_shell(config: DockerManagerLoginConfig, result: DockerManagerLog
738
736
  # need to re-probe it (a verified probe against a private CA only
739
737
  # produces aborted TLS connections on the manager).
740
738
  env[DOCKER_MANAGER_SKIP_TLS_VERIFY_ENV] = "1" if config.skip_tls_verify else "0"
741
- env[SHELL_ORIGINAL_ZDOTDIR_ENV] = str(
742
- Path(os.getenv(SHELL_ORIGINAL_ZDOTDIR_ENV) or os.getenv("ZDOTDIR", str(Path.home())))
743
- )
739
+ env[SHELL_ORIGINAL_ZDOTDIR_ENV] = str(Path(os.getenv(SHELL_ORIGINAL_ZDOTDIR_ENV) or os.getenv("ZDOTDIR", str(Path.home()))))
744
740
  command = [shell, "--rcfile", str(session_dir / "bashrc"), "-i"] if shell_name == "bash" else [shell, "-i"]
745
741
  return subprocess.run(command, check=False, env=env).returncode
746
742
  finally:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docker-stack
3
- Version: 2.3.2
3
+ Version: 2.3.3
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
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="docker-stack",
5
- version="2.3.2",
5
+ version="2.3.3",
6
6
  description="CLI for deploying and managing Docker stacks.",
7
7
  long_description=open("README.md").read(), # You can include a README file to describe your package
8
8
  long_description_content_type="text/markdown",
@@ -45,8 +45,7 @@ def test_node_use_updates_only_managed_shell_config(monkeypatch, tmp_path):
45
45
  {"id": "node-worker-123", "hostname": "worker-02", "state": "Ready"},
46
46
  ]
47
47
  },
48
- check_node_agent=lambda selector: checked.append(selector)
49
- or {"node_id": selector, "node_name": "worker-02", "agent": "ready"},
48
+ check_node_agent=lambda selector: checked.append(selector) or {"node_id": selector, "node_name": "worker-02", "agent": "ready"},
50
49
  )
51
50
  monkeypatch.setattr("docker_stack.cli._require_manager", lambda: client)
52
51
 
@@ -84,7 +83,7 @@ def test_managed_docker_ps_renders_node_column(monkeypatch, capsys):
84
83
  }
85
84
  ],
86
85
  "node_errors": [],
87
- }
86
+ },
88
87
  )
89
88
  monkeypatch.setattr("docker_stack.cli.discover_manager_client", lambda: client)
90
89
 
@@ -128,7 +127,15 @@ def test_managed_docker_ps_matches_docker_columns(monkeypatch, capsys):
128
127
  header, row = capsys.readouterr().out.splitlines()
129
128
 
130
129
  assert header.split() == [
131
- "CONTAINER", "ID", "IMAGE", "COMMAND", "CREATED", "STATUS", "PORTS", "NAMES", "NODE",
130
+ "CONTAINER",
131
+ "ID",
132
+ "IMAGE",
133
+ "COMMAND",
134
+ "CREATED",
135
+ "STATUS",
136
+ "PORTS",
137
+ "NAMES",
138
+ "NODE",
132
139
  ]
133
140
  assert row.startswith("467fce208a05 ")
134
141
  assert '"sh -c \'java $JAVA_O\u2026"' in row
@@ -193,8 +200,7 @@ def test_managed_docker_ps_forwards_global_latest_and_limit(monkeypatch):
193
200
  calls = []
194
201
  client = SimpleNamespace(
195
202
  is_manager_backend=lambda: True,
196
- list_containers=lambda **kwargs: calls.append(kwargs)
197
- or {"containers": [], "node_errors": []},
203
+ list_containers=lambda **kwargs: calls.append(kwargs) or {"containers": [], "node_errors": []},
198
204
  )
199
205
  monkeypatch.setattr("docker_stack.cli.discover_manager_client", lambda: client)
200
206
 
@@ -686,7 +692,8 @@ def test_rendered_compose_normalizes_main_source_name(monkeypatch, tmp_path):
686
692
 
687
693
  def test_build_uses_service_dockerfile(tmp_path):
688
694
  compose_file = tmp_path / "docker-compose.yml"
689
- compose_file.write_text("""
695
+ compose_file.write_text(
696
+ """
690
697
  services:
691
698
  storybook:
692
699
  image: example/storybook:test
@@ -695,7 +702,8 @@ services:
695
702
  dockerfile: storybook.Dockerfile
696
703
  args:
697
704
  NPMRC_TOKEN: dummy
698
- """.strip())
705
+ """.strip()
706
+ )
699
707
 
700
708
  docker = Docker()
701
709
  docker.stack.build_and_push(str(compose_file))
@@ -1015,9 +1023,7 @@ def test_manager_deploy_prints_generated_secret_values(capsys):
1015
1023
  "warnings": [],
1016
1024
  "stdout": "",
1017
1025
  "stderr": "",
1018
- "generated_secrets": [
1019
- {"logical_name": "app-token", "actual_name": "team-a_app-token", "value": "generated-value"}
1020
- ],
1026
+ "generated_secrets": [{"logical_name": "app-token", "actual_name": "team-a_app-token", "value": "generated-value"}],
1021
1027
  }
1022
1028
 
1023
1029
  Docker().stack._deploy_via_manager(
@@ -1500,7 +1506,9 @@ def test_shell_explicit_target_persists_context_before_opening(monkeypatch):
1500
1506
  events = []
1501
1507
  monkeypatch.setattr("docker_stack.cli.resolve_shell_login_config", lambda **_kwargs: config)
1502
1508
  monkeypatch.setattr("docker_stack.cli.persist_shell_context", lambda value: events.append(("persist", value.context_name)))
1503
- monkeypatch.setattr("docker_stack.cli.browser_login", lambda _config: events.append(("login", "saas-a")) or SimpleNamespace(expires_at=None))
1509
+ monkeypatch.setattr(
1510
+ "docker_stack.cli.browser_login", lambda _config: events.append(("login", "saas-a")) or SimpleNamespace(expires_at=None)
1511
+ )
1504
1512
  monkeypatch.setattr("docker_stack.cli.run_managed_shell", lambda _config, _result: 0)
1505
1513
 
1506
1514
  with pytest.raises(SystemExit) as excinfo:
@@ -1919,3 +1927,70 @@ secrets:
1919
1927
  # The secret is inlined here instead, so its value never lands in .env.
1920
1928
  assert data["secrets"]["api_token"]["x-content"] == "t0ken"
1921
1929
  assert "API_TOKEN" not in decode_x_files(rendered.enriched)[".env"]
1930
+
1931
+
1932
+ # Compose interpolation forms the manager's `substitute_template` understands.
1933
+ # The CLI leaves these standing for the manager to resolve, so every variable
1934
+ # one of them names has to reach the manager in the stack `.env`. Forms the CLI
1935
+ # fails to recognise are collected by nothing and the deploy dies with
1936
+ # `missing template variable` against a variable that was set the whole time.
1937
+ MANAGER_INTERPOLATION_FORMS = [
1938
+ "${DEPLOY_VAR}",
1939
+ "${DEPLOY_VAR:-fallback}",
1940
+ "${DEPLOY_VAR-fallback}",
1941
+ "${DEPLOY_VAR:?must be set}",
1942
+ "${DEPLOY_VAR?}",
1943
+ "${DEPLOY_VAR:+present}",
1944
+ "${DEPLOY_VAR+present}",
1945
+ "$DEPLOY_VAR",
1946
+ ]
1947
+
1948
+
1949
+ def _manager_resolvable(rendered, name):
1950
+ """Whether the manager could resolve `name` from what the CLI shipped.
1951
+
1952
+ Either the reference was expanded before sending, or the value travels in
1953
+ the stack `.env` the manager interpolates from.
1954
+ """
1955
+ if name not in rendered.clean:
1956
+ return True
1957
+ return f"{name}=" in decode_x_files(rendered.enriched).get(".env", "")
1958
+
1959
+
1960
+ @pytest.mark.parametrize("form", MANAGER_INTERPOLATION_FORMS)
1961
+ def test_every_manager_interpolation_form_ships_its_variable(monkeypatch, tmp_path, form):
1962
+ monkeypatch.setenv("DEPLOY_VAR", '{"dev":"k1"}')
1963
+ compose_text = (
1964
+ "services:\n"
1965
+ " annotator-api:\n"
1966
+ " image: nginx:alpine\n"
1967
+ " environment:\n"
1968
+ f' ANNOTATOR_API_KEYS_JSON: "{form}"\n'
1969
+ )
1970
+
1971
+ rendered = _render_for_manager(monkeypatch, tmp_path, compose_text)
1972
+
1973
+ assert _manager_resolvable(rendered, "DEPLOY_VAR"), (
1974
+ f"{form} reaches the manager unresolved and DEPLOY_VAR is absent from .env"
1975
+ )
1976
+
1977
+
1978
+ def test_conditional_operand_is_not_mistaken_for_a_default(monkeypatch, tmp_path):
1979
+ """`:+` supplies a value only when the variable IS set; it is not a default.
1980
+
1981
+ Collecting the operand as one would write `FEATURE_FLAGS=present` into the
1982
+ `.env` for an unset variable, so the manager would expand the alternate
1983
+ value as though it were the variable's own.
1984
+ """
1985
+ monkeypatch.delenv("FEATURE_FLAGS", raising=False)
1986
+ compose_text = (
1987
+ "services:\n"
1988
+ " annotator-api:\n"
1989
+ " image: nginx:alpine\n"
1990
+ " environment:\n"
1991
+ ' FLAGS: "${FEATURE_FLAGS:+present}"\n'
1992
+ )
1993
+
1994
+ rendered = _render_for_manager(monkeypatch, tmp_path, compose_text)
1995
+
1996
+ assert "FEATURE_FLAGS=present" not in decode_x_files(rendered.enriched).get(".env", "")
@@ -502,7 +502,9 @@ def test_resolve_login_config_ignores_malformed_context_host(monkeypatch):
502
502
 
503
503
 
504
504
  def test_resolve_login_config_detects_tls_for_positional_target(monkeypatch):
505
- monkeypatch.setattr("docker_stack.login.detect_manager_url", lambda value, verify_ssl=False, **_kwargs: ("https://172.31.0.6:2378", True))
505
+ monkeypatch.setattr(
506
+ "docker_stack.login.detect_manager_url", lambda value, verify_ssl=False, **_kwargs: ("https://172.31.0.6:2378", True)
507
+ )
506
508
 
507
509
  config = resolve_login_config(manager_target="172.31.0.6:2378", context_name="office")
508
510
 
@@ -516,7 +518,9 @@ def test_resolve_login_config_prefers_named_context_for_portless_target(monkeypa
516
518
  "docker_stack.login.docker_context_target",
517
519
  lambda context_name, docker_config_dir=None: "tcp://172.31.0.6:2378" if context_name == "office" else None,
518
520
  )
519
- monkeypatch.setattr("docker_stack.login.detect_manager_url", lambda value, verify_ssl=False, **_kwargs: ("https://172.31.0.6:2378", True))
521
+ monkeypatch.setattr(
522
+ "docker_stack.login.detect_manager_url", lambda value, verify_ssl=False, **_kwargs: ("https://172.31.0.6:2378", True)
523
+ )
520
524
 
521
525
  config = resolve_login_config(manager_target="office")
522
526
 
@@ -550,7 +554,9 @@ def test_resolve_login_config_uses_current_context_target(monkeypatch):
550
554
  "docker_stack.login.current_docker_context_target",
551
555
  lambda: ("office", "tcp://172.31.0.6:2378"),
552
556
  )
553
- monkeypatch.setattr("docker_stack.login.detect_manager_url", lambda value, verify_ssl=False, **_kwargs: ("https://172.31.0.6:2378", True))
557
+ monkeypatch.setattr(
558
+ "docker_stack.login.detect_manager_url", lambda value, verify_ssl=False, **_kwargs: ("https://172.31.0.6:2378", True)
559
+ )
554
560
 
555
561
  config = resolve_login_config()
556
562
 
@@ -761,9 +761,7 @@ def test_abort_forbidden_is_a_typed_error(monkeypatch):
761
761
  client = ManagerApiClient("https://172.31.0.6:2378", skip_tls_verify=True)
762
762
 
763
763
  def urlopen(request, **_kwargs):
764
- raise urllib.error.HTTPError(
765
- request.full_url, 403, "Forbidden", {}, io.BytesIO(b'{"message":"stack deploy permission required"}')
766
- )
764
+ raise urllib.error.HTTPError(request.full_url, 403, "Forbidden", {}, io.BytesIO(b'{"message":"stack deploy permission required"}'))
767
765
 
768
766
  monkeypatch.setattr("docker_stack.manager_api.ensure_shell_access", lambda: None)
769
767
  monkeypatch.setattr("docker_stack.manager_api._docker_config_headers", dict)
@@ -807,7 +805,10 @@ def test_force_reports_when_a_different_run_was_aborted(monkeypatch):
807
805
  monkeypatch.setattr(
808
806
  client,
809
807
  "abort_active_deployment",
810
- lambda **_kw: {"aborted": {"operation": "image deploy", "actor": "ci", "deployment_id": None, "started_at_ms": 5}, "released": True},
808
+ lambda **_kw: {
809
+ "aborted": {"operation": "image deploy", "actor": "ci", "deployment_id": None, "started_at_ms": 5},
810
+ "released": True,
811
+ },
811
812
  )
812
813
 
813
814
  client.deploy_stack_stream(
@@ -958,7 +959,9 @@ def test_rollback_stack_threads_wait_for_poll_and_force(monkeypatch):
958
959
 
959
960
  monkeypatch.setattr(client, "_request_json", fake_request)
960
961
 
961
- payload = client.rollback_stack(stack="web", namespace="team-a", version="3", on_wait=notices.append, wait_for_poll=lambda a, s: "force")
962
+ payload = client.rollback_stack(
963
+ stack="web", namespace="team-a", version="3", on_wait=notices.append, wait_for_poll=lambda a, s: "force"
964
+ )
962
965
 
963
966
  assert payload["stdout"] == "rolled back"
964
967
  assert ("POST", "/api/stacks/deploy/abort", {"namespace": "team-a", "stack": "web"}, 45) in calls
@@ -239,11 +239,15 @@ def test_missing_and_wrong_secrets_are_rejected_identically(tmp_path, monkeypatc
239
239
  base = {"version": PROTOCOL_VERSION, "operation": "status"}
240
240
  missing = raw_request(broker.socket_path, base)
241
241
  wrong = raw_request(broker.socket_path, {**base, "secret": "wrong"})
242
- assert missing == wrong == {
243
- "ok": False,
244
- "reason": "unauthorized",
245
- "message": "shell authentication rejected",
246
- }
242
+ assert (
243
+ missing
244
+ == wrong
245
+ == {
246
+ "ok": False,
247
+ "reason": "unauthorized",
248
+ "message": "shell authentication rejected",
249
+ }
250
+ )
247
251
  finally:
248
252
  stop_broker(broker, thread, monkeypatch)
249
253
 
@@ -290,7 +294,7 @@ def test_prompt_hooks_only_read_local_status_and_docker_wrapper_preflights(tmp_p
290
294
  wrapper = (tmp_path / "bashrc").read_text(encoding="utf-8")
291
295
  prompt_function = wrapper.split("__docker_stack_prompt_update() {", 1)[1].split("}", 1)[0]
292
296
  assert "shell-auth" not in prompt_function
293
- assert "docker-stack shell-auth ensure || return $?; docker-stack docker -- \"$@\"" in wrapper
297
+ assert 'docker-stack shell-auth ensure || return $?; docker-stack docker -- "$@"' in wrapper
294
298
  assert "(docker:office@${node})" in wrapper
295
299
  assert "(docker:!DOCKER_HOST)" in wrapper
296
300
 
@@ -395,7 +399,9 @@ def test_shell_supervisor_isolates_secret_and_removes_all_session_artifacts(tmp_
395
399
  monkeypatch.setenv("HOME", str(tmp_path))
396
400
  monkeypatch.setenv("SHELL", "/bin/bash")
397
401
  monkeypatch.setenv("DOCKER_STACK_SHELL_SECRET", "outer-shell-secret")
398
- monkeypatch.setattr("docker_stack.shell_auth.configure_docker_context_in_store", lambda _config, path: captured.setdefault("session", path))
402
+ monkeypatch.setattr(
403
+ "docker_stack.shell_auth.configure_docker_context_in_store", lambda _config, path: captured.setdefault("session", path)
404
+ )
399
405
 
400
406
  def fake_popen(command, **kwargs):
401
407
  captured["broker_command"] = command
File without changes
File without changes