docker-stack 2.0.7__tar.gz → 2.1.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 (32) hide show
  1. {docker_stack-2.0.7 → docker_stack-2.1.0}/PKG-INFO +42 -2
  2. {docker_stack-2.0.7 → docker_stack-2.1.0}/README.md +41 -1
  3. {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack/__init__.py +1 -0
  4. {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack/cli.py +195 -65
  5. {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack/docker_objects.py +36 -10
  6. {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack/helpers.py +15 -0
  7. {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack/login.py +49 -7
  8. {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack/manager_api.py +184 -41
  9. {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack.egg-info/PKG-INFO +42 -2
  10. {docker_stack-2.0.7 → docker_stack-2.1.0}/setup.py +1 -1
  11. {docker_stack-2.0.7 → docker_stack-2.1.0}/tests/test_docker_objects.py +21 -0
  12. {docker_stack-2.0.7 → docker_stack-2.1.0}/tests/test_docker_stack.py +369 -21
  13. {docker_stack-2.0.7 → docker_stack-2.1.0}/tests/test_login.py +54 -26
  14. docker_stack-2.1.0/tests/test_manager_api.py +208 -0
  15. docker_stack-2.0.7/tests/test_manager_api.py +0 -72
  16. {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack/command_runner.py +0 -0
  17. {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack/compose.py +0 -0
  18. {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack/envsubst.py +0 -0
  19. {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack/envsubst_merge.py +0 -0
  20. {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack/markers.py +0 -0
  21. {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack/merge_conf.py +0 -0
  22. {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack/registry.py +0 -0
  23. {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack/url_parser.py +0 -0
  24. {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack.egg-info/SOURCES.txt +0 -0
  25. {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack.egg-info/dependency_links.txt +0 -0
  26. {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack.egg-info/entry_points.txt +0 -0
  27. {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack.egg-info/requires.txt +0 -0
  28. {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack.egg-info/top_level.txt +0 -0
  29. {docker_stack-2.0.7 → docker_stack-2.1.0}/pyproject.toml +0 -0
  30. {docker_stack-2.0.7 → docker_stack-2.1.0}/setup.cfg +0 -0
  31. {docker_stack-2.0.7 → docker_stack-2.1.0}/tests/test_load_env.py +0 -0
  32. {docker_stack-2.0.7 → docker_stack-2.1.0}/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.0.7
3
+ Version: 2.1.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
@@ -64,7 +64,9 @@ What this gives you on a raw Docker daemon:
64
64
 
65
65
  ### GitHub Actions
66
66
 
67
- If you want to use `docker-stack` directly in a workflow, install the package explicitly:
67
+ #### 1. Normal Docker daemon
68
+
69
+ Use this when the runner already has Docker access through the default Docker context or `DOCKER_HOST`.
68
70
 
69
71
  ```yaml
70
72
  steps:
@@ -76,6 +78,27 @@ steps:
76
78
  - run: docker-stack deploy my-stack docker-compose.yml
77
79
  ```
78
80
 
81
+ This uses the normal Docker CLI path and runs `docker config` / `docker stack deploy` against the configured daemon.
82
+
83
+ #### 2. Docker-Manager
84
+
85
+ Use the bundled action when deploying through Docker-Manager:
86
+
87
+ ```yaml
88
+ permissions:
89
+ contents: read
90
+ id-token: write
91
+
92
+ steps:
93
+ - uses: actions/checkout@v4
94
+ - uses: mesudip/docker-stack@v2
95
+ with:
96
+ manager: https://manager.example.com:2378
97
+ - run: docker-stack deploy --with-registry-auth my-stack docker-compose.yml
98
+ ```
99
+
100
+ The action uses `actions/setup-python@v6`, installs `docker-stack`, defaults to GitHub OIDC auth and the `dm-proxy` context, exports `DOCKER_CONFIG`, `DOCKER_CONTEXT`, and `DOCKER_MANAGER_URL` for later steps, and removes its generated Docker config directory in the post-action cleanup phase.
101
+
79
102
  ## Core Capabilities
80
103
 
81
104
  - **Advanced Deployments on Plain Docker Daemons:**
@@ -139,6 +162,23 @@ Vanilla Docker Stack deployments can sometimes lack the flexibility needed for d
139
162
  ```
140
163
  *(Content of `./templates/my_config.tpl` might be: `DB_HOST=${DATABASE_HOST}`)*
141
164
 
165
+ ### `environment`: Secret Content from Environment Variables
166
+ Secrets can read their content from an environment variable at deploy time.
167
+
168
+ ```yaml
169
+ secrets:
170
+ api_token:
171
+ environment: API_TOKEN
172
+ ```
173
+
174
+ If the variable is unset or empty, deployment fails before Docker objects are created.
175
+
176
+ ### Stored Source Metadata
177
+ Versioned stack configs include a top-level `x-files` list with base64-encoded source material for recovery and auditing. This includes the original compose file as `compose.yml`, a generated `.env` containing referenced non-secret environment values, and config files referenced by `configs.*.file` or `configs.*.x-template-file`. Secret source files and variables used by `secrets.*.environment` are not stored in `x-files`.
178
+
179
+ ### Known Issues
180
+ Stored `x-files` metadata is written into Docker configs during local deploys. Docker config content is limited to 500 KB, and base64-encoded source files add roughly 33% overhead, so stacks with large compose/config source files can exceed the Docker config payload limit even when the rendered compose is valid.
181
+
142
182
  ### `x-generate`: Dynamic Secret Generation (Secrets Only)
143
183
  This powerful feature allows you to automatically generate random secrets based on specified criteria, eliminating the need to manually create and manage them. This is particularly useful for passwords, API keys, and other sensitive data.
144
184
 
@@ -38,7 +38,9 @@ What this gives you on a raw Docker daemon:
38
38
 
39
39
  ### GitHub Actions
40
40
 
41
- If you want to use `docker-stack` directly in a workflow, install the package explicitly:
41
+ #### 1. Normal Docker daemon
42
+
43
+ Use this when the runner already has Docker access through the default Docker context or `DOCKER_HOST`.
42
44
 
43
45
  ```yaml
44
46
  steps:
@@ -50,6 +52,27 @@ steps:
50
52
  - run: docker-stack deploy my-stack docker-compose.yml
51
53
  ```
52
54
 
55
+ This uses the normal Docker CLI path and runs `docker config` / `docker stack deploy` against the configured daemon.
56
+
57
+ #### 2. Docker-Manager
58
+
59
+ Use the bundled action when deploying through Docker-Manager:
60
+
61
+ ```yaml
62
+ permissions:
63
+ contents: read
64
+ id-token: write
65
+
66
+ steps:
67
+ - uses: actions/checkout@v4
68
+ - uses: mesudip/docker-stack@v2
69
+ with:
70
+ manager: https://manager.example.com:2378
71
+ - run: docker-stack deploy --with-registry-auth my-stack docker-compose.yml
72
+ ```
73
+
74
+ The action uses `actions/setup-python@v6`, installs `docker-stack`, defaults to GitHub OIDC auth and the `dm-proxy` context, exports `DOCKER_CONFIG`, `DOCKER_CONTEXT`, and `DOCKER_MANAGER_URL` for later steps, and removes its generated Docker config directory in the post-action cleanup phase.
75
+
53
76
  ## Core Capabilities
54
77
 
55
78
  - **Advanced Deployments on Plain Docker Daemons:**
@@ -113,6 +136,23 @@ Vanilla Docker Stack deployments can sometimes lack the flexibility needed for d
113
136
  ```
114
137
  *(Content of `./templates/my_config.tpl` might be: `DB_HOST=${DATABASE_HOST}`)*
115
138
 
139
+ ### `environment`: Secret Content from Environment Variables
140
+ Secrets can read their content from an environment variable at deploy time.
141
+
142
+ ```yaml
143
+ secrets:
144
+ api_token:
145
+ environment: API_TOKEN
146
+ ```
147
+
148
+ If the variable is unset or empty, deployment fails before Docker objects are created.
149
+
150
+ ### Stored Source Metadata
151
+ Versioned stack configs include a top-level `x-files` list with base64-encoded source material for recovery and auditing. This includes the original compose file as `compose.yml`, a generated `.env` containing referenced non-secret environment values, and config files referenced by `configs.*.file` or `configs.*.x-template-file`. Secret source files and variables used by `secrets.*.environment` are not stored in `x-files`.
152
+
153
+ ### Known Issues
154
+ Stored `x-files` metadata is written into Docker configs during local deploys. Docker config content is limited to 500 KB, and base64-encoded source files add roughly 33% overhead, so stacks with large compose/config source files can exceed the Docker config payload limit even when the rendered compose is valid.
155
+
116
156
  ### `x-generate`: Dynamic Secret Generation (Secrets Only)
117
157
  This powerful feature allows you to automatically generate random secrets based on specified criteria, eliminating the need to manually create and manage them. This is particularly useful for passwords, API keys, and other sensitive data.
118
158
 
@@ -8,6 +8,7 @@ def main(args=None):
8
8
 
9
9
  return cli_main(args)
10
10
 
11
+
11
12
  """
12
13
  Functions:
13
14
 
@@ -32,6 +32,14 @@ from docker_stack.manager_api import (
32
32
  from docker_stack.registry import DockerRegistry
33
33
  from .envsubst import LineCheckResult, SubstitutionError, envsubst, envsubst_load_file
34
34
 
35
+ DOCKER_SHELL_ENDPOINT_ENV_VARS = (
36
+ "DOCKER_HOST",
37
+ "DOCKER_TLS_VERIFY",
38
+ "DOCKER_CERT_PATH",
39
+ "DOCKER_API_VERSION",
40
+ "DOCKER_MACHINE_NAME",
41
+ )
42
+
35
43
 
36
44
  @dataclass
37
45
  class EnvFileEntry:
@@ -43,6 +51,12 @@ class EnvFileEntry:
43
51
  value_inner_offset: int
44
52
 
45
53
 
54
+ @dataclass
55
+ class RenderedCompose:
56
+ clean: str
57
+ enriched: str
58
+
59
+
46
60
  class EnvFileResolutionError(Exception):
47
61
  def __init__(self, env_file: str, reason: str, results: List[LineCheckResult], template_lines: List[str]):
48
62
  self.env_file = env_file
@@ -133,7 +147,9 @@ def _map_substitution_error(entry: EnvFileEntry, err: SubstitutionError) -> List
133
147
  return mapped_results
134
148
 
135
149
 
136
- def _resolve_env_entries(entries: List[EnvFileEntry], env_file: str, base_env: Optional[Dict[str, str]] = None, max_cycles: int = 5) -> Dict[str, str]:
150
+ def _resolve_env_entries(
151
+ entries: List[EnvFileEntry], env_file: str, base_env: Optional[Dict[str, str]] = None, max_cycles: int = 5
152
+ ) -> Dict[str, str]:
137
153
  base_env = dict(base_env or os.environ)
138
154
  current_values = {entry.key: entry.value for entry in entries}
139
155
  local_keys = set(current_values.keys())
@@ -183,9 +199,7 @@ def _resolve_env_entries(entries: List[EnvFileEntry], env_file: str, base_env: O
183
199
  break
184
200
 
185
201
  if cyclic_results:
186
- deduped = {
187
- (result.line_no, result.variable_name, result.start_index): result for result in cyclic_results
188
- }
202
+ deduped = {(result.line_no, result.variable_name, result.start_index): result for result in cyclic_results}
189
203
  ordered_results = sorted(deduped.values(), key=lambda x: (x.line_no, x.start_index or 0))
190
204
  raise EnvFileResolutionError(
191
205
  env_file,
@@ -255,30 +269,127 @@ class DockerStack:
255
269
  with open(compose_file) as f:
256
270
  return self.decode_yaml(f.read())
257
271
 
258
- def rendered_compose_file(self, compose_file, stack=None, include_build=True) -> str:
259
- with open(compose_file) as f:
260
- template_content = f.read()
272
+ @staticmethod
273
+ def _b64_content(content: bytes) -> str:
274
+ return base64.b64encode(content).decode("utf-8")
275
+
276
+ @staticmethod
277
+ def _strip_source_metadata(compose_content: str) -> str:
278
+ compose_data = yaml.safe_load(compose_content) or {}
279
+ if isinstance(compose_data, dict):
280
+ compose_data.pop("x-files", None)
281
+ return yaml.dump(compose_data, sort_keys=False)
282
+
283
+ @staticmethod
284
+ def _iter_env_refs(value: str):
285
+ for match in ENV_VAR_PATTERN.finditer(value):
286
+ name = match.group(1) if match.group(1) is not None else match.group(3)
287
+ default = match.group(2) if match.group(1) is not None else None
288
+ yield name, default
289
+
290
+ @staticmethod
291
+ def _secret_environment_vars(compose_data: dict) -> set:
292
+ secret_env_vars = set()
293
+ secrets = compose_data.get("secrets", {})
294
+ if not isinstance(secrets, dict):
295
+ return secret_env_vars
296
+ for details in secrets.values():
297
+ if isinstance(details, dict) and "environment" in details:
298
+ env_name = str(details["environment"]).strip()
299
+ if env_name:
300
+ secret_env_vars.add(env_name)
301
+ return secret_env_vars
302
+
303
+ @staticmethod
304
+ def _relative_source_name(path: Path, base_dir: Path) -> str:
305
+ return os.path.relpath(path, base_dir).replace(os.sep, "/")
306
+
307
+ def _build_env_xfile(self, sources: List[str], excluded_names: set) -> Optional[Dict[str, str]]:
308
+ seen = set()
309
+ lines = []
310
+ for source in sources:
311
+ for name, default in self._iter_env_refs(source):
312
+ if name in seen or name in excluded_names:
313
+ continue
314
+ value = os.environ.get(name)
315
+ if value in (None, "") and default is not None:
316
+ value = default
317
+ if value in (None, ""):
318
+ continue
319
+ seen.add(name)
320
+ lines.append(f"{name}={value}")
321
+ if not lines:
322
+ return None
323
+ content = ("\n".join(lines) + "\n").encode("utf-8")
324
+ return {"path": ".env", "content": self._b64_content(content)}
325
+
326
+ def _collect_config_source_files(self, compose_data: dict, base_dir: Path) -> Tuple[List[Dict[str, str]], List[str]]:
327
+ x_files = []
328
+ env_sources = []
329
+ configs = compose_data.get("configs", {})
330
+ if not isinstance(configs, dict):
331
+ return x_files, env_sources
332
+
333
+ for details in configs.values():
334
+ if not isinstance(details, dict):
335
+ continue
336
+ source_path = details.get("x-template-file", details.get("file"))
337
+ if not source_path:
338
+ continue
339
+ filename = (base_dir / source_path).resolve()
340
+ content = filename.read_bytes()
341
+ x_files.append(
342
+ {
343
+ "path": self._relative_source_name(filename, base_dir),
344
+ "content": self._b64_content(content),
345
+ }
346
+ )
347
+ env_sources.append(content.decode("utf-8", errors="ignore"))
348
+ return x_files, env_sources
349
+
350
+ def rendered_compose_file(self, compose_file, stack=None, include_build=True) -> RenderedCompose:
351
+ compose_path = Path(compose_file).resolve()
352
+ template_bytes = compose_path.read_bytes()
353
+ template_content = template_bytes.decode("utf-8")
261
354
  # Parse the YAML content
262
355
  compose_data = self.decode_yaml(template_content)
356
+ base_dir = compose_path.parent
357
+ source_x_files: List[Dict[str, str]] = []
358
+ env_sources = [template_content]
359
+ secret_env_vars = self._secret_environment_vars(compose_data)
360
+ if stack:
361
+ source_x_files, config_env_sources = self._collect_config_source_files(compose_data, base_dir)
362
+ env_sources.extend(config_env_sources)
263
363
  if not include_build:
264
364
  services: dict = compose_data.get("services", {})
265
365
  for k, v in services.items():
266
366
  if "build" in v:
267
367
  del v["build"]
268
368
  if stack:
269
- base_dir = os.path.dirname(os.path.abspath(compose_file))
270
369
  if "configs" in compose_data:
271
370
  compose_data["configs"] = self._process_x_content(
272
- compose_data["configs"], self.docker.config, base_dir=base_dir, stack=stack
371
+ compose_data["configs"], self.docker.config, base_dir=str(base_dir), stack=stack
273
372
  )
274
373
  if "secrets" in compose_data:
275
374
  compose_data["secrets"] = self._process_x_content(
276
- compose_data["secrets"], self.docker.secret, base_dir=base_dir, stack=stack
375
+ compose_data["secrets"], self.docker.secret, base_dir=str(base_dir), stack=stack
277
376
  )
278
377
 
279
378
  # Define the replacements for '$' to '$$' for env variables in compose files
280
379
  replacements_map = {"$": "$$"}
281
- return envsubst(yaml.dump(compose_data, sort_keys=False), replacements=replacements_map)
380
+ clean_content = envsubst(yaml.dump(compose_data, sort_keys=False), replacements=replacements_map)
381
+ enriched_data = yaml.safe_load(clean_content) or {}
382
+ x_files = [
383
+ {"path": "compose.yml", "content": self._b64_content(template_bytes)},
384
+ ]
385
+ env_xfile = self._build_env_xfile(env_sources, secret_env_vars)
386
+ if env_xfile:
387
+ x_files.append(env_xfile)
388
+ x_files.extend(source_x_files)
389
+ if isinstance(enriched_data, dict):
390
+ enriched_data["x-files"] = x_files
391
+ enriched_content = yaml.dump(enriched_data, sort_keys=False)
392
+ return RenderedCompose(clean=clean_content, enriched=enriched_content)
282
393
 
283
394
  def decode_yaml(self, data: str) -> dict:
284
395
  return yaml.safe_load(data)
@@ -287,15 +398,7 @@ class DockerStack:
287
398
  """
288
399
  Render the Docker Compose file with environment variables and create Docker configs/secrets.
289
400
  """
290
-
291
- # Convert the modified data back to YAML
292
- rendered_content = self.rendered_compose_file(compose_file, stack, include_build=include_build)
293
-
294
- # Write the rendered file
295
- rendered_filename = Path(compose_file).with_name(f"{Path(compose_file).stem}-rendered{Path(compose_file).suffix}")
296
- with open(rendered_filename, "w") as f:
297
- f.write(rendered_content)
298
- return (rendered_filename, rendered_content)
401
+ return self.rendered_compose_file(compose_file, stack, include_build=include_build)
299
402
 
300
403
  def _process_x_content(self, objects, manager: DockerObjectManager, base_dir="", stack=None):
301
404
  """
@@ -354,7 +457,7 @@ class DockerStack:
354
457
  processed_objects[name] = {"name": response["actual_name"], "external": True}
355
458
  return
356
459
 
357
- (object_name, command) = manager.create(docker_object_name, data, labels=labels, stack=stack)
460
+ object_name, command = manager.create(docker_object_name, data, labels=labels, stack=stack)
358
461
  if not command.isNop():
359
462
  self.commands.append(command)
360
463
  if is_generated_secret:
@@ -366,7 +469,7 @@ class DockerStack:
366
469
  if isinstance(details, dict) and "x-content" in details:
367
470
  add_obj(name, details["x-content"], explicit_name=explicit_name)
368
471
  elif isinstance(details, dict) and "x-template" in details:
369
- add_obj(name, envsubst(details["x-content"], os.environ), explicit_name=explicit_name)
472
+ add_obj(name, envsubst(details["x-template"], os.environ), explicit_name=explicit_name)
370
473
  elif isinstance(details, dict) and "x-template-file" in details:
371
474
  filename = os.path.join(base_dir, details["x-template-file"])
372
475
  add_obj(name, envsubst_load_file(filename, os.environ), explicit_name=explicit_name)
@@ -374,8 +477,15 @@ class DockerStack:
374
477
  filename = os.path.join(base_dir, details["file"])
375
478
  with open(filename) as file:
376
479
  add_obj(name, file.read(), explicit_name=explicit_name)
377
- elif isinstance(details, dict) and "x-generate" in details and manager.object_type == "secret":
378
- generate_options = details["x-generate"]
480
+ elif isinstance(details, dict) and manager.object_type == "secret" and "environment" in details:
481
+ env_name = str(details["environment"]).strip()
482
+ env_value = os.environ.get(env_name)
483
+ if env_value in (None, ""):
484
+ raise ValueError(f"Secret {name} references environment variable {env_name}, but it is not set.")
485
+ add_obj(name, env_value, explicit_name=explicit_name)
486
+ elif isinstance(details, dict) and manager.object_type == "secret" and ("x-generate" in details or "x-generated" in details):
487
+ # Support both spellings for compatibility.
488
+ generate_options = details.get("x-generate", details.get("x-generated"))
379
489
  if isinstance(generate_options, bool) and generate_options:
380
490
  if manager_client and stack:
381
491
  add_obj(name, "", explicit_name=explicit_name, is_generated_secret=True, generate_options={})
@@ -383,7 +493,9 @@ class DockerStack:
383
493
  add_obj(name, generate_secret(), explicit_name=explicit_name, is_generated_secret=True)
384
494
  elif isinstance(generate_options, int):
385
495
  if manager_client and stack:
386
- add_obj(name, "", explicit_name=explicit_name, is_generated_secret=True, generate_options={"length": generate_options})
496
+ add_obj(
497
+ name, "", explicit_name=explicit_name, is_generated_secret=True, generate_options={"length": generate_options}
498
+ )
387
499
  else:
388
500
  add_obj(name, generate_secret(length=generate_options), explicit_name=explicit_name, is_generated_secret=True)
389
501
  elif isinstance(generate_options, dict):
@@ -465,9 +577,7 @@ class DockerStack:
465
577
  str(item.get("stack")): [
466
578
  str(version)
467
579
  for version in (
468
- item.get("versions")
469
- if isinstance(item.get("versions"), list)
470
- else item.get("available_versions", [])
580
+ item.get("versions") if isinstance(item.get("versions"), list) else item.get("available_versions", [])
471
581
  )
472
582
  if str(version).strip()
473
583
  ]
@@ -645,15 +755,12 @@ class DockerStack:
645
755
  version = matching_versions[0]
646
756
  compose_content = self.cat(stack_name, version, namespace=namespace)
647
757
 
648
- temp_file = f"/tmp/{stack_name}_v{version}.yml"
649
- with open(temp_file, "w") as f:
650
- f.write(compose_content)
651
-
758
+ deploy_content = self._strip_source_metadata(compose_content)
652
759
  print(f"Deploying stack {stack_name} with version {version} (tag: {tag})...")
653
760
  self._deploy(
654
761
  stack_name,
655
- temp_file,
656
- compose_content,
762
+ deploy_content,
763
+ stored_content=compose_content,
657
764
  with_registry_auth=with_registry_auth,
658
765
  tag=tag,
659
766
  namespace=namespace,
@@ -663,13 +770,14 @@ class DockerStack:
663
770
  def _deploy(
664
771
  self,
665
772
  stack_name,
666
- rendered_filename,
667
773
  rendered_content,
774
+ stored_content=None,
668
775
  with_registry_auth=False,
669
776
  tag=None,
670
777
  namespace: str = "default",
671
778
  dry_run: bool = False,
672
779
  ):
780
+ stored_content = stored_content if stored_content is not None else rendered_content
673
781
  labels = [
674
782
  f"mesudip.stack.name={stack_name}",
675
783
  f"com.mesudip.namespace={namespace}",
@@ -679,19 +787,21 @@ class DockerStack:
679
787
  labels.append(f"mesudip.stack.tag={tag}")
680
788
 
681
789
  manager_deploy = self._manager_client_for_feature(FEATURE_STACK_DEPLOY)
682
- if manager_deploy and not with_registry_auth and dry_run:
790
+ manager_options = {"with_registry_auth": with_registry_auth} if with_registry_auth else {}
791
+ if manager_deploy and dry_run:
683
792
  self._validate_via_manager(
684
793
  manager_deploy,
685
794
  stack_name=stack_name,
686
795
  namespace=namespace,
687
- rendered_content=rendered_content,
796
+ rendered_content=stored_content,
797
+ options=manager_options,
688
798
  )
689
799
  return
690
800
 
691
801
  # Manager-backed deploys must stay on the manager stack APIs. Hitting
692
802
  # direct daemon config endpoints here breaks GitHub OIDC workflows,
693
803
  # which are intentionally restricted away from generic daemon access.
694
- if manager_deploy and not with_registry_auth:
804
+ if manager_deploy:
695
805
  self.commands.append(
696
806
  CallbackCommand(
697
807
  f"docker-manager stack deploy {stack_name}",
@@ -699,20 +809,21 @@ class DockerStack:
699
809
  manager_deploy,
700
810
  stack_name=stack_name,
701
811
  namespace=namespace,
702
- rendered_content=rendered_content,
812
+ rendered_content=stored_content,
813
+ options=manager_options,
703
814
  ),
704
815
  )
705
816
  )
706
817
  return
707
818
 
708
- _, cmd = self.docker.config.increment(stack_name, rendered_content, labels=labels, stack=stack_name)
819
+ _, cmd = self.docker.config.increment(stack_name, stored_content, labels=labels, stack=stack_name)
709
820
  if not cmd.isNop():
710
821
  self.commands.append(cmd)
711
822
 
712
- cmd = ["docker", "stack", "deploy", "-c", str(rendered_filename), stack_name]
823
+ cmd = ["docker", "stack", "deploy", "-c", "-", stack_name]
713
824
  if with_registry_auth:
714
825
  cmd.insert(3, "--with-registry-auth")
715
- self.commands.append(Command(cmd, give_console=True))
826
+ self.commands.append(Command(cmd, stdin=rendered_content, give_console=True))
716
827
 
717
828
  @staticmethod
718
829
  def _validate_via_manager(
@@ -721,12 +832,13 @@ class DockerStack:
721
832
  stack_name: str,
722
833
  namespace: str,
723
834
  rendered_content: str,
835
+ options: Dict[str, object],
724
836
  ) -> Optional[str]:
725
837
  payload = manager_client.validate_stack(
726
838
  stack=stack_name,
727
839
  namespace=namespace,
728
840
  compose=rendered_content,
729
- options={},
841
+ options=options,
730
842
  )
731
843
  warnings = payload.get("warnings") or []
732
844
  for warning in warnings:
@@ -735,10 +847,7 @@ class DockerStack:
735
847
  service_count = summary.get("service_count", 0)
736
848
  config_count = summary.get("config_count", 0)
737
849
  secret_count = summary.get("secret_count", 0)
738
- print(
739
- "[manager] validation: "
740
- f"services={service_count}, configs={config_count}, secrets={secret_count}"
741
- )
850
+ print("[manager] validation: " f"services={service_count}, configs={config_count}, secrets={secret_count}")
742
851
  return None
743
852
 
744
853
  @staticmethod
@@ -748,12 +857,13 @@ class DockerStack:
748
857
  stack_name: str,
749
858
  namespace: str,
750
859
  rendered_content: str,
860
+ options: Dict[str, object],
751
861
  ) -> Optional[str]:
752
862
  payload = manager_client.deploy_stack(
753
863
  stack=stack_name,
754
864
  namespace=namespace,
755
865
  compose=rendered_content,
756
- options={},
866
+ options=options,
757
867
  )
758
868
  warnings = payload.get("warnings") or []
759
869
  for warning in warnings:
@@ -801,11 +911,11 @@ class DockerStack:
801
911
  dry_run: bool = False,
802
912
  ):
803
913
  self.generated_secrets = {} # Reset for each deployment
804
- rendered_filename, rendered_content = self.render_compose_file(compose_file, stack=stack_name, include_build=False)
914
+ rendered = self.render_compose_file(compose_file, stack=stack_name, include_build=False)
805
915
  self._deploy(
806
916
  stack_name,
807
- rendered_filename,
808
- rendered_content,
917
+ rendered.clean,
918
+ stored_content=rendered.enriched,
809
919
  with_registry_auth=with_registry_auth,
810
920
  tag=tag,
811
921
  namespace=namespace,
@@ -916,10 +1026,7 @@ class DockerNode:
916
1026
  ("Address", "address"),
917
1027
  ]
918
1028
 
919
- widths = {
920
- key: max(len(title), max((len(str(row[key])) for row in rows), default=0))
921
- for title, key in columns
922
- }
1029
+ widths = {key: max(len(title), max((len(str(row[key])) for row in rows), default=0)) for title, key in columns}
923
1030
  terminal_width = shutil.get_terminal_size((120, 20)).columns
924
1031
  static_width = sum(widths.values()) + (3 * (len(columns) - 1))
925
1032
  label_width = max(24, min(60, terminal_width - static_width - 3 - len("Labels")))
@@ -967,9 +1074,7 @@ class DockerNode:
967
1074
  if not line:
968
1075
  continue
969
1076
  node = json.loads(line)
970
- inspect = json.loads(
971
- run_cli_command(["docker", "node", "inspect", node["ID"], "--format", "{{json .}}"], log=False)
972
- )
1077
+ inspect = json.loads(run_cli_command(["docker", "node", "inspect", node["ID"], "--format", "{{json .}}"], log=False))
973
1078
  labels = inspect.get("Spec", {}).get("Labels", {})
974
1079
  manager_status = node.get("ManagerStatus", "").strip()
975
1080
  role = inspect.get("Spec", {}).get("Role", "-")
@@ -991,12 +1096,22 @@ class DockerNode:
991
1096
 
992
1097
  def open_context_shell(config_dir: Path, context_name: str) -> int:
993
1098
  env = dict(os.environ)
1099
+ for key in DOCKER_SHELL_ENDPOINT_ENV_VARS:
1100
+ env.pop(key, None)
994
1101
  env["DOCKER_CONFIG"] = str(config_dir)
995
1102
  env["DOCKER_CONTEXT"] = context_name
996
1103
  shell = env.get("SHELL", "").strip() or "/bin/bash"
997
1104
  return subprocess.run([shell, "-i"], check=False, env=env).returncode
998
1105
 
999
1106
 
1107
+ def active_shell_config_dir(context_name: str) -> Optional[Path]:
1108
+ docker_config = os.getenv("DOCKER_CONFIG", "").strip()
1109
+ docker_context = os.getenv("DOCKER_CONTEXT", "").strip()
1110
+ if not docker_config or docker_context != context_name:
1111
+ return None
1112
+ return Path(docker_config)
1113
+
1114
+
1000
1115
  def main(args: List[str] = None):
1001
1116
  parser = argparse.ArgumentParser(description="Deploy and manage Docker stacks.")
1002
1117
  subparsers = parser.add_subparsers(dest="command", required=True)
@@ -1101,19 +1216,30 @@ def main(args: List[str] = None):
1101
1216
  context_name=args.context_name,
1102
1217
  timeout_secs=args.timeout_secs,
1103
1218
  )
1104
- result = docker_manager_login(config)
1219
+ current_shell_config = active_shell_config_dir(config.context_name)
1220
+ if current_shell_config:
1221
+ config_dir, result = ensure_isolated_login(config, docker_config_dir=current_shell_config)
1222
+ else:
1223
+ config_dir = None
1224
+ result = docker_manager_login(config)
1105
1225
  except RuntimeError as exc:
1106
1226
  print(f"docker-stack login: {exc}", file=sys.stderr)
1107
1227
  sys.exit(2)
1108
- print("Docker-Manager browser login successful.")
1109
- print(f"Callback: {result.redirect_uri}")
1228
+ if result is None:
1229
+ print("Docker-Manager login already active.")
1230
+ else:
1231
+ print("Docker-Manager browser login successful.")
1232
+ print(f"Callback: {result.redirect_uri}")
1110
1233
  print(f"DOCKER_CONTEXT={config.context_name}")
1234
+ if config_dir is not None:
1235
+ print(f"DOCKER_CONFIG={config_dir}")
1111
1236
  print(f"Context host={config.docker_context_host}")
1112
1237
  if config.manager_url.startswith("https://"):
1113
1238
  print(f"TLS detected for manager endpoint ({'verification skipped' if config.skip_tls_verify else 'verified'})")
1114
- expiry = format_expiry(result.expires_at)
1115
- if expiry:
1116
- print(f"Access token expires in {expiry}")
1239
+ if result is not None:
1240
+ expiry = format_expiry(result.expires_at)
1241
+ if expiry:
1242
+ print(f"Access token expires in {expiry}")
1117
1243
  print("Try: docker ps")
1118
1244
  return
1119
1245
 
@@ -1127,11 +1253,15 @@ def main(args: List[str] = None):
1127
1253
  context_name=args.context_name,
1128
1254
  timeout_secs=args.timeout_secs,
1129
1255
  )
1256
+ existing_shell_config = active_shell_config_dir(config.context_name)
1130
1257
  config_dir, result = ensure_isolated_login(config)
1131
1258
  except RuntimeError as exc:
1132
1259
  print(f"docker-stack shell: {exc}", file=sys.stderr)
1133
1260
  sys.exit(2)
1134
- print(f"Shell context={config.context_name}")
1261
+ if existing_shell_config:
1262
+ print(f"Refreshing current shell context={config.context_name}")
1263
+ else:
1264
+ print(f"Opening shell context={config.context_name}")
1135
1265
  print(f"Shell manager={config.manager_url}")
1136
1266
  print(f"DOCKER_CONFIG={config_dir}")
1137
1267
  if result is not None: