llama-deploy-appserver 0.2.7a1__py3-none-any.whl → 0.3.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.
- llama_deploy/appserver/app.py +274 -26
- llama_deploy/appserver/bootstrap.py +55 -25
- llama_deploy/appserver/configure_logging.py +189 -0
- llama_deploy/appserver/correlation_id.py +24 -0
- llama_deploy/appserver/deployment.py +70 -412
- llama_deploy/appserver/deployment_config_parser.py +12 -130
- llama_deploy/appserver/interrupts.py +55 -0
- llama_deploy/appserver/process_utils.py +214 -0
- llama_deploy/appserver/py.typed +0 -0
- llama_deploy/appserver/routers/__init__.py +4 -3
- llama_deploy/appserver/routers/deployments.py +163 -382
- llama_deploy/appserver/routers/status.py +4 -31
- llama_deploy/appserver/routers/ui_proxy.py +255 -0
- llama_deploy/appserver/settings.py +99 -49
- llama_deploy/appserver/types.py +0 -3
- llama_deploy/appserver/workflow_loader.py +431 -0
- llama_deploy/appserver/workflow_store/agent_data_store.py +100 -0
- llama_deploy/appserver/workflow_store/keyed_lock.py +32 -0
- llama_deploy/appserver/workflow_store/lru_cache.py +49 -0
- llama_deploy_appserver-0.3.0.dist-info/METADATA +25 -0
- llama_deploy_appserver-0.3.0.dist-info/RECORD +24 -0
- {llama_deploy_appserver-0.2.7a1.dist-info → llama_deploy_appserver-0.3.0.dist-info}/WHEEL +1 -1
- llama_deploy/appserver/__main__.py +0 -14
- llama_deploy/appserver/client/__init__.py +0 -3
- llama_deploy/appserver/client/base.py +0 -30
- llama_deploy/appserver/client/client.py +0 -49
- llama_deploy/appserver/client/models/__init__.py +0 -4
- llama_deploy/appserver/client/models/apiserver.py +0 -356
- llama_deploy/appserver/client/models/model.py +0 -82
- llama_deploy/appserver/run_autodeploy.py +0 -141
- llama_deploy/appserver/server.py +0 -60
- llama_deploy/appserver/source_managers/__init__.py +0 -5
- llama_deploy/appserver/source_managers/base.py +0 -33
- llama_deploy/appserver/source_managers/git.py +0 -48
- llama_deploy/appserver/source_managers/local.py +0 -51
- llama_deploy/appserver/tracing.py +0 -237
- llama_deploy_appserver-0.2.7a1.dist-info/METADATA +0 -23
- llama_deploy_appserver-0.2.7a1.dist-info/RECORD +0 -28
|
@@ -1,41 +1,26 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
-
import importlib
|
|
3
2
|
import json
|
|
4
3
|
import logging
|
|
5
4
|
import os
|
|
6
|
-
import site
|
|
7
|
-
import subprocess
|
|
8
|
-
import sys
|
|
9
|
-
import tempfile
|
|
10
|
-
from asyncio.subprocess import Process
|
|
11
|
-
from multiprocessing.pool import ThreadPool
|
|
12
5
|
from pathlib import Path
|
|
13
|
-
from typing import Any, Tuple
|
|
6
|
+
from typing import Any, Tuple
|
|
14
7
|
|
|
15
|
-
from
|
|
16
|
-
from
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
from fastapi.responses import RedirectResponse
|
|
10
|
+
from llama_deploy.appserver.deployment_config_parser import get_deployment_config
|
|
11
|
+
from llama_deploy.appserver.settings import ApiserverSettings, settings
|
|
17
12
|
from llama_deploy.appserver.types import generate_id
|
|
13
|
+
from llama_deploy.appserver.workflow_loader import DEFAULT_SERVICE_ID
|
|
14
|
+
from llama_deploy.appserver.workflow_store.agent_data_store import AgentDataStore
|
|
15
|
+
from llama_deploy.core.deployment_config import DeploymentConfig
|
|
16
|
+
from starlette.routing import Route
|
|
17
|
+
from starlette.staticfiles import StaticFiles
|
|
18
18
|
from workflows import Context, Workflow
|
|
19
19
|
from workflows.handler import WorkflowHandler
|
|
20
|
-
|
|
21
|
-
from .
|
|
22
|
-
DeploymentConfig,
|
|
23
|
-
Service,
|
|
24
|
-
SourceType,
|
|
25
|
-
)
|
|
26
|
-
from .source_managers import GitSourceManager, LocalSourceManager, SourceManager
|
|
27
|
-
from .stats import deployment_state, service_state
|
|
20
|
+
from workflows.server import SqliteWorkflowStore, WorkflowServer
|
|
21
|
+
from workflows.server.abstract_workflow_store import EmptyWorkflowStore
|
|
28
22
|
|
|
29
23
|
logger = logging.getLogger()
|
|
30
|
-
SOURCE_MANAGERS: dict[SourceType, Type[SourceManager]] = {
|
|
31
|
-
SourceType.git: GitSourceManager,
|
|
32
|
-
SourceType.local: LocalSourceManager,
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
class Client:
|
|
37
|
-
# stub class
|
|
38
|
-
pass
|
|
39
24
|
|
|
40
25
|
|
|
41
26
|
class DeploymentError(Exception): ...
|
|
@@ -44,11 +29,7 @@ class DeploymentError(Exception): ...
|
|
|
44
29
|
class Deployment:
|
|
45
30
|
def __init__(
|
|
46
31
|
self,
|
|
47
|
-
|
|
48
|
-
config: DeploymentConfig,
|
|
49
|
-
base_path: Path,
|
|
50
|
-
deployment_path: Path,
|
|
51
|
-
local: bool = False,
|
|
32
|
+
workflows: dict[str, Workflow],
|
|
52
33
|
) -> None:
|
|
53
34
|
"""Creates a Deployment instance.
|
|
54
35
|
|
|
@@ -57,37 +38,19 @@ class Deployment:
|
|
|
57
38
|
root_path: The path on the filesystem used to store deployment data
|
|
58
39
|
local: Whether the deployment is local. If true, sources won't be synced
|
|
59
40
|
"""
|
|
60
|
-
|
|
61
|
-
self.
|
|
62
|
-
self._base_path = base_path
|
|
63
|
-
# If not local, isolate the deployment in a folder with the same name to avoid conflicts
|
|
64
|
-
self._deployment_path = (
|
|
65
|
-
deployment_path if local else deployment_path / config.name
|
|
66
|
-
)
|
|
67
|
-
self._client = Client()
|
|
68
|
-
self._default_service: str | None = None
|
|
69
|
-
self._running = False
|
|
41
|
+
|
|
42
|
+
self._default_service: str | None = workflows.get(DEFAULT_SERVICE_ID)
|
|
70
43
|
self._service_tasks: list[asyncio.Task] = []
|
|
71
|
-
self._ui_server_process: Process | None = None
|
|
72
44
|
# Ready to load services
|
|
73
|
-
self._workflow_services: dict[str, Workflow] =
|
|
45
|
+
self._workflow_services: dict[str, Workflow] = workflows
|
|
74
46
|
self._contexts: dict[str, Context] = {}
|
|
75
47
|
self._handlers: dict[str, WorkflowHandler] = {}
|
|
76
48
|
self._handler_inputs: dict[str, str] = {}
|
|
77
|
-
self._config = config
|
|
78
|
-
deployment_state.labels(self._name).state("ready")
|
|
79
49
|
|
|
80
50
|
@property
|
|
81
|
-
def default_service(self) ->
|
|
82
|
-
if not self._default_service:
|
|
83
|
-
self._default_service = list(self._workflow_services.keys())[0]
|
|
51
|
+
def default_service(self) -> Workflow | None:
|
|
84
52
|
return self._default_service
|
|
85
53
|
|
|
86
|
-
@property
|
|
87
|
-
def client(self) -> Client:
|
|
88
|
-
"""Returns an async client to interact with this deployment."""
|
|
89
|
-
return self._client
|
|
90
|
-
|
|
91
54
|
@property
|
|
92
55
|
def name(self) -> str:
|
|
93
56
|
"""Returns the name of this deployment."""
|
|
@@ -128,368 +91,63 @@ class Deployment:
|
|
|
128
91
|
self._handler_inputs[handler_id] = json.dumps(run_kwargs)
|
|
129
92
|
return handler_id, session_id
|
|
130
93
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
# UI
|
|
140
|
-
if self._config.ui:
|
|
141
|
-
await self._start_ui_server()
|
|
142
|
-
|
|
143
|
-
async def reload(self, config: DeploymentConfig) -> None:
|
|
144
|
-
# Reset default service, it might change across reloads
|
|
145
|
-
self._default_service = None
|
|
146
|
-
# Tear down the UI server
|
|
147
|
-
self._stop_ui_server()
|
|
148
|
-
# Reload the services
|
|
149
|
-
self._workflow_services = self._load_services(config)
|
|
150
|
-
|
|
151
|
-
# UI
|
|
152
|
-
if self._config.ui:
|
|
153
|
-
await self._start_ui_server()
|
|
154
|
-
|
|
155
|
-
def _stop_ui_server(self) -> None:
|
|
156
|
-
if self._ui_server_process is None:
|
|
157
|
-
return
|
|
158
|
-
|
|
159
|
-
self._ui_server_process.terminate()
|
|
160
|
-
|
|
161
|
-
async def _start_ui_server(self) -> None:
|
|
162
|
-
"""Creates WorkflowService instances according to the configuration object."""
|
|
163
|
-
if not self._config.ui:
|
|
164
|
-
raise ValueError("missing ui configuration settings")
|
|
165
|
-
|
|
166
|
-
source = self._config.ui.source
|
|
167
|
-
if source is None:
|
|
168
|
-
raise ValueError("source must be defined")
|
|
169
|
-
|
|
170
|
-
# Sync the service source
|
|
171
|
-
destination = self._deployment_path.resolve()
|
|
172
|
-
source_manager = SOURCE_MANAGERS[source.type](self._config, self._base_path)
|
|
173
|
-
policy = source.sync_policy or (
|
|
174
|
-
SyncPolicy.SKIP if self._local else SyncPolicy.REPLACE
|
|
175
|
-
)
|
|
176
|
-
source_manager.sync(source.location, str(destination), policy)
|
|
177
|
-
installed_path = destination / source_manager.relative_path(source.location)
|
|
178
|
-
|
|
179
|
-
install = await asyncio.create_subprocess_exec(
|
|
180
|
-
"pnpm", "install", cwd=installed_path
|
|
181
|
-
)
|
|
182
|
-
await install.wait()
|
|
183
|
-
|
|
184
|
-
env = os.environ.copy()
|
|
185
|
-
# TODO - delete me later once templates refactored to not depend on these
|
|
186
|
-
env["LLAMA_DEPLOY_NEXTJS_BASE_PATH"] = f"/deployments/{self._config.name}/ui"
|
|
187
|
-
env["LLAMA_DEPLOY_NEXTJS_DEPLOYMENT_NAME"] = self._config.name
|
|
188
|
-
# END TODO
|
|
189
|
-
# Note! Cloud Llama Deploy also sets a LLAMA_DEPLOY_DEPLOYMENT_NAME, which _must_ be undefined when running locally, otherwise,
|
|
190
|
-
# the UI will make assumptions that it is in a deployed environment. If we configure the templates to check LLAMA_DEPLOY_IS_DEPLOYED instead, then
|
|
191
|
-
# we can just always define LLAMA_DEPLOY_DEPLOYMENT_NAME to keep things simple
|
|
192
|
-
env["LLAMA_DEPLOY_DEPLOYMENT_URL_ID"] = self._config.name
|
|
193
|
-
env["LLAMA_DEPLOY_DEPLOYMENT_BASE_PATH"] = (
|
|
194
|
-
f"/deployments/{self._config.name}/ui"
|
|
195
|
-
)
|
|
196
|
-
# Override PORT and force using the one from the deployment.yaml file
|
|
197
|
-
env["PORT"] = str(self._config.ui.port)
|
|
198
|
-
|
|
199
|
-
self._ui_server_process = await asyncio.create_subprocess_exec(
|
|
200
|
-
"pnpm",
|
|
201
|
-
"run",
|
|
202
|
-
"dev",
|
|
203
|
-
cwd=installed_path,
|
|
204
|
-
env=env,
|
|
205
|
-
)
|
|
206
|
-
|
|
207
|
-
print(f"Started Next.js app with PID {self._ui_server_process.pid}")
|
|
208
|
-
|
|
209
|
-
def _load_services(self, config: DeploymentConfig) -> dict[str, Workflow]:
|
|
210
|
-
"""Creates WorkflowService instances according to the configuration object."""
|
|
211
|
-
deployment_state.labels(self._name).state("loading_services")
|
|
212
|
-
workflow_services = {}
|
|
213
|
-
for service_id, service_config in config.services.items():
|
|
214
|
-
service_state.labels(self._name, service_id).state("loading")
|
|
215
|
-
source = service_config.source
|
|
216
|
-
if source is None:
|
|
217
|
-
# this is a default service, skip for now
|
|
218
|
-
# TODO: check the service name is valid and supported
|
|
219
|
-
# TODO: possibly start the default service if not running already
|
|
220
|
-
continue
|
|
221
|
-
|
|
222
|
-
if service_config.import_path is None:
|
|
223
|
-
msg = "path field in service definition must be set"
|
|
224
|
-
raise ValueError(msg)
|
|
225
|
-
|
|
226
|
-
# Sync the service source
|
|
227
|
-
service_state.labels(self._name, service_id).state("syncing")
|
|
228
|
-
destination = self._deployment_path.resolve()
|
|
229
|
-
source_manager = SOURCE_MANAGERS[source.type](config, self._base_path)
|
|
230
|
-
policy = SyncPolicy.SKIP if self._local else SyncPolicy.REPLACE
|
|
231
|
-
source_manager.sync(source.location, str(destination), policy)
|
|
232
|
-
|
|
233
|
-
# Install dependencies
|
|
234
|
-
service_state.labels(self._name, service_id).state("installing")
|
|
235
|
-
self._install_dependencies(service_config, destination)
|
|
236
|
-
|
|
237
|
-
# Set environment variables
|
|
238
|
-
self._set_environment_variables(service_config, destination)
|
|
239
|
-
|
|
240
|
-
# Search for a workflow instance in the service path
|
|
241
|
-
module_path_str, workflow_name = service_config.import_path.split(":")
|
|
242
|
-
module_path = Path(module_path_str)
|
|
243
|
-
module_name = module_path.name
|
|
244
|
-
pythonpath = (destination / module_path.parent).resolve()
|
|
245
|
-
logger.debug("Extending PYTHONPATH to %s", pythonpath)
|
|
246
|
-
sys.path.append(str(pythonpath))
|
|
247
|
-
|
|
248
|
-
module = importlib.import_module(module_name)
|
|
249
|
-
workflow_services[service_id] = getattr(module, workflow_name)
|
|
250
|
-
|
|
251
|
-
service_state.labels(self._name, service_id).state("ready")
|
|
252
|
-
|
|
253
|
-
if config.default_service:
|
|
254
|
-
if config.default_service in workflow_services:
|
|
255
|
-
self._default_service = config.default_service
|
|
256
|
-
else:
|
|
257
|
-
msg = f"Service with id '{config.default_service}' does not exist, cannot set it as default."
|
|
258
|
-
logger.warning(msg)
|
|
259
|
-
self._default_service = None
|
|
260
|
-
|
|
261
|
-
return workflow_services
|
|
262
|
-
|
|
263
|
-
@staticmethod
|
|
264
|
-
def _validate_path_is_safe(
|
|
265
|
-
path: str, source_root: Path, path_type: str = "path"
|
|
266
|
-
) -> None:
|
|
267
|
-
"""Validates that a path is within the source root to prevent path traversal attacks.
|
|
268
|
-
|
|
269
|
-
Args:
|
|
270
|
-
path: The path to validate
|
|
271
|
-
source_root: The root directory that paths should be relative to
|
|
272
|
-
path_type: Description of the path type for error messages
|
|
273
|
-
|
|
274
|
-
Raises:
|
|
275
|
-
DeploymentError: If the path is outside the source root
|
|
276
|
-
"""
|
|
277
|
-
resolved_path = (source_root / path).resolve()
|
|
278
|
-
resolved_source_root = source_root.resolve()
|
|
279
|
-
|
|
280
|
-
if not resolved_path.is_relative_to(resolved_source_root):
|
|
281
|
-
msg = f"{path_type} {path} is not a subdirectory of the source root {source_root}"
|
|
282
|
-
raise DeploymentError(msg)
|
|
283
|
-
|
|
284
|
-
@staticmethod
|
|
285
|
-
def _set_environment_variables(
|
|
286
|
-
service_config: Service, root: Path | None = None
|
|
287
|
-
) -> None:
|
|
288
|
-
"""Sets environment variables for the service."""
|
|
289
|
-
env_vars: dict[str, str | None] = {}
|
|
290
|
-
|
|
291
|
-
if service_config.env:
|
|
292
|
-
env_vars.update(**service_config.env)
|
|
293
|
-
|
|
294
|
-
if service_config.env_files:
|
|
295
|
-
for env_file in service_config.env_files:
|
|
296
|
-
# use dotenv to parse env_file
|
|
297
|
-
env_file_path = root / env_file if root else Path(env_file)
|
|
298
|
-
env_vars.update(**dotenv_values(env_file_path))
|
|
299
|
-
|
|
300
|
-
for k, v in env_vars.items():
|
|
301
|
-
if v:
|
|
302
|
-
os.environ[k] = v
|
|
303
|
-
|
|
304
|
-
@staticmethod
|
|
305
|
-
def _install_dependencies(service_config: Service, source_root: Path) -> None:
|
|
306
|
-
"""Runs `pip install` on the items listed under `python-dependencies` in the service configuration."""
|
|
307
|
-
if not service_config.python_dependencies:
|
|
308
|
-
return
|
|
309
|
-
install_args = []
|
|
310
|
-
for dep in service_config.python_dependencies or []:
|
|
311
|
-
if dep.endswith("requirements.txt"):
|
|
312
|
-
Deployment._validate_path_is_safe(dep, source_root, "requirements file")
|
|
313
|
-
resolved_dep = source_root / dep
|
|
314
|
-
install_args.extend(["-r", str(resolved_dep)])
|
|
315
|
-
else:
|
|
316
|
-
if "." in dep or "/" in dep:
|
|
317
|
-
Deployment._validate_path_is_safe(
|
|
318
|
-
dep, source_root, "dependency path"
|
|
319
|
-
)
|
|
320
|
-
resolved_dep = source_root / dep
|
|
321
|
-
if os.path.isfile(resolved_dep) or os.path.isdir(resolved_dep):
|
|
322
|
-
# install as editable, such that sources are left in place, and can reference repository files
|
|
323
|
-
install_args.extend(["-e", str(resolved_dep.resolve())])
|
|
324
|
-
else:
|
|
325
|
-
install_args.append(dep)
|
|
326
|
-
else:
|
|
327
|
-
install_args.append(dep)
|
|
328
|
-
|
|
329
|
-
# Check if uv is available on the path
|
|
330
|
-
uv_available = False
|
|
331
|
-
try:
|
|
332
|
-
subprocess.check_call(
|
|
333
|
-
["uv", "--version"],
|
|
334
|
-
stdout=subprocess.DEVNULL,
|
|
335
|
-
stderr=subprocess.DEVNULL,
|
|
94
|
+
def create_workflow_server(
|
|
95
|
+
self, deployment_config: DeploymentConfig, settings: ApiserverSettings
|
|
96
|
+
) -> WorkflowServer:
|
|
97
|
+
persistence = EmptyWorkflowStore()
|
|
98
|
+
if settings.persistence == "local":
|
|
99
|
+
persistence = SqliteWorkflowStore(
|
|
100
|
+
settings.local_persistence_path or "workflows.db"
|
|
336
101
|
)
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
102
|
+
elif settings.persistence == "cloud" or (
|
|
103
|
+
# default to cloud if api key is present to use
|
|
104
|
+
settings.persistence is None and os.getenv("LLAMA_DEPLOY_API_KEY")
|
|
105
|
+
):
|
|
106
|
+
persistence = AgentDataStore(deployment_config, settings)
|
|
107
|
+
server = WorkflowServer(workflow_store=persistence)
|
|
108
|
+
for service_id, workflow in self._workflow_services.items():
|
|
109
|
+
server.add_workflow(service_id, workflow)
|
|
110
|
+
return server
|
|
111
|
+
|
|
112
|
+
def mount_workflow_server(self, app: FastAPI) -> WorkflowServer:
|
|
113
|
+
config = get_deployment_config()
|
|
114
|
+
server = self.create_workflow_server(config, settings)
|
|
115
|
+
|
|
116
|
+
for route in server.app.routes:
|
|
117
|
+
# add routes directly rather than mounting, so that we can share a root (only one ASGI app can be mounted at a path)
|
|
118
|
+
if isinstance(route, Route):
|
|
119
|
+
logger.info(f"Adding route {route.path} to app")
|
|
120
|
+
app.add_api_route(
|
|
121
|
+
f"/deployments/{config.name}{route.path}",
|
|
122
|
+
route.endpoint,
|
|
123
|
+
name=f"{config.name}_{route.name}",
|
|
124
|
+
methods=route.methods,
|
|
125
|
+
include_in_schema=True, # change to false when schemas are added to workflow server
|
|
126
|
+
tags=["workflows"],
|
|
351
127
|
)
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
# the system python
|
|
360
|
-
# https://docs.astral.sh/uv/concepts/projects/config/#project-environment-path
|
|
361
|
-
python_bin_path = os.path.dirname(sys.executable)
|
|
362
|
-
python_parent_dir = os.path.dirname(python_bin_path)
|
|
363
|
-
if install_args:
|
|
364
|
-
try:
|
|
365
|
-
subprocess.check_call(
|
|
366
|
-
[
|
|
367
|
-
"uv",
|
|
368
|
-
"pip",
|
|
369
|
-
"install",
|
|
370
|
-
f"--prefix={python_parent_dir}", # installs to the current python environment
|
|
371
|
-
*install_args,
|
|
372
|
-
],
|
|
373
|
-
cwd=source_root,
|
|
128
|
+
# kludge, temporarily make it accessible to the debugger, which hard codes
|
|
129
|
+
app.add_api_route(
|
|
130
|
+
f"{route.path}",
|
|
131
|
+
route.endpoint,
|
|
132
|
+
name=f"_kludge_{config.name}_{route.name}",
|
|
133
|
+
methods=route.methods,
|
|
134
|
+
include_in_schema=False,
|
|
374
135
|
)
|
|
375
136
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
except subprocess.CalledProcessError as e:
|
|
382
|
-
msg = f"Unable to install service dependencies using command '{e.cmd}': {e.stderr}"
|
|
383
|
-
raise DeploymentError(msg) from None
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
class Manager:
|
|
387
|
-
"""The Manager orchestrates deployments and their runtime.
|
|
388
|
-
|
|
389
|
-
Usage example:
|
|
390
|
-
```python
|
|
391
|
-
config = Config.from_yaml(data_path / "git_service.yaml")
|
|
392
|
-
manager = Manager(tmp_path)
|
|
393
|
-
t = threading.Thread(target=asyncio.run, args=(manager.serve(),))
|
|
394
|
-
t.start()
|
|
395
|
-
manager.deploy(config)
|
|
396
|
-
t.join()
|
|
397
|
-
```
|
|
398
|
-
"""
|
|
399
|
-
|
|
400
|
-
def __init__(self, max_deployments: int = 10) -> None:
|
|
401
|
-
"""Creates a Manager instance.
|
|
402
|
-
|
|
403
|
-
Args:
|
|
404
|
-
max_deployments: The maximum number of deployments supported by this manager.
|
|
405
|
-
"""
|
|
406
|
-
self._deployments: dict[str, Deployment] = {}
|
|
407
|
-
self._deployments_path: Path | None = None
|
|
408
|
-
self._max_deployments = max_deployments
|
|
409
|
-
self._pool = ThreadPool(processes=max_deployments)
|
|
410
|
-
self._last_control_plane_port = 8002
|
|
411
|
-
self._simple_message_queue_server: asyncio.Task | None = None
|
|
412
|
-
self._serving = False
|
|
413
|
-
|
|
414
|
-
@property
|
|
415
|
-
def deployment_names(self) -> list[str]:
|
|
416
|
-
"""Return a list of names for the active deployments."""
|
|
417
|
-
return list(self._deployments.keys())
|
|
418
|
-
|
|
419
|
-
@property
|
|
420
|
-
def deployments_path(self) -> Path:
|
|
421
|
-
if self._deployments_path is None:
|
|
422
|
-
raise ValueError("Deployments path not set")
|
|
423
|
-
return self._deployments_path
|
|
424
|
-
|
|
425
|
-
def set_deployments_path(self, path: Path | None) -> None:
|
|
426
|
-
self._deployments_path = (
|
|
427
|
-
path or Path(tempfile.gettempdir()) / "llama_deploy" / "deployments"
|
|
428
|
-
)
|
|
429
|
-
|
|
430
|
-
def get_deployment(self, deployment_name: str) -> Deployment | None:
|
|
431
|
-
return self._deployments.get(deployment_name)
|
|
432
|
-
|
|
433
|
-
async def serve(self) -> None:
|
|
434
|
-
"""The server loop, it keeps the manager running."""
|
|
435
|
-
if self._deployments_path is None:
|
|
436
|
-
raise RuntimeError("Deployments path not set")
|
|
437
|
-
|
|
438
|
-
self._serving = True
|
|
439
|
-
|
|
440
|
-
event = asyncio.Event()
|
|
441
|
-
try:
|
|
442
|
-
# Waits indefinitely since `event` will never be set
|
|
443
|
-
await event.wait()
|
|
444
|
-
except asyncio.CancelledError:
|
|
445
|
-
if self._simple_message_queue_server is not None:
|
|
446
|
-
self._simple_message_queue_server.cancel()
|
|
447
|
-
await self._simple_message_queue_server
|
|
448
|
-
|
|
449
|
-
async def deploy(
|
|
450
|
-
self,
|
|
451
|
-
config: DeploymentConfig,
|
|
452
|
-
base_path: str,
|
|
453
|
-
reload: bool = False,
|
|
454
|
-
local: bool = False,
|
|
455
|
-
) -> None:
|
|
456
|
-
"""Creates a Deployment instance and starts the relative runtime.
|
|
457
|
-
|
|
458
|
-
Args:
|
|
459
|
-
config: The deployment configuration.
|
|
460
|
-
reload: Reload an existing deployment instead of raising an error.
|
|
461
|
-
local: Deploy a local configuration. Source code will be used in place locally.
|
|
462
|
-
|
|
463
|
-
Raises:
|
|
464
|
-
ValueError: If a deployment with the same name already exists or the maximum number of deployment exceeded.
|
|
465
|
-
DeploymentError: If it wasn't possible to create a deployment.
|
|
466
|
-
"""
|
|
467
|
-
if not self._serving:
|
|
468
|
-
raise RuntimeError("Manager main loop not started, call serve() first.")
|
|
137
|
+
# be defensive since this is external and private
|
|
138
|
+
server_debugger = getattr(server, "_assets_path", None)
|
|
139
|
+
if isinstance(server_debugger, Path):
|
|
140
|
+
app.get(f"/deployments/{config.name}/debugger", include_in_schema=False)
|
|
469
141
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
# Raise an error if we can't create any new deployment
|
|
477
|
-
if len(self._deployments) == self._max_deployments:
|
|
478
|
-
msg = "Reached the maximum number of deployments, cannot schedule more"
|
|
479
|
-
raise ValueError(msg)
|
|
142
|
+
@app.get(f"/deployments/{config.name}/debugger/", include_in_schema=False)
|
|
143
|
+
def redirect_to_debugger() -> RedirectResponse:
|
|
144
|
+
return RedirectResponse(
|
|
145
|
+
f"/deployments/{config.name}/debugger/index.html"
|
|
146
|
+
)
|
|
480
147
|
|
|
481
|
-
|
|
482
|
-
config
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
local=local,
|
|
148
|
+
app.mount(
|
|
149
|
+
f"/deployments/{config.name}/debugger",
|
|
150
|
+
StaticFiles(directory=server_debugger),
|
|
151
|
+
name=f"debugger-{config.name}",
|
|
486
152
|
)
|
|
487
|
-
|
|
488
|
-
await deployment.start()
|
|
489
|
-
else:
|
|
490
|
-
if config.name not in self._deployments:
|
|
491
|
-
msg = f"Cannot find deployment to reload: {config.name}"
|
|
492
|
-
raise ValueError(msg)
|
|
493
|
-
|
|
494
|
-
deployment = self._deployments[config.name]
|
|
495
|
-
await deployment.reload(config)
|
|
153
|
+
return server
|
|
@@ -1,133 +1,15 @@
|
|
|
1
|
-
import
|
|
2
|
-
import warnings
|
|
3
|
-
from enum import Enum
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
from typing import Any, Optional
|
|
1
|
+
import functools
|
|
6
2
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
else: # pragma: no cover
|
|
10
|
-
from typing_extensions import Self
|
|
3
|
+
from llama_deploy.appserver.settings import BootstrapSettings, settings
|
|
4
|
+
from llama_deploy.core.deployment_config import DeploymentConfig, read_deployment_config
|
|
11
5
|
|
|
12
|
-
import yaml
|
|
13
|
-
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
14
6
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
class SyncPolicy(Enum):
|
|
25
|
-
"""Define the sync behaviour in case the destination target exists."""
|
|
26
|
-
|
|
27
|
-
REPLACE = "replace"
|
|
28
|
-
MERGE = "merge"
|
|
29
|
-
SKIP = "skip"
|
|
30
|
-
FAIL = "fail"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
class ServiceSource(BaseModel):
|
|
34
|
-
"""Configuration for the `source` parameter of a service."""
|
|
35
|
-
|
|
36
|
-
type: SourceType
|
|
37
|
-
location: str
|
|
38
|
-
sync_policy: Optional[SyncPolicy] = None
|
|
39
|
-
|
|
40
|
-
@model_validator(mode="before")
|
|
41
|
-
@classmethod
|
|
42
|
-
def handle_deprecated_fields(cls, data: Any) -> Any:
|
|
43
|
-
if isinstance(data, dict):
|
|
44
|
-
if "name" in data and "location" not in data: # pragma: no cover
|
|
45
|
-
warnings.warn(
|
|
46
|
-
"The 'name' field is deprecated. Use 'location' instead.",
|
|
47
|
-
DeprecationWarning,
|
|
48
|
-
)
|
|
49
|
-
data["location"] = data["name"]
|
|
50
|
-
return data
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
class Service(BaseModel):
|
|
54
|
-
"""Configuration for a single service."""
|
|
55
|
-
|
|
56
|
-
name: str
|
|
57
|
-
source: ServiceSource
|
|
58
|
-
import_path: str | None = Field(None)
|
|
59
|
-
host: str | None = None
|
|
60
|
-
port: int | None = None
|
|
61
|
-
env: dict[str, str] | None = Field(None)
|
|
62
|
-
env_files: list[str] | None = Field(None)
|
|
63
|
-
python_dependencies: list[str] | None = Field(None)
|
|
64
|
-
ts_dependencies: dict[str, str] | None = Field(None)
|
|
65
|
-
|
|
66
|
-
@model_validator(mode="before")
|
|
67
|
-
@classmethod
|
|
68
|
-
def validate_fields(cls, data: Any) -> Any:
|
|
69
|
-
if isinstance(data, dict):
|
|
70
|
-
if "path" in data and "import-path" not in data: # pragma: no cover
|
|
71
|
-
warnings.warn(
|
|
72
|
-
"The 'path' field is deprecated. Use 'import-path' instead.",
|
|
73
|
-
DeprecationWarning,
|
|
74
|
-
)
|
|
75
|
-
data["import-path"] = data["path"]
|
|
76
|
-
|
|
77
|
-
# Handle YAML aliases
|
|
78
|
-
if "import-path" in data:
|
|
79
|
-
data["import_path"] = data.pop("import-path")
|
|
80
|
-
if "env-files" in data:
|
|
81
|
-
data["env_files"] = data.pop("env-files")
|
|
82
|
-
if "python-dependencies" in data:
|
|
83
|
-
data["python_dependencies"] = data.pop("python-dependencies")
|
|
84
|
-
if "ts-dependencies" in data:
|
|
85
|
-
data["ts_dependencies"] = data.pop("ts-dependencies")
|
|
86
|
-
|
|
87
|
-
return data
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
class UIService(Service):
|
|
91
|
-
port: int | None = Field(
|
|
92
|
-
default=3000,
|
|
93
|
-
description="The TCP port to use for the nextjs server",
|
|
94
|
-
)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
class DeploymentConfig(BaseModel):
|
|
98
|
-
"""Model definition mapping a deployment config file."""
|
|
99
|
-
|
|
100
|
-
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
|
101
|
-
|
|
102
|
-
name: str
|
|
103
|
-
default_service: str | None = Field(None)
|
|
104
|
-
services: dict[str, Service]
|
|
105
|
-
ui: UIService | None = None
|
|
106
|
-
|
|
107
|
-
@model_validator(mode="before")
|
|
108
|
-
@classmethod
|
|
109
|
-
def validate_fields(cls, data: Any) -> Any:
|
|
110
|
-
# Handle YAML aliases
|
|
111
|
-
if isinstance(data, dict):
|
|
112
|
-
if "control-plane" in data:
|
|
113
|
-
data["control_plane"] = data.pop("control-plane")
|
|
114
|
-
if "message-queue" in data:
|
|
115
|
-
data["message_queue"] = data.pop("message-queue")
|
|
116
|
-
if "default-service" in data:
|
|
117
|
-
data["default_service"] = data.pop("default-service")
|
|
118
|
-
|
|
119
|
-
return data
|
|
120
|
-
|
|
121
|
-
@classmethod
|
|
122
|
-
def from_yaml_bytes(cls, src: bytes) -> Self:
|
|
123
|
-
"""Read config data from bytes containing yaml code."""
|
|
124
|
-
config = yaml.safe_load(src) or {}
|
|
125
|
-
return cls(**config)
|
|
126
|
-
|
|
127
|
-
@classmethod
|
|
128
|
-
def from_yaml(cls, path: Path) -> Self:
|
|
129
|
-
"""Read config data from a yaml file."""
|
|
130
|
-
with open(path, "r") as yaml_file:
|
|
131
|
-
config = yaml.safe_load(yaml_file) or {}
|
|
132
|
-
|
|
133
|
-
return cls(**config)
|
|
7
|
+
@functools.cache
|
|
8
|
+
def get_deployment_config() -> DeploymentConfig:
|
|
9
|
+
base_settings = BootstrapSettings()
|
|
10
|
+
base = settings.app_root.resolve()
|
|
11
|
+
name = base_settings.deployment_name
|
|
12
|
+
parsed = read_deployment_config(base, settings.deployment_file_path)
|
|
13
|
+
if name is not None:
|
|
14
|
+
parsed.name = name
|
|
15
|
+
return parsed
|