docker-stack 2.1.2__tar.gz → 2.2.0__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.1.2 → docker_stack-2.2.0}/PKG-INFO +35 -3
- {docker_stack-2.1.2 → docker_stack-2.2.0}/README.md +34 -2
- {docker_stack-2.1.2 → docker_stack-2.2.0}/docker_stack/cli.py +256 -0
- {docker_stack-2.1.2 → docker_stack-2.2.0}/docker_stack/login.py +11 -1
- {docker_stack-2.1.2 → docker_stack-2.2.0}/docker_stack/manager_api.py +35 -0
- {docker_stack-2.1.2 → docker_stack-2.2.0}/docker_stack/shell_auth.py +37 -7
- {docker_stack-2.1.2 → docker_stack-2.2.0}/docker_stack.egg-info/PKG-INFO +35 -3
- {docker_stack-2.1.2 → docker_stack-2.2.0}/setup.py +1 -1
- {docker_stack-2.1.2 → docker_stack-2.2.0}/tests/test_docker_stack.py +189 -1
- {docker_stack-2.1.2 → docker_stack-2.2.0}/tests/test_login.py +20 -0
- {docker_stack-2.1.2 → docker_stack-2.2.0}/tests/test_shell_auth.py +55 -2
- {docker_stack-2.1.2 → docker_stack-2.2.0}/docker_stack/__init__.py +0 -0
- {docker_stack-2.1.2 → docker_stack-2.2.0}/docker_stack/command_runner.py +0 -0
- {docker_stack-2.1.2 → docker_stack-2.2.0}/docker_stack/compose.py +0 -0
- {docker_stack-2.1.2 → docker_stack-2.2.0}/docker_stack/docker_objects.py +0 -0
- {docker_stack-2.1.2 → docker_stack-2.2.0}/docker_stack/envsubst.py +0 -0
- {docker_stack-2.1.2 → docker_stack-2.2.0}/docker_stack/envsubst_merge.py +0 -0
- {docker_stack-2.1.2 → docker_stack-2.2.0}/docker_stack/helpers.py +0 -0
- {docker_stack-2.1.2 → docker_stack-2.2.0}/docker_stack/markers.py +0 -0
- {docker_stack-2.1.2 → docker_stack-2.2.0}/docker_stack/merge_conf.py +0 -0
- {docker_stack-2.1.2 → docker_stack-2.2.0}/docker_stack/registry.py +0 -0
- {docker_stack-2.1.2 → docker_stack-2.2.0}/docker_stack/url_parser.py +0 -0
- {docker_stack-2.1.2 → docker_stack-2.2.0}/docker_stack.egg-info/SOURCES.txt +0 -0
- {docker_stack-2.1.2 → docker_stack-2.2.0}/docker_stack.egg-info/dependency_links.txt +0 -0
- {docker_stack-2.1.2 → docker_stack-2.2.0}/docker_stack.egg-info/entry_points.txt +0 -0
- {docker_stack-2.1.2 → docker_stack-2.2.0}/docker_stack.egg-info/requires.txt +0 -0
- {docker_stack-2.1.2 → docker_stack-2.2.0}/docker_stack.egg-info/top_level.txt +0 -0
- {docker_stack-2.1.2 → docker_stack-2.2.0}/pyproject.toml +0 -0
- {docker_stack-2.1.2 → docker_stack-2.2.0}/setup.cfg +0 -0
- {docker_stack-2.1.2 → docker_stack-2.2.0}/tests/test_docker_objects.py +0 -0
- {docker_stack-2.1.2 → docker_stack-2.2.0}/tests/test_load_env.py +0 -0
- {docker_stack-2.1.2 → docker_stack-2.2.0}/tests/test_manager_api.py +0 -0
- {docker_stack-2.1.2 → docker_stack-2.2.0}/tests/test_node_ls.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: docker-stack
|
|
3
|
-
Version: 2.
|
|
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:**
|
|
@@ -58,7 +58,10 @@ Use this option when CI can connect directly to the target Docker daemon.
|
|
|
58
58
|
|
|
59
59
|
#### 2. Docker-Manager
|
|
60
60
|
|
|
61
|
-
Use the bundled action when deploying through
|
|
61
|
+
Use the bundled action when deploying through
|
|
62
|
+
[Docker-Manager](https://github.com/mesudip/docker-enterprise). The manager
|
|
63
|
+
repository contains the server source, installation instructions, and deployment
|
|
64
|
+
documentation.
|
|
62
65
|
|
|
63
66
|
For a full compose deployment directly from CI, use the action to configure
|
|
64
67
|
Docker-Manager authentication and then run the normal `docker-stack deploy`
|
|
@@ -136,11 +139,40 @@ Stack commands use the `default` namespace unless `-n/--namespace` is supplied.
|
|
|
136
139
|
Listings print the selected namespace; `docker-stack ls -A` (or
|
|
137
140
|
`--all-namespaces`) lists every visible namespace.
|
|
138
141
|
|
|
139
|
-
The prompt displays `(docker:office)`, keeps the selected manager context active,
|
|
142
|
+
The prompt displays `(docker:office@cluster)`, keeps the selected manager context active,
|
|
140
143
|
and refreshes authentication when needed. The session supports `docker` and
|
|
141
144
|
`docker compose`; legacy `docker-compose` is not supported. If authentication
|
|
142
145
|
expires, run `docker-stack login` again.
|
|
143
146
|
|
|
147
|
+
Container listing is cluster-aware and includes the owning Swarm node. Select a
|
|
148
|
+
node when working with daemon-local resources such as volumes and images:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
docker ps
|
|
152
|
+
docker-stack node current
|
|
153
|
+
docker-stack node use worker-02
|
|
154
|
+
docker volume ls
|
|
155
|
+
docker image ls
|
|
156
|
+
docker-stack node use cluster
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Node selection is stored only in the managed shell's isolated Docker
|
|
160
|
+
configuration and appears in the prompt as `(docker:office@worker-02)`.
|
|
161
|
+
Selected-node requests remain subject to the manager's Docker permissions;
|
|
162
|
+
image management is root-only because the manager does not define delegated
|
|
163
|
+
image permissions.
|
|
164
|
+
|
|
165
|
+
Cluster-aware output is enabled after the manager has observed compatible
|
|
166
|
+
agents on every discovered node. The manager remembers that capability until it
|
|
167
|
+
restarts, so a temporary agent outage does not make the CLI revert to a legacy
|
|
168
|
+
feature decision. A partial cluster listing prints the available containers,
|
|
169
|
+
reports each failed node on stderr with its incident id, and exits non-zero.
|
|
170
|
+
|
|
171
|
+
`docker ps --quiet` and `docker ps --format ...` use Docker's native formatter
|
|
172
|
+
and therefore do not add the `NODE` column. Explicit Docker overrides such as
|
|
173
|
+
`--context`, `--host`/`-H`, and `--config` bypass managed cluster formatting and
|
|
174
|
+
are sent unchanged to the Docker CLI.
|
|
175
|
+
|
|
144
176
|
## Core Capabilities
|
|
145
177
|
|
|
146
178
|
- **Advanced Deployments on Plain Docker Daemons:**
|
|
@@ -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)
|
|
@@ -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
|
|
|
@@ -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
|
*,
|
|
@@ -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:**
|
|
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
|
|
|
2
2
|
|
|
3
3
|
setup(
|
|
4
4
|
name="docker-stack",
|
|
5
|
-
version="2.
|
|
5
|
+
version="2.2.0",
|
|
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",
|
|
@@ -8,7 +8,15 @@ from types import SimpleNamespace
|
|
|
8
8
|
import pytest
|
|
9
9
|
import yaml
|
|
10
10
|
|
|
11
|
-
from docker_stack.cli import
|
|
11
|
+
from docker_stack.cli import (
|
|
12
|
+
DOCKER_SHELL_ENDPOINT_ENV_VARS,
|
|
13
|
+
Docker,
|
|
14
|
+
_managed_docker,
|
|
15
|
+
_read_selected_node,
|
|
16
|
+
_select_node,
|
|
17
|
+
active_shell_config_dir,
|
|
18
|
+
open_context_shell,
|
|
19
|
+
)
|
|
12
20
|
from docker_stack.helpers import Command
|
|
13
21
|
from docker_stack.login import UnknownShellContextError
|
|
14
22
|
from docker_stack import main
|
|
@@ -19,6 +27,134 @@ def decode_x_files(compose_content):
|
|
|
19
27
|
return {item["path"]: base64.b64decode(item["content"]).decode("utf-8") for item in data.get("x-files", [])}
|
|
20
28
|
|
|
21
29
|
|
|
30
|
+
def test_node_use_updates_only_managed_shell_config(monkeypatch, tmp_path):
|
|
31
|
+
config_path = tmp_path / "config.json"
|
|
32
|
+
config_path.write_text(json.dumps({"HttpHeaders": {"Authorization": "Bearer token"}}), encoding="utf-8")
|
|
33
|
+
node_state = tmp_path / "node-selection"
|
|
34
|
+
monkeypatch.setenv("DOCKER_STACK_SHELL_SOCKET", str(tmp_path / "auth.sock"))
|
|
35
|
+
monkeypatch.setenv("DOCKER_STACK_SHELL_SECRET", "secret")
|
|
36
|
+
monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path))
|
|
37
|
+
monkeypatch.setenv("DOCKER_STACK_SHELL_NODE_STATE", str(node_state))
|
|
38
|
+
client = SimpleNamespace(
|
|
39
|
+
list_nodes=lambda: {
|
|
40
|
+
"nodes": [
|
|
41
|
+
{"id": "node-worker-123", "hostname": "worker-02", "state": "Ready"},
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
monkeypatch.setattr("docker_stack.cli._manager_with_cluster_cli", lambda: client)
|
|
46
|
+
|
|
47
|
+
assert _select_node("worker-02") == "worker-02"
|
|
48
|
+
payload = json.loads(config_path.read_text(encoding="utf-8"))
|
|
49
|
+
assert payload["HttpHeaders"]["Authorization"] == "Bearer token"
|
|
50
|
+
assert payload["HttpHeaders"]["X-Docker-Manager-Node"] == "node-worker-123"
|
|
51
|
+
assert _read_selected_node() == "node-worker-123"
|
|
52
|
+
assert node_state.read_text(encoding="utf-8").strip() == "worker-02"
|
|
53
|
+
|
|
54
|
+
assert _select_node("cluster") == "cluster"
|
|
55
|
+
payload = json.loads(config_path.read_text(encoding="utf-8"))
|
|
56
|
+
assert "X-Docker-Manager-Node" not in payload["HttpHeaders"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_managed_docker_ps_renders_node_column(monkeypatch, capsys):
|
|
60
|
+
client = SimpleNamespace(
|
|
61
|
+
supports=lambda feature: feature == "cluster_container_cli_v1",
|
|
62
|
+
list_containers=lambda **_kwargs: {
|
|
63
|
+
"containers": [
|
|
64
|
+
{
|
|
65
|
+
"node_id": "node-1",
|
|
66
|
+
"node_name": "worker-02",
|
|
67
|
+
"docker": {
|
|
68
|
+
"Id": "abcdef1234567890",
|
|
69
|
+
"Image": "nginx:1.27",
|
|
70
|
+
"Command": "nginx",
|
|
71
|
+
"Created": 1,
|
|
72
|
+
"Status": "Up 1 hour",
|
|
73
|
+
"Ports": [{"PrivatePort": 80, "Type": "tcp"}],
|
|
74
|
+
"Names": ["/web.1.test"],
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
],
|
|
78
|
+
"node_errors": [],
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
monkeypatch.setattr("docker_stack.cli.discover_manager_client", lambda: client)
|
|
82
|
+
|
|
83
|
+
assert _managed_docker(["ps"]) == 0
|
|
84
|
+
output = capsys.readouterr().out
|
|
85
|
+
assert "CONTAINER ID" in output
|
|
86
|
+
assert "NODE" in output
|
|
87
|
+
assert "worker-02" in output
|
|
88
|
+
assert "abcdef123456" in output
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_managed_docker_ps_forwards_global_latest_and_limit(monkeypatch):
|
|
92
|
+
calls = []
|
|
93
|
+
client = SimpleNamespace(
|
|
94
|
+
supports=lambda feature: feature == "cluster_container_cli_v1",
|
|
95
|
+
list_containers=lambda **kwargs: calls.append(kwargs)
|
|
96
|
+
or {"containers": [], "node_errors": []},
|
|
97
|
+
)
|
|
98
|
+
monkeypatch.setattr("docker_stack.cli.discover_manager_client", lambda: client)
|
|
99
|
+
|
|
100
|
+
assert _managed_docker(["ps", "--latest"]) == 0
|
|
101
|
+
assert _managed_docker(["ps", "--last", "3"]) == 0
|
|
102
|
+
|
|
103
|
+
assert calls[0]["latest"] is True
|
|
104
|
+
assert calls[0]["limit"] is None
|
|
105
|
+
assert calls[1]["latest"] is False
|
|
106
|
+
assert calls[1]["limit"] == 3
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_managed_docker_delegates_format_to_real_docker(monkeypatch):
|
|
110
|
+
calls = []
|
|
111
|
+
monkeypatch.setattr(
|
|
112
|
+
"docker_stack.cli.subprocess.run",
|
|
113
|
+
lambda command, **_kwargs: calls.append(command) or SimpleNamespace(returncode=7),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
assert _managed_docker(["ps", "--format", "{{.ID}}"]) == 7
|
|
117
|
+
assert calls == [["docker", "ps", "--format", "{{.ID}}"]]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.mark.parametrize(
|
|
121
|
+
"arguments",
|
|
122
|
+
[
|
|
123
|
+
["--context", "other", "ps"],
|
|
124
|
+
["--context=other", "ps"],
|
|
125
|
+
["-H", "tcp://other:2375", "ps"],
|
|
126
|
+
["-H=tcp://other:2375", "ps"],
|
|
127
|
+
["--config", "/tmp/other-docker-config", "ps"],
|
|
128
|
+
],
|
|
129
|
+
)
|
|
130
|
+
def test_managed_docker_delegates_explicit_target_overrides(monkeypatch, arguments):
|
|
131
|
+
calls = []
|
|
132
|
+
monkeypatch.setattr(
|
|
133
|
+
"docker_stack.cli.subprocess.run",
|
|
134
|
+
lambda command, **_kwargs: calls.append(command) or SimpleNamespace(returncode=6),
|
|
135
|
+
)
|
|
136
|
+
monkeypatch.setattr(
|
|
137
|
+
"docker_stack.cli.discover_manager_client",
|
|
138
|
+
lambda: pytest.fail("explicit Docker targets must bypass managed cluster discovery"),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
assert _managed_docker(arguments) == 6
|
|
142
|
+
assert calls == [["docker", *arguments]]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_managed_docker_ps_delegates_when_manager_lacks_cluster_feature(monkeypatch):
|
|
146
|
+
calls = []
|
|
147
|
+
client = SimpleNamespace(supports=lambda _feature: False)
|
|
148
|
+
monkeypatch.setattr("docker_stack.cli.discover_manager_client", lambda: client)
|
|
149
|
+
monkeypatch.setattr(
|
|
150
|
+
"docker_stack.cli.subprocess.run",
|
|
151
|
+
lambda command, **_kwargs: calls.append(command) or SimpleNamespace(returncode=9),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
assert _managed_docker(["ps"]) == 9
|
|
155
|
+
assert calls == [["docker", "ps"]]
|
|
156
|
+
|
|
157
|
+
|
|
22
158
|
class RecordingManager:
|
|
23
159
|
def __init__(self):
|
|
24
160
|
self.calls = []
|
|
@@ -1023,6 +1159,37 @@ def test_login_prefers_existing_context_for_portless_target(monkeypatch, capsys)
|
|
|
1023
1159
|
assert "DOCKER_CONTEXT=office" in output
|
|
1024
1160
|
|
|
1025
1161
|
|
|
1162
|
+
def test_login_named_context_reuses_persisted_isolated_config(monkeypatch, tmp_path, capsys):
|
|
1163
|
+
(tmp_path / "config.json").write_text("{}\n", encoding="utf-8")
|
|
1164
|
+
captured = {}
|
|
1165
|
+
monkeypatch.setattr(
|
|
1166
|
+
"docker_stack.cli.resolve_login_config",
|
|
1167
|
+
lambda **_kwargs: SimpleNamespace(
|
|
1168
|
+
manager_url="https://172.31.3.3:2376",
|
|
1169
|
+
context_name="saas-a",
|
|
1170
|
+
docker_context_host="tcp://172.31.3.3:2376",
|
|
1171
|
+
skip_tls_verify=False,
|
|
1172
|
+
docker_config_dir=tmp_path,
|
|
1173
|
+
),
|
|
1174
|
+
)
|
|
1175
|
+
|
|
1176
|
+
def fake_ensure(config, docker_config_dir=None):
|
|
1177
|
+
captured["context"] = config.context_name
|
|
1178
|
+
captured["config_dir"] = docker_config_dir
|
|
1179
|
+
return docker_config_dir, None
|
|
1180
|
+
|
|
1181
|
+
monkeypatch.setattr("docker_stack.cli.ensure_isolated_login", fake_ensure)
|
|
1182
|
+
monkeypatch.setattr(
|
|
1183
|
+
"docker_stack.cli.docker_manager_login",
|
|
1184
|
+
lambda _config: pytest.fail("named persisted login must not use global Docker config"),
|
|
1185
|
+
)
|
|
1186
|
+
|
|
1187
|
+
main(["login", "saas-a"])
|
|
1188
|
+
|
|
1189
|
+
assert captured == {"context": "saas-a", "config_dir": tmp_path}
|
|
1190
|
+
assert f"DOCKER_CONFIG={tmp_path}" in capsys.readouterr().out
|
|
1191
|
+
|
|
1192
|
+
|
|
1026
1193
|
def test_login_inside_shell_updates_existing_isolated_config(monkeypatch, tmp_path, capsys):
|
|
1027
1194
|
captured = {}
|
|
1028
1195
|
|
|
@@ -1167,6 +1334,27 @@ def test_shell_unknown_context_prompts_for_manager_url_and_creates_it(monkeypatc
|
|
|
1167
1334
|
assert "Creating Docker-Manager shell context 'prod1'" in output
|
|
1168
1335
|
|
|
1169
1336
|
|
|
1337
|
+
def test_shell_explicit_target_persists_context_before_opening(monkeypatch):
|
|
1338
|
+
config = SimpleNamespace(
|
|
1339
|
+
manager_url="https://172.31.3.3:2376",
|
|
1340
|
+
context_name="saas-a",
|
|
1341
|
+
docker_context_host="tcp://172.31.3.3:2376",
|
|
1342
|
+
skip_tls_verify=False,
|
|
1343
|
+
timeout_secs=30,
|
|
1344
|
+
)
|
|
1345
|
+
events = []
|
|
1346
|
+
monkeypatch.setattr("docker_stack.cli.resolve_shell_login_config", lambda **_kwargs: config)
|
|
1347
|
+
monkeypatch.setattr("docker_stack.cli.persist_shell_context", lambda value: events.append(("persist", value.context_name)))
|
|
1348
|
+
monkeypatch.setattr("docker_stack.cli.browser_login", lambda _config: events.append(("login", "saas-a")) or SimpleNamespace(expires_at=None))
|
|
1349
|
+
monkeypatch.setattr("docker_stack.cli.run_managed_shell", lambda _config, _result: 0)
|
|
1350
|
+
|
|
1351
|
+
with pytest.raises(SystemExit) as excinfo:
|
|
1352
|
+
main(["shell", "--context", "saas-a", "https://172.31.3.3:2376"])
|
|
1353
|
+
|
|
1354
|
+
assert excinfo.value.code == 0
|
|
1355
|
+
assert events[:2] == [("persist", "saas-a"), ("login", "saas-a")]
|
|
1356
|
+
|
|
1357
|
+
|
|
1170
1358
|
def test_shell_inside_same_context_reports_already_active(monkeypatch, capsys):
|
|
1171
1359
|
def fake_resolve_shell_login_config(**_kwargs):
|
|
1172
1360
|
return SimpleNamespace(
|
|
@@ -384,6 +384,26 @@ def test_resolve_login_config_prefers_named_context_for_portless_target(monkeypa
|
|
|
384
384
|
assert config.skip_tls_verify is True
|
|
385
385
|
|
|
386
386
|
|
|
387
|
+
def test_resolve_login_config_prefers_isolated_named_context(monkeypatch, tmp_path):
|
|
388
|
+
monkeypatch.setattr("docker_stack.login.isolated_docker_config_dir", lambda _name: tmp_path)
|
|
389
|
+
monkeypatch.setattr(
|
|
390
|
+
"docker_stack.login.docker_context_target",
|
|
391
|
+
lambda context_name, docker_config_dir=None: (
|
|
392
|
+
"tcp://172.31.3.3:2376" if context_name == "saas-a" and docker_config_dir == tmp_path else None
|
|
393
|
+
),
|
|
394
|
+
)
|
|
395
|
+
monkeypatch.setattr(
|
|
396
|
+
"docker_stack.login.detect_manager_url",
|
|
397
|
+
lambda value, verify_ssl=False: ("https://172.31.3.3:2376", False),
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
config = resolve_login_config(manager_target="saas-a")
|
|
401
|
+
|
|
402
|
+
assert config.manager_url == "https://172.31.3.3:2376"
|
|
403
|
+
assert config.context_name == "saas-a"
|
|
404
|
+
assert config.docker_config_dir == tmp_path
|
|
405
|
+
|
|
406
|
+
|
|
387
407
|
def test_resolve_login_config_uses_current_context_target(monkeypatch):
|
|
388
408
|
monkeypatch.setattr(
|
|
389
409
|
"docker_stack.login.current_docker_context_target",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import os
|
|
3
|
+
import shutil
|
|
3
4
|
import socket
|
|
4
5
|
import subprocess
|
|
5
6
|
import tempfile
|
|
@@ -289,8 +290,60 @@ def test_prompt_hooks_only_read_local_status_and_docker_wrapper_preflights(tmp_p
|
|
|
289
290
|
wrapper = (tmp_path / "bashrc").read_text(encoding="utf-8")
|
|
290
291
|
prompt_function = wrapper.split("__docker_stack_prompt_update() {", 1)[1].split("}", 1)[0]
|
|
291
292
|
assert "shell-auth" not in prompt_function
|
|
292
|
-
assert "docker-stack shell-auth ensure || return $?;
|
|
293
|
-
assert "(docker:office)" in wrapper
|
|
293
|
+
assert "docker-stack shell-auth ensure || return $?; docker-stack docker -- \"$@\"" in wrapper
|
|
294
|
+
assert "(docker:office@${node})" in wrapper
|
|
295
|
+
assert "(docker:!DOCKER_HOST)" in wrapper
|
|
296
|
+
|
|
297
|
+
zsh_wrapper = (tmp_path / "zsh" / ".zshrc").read_text(encoding="utf-8")
|
|
298
|
+
assert '[[ -n "${DOCKER_HOST:-}" ]]' in zsh_wrapper
|
|
299
|
+
assert "(docker:!DOCKER_HOST)" in zsh_wrapper
|
|
300
|
+
assert f"typeset -g HISTFILE={json.dumps(str(tmp_path / '.zsh_history'))}" in zsh_wrapper
|
|
301
|
+
assert '[[ -r "$HISTFILE" ]] && fc -R "$HISTFILE"' in zsh_wrapper
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def test_zsh_wrapper_loads_and_persists_original_history(tmp_path, monkeypatch):
|
|
305
|
+
zsh = shutil.which("zsh")
|
|
306
|
+
if zsh is None:
|
|
307
|
+
pytest.skip("zsh is not installed")
|
|
308
|
+
|
|
309
|
+
original_zdotdir = tmp_path / "original-zdotdir"
|
|
310
|
+
original_zdotdir.mkdir()
|
|
311
|
+
(original_zdotdir / ".zshrc").write_text(
|
|
312
|
+
"HISTSIZE=100\nSAVEHIST=100\nsetopt append_history\n",
|
|
313
|
+
encoding="utf-8",
|
|
314
|
+
)
|
|
315
|
+
history_path = original_zdotdir / ".zsh_history"
|
|
316
|
+
history_path.write_text(": 1:0;existing-managed-shell-history\n", encoding="utf-8")
|
|
317
|
+
status_path = tmp_path / "status"
|
|
318
|
+
status_path.write_text("active 9999999999\n", encoding="utf-8")
|
|
319
|
+
|
|
320
|
+
monkeypatch.setenv("ZDOTDIR", str(original_zdotdir))
|
|
321
|
+
wrapper_env = _write_shell_wrappers(
|
|
322
|
+
tmp_path,
|
|
323
|
+
zsh,
|
|
324
|
+
context_name="office",
|
|
325
|
+
status_path=status_path,
|
|
326
|
+
)
|
|
327
|
+
env = dict(os.environ)
|
|
328
|
+
env.update(wrapper_env)
|
|
329
|
+
env.pop("TERM_SESSION_ID", None)
|
|
330
|
+
|
|
331
|
+
result = subprocess.run(
|
|
332
|
+
[
|
|
333
|
+
zsh,
|
|
334
|
+
"-i",
|
|
335
|
+
"-c",
|
|
336
|
+
'fc -l 1; print -s "persisted-managed-shell-history"',
|
|
337
|
+
],
|
|
338
|
+
check=False,
|
|
339
|
+
capture_output=True,
|
|
340
|
+
text=True,
|
|
341
|
+
env=env,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
assert result.returncode == 0, result.stderr
|
|
345
|
+
assert "existing-managed-shell-history" in result.stdout
|
|
346
|
+
assert "persisted-managed-shell-history" in history_path.read_text(encoding="utf-8")
|
|
294
347
|
|
|
295
348
|
|
|
296
349
|
def test_broker_stop_erases_in_memory_credentials_and_socket(tmp_path, monkeypatch):
|
|
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
|