docker-stack 2.0.6__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.6 → docker_stack-2.1.0}/PKG-INFO +42 -2
  2. {docker_stack-2.0.6 → docker_stack-2.1.0}/README.md +41 -1
  3. {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack/__init__.py +1 -0
  4. {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack/cli.py +198 -74
  5. {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack/docker_objects.py +36 -10
  6. {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack/helpers.py +15 -0
  7. {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack/login.py +49 -7
  8. {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack/manager_api.py +184 -41
  9. {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack.egg-info/PKG-INFO +42 -2
  10. {docker_stack-2.0.6 → docker_stack-2.1.0}/setup.py +1 -1
  11. {docker_stack-2.0.6 → docker_stack-2.1.0}/tests/test_docker_objects.py +21 -0
  12. {docker_stack-2.0.6 → docker_stack-2.1.0}/tests/test_docker_stack.py +369 -48
  13. {docker_stack-2.0.6 → 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.6/tests/test_manager_api.py +0 -72
  16. {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack/command_runner.py +0 -0
  17. {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack/compose.py +0 -0
  18. {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack/envsubst.py +0 -0
  19. {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack/envsubst_merge.py +0 -0
  20. {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack/markers.py +0 -0
  21. {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack/merge_conf.py +0 -0
  22. {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack/registry.py +0 -0
  23. {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack/url_parser.py +0 -0
  24. {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack.egg-info/SOURCES.txt +0 -0
  25. {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack.egg-info/dependency_links.txt +0 -0
  26. {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack.egg-info/entry_points.txt +0 -0
  27. {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack.egg-info/requires.txt +0 -0
  28. {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack.egg-info/top_level.txt +0 -0
  29. {docker_stack-2.0.6 → docker_stack-2.1.0}/pyproject.toml +0 -0
  30. {docker_stack-2.0.6 → docker_stack-2.1.0}/setup.cfg +0 -0
  31. {docker_stack-2.0.6 → docker_stack-2.1.0}/tests/test_load_env.py +0 -0
  32. {docker_stack-2.0.6 → 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.6
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):
@@ -433,12 +545,6 @@ class DockerStack:
433
545
  @staticmethod
434
546
  def _version_sort_key(raw: str):
435
547
  return int(raw) if raw.isdigit() else raw
436
- @staticmethod
437
- def _display_stack_name(raw: str) -> str:
438
- value = raw.strip()
439
- if value.startswith("default--"):
440
- return value[len("default--"):]
441
- return value
442
548
 
443
549
  def _print_stack_listing(self, stack_versions: Dict[str, List[str]]) -> None:
444
550
  max_stack_name_length = max(len(stack) for stack in stack_versions) if stack_versions else 10
@@ -468,12 +574,10 @@ class DockerStack:
468
574
  try:
469
575
  payload = client.list_stacks()
470
576
  stack_versions = {
471
- self._display_stack_name(str(item.get("stack"))): [
577
+ str(item.get("stack")): [
472
578
  str(version)
473
579
  for version in (
474
- item.get("versions")
475
- if isinstance(item.get("versions"), list)
476
- else item.get("available_versions", [])
580
+ item.get("versions") if isinstance(item.get("versions"), list) else item.get("available_versions", [])
477
581
  )
478
582
  if str(version).strip()
479
583
  ]
@@ -494,7 +598,7 @@ class DockerStack:
494
598
  parts = line.split("\t")
495
599
  if len(parts) == 3 and "mesudip.stack.name" in parts[2]:
496
600
  labels = {k: v for k, v in (label.split("=") for label in parts[2].split(",") if "=" in label)}
497
- stack_name = self._display_stack_name(labels.get("mesudip.stack.name", "")) or None
601
+ stack_name = labels.get("mesudip.stack.name")
498
602
  version = labels.get("mesudip.object.version", "unknown")
499
603
 
500
604
  if stack_name:
@@ -565,7 +669,7 @@ class DockerStack:
565
669
  parts = line.split("\t")
566
670
  if len(parts) == 2 and "mesudip.stack.name" in parts[1]:
567
671
  labels = {k: v for k, v in (label.split("=") for label in parts[1].split(",") if "=" in label)}
568
- stack = self._display_stack_name(labels.get("mesudip.stack.name", "")) or None
672
+ stack = labels.get("mesudip.stack.name")
569
673
  version = labels.get("mesudip.object.version", "unknown")
570
674
  tag = labels.get("mesudip.stack.tag", "")
571
675
 
@@ -651,15 +755,12 @@ class DockerStack:
651
755
  version = matching_versions[0]
652
756
  compose_content = self.cat(stack_name, version, namespace=namespace)
653
757
 
654
- temp_file = f"/tmp/{stack_name}_v{version}.yml"
655
- with open(temp_file, "w") as f:
656
- f.write(compose_content)
657
-
758
+ deploy_content = self._strip_source_metadata(compose_content)
658
759
  print(f"Deploying stack {stack_name} with version {version} (tag: {tag})...")
659
760
  self._deploy(
660
761
  stack_name,
661
- temp_file,
662
- compose_content,
762
+ deploy_content,
763
+ stored_content=compose_content,
663
764
  with_registry_auth=with_registry_auth,
664
765
  tag=tag,
665
766
  namespace=namespace,
@@ -669,13 +770,14 @@ class DockerStack:
669
770
  def _deploy(
670
771
  self,
671
772
  stack_name,
672
- rendered_filename,
673
773
  rendered_content,
774
+ stored_content=None,
674
775
  with_registry_auth=False,
675
776
  tag=None,
676
777
  namespace: str = "default",
677
778
  dry_run: bool = False,
678
779
  ):
780
+ stored_content = stored_content if stored_content is not None else rendered_content
679
781
  labels = [
680
782
  f"mesudip.stack.name={stack_name}",
681
783
  f"com.mesudip.namespace={namespace}",
@@ -685,19 +787,21 @@ class DockerStack:
685
787
  labels.append(f"mesudip.stack.tag={tag}")
686
788
 
687
789
  manager_deploy = self._manager_client_for_feature(FEATURE_STACK_DEPLOY)
688
- 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:
689
792
  self._validate_via_manager(
690
793
  manager_deploy,
691
794
  stack_name=stack_name,
692
795
  namespace=namespace,
693
- rendered_content=rendered_content,
796
+ rendered_content=stored_content,
797
+ options=manager_options,
694
798
  )
695
799
  return
696
800
 
697
801
  # Manager-backed deploys must stay on the manager stack APIs. Hitting
698
802
  # direct daemon config endpoints here breaks GitHub OIDC workflows,
699
803
  # which are intentionally restricted away from generic daemon access.
700
- if manager_deploy and not with_registry_auth:
804
+ if manager_deploy:
701
805
  self.commands.append(
702
806
  CallbackCommand(
703
807
  f"docker-manager stack deploy {stack_name}",
@@ -705,20 +809,21 @@ class DockerStack:
705
809
  manager_deploy,
706
810
  stack_name=stack_name,
707
811
  namespace=namespace,
708
- rendered_content=rendered_content,
812
+ rendered_content=stored_content,
813
+ options=manager_options,
709
814
  ),
710
815
  )
711
816
  )
712
817
  return
713
818
 
714
- _, 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)
715
820
  if not cmd.isNop():
716
821
  self.commands.append(cmd)
717
822
 
718
- cmd = ["docker", "stack", "deploy", "-c", str(rendered_filename), stack_name]
823
+ cmd = ["docker", "stack", "deploy", "-c", "-", stack_name]
719
824
  if with_registry_auth:
720
825
  cmd.insert(3, "--with-registry-auth")
721
- self.commands.append(Command(cmd, give_console=True))
826
+ self.commands.append(Command(cmd, stdin=rendered_content, give_console=True))
722
827
 
723
828
  @staticmethod
724
829
  def _validate_via_manager(
@@ -727,12 +832,13 @@ class DockerStack:
727
832
  stack_name: str,
728
833
  namespace: str,
729
834
  rendered_content: str,
835
+ options: Dict[str, object],
730
836
  ) -> Optional[str]:
731
837
  payload = manager_client.validate_stack(
732
838
  stack=stack_name,
733
839
  namespace=namespace,
734
840
  compose=rendered_content,
735
- options={},
841
+ options=options,
736
842
  )
737
843
  warnings = payload.get("warnings") or []
738
844
  for warning in warnings:
@@ -741,10 +847,7 @@ class DockerStack:
741
847
  service_count = summary.get("service_count", 0)
742
848
  config_count = summary.get("config_count", 0)
743
849
  secret_count = summary.get("secret_count", 0)
744
- print(
745
- "[manager] validation: "
746
- f"services={service_count}, configs={config_count}, secrets={secret_count}"
747
- )
850
+ print("[manager] validation: " f"services={service_count}, configs={config_count}, secrets={secret_count}")
748
851
  return None
749
852
 
750
853
  @staticmethod
@@ -754,12 +857,13 @@ class DockerStack:
754
857
  stack_name: str,
755
858
  namespace: str,
756
859
  rendered_content: str,
860
+ options: Dict[str, object],
757
861
  ) -> Optional[str]:
758
862
  payload = manager_client.deploy_stack(
759
863
  stack=stack_name,
760
864
  namespace=namespace,
761
865
  compose=rendered_content,
762
- options={},
866
+ options=options,
763
867
  )
764
868
  warnings = payload.get("warnings") or []
765
869
  for warning in warnings:
@@ -807,11 +911,11 @@ class DockerStack:
807
911
  dry_run: bool = False,
808
912
  ):
809
913
  self.generated_secrets = {} # Reset for each deployment
810
- 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)
811
915
  self._deploy(
812
916
  stack_name,
813
- rendered_filename,
814
- rendered_content,
917
+ rendered.clean,
918
+ stored_content=rendered.enriched,
815
919
  with_registry_auth=with_registry_auth,
816
920
  tag=tag,
817
921
  namespace=namespace,
@@ -922,10 +1026,7 @@ class DockerNode:
922
1026
  ("Address", "address"),
923
1027
  ]
924
1028
 
925
- widths = {
926
- key: max(len(title), max((len(str(row[key])) for row in rows), default=0))
927
- for title, key in columns
928
- }
1029
+ widths = {key: max(len(title), max((len(str(row[key])) for row in rows), default=0)) for title, key in columns}
929
1030
  terminal_width = shutil.get_terminal_size((120, 20)).columns
930
1031
  static_width = sum(widths.values()) + (3 * (len(columns) - 1))
931
1032
  label_width = max(24, min(60, terminal_width - static_width - 3 - len("Labels")))
@@ -973,9 +1074,7 @@ class DockerNode:
973
1074
  if not line:
974
1075
  continue
975
1076
  node = json.loads(line)
976
- inspect = json.loads(
977
- run_cli_command(["docker", "node", "inspect", node["ID"], "--format", "{{json .}}"], log=False)
978
- )
1077
+ inspect = json.loads(run_cli_command(["docker", "node", "inspect", node["ID"], "--format", "{{json .}}"], log=False))
979
1078
  labels = inspect.get("Spec", {}).get("Labels", {})
980
1079
  manager_status = node.get("ManagerStatus", "").strip()
981
1080
  role = inspect.get("Spec", {}).get("Role", "-")
@@ -997,12 +1096,22 @@ class DockerNode:
997
1096
 
998
1097
  def open_context_shell(config_dir: Path, context_name: str) -> int:
999
1098
  env = dict(os.environ)
1099
+ for key in DOCKER_SHELL_ENDPOINT_ENV_VARS:
1100
+ env.pop(key, None)
1000
1101
  env["DOCKER_CONFIG"] = str(config_dir)
1001
1102
  env["DOCKER_CONTEXT"] = context_name
1002
1103
  shell = env.get("SHELL", "").strip() or "/bin/bash"
1003
1104
  return subprocess.run([shell, "-i"], check=False, env=env).returncode
1004
1105
 
1005
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
+
1006
1115
  def main(args: List[str] = None):
1007
1116
  parser = argparse.ArgumentParser(description="Deploy and manage Docker stacks.")
1008
1117
  subparsers = parser.add_subparsers(dest="command", required=True)
@@ -1107,19 +1216,30 @@ def main(args: List[str] = None):
1107
1216
  context_name=args.context_name,
1108
1217
  timeout_secs=args.timeout_secs,
1109
1218
  )
1110
- 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)
1111
1225
  except RuntimeError as exc:
1112
1226
  print(f"docker-stack login: {exc}", file=sys.stderr)
1113
1227
  sys.exit(2)
1114
- print("Docker-Manager browser login successful.")
1115
- 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}")
1116
1233
  print(f"DOCKER_CONTEXT={config.context_name}")
1234
+ if config_dir is not None:
1235
+ print(f"DOCKER_CONFIG={config_dir}")
1117
1236
  print(f"Context host={config.docker_context_host}")
1118
1237
  if config.manager_url.startswith("https://"):
1119
1238
  print(f"TLS detected for manager endpoint ({'verification skipped' if config.skip_tls_verify else 'verified'})")
1120
- expiry = format_expiry(result.expires_at)
1121
- if expiry:
1122
- 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}")
1123
1243
  print("Try: docker ps")
1124
1244
  return
1125
1245
 
@@ -1133,11 +1253,15 @@ def main(args: List[str] = None):
1133
1253
  context_name=args.context_name,
1134
1254
  timeout_secs=args.timeout_secs,
1135
1255
  )
1256
+ existing_shell_config = active_shell_config_dir(config.context_name)
1136
1257
  config_dir, result = ensure_isolated_login(config)
1137
1258
  except RuntimeError as exc:
1138
1259
  print(f"docker-stack shell: {exc}", file=sys.stderr)
1139
1260
  sys.exit(2)
1140
- 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}")
1141
1265
  print(f"Shell manager={config.manager_url}")
1142
1266
  print(f"DOCKER_CONFIG={config_dir}")
1143
1267
  if result is not None: