flowmesh-cli-stack 0.1.5rc1__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_cli_stack-0.1.5rc1/src/flowmesh_cli_stack.egg-info → flowmesh_cli_stack-0.1.6}/PKG-INFO +3 -3
  2. {flowmesh_cli_stack-0.1.5rc1 → flowmesh_cli_stack-0.1.6}/pyproject.toml +3 -3
  3. {flowmesh_cli_stack-0.1.5rc1 → flowmesh_cli_stack-0.1.6}/src/flowmesh_cli_stack/__init__.py +2 -0
  4. {flowmesh_cli_stack-0.1.5rc1 → flowmesh_cli_stack-0.1.6}/src/flowmesh_cli_stack/assets/.env.example +14 -5
  5. {flowmesh_cli_stack-0.1.5rc1 → flowmesh_cli_stack-0.1.6}/src/flowmesh_cli_stack/env_schema.py +42 -10
  6. flowmesh_cli_stack-0.1.6/src/flowmesh_cli_stack/image.py +413 -0
  7. {flowmesh_cli_stack-0.1.5rc1 → flowmesh_cli_stack-0.1.6}/src/flowmesh_cli_stack/stack.py +0 -76
  8. {flowmesh_cli_stack-0.1.5rc1 → flowmesh_cli_stack-0.1.6/src/flowmesh_cli_stack.egg-info}/PKG-INFO +3 -3
  9. {flowmesh_cli_stack-0.1.5rc1 → flowmesh_cli_stack-0.1.6}/src/flowmesh_cli_stack.egg-info/SOURCES.txt +1 -0
  10. flowmesh_cli_stack-0.1.6/src/flowmesh_cli_stack.egg-info/requires.txt +5 -0
  11. flowmesh_cli_stack-0.1.5rc1/src/flowmesh_cli_stack.egg-info/requires.txt +0 -5
  12. {flowmesh_cli_stack-0.1.5rc1 → flowmesh_cli_stack-0.1.6}/LICENSE +0 -0
  13. {flowmesh_cli_stack-0.1.5rc1 → flowmesh_cli_stack-0.1.6}/README.md +0 -0
  14. {flowmesh_cli_stack-0.1.5rc1 → flowmesh_cli_stack-0.1.6}/setup.cfg +0 -0
  15. {flowmesh_cli_stack-0.1.5rc1 → flowmesh_cli_stack-0.1.6}/src/flowmesh_cli_stack/assets/compose.yml +0 -0
  16. {flowmesh_cli_stack-0.1.5rc1 → flowmesh_cli_stack-0.1.6}/src/flowmesh_cli_stack/assets/docker-bake.hcl +0 -0
  17. {flowmesh_cli_stack-0.1.5rc1 → flowmesh_cli_stack-0.1.6}/src/flowmesh_cli_stack/bundle.py +0 -0
  18. {flowmesh_cli_stack-0.1.5rc1 → flowmesh_cli_stack-0.1.6}/src/flowmesh_cli_stack/utils.py +0 -0
  19. {flowmesh_cli_stack-0.1.5rc1 → flowmesh_cli_stack-0.1.6}/src/flowmesh_cli_stack/worker.py +0 -0
  20. {flowmesh_cli_stack-0.1.5rc1 → flowmesh_cli_stack-0.1.6}/src/flowmesh_cli_stack.egg-info/dependency_links.txt +0 -0
  21. {flowmesh_cli_stack-0.1.5rc1 → flowmesh_cli_stack-0.1.6}/src/flowmesh_cli_stack.egg-info/top_level.txt +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowmesh-cli-stack
3
- Version: 0.1.5rc1
3
+ Version: 0.1.6
4
4
  Summary: FlowMesh CLI stack commands
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-cli==0.1.5rc1
10
- Requires-Dist: flowmesh-sdk-stack==0.1.5rc1
9
+ Requires-Dist: flowmesh-cli==0.1.6
10
+ Requires-Dist: flowmesh-sdk-stack==0.1.6
11
11
  Requires-Dist: docker>=7.1.0
12
12
  Requires-Dist: packaging>=24
13
13
  Requires-Dist: pyyaml>=6.0.2
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "flowmesh-cli-stack"
7
- version = "0.1.5rc1"
7
+ version = "0.1.6"
8
8
  description = "FlowMesh CLI stack commands"
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-cli==0.1.5rc1",
15
- "flowmesh-sdk-stack==0.1.5rc1",
14
+ "flowmesh-cli==0.1.6",
15
+ "flowmesh-sdk-stack==0.1.6",
16
16
  "docker>=7.1.0",
17
17
  "packaging>=24",
18
18
  "pyyaml>=6.0.2",
@@ -3,11 +3,13 @@
3
3
  import typer
4
4
 
5
5
  from .bundle import app as bundle_app
6
+ from .image import app as image_app
6
7
  from .stack import app as stack_app
7
8
  from .worker import app as worker_app
8
9
 
9
10
 
10
11
  def register(root: typer.Typer) -> None:
11
12
  root.add_typer(stack_app, name="stack")
13
+ stack_app.add_typer(image_app, name="image")
12
14
  stack_app.add_typer(worker_app, name="worker")
13
15
  stack_app.add_typer(bundle_app, name="bundle")
@@ -75,14 +75,20 @@ REDIS_TLS_CA_FILE=/etc/ssl/redis/redis-ca.pem
75
75
  REDIS_TLS_CERT_FILE=/etc/ssl/redis/redis-server.pem
76
76
  REDIS_TLS_KEY_FILE=/etc/ssl/redis/redis-server.key
77
77
 
78
+ # ==== Port Forward Support ====
79
+ ENABLE_SERVER_PORT_FORWARD=true
80
+ ENABLE_PERSISTENT_PORT_FORWARD=true
81
+ SERVER_PORT_FORWARD_BIND_HOST=0.0.0.0
82
+ SERVER_PORT_FORWARD_PUBLIC_HOST=localhost
83
+ SERVER_PORT_FORWARD_PORT_START=32000
84
+ SERVER_PORT_FORWARD_PORT_END=32100
85
+
78
86
  # ==== SSH Task Support ====
79
87
  ENABLE_SERVER_SSH_PROXY=true
80
- ENABLE_SERVER_SSH_FORWARD=true
81
88
  ENABLE_SERVER_SSH_CONNECTION_AUDIT=true
82
- SERVER_SSH_FORWARD_BIND_HOST=0.0.0.0
83
- SERVER_SSH_FORWARD_PUBLIC_HOST=localhost
84
- SERVER_SSH_FORWARD_PORT_START=32000
85
- SERVER_SSH_FORWARD_PORT_END=32100
89
+
90
+ # ==== Serve Task Support ====
91
+ ENABLE_SERVER_SERVE_PROXY=true
86
92
 
87
93
  # ==== SSH Worker Defaults ====
88
94
  ENABLE_SSH_BY_DEFAULT=true
@@ -158,7 +164,10 @@ SERVER_CUDA_PROBE_IMAGE=nvidia/cuda:12.9.1-base-ubuntu24.04
158
164
  DOCKER_GPU_RUNTIME=nvidia
159
165
  CUDA_VISIBLE_DEVICES=all
160
166
  WORKER_UPLOAD_RESULTS=false
167
+ WORKER_EXECUTOR_IDLE_CLEANUP_SEC=60
161
168
  MODEL_CLEANUP_AFTER_UPLOAD=0
169
+ SERVE_DEFAULT_TTL_SEC=3600
170
+ SERVE_MAX_TTL_SEC=86400
162
171
 
163
172
  # ==== Model Pre-downloading ====
164
173
  # Comma-separated list of models to pre-download during worker startup
@@ -263,26 +263,23 @@ STACK_ENV_SCHEMA = EnvSchema(
263
263
  ],
264
264
  ),
265
265
  EnvSection(
266
- title="SSH Task Support",
266
+ title="Port Forward Support",
267
267
  vars=[
268
- EnvVar("ENABLE_SERVER_SSH_PROXY", "true", var_type=EnvVarType.BOOL),
269
- EnvVar("ENABLE_SERVER_SSH_FORWARD", "true", var_type=EnvVarType.BOOL),
268
+ EnvVar("ENABLE_SERVER_PORT_FORWARD", "true", var_type=EnvVarType.BOOL),
270
269
  EnvVar(
271
- "ENABLE_SERVER_SSH_CONNECTION_AUDIT",
272
- "true",
273
- var_type=EnvVarType.BOOL,
270
+ "ENABLE_PERSISTENT_PORT_FORWARD", "true", var_type=EnvVarType.BOOL
274
271
  ),
275
- EnvVar("SERVER_SSH_FORWARD_BIND_HOST", "0.0.0.0", required=True),
276
- EnvVar("SERVER_SSH_FORWARD_PUBLIC_HOST", "localhost", required=True),
272
+ EnvVar("SERVER_PORT_FORWARD_BIND_HOST", "0.0.0.0", required=True),
273
+ EnvVar("SERVER_PORT_FORWARD_PUBLIC_HOST", "localhost", required=True),
277
274
  EnvVar(
278
- "SERVER_SSH_FORWARD_PORT_START",
275
+ "SERVER_PORT_FORWARD_PORT_START",
279
276
  "32000",
280
277
  var_type=EnvVarType.INT,
281
278
  required=True,
282
279
  min_value=1,
283
280
  ),
284
281
  EnvVar(
285
- "SERVER_SSH_FORWARD_PORT_END",
282
+ "SERVER_PORT_FORWARD_PORT_END",
286
283
  "32100",
287
284
  var_type=EnvVarType.INT,
288
285
  required=True,
@@ -290,6 +287,23 @@ STACK_ENV_SCHEMA = EnvSchema(
290
287
  ),
291
288
  ],
292
289
  ),
290
+ EnvSection(
291
+ title="SSH Task Support",
292
+ vars=[
293
+ EnvVar("ENABLE_SERVER_SSH_PROXY", "true", var_type=EnvVarType.BOOL),
294
+ EnvVar(
295
+ "ENABLE_SERVER_SSH_CONNECTION_AUDIT",
296
+ "true",
297
+ var_type=EnvVarType.BOOL,
298
+ ),
299
+ ],
300
+ ),
301
+ EnvSection(
302
+ title="Serve Task Support",
303
+ vars=[
304
+ EnvVar("ENABLE_SERVER_SERVE_PROXY", "true", var_type=EnvVarType.BOOL),
305
+ ],
306
+ ),
293
307
  EnvSection(
294
308
  title="SSH Worker Defaults",
295
309
  vars=[
@@ -510,12 +524,30 @@ STACK_ENV_SCHEMA = EnvSchema(
510
524
  "CUDA_VISIBLE_DEVICES", "all", var_type=EnvVarType.CSV_INTS_OR_ALL
511
525
  ),
512
526
  EnvVar("WORKER_UPLOAD_RESULTS", "false", var_type=EnvVarType.BOOL),
527
+ EnvVar(
528
+ "WORKER_EXECUTOR_IDLE_CLEANUP_SEC",
529
+ "60",
530
+ var_type=EnvVarType.FLOAT,
531
+ min_value=0,
532
+ ),
513
533
  EnvVar(
514
534
  "MODEL_CLEANUP_AFTER_UPLOAD",
515
535
  "0",
516
536
  var_type=EnvVarType.INT,
517
537
  min_value=0,
518
538
  ),
539
+ EnvVar(
540
+ "SERVE_DEFAULT_TTL_SEC",
541
+ "3600",
542
+ var_type=EnvVarType.FLOAT,
543
+ min_value=0,
544
+ ),
545
+ EnvVar(
546
+ "SERVE_MAX_TTL_SEC",
547
+ "86400",
548
+ var_type=EnvVarType.FLOAT,
549
+ min_value=0,
550
+ ),
519
551
  ],
520
552
  ),
521
553
  EnvSection(
@@ -0,0 +1,413 @@
1
+ """Local Docker image management commands."""
2
+
3
+ import json
4
+ import os
5
+ from datetime import UTC, datetime
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import typer
10
+ from flowmesh_cli.core import logging
11
+ from flowmesh_cli.core.typer import get_typer
12
+ from flowmesh_stack.docker import (
13
+ DockerError,
14
+ ManagedImage,
15
+ RemovalResult,
16
+ container_image_refs,
17
+ ensure_docker_available,
18
+ list_managed_images,
19
+ remove_images,
20
+ )
21
+ from flowmesh_stack.env import load_env
22
+ from flowmesh_stack.image_prune import PrunePlan, parse_duration, select_prune_targets
23
+ from flowmesh_stack.images import BUILD_TARGETS, get_image_ref
24
+
25
+ from .utils import DEFAULT_ENV_FILE, STACK_PATH_KEYS
26
+
27
+ app = get_typer(help="Manage FlowMesh Docker images on the local daemon.")
28
+
29
+ _BUILDER_TARGET = "flowmesh_worker_gpu_builder"
30
+ _DEFAULT_REGISTRY = "ghcr.io/mlsys-io"
31
+
32
+
33
+ def _prepare(env_file: Path) -> str:
34
+ """Guard Docker, load the env file, and return the configured registry."""
35
+ try:
36
+ ensure_docker_available()
37
+ except DockerError as exc:
38
+ logging.error(str(exc))
39
+ raise typer.Exit(code=1)
40
+ load_env(env_file, base_dir=Path.cwd(), path_keys=STACK_PATH_KEYS)
41
+ return os.getenv("FLOWMESH_REGISTRY", _DEFAULT_REGISTRY)
42
+
43
+
44
+ def _in_use_ids() -> set[str]:
45
+ """Resolve in-use image ids, aborting if the daemon can't be queried."""
46
+ try:
47
+ return container_image_refs()
48
+ except DockerError as exc:
49
+ logging.error(str(exc))
50
+ raise typer.Exit(code=1)
51
+
52
+
53
+ def _resolve_targets(targets: list[str] | None, include_builder: bool) -> list[str]:
54
+ if targets:
55
+ invalid = [t for t in targets if t not in BUILD_TARGETS]
56
+ if invalid:
57
+ logging.error(
58
+ f"Invalid target(s): {', '.join(invalid)}. "
59
+ f"Known: {', '.join(BUILD_TARGETS)}."
60
+ )
61
+ raise typer.Exit(code=1)
62
+ resolved = list(dict.fromkeys(targets))
63
+ else:
64
+ resolved = [t for t in BUILD_TARGETS if t != _BUILDER_TARGET]
65
+ if include_builder and _BUILDER_TARGET not in resolved:
66
+ resolved.append(_BUILDER_TARGET)
67
+ return resolved
68
+
69
+
70
+ def _human_size(num: int) -> str:
71
+ size = float(num)
72
+ for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
73
+ if size < 1024 or unit == "TiB":
74
+ return f"{size:.0f}{unit}" if unit == "B" else f"{size:.1f}{unit}"
75
+ size /= 1024
76
+ return f"{size:.1f}TiB"
77
+
78
+
79
+ def _image_row(image: ManagedImage) -> dict[str, Any]:
80
+ return {
81
+ "repo": image.repo,
82
+ "tag": image.tag,
83
+ "target": image.target,
84
+ "version": image.version,
85
+ "image_id": image.image_id,
86
+ "size_bytes": image.size_bytes,
87
+ "created": image.created.isoformat() if image.created else None,
88
+ "dangling": image.dangling,
89
+ "in_use": image.in_use,
90
+ }
91
+
92
+
93
+ def _short(image: ManagedImage) -> dict[str, Any]:
94
+ return {
95
+ "tag": image.tag,
96
+ "target": image.target,
97
+ "version": image.version,
98
+ "image_id": image.image_id,
99
+ }
100
+
101
+
102
+ def _render_table(images: list[ManagedImage]) -> None:
103
+ headers = ("REPOSITORY", "TAG", "VERSION", "SIZE", "CREATED", "IN-USE")
104
+ rows: list[tuple[str, ...]] = []
105
+ for image in images:
106
+ tag = image.tag.split(":", 1)[-1] if image.tag else "<none>"
107
+ rows.append(
108
+ (
109
+ image.repo,
110
+ tag,
111
+ image.version or "-",
112
+ _human_size(image.size_bytes),
113
+ image.created.date().isoformat() if image.created else "unknown",
114
+ "yes" if image.in_use else "no",
115
+ )
116
+ )
117
+ widths = [len(header) for header in headers]
118
+ for row in rows:
119
+ widths = [max(width, len(cell)) for width, cell in zip(widths, row)]
120
+ logging.log(" ".join(header.ljust(widths[i]) for i, header in enumerate(headers)))
121
+ for row in rows:
122
+ logging.log(" ".join(cell.ljust(widths[i]) for i, cell in enumerate(row)))
123
+
124
+
125
+ @app.command("list")
126
+ @app.command("ls")
127
+ def list_images(
128
+ targets: list[str] | None = typer.Option(
129
+ None, "--target", "-t", help="Restrict to specific build targets."
130
+ ),
131
+ version: str | None = typer.Option(
132
+ None, "--version", help="Restrict to a single version tag."
133
+ ),
134
+ in_use: bool = typer.Option(
135
+ False, "--in-use", help="Only images backing a container (running or stopped)."
136
+ ),
137
+ dangling: bool = typer.Option(
138
+ False, "--dangling", help="Only untagged FlowMesh layers."
139
+ ),
140
+ include_builder: bool = typer.Option(
141
+ False, "--include-builder", help="Include the GPU builder image."
142
+ ),
143
+ as_json: bool = typer.Option(False, "--json", help="Emit rows as JSON."),
144
+ env_file: Path = typer.Option(
145
+ DEFAULT_ENV_FILE, "--env-file", help="Env file for docker"
146
+ ),
147
+ ) -> None:
148
+ """List FlowMesh Docker images on the local daemon."""
149
+ registry = _prepare(env_file)
150
+ resolved = _resolve_targets(targets, include_builder)
151
+ in_use_ids = _in_use_ids()
152
+ images = list_managed_images(
153
+ registry, include_dangling=dangling, in_use_ids=in_use_ids
154
+ )
155
+
156
+ resolved_set = set(resolved)
157
+ explicit = bool(targets)
158
+ selected: list[ManagedImage] = []
159
+ for image in images:
160
+ if dangling and not image.dangling:
161
+ continue
162
+ if image.dangling:
163
+ selected.append(image)
164
+ elif image.target in resolved_set:
165
+ selected.append(image)
166
+ elif image.target is None and not explicit:
167
+ selected.append(image)
168
+
169
+ if version is not None:
170
+ selected = [i for i in selected if i.version == version]
171
+ if in_use:
172
+ selected = [i for i in selected if i.in_use]
173
+
174
+ selected.sort(key=lambda i: (i.repo, i.version or "", i.tag or ""))
175
+
176
+ if as_json:
177
+ logging.log(json.dumps([_image_row(i) for i in selected], indent=2))
178
+ return
179
+ if not selected:
180
+ logging.info("No matching FlowMesh images found.")
181
+ return
182
+ _render_table(selected)
183
+
184
+
185
+ @app.command("prune")
186
+ def prune(
187
+ keep_last: int | None = typer.Option(
188
+ None, "--keep-last", min=0, help="Protect the N newest versions per target."
189
+ ),
190
+ keep_active: bool = typer.Option(
191
+ False,
192
+ "--keep-active",
193
+ help="Protect versions backing a container (running or stopped).",
194
+ ),
195
+ keep: list[str] | None = typer.Option(
196
+ None, "--keep", help="Protect explicit version(s)."
197
+ ),
198
+ older_than: str | None = typer.Option(
199
+ None, "--older-than", help="Only images created before e.g. 30d, 12h."
200
+ ),
201
+ dangling: bool = typer.Option(
202
+ False, "--dangling", help="Also remove dangling FlowMesh layers."
203
+ ),
204
+ targets: list[str] | None = typer.Option(
205
+ None, "--target", "-t", help="Restrict to specific build targets."
206
+ ),
207
+ include_builder: bool = typer.Option(
208
+ False, "--include-builder", help="Include the GPU builder image."
209
+ ),
210
+ dry_run: bool = typer.Option(
211
+ False, "--dry-run", help="Show the deletion plan without removing anything."
212
+ ),
213
+ yes: bool = typer.Option(
214
+ False, "--yes", "-y", help="Skip the confirmation prompt."
215
+ ),
216
+ as_json: bool = typer.Option(False, "--json", help="Emit the plan/result as JSON."),
217
+ env_file: Path = typer.Option(
218
+ DEFAULT_ENV_FILE, "--env-file", help="Env file for docker"
219
+ ),
220
+ ) -> None:
221
+ """Remove stale FlowMesh images by policy.
222
+
223
+ Requires at least one selection policy (--keep-last, --older-than, or
224
+ --dangling); --keep and --keep-active only protect images and never select.
225
+ """
226
+ if keep_last is None and older_than is None and not dangling:
227
+ logging.error(
228
+ "Refusing to prune without a selection policy. "
229
+ "Pass at least one of --keep-last, --older-than, or --dangling."
230
+ )
231
+ raise typer.Exit(code=1)
232
+ if as_json and not yes and not dry_run:
233
+ logging.error(
234
+ "Refusing to delete in --json mode without confirmation. "
235
+ "Add --yes to proceed non-interactively, or --dry-run to preview."
236
+ )
237
+ raise typer.Exit(code=1)
238
+ if dangling and targets:
239
+ logging.error(
240
+ "--dangling cannot be combined with --target: dangling layers have no "
241
+ "build target to restrict them to. Drop --target, or drop --dangling."
242
+ )
243
+ raise typer.Exit(code=1)
244
+
245
+ registry = _prepare(env_file)
246
+ resolved = set(_resolve_targets(targets, include_builder))
247
+ duration = None
248
+ if older_than is not None:
249
+ try:
250
+ duration = parse_duration(older_than)
251
+ except ValueError as exc:
252
+ logging.error(str(exc))
253
+ raise typer.Exit(code=1)
254
+
255
+ in_use_ids = _in_use_ids()
256
+ images = list_managed_images(
257
+ registry, include_dangling=dangling, in_use_ids=in_use_ids
258
+ )
259
+ scoped = [i for i in images if i.dangling or i.target in resolved]
260
+
261
+ plan = select_prune_targets(
262
+ scoped,
263
+ keep_last=keep_last,
264
+ keep_versions=set(keep or []),
265
+ keep_active=keep_active,
266
+ older_than=duration,
267
+ include_dangling=dangling,
268
+ now=datetime.now(UTC),
269
+ )
270
+
271
+ if dry_run:
272
+ _report_plan(plan, as_json=as_json)
273
+ return
274
+ if not plan.deleted:
275
+ if as_json:
276
+ logging.log(json.dumps(_result_payload(False, [], []), indent=2))
277
+ else:
278
+ logging.info("Nothing to prune.")
279
+ return
280
+
281
+ if not as_json and not yes:
282
+ logging.info(f"About to remove {len(plan.deleted)} image(s):")
283
+ for image in plan.deleted:
284
+ logging.log(f" {image.removal_ref}")
285
+ if not typer.confirm("Proceed?", default=False):
286
+ logging.info("Aborted.")
287
+ return
288
+
289
+ results = remove_images([i.removal_ref for i in plan.deleted])
290
+ _report_results(results, as_json=as_json)
291
+
292
+
293
+ @app.command("rm")
294
+ def rm(
295
+ versions: list[str] = typer.Argument(..., help="Version(s) to remove."),
296
+ targets: list[str] | None = typer.Option(
297
+ None, "--target", "-t", help="Restrict to specific build targets."
298
+ ),
299
+ include_builder: bool = typer.Option(
300
+ False, "--include-builder", help="Include the GPU builder image."
301
+ ),
302
+ force: bool = typer.Option(
303
+ False, "--force", "-f", help="Remove even if the image is referenced."
304
+ ),
305
+ dry_run: bool = typer.Option(
306
+ False, "--dry-run", help="Show what would be removed without removing it."
307
+ ),
308
+ yes: bool = typer.Option(
309
+ False, "--yes", "-y", help="Skip the confirmation prompt."
310
+ ),
311
+ as_json: bool = typer.Option(False, "--json", help="Emit the result as JSON."),
312
+ env_file: Path = typer.Option(
313
+ DEFAULT_ENV_FILE, "--env-file", help="Env file for docker"
314
+ ),
315
+ ) -> None:
316
+ """Remove FlowMesh images for one or more explicit versions."""
317
+ if as_json and not yes and not dry_run:
318
+ logging.error(
319
+ "Refusing to delete in --json mode without confirmation. "
320
+ "Add --yes to proceed non-interactively, or --dry-run to preview."
321
+ )
322
+ raise typer.Exit(code=1)
323
+ registry = _prepare(env_file)
324
+ resolved = _resolve_targets(targets, include_builder)
325
+
326
+ present = {
327
+ image.tag for image in list_managed_images(registry) if image.tag is not None
328
+ }
329
+ refs: list[str] = []
330
+ for version in dict.fromkeys(versions):
331
+ for target in resolved:
332
+ ref = get_image_ref(registry=registry, version=version, target=target)
333
+ if ref in present:
334
+ refs.append(ref)
335
+ else:
336
+ logging.warning(f"Image not found: {ref}")
337
+
338
+ if not refs:
339
+ logging.info("No matching images to remove.")
340
+ return
341
+
342
+ if dry_run:
343
+ if as_json:
344
+ logging.log(
345
+ json.dumps(
346
+ {"dry_run": True, "deleted": [{"tag": r} for r in refs]}, indent=2
347
+ )
348
+ )
349
+ else:
350
+ logging.info("Images to be removed:")
351
+ for ref in refs:
352
+ logging.log(f" {ref}")
353
+ return
354
+
355
+ if not as_json and not yes:
356
+ logging.info(f"About to remove {len(refs)} image(s):")
357
+ for ref in refs:
358
+ logging.log(f" {ref}")
359
+ if not typer.confirm("Proceed?", default=False):
360
+ logging.info("Aborted.")
361
+ return
362
+
363
+ results = remove_images(refs, force=force)
364
+ _report_results(results, as_json=as_json)
365
+
366
+
367
+ def _result_payload(
368
+ dry_run: bool,
369
+ deleted: list[dict[str, Any]],
370
+ failed: list[dict[str, Any]],
371
+ ) -> dict[str, Any]:
372
+ return {"dry_run": dry_run, "deleted": deleted, "failed": failed}
373
+
374
+
375
+ def _report_plan(plan: PrunePlan, *, as_json: bool) -> None:
376
+ if as_json:
377
+ payload = {
378
+ "dry_run": True,
379
+ "deleted": [_short(i) for i in plan.deleted],
380
+ "protected": [
381
+ {"tag": i.tag, "reason": reason} for i, reason in plan.protected
382
+ ],
383
+ "failed": [],
384
+ }
385
+ logging.log(json.dumps(payload, indent=2))
386
+ return
387
+ if not plan.deleted:
388
+ logging.info("Nothing to prune.")
389
+ return
390
+ logging.info(f"Would remove {len(plan.deleted)} image(s):")
391
+ for image in plan.deleted:
392
+ logging.log(f" {image.removal_ref}")
393
+
394
+
395
+ def _report_results(results: list[RemovalResult], *, as_json: bool) -> None:
396
+ removed = [r for r in results if r.ok]
397
+ failed = [r for r in results if not r.ok]
398
+ if as_json:
399
+ payload = _result_payload(
400
+ False,
401
+ [{"tag": r.ref} for r in removed],
402
+ [{"tag": r.ref, "error": r.error} for r in failed],
403
+ )
404
+ logging.log(json.dumps(payload, indent=2))
405
+ else:
406
+ for result in removed:
407
+ logging.success(f"Removed image: {result.ref}")
408
+ for result in failed:
409
+ logging.error(f"Failed to remove image: {result.ref}")
410
+ if result.error:
411
+ logging.log(result.error, err=True)
412
+ if failed:
413
+ raise typer.Exit(code=1)
@@ -16,8 +16,6 @@ from flowmesh_stack.docker import (
16
16
  DockerError,
17
17
  ensure_docker_available,
18
18
  image_env_overrides,
19
- inspect_image,
20
- remove_image,
21
19
  )
22
20
  from flowmesh_stack.doctor import DoctorFinding, run_doctor_checks
23
21
  from flowmesh_stack.env import ensure_env_file, load_env, parse_env_file
@@ -27,7 +25,6 @@ from flowmesh_stack.images import (
27
25
  BUILD_TARGETS,
28
26
  expand_build_targets,
29
27
  get_cache_ref,
30
- get_image_ref,
31
28
  get_push_platforms,
32
29
  )
33
30
 
@@ -770,76 +767,3 @@ def init(
770
767
  }
771
768
  env_file.write_text(render_env_example(STACK_ENV_SCHEMA, overrides=overrides))
772
769
  logging.success(f"Wrote {env_file} (NODE_ROLE={node_role.value}).")
773
-
774
-
775
- @app.command("purge")
776
- def purge(
777
- version: str = typer.Argument(
778
- ..., help="FlowMesh version to purge from local Docker"
779
- ),
780
- targets: list[str] | None = typer.Option(
781
- None,
782
- "--target",
783
- "-t",
784
- help="Optional specific image targets to purge "
785
- "(e.g., flowmesh_server, flowmesh_worker_gpu, etc.)",
786
- ),
787
- dry_run: bool = typer.Option(
788
- False, "--dry-run", help="List images to be purged without deleting them"
789
- ),
790
- env_file: Path = typer.Option(
791
- DEFAULT_ENV_FILE, "--env-file", help="Env file for docker"
792
- ),
793
- ) -> None:
794
- """Purge FlowMesh Docker images for a specific version from local Docker."""
795
- try:
796
- ensure_docker_available()
797
- except DockerError as exc:
798
- logging.error(str(exc))
799
- raise typer.Exit(code=1)
800
-
801
- logging.info(f"Purging FlowMesh Docker images with version '{version}'...")
802
- load_env(env_file, base_dir=Path.cwd(), path_keys=STACK_PATH_KEYS)
803
-
804
- if targets is None:
805
- targets = list(BUILD_TARGETS)
806
- else:
807
- invalid = [t for t in targets if t not in BUILD_TARGETS]
808
- if invalid:
809
- logging.error(f"Invalid targets specified: {', '.join(invalid)}")
810
- raise typer.Exit(code=1)
811
-
812
- images_to_purge: list[str] = []
813
- registry = os.getenv("FLOWMESH_REGISTRY", "ghcr.io/mlsys-io")
814
- for target in targets:
815
- image_ref = get_image_ref(registry=registry, version=version, target=target)
816
- result = inspect_image(image_ref, capture_output=True) # Check if image exists
817
- if result.returncode == 0:
818
- images_to_purge.append(image_ref)
819
- else:
820
- logging.warning(f"Image not found: {image_ref}")
821
-
822
- if not images_to_purge:
823
- logging.info("No images to purge.")
824
- return
825
-
826
- if dry_run:
827
- logging.info("Images to be purged:")
828
- for image in images_to_purge:
829
- logging.log(f" {image}")
830
- return
831
-
832
- error = False
833
- for image in images_to_purge:
834
- result = remove_image(image, capture_output=True)
835
- if result.returncode == 0:
836
- logging.success(f"Removed image: {image}")
837
- else:
838
- logging.error(f"Failed to remove image: {image}")
839
- if result.stdout:
840
- logging.log(result.stdout)
841
- if result.stderr:
842
- logging.log(result.stderr, err=True)
843
- error = True
844
- if error:
845
- raise typer.Exit(code=1)
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowmesh-cli-stack
3
- Version: 0.1.5rc1
3
+ Version: 0.1.6
4
4
  Summary: FlowMesh CLI stack commands
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-cli==0.1.5rc1
10
- Requires-Dist: flowmesh-sdk-stack==0.1.5rc1
9
+ Requires-Dist: flowmesh-cli==0.1.6
10
+ Requires-Dist: flowmesh-sdk-stack==0.1.6
11
11
  Requires-Dist: docker>=7.1.0
12
12
  Requires-Dist: packaging>=24
13
13
  Requires-Dist: pyyaml>=6.0.2
@@ -4,6 +4,7 @@ pyproject.toml
4
4
  src/flowmesh_cli_stack/__init__.py
5
5
  src/flowmesh_cli_stack/bundle.py
6
6
  src/flowmesh_cli_stack/env_schema.py
7
+ src/flowmesh_cli_stack/image.py
7
8
  src/flowmesh_cli_stack/stack.py
8
9
  src/flowmesh_cli_stack/utils.py
9
10
  src/flowmesh_cli_stack/worker.py
@@ -0,0 +1,5 @@
1
+ flowmesh-cli==0.1.6
2
+ flowmesh-sdk-stack==0.1.6
3
+ docker>=7.1.0
4
+ packaging>=24
5
+ pyyaml>=6.0.2
@@ -1,5 +0,0 @@
1
- flowmesh-cli==0.1.5rc1
2
- flowmesh-sdk-stack==0.1.5rc1
3
- docker>=7.1.0
4
- packaging>=24
5
- pyyaml>=6.0.2