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.
- lumilake_deploy/__init__.py +11 -0
- lumilake_deploy/_demo_data.py +139 -0
- lumilake_deploy/assets/.env.example +82 -0
- lumilake_deploy/assets/__init__.py +35 -0
- lumilake_deploy/assets/compose.yml +40 -0
- lumilake_deploy/containers.py +48 -0
- lumilake_deploy/docker_client.py +175 -0
- lumilake_deploy/doctor.py +218 -0
- lumilake_deploy/env.py +63 -0
- lumilake_deploy/errors.py +5 -0
- lumilake_deploy/flowmesh.py +344 -0
- lumilake_deploy/setup.py +267 -0
- lumilake_deploy/shell.py +109 -0
- lumilake_deploy/stop.py +105 -0
- lumilake_deploy/update_flowmesh.py +32 -0
- lumilake_deploy-0.1.0.dev1.dist-info/METADATA +27 -0
- lumilake_deploy-0.1.0.dev1.dist-info/RECORD +20 -0
- lumilake_deploy-0.1.0.dev1.dist-info/WHEEL +5 -0
- lumilake_deploy-0.1.0.dev1.dist-info/licenses/LICENSE +201 -0
- lumilake_deploy-0.1.0.dev1.dist-info/top_level.txt +1 -0
|
@@ -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,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()]
|