lumilake-deploy 0.1.0.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,218 @@
1
+ """Validate ``.env`` (and optionally ``.env.flowmesh``) before ``deploy up``.
2
+
3
+ Mirrors :func:`flowmesh_stack.doctor.run_doctor_checks` in spirit: emits a
4
+ structured report of errors / warnings / notes the operator can read at a
5
+ glance. The primary purpose is to catch a malformed ``.env`` (missing
6
+ required keys, obviously-bad values) before ``deploy up`` brings the
7
+ server up against broken environment.
8
+ """
9
+
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any, Literal
14
+ from urllib.parse import urlparse
15
+
16
+ type FindingLevel = Literal["note", "warning", "error"]
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class DoctorFinding:
21
+ level: FindingLevel
22
+ message: str
23
+
24
+
25
+ @dataclass
26
+ class DoctorReport:
27
+ findings: list[DoctorFinding] = field(default_factory=list)
28
+ callback: Callable[[DoctorFinding], Any] | None = None
29
+
30
+ @property
31
+ def errors(self) -> list[str]:
32
+ return [f.message for f in self.findings if f.level == "error"]
33
+
34
+ @property
35
+ def warnings(self) -> list[str]:
36
+ return [f.message for f in self.findings if f.level == "warning"]
37
+
38
+ @property
39
+ def notes(self) -> list[str]:
40
+ return [f.message for f in self.findings if f.level == "note"]
41
+
42
+ def error(self, message: str) -> None:
43
+ self._add("error", message)
44
+
45
+ def warning(self, message: str) -> None:
46
+ self._add("warning", message)
47
+
48
+ def note(self, message: str) -> None:
49
+ self._add("note", message)
50
+
51
+ def _add(self, level: FindingLevel, message: str) -> None:
52
+ finding = DoctorFinding(level, message)
53
+ self.findings.append(finding)
54
+ if self.callback:
55
+ self.callback(finding)
56
+
57
+
58
+ # Keys the server always needs.
59
+ _ALWAYS_REQUIRED: tuple[str, ...] = (
60
+ "LUMILAKE_SERVER_HOST",
61
+ "LUMILAKE_SERVER_PORT",
62
+ "LUMILAKE_RUNTIME_ORCHESTRATOR_URL",
63
+ "S3_ARCHIVE_PREFIX",
64
+ "LUMILAKE_IMAGE_TAG",
65
+ )
66
+
67
+ # Keys required for direct compute mode (when ``LUMID_DATA_URL`` is empty).
68
+ # When LUMID_DATA_URL is set, lumid.data owns these credentials and the
69
+ # server skips direct connections — the keys become optional.
70
+ _DIRECT_MODE_REQUIRED: tuple[str, ...] = (
71
+ "DATABASE_URL",
72
+ "S3_URL",
73
+ "S3_USER_DATA_PREFIX",
74
+ )
75
+
76
+ _OPTIONAL_KEYS: tuple[str, ...] = (
77
+ "LUMILAKE_LOG_LEVEL",
78
+ "LUMILAKE_REGISTRY",
79
+ "LUMILAKE_OPTIMIZER_TYPE",
80
+ "LUMILAKE_OPTIMIZER_BATCH_SIZE",
81
+ "LUMILAKE_STARVATION_LIMIT",
82
+ "LUMILAKE_BATCH_ACCUMULATION_SECONDS",
83
+ "LUMILAKE_OPTIMIZER_SUBPROCESS_TIMEOUT_SECONDS",
84
+ "LUMILAKE_CPU_WORKER_GROUP_SIZE",
85
+ "LUMILAKE_GPU_WORKER_GROUP_SIZE",
86
+ "LUMILAKE_FLOWMESH_OUTPUT_DESTINATION",
87
+ "LUMILAKE_RUNTIME_TOKEN",
88
+ "LUMILAKE_JOB_MANAGER_TYPE",
89
+ "LUMILAKE_RUNTIME_MANAGER_TYPE",
90
+ "LUMILAKE_POLL_TIMEOUT_SECONDS",
91
+ "LUMILAKE_POLL_INTERVAL_SECONDS",
92
+ "LUMILAKE_HTTP_TIMEOUT_SECONDS",
93
+ "LUMILAKE_QUEUE_QUANTUM_MEDIUM",
94
+ "LUMILAKE_DATA_PROFILE_NUM_TEST_QUERIES",
95
+ "LUMILAKE_S3_PROFILE_COST_PER_FILE",
96
+ "LUMILAKE_S3_PROFILE_COST_PER_MIB",
97
+ "LUMILAKE_SKIP_DOTENV_CHECK",
98
+ "S3_CERT_FILE",
99
+ "LUMID_DATA_URL",
100
+ "LUMID_DATA_TOKEN",
101
+ "LUMID_DATA_TIMEOUT_SECONDS",
102
+ "HARDWARE_CPU_REQUIREMENT",
103
+ "HARDWARE_MEMORY_REQUIREMENT",
104
+ "HARDWARE_GPU_REQUIREMENT",
105
+ "HARDWARE_GPU_MEMORY_REQUIREMENT",
106
+ "LUMILAKE_GPU_DEVICES",
107
+ )
108
+
109
+ _KNOWN_KEYS: frozenset[str] = frozenset(
110
+ _ALWAYS_REQUIRED + _DIRECT_MODE_REQUIRED + _OPTIONAL_KEYS
111
+ )
112
+
113
+
114
+ def _parse_env_file(path: Path) -> dict[str, str]:
115
+ """Tolerant ``.env`` parser — accepts ``KEY=value`` or ``KEY="value"``."""
116
+ values: dict[str, str] = {}
117
+ for raw in path.read_text().splitlines():
118
+ line = raw.strip()
119
+ if not line or line.startswith("#") or "=" not in line:
120
+ continue
121
+ key, _, value = line.partition("=")
122
+ key = key.strip()
123
+ value = value.strip()
124
+ if value.startswith('"') and value.endswith('"'):
125
+ value = value[1:-1]
126
+ values[key] = value
127
+ return values
128
+
129
+
130
+ def _check_required(values: dict[str, str], report: DoctorReport) -> None:
131
+ for key in _ALWAYS_REQUIRED:
132
+ if not values.get(key):
133
+ report.error(f"{key} is required but missing or empty")
134
+ if not values.get("LUMID_DATA_URL"):
135
+ for key in _DIRECT_MODE_REQUIRED:
136
+ if not values.get(key):
137
+ report.error(
138
+ f"{key} is required in direct mode "
139
+ "(set ``LUMID_DATA_URL`` to route through lumid.data instead)"
140
+ )
141
+
142
+
143
+ def _check_unknown(values: dict[str, str], report: DoctorReport) -> None:
144
+ for key in values:
145
+ if key not in _KNOWN_KEYS:
146
+ report.warning(f"{key} is unknown — typo, or stale schema?")
147
+
148
+
149
+ def _check_data_plane(values: dict[str, str], report: DoctorReport) -> None:
150
+ """When LUMID_DATA_URL is set, flag the rest of the lumid block to
151
+ encourage consistent configuration.
152
+ """
153
+ if values.get("LUMID_DATA_URL") and not values.get("LUMID_DATA_TIMEOUT_SECONDS"):
154
+ report.note("LUMID_DATA_TIMEOUT_SECONDS unset; defaulting to 30s.")
155
+
156
+
157
+ def _check_s3_url(values: dict[str, str], report: DoctorReport) -> None:
158
+ if values.get("LUMID_DATA_URL"):
159
+ return
160
+ raw_url = values.get("S3_URL", "")
161
+ if not raw_url:
162
+ return
163
+ parsed = urlparse(raw_url)
164
+ if parsed.scheme != "s3":
165
+ report.error("S3_URL must use the s3:// scheme")
166
+ if not parsed.hostname:
167
+ report.error("S3_URL must include an endpoint host")
168
+ if not parsed.username or not parsed.password:
169
+ report.error("S3_URL must include access key and secret")
170
+ if not parsed.path.lstrip("/"):
171
+ report.error("S3_URL must include a bucket or bucket/prefix path")
172
+
173
+
174
+ def run_env_checks(
175
+ env_path: Path,
176
+ callback: Callable[[DoctorFinding], Any] | None = None,
177
+ ) -> DoctorReport:
178
+ """Validate a single lumilake ``.env`` file."""
179
+ report = DoctorReport(callback=callback)
180
+ if not env_path.is_file():
181
+ report.error(
182
+ f"{env_path} not found. Run ``lumilake deploy init`` to "
183
+ "create one from ``.env.example``."
184
+ )
185
+ return report
186
+ values = _parse_env_file(env_path)
187
+ _check_required(values, report)
188
+ _check_unknown(values, report)
189
+ _check_data_plane(values, report)
190
+ _check_s3_url(values, report)
191
+ if not report.errors:
192
+ report.note(f"{env_path}: schema looks correct")
193
+ return report
194
+
195
+
196
+ def run_doctor(
197
+ env_path: Path,
198
+ *,
199
+ flowmesh_env_path: Path | None = None,
200
+ callback: Callable[[DoctorFinding], Any] | None = None,
201
+ ) -> DoctorReport:
202
+ """Top-level entry point. Validates ``.env`` and, when supplied,
203
+ notes the presence of ``.env.flowmesh`` (full validation belongs to
204
+ ``flowmesh stack doctor``).
205
+ """
206
+ report = run_env_checks(env_path, callback=callback)
207
+ if flowmesh_env_path is not None:
208
+ if flowmesh_env_path.is_file():
209
+ report.note(
210
+ f"{flowmesh_env_path}: present "
211
+ "(run ``flowmesh stack doctor`` for FlowMesh-side validation)"
212
+ )
213
+ else:
214
+ report.error(
215
+ f"{flowmesh_env_path} not found. Run "
216
+ "``lumilake deploy init --flowmesh`` to create one."
217
+ )
218
+ return report
lumilake_deploy/env.py ADDED
@@ -0,0 +1,63 @@
1
+ """``.env``-file helpers for the deploy CLI."""
2
+
3
+ from pathlib import Path
4
+
5
+ ENV_FILE_NAME = ".env"
6
+ ENV_TEMPLATE_NAME = ".env.example"
7
+ FLOWMESH_ENV_FILE_NAME = ".env.flowmesh"
8
+
9
+
10
+ def _quote(value: str) -> str:
11
+ """Shell-quote a value for the env file (simple double-quote wrapping)."""
12
+ escaped = value.replace('"', '\\"')
13
+ return f'"{escaped}"'
14
+
15
+
16
+ def _line(key: str, value: str) -> str:
17
+ return f"{key}={_quote(value)}\n"
18
+
19
+
20
+ def read_env_value(path: Path, key: str) -> str:
21
+ """Read a single env var value from a ``.env``-style file.
22
+
23
+ Returns an empty string if the key is not found. Strips surrounding
24
+ double-quotes on the value.
25
+ """
26
+ if not path.is_file():
27
+ return ""
28
+ prefix = f"{key}="
29
+ for raw in path.read_text().splitlines():
30
+ line = raw.strip()
31
+ if not line or line.startswith("#") or not line.startswith(prefix):
32
+ continue
33
+ value = line[len(prefix) :]
34
+ if value.startswith('"') and value.endswith('"'):
35
+ value = value[1:-1]
36
+ return value
37
+ return ""
38
+
39
+
40
+ def patch_env_value(path: Path, key: str, value: str) -> None:
41
+ """Replace ``key=...`` in ``path`` if present, append otherwise.
42
+
43
+ Used by ``deploy init`` only — never by lifecycle commands.
44
+ Idempotent: writing the same value is a no-op.
45
+ """
46
+ quoted_line = _line(key, value)
47
+ if not path.is_file():
48
+ path.write_text(quoted_line)
49
+ return
50
+ prefix = f"{key}="
51
+ out: list[str] = []
52
+ replaced = False
53
+ for raw in path.read_text().splitlines(keepends=True):
54
+ if raw.lstrip().startswith(prefix):
55
+ out.append(quoted_line)
56
+ replaced = True
57
+ else:
58
+ out.append(raw)
59
+ if not replaced:
60
+ if out and not out[-1].endswith("\n"):
61
+ out[-1] = out[-1] + "\n"
62
+ out.append(quoted_line)
63
+ path.write_text("".join(out))
@@ -0,0 +1,5 @@
1
+ """Shared exception types for the deploy orchestration."""
2
+
3
+
4
+ class DeployError(RuntimeError):
5
+ """Raised when the deploy orchestration can't proceed."""
@@ -0,0 +1,344 @@
1
+ """FlowMesh stack lifecycle management for lumilake deploy."""
2
+
3
+ import subprocess
4
+ import time
5
+ from pathlib import Path
6
+
7
+ from docker import DockerClient
8
+ from docker.errors import ImageNotFound
9
+ from flowmesh import FlowMesh
10
+ from flowmesh.exceptions import FlowMeshConnectionError
11
+ from flowmesh_cli_stack.utils import (
12
+ STACK_PATH_KEYS,
13
+ stack_compose_file,
14
+ stack_env_example,
15
+ stack_resource_env_overrides,
16
+ )
17
+ from flowmesh_stack.docker import DockerComposeStack
18
+ from flowmesh_stack.env import ensure_env_file, load_env, parse_env_file
19
+ from flowmesh_stack.node_client import NodeClient
20
+ from flowmesh_stack.paths import ensure_dir, ensure_file, resolve_path
21
+ from flowmesh_stack.workers import create_workers as sdk_create_workers
22
+ from lumilake import envs
23
+ from lumilake.log import init_child_logger
24
+
25
+ logger = init_child_logger("deploy.flowmesh")
26
+
27
+
28
+ def _info(msg: str) -> None:
29
+ logger.info(msg)
30
+
31
+
32
+ def _error(msg: str) -> None:
33
+ logger.error(msg)
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Port collision check
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ def check_ports(ports: dict[str, int]) -> list[str]:
42
+ """Return error messages for ports already in use."""
43
+ errors: list[str] = []
44
+ for label, port in ports.items():
45
+ result = subprocess.run(
46
+ ["ss", "-tln", f"sport = :{port}"],
47
+ capture_output=True,
48
+ text=True,
49
+ )
50
+ if result.stdout.strip().count("\n") > 0:
51
+ errors.append(f"Port {port} ({label}) is already in use.")
52
+ return errors
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Stack management via SDK
57
+ # ---------------------------------------------------------------------------
58
+
59
+ _WORKDIR = Path(".flowmesh")
60
+
61
+
62
+ def _make_workdir() -> Path:
63
+ """Create a working directory for FlowMesh compose mounts."""
64
+ _WORKDIR.mkdir(exist_ok=True)
65
+ (_WORKDIR / "secrets" / "tls" / "redis").mkdir(parents=True, exist_ok=True)
66
+ (_WORKDIR / "secrets" / "tls" / "server").mkdir(parents=True, exist_ok=True)
67
+ wc = _WORKDIR / "configs" / "worker_config.yaml"
68
+ wc.parent.mkdir(parents=True, exist_ok=True)
69
+ if not wc.exists():
70
+ wc.write_text("default_worker_config:\n hb_interval: 30\n\nworkers: []\n")
71
+ return _WORKDIR
72
+
73
+
74
+ def _ensure_deploy_paths_in_workdir(_base_dir: Path) -> None:
75
+ """ensure_deploy_paths pinned to _WORKDIR (ignores the cwd-based base_dir)."""
76
+ base = _WORKDIR.resolve()
77
+ ensure_dir(
78
+ resolve_path(envs.REDIS_TLS_DIR, default="./secrets/tls/redis", base_dir=base)
79
+ )
80
+ ensure_dir(
81
+ resolve_path(
82
+ envs.SERVER_TLS_DIR,
83
+ default="./secrets/tls/server",
84
+ base_dir=base,
85
+ )
86
+ )
87
+ ensure_file(
88
+ resolve_path(
89
+ envs.SERVER_WORKER_CONFIG,
90
+ default="./configs/worker_config.yaml",
91
+ base_dir=base,
92
+ )
93
+ )
94
+
95
+
96
+ def _load_env(ef: Path) -> None:
97
+ ensure_env_file(ef, stack_env_example())
98
+ load_env(ef, base_dir=_WORKDIR.resolve(), path_keys=STACK_PATH_KEYS)
99
+
100
+
101
+ _stack = DockerComposeStack(
102
+ compose_file=stack_compose_file(),
103
+ env_file_var="STACK_ENV_FILE",
104
+ load_env=_load_env,
105
+ ensure_deploy_paths=_ensure_deploy_paths_in_workdir,
106
+ )
107
+
108
+
109
+ def env_ports(env_file: str | Path) -> dict[str, int]:
110
+ """Extract configured ports from env file for collision checking."""
111
+ env = parse_env_file(Path(env_file))
112
+ return {
113
+ "FlowMesh HTTP": int(env["SERVER_HTTP_PORT"]),
114
+ "FlowMesh gRPC": int(env["SERVER_GRPC_PORT"]),
115
+ "FlowMesh Redis control": int(env["REDIS_CONTROL_PORT"]),
116
+ "FlowMesh Redis telemetry": int(env["REDIS_TELEMETRY_PORT"]),
117
+ }
118
+
119
+
120
+ def _server_image_exists(env_file: str | Path) -> bool:
121
+ """Check if the FlowMesh server image is already available locally."""
122
+ env = parse_env_file(Path(env_file))
123
+ registry = env["FLOWMESH_REGISTRY"]
124
+ version = env["FLOWMESH_VERSION"]
125
+ tag = f"{registry}/flowmesh_server:{version}"
126
+ try:
127
+ client = DockerClient.from_env()
128
+ client.images.get(tag)
129
+ return True
130
+ except ImageNotFound:
131
+ return False
132
+
133
+
134
+ def _slug_env(env_file: Path) -> dict[str, str]:
135
+ """Resolve the FlowMesh stack slug into compose-bound env vars."""
136
+ slug = stack_slug(env_file)
137
+ return {
138
+ "FLOWMESH_STACK_SLUG": slug,
139
+ "COMPOSE_PROJECT_NAME": slug,
140
+ "WORKER_RESULTS_DIR": f"{slug}_results",
141
+ }
142
+
143
+
144
+ def stack_slug(env_file: Path) -> str:
145
+ """Return the FlowMesh stack slug derived from ``env_file``."""
146
+ env = parse_env_file(env_file)
147
+ return stack_resource_env_overrides(env)["FLOWMESH_STACK_SLUG"]
148
+
149
+
150
+ def stack_pull(env_file: str | Path) -> None:
151
+ """Pull FlowMesh Docker images (skipped if already present locally)."""
152
+ _make_workdir()
153
+ env_path = Path(env_file).resolve()
154
+ if _server_image_exists(env_file):
155
+ _info("FlowMesh images already present locally, skipping pull.")
156
+ return
157
+ _info("Pulling FlowMesh images...")
158
+ _stack.run(["pull"], env_file=env_path, env=_slug_env(env_path))
159
+
160
+
161
+ def stack_up(env_file: str | Path) -> None:
162
+ """Start the FlowMesh Docker Compose stack."""
163
+ _make_workdir()
164
+ env_path = Path(env_file).resolve()
165
+
166
+ errors = check_ports(env_ports(env_file))
167
+ if errors:
168
+ for e in errors:
169
+ _error(e)
170
+ raise RuntimeError("Fix port conflicts in .env.flowmesh and retry.")
171
+
172
+ # ``--profile root`` is required so compose activates redis_control +
173
+ # redis_telemetry; the bundled FlowMesh compose puts them under
174
+ # ``profiles: [root]`` and without this flag they get silently skipped.
175
+ args = ["--profile", "root", "up", "-d", "--wait"]
176
+
177
+ _info("Starting FlowMesh stack...")
178
+ result = _stack.run(
179
+ args, env_file=env_path, env=_slug_env(env_path), to_deploy=True
180
+ )
181
+ if result.returncode != 0:
182
+ raise RuntimeError("Failed to start FlowMesh stack.")
183
+ _info("FlowMesh stack is up.")
184
+
185
+
186
+ def destroy_all_workers(env_file: str | Path) -> None:
187
+ """Destroy every FlowMesh-managed worker via the SDK."""
188
+ env = parse_env_file(Path(env_file))
189
+ base_url = f"http://localhost:{env['SERVER_HTTP_PORT']}"
190
+ # Bundled .env.flowmesh template ships no SERVER_TOKEN; default to
191
+ # empty (the unauthed local stack accepts an empty token).
192
+ token = env.get("SERVER_TOKEN", "")
193
+ _info("Destroying FlowMesh workers...")
194
+ with NodeClient(base_url=base_url, token=token) as client:
195
+ client.destroy_all_workers(ignore_unreachable=True)
196
+
197
+
198
+ def stack_down(env_file: str | Path) -> None:
199
+ """Stop the FlowMesh stack."""
200
+ if not _WORKDIR.exists():
201
+ return
202
+ env_path = Path(env_file).resolve()
203
+ destroy_all_workers(env_path)
204
+ _info("Stopping FlowMesh stack...")
205
+ # ``--profile root`` so compose sees redis_control + redis_telemetry
206
+ # and tears them down too; without it they orphan and block re-up.
207
+ _stack.run(
208
+ ["--profile", "root", "down"], env_file=env_path, env=_slug_env(env_path)
209
+ )
210
+
211
+
212
+ def stack_clean(env_file: str | Path) -> None:
213
+ """Stop the FlowMesh stack and remove volumes."""
214
+ if not _WORKDIR.exists():
215
+ return
216
+ env_path = Path(env_file).resolve()
217
+ _info("Cleaning FlowMesh stack (removing volumes)...")
218
+ _stack.run(
219
+ ["--profile", "root", "down", "-v"],
220
+ env_file=env_path,
221
+ env=_slug_env(env_path),
222
+ )
223
+
224
+
225
+ # Aliases used by ``stop.py`` — keep the older verb-first names readable.
226
+ def stop_stack(*, env_file: str | Path) -> None:
227
+ stack_down(env_file)
228
+
229
+
230
+ def clean_stack(*, env_file: str | Path) -> None:
231
+ stack_clean(env_file)
232
+
233
+
234
+ # ---------------------------------------------------------------------------
235
+ # Health check via SDK
236
+ # ---------------------------------------------------------------------------
237
+
238
+
239
+ def wait_healthy(env_file: str | Path, timeout: int = 120) -> bool:
240
+ """Wait for FlowMesh server health using the FlowMesh SDK."""
241
+ env = parse_env_file(Path(env_file))
242
+ base_url = f"http://localhost:{env['SERVER_HTTP_PORT']}"
243
+ _info(f"Waiting for FlowMesh server at {base_url}...")
244
+ deadline = time.monotonic() + timeout
245
+ while time.monotonic() < deadline:
246
+ try:
247
+ with FlowMesh(base_url=base_url) as client:
248
+ resp = client.system.health()
249
+ if resp.ok:
250
+ _info("FlowMesh server is healthy.")
251
+ return True
252
+ except (FlowMeshConnectionError, Exception):
253
+ pass
254
+ time.sleep(2)
255
+ _error(f"FlowMesh server not healthy at {base_url} after {timeout}s")
256
+ return False
257
+
258
+
259
+ # ---------------------------------------------------------------------------
260
+ # Worker creation via SDK
261
+ # ---------------------------------------------------------------------------
262
+
263
+
264
+ def _pull_worker_image(env_file: str | Path, kind: str) -> None:
265
+ """Pre-pull the worker image for a given kind ("cpu" or "gpu") on the host.
266
+
267
+ FlowMesh's server spawns worker containers via the Docker Engine API
268
+ (mounted socket). Pulling via the CLI first caches the image locally
269
+ and lets FlowMesh's subsequent image reference resolve without a pull.
270
+ """
271
+ env = parse_env_file(Path(env_file))
272
+ registry = env["FLOWMESH_REGISTRY"]
273
+ version = env["FLOWMESH_VERSION"]
274
+ image = f"{registry}/flowmesh_worker:{version}-{kind}"
275
+ try:
276
+ DockerClient.from_env().images.get(image)
277
+ _info(f"Worker image {image} already present locally; skipping pre-pull.")
278
+ return
279
+ except ImageNotFound:
280
+ pass
281
+ _info(f"Pre-pulling worker image {image}...")
282
+ result = subprocess.run(
283
+ ["docker", "pull", image], check=False, capture_output=True, text=True
284
+ )
285
+ if result.returncode != 0:
286
+ raise RuntimeError(
287
+ f"Failed to pull {image}. "
288
+ f"Ensure `docker login {registry.split('/')[0]}` has been run "
289
+ f"with a token that can read this image.\n{result.stderr}"
290
+ )
291
+
292
+
293
+ def create_workers(env_file: str | Path, cpu_count: int, gpu_devices: str) -> None:
294
+ """Create FlowMesh workers via SDK."""
295
+ env = parse_env_file(Path(env_file))
296
+ base_url = f"http://localhost:{env['SERVER_HTTP_PORT']}"
297
+ token = env.get("SERVER_TOKEN", "")
298
+
299
+ with NodeClient(base_url=base_url, token=token) as client:
300
+ if cpu_count > 0:
301
+ _pull_worker_image(env_file, "cpu")
302
+ _info(f"Creating {cpu_count} CPU worker(s)...")
303
+ created = sdk_create_workers(client, kind="cpu", count=cpu_count)
304
+ for label, resp in created:
305
+ _info(f"Created {label}: {resp['name']}")
306
+
307
+ device_ids = _resolve_gpu_device_ids(gpu_devices)
308
+ if not device_ids:
309
+ _info(
310
+ "No GPUs detected; skipping GPU worker creation. "
311
+ "Set LUMILAKE_GPU_DEVICES to a comma-separated list "
312
+ "of indices to force-create."
313
+ )
314
+ else:
315
+ _pull_worker_image(env_file, "gpu")
316
+ targets = ",".join(device_ids)
317
+ _info(f"Creating {len(device_ids)} GPU worker(s): {targets}...")
318
+ created = sdk_create_workers(
319
+ client,
320
+ kind="gpu",
321
+ count=len(device_ids),
322
+ targets=targets,
323
+ )
324
+ for label, resp in created:
325
+ _info(f"Created {label}: {resp['name']}")
326
+
327
+
328
+ def _resolve_gpu_device_ids(gpu_devices: str) -> list[str]:
329
+ """Resolve a LUMILAKE_GPU_DEVICES-style string into explicit device ids."""
330
+ normalized = gpu_devices.strip()
331
+ if normalized and normalized.lower() != "all":
332
+ return [tok.strip() for tok in normalized.split(",") if tok.strip()]
333
+ try:
334
+ result = subprocess.run(
335
+ ["nvidia-smi", "--query-gpu=index", "--format=csv,noheader"],
336
+ check=False,
337
+ capture_output=True,
338
+ text=True,
339
+ )
340
+ except FileNotFoundError:
341
+ return []
342
+ if result.returncode != 0:
343
+ return []
344
+ return [line.strip() for line in result.stdout.splitlines() if line.strip()]