-
Notifications
You must be signed in to change notification settings - Fork 2.2k
omni boxes #293
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
omni boxes #293
Changes from all commits
c9747ee
05c417f
0303685
cdd9e0c
040acb8
9d99d5f
8de62e0
8e90ce0
9993859
33df388
e37a0c5
be00f11
0a9b20d
9d5b728
f69e20b
8db3034
e8a00c4
28a1522
ca1bc7b
3b16921
39bf238
d34b311
2dba2dc
9c84ba9
0b40941
3246267
5d9da46
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| { | ||
| "cells": [ | ||
| { | ||
| "cell_type": "markdown", | ||
| "id": "578457c0", | ||
| "metadata": {}, | ||
| "source": [ | ||
| "# Test instance" | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "code", | ||
| "execution_count": null, | ||
| "id": "ae7b2936", | ||
| "metadata": {}, | ||
| "outputs": [], | ||
| "source": [ | ||
| "import client\n", | ||
| "client.InstanceClient(port = 5000).ui()" | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "markdown", | ||
| "id": "741598f1", | ||
| "metadata": {}, | ||
| "source": [ | ||
| "# Test node" | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "code", | ||
| "execution_count": null, | ||
| "id": "eca33f8c", | ||
| "metadata": {}, | ||
| "outputs": [], | ||
| "source": [ | ||
| "import client\n", | ||
| "c = client.NodeClient(port = 8000)\n", | ||
| "c.ui()" | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "markdown", | ||
| "id": "c2b1810b", | ||
| "metadata": {}, | ||
| "source": [ | ||
| "# Test master node" | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "code", | ||
| "execution_count": null, | ||
| "id": "bf6188a0", | ||
| "metadata": {}, | ||
| "outputs": [], | ||
| "source": [ | ||
| "import client\n", | ||
| "c = client.MasterClient(port = 7000)\n", | ||
| "c.ui()" | ||
| ] | ||
| } | ||
| ], | ||
| "metadata": { | ||
| "kernelspec": { | ||
| "display_name": "omnibox", | ||
| "language": "python", | ||
| "name": "python3" | ||
| }, | ||
| "language_info": { | ||
| "codemirror_mode": { | ||
| "name": "ipython", | ||
| "version": 3 | ||
| }, | ||
| "file_extension": ".py", | ||
| "mimetype": "text/x-python", | ||
| "name": "python", | ||
| "nbconvert_exporter": "python", | ||
| "pygments_lexer": "ipython3", | ||
| "version": "3.12.9" | ||
| } | ||
| }, | ||
| "nbformat": 4, | ||
| "nbformat_minor": 5 | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| pillow | ||
| ipywidgets | ||
| requests |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import logging | ||
|
|
||
| def default_logger(): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. remove - duplicated in node_manager.py |
||
| logging.basicConfig( | ||
| level=logging.INFO, | ||
| format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", | ||
| ) | ||
| return logging.getLogger("omnibox-master") | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| import asyncio | ||
| import httpx | ||
| import uvicorn | ||
| import logging | ||
| from fastapi import FastAPI, HTTPException, status, Response | ||
| from pydantic import BaseModel, Field | ||
| from typing import Dict, List, Optional, Any | ||
| import logging_utils | ||
|
|
||
|
|
||
| class NodeRegistration(BaseModel): | ||
| url: str | ||
|
|
||
|
|
||
| class NodeStatus(BaseModel): | ||
| url: str | ||
| hash: str | ||
| healthy: bool | ||
| capacity: int | ||
| available: int | ||
| instances: List[str] | ||
|
|
||
| @staticmethod | ||
| def failed(url): | ||
| return NodeStatus(url=url, hash=url_hash(url), healthy=False, capacity=0, available=0, instances=[]) | ||
|
|
||
|
|
||
| def url_hash(url: str) -> str: | ||
| return url | ||
|
|
||
|
|
||
| def _default_logger(): | ||
| logging.basicConfig( | ||
| level=logging.INFO, | ||
| format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", | ||
| ) | ||
| return logging.getLogger("omnibox-master") | ||
|
|
||
|
|
||
| class NodeManager: | ||
| def __init__(self, logger = None, update_timeout: int = 10): | ||
| self._nodes = {} | ||
| self._node_info = {} | ||
| self.logger = logger or _default_logger() | ||
| self.update_timeout = update_timeout | ||
|
|
||
| async def _get_status(self, node_url): | ||
| try: | ||
| async with httpx.AsyncClient(timeout=5.0) as client: | ||
| response = await client.get(f"{node_url}/info") | ||
| if response.status_code != 200: | ||
| return NodeStatus.failed(node_url) | ||
| data = response.json() | ||
| return NodeStatus( | ||
| url = node_url, | ||
| hash = url_hash(node_url), | ||
| healthy = True, | ||
| capacity = data.get("capacity", 0), | ||
| available = data.get("available", 0), | ||
| instances = data.get("in_use", []) | ||
| ) | ||
| except Exception as e: | ||
| self.logger.warning(f"Node {node_url} health check failed: {str(e)}") | ||
| return NodeStatus.failed(node_url) | ||
|
|
||
| async def update_statuses(self): | ||
| for node_url in self._nodes.keys(): | ||
| self._node_info[node_url] = await self._get_status(node_url) | ||
|
|
||
| async def update_statuses_worker(self): | ||
| while True: | ||
| await self.update_statuses() | ||
| await asyncio.sleep(self.update_timeout) | ||
|
|
||
| async def register_node(self, node: NodeRegistration): | ||
| self._nodes[url_hash(node.url)] = node | ||
| self._node_info[url_hash(node.url)] = await self._get_status(node.url) | ||
|
|
||
| async def unregister_node(self, node_url: str): | ||
| """Unregister a worker node from the master""" | ||
| node_hash = url_hash(node_url) | ||
| if node_hash not in self._nodes: | ||
| self.logger.warning(f"Node {node_url} not found for unregistration") | ||
| return False | ||
|
|
||
| self._nodes.pop(node_hash, None) | ||
| self._node_info.pop(node_hash, None) | ||
| return True | ||
|
|
||
| def get_best_node(self) -> Optional[str]: | ||
| result = None | ||
| available = 0 | ||
| for node in self._node_info.values(): | ||
| if node.healthy and node.available > available: | ||
| result = node | ||
| available = node.available | ||
| return result | ||
|
|
||
| def get_node(self, hash: str) -> Optional[NodeStatus]: | ||
| return self._node_info.get(hash, None) | ||
|
|
||
| def node_info(self): | ||
| return self._node_info | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,147 @@ | ||
| import asyncio | ||
| import httpx | ||
| import uvicorn | ||
| import logging | ||
| from fastapi import FastAPI, HTTPException, status, Response | ||
| from pydantic import BaseModel, Field | ||
| from typing import Dict, List, Optional, Any | ||
| from contextlib import asynccontextmanager | ||
| from fastapi.responses import JSONResponse | ||
| from node_manager import NodeManager, NodeRegistration | ||
| import requests | ||
| import argparse | ||
|
|
||
| # Configure logging | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. NodeManager will make its own logger if not passed in so this can be removed |
||
| logging.basicConfig( | ||
| level=logging.INFO, | ||
| format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", | ||
| ) | ||
| logger = logging.getLogger("omnibox-master") | ||
|
|
||
| node_manager = NodeManager(logger = logger) | ||
|
|
||
| @asynccontextmanager | ||
| async def lifespan(app: FastAPI): | ||
| await node_manager.register_node(NodeRegistration(url = "http://localhost:8000")) | ||
| # await node_manager.register_node(NodeRegistration(url = "http://localhost:8001")) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove commented out |
||
| tasks = asyncio.create_task(node_manager.update_statuses_worker()) | ||
| yield | ||
| # Shutdown code goes here | ||
|
|
||
| app = FastAPI( | ||
| title="OmniBox Master Node", | ||
| description="Manages redirection of instance operations to worker nodes", | ||
| lifespan = lifespan) | ||
|
|
||
| @app.post("/get") | ||
| def create_instance(): | ||
| """Get an available new instance from less occupied node""" | ||
| node = node_manager.get_best_node() | ||
| if node is None: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_503_SERVICE_UNAVAILABLE, | ||
| detail="No available nodes with capacity to create new instance" | ||
| ) | ||
|
|
||
| data = requests.post(f'{node.url}/get').json() | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. node_manager should handle this post to |
||
| if 'instance_id' in data: | ||
| return { | ||
| 'instance_id': data['instance_id'], | ||
| 'node': node.hash, | ||
| } | ||
| raise HTTPException( | ||
| status_code=status.HTTP_503_SERVICE_UNAVAILABLE, | ||
| detail="No available nodes with capacity to create new instance" | ||
| ) | ||
|
|
||
| @app.post("/reset") | ||
| async def reset(instance_id: str, node: str): | ||
| """Reset an existing instance by delegating to the worker node that hosts it""" | ||
| node_info = node_manager.get_node(node) | ||
| if node_info is None: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_400_BAD_REQUEST, | ||
| detail=f"Node {node} is not found" | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All calls to requests should occur in node_manager to keep this server file focused on handling returned status codes |
||
| ) | ||
| response = requests.post(f'{node_info.url}/reset', params={"instance_id": instance_id}) | ||
| return JSONResponse(content=response.json(), status_code=response.status_code) | ||
|
|
||
|
|
||
| @app.get("/probe") | ||
| async def probe(instance_id: str, node: str): | ||
| """Probe the instance by delegating to the worker node that hosts it""" | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This gets called often so could be refactored into a private function? |
||
| node_info = node_manager.get_node(node) | ||
| if node_info is None: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_400_BAD_REQUEST, | ||
| detail=f"Node {node} is not found" | ||
| ) | ||
| response = requests.get(f'{node_info.url}/probe', params={"instance_id": instance_id}) | ||
| return JSONResponse(content=response.json(), status_code=response.status_code) | ||
|
|
||
| @app.get("/screenshot") | ||
| async def screenshot(instance_id: str, node: str): | ||
| """Make a screenshot of an existing instance by delegating to the worker node that hosts it""" | ||
| node_info = node_manager.get_node(node) | ||
| if node_info is None: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_400_BAD_REQUEST, | ||
| detail=f"Node {node} is not found" | ||
| ) | ||
| response = requests.get(f'{node_info.url}/screenshot', params={"instance_id": instance_id}) | ||
| if response.status_code == 200: | ||
| return Response(content=response.content, media_type="image/png") | ||
|
|
||
| return JSONResponse( | ||
| content={"status": "error", "message": f"Failed to get screenshot: {response.text}"}, | ||
| status_code=response.status_code | ||
| ) | ||
|
|
||
| @app.post("/execute") | ||
| async def execute(command_data: Dict[str, Any]): | ||
| """Forward execute command to the Flask server in the specified instance""" | ||
| node = command_data.pop('node') | ||
| instance_id = command_data.pop('instance_id') | ||
| node_info = node_manager.get_node(node) | ||
| if node_info is None: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_400_BAD_REQUEST, | ||
| detail=f"Node {node} is not found" | ||
| ) | ||
| response = requests.post(f'{node_info.url}/execute', | ||
| params={"instance_id": instance_id}, | ||
| json = command_data) | ||
| if response.status_code == 200: | ||
| return JSONResponse(content=response.json(), status_code=response.status_code) | ||
|
|
||
| return JSONResponse( | ||
| content={"status": "error", "message": f"Failed to execute command: {response.text}"}, | ||
| status_code=response.status_code | ||
| ) | ||
|
|
||
| @app.get("/info") | ||
| def get_info(): | ||
| node_info = node_manager.node_info() | ||
| return JSONResponse( | ||
| content={ | ||
| "nodes": [ | ||
| { | ||
| "url": node.url, | ||
| "hash": node.hash, | ||
| "healthy": node.healthy, | ||
| "capacity": node.capacity, | ||
| "available": node.available, | ||
| "instances": node.instances | ||
| } | ||
| for node in node_info.values() | ||
| ] | ||
| }, | ||
| status_code=status.HTTP_200_OK | ||
| ) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| parser = argparse.ArgumentParser(description="OmniBox Host") | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Master Omniboxes node |
||
| parser.add_argument("--port", type=int, default=7000, help="Port to run the server on") | ||
| args = parser.parse_args() | ||
| uvicorn.run(app, host="0.0.0.0", port=args.port) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To align with Kubernetes terminology rename folder from |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can this be notebook be moved into a separate environment_management folder or sth similar?