docker-stack 2.1.1__py3-none-any.whl → 2.2.0__py3-none-any.whl
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/cli.py +456 -46
- docker_stack/login.py +25 -5
- docker_stack/manager_api.py +41 -3
- docker_stack/shell_auth.py +37 -7
- {docker_stack-2.1.1.dist-info → docker_stack-2.2.0.dist-info}/METADATA +45 -3
- {docker_stack-2.1.1.dist-info → docker_stack-2.2.0.dist-info}/RECORD +9 -9
- {docker_stack-2.1.1.dist-info → docker_stack-2.2.0.dist-info}/WHEEL +0 -0
- {docker_stack-2.1.1.dist-info → docker_stack-2.2.0.dist-info}/entry_points.txt +0 -0
- {docker_stack-2.1.1.dist-info → docker_stack-2.2.0.dist-info}/top_level.txt +0 -0
docker_stack/cli.py
CHANGED
|
@@ -12,6 +12,7 @@ from typing import Dict, List, Optional, Tuple
|
|
|
12
12
|
import os
|
|
13
13
|
import yaml
|
|
14
14
|
import json
|
|
15
|
+
import time
|
|
15
16
|
from docker_stack.docker_objects import DockerConfig, DockerObjectManager, DockerSecret
|
|
16
17
|
from docker_stack.helpers import CallbackCommand, Command, generate_secret, run_cli_command
|
|
17
18
|
from docker_stack.login import (
|
|
@@ -19,13 +20,16 @@ from docker_stack.login import (
|
|
|
19
20
|
ensure_isolated_login,
|
|
20
21
|
format_expiry,
|
|
21
22
|
login as docker_manager_login,
|
|
23
|
+
persist_shell_context,
|
|
22
24
|
resolve_login_config,
|
|
23
25
|
resolve_shell_login_config,
|
|
24
26
|
setup_auth as docker_manager_setup_auth,
|
|
25
27
|
switch_docker_context,
|
|
28
|
+
UnknownShellContextError,
|
|
26
29
|
)
|
|
27
30
|
from docker_stack.shell_auth import (
|
|
28
31
|
SHELL_CONTEXT_ENV,
|
|
32
|
+
SHELL_NODE_STATE_ENV,
|
|
29
33
|
ShellAuthError,
|
|
30
34
|
ensure_shell_access,
|
|
31
35
|
login_for_active_shell,
|
|
@@ -33,6 +37,7 @@ from docker_stack.shell_auth import (
|
|
|
33
37
|
shell_session_active,
|
|
34
38
|
)
|
|
35
39
|
from docker_stack.manager_api import (
|
|
40
|
+
FEATURE_CLUSTER_CONTAINER_CLI,
|
|
36
41
|
FEATURE_STACK_DEPLOY,
|
|
37
42
|
FEATURE_STACK_QUERY,
|
|
38
43
|
ManagerApiClient,
|
|
@@ -48,6 +53,281 @@ DOCKER_SHELL_ENDPOINT_ENV_VARS = (
|
|
|
48
53
|
"DOCKER_API_VERSION",
|
|
49
54
|
"DOCKER_MACHINE_NAME",
|
|
50
55
|
)
|
|
56
|
+
DEFAULT_NAMESPACE = "default"
|
|
57
|
+
NODE_HEADER = "X-Docker-Manager-Node"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _active_docker_config_path() -> Path:
|
|
61
|
+
if not shell_session_active():
|
|
62
|
+
raise RuntimeError("node selection is available only inside docker-stack shell")
|
|
63
|
+
config_root = os.getenv("DOCKER_CONFIG", "").strip()
|
|
64
|
+
if not config_root:
|
|
65
|
+
raise RuntimeError("managed shell Docker configuration is unavailable")
|
|
66
|
+
return Path(config_root) / "config.json"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _read_selected_node() -> str:
|
|
70
|
+
config_path = _active_docker_config_path()
|
|
71
|
+
try:
|
|
72
|
+
payload = json.loads(config_path.read_text(encoding="utf-8"))
|
|
73
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
74
|
+
raise RuntimeError(f"failed reading managed Docker configuration: {exc}") from exc
|
|
75
|
+
headers = payload.get("HttpHeaders")
|
|
76
|
+
value = headers.get(NODE_HEADER) if isinstance(headers, dict) else None
|
|
77
|
+
return str(value).strip() if value else "cluster"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _write_selected_node(value: Optional[str], display_name: str) -> None:
|
|
81
|
+
config_path = _active_docker_config_path()
|
|
82
|
+
try:
|
|
83
|
+
payload = json.loads(config_path.read_text(encoding="utf-8"))
|
|
84
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
85
|
+
raise RuntimeError(f"failed reading managed Docker configuration: {exc}") from exc
|
|
86
|
+
headers = payload.get("HttpHeaders")
|
|
87
|
+
headers = dict(headers) if isinstance(headers, dict) else {}
|
|
88
|
+
if value:
|
|
89
|
+
headers[NODE_HEADER] = value
|
|
90
|
+
else:
|
|
91
|
+
headers.pop(NODE_HEADER, None)
|
|
92
|
+
if headers:
|
|
93
|
+
payload["HttpHeaders"] = headers
|
|
94
|
+
else:
|
|
95
|
+
payload.pop("HttpHeaders", None)
|
|
96
|
+
temporary = config_path.with_suffix(".tmp")
|
|
97
|
+
temporary.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
98
|
+
os.chmod(temporary, 0o600)
|
|
99
|
+
os.replace(temporary, config_path)
|
|
100
|
+
node_state = os.getenv(SHELL_NODE_STATE_ENV, "").strip()
|
|
101
|
+
if node_state:
|
|
102
|
+
Path(node_state).write_text(f"{display_name}\n", encoding="utf-8")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _manager_with_cluster_cli() -> ManagerApiClient:
|
|
106
|
+
client = discover_manager_client()
|
|
107
|
+
if client is None or not client.supports(FEATURE_CLUSTER_CONTAINER_CLI):
|
|
108
|
+
raise RuntimeError("Docker-Manager does not support cluster container CLI; upgrade the manager and all node agents")
|
|
109
|
+
return client
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _select_node(value: str) -> str:
|
|
113
|
+
client = _manager_with_cluster_cli()
|
|
114
|
+
requested = value.strip()
|
|
115
|
+
if requested.lower() == "cluster":
|
|
116
|
+
_write_selected_node(None, "cluster")
|
|
117
|
+
return "cluster"
|
|
118
|
+
nodes = client.list_nodes().get("nodes", [])
|
|
119
|
+
exact = [node for node in nodes if str(node.get("id") or "") == requested]
|
|
120
|
+
candidates = exact or [
|
|
121
|
+
node
|
|
122
|
+
for node in nodes
|
|
123
|
+
if str(node.get("id") or "").startswith(requested)
|
|
124
|
+
or str(node.get("hostname") or "") == requested
|
|
125
|
+
]
|
|
126
|
+
if not candidates:
|
|
127
|
+
raise RuntimeError(f"node '{requested}' is not visible or does not exist")
|
|
128
|
+
if len(candidates) != 1:
|
|
129
|
+
names = ", ".join(str(node.get("hostname") or node.get("id")) for node in candidates)
|
|
130
|
+
raise RuntimeError(f"node '{requested}' is ambiguous: {names}")
|
|
131
|
+
node = candidates[0]
|
|
132
|
+
if str(node.get("state") or "").lower() != "ready":
|
|
133
|
+
raise RuntimeError(f"node '{requested}' is not ready")
|
|
134
|
+
node_id = str(node.get("id") or "").strip()
|
|
135
|
+
hostname = str(node.get("hostname") or node_id).strip()
|
|
136
|
+
_write_selected_node(node_id, hostname)
|
|
137
|
+
return hostname
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _human_age(timestamp: object) -> str:
|
|
141
|
+
try:
|
|
142
|
+
seconds = max(0, int(time.time()) - int(timestamp))
|
|
143
|
+
except (TypeError, ValueError):
|
|
144
|
+
return "-"
|
|
145
|
+
if seconds < 60:
|
|
146
|
+
return f"{seconds} seconds ago"
|
|
147
|
+
if seconds < 3600:
|
|
148
|
+
return f"{seconds // 60} minutes ago"
|
|
149
|
+
if seconds < 86400:
|
|
150
|
+
return f"{seconds // 3600} hours ago"
|
|
151
|
+
return f"{seconds // 86400} days ago"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _format_ports(ports: object) -> str:
|
|
155
|
+
if not isinstance(ports, list):
|
|
156
|
+
return ""
|
|
157
|
+
values = []
|
|
158
|
+
for port in ports:
|
|
159
|
+
if not isinstance(port, dict):
|
|
160
|
+
continue
|
|
161
|
+
private = port.get("PrivatePort")
|
|
162
|
+
protocol = port.get("Type") or "tcp"
|
|
163
|
+
public = port.get("PublicPort")
|
|
164
|
+
address = port.get("IP") or "0.0.0.0"
|
|
165
|
+
values.append(f"{address}:{public}->{private}/{protocol}" if public is not None else f"{private}/{protocol}")
|
|
166
|
+
return ", ".join(values)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _print_cluster_containers(payload: Dict[str, object], *, no_trunc: bool = False, show_size: bool = False) -> int:
|
|
170
|
+
rows = []
|
|
171
|
+
for entry in payload.get("containers", []):
|
|
172
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("docker"), dict):
|
|
173
|
+
continue
|
|
174
|
+
item = entry["docker"]
|
|
175
|
+
container_id = str(item.get("Id") or "-")
|
|
176
|
+
names = item.get("Names") if isinstance(item.get("Names"), list) else []
|
|
177
|
+
row = {
|
|
178
|
+
"CONTAINER ID": container_id if no_trunc else container_id[:12],
|
|
179
|
+
"IMAGE": str(item.get("Image") or "-"),
|
|
180
|
+
"COMMAND": str(item.get("Command") or "-"),
|
|
181
|
+
"CREATED": _human_age(item.get("Created")),
|
|
182
|
+
"STATUS": str(item.get("Status") or item.get("State") or "-"),
|
|
183
|
+
"PORTS": _format_ports(item.get("Ports")),
|
|
184
|
+
"NAMES": ",".join(str(name).lstrip("/") for name in names) or "-",
|
|
185
|
+
"NODE": str(entry.get("node_name") or entry.get("node_id") or "-"),
|
|
186
|
+
}
|
|
187
|
+
if show_size:
|
|
188
|
+
row["SIZE"] = str(item.get("SizeRw") if item.get("SizeRw") is not None else "-")
|
|
189
|
+
rows.append(row)
|
|
190
|
+
columns = ["CONTAINER ID", "IMAGE", "COMMAND", "CREATED", "STATUS", "PORTS", "NAMES"]
|
|
191
|
+
if show_size:
|
|
192
|
+
columns.append("SIZE")
|
|
193
|
+
columns.append("NODE")
|
|
194
|
+
widths = {column: max(len(column), max((len(row[column]) for row in rows), default=0)) for column in columns}
|
|
195
|
+
print(" ".join(column.ljust(widths[column]) for column in columns))
|
|
196
|
+
for row in rows:
|
|
197
|
+
print(" ".join(row[column].ljust(widths[column]) for column in columns))
|
|
198
|
+
errors = payload.get("node_errors") if isinstance(payload.get("node_errors"), list) else []
|
|
199
|
+
for error in errors:
|
|
200
|
+
if isinstance(error, dict):
|
|
201
|
+
print(
|
|
202
|
+
f"docker ps: node {error.get('node_name') or error.get('node_id')}: {error.get('message')} "
|
|
203
|
+
f"(incident: {error.get('incident_id')})",
|
|
204
|
+
file=sys.stderr,
|
|
205
|
+
)
|
|
206
|
+
return 1 if errors else 0
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _managed_docker(arguments: List[str]) -> int:
|
|
210
|
+
values = list(arguments)
|
|
211
|
+
if values and values[0] == "--":
|
|
212
|
+
values.pop(0)
|
|
213
|
+
command_index = 0
|
|
214
|
+
while command_index < len(values) and values[command_index].startswith("-"):
|
|
215
|
+
option = values[command_index]
|
|
216
|
+
if option in {"--config", "--context", "--host", "-H", "--log-level"}:
|
|
217
|
+
command_index += 2
|
|
218
|
+
else:
|
|
219
|
+
command_index += 1
|
|
220
|
+
command = values[command_index:]
|
|
221
|
+
global_options = values[:command_index]
|
|
222
|
+
explicit_target = any(
|
|
223
|
+
value in {"--config", "--context", "-c", "--host", "-H"}
|
|
224
|
+
or value.startswith(("--config=", "--context=", "--host=", "-H="))
|
|
225
|
+
or (value.startswith("-H") and value != "-H")
|
|
226
|
+
or (value.startswith("-c") and value != "-c")
|
|
227
|
+
for value in global_options
|
|
228
|
+
)
|
|
229
|
+
is_ps = bool(command) and (
|
|
230
|
+
command[0] == "ps"
|
|
231
|
+
or (len(command) >= 2 and command[0] == "container" and command[1] in {"ls", "list"})
|
|
232
|
+
)
|
|
233
|
+
if explicit_target or not is_ps or any(
|
|
234
|
+
value in {"-q", "--quiet", "--format"} or value.startswith("--format=")
|
|
235
|
+
for value in command
|
|
236
|
+
):
|
|
237
|
+
return subprocess.run(["docker", *values], check=False).returncode
|
|
238
|
+
try:
|
|
239
|
+
client = discover_manager_client()
|
|
240
|
+
if client is None or not client.supports(FEATURE_CLUSTER_CONTAINER_CLI):
|
|
241
|
+
return subprocess.run(["docker", *values], check=False).returncode
|
|
242
|
+
filters = []
|
|
243
|
+
all_containers = False
|
|
244
|
+
latest = False
|
|
245
|
+
limit = None
|
|
246
|
+
no_trunc = False
|
|
247
|
+
show_size = False
|
|
248
|
+
index = 1 if command[0] == "ps" else 2
|
|
249
|
+
while index < len(command):
|
|
250
|
+
value = command[index]
|
|
251
|
+
if value in {"-a", "--all"}:
|
|
252
|
+
all_containers = True
|
|
253
|
+
elif value in {"-l", "--latest"}:
|
|
254
|
+
latest = True
|
|
255
|
+
elif value == "--no-trunc":
|
|
256
|
+
no_trunc = True
|
|
257
|
+
elif value in {"-s", "--size"}:
|
|
258
|
+
show_size = True
|
|
259
|
+
elif value in {"-f", "--filter"} and index + 1 < len(command):
|
|
260
|
+
index += 1
|
|
261
|
+
filters.append(command[index])
|
|
262
|
+
elif value.startswith("--filter="):
|
|
263
|
+
filters.append(value.split("=", 1)[1])
|
|
264
|
+
elif value in {"-n", "--last"} and index + 1 < len(command):
|
|
265
|
+
index += 1
|
|
266
|
+
limit = int(command[index])
|
|
267
|
+
elif value.startswith("--last="):
|
|
268
|
+
limit = int(value.split("=", 1)[1])
|
|
269
|
+
else:
|
|
270
|
+
return subprocess.run(["docker", *values], check=False).returncode
|
|
271
|
+
index += 1
|
|
272
|
+
payload = client.list_containers(
|
|
273
|
+
all_containers=all_containers,
|
|
274
|
+
filters=filters,
|
|
275
|
+
limit=limit,
|
|
276
|
+
latest=latest,
|
|
277
|
+
size=show_size,
|
|
278
|
+
)
|
|
279
|
+
return _print_cluster_containers(payload, no_trunc=no_trunc, show_size=show_size)
|
|
280
|
+
except (RuntimeError, ValueError) as exc:
|
|
281
|
+
print(f"docker ps: {exc}", file=sys.stderr)
|
|
282
|
+
return 2
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def physical_stack_name(namespace: str, stack: str) -> str:
|
|
286
|
+
return stack if namespace == DEFAULT_NAMESPACE else f"{namespace}--{stack}"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _namespace_default() -> str:
|
|
290
|
+
return os.getenv("DOCKER_STACK_NAMESPACE", DEFAULT_NAMESPACE).strip() or DEFAULT_NAMESPACE
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _add_namespace_argument(parser: argparse.ArgumentParser, help_text: str = "Stack namespace") -> None:
|
|
294
|
+
parser.add_argument(
|
|
295
|
+
"-n",
|
|
296
|
+
"--namespace",
|
|
297
|
+
default=_namespace_default(),
|
|
298
|
+
help=f"{help_text} (default: %(default)s)",
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _add_listing_namespace_arguments(parser: argparse.ArgumentParser) -> None:
|
|
303
|
+
group = parser.add_mutually_exclusive_group()
|
|
304
|
+
group.add_argument(
|
|
305
|
+
"-n",
|
|
306
|
+
"--namespace",
|
|
307
|
+
default=_namespace_default(),
|
|
308
|
+
help="Stack namespace (default: %(default)s)",
|
|
309
|
+
)
|
|
310
|
+
group.add_argument(
|
|
311
|
+
"-A",
|
|
312
|
+
"--all-namespaces",
|
|
313
|
+
"-all-namespaces",
|
|
314
|
+
action="store_true",
|
|
315
|
+
help="List stacks across all namespaces",
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _prompt_for_manager_target(context_name: str) -> str:
|
|
320
|
+
if not sys.stdin.isatty():
|
|
321
|
+
raise RuntimeError(
|
|
322
|
+
f"Unknown shell context '{context_name}'. Run "
|
|
323
|
+
f"'docker-stack shell --context {context_name} <manager-url>' to create it."
|
|
324
|
+
)
|
|
325
|
+
print(f"Docker context '{context_name}' does not exist yet.")
|
|
326
|
+
print("Enter the Docker-Manager URL to create it (for example https://manager.example.com:2376).")
|
|
327
|
+
target = input(f"Manager URL for {context_name}: ").strip()
|
|
328
|
+
if not target:
|
|
329
|
+
raise RuntimeError(f"Manager URL is required to create shell context '{context_name}'")
|
|
330
|
+
return target
|
|
51
331
|
|
|
52
332
|
|
|
53
333
|
@dataclass
|
|
@@ -382,7 +662,13 @@ class DockerStack:
|
|
|
382
662
|
env_sources.append(content.decode("utf-8", errors="ignore"))
|
|
383
663
|
return x_files, env_sources
|
|
384
664
|
|
|
385
|
-
def rendered_compose_file(
|
|
665
|
+
def rendered_compose_file(
|
|
666
|
+
self,
|
|
667
|
+
compose_file,
|
|
668
|
+
stack=None,
|
|
669
|
+
include_build=True,
|
|
670
|
+
namespace: str = DEFAULT_NAMESPACE,
|
|
671
|
+
) -> RenderedCompose:
|
|
386
672
|
compose_path = Path(compose_file).resolve()
|
|
387
673
|
template_bytes = compose_path.read_bytes()
|
|
388
674
|
template_content = template_bytes.decode("utf-8")
|
|
@@ -411,11 +697,19 @@ class DockerStack:
|
|
|
411
697
|
if stack and not manager_deploy:
|
|
412
698
|
if "configs" in compose_data:
|
|
413
699
|
compose_data["configs"] = self._process_x_content(
|
|
414
|
-
compose_data["configs"],
|
|
700
|
+
compose_data["configs"],
|
|
701
|
+
self.docker.config,
|
|
702
|
+
base_dir=str(base_dir),
|
|
703
|
+
stack=stack,
|
|
704
|
+
namespace=namespace,
|
|
415
705
|
)
|
|
416
706
|
if "secrets" in compose_data:
|
|
417
707
|
compose_data["secrets"] = self._process_x_content(
|
|
418
|
-
compose_data["secrets"],
|
|
708
|
+
compose_data["secrets"],
|
|
709
|
+
self.docker.secret,
|
|
710
|
+
base_dir=str(base_dir),
|
|
711
|
+
stack=stack,
|
|
712
|
+
namespace=namespace,
|
|
419
713
|
)
|
|
420
714
|
|
|
421
715
|
# Define the replacements for '$' to '$$' for env variables in compose files
|
|
@@ -437,26 +731,38 @@ class DockerStack:
|
|
|
437
731
|
def decode_yaml(self, data: str) -> dict:
|
|
438
732
|
return yaml.safe_load(data)
|
|
439
733
|
|
|
440
|
-
def render_compose_file(self, compose_file, stack=None, include_build=True):
|
|
734
|
+
def render_compose_file(self, compose_file, stack=None, include_build=True, namespace: str = DEFAULT_NAMESPACE):
|
|
441
735
|
"""
|
|
442
736
|
Render the Docker Compose file with environment variables and create Docker configs/secrets.
|
|
443
737
|
"""
|
|
444
|
-
return self.rendered_compose_file(
|
|
738
|
+
return self.rendered_compose_file(
|
|
739
|
+
compose_file,
|
|
740
|
+
stack,
|
|
741
|
+
include_build=include_build,
|
|
742
|
+
namespace=namespace,
|
|
743
|
+
)
|
|
445
744
|
|
|
446
|
-
def _process_x_content(
|
|
745
|
+
def _process_x_content(
|
|
746
|
+
self,
|
|
747
|
+
objects,
|
|
748
|
+
manager: DockerObjectManager,
|
|
749
|
+
base_dir="",
|
|
750
|
+
stack=None,
|
|
751
|
+
namespace: str = DEFAULT_NAMESPACE,
|
|
752
|
+
):
|
|
447
753
|
"""
|
|
448
754
|
Process configs or secrets with x-content keys.
|
|
449
755
|
Returns a tuple: (processed_objects, commands)
|
|
450
756
|
"""
|
|
451
757
|
processed_objects = {}
|
|
452
758
|
manager_client = self._manager_client_for_feature(FEATURE_STACK_DEPLOY)
|
|
453
|
-
|
|
759
|
+
object_stack = physical_stack_name(namespace, stack) if stack else None
|
|
454
760
|
|
|
455
761
|
def docker_object_name_for(name, explicit_name=None):
|
|
456
762
|
if explicit_name:
|
|
457
763
|
return explicit_name
|
|
458
|
-
if
|
|
459
|
-
return f"{
|
|
764
|
+
if object_stack:
|
|
765
|
+
return f"{object_stack}_{name}"
|
|
460
766
|
return name
|
|
461
767
|
|
|
462
768
|
def normalize_labels(labels):
|
|
@@ -500,7 +806,7 @@ class DockerStack:
|
|
|
500
806
|
processed_objects[name] = {"name": response["actual_name"], "external": True}
|
|
501
807
|
return
|
|
502
808
|
|
|
503
|
-
object_name, command = manager.create(docker_object_name, data, labels=labels, stack=
|
|
809
|
+
object_name, command = manager.create(docker_object_name, data, labels=labels, stack=object_stack)
|
|
504
810
|
if not command.isNop():
|
|
505
811
|
self.commands.append(command)
|
|
506
812
|
if is_generated_secret:
|
|
@@ -604,16 +910,55 @@ class DockerStack:
|
|
|
604
910
|
def _version_sort_key(raw: str):
|
|
605
911
|
return int(raw) if raw.isdigit() else raw
|
|
606
912
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
913
|
+
@staticmethod
|
|
914
|
+
def _compact_versions(versions: List[str]) -> str:
|
|
915
|
+
numeric = sorted({int(version.lstrip("vV")) for version in versions if version.lstrip("vV").isdigit()})
|
|
916
|
+
other = sorted({version for version in versions if not version.lstrip("vV").isdigit()})
|
|
917
|
+
compact = []
|
|
918
|
+
start = end = None
|
|
919
|
+
|
|
920
|
+
def append_range(first: int, last: int) -> None:
|
|
921
|
+
if last - first >= 2:
|
|
922
|
+
compact.append(f"v{first}...v{last}")
|
|
923
|
+
elif last == first:
|
|
924
|
+
compact.append(f"v{first}")
|
|
925
|
+
else:
|
|
926
|
+
compact.extend((f"v{first}", f"v{last}"))
|
|
610
927
|
|
|
611
|
-
|
|
612
|
-
|
|
928
|
+
for version in numeric:
|
|
929
|
+
if start is None:
|
|
930
|
+
start = end = version
|
|
931
|
+
elif version == end + 1:
|
|
932
|
+
end = version
|
|
933
|
+
else:
|
|
934
|
+
append_range(start, end)
|
|
935
|
+
start = end = version
|
|
936
|
+
if start is not None:
|
|
937
|
+
append_range(start, end)
|
|
938
|
+
compact.extend(other)
|
|
939
|
+
return ", ".join(compact)
|
|
940
|
+
|
|
941
|
+
def _print_stack_listing(self, stack_versions: Dict[Tuple[str, str], List[str]], namespace: Optional[str]) -> None:
|
|
942
|
+
max_stack_name_length = max((len(stack) for _, stack in stack_versions), default=10)
|
|
943
|
+
header_stack = "Stack Name".ljust(max_stack_name_length)
|
|
613
944
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
print(f"{
|
|
945
|
+
if namespace is not None:
|
|
946
|
+
print(f"Namespace: {namespace}")
|
|
947
|
+
print(f"{header_stack} | Versions")
|
|
948
|
+
print("-" * (max_stack_name_length + 12))
|
|
949
|
+
else:
|
|
950
|
+
max_namespace_length = max((len(item_namespace) for item_namespace, _ in stack_versions), default=9)
|
|
951
|
+
header_namespace = "Namespace".ljust(max_namespace_length)
|
|
952
|
+
print("Namespace: all")
|
|
953
|
+
print(f"{header_namespace} | {header_stack} | Versions")
|
|
954
|
+
print("-" * (max_namespace_length + max_stack_name_length + 15))
|
|
955
|
+
|
|
956
|
+
for (item_namespace, stack), versions in sorted(stack_versions.items()):
|
|
957
|
+
versions_str = self._compact_versions(versions)
|
|
958
|
+
if namespace is None:
|
|
959
|
+
print(f"{item_namespace.ljust(max_namespace_length)} | {stack.ljust(max_stack_name_length)} | {versions_str}")
|
|
960
|
+
else:
|
|
961
|
+
print(f"{stack.ljust(max_stack_name_length)} | {versions_str}")
|
|
617
962
|
|
|
618
963
|
def _print_versions(self, versions_list: List[Tuple[str, str]]) -> None:
|
|
619
964
|
rows = [("Version", "Tag")] + versions_list
|
|
@@ -626,13 +971,13 @@ class DockerStack:
|
|
|
626
971
|
for version, tag in sorted(versions_list, key=lambda x: self._version_sort_key(x[0])):
|
|
627
972
|
print(f"{version.ljust(max_version_length)} | {tag.ljust(max_tag_length)}")
|
|
628
973
|
|
|
629
|
-
def ls(self):
|
|
974
|
+
def ls(self, namespace: Optional[str] = DEFAULT_NAMESPACE):
|
|
630
975
|
client = self._manager_client_for_feature(FEATURE_STACK_QUERY)
|
|
631
976
|
if client:
|
|
632
977
|
try:
|
|
633
|
-
payload = client.list_stacks()
|
|
978
|
+
payload = client.list_stacks(namespace=namespace)
|
|
634
979
|
stack_versions = {
|
|
635
|
-
str(item.get("stack")): [
|
|
980
|
+
(str(item.get("namespace") or namespace or DEFAULT_NAMESPACE), str(item.get("stack"))): [
|
|
636
981
|
str(version)
|
|
637
982
|
for version in (
|
|
638
983
|
item.get("versions") if isinstance(item.get("versions"), list) else item.get("available_versions", [])
|
|
@@ -642,7 +987,9 @@ class DockerStack:
|
|
|
642
987
|
for item in payload.get("stacks", [])
|
|
643
988
|
if str(item.get("stack", "")).strip()
|
|
644
989
|
}
|
|
645
|
-
self._print_stack_listing(stack_versions)
|
|
990
|
+
self._print_stack_listing(stack_versions, namespace)
|
|
991
|
+
if namespace is not None:
|
|
992
|
+
return {stack: versions for (_, stack), versions in stack_versions.items()}
|
|
646
993
|
return stack_versions
|
|
647
994
|
except RuntimeError as exc:
|
|
648
995
|
self._raise_if_manager_is_explicit(exc)
|
|
@@ -650,7 +997,7 @@ class DockerStack:
|
|
|
650
997
|
cmd = ["docker", "config", "ls", "--format", "{{.ID}}\t{{.Name}}\t{{.Labels}}"]
|
|
651
998
|
raw_output = run_cli_command(cmd, log=False)
|
|
652
999
|
output = raw_output.split("\n") if raw_output else []
|
|
653
|
-
stack_versions: Dict[str, List[str]] = {}
|
|
1000
|
+
stack_versions: Dict[Tuple[str, str], List[str]] = {}
|
|
654
1001
|
|
|
655
1002
|
for line in output:
|
|
656
1003
|
parts = line.split("\t")
|
|
@@ -658,11 +1005,14 @@ class DockerStack:
|
|
|
658
1005
|
labels = {k: v for k, v in (label.split("=") for label in parts[2].split(",") if "=" in label)}
|
|
659
1006
|
stack_name = labels.get("mesudip.stack.name")
|
|
660
1007
|
version = labels.get("mesudip.object.version", "unknown")
|
|
1008
|
+
object_namespace = labels.get("com.mesudip.namespace", DEFAULT_NAMESPACE)
|
|
661
1009
|
|
|
662
|
-
if stack_name:
|
|
663
|
-
stack_versions.setdefault(stack_name, []).append(version)
|
|
1010
|
+
if stack_name and (namespace is None or object_namespace == namespace):
|
|
1011
|
+
stack_versions.setdefault((object_namespace, stack_name), []).append(version)
|
|
664
1012
|
|
|
665
|
-
self._print_stack_listing(stack_versions)
|
|
1013
|
+
self._print_stack_listing(stack_versions, namespace)
|
|
1014
|
+
if namespace is not None:
|
|
1015
|
+
return {stack: versions for (_, stack), versions in stack_versions.items()}
|
|
666
1016
|
return stack_versions
|
|
667
1017
|
|
|
668
1018
|
def cat(self, name: str, version: str, namespace: str = "default"):
|
|
@@ -681,10 +1031,11 @@ class DockerStack:
|
|
|
681
1031
|
except RuntimeError as exc:
|
|
682
1032
|
self._raise_if_manager_is_explicit(exc)
|
|
683
1033
|
|
|
1034
|
+
history_name = physical_stack_name(namespace, name)
|
|
684
1035
|
if normalized_version == "1":
|
|
685
|
-
config_name =
|
|
1036
|
+
config_name = history_name
|
|
686
1037
|
else:
|
|
687
|
-
config_name = f"{
|
|
1038
|
+
config_name = f"{history_name}_v{normalized_version}"
|
|
688
1039
|
|
|
689
1040
|
cmd = ["docker", "config", "inspect", config_name]
|
|
690
1041
|
output = run_cli_command(cmd, log=False)
|
|
@@ -730,8 +1081,9 @@ class DockerStack:
|
|
|
730
1081
|
stack = labels.get("mesudip.stack.name")
|
|
731
1082
|
version = labels.get("mesudip.object.version", "unknown")
|
|
732
1083
|
tag = labels.get("mesudip.stack.tag", "")
|
|
1084
|
+
object_namespace = labels.get("com.mesudip.namespace", DEFAULT_NAMESPACE)
|
|
733
1085
|
|
|
734
|
-
if stack == stack_name:
|
|
1086
|
+
if stack == stack_name and object_namespace == namespace:
|
|
735
1087
|
versions_list.append((version, tag))
|
|
736
1088
|
|
|
737
1089
|
if print_output:
|
|
@@ -894,11 +1246,17 @@ class DockerStack:
|
|
|
894
1246
|
if force_update:
|
|
895
1247
|
raise RuntimeError("--force-update is supported only by the Docker-Manager stack deploy API")
|
|
896
1248
|
|
|
897
|
-
|
|
1249
|
+
physical_stack = physical_stack_name(namespace, stack_name)
|
|
1250
|
+
_, cmd = self.docker.config.increment(
|
|
1251
|
+
physical_stack,
|
|
1252
|
+
stored_content,
|
|
1253
|
+
labels=labels,
|
|
1254
|
+
stack=physical_stack,
|
|
1255
|
+
)
|
|
898
1256
|
if not cmd.isNop():
|
|
899
1257
|
self.commands.append(cmd)
|
|
900
1258
|
|
|
901
|
-
cmd = ["docker", "stack", "deploy", "-c", "-",
|
|
1259
|
+
cmd = ["docker", "stack", "deploy", "-c", "-", physical_stack]
|
|
902
1260
|
if with_registry_auth:
|
|
903
1261
|
cmd.insert(3, "--with-registry-auth")
|
|
904
1262
|
if prune:
|
|
@@ -1059,7 +1417,12 @@ class DockerStack:
|
|
|
1059
1417
|
force_update: bool = False,
|
|
1060
1418
|
):
|
|
1061
1419
|
self.generated_secrets = {} # Reset for each deployment
|
|
1062
|
-
rendered = self.render_compose_file(
|
|
1420
|
+
rendered = self.render_compose_file(
|
|
1421
|
+
compose_file,
|
|
1422
|
+
stack=stack_name,
|
|
1423
|
+
include_build=False,
|
|
1424
|
+
namespace=namespace,
|
|
1425
|
+
)
|
|
1063
1426
|
self._deploy(
|
|
1064
1427
|
stack_name,
|
|
1065
1428
|
rendered.clean,
|
|
@@ -1098,7 +1461,12 @@ class DockerStack:
|
|
|
1098
1461
|
)
|
|
1099
1462
|
)
|
|
1100
1463
|
return
|
|
1101
|
-
self.commands.append(
|
|
1464
|
+
self.commands.append(
|
|
1465
|
+
Command(
|
|
1466
|
+
["docker", "stack", "rm", physical_stack_name(namespace, stack_name)],
|
|
1467
|
+
give_console=True,
|
|
1468
|
+
)
|
|
1469
|
+
)
|
|
1102
1470
|
|
|
1103
1471
|
def push(self, compose_file):
|
|
1104
1472
|
compose_data = self.read_compose_file(compose_file)
|
|
@@ -1380,6 +1748,9 @@ def main(args: List[str] = None):
|
|
|
1380
1748
|
shell_auth_parser = subparsers.add_parser("shell-auth", help=argparse.SUPPRESS)
|
|
1381
1749
|
shell_auth_parser.add_argument("operation", choices=("ensure", "status"), help=argparse.SUPPRESS)
|
|
1382
1750
|
|
|
1751
|
+
docker_parser = subparsers.add_parser("docker", help=argparse.SUPPRESS)
|
|
1752
|
+
docker_parser.add_argument("docker_args", nargs=argparse.REMAINDER, help=argparse.SUPPRESS)
|
|
1753
|
+
|
|
1383
1754
|
# Build subcommand
|
|
1384
1755
|
build_parser = subparsers.add_parser("build", help="Build images using docker-compose")
|
|
1385
1756
|
build_parser.add_argument("compose_file", help="Path to the compose file")
|
|
@@ -1405,36 +1776,40 @@ def main(args: List[str] = None):
|
|
|
1405
1776
|
action="store_true",
|
|
1406
1777
|
help="Force service task updates (Docker-Manager deploys only)",
|
|
1407
1778
|
)
|
|
1408
|
-
deploy_parser
|
|
1779
|
+
_add_namespace_argument(deploy_parser, "Deployment namespace")
|
|
1409
1780
|
deploy_parser.add_argument("-t", "--tag", help="Tag the current deployment for later checkout", required=False)
|
|
1410
1781
|
deploy_parser.add_argument("--show-generated", action="store_true", default=True, help="Show newly generated secrets after deployment")
|
|
1411
1782
|
|
|
1412
1783
|
# Remove subcommand
|
|
1413
1784
|
rm_parser = subparsers.add_parser("rm", help="Remove a deployed stack")
|
|
1414
1785
|
rm_parser.add_argument("stack_name", help="Name of the stack")
|
|
1415
|
-
rm_parser
|
|
1786
|
+
_add_namespace_argument(rm_parser)
|
|
1416
1787
|
|
|
1417
1788
|
# Prune command
|
|
1418
1789
|
subparsers.add_parser("prune", help="Remove old versions of configs and secrets")
|
|
1419
1790
|
|
|
1420
1791
|
# Ls command
|
|
1421
|
-
subparsers.add_parser("ls", help="List docker-stacks")
|
|
1792
|
+
ls_parser = subparsers.add_parser("ls", help="List docker-stacks")
|
|
1793
|
+
_add_listing_namespace_arguments(ls_parser)
|
|
1422
1794
|
|
|
1423
1795
|
node_parser = subparsers.add_parser("node", help="Inspect Docker Swarm nodes")
|
|
1424
1796
|
node_subparsers = node_parser.add_subparsers(dest="node_command", required=True)
|
|
1425
1797
|
node_subparsers.add_parser("ls", help="List Docker Swarm nodes and their labels")
|
|
1798
|
+
node_subparsers.add_parser("current", help="Show the node selected in this managed shell")
|
|
1799
|
+
node_use_parser = node_subparsers.add_parser("use", help="Select a node for node-local Docker commands")
|
|
1800
|
+
node_use_parser.add_argument("node", help="Node id, id prefix, hostname, or 'cluster'")
|
|
1426
1801
|
|
|
1427
1802
|
cat_parser = subparsers.add_parser(
|
|
1428
1803
|
"cat", help="Print the docker compose of specific version. Defaults to latest version if not specified."
|
|
1429
1804
|
)
|
|
1430
1805
|
cat_parser.add_argument("stack_name", help="Name of the stack")
|
|
1431
1806
|
cat_parser.add_argument("version", nargs="?", help="Stack version to cat. Defaults to latest if omitted.")
|
|
1432
|
-
cat_parser
|
|
1807
|
+
_add_namespace_argument(cat_parser)
|
|
1433
1808
|
|
|
1434
1809
|
checkout_parser = subparsers.add_parser("checkout", help="Deploy specific version of the stack")
|
|
1435
1810
|
checkout_parser.add_argument("stack_name", help="Name of the stack")
|
|
1436
1811
|
checkout_parser.add_argument("version", help="Stack version to cat")
|
|
1437
|
-
checkout_parser
|
|
1812
|
+
_add_namespace_argument(checkout_parser)
|
|
1438
1813
|
|
|
1439
1814
|
# version_parser = subparsers.add_parser("version",help="Deploy specific version of the stack")
|
|
1440
1815
|
# version_parser.add_argument("stack_name", help="Name of the stack")
|
|
@@ -1442,7 +1817,7 @@ def main(args: List[str] = None):
|
|
|
1442
1817
|
|
|
1443
1818
|
version_parser = subparsers.add_parser("version", aliases=["versions"], help="Deploy specific version of the stack")
|
|
1444
1819
|
version_parser.add_argument("stack_name", help="Name of the stack")
|
|
1445
|
-
version_parser
|
|
1820
|
+
_add_namespace_argument(version_parser)
|
|
1446
1821
|
|
|
1447
1822
|
parser.add_argument(
|
|
1448
1823
|
"-u", "--user", help="Registry credentials in format hostname:username:password", action="append", required=False, default=[]
|
|
@@ -1471,6 +1846,9 @@ def main(args: List[str] = None):
|
|
|
1471
1846
|
return 2
|
|
1472
1847
|
return 0
|
|
1473
1848
|
|
|
1849
|
+
if args.command == "docker":
|
|
1850
|
+
return _managed_docker(args.docker_args)
|
|
1851
|
+
|
|
1474
1852
|
if args.command == "login":
|
|
1475
1853
|
try:
|
|
1476
1854
|
config = resolve_login_config(
|
|
@@ -1480,6 +1858,8 @@ def main(args: List[str] = None):
|
|
|
1480
1858
|
timeout_secs=args.timeout_secs,
|
|
1481
1859
|
)
|
|
1482
1860
|
current_shell_config = active_shell_config_dir(config.context_name)
|
|
1861
|
+
if current_shell_config is None:
|
|
1862
|
+
current_shell_config = getattr(config, "docker_config_dir", None)
|
|
1483
1863
|
if shell_session_active():
|
|
1484
1864
|
result = login_for_active_shell(config)
|
|
1485
1865
|
config_dir = current_shell_config
|
|
@@ -1513,12 +1893,26 @@ def main(args: List[str] = None):
|
|
|
1513
1893
|
shell_name = args.target if args.context_name is None else None
|
|
1514
1894
|
manager_target = args.target if args.context_name is not None else None
|
|
1515
1895
|
try:
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1896
|
+
try:
|
|
1897
|
+
config = resolve_shell_login_config(
|
|
1898
|
+
shell_name=shell_name,
|
|
1899
|
+
manager_target=manager_target,
|
|
1900
|
+
context_name=args.context_name,
|
|
1901
|
+
timeout_secs=args.timeout_secs,
|
|
1902
|
+
)
|
|
1903
|
+
except UnknownShellContextError as exc:
|
|
1904
|
+
manager_target = _prompt_for_manager_target(exc.context_name)
|
|
1905
|
+
print(f"Creating Docker-Manager shell context '{exc.context_name}'...")
|
|
1906
|
+
config = resolve_shell_login_config(
|
|
1907
|
+
shell_name=None,
|
|
1908
|
+
manager_target=manager_target,
|
|
1909
|
+
context_name=exc.context_name,
|
|
1910
|
+
timeout_secs=args.timeout_secs,
|
|
1911
|
+
)
|
|
1912
|
+
persist_shell_context(config)
|
|
1913
|
+
else:
|
|
1914
|
+
if manager_target is not None:
|
|
1915
|
+
persist_shell_context(config)
|
|
1522
1916
|
if shell_session_active() and os.getenv(SHELL_CONTEXT_ENV) == config.context_name:
|
|
1523
1917
|
print(f"docker-stack shell context={config.context_name} is already active")
|
|
1524
1918
|
return
|
|
@@ -1586,6 +1980,9 @@ def main(args: List[str] = None):
|
|
|
1586
1980
|
docker = Docker(registries=args.user)
|
|
1587
1981
|
docker.load_env()
|
|
1588
1982
|
|
|
1983
|
+
if hasattr(args, "namespace") and args.command != "ls":
|
|
1984
|
+
print(f"Namespace: {args.namespace}", file=sys.stderr)
|
|
1985
|
+
|
|
1589
1986
|
if args.command == "build":
|
|
1590
1987
|
docker.stack.build_and_push(args.compose_file, push=args.push)
|
|
1591
1988
|
elif args.command == "push":
|
|
@@ -1604,10 +2001,23 @@ def main(args: List[str] = None):
|
|
|
1604
2001
|
force_update=args.force_update,
|
|
1605
2002
|
)
|
|
1606
2003
|
elif args.command == "ls":
|
|
1607
|
-
docker.stack.ls()
|
|
2004
|
+
docker.stack.ls(namespace=None if args.all_namespaces else (args.namespace or DEFAULT_NAMESPACE))
|
|
1608
2005
|
elif args.command == "node":
|
|
1609
2006
|
if args.node_command == "ls":
|
|
1610
2007
|
docker.node.ls()
|
|
2008
|
+
elif args.node_command == "current":
|
|
2009
|
+
try:
|
|
2010
|
+
print(_read_selected_node())
|
|
2011
|
+
except RuntimeError as exc:
|
|
2012
|
+
print(f"docker-stack node current: {exc}", file=sys.stderr)
|
|
2013
|
+
return 2
|
|
2014
|
+
elif args.node_command == "use":
|
|
2015
|
+
try:
|
|
2016
|
+
selected = _select_node(args.node)
|
|
2017
|
+
except RuntimeError as exc:
|
|
2018
|
+
print(f"docker-stack node use: {exc}", file=sys.stderr)
|
|
2019
|
+
return 2
|
|
2020
|
+
print(f"Docker node selection: {selected}")
|
|
1611
2021
|
|
|
1612
2022
|
elif args.command == "rm":
|
|
1613
2023
|
docker.stack.rm(args.stack_name, namespace=args.namespace)
|
docker_stack/login.py
CHANGED
|
@@ -25,6 +25,7 @@ class DockerManagerLoginConfig:
|
|
|
25
25
|
context_name: str
|
|
26
26
|
timeout_secs: int
|
|
27
27
|
skip_tls_verify: bool = False
|
|
28
|
+
docker_config_dir: Optional[Path] = None
|
|
28
29
|
|
|
29
30
|
@property
|
|
30
31
|
def docker_context_host(self) -> str:
|
|
@@ -60,6 +61,12 @@ class DockerManagerSetupAuthResult:
|
|
|
60
61
|
validation_skipped: bool = False
|
|
61
62
|
|
|
62
63
|
|
|
64
|
+
class UnknownShellContextError(RuntimeError):
|
|
65
|
+
def __init__(self, context_name: str):
|
|
66
|
+
self.context_name = context_name
|
|
67
|
+
super().__init__(f"Unknown shell context '{context_name}'")
|
|
68
|
+
|
|
69
|
+
|
|
63
70
|
def normalize_loopback_host(url: str) -> str:
|
|
64
71
|
if "127.0.0.1" in url:
|
|
65
72
|
return url.replace("127.0.0.1", "localhost")
|
|
@@ -261,15 +268,23 @@ def resolve_login_config(
|
|
|
261
268
|
current_context_name = None
|
|
262
269
|
current_context_target = None
|
|
263
270
|
inferred_context_name = None
|
|
271
|
+
resolved_docker_config_dir = None
|
|
264
272
|
if not manager_url and not manager_target and not env_manager_value:
|
|
265
273
|
current_context_name, current_context_target = current_docker_context_target()
|
|
266
274
|
|
|
267
275
|
raw_manager_value = manager_url or manager_target or env_manager_value or current_context_target or "http://localhost:8080"
|
|
268
276
|
if manager_target and "://" not in manager_target and ":" not in manager_target:
|
|
269
|
-
|
|
277
|
+
isolated_config_dir = isolated_docker_config_dir(manager_target)
|
|
278
|
+
isolated_context_target = docker_context_target(
|
|
279
|
+
manager_target,
|
|
280
|
+
isolated_config_dir,
|
|
281
|
+
)
|
|
282
|
+
context_target = isolated_context_target or docker_context_target(manager_target)
|
|
270
283
|
if context_target:
|
|
271
284
|
raw_manager_value = context_target
|
|
272
285
|
inferred_context_name = manager_target
|
|
286
|
+
if isolated_context_target:
|
|
287
|
+
resolved_docker_config_dir = isolated_config_dir
|
|
273
288
|
if manager_target:
|
|
274
289
|
resolved_manager_url, skip_tls_verify = detect_manager_url(raw_manager_value, verify_ssl=verify_ssl)
|
|
275
290
|
elif manager_url:
|
|
@@ -296,6 +311,7 @@ def resolve_login_config(
|
|
|
296
311
|
or "dm-proxy",
|
|
297
312
|
timeout_secs=int(timeout_secs or os.getenv("DOCKER_MANAGER_LOGIN_TIMEOUT_SECS", "300")),
|
|
298
313
|
skip_tls_verify=skip_tls_verify,
|
|
314
|
+
docker_config_dir=resolved_docker_config_dir,
|
|
299
315
|
)
|
|
300
316
|
|
|
301
317
|
|
|
@@ -343,10 +359,7 @@ def resolve_shell_login_config(
|
|
|
343
359
|
)
|
|
344
360
|
target = persisted_target or docker_context_target(resolved_context_name)
|
|
345
361
|
if not target:
|
|
346
|
-
raise
|
|
347
|
-
f"Unknown shell context '{resolved_context_name}'. First run "
|
|
348
|
-
f"'docker-stack shell --context {resolved_context_name} <manager-host>'."
|
|
349
|
-
)
|
|
362
|
+
raise UnknownShellContextError(resolved_context_name)
|
|
350
363
|
resolved_manager_url, skip_tls_verify = detect_manager_url(target)
|
|
351
364
|
return DockerManagerLoginConfig(
|
|
352
365
|
manager_url=resolved_manager_url,
|
|
@@ -618,6 +631,13 @@ def configure_docker_context_in_store(config: DockerManagerLoginConfig, docker_c
|
|
|
618
631
|
raise RuntimeError((result.stderr or result.stdout or "failed to configure docker context").strip())
|
|
619
632
|
|
|
620
633
|
|
|
634
|
+
def persist_shell_context(config: DockerManagerLoginConfig) -> Path:
|
|
635
|
+
config_dir = isolated_docker_config_dir(config.context_name)
|
|
636
|
+
ensure_isolated_docker_config(config_dir)
|
|
637
|
+
configure_docker_context_in_store(config, config_dir)
|
|
638
|
+
return config_dir
|
|
639
|
+
|
|
640
|
+
|
|
621
641
|
def ensure_isolated_login(
|
|
622
642
|
config: DockerManagerLoginConfig,
|
|
623
643
|
*,
|
docker_stack/manager_api.py
CHANGED
|
@@ -18,6 +18,7 @@ from docker_stack.shell_auth import ensure_shell_access
|
|
|
18
18
|
FEATURE_MESUDIP_DOCKER_ENTERPRISE = "mesudip-docker-enterprise-v1"
|
|
19
19
|
FEATURE_STACK_QUERY = "docker_stack_query_v1"
|
|
20
20
|
FEATURE_STACK_DEPLOY = "docker_stack_deploy_v1"
|
|
21
|
+
FEATURE_CLUSTER_CONTAINER_CLI = "cluster_container_cli_v1"
|
|
21
22
|
DEFAULT_NAMESPACE = "default"
|
|
22
23
|
|
|
23
24
|
|
|
@@ -429,10 +430,13 @@ class ManagerApiClient:
|
|
|
429
430
|
return False
|
|
430
431
|
return True
|
|
431
432
|
|
|
432
|
-
def list_stacks(self) -> Dict[str, Any]:
|
|
433
|
+
def list_stacks(self, *, namespace: Optional[str] = DEFAULT_NAMESPACE) -> Dict[str, Any]:
|
|
434
|
+
query = urllib.parse.urlencode(
|
|
435
|
+
{"namespace": namespace} if namespace is not None else {"all_namespaces": "true"}
|
|
436
|
+
)
|
|
433
437
|
if self._detect_manager_backend():
|
|
434
|
-
return self._request_json("/api/docker-stack/stacks")
|
|
435
|
-
return self._request_json(self._endpoint_path(
|
|
438
|
+
return self._request_json(f"/api/docker-stack/stacks?{query}")
|
|
439
|
+
return self._request_json(f"{self._endpoint_path('/inventory/stacks')}?{query}")
|
|
436
440
|
|
|
437
441
|
def list_stack_versions(self, stack_name: str, *, namespace: str = DEFAULT_NAMESPACE) -> Dict[str, Any]:
|
|
438
442
|
stack = urllib.parse.quote(stack_name, safe="")
|
|
@@ -487,6 +491,7 @@ class ManagerApiClient:
|
|
|
487
491
|
|
|
488
492
|
nodes.append(
|
|
489
493
|
{
|
|
494
|
+
"id": str(item.get("ID") or ""),
|
|
490
495
|
"hostname": str(description.get("Hostname") or spec.get("Name") or item.get("ID") or "-"),
|
|
491
496
|
"role": role,
|
|
492
497
|
"manager_status": manager_status,
|
|
@@ -499,6 +504,39 @@ class ManagerApiClient:
|
|
|
499
504
|
|
|
500
505
|
return {"nodes": nodes}
|
|
501
506
|
|
|
507
|
+
def list_containers(
|
|
508
|
+
self,
|
|
509
|
+
*,
|
|
510
|
+
all_containers: bool = False,
|
|
511
|
+
filters: Optional[list[str]] = None,
|
|
512
|
+
limit: Optional[int] = None,
|
|
513
|
+
latest: bool = False,
|
|
514
|
+
size: bool = False,
|
|
515
|
+
) -> Dict[str, Any]:
|
|
516
|
+
docker_filters: Dict[str, list[str]] = {}
|
|
517
|
+
for raw_filter in filters or []:
|
|
518
|
+
key, separator, value = raw_filter.partition("=")
|
|
519
|
+
key = key.strip()
|
|
520
|
+
if not key or not separator:
|
|
521
|
+
raise RuntimeError(f"Invalid Docker filter: {raw_filter}")
|
|
522
|
+
docker_filters.setdefault(key, []).append(value)
|
|
523
|
+
params: Dict[str, Any] = {
|
|
524
|
+
"all": "1" if all_containers else "0",
|
|
525
|
+
"size": "1" if size else "0",
|
|
526
|
+
}
|
|
527
|
+
if docker_filters:
|
|
528
|
+
params["filters"] = json.dumps(docker_filters, separators=(",", ":"))
|
|
529
|
+
if limit is not None:
|
|
530
|
+
params["limit"] = str(limit)
|
|
531
|
+
if latest:
|
|
532
|
+
params["latest"] = "1"
|
|
533
|
+
payload = self._request_json(
|
|
534
|
+
f"/api/docker-stack/containers?{urllib.parse.urlencode(params)}"
|
|
535
|
+
)
|
|
536
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("containers"), list):
|
|
537
|
+
raise RuntimeError("Docker-Manager containers response is invalid")
|
|
538
|
+
return payload
|
|
539
|
+
|
|
502
540
|
def resolve_config(
|
|
503
541
|
self,
|
|
504
542
|
*,
|
docker_stack/shell_auth.py
CHANGED
|
@@ -32,6 +32,7 @@ SHELL_SOCKET_ENV = "DOCKER_STACK_SHELL_SOCKET"
|
|
|
32
32
|
SHELL_SECRET_ENV = "DOCKER_STACK_SHELL_SECRET"
|
|
33
33
|
SHELL_CONTEXT_ENV = "DOCKER_STACK_SHELL_CONTEXT"
|
|
34
34
|
SHELL_STATUS_ENV = "DOCKER_STACK_SHELL_STATUS"
|
|
35
|
+
SHELL_NODE_STATE_ENV = "DOCKER_STACK_SHELL_NODE_STATE"
|
|
35
36
|
SHELL_ORIGINAL_ZDOTDIR_ENV = "DOCKER_STACK_SHELL_ORIGINAL_ZDOTDIR"
|
|
36
37
|
SHELL_AUTH_TIMEOUT_ENV = "DOCKER_STACK_SHELL_AUTH_TIMEOUT_SECS"
|
|
37
38
|
|
|
@@ -530,9 +531,11 @@ def _write_shell_wrappers(
|
|
|
530
531
|
*,
|
|
531
532
|
context_name: str,
|
|
532
533
|
status_path: Path,
|
|
534
|
+
node_state_path: Optional[Path] = None,
|
|
533
535
|
) -> Dict[str, str]:
|
|
534
536
|
context = _prompt_context(context_name)
|
|
535
537
|
status_path_value = str(status_path)
|
|
538
|
+
node_state_path_value = str(node_state_path or status_path.with_name("node-selection"))
|
|
536
539
|
no_color = bool(os.getenv("NO_COLOR"))
|
|
537
540
|
bash_rc = session_dir / "bashrc"
|
|
538
541
|
zsh_dir = session_dir / "zsh"
|
|
@@ -542,11 +545,18 @@ def _write_shell_wrappers(
|
|
|
542
545
|
os.getenv(SHELL_ORIGINAL_ZDOTDIR_ENV) or os.getenv("ZDOTDIR", str(Path.home()))
|
|
543
546
|
)
|
|
544
547
|
original_zshrc = original_zshrc_root / ".zshrc"
|
|
548
|
+
original_zsh_history = original_zshrc_root / ".zsh_history"
|
|
545
549
|
bash_rc.write_text(
|
|
546
550
|
f'''[ -f {json.dumps(str(original_bashrc))} ] && source {json.dumps(str(original_bashrc))}
|
|
547
551
|
__docker_stack_base_ps1="$PS1"
|
|
548
552
|
__docker_stack_prompt_update() {{
|
|
549
|
-
local state expires now color reset
|
|
553
|
+
local state expires now color reset node
|
|
554
|
+
if [ -n "${{DOCKER_HOST:-}}" ]; then
|
|
555
|
+
color='\\[\\e[31m\\]'; reset='\\[\\e[0m\\]'
|
|
556
|
+
[ {str(no_color).lower()} = true ] && color= && reset=
|
|
557
|
+
PS1="${{color}}(docker:!DOCKER_HOST)${{reset}} ${{__docker_stack_base_ps1}}"
|
|
558
|
+
return
|
|
559
|
+
fi
|
|
550
560
|
read -r state expires < {json.dumps(status_path_value)} 2>/dev/null || state=error
|
|
551
561
|
now=$(date +%s)
|
|
552
562
|
if [ "$state" = active ] && [ "${{expires:-0}}" -gt $((now + {REFRESH_WINDOW_SECS})) ]; then color='\\[\\e[32m\\]';
|
|
@@ -554,21 +564,35 @@ __docker_stack_prompt_update() {{
|
|
|
554
564
|
else color='\\[\\e[31m\\]'; fi
|
|
555
565
|
reset='\\[\\e[0m\\]'
|
|
556
566
|
[ {str(no_color).lower()} = true ] && color= && reset=
|
|
557
|
-
|
|
567
|
+
node=$(cat {json.dumps(node_state_path_value)} 2>/dev/null || printf cluster)
|
|
568
|
+
PS1="${{color}}(docker:{context}@${{node}})${{reset}} ${{__docker_stack_base_ps1}}"
|
|
558
569
|
}}
|
|
559
570
|
case ";${{PROMPT_COMMAND:-}};" in *';__docker_stack_prompt_update;'*) ;; *) PROMPT_COMMAND="__docker_stack_prompt_update${{PROMPT_COMMAND:+;$PROMPT_COMMAND}}";; esac
|
|
560
|
-
docker() {{ docker-stack shell-auth ensure || return $?;
|
|
571
|
+
docker() {{ docker-stack shell-auth ensure || return $?; docker-stack docker -- "$@"; }}
|
|
561
572
|
''',
|
|
562
573
|
encoding="utf-8",
|
|
563
574
|
)
|
|
564
575
|
os.chmod(bash_rc, 0o600)
|
|
565
576
|
zsh_rc = zsh_dir / ".zshrc"
|
|
566
577
|
zsh_rc.write_text(
|
|
567
|
-
f'''
|
|
578
|
+
f'''typeset -g __docker_stack_session_zdotdir="$ZDOTDIR"
|
|
579
|
+
typeset -g ZDOTDIR={json.dumps(str(original_zshrc_root))}
|
|
580
|
+
typeset -g HISTFILE={json.dumps(str(original_zsh_history))}
|
|
581
|
+
typeset -g SHELL_SESSION_HISTORY=0
|
|
582
|
+
[ -f {json.dumps(str(original_zshrc))} ] && source {json.dumps(str(original_zshrc))}
|
|
583
|
+
[[ -r "$HISTFILE" ]] && fc -R "$HISTFILE"
|
|
584
|
+
typeset -g ZDOTDIR="$__docker_stack_session_zdotdir"
|
|
585
|
+
unset __docker_stack_session_zdotdir
|
|
568
586
|
autoload -Uz add-zsh-hook
|
|
569
587
|
typeset -g __docker_stack_base_prompt="$PROMPT"
|
|
570
588
|
__docker_stack_prompt_update() {{
|
|
571
|
-
local state expires now color reset
|
|
589
|
+
local state expires now color reset node
|
|
590
|
+
if [[ -n "${{DOCKER_HOST:-}}" ]]; then
|
|
591
|
+
color='%F{{red}}'; reset='%f'
|
|
592
|
+
[[ -n "${{NO_COLOR:-}}" ]] && color= && reset=
|
|
593
|
+
PROMPT="${{color}}(docker:!DOCKER_HOST)${{reset}} ${{__docker_stack_base_prompt}}"
|
|
594
|
+
return
|
|
595
|
+
fi
|
|
572
596
|
read -r state expires < {json.dumps(status_path_value)} 2>/dev/null || state=error
|
|
573
597
|
now=$(date +%s)
|
|
574
598
|
if [[ "$state" = active && "${{expires:-0}}" -gt $((now + {REFRESH_WINDOW_SECS})) ]]; then color='%F{{green}}';
|
|
@@ -576,10 +600,11 @@ __docker_stack_prompt_update() {{
|
|
|
576
600
|
else color='%F{{red}}'; fi
|
|
577
601
|
reset='%f'
|
|
578
602
|
[[ -n "${{NO_COLOR:-}}" ]] && color= && reset=
|
|
579
|
-
|
|
603
|
+
node=$(cat {json.dumps(node_state_path_value)} 2>/dev/null || printf cluster)
|
|
604
|
+
PROMPT="${{color}}(docker:{context}@${{node}})${{reset}} ${{__docker_stack_base_prompt}}"
|
|
580
605
|
}}
|
|
581
606
|
add-zsh-hook precmd __docker_stack_prompt_update
|
|
582
|
-
docker() {{ docker-stack shell-auth ensure || return $?;
|
|
607
|
+
docker() {{ docker-stack shell-auth ensure || return $?; docker-stack docker -- "$@"; }}
|
|
583
608
|
''',
|
|
584
609
|
encoding="utf-8",
|
|
585
610
|
)
|
|
@@ -638,6 +663,9 @@ def run_managed_shell(config: DockerManagerLoginConfig, result: DockerManagerLog
|
|
|
638
663
|
update_access_header(config_path, result.access_token)
|
|
639
664
|
socket_path = session_dir / "auth.sock"
|
|
640
665
|
status_path = session_dir / "status"
|
|
666
|
+
node_state_path = session_dir / "node-selection"
|
|
667
|
+
node_state_path.write_text("cluster\n", encoding="utf-8")
|
|
668
|
+
os.chmod(node_state_path, 0o600)
|
|
641
669
|
shell_secret = os.urandom(32).hex()
|
|
642
670
|
broker_env = dict(os.environ)
|
|
643
671
|
for key in (SHELL_SOCKET_ENV, SHELL_SECRET_ENV, SHELL_CONTEXT_ENV, SHELL_STATUS_ENV):
|
|
@@ -689,12 +717,14 @@ def run_managed_shell(config: DockerManagerLoginConfig, result: DockerManagerLog
|
|
|
689
717
|
shell,
|
|
690
718
|
context_name=config.context_name,
|
|
691
719
|
status_path=status_path,
|
|
720
|
+
node_state_path=node_state_path,
|
|
692
721
|
)
|
|
693
722
|
env = dict(os.environ)
|
|
694
723
|
env[SHELL_SOCKET_ENV] = str(socket_path)
|
|
695
724
|
env[SHELL_SECRET_ENV] = shell_secret
|
|
696
725
|
env[SHELL_CONTEXT_ENV] = config.context_name
|
|
697
726
|
env[SHELL_STATUS_ENV] = str(status_path)
|
|
727
|
+
env[SHELL_NODE_STATE_ENV] = str(node_state_path)
|
|
698
728
|
env[SHELL_AUTH_TIMEOUT_ENV] = str(max(1, config.timeout_secs) + 5)
|
|
699
729
|
env.update(wrapper_env)
|
|
700
730
|
for key in ("DOCKER_HOST", "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH", "DOCKER_API_VERSION", "DOCKER_MACHINE_NAME"):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: docker-stack
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.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
|
|
@@ -47,6 +47,8 @@ Typical daemon-only workflow:
|
|
|
47
47
|
```bash
|
|
48
48
|
docker-stack deploy my-stack docker-compose.yml
|
|
49
49
|
docker-stack ls
|
|
50
|
+
docker-stack ls -n team-a
|
|
51
|
+
docker-stack ls -A
|
|
50
52
|
docker-stack versions my-stack
|
|
51
53
|
docker-stack cat my-stack
|
|
52
54
|
docker-stack checkout my-stack v2
|
|
@@ -82,7 +84,10 @@ Use this option when CI can connect directly to the target Docker daemon.
|
|
|
82
84
|
|
|
83
85
|
#### 2. Docker-Manager
|
|
84
86
|
|
|
85
|
-
Use the bundled action when deploying through
|
|
87
|
+
Use the bundled action when deploying through
|
|
88
|
+
[Docker-Manager](https://github.com/mesudip/docker-enterprise). The manager
|
|
89
|
+
repository contains the server source, installation instructions, and deployment
|
|
90
|
+
documentation.
|
|
86
91
|
|
|
87
92
|
For a full compose deployment directly from CI, use the action to configure
|
|
88
93
|
Docker-Manager authentication and then run the normal `docker-stack deploy`
|
|
@@ -152,11 +157,48 @@ Open an isolated Bash or Zsh session for a manager context:
|
|
|
152
157
|
docker-stack shell office
|
|
153
158
|
```
|
|
154
159
|
|
|
155
|
-
|
|
160
|
+
If `office` does not exist yet, the CLI asks for its Docker-Manager URL, creates
|
|
161
|
+
the context, authenticates, and opens the shell. You can also provide everything
|
|
162
|
+
non-interactively with `docker-stack shell --context office <manager-url>`.
|
|
163
|
+
|
|
164
|
+
Stack commands use the `default` namespace unless `-n/--namespace` is supplied.
|
|
165
|
+
Listings print the selected namespace; `docker-stack ls -A` (or
|
|
166
|
+
`--all-namespaces`) lists every visible namespace.
|
|
167
|
+
|
|
168
|
+
The prompt displays `(docker:office@cluster)`, keeps the selected manager context active,
|
|
156
169
|
and refreshes authentication when needed. The session supports `docker` and
|
|
157
170
|
`docker compose`; legacy `docker-compose` is not supported. If authentication
|
|
158
171
|
expires, run `docker-stack login` again.
|
|
159
172
|
|
|
173
|
+
Container listing is cluster-aware and includes the owning Swarm node. Select a
|
|
174
|
+
node when working with daemon-local resources such as volumes and images:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
docker ps
|
|
178
|
+
docker-stack node current
|
|
179
|
+
docker-stack node use worker-02
|
|
180
|
+
docker volume ls
|
|
181
|
+
docker image ls
|
|
182
|
+
docker-stack node use cluster
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Node selection is stored only in the managed shell's isolated Docker
|
|
186
|
+
configuration and appears in the prompt as `(docker:office@worker-02)`.
|
|
187
|
+
Selected-node requests remain subject to the manager's Docker permissions;
|
|
188
|
+
image management is root-only because the manager does not define delegated
|
|
189
|
+
image permissions.
|
|
190
|
+
|
|
191
|
+
Cluster-aware output is enabled after the manager has observed compatible
|
|
192
|
+
agents on every discovered node. The manager remembers that capability until it
|
|
193
|
+
restarts, so a temporary agent outage does not make the CLI revert to a legacy
|
|
194
|
+
feature decision. A partial cluster listing prints the available containers,
|
|
195
|
+
reports each failed node on stderr with its incident id, and exits non-zero.
|
|
196
|
+
|
|
197
|
+
`docker ps --quiet` and `docker ps --format ...` use Docker's native formatter
|
|
198
|
+
and therefore do not add the `NODE` column. Explicit Docker overrides such as
|
|
199
|
+
`--context`, `--host`/`-H`, and `--config` bypass managed cluster formatting and
|
|
200
|
+
are sent unchanged to the Docker CLI.
|
|
201
|
+
|
|
160
202
|
## Core Capabilities
|
|
161
203
|
|
|
162
204
|
- **Advanced Deployments on Plain Docker Daemons:**
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
docker_stack/__init__.py,sha256=qgHdW8ZDBlyk6FzIf8Mld_cJD1SjOfqo2dELg4pK668,880
|
|
2
|
-
docker_stack/cli.py,sha256=
|
|
2
|
+
docker_stack/cli.py,sha256=yvBl8rI6GjpmgFyNBhP1shEDQUTTLjRJI2uzo8Q6xdg,88496
|
|
3
3
|
docker_stack/command_runner.py,sha256=mNaUAVKtrJbji_5ARVPLKhc92avqllNKrVQj1A6S0dA,1252
|
|
4
4
|
docker_stack/compose.py,sha256=_fAVesjyW5ecid46sYYhcu02yF1vqknDSCFYflJPDOE,534
|
|
5
5
|
docker_stack/docker_objects.py,sha256=U6szytv5gKrmBYKqThSeMMXdiJ2u4STIQLkeqQ-Fn4Q,12103
|
|
6
6
|
docker_stack/envsubst.py,sha256=gdEUpsjWJeNbbahySk56vUdQh1nns85o3LbU6SUBo3I,8337
|
|
7
7
|
docker_stack/envsubst_merge.py,sha256=TwOu8BducG0rWnSreJGwesNi-VghCetljcf-aWceE8M,4971
|
|
8
8
|
docker_stack/helpers.py,sha256=nJHaIS1vtFyPYclXVl3-Qd-1h55jnSy1LwdATDrF4hk,5953
|
|
9
|
-
docker_stack/login.py,sha256=
|
|
10
|
-
docker_stack/manager_api.py,sha256=
|
|
9
|
+
docker_stack/login.py,sha256=SNHL2S3InQ7Qc3ITV1VySazix1f7H0OD7IvTPBTgF4Y,33977
|
|
10
|
+
docker_stack/manager_api.py,sha256=xb8RwY4LEn1usg9qhG9AfiCvBJ2tpQawVMLRwF3vYHY,28361
|
|
11
11
|
docker_stack/markers.py,sha256=gqStnr0tNWZ1kzqvREc2jVQVQrevheShgJtL5QzSx4g,2388
|
|
12
12
|
docker_stack/merge_conf.py,sha256=Pmsabcgf3SDyaWvf2OLsBPodzEqsvOIjj9xzn_qWXH0,1917
|
|
13
13
|
docker_stack/registry.py,sha256=sWC1J9JDIrcYyhei1POYCZHJ0xUCKC2pveisSHMgYsQ,9036
|
|
14
|
-
docker_stack/shell_auth.py,sha256=
|
|
14
|
+
docker_stack/shell_auth.py,sha256=em0_lo1qnLIB4KHYqn4vQvqwKe7HkV9NM_I0VQTDDQ4,33082
|
|
15
15
|
docker_stack/url_parser.py,sha256=Sk8GQE0nEiwCkijp1OltP2AfgtKEmM2hybcH_rBhfiI,6824
|
|
16
|
-
docker_stack-2.
|
|
17
|
-
docker_stack-2.
|
|
18
|
-
docker_stack-2.
|
|
19
|
-
docker_stack-2.
|
|
20
|
-
docker_stack-2.
|
|
16
|
+
docker_stack-2.2.0.dist-info/METADATA,sha256=xxDwMGLFu2AitS8ZFn0nOAVdTG-Z5yLxlfdW70qMkbg,13821
|
|
17
|
+
docker_stack-2.2.0.dist-info/WHEEL,sha256=SmOxYU7pzNKBqASvQJ7DjX3XGUF92lrGhMb3R6_iiqI,91
|
|
18
|
+
docker_stack-2.2.0.dist-info/entry_points.txt,sha256=mpe2RwIguARsosXIUBQEN2pKU53URqZCh2S4ATwDFL4,55
|
|
19
|
+
docker_stack-2.2.0.dist-info/top_level.txt,sha256=zT6TPL54cLrt9LO_MNkhEpGGOmsoe2HV6Na5Ohy3_2c,13
|
|
20
|
+
docker_stack-2.2.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|