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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ """Lumilake deploy orchestration implementation.
2
+
3
+ Public entry points called from ``cli/commands/deploy.py``:
4
+
5
+ - :func:`setup.run_setup` — stand up the stack from ``.env``.
6
+ - :func:`stop.run_stop` — stop all services (optionally purge volumes).
7
+ - :func:`update_flowmesh.run_update` — re-lock + install latest FlowMesh packages.
8
+
9
+ External binaries (``docker``, ``uv``) are invoked via subprocess; the
10
+ Python layer owns the control flow, parsing, and state.
11
+ """
@@ -0,0 +1,139 @@
1
+ import os
2
+ import re
3
+ import sys
4
+ from collections.abc import Iterable
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from urllib.parse import unquote, urlparse
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class S3Config:
13
+ endpoint: str
14
+ access_key: str
15
+ secret_key: str
16
+ bucket: str
17
+ secure: bool
18
+ cert_file: str | None
19
+
20
+
21
+ def load_env_file(env_file: Path | None) -> dict[str, str]:
22
+ if env_file is None:
23
+ return {}
24
+ if not env_file.is_file():
25
+ raise FileNotFoundError(f"env file not found: {env_file}")
26
+ pat = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$")
27
+ out: dict[str, str] = {}
28
+ for raw in env_file.read_text().splitlines():
29
+ line = raw.split("#", 1)[0].strip()
30
+ if not line:
31
+ continue
32
+ m = pat.match(line)
33
+ if not m:
34
+ continue
35
+ key, value = m.group(1), m.group(2)
36
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
37
+ value = value[1:-1]
38
+ out[key] = value
39
+ return out
40
+
41
+
42
+ def resolve_env(
43
+ env_file: Path | None,
44
+ overrides: dict[str, str | None] | None = None,
45
+ ) -> dict[str, str]:
46
+ merged: dict[str, str] = {}
47
+ merged.update(load_env_file(env_file))
48
+ for key, value in os.environ.items():
49
+ if value:
50
+ merged[key] = value
51
+ if overrides:
52
+ for key, override in overrides.items():
53
+ if override is not None:
54
+ merged[key] = override
55
+ return merged
56
+
57
+
58
+ def require_env(env: dict[str, str], keys: Iterable[str]) -> None:
59
+ missing = [k for k in keys if not env.get(k)]
60
+ if missing:
61
+ raise SystemExit(
62
+ f"missing required env variable(s): {', '.join(missing)} "
63
+ "(set in --env-file or process env)"
64
+ )
65
+
66
+
67
+ def parse_s3_url(raw: str, cert_file: str | None = None) -> S3Config:
68
+ parsed = urlparse(raw)
69
+ if parsed.scheme != "s3":
70
+ raise SystemExit(f"S3_URL must use s3:// scheme: {raw!r}")
71
+ if not parsed.hostname or not parsed.username or not parsed.password:
72
+ raise SystemExit(
73
+ "S3_URL must include credentials and host, e.g. "
74
+ "s3://access:secret@host:port/bucket"
75
+ )
76
+ bucket = parsed.path.lstrip("/").split("/", 1)[0]
77
+ if not bucket:
78
+ raise SystemExit("S3_URL must include a bucket in the path")
79
+ endpoint = parsed.hostname
80
+ if parsed.port:
81
+ endpoint = f"{endpoint}:{parsed.port}"
82
+ return S3Config(
83
+ endpoint=endpoint,
84
+ access_key=unquote(parsed.username),
85
+ secret_key=unquote(parsed.password),
86
+ bucket=bucket,
87
+ secure=bool(cert_file),
88
+ cert_file=cert_file,
89
+ )
90
+
91
+
92
+ def make_minio_client(cfg: S3Config) -> Any:
93
+ try:
94
+ from minio import Minio
95
+ except ImportError as exc:
96
+ raise SystemExit(
97
+ "minio package is required. Install via "
98
+ "`uv sync --all-packages` or `pip install minio`."
99
+ ) from exc
100
+
101
+ http_client = None
102
+ if cfg.cert_file:
103
+ import certifi
104
+ import urllib3
105
+
106
+ http_client = urllib3.PoolManager(
107
+ cert_reqs="CERT_REQUIRED",
108
+ ca_certs=cfg.cert_file or certifi.where(),
109
+ )
110
+ return Minio(
111
+ endpoint=cfg.endpoint,
112
+ access_key=cfg.access_key,
113
+ secret_key=cfg.secret_key,
114
+ secure=cfg.secure,
115
+ http_client=http_client,
116
+ )
117
+
118
+
119
+ def find_default_env_file(start: Path | None = None) -> Path | None:
120
+ cur = (start or Path.cwd()).resolve()
121
+ for parent in [cur, *cur.parents]:
122
+ candidate = parent / ".env"
123
+ if candidate.is_file():
124
+ return candidate
125
+ return None
126
+
127
+
128
+ def info(msg: str) -> None:
129
+ print(msg, file=sys.stderr, flush=True)
130
+
131
+
132
+ def human_bytes(n: int) -> str:
133
+ units = ["B", "KiB", "MiB", "GiB", "TiB"]
134
+ val = float(n)
135
+ for unit in units:
136
+ if val < 1024.0:
137
+ return f"{val:.1f} {unit}"
138
+ val /= 1024.0
139
+ return f"{val:.1f} PiB"
@@ -0,0 +1,82 @@
1
+ # Lumilake server env template.
2
+ #
3
+ # Copy to ``.env`` (run ``lumilake deploy init``) and edit. The packaged
4
+ # compose file consumes this via ``env_file: .env``; local runs load it via
5
+ # ``load_dotenv(".env")``.
6
+ #
7
+ # Run ``lumilake deploy doctor`` after editing to validate.
8
+
9
+ # ── Server ────────────────────────────────────────────────────────────────────
10
+ LUMILAKE_LOG_LEVEL="INFO"
11
+ LUMILAKE_SERVER_HOST="0.0.0.0"
12
+ LUMILAKE_SERVER_PORT="9000"
13
+
14
+ # ── Scheduler ─────────────────────────────────────────────────────────────────
15
+ LUMILAKE_OPTIMIZER_BATCH_SIZE="10"
16
+ LUMILAKE_STARVATION_LIMIT="3"
17
+ LUMILAKE_BATCH_ACCUMULATION_SECONDS="0"
18
+ LUMILAKE_OPTIMIZER_SUBPROCESS_TIMEOUT_SECONDS="60"
19
+ LUMILAKE_CPU_WORKER_GROUP_SIZE="1"
20
+ LUMILAKE_GPU_WORKER_GROUP_SIZE="1"
21
+ # GPU devices Lumilake assigns to FlowMesh workers, one worker per
22
+ # device. Distinct from ``.env.flowmesh``'s ``CUDA_VISIBLE_DEVICES``,
23
+ # which scopes what the FlowMesh server container itself sees. Set to a
24
+ # specific index (e.g. "0") or a comma-separated subset ("0,2") for the
25
+ # free GPUs on your host; "all" expands to every nvidia-smi-detected
26
+ # GPU. Leave blank to skip GPU worker creation.
27
+ LUMILAKE_GPU_DEVICES=""
28
+
29
+ # ── Runtime (FlowMesh) ────────────────────────────────────────────────────────
30
+ # The orchestrator URL the server dispatches workflows to. When using the
31
+ # bundled FlowMesh stack (``lumilake deploy init --flowmesh``), this is
32
+ # the http://127.0.0.1:<flowmesh.server_port> from ``.env.flowmesh``.
33
+ LUMILAKE_RUNTIME_ORCHESTRATOR_URL="http://127.0.0.1:18000"
34
+ LUMILAKE_RUNTIME_TOKEN=""
35
+
36
+ # Worker output delivery: "local" or "http". Both work identically for
37
+ # Lumilake-emitted upstream paths.
38
+ LUMILAKE_FLOWMESH_OUTPUT_DESTINATION="local"
39
+
40
+ # ── Data plane (lumid.data, agent retrievals only) --------------------------
41
+ # Only DataRetrievalOps with ``type: agent`` route through lumid.data;
42
+ # SQL and S3 retrievals always go direct against ``DATABASE_URL`` /
43
+ # ``S3_URL`` below regardless of ``LUMID_DATA_URL``.
44
+ # LUMID_DATA_URL="http://127.0.0.1:9102"
45
+ # LUMID_DATA_TOKEN=""
46
+ # LUMID_DATA_TIMEOUT_SECONDS="30"
47
+
48
+ # ── Compute database -------------------------------------------------------
49
+ # PostgreSQL connection used by every SQL ``DataRetrievalOp``.
50
+ DATABASE_URL="postgresql://lumilake:lumilake_password@127.0.0.1:15432/lumilake"
51
+
52
+ # ── Compute S3 -------------------------------------------------------------
53
+ # S3-compatible connection used by every S3 ``DataRetrievalOp`` and the
54
+ # job-record archive (see ``S3_ARCHIVE_PREFIX``).
55
+ S3_URL="s3://lumilake:lumilake_password@127.0.0.1:19100/lumilake-demo"
56
+ S3_USER_DATA_PREFIX="lumilake-demo"
57
+ # S3_CERT_FILE=""
58
+
59
+ # Archive ``bucket/prefix`` for job records + run artifacts. Shares the
60
+ # ``S3_*`` connection; only the prefix is configurable.
61
+ S3_ARCHIVE_PREFIX="lumilake-archive/artifacts"
62
+
63
+ # ── Tuning (optional) --------------------------------------------------------
64
+ # LUMILAKE_POLL_TIMEOUT_SECONDS=""
65
+ # LUMILAKE_POLL_INTERVAL_SECONDS=""
66
+ # LUMILAKE_HTTP_TIMEOUT_SECONDS="300"
67
+ # LUMILAKE_QUEUE_QUANTUM_MEDIUM=""
68
+ # LUMILAKE_DATA_PROFILE_NUM_TEST_QUERIES="1"
69
+ # LUMILAKE_S3_PROFILE_COST_PER_FILE="0.05"
70
+ # LUMILAKE_S3_PROFILE_COST_PER_MIB="0.01"
71
+
72
+ # ── Hardware Requirements (optional) ─────────────────────────────────────────
73
+ # HARDWARE_CPU_REQUIREMENT="8"
74
+ # HARDWARE_MEMORY_REQUIREMENT="16Gi"
75
+ # HARDWARE_GPU_REQUIREMENT="1"
76
+ # HARDWARE_GPU_MEMORY_REQUIREMENT=""
77
+
78
+ # ── Docker ───────────────────────────────────────────────────────────────────
79
+ # `dev` is the rolling main image. Pin to `vX.Y.Z` for production.
80
+ LUMILAKE_IMAGE_TAG="dev"
81
+ # Trust-bearing override of the server-image registry (see docs/ENV.md).
82
+ # LUMILAKE_REGISTRY="ghcr.io/mlsys-io"
@@ -0,0 +1,35 @@
1
+ """Packaged deploy assets (compose file, env template).
2
+
3
+ Resolved through :mod:`importlib.resources` so the deploy CLI works the
4
+ same from a workspace checkout or a PyPI install — operators don't have
5
+ to be in (or even have) a Lumilake source tree.
6
+ """
7
+
8
+ from importlib import resources
9
+ from pathlib import Path
10
+
11
+
12
+ class AssetNotFoundError(FileNotFoundError):
13
+ """Raised when a packaged asset cannot be resolved."""
14
+
15
+
16
+ def asset_path(*parts: str) -> Path:
17
+ """Return a usable filesystem path for an asset inside this package."""
18
+ resource = resources.files(__name__)
19
+ for part in parts:
20
+ resource /= part
21
+ try:
22
+ with resources.as_file(resource) as path:
23
+ return Path(path)
24
+ except FileNotFoundError as exc:
25
+ raise AssetNotFoundError(str(resource)) from exc
26
+
27
+
28
+ def env_example_path() -> Path:
29
+ """Path to the bundled ``.env.example`` template."""
30
+ return asset_path(".env.example")
31
+
32
+
33
+ def compose_path() -> Path:
34
+ """Path to the bundled ``compose.yml`` (docker-compose) file."""
35
+ return asset_path("compose.yml")
@@ -0,0 +1,40 @@
1
+ x-logging: &default-logging
2
+ driver: json-file
3
+ options:
4
+ max-size: "10m"
5
+ max-file: "3"
6
+
7
+ x-logging-verbose: &verbose-logging
8
+ driver: json-file
9
+ options:
10
+ max-size: "50m"
11
+ max-file: "3"
12
+
13
+ services:
14
+ server:
15
+ image: ${LUMILAKE_REGISTRY:-ghcr.io/mlsys-io}/lumilake_server:${LUMILAKE_IMAGE_TAG:-dev}
16
+ # Use the locally-tagged image when present (so ``lumilake deploy
17
+ # build`` outputs are honored); ``lumilake deploy pull`` resolves
18
+ # rolling :dev/:latest tags explicitly when an update is wanted.
19
+ pull_policy: missing
20
+ container_name: lumilake-server
21
+ profiles: ["server"]
22
+ network_mode: host
23
+ env_file: .env
24
+ environment:
25
+ # Env vars are injected via env_file; no .env inside the container.
26
+ LUMILAKE_SKIP_DOTENV_CHECK: "1"
27
+ volumes:
28
+ # Surface the host's S3 cert at the same path inside the container
29
+ # so ``S3_CERT_FILE`` resolves. Mounting ``/dev/null`` is a no-op
30
+ # when the variable is unset (direct-mode without TLS verification).
31
+ - ${S3_CERT_FILE:-/dev/null}:${S3_CERT_FILE:-/dev/null}:ro
32
+ healthcheck:
33
+ test: ["CMD", "python", "-c", "import os, urllib.request; urllib.request.urlopen(f'http://localhost:{os.environ.get(\"LUMILAKE_SERVER_PORT\", \"9000\")}/healthz')"]
34
+ interval: 10s
35
+ timeout: 5s
36
+ retries: 12
37
+ # Cover envs.validate() + DB pool init before unhealthy.
38
+ start_period: 40s
39
+ restart: unless-stopped
40
+ logging: *verbose-logging
@@ -0,0 +1,48 @@
1
+ """Service-to-container mapping for the Lumilake stack.
2
+
3
+ FlowMesh names containers and volumes from ``FLOWMESH_STACK_SLUG`` (the
4
+ slugged form of ``FLOWMESH_STACK_SUFFIX``). ``lumilake deploy init
5
+ --flowmesh`` sets the suffix to ``lumilake``, so the SDK can't hardcode
6
+ ``flowmesh_node_*`` — it has to look the slug up from ``.env.flowmesh``
7
+ in the operator's deployment directory.
8
+ """
9
+
10
+ from pathlib import Path
11
+
12
+ from . import flowmesh as fm_mod
13
+ from .env import FLOWMESH_ENV_FILE_NAME
14
+
15
+ SERVICE_NAMES: tuple[str, ...] = (
16
+ "server",
17
+ "flowmesh",
18
+ "flowmesh-redis",
19
+ "flowmesh-redis-telemetry",
20
+ )
21
+
22
+
23
+ def container_names(deploy_dir: Path) -> dict[str, str]:
24
+ """Map each service name to the docker container name.
25
+
26
+ Looks up ``FLOWMESH_STACK_SLUG`` from ``deploy_dir/.env.flowmesh``
27
+ when present; falls back to ``flowmesh_node`` so the SDK keeps
28
+ pre-init behavior intact.
29
+ """
30
+ env_fm = deploy_dir / FLOWMESH_ENV_FILE_NAME
31
+ slug = fm_mod.stack_slug(env_fm) if env_fm.is_file() else "flowmesh_node"
32
+ return {
33
+ "server": "lumilake-server",
34
+ "flowmesh": f"{slug}_server",
35
+ "flowmesh-redis": f"{slug}_redis_control",
36
+ "flowmesh-redis-telemetry": f"{slug}_redis_telemetry",
37
+ }
38
+
39
+
40
+ def flowmesh_state_volumes(deploy_dir: Path) -> tuple[str, ...]:
41
+ """Volume names FlowMesh creates for its postgres + redis state."""
42
+ env_fm = deploy_dir / FLOWMESH_ENV_FILE_NAME
43
+ slug = fm_mod.stack_slug(env_fm) if env_fm.is_file() else "flowmesh_node"
44
+ return (
45
+ f"{slug}_postgres_data",
46
+ f"{slug}_redis_control_data",
47
+ f"{slug}_redis_telemetry_data",
48
+ )
@@ -0,0 +1,175 @@
1
+ """Thin wrappers over ``docker-py`` for container lifecycle ops."""
2
+
3
+ import datetime as dt
4
+ from collections.abc import Iterator
5
+ from functools import cache
6
+
7
+ import docker
8
+ from docker.errors import APIError, DockerException, ImageNotFound
9
+ from docker.models.containers import Container
10
+ from docker.models.volumes import Volume
11
+
12
+ from .errors import DeployError
13
+
14
+
15
+ @cache
16
+ def get_docker_client() -> docker.DockerClient:
17
+ """Return a cached ``DockerClient`` built from the environment."""
18
+ try:
19
+ return docker.from_env()
20
+ except DockerException as exc:
21
+ raise DeployError(
22
+ "Cannot connect to Docker. Ensure the daemon is running and your "
23
+ "user is in the docker group (sudo usermod -aG docker $USER)."
24
+ ) from exc
25
+
26
+
27
+ def engine_is_up() -> bool:
28
+ """Return True when the Docker daemon is reachable.
29
+
30
+ ``ping()`` is an explicit probe — we don't wrap it in a check-first
31
+ pattern because "is the daemon alive" has no underlying resource to
32
+ list. DockerException here is the documented "unreachable" signal,
33
+ not a swallow; anything else would be a library contract violation.
34
+ """
35
+ try:
36
+ return bool(get_docker_client().ping())
37
+ except (DockerException, DeployError):
38
+ return False
39
+
40
+
41
+ def image_exists(tag: str) -> bool:
42
+ """Return True when ``tag`` is present in the local image store."""
43
+ try:
44
+ get_docker_client().images.get(tag)
45
+ return True
46
+ except ImageNotFound:
47
+ return False
48
+
49
+
50
+ def image_pull(tag: str) -> None:
51
+ """Pull ``tag`` from its registry into the local image store."""
52
+ client = get_docker_client()
53
+ try:
54
+ client.images.pull(tag)
55
+ except (APIError, ImageNotFound) as exc:
56
+ raise DeployError(f"Failed to pull image {tag}: {exc}") from exc
57
+
58
+
59
+ def _find_container(name: str) -> Container | None:
60
+ matches = get_docker_client().containers.list(all=True, filters={"name": name})
61
+ for container in matches:
62
+ if container.name == name:
63
+ return container
64
+ return None
65
+
66
+
67
+ def container_exists(name: str) -> bool:
68
+ return _find_container(name) is not None
69
+
70
+
71
+ def container_stop(name: str, *, timeout: int = 30) -> bool:
72
+ """Stop ``name`` if present. Returns True on success, False if missing."""
73
+ container = _find_container(name)
74
+ if container is None:
75
+ return False
76
+ try:
77
+ container.stop(timeout=timeout)
78
+ except APIError as exc:
79
+ raise DeployError(f"Failed to stop container {name}: {exc}") from exc
80
+ return True
81
+
82
+
83
+ def container_restart(name: str, *, timeout: int = 30) -> None:
84
+ container = _find_container(name)
85
+ if container is None:
86
+ raise DeployError(f"Container {name} not found")
87
+ try:
88
+ container.restart(timeout=timeout)
89
+ except APIError as exc:
90
+ raise DeployError(f"Failed to restart container {name}: {exc}") from exc
91
+
92
+
93
+ def container_logs_tail(
94
+ name: str,
95
+ *,
96
+ tail: int = 30,
97
+ since: dt.datetime | None = None,
98
+ timestamps: bool = False,
99
+ ) -> str:
100
+ """Return the last ``tail`` log lines (or empty string if missing).
101
+
102
+ ``since`` restricts output to entries after that timestamp.
103
+ ``timestamps`` prepends an RFC3339 timestamp to each line.
104
+ """
105
+ container = _find_container(name)
106
+ if container is None:
107
+ return ""
108
+ try:
109
+ data = container.logs(tail=tail, since=since, timestamps=timestamps)
110
+ except APIError as exc:
111
+ raise DeployError(f"Failed to read logs for {name}: {exc}") from exc
112
+ return data.decode(errors="replace") if isinstance(data, bytes) else str(data)
113
+
114
+
115
+ def container_logs_stream(
116
+ name: str,
117
+ *,
118
+ since: dt.datetime | None = None,
119
+ timestamps: bool = False,
120
+ ) -> Iterator[bytes]:
121
+ """Stream follow-mode logs from the container as byte chunks."""
122
+ container = _find_container(name)
123
+ if container is None:
124
+ raise DeployError(f"Container {name} not found")
125
+ try:
126
+ yield from container.logs(
127
+ stream=True, follow=True, since=since, timestamps=timestamps
128
+ )
129
+ except APIError as exc:
130
+ raise DeployError(f"Failed to stream logs for {name}: {exc}") from exc
131
+
132
+
133
+ def container_health_status(name: str) -> str:
134
+ """Return ``healthy`` / ``unhealthy`` / ``starting``, or "" when the
135
+ container is missing or has no healthcheck configured."""
136
+ container = _find_container(name)
137
+ if container is None:
138
+ return ""
139
+ health = container.attrs["State"].get("Health")
140
+ return "" if health is None else str(health["Status"])
141
+
142
+
143
+ def container_status(name: str) -> str:
144
+ """Return the container's lifecycle state, or ``missing`` when absent."""
145
+ container = _find_container(name)
146
+ if container is None:
147
+ return "missing"
148
+ return container.status
149
+
150
+
151
+ def _find_volume(name: str) -> Volume | None:
152
+ matches = get_docker_client().volumes.list(filters={"name": name})
153
+ for volume in matches:
154
+ if volume.name == name:
155
+ return volume
156
+ return None
157
+
158
+
159
+ def volume_exists(name: str) -> bool:
160
+ return _find_volume(name) is not None
161
+
162
+
163
+ def volume_remove(name: str) -> bool:
164
+ """Remove ``name`` if present. Returns True on success, False if missing.
165
+
166
+ Raises ``DeployError`` on API errors (e.g. volume still in use).
167
+ """
168
+ volume = _find_volume(name)
169
+ if volume is None:
170
+ return False
171
+ try:
172
+ volume.remove()
173
+ except APIError as exc:
174
+ raise DeployError(f"Failed to remove volume {name}: {exc}") from exc
175
+ return True