docker-stack 2.0.0__tar.gz → 2.0.2__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.0 → docker_stack-2.0.2}/PKG-INFO +1 -1
- {docker_stack-2.0.0 → docker_stack-2.0.2}/docker_stack/cli.py +7 -4
- {docker_stack-2.0.0 → docker_stack-2.0.2}/docker_stack/manager_api.py +30 -69
- {docker_stack-2.0.0 → docker_stack-2.0.2}/docker_stack.egg-info/PKG-INFO +1 -1
- {docker_stack-2.0.0 → docker_stack-2.0.2}/docker_stack.egg-info/SOURCES.txt +1 -0
- {docker_stack-2.0.0 → docker_stack-2.0.2}/setup.py +1 -1
- {docker_stack-2.0.0 → docker_stack-2.0.2}/tests/test_docker_stack.py +4 -1
- {docker_stack-2.0.0 → docker_stack-2.0.2}/tests/test_login.py +1 -1
- docker_stack-2.0.2/tests/test_manager_api.py +72 -0
- {docker_stack-2.0.0 → docker_stack-2.0.2}/README.md +0 -0
- {docker_stack-2.0.0 → docker_stack-2.0.2}/docker_stack/__init__.py +0 -0
- {docker_stack-2.0.0 → docker_stack-2.0.2}/docker_stack/command_runner.py +0 -0
- {docker_stack-2.0.0 → docker_stack-2.0.2}/docker_stack/compose.py +0 -0
- {docker_stack-2.0.0 → docker_stack-2.0.2}/docker_stack/docker_objects.py +0 -0
- {docker_stack-2.0.0 → docker_stack-2.0.2}/docker_stack/envsubst.py +0 -0
- {docker_stack-2.0.0 → docker_stack-2.0.2}/docker_stack/envsubst_merge.py +0 -0
- {docker_stack-2.0.0 → docker_stack-2.0.2}/docker_stack/helpers.py +0 -0
- {docker_stack-2.0.0 → docker_stack-2.0.2}/docker_stack/login.py +0 -0
- {docker_stack-2.0.0 → docker_stack-2.0.2}/docker_stack/markers.py +0 -0
- {docker_stack-2.0.0 → docker_stack-2.0.2}/docker_stack/merge_conf.py +0 -0
- {docker_stack-2.0.0 → docker_stack-2.0.2}/docker_stack/registry.py +0 -0
- {docker_stack-2.0.0 → docker_stack-2.0.2}/docker_stack/url_parser.py +0 -0
- {docker_stack-2.0.0 → docker_stack-2.0.2}/docker_stack.egg-info/dependency_links.txt +0 -0
- {docker_stack-2.0.0 → docker_stack-2.0.2}/docker_stack.egg-info/entry_points.txt +0 -0
- {docker_stack-2.0.0 → docker_stack-2.0.2}/docker_stack.egg-info/requires.txt +0 -0
- {docker_stack-2.0.0 → docker_stack-2.0.2}/docker_stack.egg-info/top_level.txt +0 -0
- {docker_stack-2.0.0 → docker_stack-2.0.2}/pyproject.toml +0 -0
- {docker_stack-2.0.0 → docker_stack-2.0.2}/setup.cfg +0 -0
- {docker_stack-2.0.0 → docker_stack-2.0.2}/tests/test_docker_objects.py +0 -0
- {docker_stack-2.0.0 → docker_stack-2.0.2}/tests/test_load_env.py +0 -0
- {docker_stack-2.0.0 → docker_stack-2.0.2}/tests/test_node_ls.py +0 -0
|
@@ -639,10 +639,9 @@ class DockerStack:
|
|
|
639
639
|
)
|
|
640
640
|
return
|
|
641
641
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
642
|
+
# Manager-backed deploys must stay on the manager stack APIs. Hitting
|
|
643
|
+
# direct daemon config endpoints here breaks GitHub OIDC workflows,
|
|
644
|
+
# which are intentionally restricted away from generic daemon access.
|
|
646
645
|
if manager_deploy and not with_registry_auth:
|
|
647
646
|
self.commands.append(
|
|
648
647
|
CallbackCommand(
|
|
@@ -657,6 +656,10 @@ class DockerStack:
|
|
|
657
656
|
)
|
|
658
657
|
return
|
|
659
658
|
|
|
659
|
+
_, cmd = self.docker.config.increment(stack_name, rendered_content, labels=labels, stack=stack_name)
|
|
660
|
+
if not cmd.isNop():
|
|
661
|
+
self.commands.append(cmd)
|
|
662
|
+
|
|
660
663
|
cmd = ["docker", "stack", "deploy", "-c", str(rendered_filename), stack_name]
|
|
661
664
|
if with_registry_auth:
|
|
662
665
|
cmd.insert(3, "--with-registry-auth")
|
|
@@ -48,6 +48,28 @@ def _manager_target_from_env() -> Optional[str]:
|
|
|
48
48
|
return None
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
def _format_control_plane_endpoints(endpoints: Any) -> str:
|
|
52
|
+
if not isinstance(endpoints, list) or not endpoints:
|
|
53
|
+
return "none"
|
|
54
|
+
values = []
|
|
55
|
+
for item in endpoints:
|
|
56
|
+
if not isinstance(item, dict):
|
|
57
|
+
continue
|
|
58
|
+
endpoint_id = item.get("id")
|
|
59
|
+
name = str(item.get("name") or "").strip()
|
|
60
|
+
slug = str(item.get("slug") or "").strip()
|
|
61
|
+
parts = []
|
|
62
|
+
if endpoint_id is not None:
|
|
63
|
+
parts.append(f"id={endpoint_id}")
|
|
64
|
+
if name:
|
|
65
|
+
parts.append(f"name={name}")
|
|
66
|
+
if slug:
|
|
67
|
+
parts.append(f"slug={slug}")
|
|
68
|
+
if parts:
|
|
69
|
+
values.append(", ".join(parts))
|
|
70
|
+
return "; ".join(values) if values else "none"
|
|
71
|
+
|
|
72
|
+
|
|
51
73
|
class ManagerApiClient:
|
|
52
74
|
def __init__(
|
|
53
75
|
self,
|
|
@@ -134,70 +156,13 @@ class ManagerApiClient:
|
|
|
134
156
|
|
|
135
157
|
self._endpoint_id_checked = True
|
|
136
158
|
|
|
137
|
-
env_endpoint_id = os.getenv("DOCKER_MANAGER_ENDPOINT_ID", "").strip()
|
|
138
|
-
if env_endpoint_id:
|
|
139
|
-
try:
|
|
140
|
-
parsed_id = int(env_endpoint_id)
|
|
141
|
-
except ValueError as exc:
|
|
142
|
-
raise RuntimeError("DOCKER_MANAGER_ENDPOINT_ID must be a positive integer") from exc
|
|
143
|
-
if parsed_id <= 0:
|
|
144
|
-
raise RuntimeError("DOCKER_MANAGER_ENDPOINT_ID must be a positive integer")
|
|
145
|
-
self._endpoint_id = parsed_id
|
|
146
|
-
return parsed_id
|
|
147
|
-
|
|
148
159
|
payload = self._request_json("/api/endpoints")
|
|
149
160
|
endpoints = payload.get("endpoints")
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
selected = None
|
|
156
|
-
|
|
157
|
-
if endpoint_slug:
|
|
158
|
-
selected = next(
|
|
159
|
-
(
|
|
160
|
-
item
|
|
161
|
-
for item in endpoints
|
|
162
|
-
if isinstance(item, dict) and str(item.get("slug", "")).strip() == endpoint_slug
|
|
163
|
-
),
|
|
164
|
-
None,
|
|
165
|
-
)
|
|
166
|
-
if selected is None:
|
|
167
|
-
raise RuntimeError(
|
|
168
|
-
f"Could not find endpoint with slug '{endpoint_slug}'. "
|
|
169
|
-
"Set DOCKER_MANAGER_ENDPOINT_ID to select explicitly."
|
|
170
|
-
)
|
|
171
|
-
elif endpoint_name:
|
|
172
|
-
selected = next(
|
|
173
|
-
(
|
|
174
|
-
item
|
|
175
|
-
for item in endpoints
|
|
176
|
-
if isinstance(item, dict) and str(item.get("name", "")).strip() == endpoint_name
|
|
177
|
-
),
|
|
178
|
-
None,
|
|
179
|
-
)
|
|
180
|
-
if selected is None:
|
|
181
|
-
raise RuntimeError(
|
|
182
|
-
f"Could not find endpoint with name '{endpoint_name}'. "
|
|
183
|
-
"Set DOCKER_MANAGER_ENDPOINT_ID to select explicitly."
|
|
184
|
-
)
|
|
185
|
-
else:
|
|
186
|
-
selected = next((item for item in endpoints if isinstance(item, dict)), None)
|
|
187
|
-
|
|
188
|
-
if not isinstance(selected, dict):
|
|
189
|
-
raise RuntimeError("Docker-Manager endpoint payload is invalid")
|
|
190
|
-
|
|
191
|
-
try:
|
|
192
|
-
endpoint_id = int(selected.get("id"))
|
|
193
|
-
except (TypeError, ValueError) as exc:
|
|
194
|
-
raise RuntimeError("Docker-Manager endpoint payload is missing a valid id") from exc
|
|
195
|
-
|
|
196
|
-
if endpoint_id <= 0:
|
|
197
|
-
raise RuntimeError("Docker-Manager endpoint id must be positive")
|
|
198
|
-
|
|
199
|
-
self._endpoint_id = endpoint_id
|
|
200
|
-
return endpoint_id
|
|
161
|
+
raise RuntimeError(
|
|
162
|
+
"docker-stack does not support Docker-Manager control-plane targets. "
|
|
163
|
+
"Point DOCKER_MANAGER_URL at a direct manager stack API instead. "
|
|
164
|
+
f"Visible endpoints: {_format_control_plane_endpoints(endpoints)}"
|
|
165
|
+
)
|
|
201
166
|
|
|
202
167
|
def _endpoint_path(self, suffix: str) -> str:
|
|
203
168
|
normalized = suffix if suffix.startswith("/") else f"/{suffix}"
|
|
@@ -208,13 +173,9 @@ class ManagerApiClient:
|
|
|
208
173
|
features = self.detect_features()
|
|
209
174
|
if feature_name not in features:
|
|
210
175
|
return False
|
|
211
|
-
if feature_name
|
|
176
|
+
if feature_name == FEATURE_STACK_DEPLOY:
|
|
212
177
|
if not self._detect_manager_backend():
|
|
213
178
|
return False
|
|
214
|
-
try:
|
|
215
|
-
self._resolve_endpoint_id()
|
|
216
|
-
except RuntimeError:
|
|
217
|
-
return False
|
|
218
179
|
return True
|
|
219
180
|
|
|
220
181
|
def list_stacks(self) -> Dict[str, Any]:
|
|
@@ -300,7 +261,7 @@ class ManagerApiClient:
|
|
|
300
261
|
if options:
|
|
301
262
|
payload["options"] = options
|
|
302
263
|
return self._request_json(
|
|
303
|
-
|
|
264
|
+
"/api/stacks/validate",
|
|
304
265
|
method="POST",
|
|
305
266
|
payload=payload,
|
|
306
267
|
)
|
|
@@ -317,7 +278,7 @@ class ManagerApiClient:
|
|
|
317
278
|
if options:
|
|
318
279
|
payload["options"] = options
|
|
319
280
|
return self._request_json(
|
|
320
|
-
|
|
281
|
+
"/api/stacks/deploy",
|
|
321
282
|
method="POST",
|
|
322
283
|
payload=payload,
|
|
323
284
|
)
|
|
@@ -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.2",
|
|
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",
|
|
@@ -311,7 +311,10 @@ def test_checkout_uses_manager_fast_path_for_tag(monkeypatch):
|
|
|
311
311
|
def test_deploy_enqueues_manager_callback_when_supported(monkeypatch, tmp_path):
|
|
312
312
|
fake_manager = FakeManagerClient()
|
|
313
313
|
monkeypatch.setattr("docker_stack.cli.discover_manager_client", lambda *_args, **_kwargs: fake_manager)
|
|
314
|
-
monkeypatch.setattr(
|
|
314
|
+
monkeypatch.setattr(
|
|
315
|
+
"docker_stack.cli.run_cli_command",
|
|
316
|
+
lambda *args, **kwargs: pytest.fail("manager-backed deploy should not hit direct daemon CLI"),
|
|
317
|
+
)
|
|
315
318
|
compose_file = tmp_path / "docker-compose.yml"
|
|
316
319
|
compose_file.write_text("services:\n api:\n image: busybox\n")
|
|
317
320
|
|
|
@@ -552,6 +552,6 @@ def test_browser_login_handles_callback_and_token_exchange(monkeypatch):
|
|
|
552
552
|
result = browser_login(config, browser_opener=fake_browser_open, port_finder=port_finder)
|
|
553
553
|
|
|
554
554
|
assert result.redirect_uri.endswith("/auth/callback")
|
|
555
|
-
assert result.callback_port
|
|
555
|
+
assert result.callback_port > 0
|
|
556
556
|
assert result.access_token == "header.eyJleHAiOjE5MDAwMDAwMDB9.signature"
|
|
557
557
|
assert result.expires_at == 1900000000
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from docker_stack.manager_api import FEATURE_STACK_DEPLOY, ManagerApiClient
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_supports_stack_deploy_without_endpoint_catalog(monkeypatch):
|
|
7
|
+
client = ManagerApiClient("https://172.31.0.6:2378", skip_tls_verify=True)
|
|
8
|
+
calls = []
|
|
9
|
+
|
|
10
|
+
def fake_request(path, *, method="GET", payload=None):
|
|
11
|
+
calls.append((method, path, payload))
|
|
12
|
+
if path == "/version":
|
|
13
|
+
return {"MesudipFeatures": [FEATURE_STACK_DEPLOY]}
|
|
14
|
+
raise AssertionError(f"unexpected request: {method} {path}")
|
|
15
|
+
|
|
16
|
+
monkeypatch.setattr(client, "_request_json", fake_request)
|
|
17
|
+
|
|
18
|
+
assert client.supports(FEATURE_STACK_DEPLOY) is True
|
|
19
|
+
assert calls == [("GET", "/version", None)]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_deploy_stack_uses_direct_stack_api(monkeypatch):
|
|
23
|
+
client = ManagerApiClient("https://172.31.0.6:2378", skip_tls_verify=True)
|
|
24
|
+
calls = []
|
|
25
|
+
|
|
26
|
+
def fake_request(path, *, method="GET", payload=None):
|
|
27
|
+
calls.append((method, path, payload))
|
|
28
|
+
return {"warnings": []}
|
|
29
|
+
|
|
30
|
+
monkeypatch.setattr(client, "_request_json", fake_request)
|
|
31
|
+
|
|
32
|
+
payload = client.deploy_stack(
|
|
33
|
+
stack="trusted-publish-test",
|
|
34
|
+
namespace="default",
|
|
35
|
+
compose="services: {}",
|
|
36
|
+
options={},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
assert payload == {"warnings": []}
|
|
40
|
+
assert calls == [
|
|
41
|
+
(
|
|
42
|
+
"POST",
|
|
43
|
+
"/api/stacks/deploy",
|
|
44
|
+
{
|
|
45
|
+
"stack": "trusted-publish-test",
|
|
46
|
+
"namespace": "default",
|
|
47
|
+
"compose": "services: {}",
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_control_plane_error_lists_visible_endpoints(monkeypatch):
|
|
54
|
+
client = ManagerApiClient("https://172.31.0.6:2378", skip_tls_verify=True)
|
|
55
|
+
|
|
56
|
+
def fake_request(path, *, method="GET", payload=None):
|
|
57
|
+
assert path == "/api/endpoints"
|
|
58
|
+
return {
|
|
59
|
+
"endpoints": [
|
|
60
|
+
{"id": 1, "name": "office", "slug": "office"},
|
|
61
|
+
{"id": 2, "name": "lab"},
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
monkeypatch.setattr(client, "_request_json", fake_request)
|
|
66
|
+
|
|
67
|
+
with pytest.raises(RuntimeError) as excinfo:
|
|
68
|
+
client._resolve_endpoint_id()
|
|
69
|
+
|
|
70
|
+
assert "control-plane targets" in str(excinfo.value)
|
|
71
|
+
assert "id=1, name=office, slug=office" in str(excinfo.value)
|
|
72
|
+
assert "id=2, name=lab" in str(excinfo.value)
|
|
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
|