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.
Files changed (21) hide show
  1. {flowmesh_sdk_stack-0.1.5/src/flowmesh_sdk_stack.egg-info → flowmesh_sdk_stack-0.1.6}/PKG-INFO +2 -2
  2. {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/pyproject.toml +2 -2
  3. {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6/src/flowmesh_sdk_stack.egg-info}/PKG-INFO +2 -2
  4. {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/src/flowmesh_sdk_stack.egg-info/SOURCES.txt +1 -0
  5. {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/src/flowmesh_sdk_stack.egg-info/requires.txt +1 -1
  6. {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/src/flowmesh_stack/__init__.py +9 -1
  7. flowmesh_sdk_stack-0.1.6/src/flowmesh_stack/docker.py +493 -0
  8. flowmesh_sdk_stack-0.1.6/src/flowmesh_stack/image_prune.py +128 -0
  9. {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/src/flowmesh_stack/images.py +60 -12
  10. flowmesh_sdk_stack-0.1.5/src/flowmesh_stack/docker.py +0 -262
  11. {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/LICENSE +0 -0
  12. {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/README.md +0 -0
  13. {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/setup.cfg +0 -0
  14. {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/src/flowmesh_sdk_stack.egg-info/dependency_links.txt +0 -0
  15. {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/src/flowmesh_sdk_stack.egg-info/top_level.txt +0 -0
  16. {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/src/flowmesh_stack/doctor.py +0 -0
  17. {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/src/flowmesh_stack/env.py +0 -0
  18. {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/src/flowmesh_stack/env_schema.py +0 -0
  19. {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/src/flowmesh_stack/node_client.py +0 -0
  20. {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/src/flowmesh_stack/paths.py +0 -0
  21. {flowmesh_sdk_stack-0.1.5 → flowmesh_sdk_stack-0.1.6}/src/flowmesh_stack/workers.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowmesh-sdk-stack
3
- Version: 0.1.5
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.5
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.5"
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.5",
14
+ "flowmesh-sdk==0.1.6",
15
15
  "httpx>=0.27.0",
16
16
  "pyyaml>=6.0.0",
17
17
  "docker>=7.1.0",
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowmesh-sdk-stack
3
- Version: 0.1.5
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.5
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
@@ -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,4 +1,4 @@
1
- flowmesh-sdk==0.1.5
1
+ flowmesh-sdk==0.1.6
2
2
  httpx>=0.27.0
3
3
  pyyaml>=6.0.0
4
4
  docker>=7.1.0
@@ -1,6 +1,12 @@
1
1
  """FlowMesh SDK — Stack management extensions."""
2
2
 
3
- from .images import BUILD_GROUPS, BUILD_TARGETS, get_image_ref
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
- "flowmesh_server": "{registry}/flowmesh_server:{version}",
9
- "flowmesh_worker_cpu": "{registry}/flowmesh_worker:{version}-cpu",
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
- "flowmesh_server": "{registry}/flowmesh_server:{scope}",
19
- "flowmesh_worker_cpu": "{registry}/flowmesh_worker:{scope}-cpu",
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