lumilake-cli 0.1.0__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_cli/__init__.py +0 -0
- lumilake_cli/cli.py +54 -0
- lumilake_cli/commands/__init__.py +69 -0
- lumilake_cli/commands/base.py +41 -0
- lumilake_cli/commands/deploy.py +562 -0
- lumilake_cli/commands/job.py +661 -0
- lumilake_cli/commands/trace.py +143 -0
- lumilake_cli/commands/worker.py +39 -0
- lumilake_cli/core/__init__.py +0 -0
- lumilake_cli/core/config.py +39 -0
- lumilake_cli/core/http.py +132 -0
- lumilake_cli/core/logging.py +34 -0
- lumilake_cli/core/query.py +16 -0
- lumilake_cli/core/typer.py +12 -0
- lumilake_cli-0.1.0.dist-info/METADATA +28 -0
- lumilake_cli-0.1.0.dist-info/RECORD +20 -0
- lumilake_cli-0.1.0.dist-info/WHEEL +5 -0
- lumilake_cli-0.1.0.dist-info/entry_points.txt +2 -0
- lumilake_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- lumilake_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
"""Deploy commands: init, doctor, up, down, clean, restart, reset, logs."""
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
import difflib
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from flowmesh_cli_stack.stack import stack_env_example
|
|
11
|
+
from lumilake import envs
|
|
12
|
+
from lumilake_deploy import docker_client
|
|
13
|
+
from lumilake_deploy import setup as setup_mod
|
|
14
|
+
from lumilake_deploy import stop as stop_mod
|
|
15
|
+
from lumilake_deploy import update_flowmesh as update_fm_mod
|
|
16
|
+
from lumilake_deploy.assets import env_example_path
|
|
17
|
+
from lumilake_deploy.containers import SERVICE_NAMES, container_names
|
|
18
|
+
from lumilake_deploy.doctor import DoctorFinding, run_doctor
|
|
19
|
+
from lumilake_deploy.env import (
|
|
20
|
+
ENV_FILE_NAME,
|
|
21
|
+
FLOWMESH_ENV_FILE_NAME,
|
|
22
|
+
patch_env_value,
|
|
23
|
+
)
|
|
24
|
+
from lumilake_deploy.errors import DeployError
|
|
25
|
+
from lumilake_deploy.setup import (
|
|
26
|
+
SetupOptions,
|
|
27
|
+
build_server_image,
|
|
28
|
+
pull_server_image,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from ..core import logging
|
|
32
|
+
from ..core.config import DEFAULT_CONFIG_PATH, LumilakeConfig, load_config, save_config
|
|
33
|
+
from ..core.typer import get_typer
|
|
34
|
+
|
|
35
|
+
app = get_typer(
|
|
36
|
+
help=(
|
|
37
|
+
"Deploy and manage the Lumilake stack. Reads .env (and optionally "
|
|
38
|
+
".env.flowmesh) from --project-dir (or the current working "
|
|
39
|
+
"directory). The packaged compose file and server image are "
|
|
40
|
+
"resolved from the installed lumilake-deploy package."
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.callback()
|
|
46
|
+
def _deploy_callback(
|
|
47
|
+
ctx: typer.Context,
|
|
48
|
+
project_dir: Path = typer.Option(
|
|
49
|
+
None,
|
|
50
|
+
"--project-dir",
|
|
51
|
+
"-C",
|
|
52
|
+
envvar="LUMILAKE_DEPLOY_DIR",
|
|
53
|
+
help=(
|
|
54
|
+
"Directory holding .env / .env.flowmesh and where compose "
|
|
55
|
+
"stores runtime state. Defaults to the current working directory."
|
|
56
|
+
),
|
|
57
|
+
),
|
|
58
|
+
) -> None:
|
|
59
|
+
ctx.obj = project_dir.resolve() if project_dir else Path.cwd()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _project_dir(ctx: typer.Context) -> Path:
|
|
63
|
+
if isinstance(ctx.obj, Path):
|
|
64
|
+
return ctx.obj
|
|
65
|
+
return Path.cwd()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _run_setup(
|
|
69
|
+
root: Path,
|
|
70
|
+
*,
|
|
71
|
+
background: bool = True,
|
|
72
|
+
reset: bool = False,
|
|
73
|
+
no_server: bool = False,
|
|
74
|
+
) -> None:
|
|
75
|
+
try:
|
|
76
|
+
setup_mod.run_setup(
|
|
77
|
+
root,
|
|
78
|
+
SetupOptions(
|
|
79
|
+
reset=reset,
|
|
80
|
+
no_server=no_server,
|
|
81
|
+
background=background,
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
except DeployError as exc:
|
|
85
|
+
logging.error(str(exc))
|
|
86
|
+
raise typer.Exit(code=1) from exc
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _preview_write(target: Path, new_content: str) -> None:
|
|
90
|
+
if target.exists():
|
|
91
|
+
old = target.read_text().splitlines(keepends=True)
|
|
92
|
+
new = new_content.splitlines(keepends=True)
|
|
93
|
+
diff = "".join(
|
|
94
|
+
difflib.unified_diff(
|
|
95
|
+
old,
|
|
96
|
+
new,
|
|
97
|
+
fromfile=f"a/{target.name}",
|
|
98
|
+
tofile=f"b/{target.name}",
|
|
99
|
+
n=3,
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
if diff:
|
|
103
|
+
logging.info(f"Diff vs existing {target}:")
|
|
104
|
+
for line in diff.splitlines():
|
|
105
|
+
logging.info(line)
|
|
106
|
+
else:
|
|
107
|
+
logging.info(f"{target} already matches the bundled template.")
|
|
108
|
+
return
|
|
109
|
+
logging.info(f"Preview of {target} (first 20 lines):")
|
|
110
|
+
for line in new_content.splitlines()[:20]:
|
|
111
|
+
logging.info(line)
|
|
112
|
+
remaining = max(0, len(new_content.splitlines()) - 20)
|
|
113
|
+
if remaining:
|
|
114
|
+
logging.info(f"... and {remaining} more line(s).")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _confirm_overwrite(target: Path, *, force: bool, new_content: str) -> bool:
|
|
118
|
+
"""Return True when the target may be written, False to skip.
|
|
119
|
+
|
|
120
|
+
Shows a preview (or diff vs an existing file) before prompting on
|
|
121
|
+
overwrite. Brand-new writes pass through without prompting.
|
|
122
|
+
"""
|
|
123
|
+
if force:
|
|
124
|
+
return True
|
|
125
|
+
_preview_write(target, new_content)
|
|
126
|
+
if not target.exists():
|
|
127
|
+
return True
|
|
128
|
+
if typer.confirm(f"{target} already exists. Overwrite?", default=False):
|
|
129
|
+
return True
|
|
130
|
+
logging.info(f"Keeping existing {target}.")
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _flowmesh_env_value(env_path: Path, key: str) -> str:
|
|
135
|
+
"""Read a single line from ``.env.flowmesh`` (no quoting strip pattern)."""
|
|
136
|
+
if not env_path.is_file():
|
|
137
|
+
return ""
|
|
138
|
+
prefix = f"{key}="
|
|
139
|
+
for raw in env_path.read_text().splitlines():
|
|
140
|
+
line = raw.strip()
|
|
141
|
+
if not line.startswith(prefix):
|
|
142
|
+
continue
|
|
143
|
+
value = line[len(prefix) :]
|
|
144
|
+
if value.startswith('"') and value.endswith('"'):
|
|
145
|
+
value = value[1:-1]
|
|
146
|
+
return value
|
|
147
|
+
return ""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
_FLOWMESH_LOCAL_OVERRIDES: tuple[tuple[str, str], ...] = (
|
|
151
|
+
("FLOWMESH_STACK_SUFFIX", "lumilake"),
|
|
152
|
+
("SERVER_GRPC_TLS_CA_FILE", ""),
|
|
153
|
+
("SERVER_GRPC_TLS_CERT_FILE", ""),
|
|
154
|
+
("SERVER_GRPC_TLS_KEY_FILE", ""),
|
|
155
|
+
("REDIS_TLS_CA_FILE", ""),
|
|
156
|
+
("REDIS_TLS_CERT_FILE", ""),
|
|
157
|
+
("REDIS_TLS_KEY_FILE", ""),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _patch_flowmesh_env(env_path: Path) -> None:
|
|
162
|
+
"""Disable TLS bind mounts on the bundled FlowMesh template."""
|
|
163
|
+
for key, value in _FLOWMESH_LOCAL_OVERRIDES:
|
|
164
|
+
patch_env_value(env_path, key, value)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _cross_populate_runtime(env_path: Path, fm_env_path: Path) -> None:
|
|
168
|
+
"""After ``init --flowmesh`` writes both files, point ``.env``'s
|
|
169
|
+
``LUMILAKE_RUNTIME_ORCHESTRATOR_URL`` at the FlowMesh server's port."""
|
|
170
|
+
server_port = _flowmesh_env_value(fm_env_path, "SERVER_HTTP_PORT") or "18000"
|
|
171
|
+
patch_env_value(
|
|
172
|
+
env_path,
|
|
173
|
+
"LUMILAKE_RUNTIME_ORCHESTRATOR_URL",
|
|
174
|
+
f"http://127.0.0.1:{server_port}",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@app.command("init")
|
|
179
|
+
def init(
|
|
180
|
+
ctx: typer.Context,
|
|
181
|
+
flowmesh: bool = typer.Option(
|
|
182
|
+
False,
|
|
183
|
+
"--flowmesh",
|
|
184
|
+
help="Also generate ``.env.flowmesh`` from FlowMesh's stack template.",
|
|
185
|
+
),
|
|
186
|
+
force: bool = typer.Option(
|
|
187
|
+
False,
|
|
188
|
+
"--force",
|
|
189
|
+
"-f",
|
|
190
|
+
help="Overwrite existing env files without prompting.",
|
|
191
|
+
),
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Initialize ``.env`` from the bundled ``.env.example`` template."""
|
|
194
|
+
root = _project_dir(ctx)
|
|
195
|
+
template = env_example_path()
|
|
196
|
+
target = root / ENV_FILE_NAME
|
|
197
|
+
template_text = template.read_text()
|
|
198
|
+
wrote_env = False
|
|
199
|
+
if _confirm_overwrite(target, force=force, new_content=template_text):
|
|
200
|
+
target.write_text(template_text)
|
|
201
|
+
logging.success(f"Wrote {target} from packaged {template.name}.")
|
|
202
|
+
wrote_env = True
|
|
203
|
+
|
|
204
|
+
if flowmesh:
|
|
205
|
+
fm_target = root / FLOWMESH_ENV_FILE_NAME
|
|
206
|
+
example = stack_env_example()
|
|
207
|
+
if not example.exists():
|
|
208
|
+
logging.error(f"FlowMesh env example not found: {example}")
|
|
209
|
+
raise typer.Exit(code=1)
|
|
210
|
+
fm_template_text = example.read_text()
|
|
211
|
+
wrote_flowmesh_env = False
|
|
212
|
+
if _confirm_overwrite(fm_target, force=force, new_content=fm_template_text):
|
|
213
|
+
fm_target.write_text(fm_template_text)
|
|
214
|
+
logging.success(f"Wrote {fm_target} from {example.name}.")
|
|
215
|
+
_patch_flowmesh_env(fm_target)
|
|
216
|
+
wrote_flowmesh_env = True
|
|
217
|
+
if wrote_env:
|
|
218
|
+
_cross_populate_runtime(target, fm_target)
|
|
219
|
+
elif wrote_flowmesh_env:
|
|
220
|
+
logging.info(
|
|
221
|
+
f"Keeping existing {target}; not patching runtime URL from "
|
|
222
|
+
f"{fm_target}."
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@app.command()
|
|
227
|
+
def doctor(
|
|
228
|
+
ctx: typer.Context,
|
|
229
|
+
flowmesh: bool = typer.Option(
|
|
230
|
+
False,
|
|
231
|
+
"--flowmesh",
|
|
232
|
+
help="Also validate that ``.env.flowmesh`` is present.",
|
|
233
|
+
),
|
|
234
|
+
) -> None:
|
|
235
|
+
"""Validate ``.env`` (and optionally ``.env.flowmesh``)."""
|
|
236
|
+
root = _project_dir(ctx)
|
|
237
|
+
|
|
238
|
+
def _emit(finding: DoctorFinding) -> None:
|
|
239
|
+
if finding.level == "error":
|
|
240
|
+
logging.error(finding.message)
|
|
241
|
+
elif finding.level == "warning":
|
|
242
|
+
logging.warning(finding.message)
|
|
243
|
+
else:
|
|
244
|
+
logging.info(finding.message)
|
|
245
|
+
|
|
246
|
+
report = run_doctor(
|
|
247
|
+
root / ENV_FILE_NAME,
|
|
248
|
+
flowmesh_env_path=(root / FLOWMESH_ENV_FILE_NAME) if flowmesh else None,
|
|
249
|
+
callback=_emit,
|
|
250
|
+
)
|
|
251
|
+
if report.errors:
|
|
252
|
+
logging.error(f"Doctor found {len(report.errors)} issue(s).")
|
|
253
|
+
raise typer.Exit(code=1)
|
|
254
|
+
logging.success("Doctor checks passed.")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@app.command()
|
|
258
|
+
def build(ctx: typer.Context) -> None:
|
|
259
|
+
"""Build the lumilake server Docker image from source."""
|
|
260
|
+
root = _project_dir(ctx)
|
|
261
|
+
setup_mod.load_project_env(root)
|
|
262
|
+
image_tag = setup_mod.resolve_image_tag()
|
|
263
|
+
try:
|
|
264
|
+
build_server_image(root, image_tag)
|
|
265
|
+
except DeployError as exc:
|
|
266
|
+
logging.error(str(exc))
|
|
267
|
+
raise typer.Exit(code=1) from exc
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@app.command()
|
|
271
|
+
def pull(ctx: typer.Context) -> None:
|
|
272
|
+
"""Pull the published lumilake server image from the registry."""
|
|
273
|
+
root = _project_dir(ctx)
|
|
274
|
+
setup_mod.load_project_env(root)
|
|
275
|
+
image_tag = setup_mod.resolve_image_tag()
|
|
276
|
+
try:
|
|
277
|
+
pull_server_image(image_tag)
|
|
278
|
+
except DeployError as exc:
|
|
279
|
+
logging.error(str(exc))
|
|
280
|
+
raise typer.Exit(code=1) from exc
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _write_cli_config(root: Path, config_path: Path | None = None) -> None:
|
|
284
|
+
"""Persist the local server URL so subsequent CLI / SDK calls find it.
|
|
285
|
+
|
|
286
|
+
Reads the deployment's ``.env`` to compute the host port the server
|
|
287
|
+
is listening on, then writes ``base_url`` to ``config_path``. If a
|
|
288
|
+
config already exists with a different ``base_url``, we log a hint
|
|
289
|
+
and overwrite. Errors are non-fatal — the stack is already running.
|
|
290
|
+
"""
|
|
291
|
+
if config_path is None:
|
|
292
|
+
config_path = DEFAULT_CONFIG_PATH
|
|
293
|
+
setup_mod.load_project_env(root)
|
|
294
|
+
port = envs.LUMILAKE_SERVER_PORT or 9000
|
|
295
|
+
base_url = f"http://127.0.0.1:{port}"
|
|
296
|
+
try:
|
|
297
|
+
existing = load_config(config_path)
|
|
298
|
+
except (FileNotFoundError, ValueError):
|
|
299
|
+
existing = None
|
|
300
|
+
if existing is not None and existing.base_url == base_url:
|
|
301
|
+
logging.info(f"CLI config already points at {base_url}.")
|
|
302
|
+
return
|
|
303
|
+
if existing is not None and existing.base_url != base_url:
|
|
304
|
+
logging.info(f"Updating {config_path}: {existing.base_url} -> {base_url}")
|
|
305
|
+
try:
|
|
306
|
+
save_config(LumilakeConfig(base_url=base_url), path=config_path)
|
|
307
|
+
except OSError as exc:
|
|
308
|
+
logging.warning(f"Could not save CLI config to {config_path}: {exc}")
|
|
309
|
+
return
|
|
310
|
+
logging.info(f"Saved CLI config to {config_path} (base_url={base_url}).")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@app.command()
|
|
314
|
+
def up(ctx: typer.Context) -> None:
|
|
315
|
+
"""Start the full Lumilake stack (Docker-backed).
|
|
316
|
+
|
|
317
|
+
The server image must already be present locally — run
|
|
318
|
+
``lumilake deploy pull`` or ``lumilake deploy build`` first.
|
|
319
|
+
"""
|
|
320
|
+
root = _project_dir(ctx)
|
|
321
|
+
_run_setup(root, background=True)
|
|
322
|
+
_write_cli_config(root)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@app.command()
|
|
326
|
+
def down(
|
|
327
|
+
ctx: typer.Context,
|
|
328
|
+
wipe_archive: bool = typer.Option(
|
|
329
|
+
False,
|
|
330
|
+
"--wipe-archive",
|
|
331
|
+
help=(
|
|
332
|
+
"Also wipe runtime state that accumulates across "
|
|
333
|
+
"deploy cycles (FlowMesh postgres + redis). "
|
|
334
|
+
"Fixes the 'Duplicate key' / silent-retry-loop failure mode "
|
|
335
|
+
"after a killed prior run. Use this between experiment runs "
|
|
336
|
+
"if you don't want a full "
|
|
337
|
+
"``deploy reset`` + re-stage."
|
|
338
|
+
),
|
|
339
|
+
),
|
|
340
|
+
) -> None:
|
|
341
|
+
"""Stop the stack but keep data volumes.
|
|
342
|
+
|
|
343
|
+
Safe to run between sessions: the archive bucket (job records, run
|
|
344
|
+
artifacts) and the compute postgres/minio volumes survive, so
|
|
345
|
+
``deploy up`` resumes against the same state. ``--wipe-archive`` also
|
|
346
|
+
removes the compute Postgres and FlowMesh runtime-state volumes, while
|
|
347
|
+
preserving MinIO corpus data. Use ``deploy reset`` (destructive) to
|
|
348
|
+
wipe every volume instead.
|
|
349
|
+
"""
|
|
350
|
+
try:
|
|
351
|
+
stop_mod.run_stop(
|
|
352
|
+
_project_dir(ctx),
|
|
353
|
+
purge=False,
|
|
354
|
+
wipe_archive=wipe_archive,
|
|
355
|
+
)
|
|
356
|
+
except DeployError as exc:
|
|
357
|
+
logging.error(str(exc))
|
|
358
|
+
raise typer.Exit(code=1) from exc
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@app.command()
|
|
362
|
+
def clean(ctx: typer.Context) -> None:
|
|
363
|
+
"""Stop all services and delete volumes."""
|
|
364
|
+
try:
|
|
365
|
+
stop_mod.run_stop(_project_dir(ctx), purge=True)
|
|
366
|
+
except DeployError as exc:
|
|
367
|
+
logging.error(str(exc))
|
|
368
|
+
raise typer.Exit(code=1) from exc
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@app.command()
|
|
372
|
+
def status(ctx: typer.Context) -> None:
|
|
373
|
+
"""Show the running state of every Lumilake stack container."""
|
|
374
|
+
rows: list[tuple[str, str, str, str]] = []
|
|
375
|
+
for service, container in container_names(_project_dir(ctx)).items():
|
|
376
|
+
state = docker_client.container_status(container)
|
|
377
|
+
health = (
|
|
378
|
+
docker_client.container_health_status(container)
|
|
379
|
+
if state == "running"
|
|
380
|
+
else ""
|
|
381
|
+
)
|
|
382
|
+
rows.append((service, container, state, health))
|
|
383
|
+
|
|
384
|
+
svc_w = max(len(r[0]) for r in rows)
|
|
385
|
+
cnt_w = max(len(r[1]) for r in rows)
|
|
386
|
+
state_w = max(len(r[2]) for r in rows)
|
|
387
|
+
for service, container, state, health in rows:
|
|
388
|
+
line = f"{service:<{svc_w}} {container:<{cnt_w}} {state:<{state_w}}"
|
|
389
|
+
if health:
|
|
390
|
+
line += f" ({health})"
|
|
391
|
+
logging.info(line)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@app.command()
|
|
395
|
+
def restart(
|
|
396
|
+
ctx: typer.Context,
|
|
397
|
+
service: str | None = typer.Argument(
|
|
398
|
+
None,
|
|
399
|
+
help=(
|
|
400
|
+
"Single service to restart. Choose from: "
|
|
401
|
+
f"{', '.join(SERVICE_NAMES)}. When omitted, restarts the "
|
|
402
|
+
"entire stack (re-runs setup, rebuilds images)."
|
|
403
|
+
),
|
|
404
|
+
),
|
|
405
|
+
) -> None:
|
|
406
|
+
"""Restart deployment services.
|
|
407
|
+
|
|
408
|
+
Without ``service``: stops the entire stack and re-runs setup
|
|
409
|
+
(rebuilds images if code changed). With ``service``: restarts just
|
|
410
|
+
that container in place.
|
|
411
|
+
"""
|
|
412
|
+
root = _project_dir(ctx)
|
|
413
|
+
if service is not None:
|
|
414
|
+
container = container_names(root).get(service)
|
|
415
|
+
if container is None:
|
|
416
|
+
logging.error(
|
|
417
|
+
f"Unknown service '{service}'. "
|
|
418
|
+
f"Choose from: {', '.join(SERVICE_NAMES)}"
|
|
419
|
+
)
|
|
420
|
+
raise typer.Exit(code=1)
|
|
421
|
+
try:
|
|
422
|
+
docker_client.container_restart(container)
|
|
423
|
+
except DeployError as exc:
|
|
424
|
+
logging.error(str(exc))
|
|
425
|
+
raise typer.Exit(code=1) from exc
|
|
426
|
+
return
|
|
427
|
+
|
|
428
|
+
try:
|
|
429
|
+
stop_mod.run_stop(root, purge=False)
|
|
430
|
+
except DeployError as exc:
|
|
431
|
+
logging.error(str(exc))
|
|
432
|
+
raise typer.Exit(code=1) from exc
|
|
433
|
+
_run_setup(root, background=True)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@app.command()
|
|
437
|
+
def reset(
|
|
438
|
+
ctx: typer.Context,
|
|
439
|
+
yes: bool = typer.Option(
|
|
440
|
+
False,
|
|
441
|
+
"--yes",
|
|
442
|
+
"-y",
|
|
443
|
+
help="Skip the destructive-action confirmation prompt.",
|
|
444
|
+
),
|
|
445
|
+
) -> None:
|
|
446
|
+
"""Wipe the archive and every volume, then start the stack fresh.
|
|
447
|
+
|
|
448
|
+
Destructive: removes the compute postgres / minio volumes (job
|
|
449
|
+
records, run artifacts, demo data) and the FlowMesh runtime state.
|
|
450
|
+
Use ``deploy down`` if you want to stop the stack but keep its data.
|
|
451
|
+
"""
|
|
452
|
+
if not yes and not typer.confirm(
|
|
453
|
+
"deploy reset deletes every Lumilake volume (archive + compute "
|
|
454
|
+
"data). Continue?",
|
|
455
|
+
default=False,
|
|
456
|
+
):
|
|
457
|
+
logging.info("Aborted.")
|
|
458
|
+
raise typer.Exit(code=0)
|
|
459
|
+
root = _project_dir(ctx)
|
|
460
|
+
try:
|
|
461
|
+
stop_mod.run_stop(root, purge=True)
|
|
462
|
+
except DeployError as exc:
|
|
463
|
+
logging.error(str(exc))
|
|
464
|
+
raise typer.Exit(code=1) from exc
|
|
465
|
+
_run_setup(root, background=True, reset=True)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
_SINCE_RE = re.compile(r"^(\d+)([smhd])$")
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _parse_since(value: str | None) -> dt.datetime | None:
|
|
472
|
+
"""Parse ``10m`` / ``2h`` / ``1d`` / ``30s`` into a past ``datetime``.
|
|
473
|
+
|
|
474
|
+
Returns ``None`` when ``value`` is ``None``. Exits the CLI on an
|
|
475
|
+
unparsable value.
|
|
476
|
+
"""
|
|
477
|
+
if value is None:
|
|
478
|
+
return None
|
|
479
|
+
match = _SINCE_RE.match(value.strip())
|
|
480
|
+
if not match:
|
|
481
|
+
logging.error(
|
|
482
|
+
f"--since must be one of <N>s/m/h/d (e.g. 30s, 10m, 2h, 1d); got {value!r}"
|
|
483
|
+
)
|
|
484
|
+
raise typer.Exit(code=1)
|
|
485
|
+
amount = int(match.group(1))
|
|
486
|
+
unit = match.group(2)
|
|
487
|
+
delta = {
|
|
488
|
+
"s": dt.timedelta(seconds=amount),
|
|
489
|
+
"m": dt.timedelta(minutes=amount),
|
|
490
|
+
"h": dt.timedelta(hours=amount),
|
|
491
|
+
"d": dt.timedelta(days=amount),
|
|
492
|
+
}[unit]
|
|
493
|
+
return dt.datetime.now(dt.UTC) - delta
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@app.command()
|
|
497
|
+
def logs(
|
|
498
|
+
ctx: typer.Context,
|
|
499
|
+
service: str = typer.Argument(
|
|
500
|
+
"server",
|
|
501
|
+
help=f"Service name: {', '.join(SERVICE_NAMES)}",
|
|
502
|
+
),
|
|
503
|
+
tail: int | None = typer.Option(
|
|
504
|
+
None, "--tail", "-n", help="Show last N lines and exit (default: follow live)."
|
|
505
|
+
),
|
|
506
|
+
since: str | None = typer.Option(
|
|
507
|
+
None,
|
|
508
|
+
"--since",
|
|
509
|
+
help=(
|
|
510
|
+
"Only show logs newer than this duration: "
|
|
511
|
+
"<N>s, <N>m, <N>h, <N>d (e.g. 30s, 10m, 2h, 1d)."
|
|
512
|
+
),
|
|
513
|
+
),
|
|
514
|
+
timestamps: bool = typer.Option(
|
|
515
|
+
False,
|
|
516
|
+
"--timestamps",
|
|
517
|
+
"-t",
|
|
518
|
+
help="Prepend an RFC3339 timestamp to each log line.",
|
|
519
|
+
),
|
|
520
|
+
) -> None:
|
|
521
|
+
"""Show logs for a Lumilake service.
|
|
522
|
+
|
|
523
|
+
Follows by default; use ``-n`` to show the last N lines and exit.
|
|
524
|
+
``--since`` filters to entries newer than the given relative duration.
|
|
525
|
+
"""
|
|
526
|
+
container = container_names(_project_dir(ctx)).get(service)
|
|
527
|
+
if container is None:
|
|
528
|
+
logging.error(
|
|
529
|
+
f"Unknown service '{service}'. Choose from: {', '.join(SERVICE_NAMES)}"
|
|
530
|
+
)
|
|
531
|
+
raise typer.Exit(code=1)
|
|
532
|
+
since_dt = _parse_since(since)
|
|
533
|
+
try:
|
|
534
|
+
if tail is not None:
|
|
535
|
+
output = docker_client.container_logs_tail(
|
|
536
|
+
container, tail=tail, since=since_dt, timestamps=timestamps
|
|
537
|
+
)
|
|
538
|
+
sys.stdout.write(output)
|
|
539
|
+
if output and not output.endswith("\n"):
|
|
540
|
+
sys.stdout.write("\n")
|
|
541
|
+
sys.stdout.flush()
|
|
542
|
+
else:
|
|
543
|
+
for chunk in docker_client.container_logs_stream(
|
|
544
|
+
container, since=since_dt, timestamps=timestamps
|
|
545
|
+
):
|
|
546
|
+
sys.stdout.buffer.write(chunk)
|
|
547
|
+
sys.stdout.buffer.flush()
|
|
548
|
+
except DeployError as exc:
|
|
549
|
+
logging.error(str(exc))
|
|
550
|
+
raise typer.Exit(code=1) from exc
|
|
551
|
+
except KeyboardInterrupt:
|
|
552
|
+
return
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
@app.command("update-flowmesh")
|
|
556
|
+
def update_flowmesh_cmd(ctx: typer.Context) -> None:
|
|
557
|
+
"""Re-lock and install the latest FlowMesh packages."""
|
|
558
|
+
try:
|
|
559
|
+
update_fm_mod.run_update(_project_dir(ctx))
|
|
560
|
+
except DeployError as exc:
|
|
561
|
+
logging.error(str(exc))
|
|
562
|
+
raise typer.Exit(code=1) from exc
|