lumilake-cli 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_cli/__init__.py +0 -0
- lumilake_cli/cli.py +54 -0
- lumilake_cli/commands/__init__.py +69 -0
- lumilake_cli/commands/base.py +85 -0
- lumilake_cli/commands/deploy.py +462 -0
- lumilake_cli/commands/job.py +644 -0
- lumilake_cli/commands/trace.py +137 -0
- lumilake_cli/commands/worker.py +35 -0
- lumilake_cli/core/__init__.py +0 -0
- lumilake_cli/core/config.py +38 -0
- lumilake_cli/core/http.py +126 -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.dev1.dist-info/METADATA +28 -0
- lumilake_cli-0.1.0.dev1.dist-info/RECORD +20 -0
- lumilake_cli-0.1.0.dev1.dist-info/WHEEL +5 -0
- lumilake_cli-0.1.0.dev1.dist-info/entry_points.txt +2 -0
- lumilake_cli-0.1.0.dev1.dist-info/licenses/LICENSE +201 -0
- lumilake_cli-0.1.0.dev1.dist-info/top_level.txt +1 -0
lumilake_cli/__init__.py
ADDED
|
File without changes
|
lumilake_cli/cli.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Lumilake CLI entrypoint.
|
|
2
|
+
|
|
3
|
+
Installs a typer-aware handler on the shared ``Lumilake`` logger root so
|
|
4
|
+
records from every workspace package render with the CLI colour palette.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from lumilake.log import ColorFormatter, get_default_logger
|
|
11
|
+
|
|
12
|
+
from .commands import register
|
|
13
|
+
from .core.typer import get_typer
|
|
14
|
+
|
|
15
|
+
root_app = get_typer(help="Lumilake command line interface.")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _TyperHandler(logging.Handler):
|
|
19
|
+
"""Routes records through ``typer.echo``: stdout for INFO/DEBUG,
|
|
20
|
+
stderr for WARNING/ERROR/CRITICAL. Honours typer's color detection.
|
|
21
|
+
Coloring is left to ``ColorFormatter``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
25
|
+
try:
|
|
26
|
+
message = self.format(record)
|
|
27
|
+
except Exception:
|
|
28
|
+
self.handleError(record)
|
|
29
|
+
return
|
|
30
|
+
typer.echo(message, err=record.levelno >= logging.WARNING)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _install_handler() -> None:
|
|
34
|
+
root = get_default_logger()
|
|
35
|
+
if any(isinstance(h, _TyperHandler) for h in root.handlers):
|
|
36
|
+
return
|
|
37
|
+
# Drop get_default_logger's StreamHandler so records aren't doubled.
|
|
38
|
+
for existing in list(root.handlers):
|
|
39
|
+
root.removeHandler(existing)
|
|
40
|
+
handler = _TyperHandler()
|
|
41
|
+
handler.setFormatter(ColorFormatter())
|
|
42
|
+
root.addHandler(handler)
|
|
43
|
+
root.propagate = False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main() -> None:
|
|
47
|
+
"""Attach command groups and dispatch to Typer."""
|
|
48
|
+
_install_handler()
|
|
49
|
+
register(root_app)
|
|
50
|
+
root_app()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
main()
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Command modules for the Lumilake CLI."""
|
|
2
|
+
|
|
3
|
+
from importlib.util import find_spec
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from .base import app as base_app
|
|
8
|
+
from .job import app as job_app
|
|
9
|
+
from .trace import app as trace_app
|
|
10
|
+
from .worker import app as worker_app
|
|
11
|
+
|
|
12
|
+
# The ``[deploy]`` extra pulls ``lumilake-deploy`` and
|
|
13
|
+
# ``flowmesh-cli-stack``; gate on both up front so real ImportError
|
|
14
|
+
# from inside .deploy still bubbles up loudly.
|
|
15
|
+
_DEPLOY_AVAILABLE = (
|
|
16
|
+
find_spec("lumilake_deploy") is not None
|
|
17
|
+
and find_spec("flowmesh_cli_stack") is not None
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
if _DEPLOY_AVAILABLE:
|
|
21
|
+
from .deploy import app as deploy_app
|
|
22
|
+
else:
|
|
23
|
+
_DEPLOY_INSTALL_HINT = (
|
|
24
|
+
"lumilake deploy requires the `deploy` extra. Install with "
|
|
25
|
+
"`pip install 'lumilake[cli]'` or "
|
|
26
|
+
"`pip install 'lumilake-cli[deploy]'`."
|
|
27
|
+
)
|
|
28
|
+
deploy_app = typer.Typer(
|
|
29
|
+
help=(
|
|
30
|
+
"Deploy and manage the Lumilake stack. Install with "
|
|
31
|
+
"`pip install 'lumilake[cli]'` or "
|
|
32
|
+
"`pip install 'lumilake-cli[deploy]'` to enable."
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def _deploy_hint() -> None:
|
|
37
|
+
typer.echo(_DEPLOY_INSTALL_HINT, err=True)
|
|
38
|
+
raise typer.Exit(code=2)
|
|
39
|
+
|
|
40
|
+
@deploy_app.callback(invoke_without_command=True)
|
|
41
|
+
def _deploy_no_subcommand(ctx: typer.Context) -> None:
|
|
42
|
+
if ctx.resilient_parsing or ctx.invoked_subcommand is not None:
|
|
43
|
+
return
|
|
44
|
+
_deploy_hint()
|
|
45
|
+
|
|
46
|
+
for _name in (
|
|
47
|
+
"init",
|
|
48
|
+
"doctor",
|
|
49
|
+
"build",
|
|
50
|
+
"pull",
|
|
51
|
+
"up",
|
|
52
|
+
"down",
|
|
53
|
+
"clean",
|
|
54
|
+
"reset",
|
|
55
|
+
"status",
|
|
56
|
+
"restart",
|
|
57
|
+
"logs",
|
|
58
|
+
"update-flowmesh",
|
|
59
|
+
):
|
|
60
|
+
deploy_app.command(_name)(_deploy_hint)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def register(app: typer.Typer) -> None:
|
|
64
|
+
"""Register command groups on the root app."""
|
|
65
|
+
app.add_typer(base_app)
|
|
66
|
+
app.add_typer(deploy_app, name="deploy")
|
|
67
|
+
app.add_typer(job_app, name="job")
|
|
68
|
+
app.add_typer(worker_app, name="worker")
|
|
69
|
+
app.add_typer(trace_app, name="trace")
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from ..core import logging
|
|
7
|
+
from ..core.config import DEFAULT_CONFIG_PATH, LumilakeConfig, save_config
|
|
8
|
+
from ..core.http import HttpClient, HttpError, client_from_config
|
|
9
|
+
from ..core.typer import get_typer
|
|
10
|
+
|
|
11
|
+
app = get_typer()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command()
|
|
15
|
+
def login(
|
|
16
|
+
url: str = typer.Argument(
|
|
17
|
+
..., help="Lumilake server URL (e.g., http://localhost:9000)"
|
|
18
|
+
),
|
|
19
|
+
config_path: Path = typer.Option(
|
|
20
|
+
DEFAULT_CONFIG_PATH, "--config", help="Path to save configuration file"
|
|
21
|
+
),
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Configure connection to a Lumilake server and save it locally."""
|
|
24
|
+
client = HttpClient(base_url=url)
|
|
25
|
+
try:
|
|
26
|
+
client.get("/healthz")
|
|
27
|
+
except HttpError as exc:
|
|
28
|
+
logging.error(str(exc))
|
|
29
|
+
raise typer.Exit(code=1)
|
|
30
|
+
save_config(LumilakeConfig(base_url=url), path=config_path)
|
|
31
|
+
logging.success(f"Login successful. Saved config to {config_path}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command()
|
|
35
|
+
def logout(
|
|
36
|
+
config_path: Path = typer.Option(
|
|
37
|
+
DEFAULT_CONFIG_PATH, "--config", help="Path to configuration file to remove"
|
|
38
|
+
),
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Delete saved configuration."""
|
|
41
|
+
config_path.unlink(missing_ok=True)
|
|
42
|
+
logging.success(f"Logout successful. Removed config {config_path}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.command()
|
|
46
|
+
def config(
|
|
47
|
+
refresh: bool = typer.Option(
|
|
48
|
+
False, "--refresh", "-r", help="Reload and show the latest configuration"
|
|
49
|
+
),
|
|
50
|
+
config_path: Path = typer.Option(
|
|
51
|
+
DEFAULT_CONFIG_PATH, "--config", help="Path to configuration file"
|
|
52
|
+
),
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Show the current Lumilake CLI configuration."""
|
|
55
|
+
from ..core.http import _require_config
|
|
56
|
+
|
|
57
|
+
cfg = _require_config(config_path)
|
|
58
|
+
if refresh:
|
|
59
|
+
client = HttpClient(base_url=cfg.base_url)
|
|
60
|
+
try:
|
|
61
|
+
client.get("/healthz")
|
|
62
|
+
except HttpError as exc:
|
|
63
|
+
logging.error(str(exc))
|
|
64
|
+
raise typer.Exit(code=1)
|
|
65
|
+
save_config(cfg, path=config_path)
|
|
66
|
+
logging.success(f"Configuration refreshed. Saved to {config_path}")
|
|
67
|
+
logging.log(json.dumps(cfg.to_mapping(), indent=2))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command()
|
|
71
|
+
def info() -> None:
|
|
72
|
+
"""Query server health status."""
|
|
73
|
+
client = client_from_config()
|
|
74
|
+
try:
|
|
75
|
+
response = client.get("/healthz")
|
|
76
|
+
except HttpError as exc:
|
|
77
|
+
logging.error(str(exc))
|
|
78
|
+
raise typer.Exit(code=1)
|
|
79
|
+
logging.log(json.dumps(response.json(), indent=2))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command()
|
|
83
|
+
def health() -> None:
|
|
84
|
+
"""Check if the Lumilake server is reachable and healthy."""
|
|
85
|
+
info()
|
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"""Deploy commands: init, doctor, up, down, clean, restart, reset, logs."""
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from flowmesh_cli_stack.stack import stack_env_example
|
|
10
|
+
from lumilake_deploy import docker_client
|
|
11
|
+
from lumilake_deploy import setup as setup_mod
|
|
12
|
+
from lumilake_deploy import stop as stop_mod
|
|
13
|
+
from lumilake_deploy import update_flowmesh as update_fm_mod
|
|
14
|
+
from lumilake_deploy.assets import env_example_path
|
|
15
|
+
from lumilake_deploy.containers import SERVICE_NAMES, container_names
|
|
16
|
+
from lumilake_deploy.doctor import DoctorFinding, run_doctor
|
|
17
|
+
from lumilake_deploy.env import (
|
|
18
|
+
ENV_FILE_NAME,
|
|
19
|
+
FLOWMESH_ENV_FILE_NAME,
|
|
20
|
+
patch_env_value,
|
|
21
|
+
)
|
|
22
|
+
from lumilake_deploy.errors import DeployError
|
|
23
|
+
from lumilake_deploy.setup import (
|
|
24
|
+
SetupOptions,
|
|
25
|
+
build_server_image,
|
|
26
|
+
pull_server_image,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from ..core import logging
|
|
30
|
+
from ..core.typer import get_typer
|
|
31
|
+
|
|
32
|
+
app = get_typer(
|
|
33
|
+
help=(
|
|
34
|
+
"Deploy and manage the Lumilake stack. Reads .env (and optionally "
|
|
35
|
+
".env.flowmesh) from --project-dir (or the current working "
|
|
36
|
+
"directory). The packaged compose file and server image are "
|
|
37
|
+
"resolved from the installed lumilake-deploy package."
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.callback()
|
|
43
|
+
def _deploy_callback(
|
|
44
|
+
ctx: typer.Context,
|
|
45
|
+
project_dir: Path = typer.Option(
|
|
46
|
+
None,
|
|
47
|
+
"--project-dir",
|
|
48
|
+
"-C",
|
|
49
|
+
envvar="LUMILAKE_DEPLOY_DIR",
|
|
50
|
+
help=(
|
|
51
|
+
"Directory holding .env / .env.flowmesh and where compose "
|
|
52
|
+
"stores runtime state. Defaults to the current working directory."
|
|
53
|
+
),
|
|
54
|
+
),
|
|
55
|
+
) -> None:
|
|
56
|
+
ctx.obj = project_dir.resolve() if project_dir else Path.cwd()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _project_dir(ctx: typer.Context) -> Path:
|
|
60
|
+
if isinstance(ctx.obj, Path):
|
|
61
|
+
return ctx.obj
|
|
62
|
+
return Path.cwd()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _run_setup(
|
|
66
|
+
root: Path,
|
|
67
|
+
*,
|
|
68
|
+
background: bool = True,
|
|
69
|
+
reset: bool = False,
|
|
70
|
+
no_server: bool = False,
|
|
71
|
+
) -> None:
|
|
72
|
+
try:
|
|
73
|
+
setup_mod.run_setup(
|
|
74
|
+
root,
|
|
75
|
+
SetupOptions(
|
|
76
|
+
reset=reset,
|
|
77
|
+
no_server=no_server,
|
|
78
|
+
background=background,
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
except DeployError as exc:
|
|
82
|
+
logging.error(str(exc))
|
|
83
|
+
raise typer.Exit(code=1) from exc
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _confirm_overwrite(target: Path, *, force: bool) -> bool:
|
|
87
|
+
"""Return True when the target may be written, False to skip."""
|
|
88
|
+
if not target.exists() or force:
|
|
89
|
+
return True
|
|
90
|
+
if typer.confirm(f"{target} already exists. Overwrite?", default=False):
|
|
91
|
+
return True
|
|
92
|
+
logging.info(f"Keeping existing {target}.")
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _flowmesh_env_value(env_path: Path, key: str) -> str:
|
|
97
|
+
"""Read a single line from ``.env.flowmesh`` (no quoting strip pattern)."""
|
|
98
|
+
if not env_path.is_file():
|
|
99
|
+
return ""
|
|
100
|
+
prefix = f"{key}="
|
|
101
|
+
for raw in env_path.read_text().splitlines():
|
|
102
|
+
line = raw.strip()
|
|
103
|
+
if not line.startswith(prefix):
|
|
104
|
+
continue
|
|
105
|
+
value = line[len(prefix) :]
|
|
106
|
+
if value.startswith('"') and value.endswith('"'):
|
|
107
|
+
value = value[1:-1]
|
|
108
|
+
return value
|
|
109
|
+
return ""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
_FLOWMESH_LOCAL_OVERRIDES: tuple[tuple[str, str], ...] = (
|
|
113
|
+
("FLOWMESH_STACK_SUFFIX", "lumilake"),
|
|
114
|
+
("SERVER_GRPC_TLS_CA_FILE", ""),
|
|
115
|
+
("SERVER_GRPC_TLS_CERT_FILE", ""),
|
|
116
|
+
("SERVER_GRPC_TLS_KEY_FILE", ""),
|
|
117
|
+
("REDIS_TLS_CA_FILE", ""),
|
|
118
|
+
("REDIS_TLS_CERT_FILE", ""),
|
|
119
|
+
("REDIS_TLS_KEY_FILE", ""),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _patch_flowmesh_env(env_path: Path) -> None:
|
|
124
|
+
"""Disable TLS bind mounts on the bundled FlowMesh template."""
|
|
125
|
+
for key, value in _FLOWMESH_LOCAL_OVERRIDES:
|
|
126
|
+
patch_env_value(env_path, key, value)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _cross_populate_runtime(env_path: Path, fm_env_path: Path) -> None:
|
|
130
|
+
"""After ``init --flowmesh`` writes both files, point ``.env``'s
|
|
131
|
+
``LUMILAKE_RUNTIME_ORCHESTRATOR_URL`` at the FlowMesh server's port."""
|
|
132
|
+
server_port = _flowmesh_env_value(fm_env_path, "SERVER_HTTP_PORT") or "18000"
|
|
133
|
+
patch_env_value(
|
|
134
|
+
env_path,
|
|
135
|
+
"LUMILAKE_RUNTIME_ORCHESTRATOR_URL",
|
|
136
|
+
f"http://127.0.0.1:{server_port}",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.command("init")
|
|
141
|
+
def init(
|
|
142
|
+
ctx: typer.Context,
|
|
143
|
+
flowmesh: bool = typer.Option(
|
|
144
|
+
False,
|
|
145
|
+
"--flowmesh",
|
|
146
|
+
help="Also generate ``.env.flowmesh`` from FlowMesh's stack template.",
|
|
147
|
+
),
|
|
148
|
+
force: bool = typer.Option(
|
|
149
|
+
False,
|
|
150
|
+
"--force",
|
|
151
|
+
"-f",
|
|
152
|
+
help="Overwrite existing env files without prompting.",
|
|
153
|
+
),
|
|
154
|
+
) -> None:
|
|
155
|
+
"""Initialize ``.env`` from the bundled ``.env.example`` template."""
|
|
156
|
+
root = _project_dir(ctx)
|
|
157
|
+
template = env_example_path()
|
|
158
|
+
target = root / ENV_FILE_NAME
|
|
159
|
+
wrote_env = False
|
|
160
|
+
if _confirm_overwrite(target, force=force):
|
|
161
|
+
target.write_text(template.read_text())
|
|
162
|
+
logging.success(f"Wrote {target} from packaged {template.name}.")
|
|
163
|
+
wrote_env = True
|
|
164
|
+
|
|
165
|
+
if flowmesh:
|
|
166
|
+
fm_target = root / FLOWMESH_ENV_FILE_NAME
|
|
167
|
+
wrote_flowmesh_env = False
|
|
168
|
+
if _confirm_overwrite(fm_target, force=force):
|
|
169
|
+
example = stack_env_example()
|
|
170
|
+
if not example.exists():
|
|
171
|
+
logging.error(f"FlowMesh env example not found: {example}")
|
|
172
|
+
raise typer.Exit(code=1)
|
|
173
|
+
fm_target.write_text(example.read_text())
|
|
174
|
+
logging.success(f"Wrote {fm_target} from {example.name}.")
|
|
175
|
+
_patch_flowmesh_env(fm_target)
|
|
176
|
+
wrote_flowmesh_env = True
|
|
177
|
+
if wrote_env:
|
|
178
|
+
_cross_populate_runtime(target, fm_target)
|
|
179
|
+
elif wrote_flowmesh_env:
|
|
180
|
+
logging.info(
|
|
181
|
+
f"Keeping existing {target}; not patching runtime URL from "
|
|
182
|
+
f"{fm_target}."
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@app.command()
|
|
187
|
+
def doctor(
|
|
188
|
+
ctx: typer.Context,
|
|
189
|
+
flowmesh: bool = typer.Option(
|
|
190
|
+
False,
|
|
191
|
+
"--flowmesh",
|
|
192
|
+
help="Also validate that ``.env.flowmesh`` is present.",
|
|
193
|
+
),
|
|
194
|
+
) -> None:
|
|
195
|
+
"""Validate ``.env`` (and optionally ``.env.flowmesh``)."""
|
|
196
|
+
root = _project_dir(ctx)
|
|
197
|
+
|
|
198
|
+
def _emit(finding: DoctorFinding) -> None:
|
|
199
|
+
if finding.level == "error":
|
|
200
|
+
logging.error(finding.message)
|
|
201
|
+
elif finding.level == "warning":
|
|
202
|
+
logging.warning(finding.message)
|
|
203
|
+
else:
|
|
204
|
+
logging.info(finding.message)
|
|
205
|
+
|
|
206
|
+
report = run_doctor(
|
|
207
|
+
root / ENV_FILE_NAME,
|
|
208
|
+
flowmesh_env_path=(root / FLOWMESH_ENV_FILE_NAME) if flowmesh else None,
|
|
209
|
+
callback=_emit,
|
|
210
|
+
)
|
|
211
|
+
if report.errors:
|
|
212
|
+
logging.error(f"Doctor found {len(report.errors)} issue(s).")
|
|
213
|
+
raise typer.Exit(code=1)
|
|
214
|
+
logging.success("Doctor checks passed.")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@app.command()
|
|
218
|
+
def build(ctx: typer.Context) -> None:
|
|
219
|
+
"""Build the lumilake server Docker image from source."""
|
|
220
|
+
root = _project_dir(ctx)
|
|
221
|
+
setup_mod.load_project_env(root)
|
|
222
|
+
image_tag = setup_mod.resolve_image_tag()
|
|
223
|
+
try:
|
|
224
|
+
build_server_image(root, image_tag)
|
|
225
|
+
except DeployError as exc:
|
|
226
|
+
logging.error(str(exc))
|
|
227
|
+
raise typer.Exit(code=1) from exc
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@app.command()
|
|
231
|
+
def pull(ctx: typer.Context) -> None:
|
|
232
|
+
"""Pull the published lumilake server image from the registry."""
|
|
233
|
+
root = _project_dir(ctx)
|
|
234
|
+
setup_mod.load_project_env(root)
|
|
235
|
+
image_tag = setup_mod.resolve_image_tag()
|
|
236
|
+
try:
|
|
237
|
+
pull_server_image(image_tag)
|
|
238
|
+
except DeployError as exc:
|
|
239
|
+
logging.error(str(exc))
|
|
240
|
+
raise typer.Exit(code=1) from exc
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@app.command()
|
|
244
|
+
def up(ctx: typer.Context) -> None:
|
|
245
|
+
"""Start the full Lumilake stack (Docker-backed).
|
|
246
|
+
|
|
247
|
+
The server image must already be present locally — run
|
|
248
|
+
``lumilake deploy pull`` or ``lumilake deploy build`` first.
|
|
249
|
+
"""
|
|
250
|
+
_run_setup(_project_dir(ctx), background=True)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@app.command()
|
|
254
|
+
def down(
|
|
255
|
+
ctx: typer.Context,
|
|
256
|
+
wipe_archive: bool = typer.Option(
|
|
257
|
+
False,
|
|
258
|
+
"--wipe-archive",
|
|
259
|
+
help=(
|
|
260
|
+
"Also wipe runtime state that accumulates across "
|
|
261
|
+
"deploy cycles (FlowMesh postgres + redis). "
|
|
262
|
+
"Fixes the 'Duplicate key' / silent-retry-loop failure mode "
|
|
263
|
+
"after a killed prior run. Use this between experiment runs "
|
|
264
|
+
"if you don't want a full "
|
|
265
|
+
"``deploy reset`` + re-stage."
|
|
266
|
+
),
|
|
267
|
+
),
|
|
268
|
+
) -> None:
|
|
269
|
+
"""Stop all services (keep data)."""
|
|
270
|
+
try:
|
|
271
|
+
stop_mod.run_stop(
|
|
272
|
+
_project_dir(ctx),
|
|
273
|
+
purge=False,
|
|
274
|
+
wipe_archive=wipe_archive,
|
|
275
|
+
)
|
|
276
|
+
except DeployError as exc:
|
|
277
|
+
logging.error(str(exc))
|
|
278
|
+
raise typer.Exit(code=1) from exc
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@app.command()
|
|
282
|
+
def clean(ctx: typer.Context) -> None:
|
|
283
|
+
"""Stop all services and delete volumes."""
|
|
284
|
+
try:
|
|
285
|
+
stop_mod.run_stop(_project_dir(ctx), purge=True)
|
|
286
|
+
except DeployError as exc:
|
|
287
|
+
logging.error(str(exc))
|
|
288
|
+
raise typer.Exit(code=1) from exc
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@app.command()
|
|
292
|
+
def status(ctx: typer.Context) -> None:
|
|
293
|
+
"""Show the running state of every Lumilake stack container."""
|
|
294
|
+
rows: list[tuple[str, str, str, str]] = []
|
|
295
|
+
for service, container in container_names(_project_dir(ctx)).items():
|
|
296
|
+
state = docker_client.container_status(container)
|
|
297
|
+
health = (
|
|
298
|
+
docker_client.container_health_status(container)
|
|
299
|
+
if state == "running"
|
|
300
|
+
else ""
|
|
301
|
+
)
|
|
302
|
+
rows.append((service, container, state, health))
|
|
303
|
+
|
|
304
|
+
svc_w = max(len(r[0]) for r in rows)
|
|
305
|
+
cnt_w = max(len(r[1]) for r in rows)
|
|
306
|
+
state_w = max(len(r[2]) for r in rows)
|
|
307
|
+
for service, container, state, health in rows:
|
|
308
|
+
line = f"{service:<{svc_w}} {container:<{cnt_w}} {state:<{state_w}}"
|
|
309
|
+
if health:
|
|
310
|
+
line += f" ({health})"
|
|
311
|
+
logging.info(line)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@app.command()
|
|
315
|
+
def restart(
|
|
316
|
+
ctx: typer.Context,
|
|
317
|
+
service: str | None = typer.Argument(
|
|
318
|
+
None,
|
|
319
|
+
help=(
|
|
320
|
+
"Single service to restart. Choose from: "
|
|
321
|
+
f"{', '.join(SERVICE_NAMES)}. When omitted, restarts the "
|
|
322
|
+
"entire stack (re-runs setup, rebuilds images)."
|
|
323
|
+
),
|
|
324
|
+
),
|
|
325
|
+
) -> None:
|
|
326
|
+
"""Restart deployment services.
|
|
327
|
+
|
|
328
|
+
Without ``service``: stops the entire stack and re-runs setup
|
|
329
|
+
(rebuilds images if code changed). With ``service``: restarts just
|
|
330
|
+
that container in place.
|
|
331
|
+
"""
|
|
332
|
+
root = _project_dir(ctx)
|
|
333
|
+
if service is not None:
|
|
334
|
+
container = container_names(root).get(service)
|
|
335
|
+
if container is None:
|
|
336
|
+
logging.error(
|
|
337
|
+
f"Unknown service '{service}'. "
|
|
338
|
+
f"Choose from: {', '.join(SERVICE_NAMES)}"
|
|
339
|
+
)
|
|
340
|
+
raise typer.Exit(code=1)
|
|
341
|
+
try:
|
|
342
|
+
docker_client.container_restart(container)
|
|
343
|
+
except DeployError as exc:
|
|
344
|
+
logging.error(str(exc))
|
|
345
|
+
raise typer.Exit(code=1) from exc
|
|
346
|
+
return
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
stop_mod.run_stop(root, purge=False)
|
|
350
|
+
except DeployError as exc:
|
|
351
|
+
logging.error(str(exc))
|
|
352
|
+
raise typer.Exit(code=1) from exc
|
|
353
|
+
_run_setup(root, background=True)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@app.command()
|
|
357
|
+
def reset(ctx: typer.Context) -> None:
|
|
358
|
+
"""Clean reset (stop + purge + up; deletes all data)."""
|
|
359
|
+
root = _project_dir(ctx)
|
|
360
|
+
try:
|
|
361
|
+
stop_mod.run_stop(root, purge=True)
|
|
362
|
+
except DeployError as exc:
|
|
363
|
+
logging.error(str(exc))
|
|
364
|
+
raise typer.Exit(code=1) from exc
|
|
365
|
+
_run_setup(root, background=True, reset=True)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
_SINCE_RE = re.compile(r"^(\d+)([smhd])$")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _parse_since(value: str | None) -> dt.datetime | None:
|
|
372
|
+
"""Parse ``10m`` / ``2h`` / ``1d`` / ``30s`` into a past ``datetime``.
|
|
373
|
+
|
|
374
|
+
Returns ``None`` when ``value`` is ``None``. Exits the CLI on an
|
|
375
|
+
unparsable value.
|
|
376
|
+
"""
|
|
377
|
+
if value is None:
|
|
378
|
+
return None
|
|
379
|
+
match = _SINCE_RE.match(value.strip())
|
|
380
|
+
if not match:
|
|
381
|
+
logging.error(
|
|
382
|
+
f"--since must be one of <N>s/m/h/d (e.g. 30s, 10m, 2h, 1d); got {value!r}"
|
|
383
|
+
)
|
|
384
|
+
raise typer.Exit(code=1)
|
|
385
|
+
amount = int(match.group(1))
|
|
386
|
+
unit = match.group(2)
|
|
387
|
+
delta = {
|
|
388
|
+
"s": dt.timedelta(seconds=amount),
|
|
389
|
+
"m": dt.timedelta(minutes=amount),
|
|
390
|
+
"h": dt.timedelta(hours=amount),
|
|
391
|
+
"d": dt.timedelta(days=amount),
|
|
392
|
+
}[unit]
|
|
393
|
+
return dt.datetime.now(dt.UTC) - delta
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@app.command()
|
|
397
|
+
def logs(
|
|
398
|
+
ctx: typer.Context,
|
|
399
|
+
service: str = typer.Argument(
|
|
400
|
+
"server",
|
|
401
|
+
help=f"Service name: {', '.join(SERVICE_NAMES)}",
|
|
402
|
+
),
|
|
403
|
+
tail: int | None = typer.Option(
|
|
404
|
+
None, "--tail", "-n", help="Show last N lines and exit (default: follow live)."
|
|
405
|
+
),
|
|
406
|
+
since: str | None = typer.Option(
|
|
407
|
+
None,
|
|
408
|
+
"--since",
|
|
409
|
+
help=(
|
|
410
|
+
"Only show logs newer than this duration: "
|
|
411
|
+
"<N>s, <N>m, <N>h, <N>d (e.g. 30s, 10m, 2h, 1d)."
|
|
412
|
+
),
|
|
413
|
+
),
|
|
414
|
+
timestamps: bool = typer.Option(
|
|
415
|
+
False,
|
|
416
|
+
"--timestamps",
|
|
417
|
+
"-t",
|
|
418
|
+
help="Prepend an RFC3339 timestamp to each log line.",
|
|
419
|
+
),
|
|
420
|
+
) -> None:
|
|
421
|
+
"""Show logs for a Lumilake service.
|
|
422
|
+
|
|
423
|
+
Follows by default; use ``-n`` to show the last N lines and exit.
|
|
424
|
+
``--since`` filters to entries newer than the given relative duration.
|
|
425
|
+
"""
|
|
426
|
+
container = container_names(_project_dir(ctx)).get(service)
|
|
427
|
+
if container is None:
|
|
428
|
+
logging.error(
|
|
429
|
+
f"Unknown service '{service}'. Choose from: {', '.join(SERVICE_NAMES)}"
|
|
430
|
+
)
|
|
431
|
+
raise typer.Exit(code=1)
|
|
432
|
+
since_dt = _parse_since(since)
|
|
433
|
+
try:
|
|
434
|
+
if tail is not None:
|
|
435
|
+
output = docker_client.container_logs_tail(
|
|
436
|
+
container, tail=tail, since=since_dt, timestamps=timestamps
|
|
437
|
+
)
|
|
438
|
+
sys.stdout.write(output)
|
|
439
|
+
if output and not output.endswith("\n"):
|
|
440
|
+
sys.stdout.write("\n")
|
|
441
|
+
sys.stdout.flush()
|
|
442
|
+
else:
|
|
443
|
+
for chunk in docker_client.container_logs_stream(
|
|
444
|
+
container, since=since_dt, timestamps=timestamps
|
|
445
|
+
):
|
|
446
|
+
sys.stdout.buffer.write(chunk)
|
|
447
|
+
sys.stdout.buffer.flush()
|
|
448
|
+
except DeployError as exc:
|
|
449
|
+
logging.error(str(exc))
|
|
450
|
+
raise typer.Exit(code=1) from exc
|
|
451
|
+
except KeyboardInterrupt:
|
|
452
|
+
return
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@app.command("update-flowmesh")
|
|
456
|
+
def update_flowmesh_cmd(ctx: typer.Context) -> None:
|
|
457
|
+
"""Re-lock and install the latest FlowMesh packages."""
|
|
458
|
+
try:
|
|
459
|
+
update_fm_mod.run_update(_project_dir(ctx))
|
|
460
|
+
except DeployError as exc:
|
|
461
|
+
logging.error(str(exc))
|
|
462
|
+
raise typer.Exit(code=1) from exc
|