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.
@@ -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