flowmesh-sdk-stack 0.1.5__tar.gz → 0.1.6__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.
- {flowmesh_sdk_stack-0.1.5/src/flowmesh_sdk_stack.egg-info → flowmesh_sdk_stack-0.1.6}/PKG-INFO +2 -2
- {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/pyproject.toml +2 -2
- {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6/src/flowmesh_sdk_stack.egg-info}/PKG-INFO +2 -2
- {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/src/flowmesh_sdk_stack.egg-info/SOURCES.txt +1 -0
- {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/src/flowmesh_sdk_stack.egg-info/requires.txt +1 -1
- {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/src/flowmesh_stack/__init__.py +9 -1
- flowmesh_sdk_stack-0.1.6/src/flowmesh_stack/docker.py +493 -0
- flowmesh_sdk_stack-0.1.6/src/flowmesh_stack/image_prune.py +128 -0
- {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/src/flowmesh_stack/images.py +60 -12
- flowmesh_sdk_stack-0.1.5/src/flowmesh_stack/docker.py +0 -262
- {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/LICENSE +0 -0
- {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/README.md +0 -0
- {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/setup.cfg +0 -0
- {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/src/flowmesh_sdk_stack.egg-info/dependency_links.txt +0 -0
- {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/src/flowmesh_sdk_stack.egg-info/top_level.txt +0 -0
- {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/src/flowmesh_stack/doctor.py +0 -0
- {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/src/flowmesh_stack/env.py +0 -0
- {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/src/flowmesh_stack/env_schema.py +0 -0
- {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/src/flowmesh_stack/node_client.py +0 -0
- {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/src/flowmesh_stack/paths.py +0 -0
- {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/src/flowmesh_stack/workers.py +0 -0
{flowmesh_sdk_stack-0.1.5/src/flowmesh_sdk_stack.egg-info → flowmesh_sdk_stack-0.1.6}/PKG-INFO
RENAMED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flowmesh-sdk-stack
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: FlowMesh SDK stack client
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
Requires-Python: >=3.12
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
8
|
License-File: LICENSE
|
|
9
|
-
Requires-Dist: flowmesh-sdk==0.1.
|
|
9
|
+
Requires-Dist: flowmesh-sdk==0.1.6
|
|
10
10
|
Requires-Dist: httpx>=0.27.0
|
|
11
11
|
Requires-Dist: pyyaml>=6.0.0
|
|
12
12
|
Requires-Dist: docker>=7.1.0
|
|
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "flowmesh-sdk-stack"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.6"
|
|
8
8
|
description = "FlowMesh SDK stack client"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
11
11
|
license = "Apache-2.0"
|
|
12
12
|
license-files = ["LICENSE"]
|
|
13
13
|
dependencies = [
|
|
14
|
-
"flowmesh-sdk==0.1.
|
|
14
|
+
"flowmesh-sdk==0.1.6",
|
|
15
15
|
"httpx>=0.27.0",
|
|
16
16
|
"pyyaml>=6.0.0",
|
|
17
17
|
"docker>=7.1.0",
|
{flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6/src/flowmesh_sdk_stack.egg-info}/PKG-INFO
RENAMED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flowmesh-sdk-stack
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: FlowMesh SDK stack client
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
Requires-Python: >=3.12
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
8
|
License-File: LICENSE
|
|
9
|
-
Requires-Dist: flowmesh-sdk==0.1.
|
|
9
|
+
Requires-Dist: flowmesh-sdk==0.1.6
|
|
10
10
|
Requires-Dist: httpx>=0.27.0
|
|
11
11
|
Requires-Dist: pyyaml>=6.0.0
|
|
12
12
|
Requires-Dist: docker>=7.1.0
|
{flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/src/flowmesh_sdk_stack.egg-info/SOURCES.txt
RENAMED
|
@@ -11,6 +11,7 @@ src/flowmesh_stack/docker.py
|
|
|
11
11
|
src/flowmesh_stack/doctor.py
|
|
12
12
|
src/flowmesh_stack/env.py
|
|
13
13
|
src/flowmesh_stack/env_schema.py
|
|
14
|
+
src/flowmesh_stack/image_prune.py
|
|
14
15
|
src/flowmesh_stack/images.py
|
|
15
16
|
src/flowmesh_stack/node_client.py
|
|
16
17
|
src/flowmesh_stack/paths.py
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
"""FlowMesh SDK — Stack management extensions."""
|
|
2
2
|
|
|
3
|
-
from .images import
|
|
3
|
+
from .images import (
|
|
4
|
+
BUILD_GROUPS,
|
|
5
|
+
BUILD_TARGETS,
|
|
6
|
+
get_image_ref,
|
|
7
|
+
managed_repos,
|
|
8
|
+
parse_image_ref,
|
|
9
|
+
)
|
|
4
10
|
from .node_client import NodeClient
|
|
5
11
|
from .workers import (
|
|
6
12
|
create_workers,
|
|
@@ -17,7 +23,9 @@ __all__ = [
|
|
|
17
23
|
"create_workers",
|
|
18
24
|
"detect_gpu_targets",
|
|
19
25
|
"get_image_ref",
|
|
26
|
+
"managed_repos",
|
|
20
27
|
"operate_workers",
|
|
28
|
+
"parse_image_ref",
|
|
21
29
|
"pull_images",
|
|
22
30
|
"select_worker_images",
|
|
23
31
|
]
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
"""Local Docker and compose helpers for FlowMesh tooling."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .images import managed_repos, parse_image_ref
|
|
15
|
+
|
|
16
|
+
FLOWMESH_IMAGE_SOURCE = "https://github.com/mlsys-io/FlowMesh"
|
|
17
|
+
"""``org.opencontainers.image.source`` label carried by every FlowMesh image."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DockerError(RuntimeError):
|
|
21
|
+
"""Raised when a docker command fails."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def ensure_docker_available() -> None:
|
|
25
|
+
"""Verify that the docker CLI is available."""
|
|
26
|
+
if shutil.which("docker") is None:
|
|
27
|
+
raise DockerError("docker is required but was not found in PATH")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def compose(
|
|
31
|
+
compose_file: Path,
|
|
32
|
+
env_file: Path | None,
|
|
33
|
+
args: Iterable[str],
|
|
34
|
+
env: Mapping[str, str] | None = None,
|
|
35
|
+
capture_output: bool = False,
|
|
36
|
+
) -> subprocess.CompletedProcess[str]:
|
|
37
|
+
"""Run docker compose with the provided arguments."""
|
|
38
|
+
ensure_docker_available()
|
|
39
|
+
cmd = ["docker", "compose"]
|
|
40
|
+
if env_file:
|
|
41
|
+
cmd += ["--env-file", str(env_file)]
|
|
42
|
+
cmd += ["-f", str(compose_file)]
|
|
43
|
+
cmd += list(args)
|
|
44
|
+
merged_env = dict(os.environ)
|
|
45
|
+
if env:
|
|
46
|
+
merged_env.update(env)
|
|
47
|
+
return subprocess.run(
|
|
48
|
+
cmd,
|
|
49
|
+
check=False,
|
|
50
|
+
capture_output=capture_output,
|
|
51
|
+
text=True,
|
|
52
|
+
env=merged_env,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def compose_logs(
|
|
57
|
+
compose_file: Path,
|
|
58
|
+
env_file: Path | None,
|
|
59
|
+
env: Mapping[str, str] | None = None,
|
|
60
|
+
service: str | None = None,
|
|
61
|
+
capture_output: bool = False,
|
|
62
|
+
profile: str | None = None,
|
|
63
|
+
) -> subprocess.CompletedProcess[str]:
|
|
64
|
+
"""Stream compose logs, optionally for a specific service."""
|
|
65
|
+
args: list[str] = []
|
|
66
|
+
if profile:
|
|
67
|
+
args += ["--profile", profile]
|
|
68
|
+
args += ["logs", "-f"]
|
|
69
|
+
if service:
|
|
70
|
+
args.append(service)
|
|
71
|
+
return compose(
|
|
72
|
+
compose_file=compose_file,
|
|
73
|
+
env_file=env_file,
|
|
74
|
+
args=args,
|
|
75
|
+
env=env,
|
|
76
|
+
capture_output=capture_output,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def container_logs(
|
|
81
|
+
container: str,
|
|
82
|
+
env: Mapping[str, str] | None = None,
|
|
83
|
+
capture_output: bool = False,
|
|
84
|
+
) -> subprocess.CompletedProcess[str]:
|
|
85
|
+
"""Stream logs for a single container via docker logs."""
|
|
86
|
+
ensure_docker_available()
|
|
87
|
+
merged_env = dict(os.environ)
|
|
88
|
+
if env:
|
|
89
|
+
merged_env.update(env)
|
|
90
|
+
return subprocess.run(
|
|
91
|
+
["docker", "logs", "-f", container],
|
|
92
|
+
check=False,
|
|
93
|
+
capture_output=capture_output,
|
|
94
|
+
text=True,
|
|
95
|
+
env=merged_env,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def image_env_overrides(image_tag: str | None) -> dict[str, str]:
|
|
100
|
+
"""Build ``FLOWMESH_VERSION`` overrides for compose commands."""
|
|
101
|
+
env: dict[str, str] = {}
|
|
102
|
+
if image_tag:
|
|
103
|
+
env["FLOWMESH_VERSION"] = image_tag
|
|
104
|
+
return env
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class ManagedImage:
|
|
109
|
+
"""A FlowMesh Docker image present on the local daemon."""
|
|
110
|
+
|
|
111
|
+
repo: str
|
|
112
|
+
tag: str | None
|
|
113
|
+
target: str | None
|
|
114
|
+
version: str | None
|
|
115
|
+
image_id: str
|
|
116
|
+
size_bytes: int
|
|
117
|
+
created: datetime | None
|
|
118
|
+
dangling: bool
|
|
119
|
+
in_use: bool
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def removal_ref(self) -> str:
|
|
123
|
+
"""Reference to pass to ``docker rmi`` (tag when tagged, else image id)."""
|
|
124
|
+
return self.tag if self.tag else self.image_id
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class RemovalResult:
|
|
129
|
+
"""Outcome of removing a single image reference."""
|
|
130
|
+
|
|
131
|
+
ref: str
|
|
132
|
+
ok: bool
|
|
133
|
+
error: str | None = None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
_TIMESTAMP_FRACTION = re.compile(r"(\.\d{6})\d+")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _parse_docker_timestamp(value: str) -> datetime | None:
|
|
140
|
+
"""Parse a docker timestamp, or return ``None`` when it can't be determined."""
|
|
141
|
+
text = value.strip().replace("Z", "+00:00")
|
|
142
|
+
if not text:
|
|
143
|
+
return None
|
|
144
|
+
text = _TIMESTAMP_FRACTION.sub(r"\1", text)
|
|
145
|
+
try:
|
|
146
|
+
parsed = datetime.fromisoformat(text)
|
|
147
|
+
except ValueError:
|
|
148
|
+
return None
|
|
149
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _docker_json_lines(
|
|
153
|
+
result: subprocess.CompletedProcess[str],
|
|
154
|
+
) -> list[dict[str, Any]]:
|
|
155
|
+
rows: list[dict[str, Any]] = []
|
|
156
|
+
for line in result.stdout.splitlines():
|
|
157
|
+
line = line.strip()
|
|
158
|
+
if not line:
|
|
159
|
+
continue
|
|
160
|
+
try:
|
|
161
|
+
obj = json.loads(line)
|
|
162
|
+
except json.JSONDecodeError:
|
|
163
|
+
continue
|
|
164
|
+
if isinstance(obj, dict):
|
|
165
|
+
rows.append(obj)
|
|
166
|
+
return rows
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass
|
|
170
|
+
class _ImageMetadata:
|
|
171
|
+
size_bytes: int
|
|
172
|
+
created: datetime | None
|
|
173
|
+
source: str
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _inspect_image_metadata(image_ids: list[str]) -> dict[str, _ImageMetadata]:
|
|
177
|
+
if not image_ids:
|
|
178
|
+
return {}
|
|
179
|
+
result = subprocess.run(
|
|
180
|
+
["docker", "image", "inspect", *image_ids, "--format", "{{json .}}"],
|
|
181
|
+
capture_output=True,
|
|
182
|
+
text=True,
|
|
183
|
+
check=False,
|
|
184
|
+
)
|
|
185
|
+
metadata: dict[str, _ImageMetadata] = {}
|
|
186
|
+
for obj in _docker_json_lines(result):
|
|
187
|
+
image_id = obj.get("Id", "")
|
|
188
|
+
if not isinstance(image_id, str) or not image_id:
|
|
189
|
+
continue
|
|
190
|
+
size = obj.get("Size", 0)
|
|
191
|
+
size_bytes = int(size) if isinstance(size, (int, float)) else 0
|
|
192
|
+
config = obj.get("Config")
|
|
193
|
+
labels = config.get("Labels") if isinstance(config, dict) else None
|
|
194
|
+
source = (
|
|
195
|
+
str(labels.get("org.opencontainers.image.source") or "")
|
|
196
|
+
if isinstance(labels, dict)
|
|
197
|
+
else ""
|
|
198
|
+
)
|
|
199
|
+
metadata[image_id] = _ImageMetadata(
|
|
200
|
+
size_bytes=size_bytes,
|
|
201
|
+
created=_parse_docker_timestamp(str(obj.get("Created", ""))),
|
|
202
|
+
source=source,
|
|
203
|
+
)
|
|
204
|
+
return metadata
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def container_image_refs() -> set[str]:
|
|
208
|
+
"""Return the image ids referenced by every container, running or stopped.
|
|
209
|
+
|
|
210
|
+
Raises :class:`DockerError` if the daemon can't be queried.
|
|
211
|
+
"""
|
|
212
|
+
ensure_docker_available()
|
|
213
|
+
listing = subprocess.run(
|
|
214
|
+
["docker", "ps", "-aq"], capture_output=True, text=True, check=False
|
|
215
|
+
)
|
|
216
|
+
if listing.returncode != 0:
|
|
217
|
+
raise DockerError(f"failed to list containers: {listing.stderr.strip()}")
|
|
218
|
+
container_ids = [
|
|
219
|
+
stripped for line in listing.stdout.splitlines() if (stripped := line.strip())
|
|
220
|
+
]
|
|
221
|
+
if not container_ids:
|
|
222
|
+
return set()
|
|
223
|
+
inspected = subprocess.run(
|
|
224
|
+
["docker", "container", "inspect", *container_ids, "--format", "{{.Image}}"],
|
|
225
|
+
capture_output=True,
|
|
226
|
+
text=True,
|
|
227
|
+
check=False,
|
|
228
|
+
)
|
|
229
|
+
if inspected.returncode != 0:
|
|
230
|
+
raise DockerError(f"failed to inspect containers: {inspected.stderr.strip()}")
|
|
231
|
+
return {
|
|
232
|
+
stripped for line in inspected.stdout.splitlines() if (stripped := line.strip())
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def list_managed_images(
|
|
237
|
+
registry: str,
|
|
238
|
+
*,
|
|
239
|
+
include_dangling: bool = False,
|
|
240
|
+
in_use_ids: set[str] | None = None,
|
|
241
|
+
) -> list[ManagedImage]:
|
|
242
|
+
"""List FlowMesh images on the local daemon.
|
|
243
|
+
|
|
244
|
+
Tagged images under a managed repository are attributed to their build target and
|
|
245
|
+
version. When ``include_dangling`` is set, untagged FlowMesh layers (identified by
|
|
246
|
+
the ``org.opencontainers.image.source`` label) are included with
|
|
247
|
+
``target``/``version`` unset. ``in_use_ids`` marks images referenced by a container.
|
|
248
|
+
"""
|
|
249
|
+
ensure_docker_available()
|
|
250
|
+
in_use = in_use_ids or set()
|
|
251
|
+
repos = managed_repos(registry)
|
|
252
|
+
|
|
253
|
+
images: list[ManagedImage] = []
|
|
254
|
+
listing = subprocess.run(
|
|
255
|
+
["docker", "image", "ls", "--no-trunc", "--format", "{{json .}}"],
|
|
256
|
+
capture_output=True,
|
|
257
|
+
text=True,
|
|
258
|
+
check=False,
|
|
259
|
+
)
|
|
260
|
+
for row in _docker_json_lines(listing):
|
|
261
|
+
repo = str(row.get("Repository", ""))
|
|
262
|
+
tag = str(row.get("Tag", ""))
|
|
263
|
+
image_id = str(row.get("ID", ""))
|
|
264
|
+
if repo not in repos or not image_id or tag == "<none>":
|
|
265
|
+
continue
|
|
266
|
+
ref = f"{repo}:{tag}"
|
|
267
|
+
parsed = parse_image_ref(registry, ref)
|
|
268
|
+
target, version = parsed if parsed else (None, None)
|
|
269
|
+
images.append(
|
|
270
|
+
ManagedImage(
|
|
271
|
+
repo=repo,
|
|
272
|
+
tag=ref,
|
|
273
|
+
target=target,
|
|
274
|
+
version=version,
|
|
275
|
+
image_id=image_id,
|
|
276
|
+
size_bytes=0,
|
|
277
|
+
created=None,
|
|
278
|
+
dangling=False,
|
|
279
|
+
in_use=image_id in in_use,
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
if include_dangling:
|
|
284
|
+
dangling = subprocess.run(
|
|
285
|
+
[
|
|
286
|
+
"docker",
|
|
287
|
+
"image",
|
|
288
|
+
"ls",
|
|
289
|
+
"--no-trunc",
|
|
290
|
+
"--filter",
|
|
291
|
+
"dangling=true",
|
|
292
|
+
"--filter",
|
|
293
|
+
f"label=org.opencontainers.image.source={FLOWMESH_IMAGE_SOURCE}",
|
|
294
|
+
"--format",
|
|
295
|
+
"{{json .}}",
|
|
296
|
+
],
|
|
297
|
+
capture_output=True,
|
|
298
|
+
text=True,
|
|
299
|
+
check=False,
|
|
300
|
+
)
|
|
301
|
+
for row in _docker_json_lines(dangling):
|
|
302
|
+
image_id = str(row.get("ID", ""))
|
|
303
|
+
if not image_id:
|
|
304
|
+
continue
|
|
305
|
+
images.append(
|
|
306
|
+
ManagedImage(
|
|
307
|
+
repo=str(row.get("Repository", "<none>")),
|
|
308
|
+
tag=None,
|
|
309
|
+
target=None,
|
|
310
|
+
version=None,
|
|
311
|
+
image_id=image_id,
|
|
312
|
+
size_bytes=0,
|
|
313
|
+
created=None,
|
|
314
|
+
dangling=True,
|
|
315
|
+
in_use=image_id in in_use,
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
metadata = _inspect_image_metadata([image.image_id for image in images])
|
|
320
|
+
resolved: list[ManagedImage] = []
|
|
321
|
+
for image in images:
|
|
322
|
+
meta = metadata.get(image.image_id)
|
|
323
|
+
if meta is None or meta.source != FLOWMESH_IMAGE_SOURCE:
|
|
324
|
+
continue
|
|
325
|
+
image.size_bytes = meta.size_bytes
|
|
326
|
+
image.created = meta.created
|
|
327
|
+
resolved.append(image)
|
|
328
|
+
return resolved
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def remove_images(refs: list[str], *, force: bool = False) -> list[RemovalResult]:
|
|
332
|
+
"""Remove image references via ``docker rmi``, reporting each outcome.
|
|
333
|
+
|
|
334
|
+
Never aborts mid-batch: a failed removal is recorded and the rest proceed.
|
|
335
|
+
"""
|
|
336
|
+
ensure_docker_available()
|
|
337
|
+
results: list[RemovalResult] = []
|
|
338
|
+
for ref in refs:
|
|
339
|
+
args = ["docker", "rmi"]
|
|
340
|
+
if force:
|
|
341
|
+
args.append("-f")
|
|
342
|
+
args.append(ref)
|
|
343
|
+
result = subprocess.run(args, capture_output=True, text=True, check=False)
|
|
344
|
+
if result.returncode == 0:
|
|
345
|
+
results.append(RemovalResult(ref=ref, ok=True))
|
|
346
|
+
else:
|
|
347
|
+
error = (result.stderr or result.stdout).strip()
|
|
348
|
+
results.append(RemovalResult(ref=ref, ok=False, error=error or None))
|
|
349
|
+
return results
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@dataclass
|
|
353
|
+
class ServerWorkerStatusRow:
|
|
354
|
+
name: str
|
|
355
|
+
status: str
|
|
356
|
+
worker_type: str
|
|
357
|
+
gpu_id: str
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def server_worker_status_rows(
|
|
361
|
+
*, include_exited: bool = False
|
|
362
|
+
) -> list[ServerWorkerStatusRow]:
|
|
363
|
+
"""Return status rows for local server worker containers."""
|
|
364
|
+
|
|
365
|
+
def _inspect(name: str, fmt: str) -> str:
|
|
366
|
+
result = subprocess.run(
|
|
367
|
+
["docker", "inspect", "--format", fmt, name],
|
|
368
|
+
capture_output=True,
|
|
369
|
+
text=True,
|
|
370
|
+
check=False,
|
|
371
|
+
)
|
|
372
|
+
if result.returncode != 0:
|
|
373
|
+
return ""
|
|
374
|
+
return result.stdout.strip()
|
|
375
|
+
|
|
376
|
+
ensure_docker_available()
|
|
377
|
+
cmd = [
|
|
378
|
+
"docker",
|
|
379
|
+
"ps",
|
|
380
|
+
"-a" if include_exited else "",
|
|
381
|
+
"--filter",
|
|
382
|
+
"label=flowmesh.group=server-workers",
|
|
383
|
+
"--format",
|
|
384
|
+
"{{.Names}}\t{{.Status}}",
|
|
385
|
+
]
|
|
386
|
+
cmd = [part for part in cmd if part]
|
|
387
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
388
|
+
rows = result.stdout.strip()
|
|
389
|
+
if not rows:
|
|
390
|
+
return []
|
|
391
|
+
|
|
392
|
+
items: list[ServerWorkerStatusRow] = []
|
|
393
|
+
for line in rows.splitlines():
|
|
394
|
+
if not line.strip():
|
|
395
|
+
continue
|
|
396
|
+
name, status = (line.split("\t", 1) + [""])[:2]
|
|
397
|
+
worker_type = _inspect(name, '{{index .Config.Labels "flowmesh.worker.type"}}')
|
|
398
|
+
gpu_id = _inspect(name, '{{index .Config.Labels "flowmesh.worker.gpu_id"}}')
|
|
399
|
+
if not worker_type:
|
|
400
|
+
env_dump = _inspect(name, "{{range .Config.Env}}{{println .}}{{end}}")
|
|
401
|
+
detected_gpu = ""
|
|
402
|
+
for entry in env_dump.splitlines():
|
|
403
|
+
if entry.startswith("WORKER_HOST_GPU_ID="):
|
|
404
|
+
detected_gpu = entry.split("=", 1)[1]
|
|
405
|
+
break
|
|
406
|
+
if detected_gpu:
|
|
407
|
+
worker_type = "gpu"
|
|
408
|
+
gpu_id = detected_gpu
|
|
409
|
+
else:
|
|
410
|
+
worker_type = "cpu"
|
|
411
|
+
items.append(
|
|
412
|
+
ServerWorkerStatusRow(
|
|
413
|
+
name=name,
|
|
414
|
+
status=status,
|
|
415
|
+
worker_type=worker_type or "unknown",
|
|
416
|
+
gpu_id=gpu_id or "-",
|
|
417
|
+
)
|
|
418
|
+
)
|
|
419
|
+
return items
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@dataclass
|
|
423
|
+
class DockerComposeStack:
|
|
424
|
+
"""Local compose wrapper."""
|
|
425
|
+
|
|
426
|
+
compose_file: Path
|
|
427
|
+
"""Path to the compose file used for all stack operations."""
|
|
428
|
+
env_file_var: str
|
|
429
|
+
"""
|
|
430
|
+
Environment variable name passed to compose to point at the selected env file.
|
|
431
|
+
"""
|
|
432
|
+
load_env: Callable[[Path], None]
|
|
433
|
+
"""
|
|
434
|
+
Callback that loads and resolves env-file values before stack operations run.
|
|
435
|
+
"""
|
|
436
|
+
ensure_deploy_paths: Callable[[Path], None] | None = None
|
|
437
|
+
"""
|
|
438
|
+
Optional callback that prepares required local files and directories before
|
|
439
|
+
deployment-oriented compose commands.
|
|
440
|
+
"""
|
|
441
|
+
|
|
442
|
+
def run(
|
|
443
|
+
self,
|
|
444
|
+
args: list[str],
|
|
445
|
+
env_file: Path,
|
|
446
|
+
env: dict[str, str] | None = None,
|
|
447
|
+
to_deploy: bool = False,
|
|
448
|
+
) -> subprocess.CompletedProcess[str]:
|
|
449
|
+
"""Run ``docker compose`` for this stack."""
|
|
450
|
+
self.load_env(env_file)
|
|
451
|
+
if to_deploy and self.ensure_deploy_paths is not None:
|
|
452
|
+
self.ensure_deploy_paths(Path.cwd())
|
|
453
|
+
compose_env = {self.env_file_var: str(env_file.resolve())}
|
|
454
|
+
if env:
|
|
455
|
+
compose_env.update(env)
|
|
456
|
+
return compose(
|
|
457
|
+
compose_file=self.compose_file,
|
|
458
|
+
env_file=env_file,
|
|
459
|
+
args=args,
|
|
460
|
+
env=compose_env,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
def stream_logs(
|
|
464
|
+
self,
|
|
465
|
+
env_file: Path,
|
|
466
|
+
service: str | None = None,
|
|
467
|
+
profile: str | None = None,
|
|
468
|
+
) -> int:
|
|
469
|
+
"""Stream stack logs and fall back to container logs when needed."""
|
|
470
|
+
self.load_env(env_file)
|
|
471
|
+
compose_env = {self.env_file_var: str(env_file.resolve())}
|
|
472
|
+
if service:
|
|
473
|
+
result = compose_logs(
|
|
474
|
+
compose_file=self.compose_file,
|
|
475
|
+
env_file=env_file,
|
|
476
|
+
env=compose_env,
|
|
477
|
+
service=service,
|
|
478
|
+
capture_output=False,
|
|
479
|
+
profile=profile,
|
|
480
|
+
)
|
|
481
|
+
if result.returncode == 0:
|
|
482
|
+
return 0
|
|
483
|
+
fallback = container_logs(container=service, env=None, capture_output=False)
|
|
484
|
+
return fallback.returncode
|
|
485
|
+
|
|
486
|
+
result = compose_logs(
|
|
487
|
+
compose_file=self.compose_file,
|
|
488
|
+
env_file=env_file,
|
|
489
|
+
env=compose_env,
|
|
490
|
+
capture_output=False,
|
|
491
|
+
profile=profile,
|
|
492
|
+
)
|
|
493
|
+
return result.returncode
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Policy-based selection for ``flowmesh stack image prune``.
|
|
2
|
+
|
|
3
|
+
Pure logic over :class:`~flowmesh_stack.docker.ManagedImage` lists: the caller
|
|
4
|
+
supplies the discovered images (with ``in_use`` already populated) and the
|
|
5
|
+
policy flags; :func:`select_prune_targets` returns which images to delete and
|
|
6
|
+
which are protected. Docker is never touched here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime, timedelta
|
|
12
|
+
|
|
13
|
+
from .docker import ManagedImage
|
|
14
|
+
|
|
15
|
+
_DURATION = re.compile(r"^(\d+)([smhdw])$")
|
|
16
|
+
_UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_duration(text: str) -> timedelta:
|
|
20
|
+
"""Parse a ``<int><unit>`` duration (units ``s``/``m``/``h``/``d``/``w``)."""
|
|
21
|
+
match = _DURATION.match(text.strip())
|
|
22
|
+
if not match:
|
|
23
|
+
raise ValueError(
|
|
24
|
+
f"invalid duration {text!r}; expected an integer followed by one of "
|
|
25
|
+
"s, m, h, d, w (e.g. 30d, 12h)"
|
|
26
|
+
)
|
|
27
|
+
return timedelta(seconds=int(match.group(1)) * _UNIT_SECONDS[match.group(2)])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class PrunePlan:
|
|
32
|
+
"""Result of a prune selection: what to delete and what is protected."""
|
|
33
|
+
|
|
34
|
+
deleted: list[ManagedImage] = field(default_factory=list)
|
|
35
|
+
protected: list[tuple[ManagedImage, str]] = field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _keep_last_versions(
|
|
39
|
+
parsed: list[ManagedImage], keep_last: int
|
|
40
|
+
) -> dict[str, set[str]]:
|
|
41
|
+
newest: dict[str, dict[str, datetime]] = {}
|
|
42
|
+
ignored: list[tuple[str, str]] = []
|
|
43
|
+
for image in parsed:
|
|
44
|
+
if image.target is None or image.version is None:
|
|
45
|
+
continue
|
|
46
|
+
if image.created is None:
|
|
47
|
+
ignored.append((image.target, image.version))
|
|
48
|
+
continue
|
|
49
|
+
per_target = newest.setdefault(image.target, {})
|
|
50
|
+
if image.version not in per_target or image.created > per_target[image.version]:
|
|
51
|
+
per_target[image.version] = image.created
|
|
52
|
+
protected: dict[str, set[str]] = {}
|
|
53
|
+
for target, versions in newest.items():
|
|
54
|
+
ordered = sorted(versions.items(), key=lambda item: item[1], reverse=True)
|
|
55
|
+
protected[target] = {version for version, _ in ordered[:keep_last]}
|
|
56
|
+
for target, version in ignored:
|
|
57
|
+
protected.setdefault(target, set()).add(version)
|
|
58
|
+
return protected
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def select_prune_targets(
|
|
62
|
+
images: list[ManagedImage],
|
|
63
|
+
*,
|
|
64
|
+
keep_last: int | None = None,
|
|
65
|
+
keep_versions: set[str] | None = None,
|
|
66
|
+
keep_active: bool = False,
|
|
67
|
+
older_than: timedelta | None = None,
|
|
68
|
+
include_dangling: bool = False,
|
|
69
|
+
now: datetime,
|
|
70
|
+
) -> PrunePlan:
|
|
71
|
+
"""Select images to prune as ``candidate pool − protected``.
|
|
72
|
+
|
|
73
|
+
The candidate pool is a filter — protections only subtract from it. The
|
|
74
|
+
non-dangling base is restricted to parsed images (``target`` set) so manual
|
|
75
|
+
tags on a managed repo are never candidates. Images whose ``created`` time
|
|
76
|
+
couldn't be determined (``None``) are never selected by ``older_than``.
|
|
77
|
+
"""
|
|
78
|
+
if keep_last is not None and keep_last < 0:
|
|
79
|
+
raise ValueError("keep_last must be >= 0")
|
|
80
|
+
keep_versions = keep_versions or set()
|
|
81
|
+
parsed = [i for i in images if not i.dangling and i.target is not None]
|
|
82
|
+
dangling = [i for i in images if i.dangling]
|
|
83
|
+
|
|
84
|
+
if older_than is not None:
|
|
85
|
+
cutoff = now - older_than
|
|
86
|
+
pool: list[ManagedImage] = [
|
|
87
|
+
i for i in parsed if i.created is not None and i.created < cutoff
|
|
88
|
+
]
|
|
89
|
+
elif keep_last is not None:
|
|
90
|
+
pool = list(parsed)
|
|
91
|
+
else:
|
|
92
|
+
pool = []
|
|
93
|
+
if include_dangling:
|
|
94
|
+
pool = pool + dangling
|
|
95
|
+
|
|
96
|
+
keep_last_versions = (
|
|
97
|
+
_keep_last_versions(parsed, keep_last) if keep_last is not None else {}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
plan = PrunePlan()
|
|
101
|
+
for image in pool:
|
|
102
|
+
reason = _protection_reason(
|
|
103
|
+
image, keep_last_versions, keep_versions, keep_active
|
|
104
|
+
)
|
|
105
|
+
if reason is None:
|
|
106
|
+
plan.deleted.append(image)
|
|
107
|
+
else:
|
|
108
|
+
plan.protected.append((image, reason))
|
|
109
|
+
return plan
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _protection_reason(
|
|
113
|
+
image: ManagedImage,
|
|
114
|
+
keep_last_versions: dict[str, set[str]],
|
|
115
|
+
keep_versions: set[str],
|
|
116
|
+
keep_active: bool,
|
|
117
|
+
) -> str | None:
|
|
118
|
+
if keep_active and image.in_use:
|
|
119
|
+
return "keep-active"
|
|
120
|
+
if image.version is not None and image.version in keep_versions:
|
|
121
|
+
return "keep"
|
|
122
|
+
if (
|
|
123
|
+
image.target is not None
|
|
124
|
+
and image.version is not None
|
|
125
|
+
and image.version in keep_last_versions.get(image.target, set())
|
|
126
|
+
):
|
|
127
|
+
return "keep-last"
|
|
128
|
+
return None
|
|
@@ -4,23 +4,23 @@ Provides the canonical mapping from build targets to image references,
|
|
|
4
4
|
used by both the CLI dev commands and programmatic build/deploy scripts.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
_TARGET_COMPONENTS: dict[str, tuple[str, str]] = {
|
|
8
|
+
"flowmesh_server": ("flowmesh_server", ""),
|
|
9
|
+
"flowmesh_worker_cpu": ("flowmesh_worker", "-cpu"),
|
|
10
|
+
"flowmesh_worker_gpu_builder": ("flowmesh_worker_builder", "-gpu"),
|
|
11
|
+
"flowmesh_worker_gpu": ("flowmesh_worker", "-gpu"),
|
|
12
|
+
"flowmesh_ssh_cpu": ("flowmesh_ssh", "-cpu"),
|
|
13
|
+
"flowmesh_ssh_gpu": ("flowmesh_ssh", "-gpu"),
|
|
14
|
+
}
|
|
7
15
|
BUILD_TARGETS: dict[str, str] = {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
"flowmesh_worker_gpu_builder": "{registry}/flowmesh_worker_builder:{version}-gpu",
|
|
11
|
-
"flowmesh_worker_gpu": "{registry}/flowmesh_worker:{version}-gpu",
|
|
12
|
-
"flowmesh_ssh_cpu": "{registry}/flowmesh_ssh:{version}-cpu",
|
|
13
|
-
"flowmesh_ssh_gpu": "{registry}/flowmesh_ssh:{version}-gpu",
|
|
16
|
+
target: f"{{registry}}/{repo}:{{version}}{suffix}"
|
|
17
|
+
for target, (repo, suffix) in _TARGET_COMPONENTS.items()
|
|
14
18
|
}
|
|
15
19
|
"""Mapping from build target name to image reference format string."""
|
|
16
20
|
|
|
17
21
|
CACHE_TARGETS: dict[str, str] = {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
"flowmesh_worker_gpu_builder": "{registry}/flowmesh_worker_builder:{scope}-gpu",
|
|
21
|
-
"flowmesh_worker_gpu": "{registry}/flowmesh_worker:{scope}-gpu",
|
|
22
|
-
"flowmesh_ssh_cpu": "{registry}/flowmesh_ssh:{scope}-cpu",
|
|
23
|
-
"flowmesh_ssh_gpu": "{registry}/flowmesh_ssh:{scope}-gpu",
|
|
22
|
+
target: f"{{registry}}/{repo}:{{scope}}{suffix}"
|
|
23
|
+
for target, (repo, suffix) in _TARGET_COMPONENTS.items()
|
|
24
24
|
}
|
|
25
25
|
"""Mapping from build target name to registry cache reference format string."""
|
|
26
26
|
|
|
@@ -110,3 +110,51 @@ def get_push_platforms(target: str) -> str:
|
|
|
110
110
|
if target not in PUSH_PLATFORMS:
|
|
111
111
|
raise ValueError(f"Unknown build target: {target}")
|
|
112
112
|
return PUSH_PLATFORMS[target]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
_RESERVED_CACHE_VERSION = "cache"
|
|
116
|
+
_RESERVED_CACHE_PREFIX = "cache-"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _split_repo_tag(ref: str) -> tuple[str, str] | None:
|
|
120
|
+
"""Split an image ref into ``(repo, tag)``, or ``None`` when it has no tag.
|
|
121
|
+
|
|
122
|
+
Splits on the final colon and applies the Docker rule that a colon whose right side
|
|
123
|
+
contains ``/`` is a registry port, not a tag separator (e.g.
|
|
124
|
+
``localhost:5000/org/img``).
|
|
125
|
+
"""
|
|
126
|
+
repo, sep, tag = ref.rpartition(":")
|
|
127
|
+
return (repo, tag) if sep and "/" not in tag else None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def managed_repos(registry: str) -> set[str]:
|
|
131
|
+
"""Return the distinct image repositories FlowMesh publishes under a registry."""
|
|
132
|
+
return {f"{registry}/{_TARGET_COMPONENTS[target][0]}" for target in BUILD_TARGETS}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def parse_image_ref(registry: str, ref: str) -> tuple[str, str] | None:
|
|
136
|
+
"""Map a concrete image reference back to its ``(target, version)``.
|
|
137
|
+
|
|
138
|
+
Inverse of :func:`get_image_ref`. Returns ``None`` for references outside the
|
|
139
|
+
managed repositories, references without a tag, and cache-scope tags reserved by
|
|
140
|
+
:data:`CACHE_TARGETS` (``cache`` / ``cache-*``), which are registry-only and must
|
|
141
|
+
never be treated as prunable versions.
|
|
142
|
+
"""
|
|
143
|
+
split = _split_repo_tag(ref)
|
|
144
|
+
if split is None:
|
|
145
|
+
return None
|
|
146
|
+
repo, tag = split
|
|
147
|
+
for target, (target_repo, suffix) in _TARGET_COMPONENTS.items():
|
|
148
|
+
if repo != f"{registry}/{target_repo}":
|
|
149
|
+
continue
|
|
150
|
+
if suffix and not tag.endswith(suffix):
|
|
151
|
+
continue
|
|
152
|
+
version = tag.removesuffix(suffix)
|
|
153
|
+
if not version:
|
|
154
|
+
continue
|
|
155
|
+
if version == _RESERVED_CACHE_VERSION or version.startswith(
|
|
156
|
+
_RESERVED_CACHE_PREFIX
|
|
157
|
+
):
|
|
158
|
+
return None
|
|
159
|
+
return target, version
|
|
160
|
+
return None
|
|
@@ -1,262 +0,0 @@
|
|
|
1
|
-
"""Local Docker and compose helpers for FlowMesh tooling."""
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import shutil
|
|
5
|
-
import subprocess
|
|
6
|
-
from collections.abc import Callable, Iterable, Mapping
|
|
7
|
-
from dataclasses import dataclass
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class DockerError(RuntimeError):
|
|
12
|
-
"""Raised when a docker command fails."""
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def ensure_docker_available() -> None:
|
|
16
|
-
"""Verify that the docker CLI is available."""
|
|
17
|
-
if shutil.which("docker") is None:
|
|
18
|
-
raise DockerError("docker is required but was not found in PATH")
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def compose(
|
|
22
|
-
compose_file: Path,
|
|
23
|
-
env_file: Path | None,
|
|
24
|
-
args: Iterable[str],
|
|
25
|
-
env: Mapping[str, str] | None = None,
|
|
26
|
-
capture_output: bool = False,
|
|
27
|
-
) -> subprocess.CompletedProcess[str]:
|
|
28
|
-
"""Run docker compose with the provided arguments."""
|
|
29
|
-
ensure_docker_available()
|
|
30
|
-
cmd = ["docker", "compose"]
|
|
31
|
-
if env_file:
|
|
32
|
-
cmd += ["--env-file", str(env_file)]
|
|
33
|
-
cmd += ["-f", str(compose_file)]
|
|
34
|
-
cmd += list(args)
|
|
35
|
-
merged_env = dict(os.environ)
|
|
36
|
-
if env:
|
|
37
|
-
merged_env.update(env)
|
|
38
|
-
return subprocess.run(
|
|
39
|
-
cmd,
|
|
40
|
-
check=False,
|
|
41
|
-
capture_output=capture_output,
|
|
42
|
-
text=True,
|
|
43
|
-
env=merged_env,
|
|
44
|
-
)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def compose_logs(
|
|
48
|
-
compose_file: Path,
|
|
49
|
-
env_file: Path | None,
|
|
50
|
-
env: Mapping[str, str] | None = None,
|
|
51
|
-
service: str | None = None,
|
|
52
|
-
capture_output: bool = False,
|
|
53
|
-
profile: str | None = None,
|
|
54
|
-
) -> subprocess.CompletedProcess[str]:
|
|
55
|
-
"""Stream compose logs, optionally for a specific service."""
|
|
56
|
-
args: list[str] = []
|
|
57
|
-
if profile:
|
|
58
|
-
args += ["--profile", profile]
|
|
59
|
-
args += ["logs", "-f"]
|
|
60
|
-
if service:
|
|
61
|
-
args.append(service)
|
|
62
|
-
return compose(
|
|
63
|
-
compose_file=compose_file,
|
|
64
|
-
env_file=env_file,
|
|
65
|
-
args=args,
|
|
66
|
-
env=env,
|
|
67
|
-
capture_output=capture_output,
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def container_logs(
|
|
72
|
-
container: str,
|
|
73
|
-
env: Mapping[str, str] | None = None,
|
|
74
|
-
capture_output: bool = False,
|
|
75
|
-
) -> subprocess.CompletedProcess[str]:
|
|
76
|
-
"""Stream logs for a single container via docker logs."""
|
|
77
|
-
ensure_docker_available()
|
|
78
|
-
merged_env = dict(os.environ)
|
|
79
|
-
if env:
|
|
80
|
-
merged_env.update(env)
|
|
81
|
-
return subprocess.run(
|
|
82
|
-
["docker", "logs", "-f", container],
|
|
83
|
-
check=False,
|
|
84
|
-
capture_output=capture_output,
|
|
85
|
-
text=True,
|
|
86
|
-
env=merged_env,
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def inspect_image(
|
|
91
|
-
image: str, capture_output: bool = False
|
|
92
|
-
) -> subprocess.CompletedProcess[str]:
|
|
93
|
-
"""Inspect a docker image by tag."""
|
|
94
|
-
ensure_docker_available()
|
|
95
|
-
return subprocess.run(
|
|
96
|
-
["docker", "image", "inspect", image],
|
|
97
|
-
check=False,
|
|
98
|
-
capture_output=capture_output,
|
|
99
|
-
text=True,
|
|
100
|
-
)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def remove_image(
|
|
104
|
-
image: str, capture_output: bool = False
|
|
105
|
-
) -> subprocess.CompletedProcess[str]:
|
|
106
|
-
"""Remove a docker image by tag."""
|
|
107
|
-
ensure_docker_available()
|
|
108
|
-
return subprocess.run(
|
|
109
|
-
["docker", "rmi", image], check=False, capture_output=capture_output, text=True
|
|
110
|
-
)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def image_env_overrides(image_tag: str | None) -> dict[str, str]:
|
|
114
|
-
"""Build ``FLOWMESH_VERSION`` overrides for compose commands."""
|
|
115
|
-
env: dict[str, str] = {}
|
|
116
|
-
if image_tag:
|
|
117
|
-
env["FLOWMESH_VERSION"] = image_tag
|
|
118
|
-
return env
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
@dataclass
|
|
122
|
-
class ServerWorkerStatusRow:
|
|
123
|
-
name: str
|
|
124
|
-
status: str
|
|
125
|
-
worker_type: str
|
|
126
|
-
gpu_id: str
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def server_worker_status_rows(
|
|
130
|
-
*, include_exited: bool = False
|
|
131
|
-
) -> list[ServerWorkerStatusRow]:
|
|
132
|
-
"""Return status rows for local server worker containers."""
|
|
133
|
-
|
|
134
|
-
def _inspect(name: str, fmt: str) -> str:
|
|
135
|
-
result = subprocess.run(
|
|
136
|
-
["docker", "inspect", "--format", fmt, name],
|
|
137
|
-
capture_output=True,
|
|
138
|
-
text=True,
|
|
139
|
-
check=False,
|
|
140
|
-
)
|
|
141
|
-
if result.returncode != 0:
|
|
142
|
-
return ""
|
|
143
|
-
return result.stdout.strip()
|
|
144
|
-
|
|
145
|
-
ensure_docker_available()
|
|
146
|
-
cmd = [
|
|
147
|
-
"docker",
|
|
148
|
-
"ps",
|
|
149
|
-
"-a" if include_exited else "",
|
|
150
|
-
"--filter",
|
|
151
|
-
"label=flowmesh.group=server-workers",
|
|
152
|
-
"--format",
|
|
153
|
-
"{{.Names}}\t{{.Status}}",
|
|
154
|
-
]
|
|
155
|
-
cmd = [part for part in cmd if part]
|
|
156
|
-
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
157
|
-
rows = result.stdout.strip()
|
|
158
|
-
if not rows:
|
|
159
|
-
return []
|
|
160
|
-
|
|
161
|
-
items: list[ServerWorkerStatusRow] = []
|
|
162
|
-
for line in rows.splitlines():
|
|
163
|
-
if not line.strip():
|
|
164
|
-
continue
|
|
165
|
-
name, status = (line.split("\t", 1) + [""])[:2]
|
|
166
|
-
worker_type = _inspect(name, '{{index .Config.Labels "flowmesh.worker.type"}}')
|
|
167
|
-
gpu_id = _inspect(name, '{{index .Config.Labels "flowmesh.worker.gpu_id"}}')
|
|
168
|
-
if not worker_type:
|
|
169
|
-
env_dump = _inspect(name, "{{range .Config.Env}}{{println .}}{{end}}")
|
|
170
|
-
detected_gpu = ""
|
|
171
|
-
for entry in env_dump.splitlines():
|
|
172
|
-
if entry.startswith("WORKER_HOST_GPU_ID="):
|
|
173
|
-
detected_gpu = entry.split("=", 1)[1]
|
|
174
|
-
break
|
|
175
|
-
if detected_gpu:
|
|
176
|
-
worker_type = "gpu"
|
|
177
|
-
gpu_id = detected_gpu
|
|
178
|
-
else:
|
|
179
|
-
worker_type = "cpu"
|
|
180
|
-
items.append(
|
|
181
|
-
ServerWorkerStatusRow(
|
|
182
|
-
name=name,
|
|
183
|
-
status=status,
|
|
184
|
-
worker_type=worker_type or "unknown",
|
|
185
|
-
gpu_id=gpu_id or "-",
|
|
186
|
-
)
|
|
187
|
-
)
|
|
188
|
-
return items
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
@dataclass
|
|
192
|
-
class DockerComposeStack:
|
|
193
|
-
"""Local compose wrapper."""
|
|
194
|
-
|
|
195
|
-
compose_file: Path
|
|
196
|
-
"""Path to the compose file used for all stack operations."""
|
|
197
|
-
env_file_var: str
|
|
198
|
-
"""
|
|
199
|
-
Environment variable name passed to compose to point at the selected env file.
|
|
200
|
-
"""
|
|
201
|
-
load_env: Callable[[Path], None]
|
|
202
|
-
"""
|
|
203
|
-
Callback that loads and resolves env-file values before stack operations run.
|
|
204
|
-
"""
|
|
205
|
-
ensure_deploy_paths: Callable[[Path], None] | None = None
|
|
206
|
-
"""
|
|
207
|
-
Optional callback that prepares required local files and directories before
|
|
208
|
-
deployment-oriented compose commands.
|
|
209
|
-
"""
|
|
210
|
-
|
|
211
|
-
def run(
|
|
212
|
-
self,
|
|
213
|
-
args: list[str],
|
|
214
|
-
env_file: Path,
|
|
215
|
-
env: dict[str, str] | None = None,
|
|
216
|
-
to_deploy: bool = False,
|
|
217
|
-
) -> subprocess.CompletedProcess[str]:
|
|
218
|
-
"""Run ``docker compose`` for this stack."""
|
|
219
|
-
self.load_env(env_file)
|
|
220
|
-
if to_deploy and self.ensure_deploy_paths is not None:
|
|
221
|
-
self.ensure_deploy_paths(Path.cwd())
|
|
222
|
-
compose_env = {self.env_file_var: str(env_file.resolve())}
|
|
223
|
-
if env:
|
|
224
|
-
compose_env.update(env)
|
|
225
|
-
return compose(
|
|
226
|
-
compose_file=self.compose_file,
|
|
227
|
-
env_file=env_file,
|
|
228
|
-
args=args,
|
|
229
|
-
env=compose_env,
|
|
230
|
-
)
|
|
231
|
-
|
|
232
|
-
def stream_logs(
|
|
233
|
-
self,
|
|
234
|
-
env_file: Path,
|
|
235
|
-
service: str | None = None,
|
|
236
|
-
profile: str | None = None,
|
|
237
|
-
) -> int:
|
|
238
|
-
"""Stream stack logs and fall back to container logs when needed."""
|
|
239
|
-
self.load_env(env_file)
|
|
240
|
-
compose_env = {self.env_file_var: str(env_file.resolve())}
|
|
241
|
-
if service:
|
|
242
|
-
result = compose_logs(
|
|
243
|
-
compose_file=self.compose_file,
|
|
244
|
-
env_file=env_file,
|
|
245
|
-
env=compose_env,
|
|
246
|
-
service=service,
|
|
247
|
-
capture_output=False,
|
|
248
|
-
profile=profile,
|
|
249
|
-
)
|
|
250
|
-
if result.returncode == 0:
|
|
251
|
-
return 0
|
|
252
|
-
fallback = container_logs(container=service, env=None, capture_output=False)
|
|
253
|
-
return fallback.returncode
|
|
254
|
-
|
|
255
|
-
result = compose_logs(
|
|
256
|
-
compose_file=self.compose_file,
|
|
257
|
-
env_file=env_file,
|
|
258
|
-
env=compose_env,
|
|
259
|
-
capture_output=False,
|
|
260
|
-
profile=profile,
|
|
261
|
-
)
|
|
262
|
-
return result.returncode
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/src/flowmesh_sdk_stack.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|