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.
- {docker_stack-2.0.7 → docker_stack-2.1.0}/PKG-INFO +42 -2
- {docker_stack-2.0.7 → docker_stack-2.1.0}/README.md +41 -1
- {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack/__init__.py +1 -0
- {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack/cli.py +195 -65
- {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack/docker_objects.py +36 -10
- {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack/helpers.py +15 -0
- {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack/login.py +49 -7
- {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack/manager_api.py +184 -41
- {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack.egg-info/PKG-INFO +42 -2
- {docker_stack-2.0.7 → docker_stack-2.1.0}/setup.py +1 -1
- {docker_stack-2.0.7 → docker_stack-2.1.0}/tests/test_docker_objects.py +21 -0
- {docker_stack-2.0.7 → docker_stack-2.1.0}/tests/test_docker_stack.py +369 -21
- {docker_stack-2.0.7 → docker_stack-2.1.0}/tests/test_login.py +54 -26
- docker_stack-2.1.0/tests/test_manager_api.py +208 -0
- docker_stack-2.0.7/tests/test_manager_api.py +0 -72
- {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack/command_runner.py +0 -0
- {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack/compose.py +0 -0
- {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack/envsubst.py +0 -0
- {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack/envsubst_merge.py +0 -0
- {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack/markers.py +0 -0
- {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack/merge_conf.py +0 -0
- {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack/registry.py +0 -0
- {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack/url_parser.py +0 -0
- {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack.egg-info/SOURCES.txt +0 -0
- {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack.egg-info/dependency_links.txt +0 -0
- {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack.egg-info/entry_points.txt +0 -0
- {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack.egg-info/requires.txt +0 -0
- {docker_stack-2.0.7 → docker_stack-2.1.0}/docker_stack.egg-info/top_level.txt +0 -0
- {docker_stack-2.0.7 → docker_stack-2.1.0}/pyproject.toml +0 -0
- {docker_stack-2.0.7 → docker_stack-2.1.0}/setup.cfg +0 -0
- {docker_stack-2.0.7 → docker_stack-2.1.0}/tests/test_load_env.py +0 -0
- {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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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(
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
|
378
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
|
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=
|
|
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,
|
|
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",
|
|
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
|
-
|
|
914
|
+
rendered = self.render_compose_file(compose_file, stack=stack_name, include_build=False)
|
|
805
915
|
self._deploy(
|
|
806
916
|
stack_name,
|
|
807
|
-
|
|
808
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1109
|
-
|
|
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
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
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
|
-
|
|
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:
|