docker-stack 2.1.2__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 +256 -0
- docker_stack/login.py +11 -1
- docker_stack/manager_api.py +35 -0
- docker_stack/shell_auth.py +37 -7
- {docker_stack-2.1.2.dist-info → docker_stack-2.2.0.dist-info}/METADATA +35 -3
- {docker_stack-2.1.2.dist-info → docker_stack-2.2.0.dist-info}/RECORD +9 -9
- {docker_stack-2.1.2.dist-info → docker_stack-2.2.0.dist-info}/WHEEL +0 -0
- {docker_stack-2.1.2.dist-info → docker_stack-2.2.0.dist-info}/entry_points.txt +0 -0
- {docker_stack-2.1.2.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 (
|
|
@@ -28,6 +29,7 @@ from docker_stack.login import (
|
|
|
28
29
|
)
|
|
29
30
|
from docker_stack.shell_auth import (
|
|
30
31
|
SHELL_CONTEXT_ENV,
|
|
32
|
+
SHELL_NODE_STATE_ENV,
|
|
31
33
|
ShellAuthError,
|
|
32
34
|
ensure_shell_access,
|
|
33
35
|
login_for_active_shell,
|
|
@@ -35,6 +37,7 @@ from docker_stack.shell_auth import (
|
|
|
35
37
|
shell_session_active,
|
|
36
38
|
)
|
|
37
39
|
from docker_stack.manager_api import (
|
|
40
|
+
FEATURE_CLUSTER_CONTAINER_CLI,
|
|
38
41
|
FEATURE_STACK_DEPLOY,
|
|
39
42
|
FEATURE_STACK_QUERY,
|
|
40
43
|
ManagerApiClient,
|
|
@@ -51,6 +54,232 @@ DOCKER_SHELL_ENDPOINT_ENV_VARS = (
|
|
|
51
54
|
"DOCKER_MACHINE_NAME",
|
|
52
55
|
)
|
|
53
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
|
|
54
283
|
|
|
55
284
|
|
|
56
285
|
def physical_stack_name(namespace: str, stack: str) -> str:
|
|
@@ -1519,6 +1748,9 @@ def main(args: List[str] = None):
|
|
|
1519
1748
|
shell_auth_parser = subparsers.add_parser("shell-auth", help=argparse.SUPPRESS)
|
|
1520
1749
|
shell_auth_parser.add_argument("operation", choices=("ensure", "status"), help=argparse.SUPPRESS)
|
|
1521
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
|
+
|
|
1522
1754
|
# Build subcommand
|
|
1523
1755
|
build_parser = subparsers.add_parser("build", help="Build images using docker-compose")
|
|
1524
1756
|
build_parser.add_argument("compose_file", help="Path to the compose file")
|
|
@@ -1563,6 +1795,9 @@ def main(args: List[str] = None):
|
|
|
1563
1795
|
node_parser = subparsers.add_parser("node", help="Inspect Docker Swarm nodes")
|
|
1564
1796
|
node_subparsers = node_parser.add_subparsers(dest="node_command", required=True)
|
|
1565
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'")
|
|
1566
1801
|
|
|
1567
1802
|
cat_parser = subparsers.add_parser(
|
|
1568
1803
|
"cat", help="Print the docker compose of specific version. Defaults to latest version if not specified."
|
|
@@ -1611,6 +1846,9 @@ def main(args: List[str] = None):
|
|
|
1611
1846
|
return 2
|
|
1612
1847
|
return 0
|
|
1613
1848
|
|
|
1849
|
+
if args.command == "docker":
|
|
1850
|
+
return _managed_docker(args.docker_args)
|
|
1851
|
+
|
|
1614
1852
|
if args.command == "login":
|
|
1615
1853
|
try:
|
|
1616
1854
|
config = resolve_login_config(
|
|
@@ -1620,6 +1858,8 @@ def main(args: List[str] = None):
|
|
|
1620
1858
|
timeout_secs=args.timeout_secs,
|
|
1621
1859
|
)
|
|
1622
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)
|
|
1623
1863
|
if shell_session_active():
|
|
1624
1864
|
result = login_for_active_shell(config)
|
|
1625
1865
|
config_dir = current_shell_config
|
|
@@ -1670,6 +1910,9 @@ def main(args: List[str] = None):
|
|
|
1670
1910
|
timeout_secs=args.timeout_secs,
|
|
1671
1911
|
)
|
|
1672
1912
|
persist_shell_context(config)
|
|
1913
|
+
else:
|
|
1914
|
+
if manager_target is not None:
|
|
1915
|
+
persist_shell_context(config)
|
|
1673
1916
|
if shell_session_active() and os.getenv(SHELL_CONTEXT_ENV) == config.context_name:
|
|
1674
1917
|
print(f"docker-stack shell context={config.context_name} is already active")
|
|
1675
1918
|
return
|
|
@@ -1762,6 +2005,19 @@ def main(args: List[str] = None):
|
|
|
1762
2005
|
elif args.command == "node":
|
|
1763
2006
|
if args.node_command == "ls":
|
|
1764
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}")
|
|
1765
2021
|
|
|
1766
2022
|
elif args.command == "rm":
|
|
1767
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:
|
|
@@ -267,15 +268,23 @@ def resolve_login_config(
|
|
|
267
268
|
current_context_name = None
|
|
268
269
|
current_context_target = None
|
|
269
270
|
inferred_context_name = None
|
|
271
|
+
resolved_docker_config_dir = None
|
|
270
272
|
if not manager_url and not manager_target and not env_manager_value:
|
|
271
273
|
current_context_name, current_context_target = current_docker_context_target()
|
|
272
274
|
|
|
273
275
|
raw_manager_value = manager_url or manager_target or env_manager_value or current_context_target or "http://localhost:8080"
|
|
274
276
|
if manager_target and "://" not in manager_target and ":" not in manager_target:
|
|
275
|
-
|
|
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)
|
|
276
283
|
if context_target:
|
|
277
284
|
raw_manager_value = context_target
|
|
278
285
|
inferred_context_name = manager_target
|
|
286
|
+
if isolated_context_target:
|
|
287
|
+
resolved_docker_config_dir = isolated_config_dir
|
|
279
288
|
if manager_target:
|
|
280
289
|
resolved_manager_url, skip_tls_verify = detect_manager_url(raw_manager_value, verify_ssl=verify_ssl)
|
|
281
290
|
elif manager_url:
|
|
@@ -302,6 +311,7 @@ def resolve_login_config(
|
|
|
302
311
|
or "dm-proxy",
|
|
303
312
|
timeout_secs=int(timeout_secs or os.getenv("DOCKER_MANAGER_LOGIN_TIMEOUT_SECS", "300")),
|
|
304
313
|
skip_tls_verify=skip_tls_verify,
|
|
314
|
+
docker_config_dir=resolved_docker_config_dir,
|
|
305
315
|
)
|
|
306
316
|
|
|
307
317
|
|
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
|
|
|
@@ -490,6 +491,7 @@ class ManagerApiClient:
|
|
|
490
491
|
|
|
491
492
|
nodes.append(
|
|
492
493
|
{
|
|
494
|
+
"id": str(item.get("ID") or ""),
|
|
493
495
|
"hostname": str(description.get("Hostname") or spec.get("Name") or item.get("ID") or "-"),
|
|
494
496
|
"role": role,
|
|
495
497
|
"manager_status": manager_status,
|
|
@@ -502,6 +504,39 @@ class ManagerApiClient:
|
|
|
502
504
|
|
|
503
505
|
return {"nodes": nodes}
|
|
504
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
|
+
|
|
505
540
|
def resolve_config(
|
|
506
541
|
self,
|
|
507
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
|
|
@@ -84,7 +84,10 @@ Use this option when CI can connect directly to the target Docker daemon.
|
|
|
84
84
|
|
|
85
85
|
#### 2. Docker-Manager
|
|
86
86
|
|
|
87
|
-
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.
|
|
88
91
|
|
|
89
92
|
For a full compose deployment directly from CI, use the action to configure
|
|
90
93
|
Docker-Manager authentication and then run the normal `docker-stack deploy`
|
|
@@ -162,11 +165,40 @@ Stack commands use the `default` namespace unless `-n/--namespace` is supplied.
|
|
|
162
165
|
Listings print the selected namespace; `docker-stack ls -A` (or
|
|
163
166
|
`--all-namespaces`) lists every visible namespace.
|
|
164
167
|
|
|
165
|
-
The prompt displays `(docker:office)`, keeps the selected manager context active,
|
|
168
|
+
The prompt displays `(docker:office@cluster)`, keeps the selected manager context active,
|
|
166
169
|
and refreshes authentication when needed. The session supports `docker` and
|
|
167
170
|
`docker compose`; legacy `docker-compose` is not supported. If authentication
|
|
168
171
|
expires, run `docker-stack login` again.
|
|
169
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
|
+
|
|
170
202
|
## Core Capabilities
|
|
171
203
|
|
|
172
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
|