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.
Files changed (31) hide show
  1. {docker_stack-2.0.2 → docker_stack-2.0.4}/PKG-INFO +1 -1
  2. {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack/cli.py +72 -24
  3. {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack/manager_api.py +64 -1
  4. {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack.egg-info/PKG-INFO +1 -1
  5. {docker_stack-2.0.2 → docker_stack-2.0.4}/setup.py +1 -1
  6. {docker_stack-2.0.2 → docker_stack-2.0.4}/tests/test_docker_stack.py +104 -0
  7. {docker_stack-2.0.2 → docker_stack-2.0.4}/README.md +0 -0
  8. {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack/__init__.py +0 -0
  9. {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack/command_runner.py +0 -0
  10. {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack/compose.py +0 -0
  11. {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack/docker_objects.py +0 -0
  12. {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack/envsubst.py +0 -0
  13. {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack/envsubst_merge.py +0 -0
  14. {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack/helpers.py +0 -0
  15. {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack/login.py +0 -0
  16. {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack/markers.py +0 -0
  17. {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack/merge_conf.py +0 -0
  18. {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack/registry.py +0 -0
  19. {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack/url_parser.py +0 -0
  20. {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack.egg-info/SOURCES.txt +0 -0
  21. {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack.egg-info/dependency_links.txt +0 -0
  22. {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack.egg-info/entry_points.txt +0 -0
  23. {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack.egg-info/requires.txt +0 -0
  24. {docker_stack-2.0.2 → docker_stack-2.0.4}/docker_stack.egg-info/top_level.txt +0 -0
  25. {docker_stack-2.0.2 → docker_stack-2.0.4}/pyproject.toml +0 -0
  26. {docker_stack-2.0.2 → docker_stack-2.0.4}/setup.cfg +0 -0
  27. {docker_stack-2.0.2 → docker_stack-2.0.4}/tests/test_docker_objects.py +0 -0
  28. {docker_stack-2.0.2 → docker_stack-2.0.4}/tests/test_load_env.py +0 -0
  29. {docker_stack-2.0.2 → docker_stack-2.0.4}/tests/test_login.py +0 -0
  30. {docker_stack-2.0.2 → docker_stack-2.0.4}/tests/test_manager_api.py +0 -0
  31. {docker_stack-2.0.2 → docker_stack-2.0.4}/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.2
3
+ Version: 2.0.4
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
@@ -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 add_obj(name, data, explicit_name=None, is_generated_secret=False):
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
- if explicit_name:
313
- docker_object_name = explicit_name
314
- elif stack:
315
- docker_object_name = f"{stack}_{name}"
316
- else:
317
- docker_object_name = name
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
- secret_content = generate_secret()
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
- secret_content = generate_secret(length=generate_options)
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
- secret_content = generate_secret(
353
- length=generate_options.get("length"),
354
- numbers=generate_options.get("numbers", True),
355
- special=generate_options.get("special", True),
356
- uppercase=generate_options.get("uppercase", True),
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
- raise RuntimeError(f"Manager request failed ({method} {path}): HTTP {exc.code}") from exc
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
  *,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docker-stack
3
- Version: 2.0.2
3
+ Version: 2.0.4
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
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="docker-stack",
5
- version="2.0.2",
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