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,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
|