doragera commited on
Commit
7e85d64
·
1 Parent(s): 20c3a08

add enterprise folder with progress page backend features

Browse files
lynxkite-enterprise/README.md ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # lynxkite-enterprise
2
+
3
+ Enterprise backend extensions for LynxKite.
lynxkite-enterprise/pyproject.toml ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "lynxkite-enterprise"
3
+ version = "0.3.0"
4
+ description = "Enterprise backend extensions for LynxKite"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "fastapi[standard]>=0.115.6",
9
+ "pycrdt-websocket>=0.16",
10
+ "pycrdt>=0.12.26",
11
+ ]
12
+ license = "AGPL-3.0-or-later"
13
+
14
+ [project.urls]
15
+ Homepage = "https://github.com/lynxkite/lynxkite-2000/"
16
+
17
+ [tool.uv.sources]
18
+ lynxkite-core = { workspace = true }
19
+
20
+ [build-system]
21
+ requires = ["setuptools", "wheel", "setuptools-scm"]
22
+ build-backend = "setuptools.build_meta"
23
+
24
+ [tool.setuptools.packages.find]
25
+ namespaces = true
26
+ where = ["src"]
lynxkite-enterprise/src/lynxkite_enterprise/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Enterprise backend extensions for LynxKite."""
lynxkite-enterprise/src/lynxkite_enterprise/backend.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Enterprise backend routes and background refresh jobs."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import importlib
6
+ from typing import Any
7
+ import fastapi
8
+ import pycrdt.websocket
9
+ from lynxkite_core import workspace
10
+ from . import progress_crdt
11
+
12
+ _k8s_unavailable_logged = False
13
+ _progress_server = None # Set by lifespan_context
14
+
15
+
16
+ def _log_k8s_unavailable_once() -> None:
17
+ global _k8s_unavailable_logged
18
+ if _k8s_unavailable_logged:
19
+ return
20
+ print("Kubernetes package is not installed; GPU service/K8s progress features are disabled.")
21
+ _k8s_unavailable_logged = True
22
+
23
+
24
+ def _k8s_client_module():
25
+ try:
26
+ return importlib.import_module("kubernetes.client")
27
+ except ModuleNotFoundError:
28
+ _log_k8s_unavailable_once()
29
+ return None
30
+
31
+
32
+ def _k8s_config_module():
33
+ try:
34
+ return importlib.import_module("kubernetes.config")
35
+ except ModuleNotFoundError:
36
+ _log_k8s_unavailable_once()
37
+ return None
38
+
39
+
40
+ def _load_k8s_config() -> bool:
41
+ """Load Kubernetes configuration (in-cluster or from kubeconfig)."""
42
+ config = _k8s_config_module()
43
+ if config is None:
44
+ return False
45
+
46
+ try:
47
+ config.load_kube_config()
48
+ except Exception:
49
+ try:
50
+ config.load_incluster_config()
51
+ except Exception:
52
+ return False
53
+ return True
54
+
55
+
56
+ def _get_active_workspaces(ws_server) -> list[tuple[str, workspace.Workspace]]:
57
+ """Return list of (name, workspace) tuples for all active workspaces."""
58
+ active_workspaces = []
59
+ for name, room in getattr(ws_server, "rooms", {}).items():
60
+ try:
61
+ ws = workspace.Workspace.model_validate(room.ws.to_py())
62
+ active_workspaces.append((name, ws))
63
+ except Exception as e:
64
+ print(f"Error loading workspace {name}: {e}")
65
+ return active_workspaces
66
+
67
+
68
+ def _compute_gpu_services(ws_server) -> list[dict[str, Any]]:
69
+ """Build a list of GPU service status dicts from live Kubernetes deployments."""
70
+ client = _k8s_client_module()
71
+ if client is None:
72
+ return []
73
+
74
+ try:
75
+ if not _load_k8s_config():
76
+ return []
77
+ system_namespaces = {"kube-system", "kube-public", "kube-node-lease"}
78
+ active_workspaces = _get_active_workspaces(ws_server)
79
+ gpu_services = []
80
+ for d in client.AppsV1Api().list_deployment_for_all_namespaces().items:
81
+ if (d.metadata.namespace or "") in system_namespaces:
82
+ continue
83
+ labels = dict(d.metadata.labels) if d.metadata.labels else {}
84
+ used_by = [w for w in [labels.get("workspace")] if w]
85
+ for ws_name, ws in active_workspaces:
86
+ for node in getattr(ws, "nodes", []):
87
+ node_name = getattr(node.data, "op_id", None) or getattr(
88
+ node.data, "title", None
89
+ )
90
+ if node_name and node_name == d.metadata.name and ws_name not in used_by:
91
+ used_by.append(ws_name)
92
+ break
93
+ gpu_services.append(
94
+ {
95
+ "publisher": d.metadata.namespace or "",
96
+ "name": d.metadata.name,
97
+ "status": "running" if (d.status.available_replicas or 0) > 0 else "stopped",
98
+ "replicasHealthy": d.status.available_replicas or 0,
99
+ "replicasRequested": d.spec.replicas or 0,
100
+ "usedByWorkspaces": used_by,
101
+ }
102
+ )
103
+ return gpu_services
104
+ except Exception as e:
105
+ print(f"GPU service refresh error: {e}")
106
+ return []
107
+
108
+
109
+ def _get_k8s_workspace_gpus() -> dict[str, int]:
110
+ """Get workspace GPU counts from Kubernetes deployment labels."""
111
+ k8s_workspace_gpus: dict[str, int] = {}
112
+ client = _k8s_client_module()
113
+ if client is None:
114
+ return k8s_workspace_gpus
115
+ try:
116
+ if not _load_k8s_config():
117
+ return k8s_workspace_gpus
118
+ for d in client.AppsV1Api().list_deployment_for_all_namespaces().items:
119
+ labels = dict(d.metadata.labels) if d.metadata.labels else {}
120
+ ws_label = labels.get("workspace")
121
+ if ws_label:
122
+ k8s_workspace_gpus[ws_label] = k8s_workspace_gpus.get(ws_label, 0) + (
123
+ d.spec.replicas or 0
124
+ )
125
+ except Exception as e:
126
+ print(f"K8s GPU info refresh error: {e}")
127
+ return k8s_workspace_gpus
128
+
129
+
130
+ async def _progress_refresh_loop(ws_server):
131
+ """Background task: refresh K8s GPU counts and GPU service data into the progress CRDT doc."""
132
+ while True:
133
+ progress_crdt.update_progress_workspaces(ws_server, _get_k8s_workspace_gpus())
134
+ progress_crdt.update_progress_gpu_services(_compute_gpu_services(ws_server))
135
+ await asyncio.sleep(15)
136
+
137
+
138
+ @contextlib.asynccontextmanager
139
+ async def progress_refresh_context(ws_server):
140
+ task = asyncio.create_task(_progress_refresh_loop(ws_server))
141
+ try:
142
+ yield
143
+ finally:
144
+ task.cancel()
145
+ try:
146
+ await task
147
+ except asyncio.CancelledError:
148
+ pass
149
+
150
+
151
+ @contextlib.asynccontextmanager
152
+ async def lifespan_context(ws_server):
153
+ """Async context manager for enterprise lifecycle: starts the progress WebSocket server
154
+ and the background K8s refresh loop."""
155
+ global _progress_server
156
+ server = progress_crdt.ProgressWebsocketServer(auto_clean_rooms=False)
157
+ _progress_server = server
158
+ async with server:
159
+ await server.get_room("progress")
160
+ progress_crdt.update_progress_workspaces(ws_server)
161
+ progress_crdt.update_progress_gpu_services([])
162
+ async with progress_refresh_context(ws_server):
163
+ yield
164
+ _progress_server = None
165
+
166
+
167
+ def on_workspace_connection_open(room_name: str, ws_server) -> None:
168
+ """Track workspace connection and update progress."""
169
+ progress_crdt.on_workspace_connection_open(room_name, ws_server)
170
+
171
+
172
+ def on_workspace_connection_close(room_name: str, ws_server) -> None:
173
+ """Track workspace disconnection and update progress."""
174
+ progress_crdt.on_workspace_connection_close(room_name, ws_server)
175
+
176
+
177
+ def on_workspace_deleted(name: str) -> None:
178
+ progress_crdt.delete_workspace_entry(name)
179
+
180
+
181
+ def on_workspace_changed(ws_server) -> None:
182
+ """Push an immediate workspace snapshot to the progress CRDT doc."""
183
+ progress_crdt.update_progress_workspaces(ws_server)
184
+
185
+
186
+ def register_routes(app: fastapi.FastAPI, crdt_module) -> None:
187
+ @app.websocket("/ws/progress/crdt/{room_name:path}")
188
+ async def progress_crdt_websocket(websocket: fastapi.WebSocket, room_name: str):
189
+ room_name = crdt_module.sanitize_path(room_name)
190
+ if _progress_server is None:
191
+ await websocket.close(code=1008)
192
+ return
193
+ server = pycrdt.websocket.ASGIServer(_progress_server)
194
+ await server({"path": room_name, "type": "websocket"}, websocket._receive, websocket._send)
195
+
196
+ @app.post("/api/pause_workspace")
197
+ async def pause_workspace(req: dict):
198
+ """Pause or resume a workspace."""
199
+ room_name = req.get("room_name")
200
+ if not isinstance(room_name, str) or not room_name:
201
+ raise fastapi.HTTPException(status_code=400, detail="Missing or invalid room_name")
202
+ room = await crdt_module.get_room(room_name)
203
+ paused = req.get("paused", True)
204
+ with room.ws.doc.transaction():
205
+ room.ws["paused"] = paused
206
+ return {"status": "ok", "room_name": room_name, "paused": paused}
207
+
208
+ @app.post("/api/stop_workspace")
209
+ async def stop_workspace(req: dict):
210
+ """Stop and reset all nodes in a workspace."""
211
+ room_name = req.get("room_name")
212
+ if not isinstance(room_name, str) or not room_name:
213
+ raise fastapi.HTTPException(status_code=400, detail="Missing or invalid room_name")
214
+ room = await crdt_module.get_room(room_name)
215
+ with room.ws.doc.transaction():
216
+ room.ws["paused"] = True
217
+ for node in room.ws["nodes"]:
218
+ node["data"]["status"] = "planned"
219
+ node["data"]["message"] = None
220
+ return {"status": "ok", "room_name": room_name}
221
+
222
+ @app.post("/api/scale_gpu_service")
223
+ async def scale_gpu_service(req: dict):
224
+ """Scale a GPU service deployment to the specified replica count."""
225
+ client = _k8s_client_module()
226
+ if client is None:
227
+ raise fastapi.HTTPException(
228
+ status_code=503,
229
+ detail="Kubernetes integration is unavailable: python package 'kubernetes' is not installed.",
230
+ )
231
+
232
+ name = req.get("name")
233
+ namespace = req.get("namespace") or req.get("publisher")
234
+ replicas = req.get("replicas")
235
+
236
+ print(f"Scaling GPU service {namespace}/{name} to {replicas} replicas")
237
+ if not isinstance(name, str) or not name:
238
+ raise fastapi.HTTPException(status_code=400, detail="Missing or invalid name")
239
+ if not isinstance(namespace, str) or not namespace:
240
+ raise fastapi.HTTPException(status_code=400, detail="Missing or invalid namespace")
241
+ if not isinstance(replicas, int) or replicas < 0:
242
+ raise fastapi.HTTPException(
243
+ status_code=400, detail="Replicas must be a non-negative integer"
244
+ )
245
+
246
+ if not _load_k8s_config():
247
+ raise fastapi.HTTPException(
248
+ status_code=503,
249
+ detail="Kubernetes integration is unavailable: configuration could not be loaded.",
250
+ )
251
+ apps = client.AppsV1Api()
252
+ apps.patch_namespaced_deployment_scale(
253
+ name=name,
254
+ namespace=namespace,
255
+ body={"spec": {"replicas": replicas}},
256
+ )
257
+
258
+ # Push a fresh snapshot right away so the UI updates without waiting for poll loop.
259
+ progress_crdt.update_progress_workspaces(
260
+ crdt_module.ws_websocket_server,
261
+ _get_k8s_workspace_gpus(),
262
+ )
263
+ progress_crdt.update_progress_gpu_services(
264
+ _compute_gpu_services(crdt_module.ws_websocket_server)
265
+ )
266
+
267
+ return {"status": "ok", "name": name, "namespace": namespace, "replicas": replicas}
lynxkite-enterprise/src/lynxkite_enterprise/progress_crdt.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Enterprise CRDT helpers for progress-page data and sockets."""
2
+
3
+ import json
4
+ import pathlib
5
+ import re
6
+ from typing import Any
7
+ import pycrdt
8
+ import pycrdt.websocket
9
+ from lynxkite_core import workspace
10
+
11
+ # Tracks currently open workspace websocket connections by room name.
12
+ _active_workspace_ws_connections: dict[str, int] = {}
13
+
14
+ # Progress CRDT - singleton ephemeral room for the progress page.
15
+ _progress_ydoc: pycrdt.Doc | None = None
16
+
17
+
18
+ def ws_exception_handler(exception, log):
19
+ for ex in exception.exceptions if isinstance(exception, ExceptionGroup) else [exception]:
20
+ log.exception(ex)
21
+ return True
22
+
23
+
24
+ class ProgressWebsocketServer(pycrdt.websocket.WebsocketServer):
25
+ """WebSocket server for the singleton progress CRDT room."""
26
+
27
+ async def init_room(self, name: str) -> pycrdt.websocket.YRoom:
28
+ global _progress_ydoc
29
+ ydoc = pycrdt.Doc()
30
+ ydoc["workspaces"] = pycrdt.Map()
31
+ ydoc["gpu_services"] = pycrdt.Text()
32
+ _progress_ydoc = ydoc
33
+ return pycrdt.websocket.YRoom(ydoc=ydoc, exception_handler=ws_exception_handler)
34
+
35
+ async def get_room(self, name: str) -> pycrdt.websocket.YRoom:
36
+ """Get or initialize a progress room with the expected schema."""
37
+ if name not in self.rooms:
38
+ self.rooms[name] = await self.init_room(name)
39
+ room = self.rooms[name]
40
+ await self.start_room(room)
41
+ return room
42
+
43
+
44
+ def _workspace_display_name(room_name: str) -> str:
45
+ return re.sub(r"[^a-zA-Z0-9]", "-", pathlib.Path(room_name).name.removesuffix(".lynxkite.json"))
46
+
47
+
48
+ def _extract_active_node(nodes: list) -> tuple[dict[str, Any] | None, float | None]:
49
+ for node in nodes:
50
+ if node.data.status != "active":
51
+ continue
52
+ telemetry = node.data.telemetry or {}
53
+ tqdm_n = telemetry.get("n")
54
+ tqdm_total = telemetry.get("total")
55
+ tqdm_rate = telemetry.get("rate")
56
+ active_node = {
57
+ "id": node.id,
58
+ "title": node.data.title,
59
+ "tqdm": {"n": tqdm_n, "total": tqdm_total, "rate": tqdm_rate} if tqdm_total else None,
60
+ }
61
+ eta_seconds = None
62
+ if tqdm_total and tqdm_rate and tqdm_rate > 0 and tqdm_n is not None:
63
+ eta_seconds = (tqdm_total - tqdm_n) / tqdm_rate
64
+ return active_node, eta_seconds
65
+ return None, None
66
+
67
+
68
+ def _workspace_status(*, total: int, done: int, active: int, paused: bool) -> str:
69
+ if total == 0:
70
+ return "idle"
71
+ if done == total:
72
+ return "done"
73
+ return "paused" if paused else ("active" if active > 0 else "running")
74
+
75
+
76
+ def _build_workspace_entry(room_name: str, room, k8s_workspace_gpus: dict[str, int]) -> str:
77
+ display_name = _workspace_display_name(room_name)
78
+ ws = workspace.Workspace.model_validate(room.ws.to_py())
79
+ nodes = ws.nodes or []
80
+ total = len(nodes)
81
+ done = sum(1 for n in nodes if n.data.status == "done")
82
+ active = sum(1 for n in nodes if n.data.status == "active")
83
+ paused = bool(ws.paused)
84
+ active_node, eta_seconds = _extract_active_node(nodes)
85
+ status = _workspace_status(total=total, done=done, active=active, paused=paused)
86
+
87
+ return json.dumps(
88
+ {
89
+ "name": display_name,
90
+ "room_name": room_name,
91
+ "status": status,
92
+ "boxes_done": done,
93
+ "boxes_total": total,
94
+ "active_node": active_node,
95
+ "eta_seconds": eta_seconds,
96
+ "gpus": (ws.execution_options or {}).get("gpus")
97
+ or k8s_workspace_gpus.get(display_name, 0),
98
+ "paused": paused,
99
+ }
100
+ )
101
+
102
+
103
+ def _connected_workspace_rooms(server) -> list[tuple[str, Any]]:
104
+ return [
105
+ (room_name, room)
106
+ for room_name in sorted(_active_workspace_ws_connections)
107
+ if (room := server.rooms.get(room_name)) is not None
108
+ ]
109
+
110
+
111
+ def _update_workspace_ws_connection(room_name: str, delta: int) -> None:
112
+ count = _active_workspace_ws_connections.get(room_name, 0) + delta
113
+ if count <= 0:
114
+ _active_workspace_ws_connections.pop(room_name, None)
115
+ else:
116
+ _active_workspace_ws_connections[room_name] = count
117
+
118
+
119
+ def on_workspace_connection_open(room_name: str, ws_websocket_server) -> None:
120
+ _update_workspace_ws_connection(room_name, +1)
121
+ update_progress_workspaces(ws_websocket_server)
122
+
123
+
124
+ def on_workspace_connection_close(room_name: str, ws_websocket_server) -> None:
125
+ _update_workspace_ws_connection(room_name, -1)
126
+ update_progress_workspaces(ws_websocket_server)
127
+
128
+
129
+ def update_progress_workspaces(ws_websocket_server, k8s_workspace_gpus: dict | None = None):
130
+ """Recompute workspace status entries and push them into the progress CRDT doc."""
131
+ if _progress_ydoc is None or not hasattr(ws_websocket_server, "rooms"):
132
+ return
133
+ k8s_workspace_gpus = k8s_workspace_gpus or {}
134
+ ws_map: pycrdt.Map = _progress_ydoc["workspaces"]
135
+ connected_rooms = _connected_workspace_rooms(ws_websocket_server)
136
+ entries_by_room: dict[str, str] = {}
137
+ for room_name, room in connected_rooms:
138
+ try:
139
+ entries_by_room[room_name] = _build_workspace_entry(room_name, room, k8s_workspace_gpus)
140
+ except Exception as e:
141
+ print(f"Error updating progress for workspace {room_name}: {e}")
142
+
143
+ # Sync the map in one transaction to avoid transient partial snapshots.
144
+ with _progress_ydoc.transaction():
145
+ for room_name, entry in entries_by_room.items():
146
+ ws_map[room_name] = entry
147
+ for name in list(ws_map.keys()):
148
+ if name not in entries_by_room:
149
+ del ws_map[name]
150
+
151
+
152
+ def update_progress_gpu_services(gpu_services_data: list):
153
+ """Update the GPU services entry in the progress CRDT doc with fresh Kubernetes data."""
154
+ if _progress_ydoc is None:
155
+ return
156
+ gpu_services_text: pycrdt.Text = _progress_ydoc["gpu_services"]
157
+ new_content = json.dumps(gpu_services_data)
158
+ if str(gpu_services_text) == new_content:
159
+ return
160
+ with _progress_ydoc.transaction():
161
+ if len(gpu_services_text) > 0:
162
+ del gpu_services_text[0 : len(gpu_services_text)]
163
+ gpu_services_text += new_content
164
+
165
+
166
+ def delete_workspace_entry(name: str) -> None:
167
+ _active_workspace_ws_connections.pop(name, None)
168
+ if _progress_ydoc is None:
169
+ return
170
+ ws_map: pycrdt.Map = _progress_ydoc["workspaces"]
171
+ if name in ws_map:
172
+ with _progress_ydoc.transaction():
173
+ del ws_map[name]
ruff.toml CHANGED
@@ -1 +1,2 @@
1
  line-length = 100
 
 
1
  line-length = 100
2
+ target-version = "py311"