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.
- {docker_stack-2.0.6 → docker_stack-2.1.0}/PKG-INFO +42 -2
- {docker_stack-2.0.6 → docker_stack-2.1.0}/README.md +41 -1
- {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack/__init__.py +1 -0
- {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack/cli.py +198 -74
- {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack/docker_objects.py +36 -10
- {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack/helpers.py +15 -0
- {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack/login.py +49 -7
- {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack/manager_api.py +184 -41
- {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack.egg-info/PKG-INFO +42 -2
- {docker_stack-2.0.6 → docker_stack-2.1.0}/setup.py +1 -1
- {docker_stack-2.0.6 → docker_stack-2.1.0}/tests/test_docker_objects.py +21 -0
- {docker_stack-2.0.6 → docker_stack-2.1.0}/tests/test_docker_stack.py +369 -48
- {docker_stack-2.0.6 → 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.6/tests/test_manager_api.py +0 -72
- {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack/command_runner.py +0 -0
- {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack/compose.py +0 -0
- {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack/envsubst.py +0 -0
- {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack/envsubst_merge.py +0 -0
- {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack/markers.py +0 -0
- {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack/merge_conf.py +0 -0
- {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack/registry.py +0 -0
- {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack/url_parser.py +0 -0
- {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack.egg-info/SOURCES.txt +0 -0
- {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack.egg-info/dependency_links.txt +0 -0
- {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack.egg-info/entry_points.txt +0 -0
- {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack.egg-info/requires.txt +0 -0
- {docker_stack-2.0.6 → docker_stack-2.1.0}/docker_stack.egg-info/top_level.txt +0 -0
- {docker_stack-2.0.6 → docker_stack-2.1.0}/pyproject.toml +0 -0
- {docker_stack-2.0.6 → docker_stack-2.1.0}/setup.cfg +0 -0
- {docker_stack-2.0.6 → docker_stack-2.1.0}/tests/test_load_env.py +0 -0
- {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
|
|
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):
|
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
|
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=
|
|
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,
|
|
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",
|
|
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
|
-
|
|
914
|
+
rendered = self.render_compose_file(compose_file, stack=stack_name, include_build=False)
|
|
811
915
|
self._deploy(
|
|
812
916
|
stack_name,
|
|
813
|
-
|
|
814
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1115
|
-
|
|
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
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
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
|
-
|
|
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:
|