docker-stack 2.0.2__tar.gz → 2.0.4__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.2 → docker_stack-2.0.4}/PKG-INFO +1 -1
- {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack/cli.py +72 -24
- {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack/manager_api.py +64 -1
- {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack.egg-info/PKG-INFO +1 -1
- {docker_stack-2.0.2 → docker_stack-2.0.4}/setup.py +1 -1
- {docker_stack-2.0.2 → docker_stack-2.0.4}/tests/test_docker_stack.py +104 -0
- {docker_stack-2.0.2 → docker_stack-2.0.4}/README.md +0 -0
- {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack/__init__.py +0 -0
- {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack/command_runner.py +0 -0
- {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack/compose.py +0 -0
- {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack/docker_objects.py +0 -0
- {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack/envsubst.py +0 -0
- {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack/envsubst_merge.py +0 -0
- {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack/helpers.py +0 -0
- {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack/login.py +0 -0
- {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack/markers.py +0 -0
- {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack/merge_conf.py +0 -0
- {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack/registry.py +0 -0
- {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack/url_parser.py +0 -0
- {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack.egg-info/SOURCES.txt +0 -0
- {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack.egg-info/dependency_links.txt +0 -0
- {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack.egg-info/entry_points.txt +0 -0
- {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack.egg-info/requires.txt +0 -0
- {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack.egg-info/top_level.txt +0 -0
- {docker_stack-2.0.2 → docker_stack-2.0.4}/pyproject.toml +0 -0
- {docker_stack-2.0.2 → docker_stack-2.0.4}/setup.cfg +0 -0
- {docker_stack-2.0.2 → docker_stack-2.0.4}/tests/test_docker_objects.py +0 -0
- {docker_stack-2.0.2 → docker_stack-2.0.4}/tests/test_load_env.py +0 -0
- {docker_stack-2.0.2 → docker_stack-2.0.4}/tests/test_login.py +0 -0
- {docker_stack-2.0.2 → docker_stack-2.0.4}/tests/test_manager_api.py +0 -0
- {docker_stack-2.0.2 → docker_stack-2.0.4}/tests/test_node_ls.py +0 -0
|
@@ -303,23 +303,59 @@ class DockerStack:
|
|
|
303
303
|
Returns a tuple: (processed_objects, commands)
|
|
304
304
|
"""
|
|
305
305
|
processed_objects = {}
|
|
306
|
+
manager_client = self._manager_client_for_feature(FEATURE_STACK_DEPLOY)
|
|
307
|
+
namespace = os.getenv("DOCKER_STACK_NAMESPACE", "default")
|
|
306
308
|
|
|
307
|
-
def
|
|
309
|
+
def docker_object_name_for(name, explicit_name=None):
|
|
310
|
+
if explicit_name:
|
|
311
|
+
return explicit_name
|
|
312
|
+
if stack:
|
|
313
|
+
return f"{stack}_{name}"
|
|
314
|
+
return name
|
|
315
|
+
|
|
316
|
+
def normalize_labels(labels):
|
|
317
|
+
normalized = {}
|
|
318
|
+
for raw in labels:
|
|
319
|
+
key, _, value = raw.partition("=")
|
|
320
|
+
if key and _:
|
|
321
|
+
normalized[key] = value
|
|
322
|
+
return normalized
|
|
323
|
+
|
|
324
|
+
def add_obj(name, data, explicit_name=None, is_generated_secret=False, generate_options=None):
|
|
308
325
|
labels = []
|
|
309
326
|
if is_generated_secret:
|
|
310
327
|
labels.append("mesudip.secret.generated=true")
|
|
311
328
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
329
|
+
docker_object_name = docker_object_name_for(name, explicit_name=explicit_name)
|
|
330
|
+
|
|
331
|
+
if manager_client and stack:
|
|
332
|
+
label_map = normalize_labels(labels)
|
|
333
|
+
if manager.object_type == "config":
|
|
334
|
+
response = manager_client.resolve_config(
|
|
335
|
+
stack=stack,
|
|
336
|
+
namespace=namespace,
|
|
337
|
+
name=docker_object_name,
|
|
338
|
+
content=data,
|
|
339
|
+
labels=label_map,
|
|
340
|
+
)
|
|
341
|
+
else:
|
|
342
|
+
response = manager_client.resolve_secret(
|
|
343
|
+
stack=stack,
|
|
344
|
+
namespace=namespace,
|
|
345
|
+
name=docker_object_name,
|
|
346
|
+
content=None if is_generated_secret else data,
|
|
347
|
+
generate=generate_options if is_generated_secret else None,
|
|
348
|
+
labels=label_map,
|
|
349
|
+
return_generated_value=is_generated_secret,
|
|
350
|
+
)
|
|
351
|
+
if is_generated_secret and response.get("generated_value"):
|
|
352
|
+
self.generated_secrets[name] = response["generated_value"]
|
|
353
|
+
processed_objects[name] = {"name": response["actual_name"], "external": True}
|
|
354
|
+
return
|
|
318
355
|
|
|
319
356
|
(object_name, command) = manager.create(docker_object_name, data, labels=labels, stack=stack)
|
|
320
357
|
if not command.isNop():
|
|
321
358
|
self.commands.append(command)
|
|
322
|
-
# If a new secret was actually created (not just reused), store it
|
|
323
359
|
if is_generated_secret:
|
|
324
360
|
self.generated_secrets[name] = data
|
|
325
361
|
processed_objects[name] = {"name": object_name, "external": True}
|
|
@@ -338,28 +374,40 @@ class DockerStack:
|
|
|
338
374
|
with open(filename) as file:
|
|
339
375
|
add_obj(name, file.read(), explicit_name=explicit_name)
|
|
340
376
|
elif isinstance(details, dict) and "x-generate" in details and manager.object_type == "secret":
|
|
341
|
-
is_generated_secret = True
|
|
342
377
|
generate_options = details["x-generate"]
|
|
343
|
-
secret_content = ""
|
|
344
|
-
|
|
345
|
-
# Determine the content to be used for the secret
|
|
346
|
-
# This logic is now simplified as DockerObjectManager.create handles persistence
|
|
347
378
|
if isinstance(generate_options, bool) and generate_options:
|
|
348
|
-
|
|
379
|
+
if manager_client and stack:
|
|
380
|
+
add_obj(name, "", explicit_name=explicit_name, is_generated_secret=True, generate_options={})
|
|
381
|
+
else:
|
|
382
|
+
add_obj(name, generate_secret(), explicit_name=explicit_name, is_generated_secret=True)
|
|
349
383
|
elif isinstance(generate_options, int):
|
|
350
|
-
|
|
384
|
+
if manager_client and stack:
|
|
385
|
+
add_obj(name, "", explicit_name=explicit_name, is_generated_secret=True, generate_options={"length": generate_options})
|
|
386
|
+
else:
|
|
387
|
+
add_obj(name, generate_secret(length=generate_options), explicit_name=explicit_name, is_generated_secret=True)
|
|
351
388
|
elif isinstance(generate_options, dict):
|
|
352
|
-
|
|
353
|
-
length
|
|
354
|
-
numbers
|
|
355
|
-
special
|
|
356
|
-
uppercase
|
|
357
|
-
|
|
389
|
+
options = {
|
|
390
|
+
"length": generate_options.get("length"),
|
|
391
|
+
"numbers": generate_options.get("numbers", True),
|
|
392
|
+
"special": generate_options.get("special", True),
|
|
393
|
+
"uppercase": generate_options.get("uppercase", True),
|
|
394
|
+
}
|
|
395
|
+
if manager_client and stack:
|
|
396
|
+
add_obj(name, "", explicit_name=explicit_name, is_generated_secret=True, generate_options=options)
|
|
397
|
+
else:
|
|
398
|
+
add_obj(
|
|
399
|
+
name,
|
|
400
|
+
generate_secret(
|
|
401
|
+
length=options.get("length"),
|
|
402
|
+
numbers=options.get("numbers", True),
|
|
403
|
+
special=options.get("special", True),
|
|
404
|
+
uppercase=options.get("uppercase", True),
|
|
405
|
+
),
|
|
406
|
+
explicit_name=explicit_name,
|
|
407
|
+
is_generated_secret=True,
|
|
408
|
+
)
|
|
358
409
|
else:
|
|
359
410
|
raise ValueError(f"Invalid x-generate value for secret {name}: {generate_options}")
|
|
360
|
-
|
|
361
|
-
# Call add_obj with the potentially new secret content and the generated flag
|
|
362
|
-
add_obj(name, secret_content, explicit_name=explicit_name, is_generated_secret=True)
|
|
363
411
|
else:
|
|
364
412
|
processed_objects[name] = details
|
|
365
413
|
return processed_objects
|
|
@@ -110,7 +110,16 @@ class ManagerApiClient:
|
|
|
110
110
|
with urllib.request.urlopen(request, timeout=self.timeout_secs, context=context) as response:
|
|
111
111
|
return json.loads(response.read().decode("utf-8"))
|
|
112
112
|
except urllib.error.HTTPError as exc:
|
|
113
|
-
|
|
113
|
+
body = ""
|
|
114
|
+
try:
|
|
115
|
+
payload = exc.read().decode("utf-8", errors="replace").strip()
|
|
116
|
+
if payload:
|
|
117
|
+
body = f": {payload}"
|
|
118
|
+
except Exception:
|
|
119
|
+
body = ""
|
|
120
|
+
raise RuntimeError(
|
|
121
|
+
f"Manager request failed ({method} {path}): HTTP {exc.code}{body}"
|
|
122
|
+
) from exc
|
|
114
123
|
except urllib.error.URLError as exc:
|
|
115
124
|
raise RuntimeError(f"Manager request failed ({method} {path}): {exc.reason}") from exc
|
|
116
125
|
except json.JSONDecodeError as exc:
|
|
@@ -249,6 +258,60 @@ class ManagerApiClient:
|
|
|
249
258
|
|
|
250
259
|
return {"nodes": nodes}
|
|
251
260
|
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def resolve_config(
|
|
264
|
+
self,
|
|
265
|
+
*,
|
|
266
|
+
stack: str,
|
|
267
|
+
namespace: str,
|
|
268
|
+
name: str,
|
|
269
|
+
content: str,
|
|
270
|
+
labels: Optional[Dict[str, str]] = None,
|
|
271
|
+
) -> Dict[str, Any]:
|
|
272
|
+
payload: Dict[str, Any] = {
|
|
273
|
+
"stack": stack,
|
|
274
|
+
"namespace": namespace,
|
|
275
|
+
"name": name,
|
|
276
|
+
"content": content,
|
|
277
|
+
}
|
|
278
|
+
if labels:
|
|
279
|
+
payload["labels"] = labels
|
|
280
|
+
return self._request_json(
|
|
281
|
+
"/api/configs/resolve",
|
|
282
|
+
method="POST",
|
|
283
|
+
payload=payload,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def resolve_secret(
|
|
287
|
+
self,
|
|
288
|
+
*,
|
|
289
|
+
stack: str,
|
|
290
|
+
namespace: str,
|
|
291
|
+
name: str,
|
|
292
|
+
content: Optional[str] = None,
|
|
293
|
+
generate: Optional[Dict[str, Any]] = None,
|
|
294
|
+
labels: Optional[Dict[str, str]] = None,
|
|
295
|
+
return_generated_value: bool = False,
|
|
296
|
+
) -> Dict[str, Any]:
|
|
297
|
+
payload: Dict[str, Any] = {
|
|
298
|
+
"stack": stack,
|
|
299
|
+
"namespace": namespace,
|
|
300
|
+
"name": name,
|
|
301
|
+
"return_generated_value": return_generated_value,
|
|
302
|
+
}
|
|
303
|
+
if content is not None:
|
|
304
|
+
payload["content"] = content
|
|
305
|
+
if generate is not None:
|
|
306
|
+
payload["generate"] = generate
|
|
307
|
+
if labels:
|
|
308
|
+
payload["labels"] = labels
|
|
309
|
+
return self._request_json(
|
|
310
|
+
"/api/secrets/resolve",
|
|
311
|
+
method="POST",
|
|
312
|
+
payload=payload,
|
|
313
|
+
)
|
|
314
|
+
|
|
252
315
|
def validate_stack(
|
|
253
316
|
self,
|
|
254
317
|
*,
|
|
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
|
|
|
2
2
|
|
|
3
3
|
setup(
|
|
4
4
|
name="docker-stack",
|
|
5
|
-
version="2.0.
|
|
5
|
+
version="2.0.4",
|
|
6
6
|
description="CLI for deploying and managing Docker stacks.",
|
|
7
7
|
long_description=open("README.md").read(), # You can include a README file to describe your package
|
|
8
8
|
long_description_content_type="text/markdown",
|
|
@@ -32,6 +32,8 @@ class FakeManagerClient:
|
|
|
32
32
|
self.features = {"docker_stack_query_v1", "docker_stack_deploy_v1"}
|
|
33
33
|
self.deploy_payloads = []
|
|
34
34
|
self.rollback_payloads = []
|
|
35
|
+
self.resolve_config_payloads = []
|
|
36
|
+
self.resolve_secret_payloads = []
|
|
35
37
|
|
|
36
38
|
def supports(self, feature_name):
|
|
37
39
|
return feature_name in self.features
|
|
@@ -83,6 +85,47 @@ class FakeManagerClient:
|
|
|
83
85
|
]
|
|
84
86
|
}
|
|
85
87
|
|
|
88
|
+
def resolve_config(self, *, stack, namespace, name, content, labels=None):
|
|
89
|
+
self.resolve_config_payloads.append(
|
|
90
|
+
{
|
|
91
|
+
"stack": stack,
|
|
92
|
+
"namespace": namespace,
|
|
93
|
+
"name": name,
|
|
94
|
+
"content": content,
|
|
95
|
+
"labels": labels or {},
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
return {
|
|
99
|
+
"logical_name": name,
|
|
100
|
+
"actual_name": f"{name}_v2",
|
|
101
|
+
"version": 2,
|
|
102
|
+
"created": True,
|
|
103
|
+
"changed": True,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
def resolve_secret(self, *, stack, namespace, name, content=None, generate=None, labels=None, return_generated_value=False):
|
|
107
|
+
self.resolve_secret_payloads.append(
|
|
108
|
+
{
|
|
109
|
+
"stack": stack,
|
|
110
|
+
"namespace": namespace,
|
|
111
|
+
"name": name,
|
|
112
|
+
"content": content,
|
|
113
|
+
"generate": generate,
|
|
114
|
+
"labels": labels or {},
|
|
115
|
+
"return_generated_value": return_generated_value,
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
payload = {
|
|
119
|
+
"logical_name": name,
|
|
120
|
+
"actual_name": name,
|
|
121
|
+
"version": 1,
|
|
122
|
+
"created": True,
|
|
123
|
+
"changed": True,
|
|
124
|
+
}
|
|
125
|
+
if return_generated_value:
|
|
126
|
+
payload["generated_value"] = "generated-from-manager"
|
|
127
|
+
return payload
|
|
128
|
+
|
|
86
129
|
def deploy_stack(self, *, stack, namespace, compose, options=None):
|
|
87
130
|
self.deploy_payloads.append(
|
|
88
131
|
{"stack": stack, "namespace": namespace, "compose": compose, "options": options or {}}
|
|
@@ -151,6 +194,67 @@ def test_process_x_content_preserves_explicit_name_override():
|
|
|
151
194
|
assert processed == {"config.json": {"name": "shared-config", "external": True}}
|
|
152
195
|
|
|
153
196
|
|
|
197
|
+
def test_process_x_content_uses_manager_resolve_apis(monkeypatch):
|
|
198
|
+
fake_manager = FakeManagerClient()
|
|
199
|
+
monkeypatch.setattr("docker_stack.cli.discover_manager_client", lambda *_args, **_kwargs: fake_manager)
|
|
200
|
+
|
|
201
|
+
docker = Docker()
|
|
202
|
+
docker.stack._manager_client = fake_manager
|
|
203
|
+
|
|
204
|
+
processed_configs = docker.stack._process_x_content(
|
|
205
|
+
{
|
|
206
|
+
"app.conf": {
|
|
207
|
+
"x-content": "hello",
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
docker.config,
|
|
211
|
+
stack="govtool",
|
|
212
|
+
)
|
|
213
|
+
processed_secrets = docker.stack._process_x_content(
|
|
214
|
+
{
|
|
215
|
+
"app-secret": {
|
|
216
|
+
"x-generate": {
|
|
217
|
+
"length": 24,
|
|
218
|
+
"numbers": True,
|
|
219
|
+
"special": False,
|
|
220
|
+
"uppercase": True,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
docker.secret,
|
|
225
|
+
stack="govtool",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
assert fake_manager.resolve_config_payloads == [
|
|
229
|
+
{
|
|
230
|
+
"stack": "govtool",
|
|
231
|
+
"namespace": "default",
|
|
232
|
+
"name": "govtool_app.conf",
|
|
233
|
+
"content": "hello",
|
|
234
|
+
"labels": {},
|
|
235
|
+
}
|
|
236
|
+
]
|
|
237
|
+
assert fake_manager.resolve_secret_payloads == [
|
|
238
|
+
{
|
|
239
|
+
"stack": "govtool",
|
|
240
|
+
"namespace": "default",
|
|
241
|
+
"name": "govtool_app-secret",
|
|
242
|
+
"content": None,
|
|
243
|
+
"generate": {
|
|
244
|
+
"length": 24,
|
|
245
|
+
"numbers": True,
|
|
246
|
+
"special": False,
|
|
247
|
+
"uppercase": True,
|
|
248
|
+
},
|
|
249
|
+
"labels": {"mesudip.secret.generated": "true"},
|
|
250
|
+
"return_generated_value": True,
|
|
251
|
+
}
|
|
252
|
+
]
|
|
253
|
+
assert processed_configs == {"app.conf": {"name": "govtool_app.conf_v2", "external": True}}
|
|
254
|
+
assert processed_secrets == {"app-secret": {"name": "govtool_app-secret", "external": True}}
|
|
255
|
+
assert docker.stack.generated_secrets == {"app-secret": "generated-from-manager"}
|
|
256
|
+
|
|
257
|
+
|
|
154
258
|
def test_build_uses_service_dockerfile(tmp_path):
|
|
155
259
|
compose_file = tmp_path / "docker-compose.yml"
|
|
156
260
|
compose_file.write_text(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|