backend.ai-appproxy-worker 25.19.1__tar.gz
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.
- backend_ai_appproxy_worker-25.19.1/MANIFEST.in +1 -0
- backend_ai_appproxy_worker-25.19.1/PKG-INFO +164 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/VERSION +1 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/__init__.py +3 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/api/health.py +78 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/api/setup.py +229 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/cli/__main__.py +144 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/cli/context.py +36 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/cli/dependencies.py +71 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/cli/health.py +202 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/cli/start_server.py +60 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/config.py +422 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/coordinator_client.py +225 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/dependencies/__init__.py +9 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/dependencies/bootstrap/__init__.py +9 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/dependencies/bootstrap/composer.py +56 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/dependencies/bootstrap/config.py +38 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/dependencies/composer.py +56 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/dependencies/infrastructure/__init__.py +11 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/dependencies/infrastructure/composer.py +39 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/dependencies/infrastructure/redis.py +75 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/errors/__init__.py +38 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/errors/circuit.py +55 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/errors/config.py +97 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/errors/process.py +41 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/metrics.py +421 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/backend/__init__.py +9 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/backend/base.py +57 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/backend/h2.py +29 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/backend/http.py +333 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/backend/tcp.py +111 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/backend/traefik.py +14 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/frontend/__init__.py +17 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/frontend/base.py +97 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/frontend/h2/base.py +44 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/frontend/h2/port.py +107 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/frontend/h2/subdomain.py +103 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/frontend/http/base.py +165 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/frontend/http/port.py +106 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/frontend/http/subdomain.py +103 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/frontend/tcp.py +133 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/frontend/traefik.py +185 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/py.typed +1 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/server.py +1028 -0
- backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/types.py +650 -0
- backend_ai_appproxy_worker-25.19.1/backend.ai_appproxy_worker.egg-info/PKG-INFO +164 -0
- backend_ai_appproxy_worker-25.19.1/backend.ai_appproxy_worker.egg-info/SOURCES.txt +54 -0
- backend_ai_appproxy_worker-25.19.1/backend.ai_appproxy_worker.egg-info/dependency_links.txt +1 -0
- backend_ai_appproxy_worker-25.19.1/backend.ai_appproxy_worker.egg-info/entry_points.txt +3 -0
- backend_ai_appproxy_worker-25.19.1/backend.ai_appproxy_worker.egg-info/namespace_packages.txt +1 -0
- backend_ai_appproxy_worker-25.19.1/backend.ai_appproxy_worker.egg-info/not-zip-safe +1 -0
- backend_ai_appproxy_worker-25.19.1/backend.ai_appproxy_worker.egg-info/requires.txt +26 -0
- backend_ai_appproxy_worker-25.19.1/backend.ai_appproxy_worker.egg-info/top_level.txt +1 -0
- backend_ai_appproxy_worker-25.19.1/backend_shim.py +31 -0
- backend_ai_appproxy_worker-25.19.1/setup.cfg +4 -0
- backend_ai_appproxy_worker-25.19.1/setup.py +199 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
include *.py
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: backend.ai-appproxy-worker
|
|
3
|
+
Version: 25.19.1
|
|
4
|
+
Summary: Backend.AI AppProxy Worker
|
|
5
|
+
Home-page: https://github.com/lablup/backend.ai
|
|
6
|
+
Author: Lablup Inc. and contributors
|
|
7
|
+
License: LGPLv3
|
|
8
|
+
Project-URL: Documentation, https://docs.backend.ai/
|
|
9
|
+
Project-URL: Source, https://github.com/lablup/backend.ai
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Programming Language :: Python
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Environment :: No Input/Output (Daemon)
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering
|
|
17
|
+
Classifier: Topic :: Software Development
|
|
18
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)
|
|
21
|
+
Requires-Python: >=3.13,<3.14
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: Jinja2~=3.1.6
|
|
24
|
+
Requires-Dist: PyJWT~=2.10.1
|
|
25
|
+
Requires-Dist: aiohttp_cors~=0.8.1
|
|
26
|
+
Requires-Dist: aiohttp_jinja2~=1.6
|
|
27
|
+
Requires-Dist: aiohttp~=3.13.0
|
|
28
|
+
Requires-Dist: aiomonitor~=0.7.0
|
|
29
|
+
Requires-Dist: aiotools~=2.2.3
|
|
30
|
+
Requires-Dist: attrs>=25.3
|
|
31
|
+
Requires-Dist: backend.ai-appproxy-common==25.19.1
|
|
32
|
+
Requires-Dist: backend.ai-common==25.19.1
|
|
33
|
+
Requires-Dist: backend.ai-logging==25.19.1
|
|
34
|
+
Requires-Dist: backend.ai-plugin==25.19.1
|
|
35
|
+
Requires-Dist: click~=8.1.7
|
|
36
|
+
Requires-Dist: memray~=1.17.2
|
|
37
|
+
Requires-Dist: multidict~=6.6.4
|
|
38
|
+
Requires-Dist: prometheus-client~=0.21.1
|
|
39
|
+
Requires-Dist: pydantic[email]~=2.11.3
|
|
40
|
+
Requires-Dist: pyroscope-io~=0.8.8
|
|
41
|
+
Requires-Dist: setproctitle~=1.3.5
|
|
42
|
+
Requires-Dist: tenacity>=9.0
|
|
43
|
+
Requires-Dist: tomli-w~=1.2.0
|
|
44
|
+
Requires-Dist: types-Jinja2
|
|
45
|
+
Requires-Dist: uvloop~=0.22.1; sys_platform != "Windows"
|
|
46
|
+
Requires-Dist: yarl~=1.19.0
|
|
47
|
+
Dynamic: author
|
|
48
|
+
Dynamic: classifier
|
|
49
|
+
Dynamic: description
|
|
50
|
+
Dynamic: description-content-type
|
|
51
|
+
Dynamic: home-page
|
|
52
|
+
Dynamic: license
|
|
53
|
+
Dynamic: project-url
|
|
54
|
+
Dynamic: requires-dist
|
|
55
|
+
Dynamic: requires-python
|
|
56
|
+
Dynamic: summary
|
|
57
|
+
|
|
58
|
+
# Backend.AI App Proxy Worker
|
|
59
|
+
|
|
60
|
+
## Purpose
|
|
61
|
+
|
|
62
|
+
The App Proxy Worker is a high-performance reverse proxy that routes user traffic to compute session services (Jupyter, SSH, TensorBoard, etc.) running on agents. It receives routing information from the Coordinator and handles SSL/TLS termination, load balancing, and traffic forwarding.
|
|
63
|
+
|
|
64
|
+
## Key Responsibilities
|
|
65
|
+
|
|
66
|
+
### 1. Traffic Proxying
|
|
67
|
+
- Proxy HTTP/HTTPS requests to session services
|
|
68
|
+
- Proxy WebSocket connections for interactive services
|
|
69
|
+
- Handle SSL/TLS termination
|
|
70
|
+
- Stream responses efficiently
|
|
71
|
+
|
|
72
|
+
### 2. Route Resolution
|
|
73
|
+
- Receive routing tables from Coordinator
|
|
74
|
+
- Resolve session services from URLs
|
|
75
|
+
- Cache routing information locally
|
|
76
|
+
- Update routes dynamically
|
|
77
|
+
|
|
78
|
+
### 3. Health Checking
|
|
79
|
+
- Monitor backend service health
|
|
80
|
+
- Detect failed services
|
|
81
|
+
- Report health status to Coordinator
|
|
82
|
+
- Handle service failover
|
|
83
|
+
|
|
84
|
+
## Architecture
|
|
85
|
+
|
|
86
|
+
### 1. Traffic Proxy (Main)
|
|
87
|
+
|
|
88
|
+
**Framework**: aiohttp + custom reverse proxy
|
|
89
|
+
|
|
90
|
+
**Port**: 5050 (default, HTTPS)
|
|
91
|
+
|
|
92
|
+
**Protocol**: HTTP/HTTPS, WebSocket
|
|
93
|
+
|
|
94
|
+
**Key Features**:
|
|
95
|
+
|
|
96
|
+
#### HTTP/HTTPS Proxy
|
|
97
|
+
- Route user requests to session services
|
|
98
|
+
- URL Pattern: `https://<worker-domain>/<session-id>/<service-name>/...`
|
|
99
|
+
|
|
100
|
+
#### WebSocket Proxy
|
|
101
|
+
- Interactive service communication (Jupyter Kernel, SSH, etc.)
|
|
102
|
+
- Real-time log streaming
|
|
103
|
+
|
|
104
|
+
**Key Characteristics**:
|
|
105
|
+
- SSL/TLS termination (Let's Encrypt auto-certificate)
|
|
106
|
+
- High-performance async proxy
|
|
107
|
+
- Connection pooling and reuse
|
|
108
|
+
- Streaming support (large file downloads)
|
|
109
|
+
- Sticky session support
|
|
110
|
+
- Auto-retry and failover
|
|
111
|
+
|
|
112
|
+
**Processing Flow**:
|
|
113
|
+
|
|
114
|
+
#### HTTP Proxy Flow
|
|
115
|
+
```
|
|
116
|
+
User → HTTPS Request → Worker (SSL termination)
|
|
117
|
+
↓
|
|
118
|
+
Parse URL (extract session_id, service_name)
|
|
119
|
+
↓
|
|
120
|
+
Lookup route from local cache
|
|
121
|
+
↓
|
|
122
|
+
Resolve backend address (agent:port)
|
|
123
|
+
↓
|
|
124
|
+
Proxy request to agent
|
|
125
|
+
↓
|
|
126
|
+
Stream response back to user
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
#### WebSocket Proxy Flow
|
|
130
|
+
```
|
|
131
|
+
User → WS Upgrade Request → Worker
|
|
132
|
+
↓
|
|
133
|
+
Establish WS connection to agent
|
|
134
|
+
↓
|
|
135
|
+
Bidirectional message forwarding
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### 2. REST API (Management)
|
|
139
|
+
|
|
140
|
+
**Framework**: aiohttp (async HTTP server)
|
|
141
|
+
|
|
142
|
+
**Port**: 6040 (default, separate management port)
|
|
143
|
+
|
|
144
|
+
**Key Features**:
|
|
145
|
+
- Communication with Coordinator
|
|
146
|
+
- Health check endpoints
|
|
147
|
+
- Metrics exposure (Prometheus)
|
|
148
|
+
- Internal management (no external access)
|
|
149
|
+
|
|
150
|
+
### Component Interaction
|
|
151
|
+
|
|
152
|
+
**Traffic Proxy Flow**:
|
|
153
|
+
```
|
|
154
|
+
User (Browser) → Worker (Port 5050) → Kernel (on Agent)
|
|
155
|
+
│
|
|
156
|
+
├─ SSL/TLS termination
|
|
157
|
+
├─ Route resolution
|
|
158
|
+
└─ Traffic proxying
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
**Management Flow**:
|
|
162
|
+
```
|
|
163
|
+
Coordinator → Worker REST API (Port 6040) → Route updates
|
|
164
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
25.19.1
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from typing import Iterable
|
|
2
|
+
|
|
3
|
+
import aiohttp_cors
|
|
4
|
+
from aiohttp import web
|
|
5
|
+
|
|
6
|
+
from ai.backend.appproxy.common.types import CORSOptions, FrontendMode, WebMiddleware
|
|
7
|
+
|
|
8
|
+
from .. import __version__
|
|
9
|
+
from ..errors import MissingPortProxyConfigError
|
|
10
|
+
from ..types import RootContext
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def hello(request: web.Request) -> web.Response:
|
|
14
|
+
"""Health check endpoint with dependency connectivity status"""
|
|
15
|
+
from ai.backend.common.dto.internal.health import HealthResponse, HealthStatus
|
|
16
|
+
|
|
17
|
+
request["do_not_print_access_log"] = True
|
|
18
|
+
|
|
19
|
+
root_ctx: RootContext = request.app["_root.context"]
|
|
20
|
+
connectivity = await root_ctx.health_probe.get_connectivity_status()
|
|
21
|
+
response = HealthResponse(
|
|
22
|
+
status=HealthStatus.OK if connectivity.overall_healthy else HealthStatus.DEGRADED,
|
|
23
|
+
version=__version__,
|
|
24
|
+
component="appproxy-worker",
|
|
25
|
+
connectivity=connectivity,
|
|
26
|
+
)
|
|
27
|
+
return web.json_response(response.model_dump_json())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def status(request: web.Request) -> web.Response:
|
|
31
|
+
"""
|
|
32
|
+
Returns health status of worker.
|
|
33
|
+
"""
|
|
34
|
+
request["do_not_print_access_log"] = True
|
|
35
|
+
|
|
36
|
+
root_ctx: RootContext = request.app["_root.context"]
|
|
37
|
+
worker_config = root_ctx.local_config.proxy_worker
|
|
38
|
+
if worker_config.frontend_mode == FrontendMode.WILDCARD_DOMAIN:
|
|
39
|
+
available_slots = 0
|
|
40
|
+
else:
|
|
41
|
+
if worker_config.port_proxy is None:
|
|
42
|
+
raise MissingPortProxyConfigError("Port proxy configuration is required for PORT mode")
|
|
43
|
+
available_slots = (
|
|
44
|
+
worker_config.port_proxy.bind_port_range[1]
|
|
45
|
+
- worker_config.port_proxy.bind_port_range[0]
|
|
46
|
+
+ 1
|
|
47
|
+
)
|
|
48
|
+
return web.json_response({
|
|
49
|
+
"version": __version__,
|
|
50
|
+
"authority": worker_config.authority,
|
|
51
|
+
"app_mode": worker_config.frontend_mode,
|
|
52
|
+
"protocol": worker_config.protocol,
|
|
53
|
+
"occupied_slots": len(root_ctx.proxy_frontend.circuits),
|
|
54
|
+
"available_slots": available_slots,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def init(app: web.Application) -> None:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def shutdown(app: web.Application) -> None:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def create_app(
|
|
67
|
+
default_cors_options: CORSOptions,
|
|
68
|
+
) -> tuple[web.Application, Iterable[WebMiddleware]]:
|
|
69
|
+
app = web.Application()
|
|
70
|
+
app["prefix"] = "health"
|
|
71
|
+
app.on_startup.append(init)
|
|
72
|
+
app.on_shutdown.append(shutdown)
|
|
73
|
+
cors = aiohttp_cors.setup(app, defaults=default_cors_options)
|
|
74
|
+
add_route = app.router.add_route
|
|
75
|
+
root_resource = cors.add(app.router.add_resource(r""))
|
|
76
|
+
cors.add(root_resource.add_route("GET", hello))
|
|
77
|
+
cors.add(add_route("GET", "/status", status))
|
|
78
|
+
return app, []
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import urllib.parse
|
|
2
|
+
from typing import Iterable
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
import aiohttp
|
|
6
|
+
import jwt
|
|
7
|
+
import yarl
|
|
8
|
+
from aiohttp import web
|
|
9
|
+
from pydantic import AnyUrl, BaseModel
|
|
10
|
+
from tenacity import AsyncRetrying, TryAgain, retry_if_exception_type, wait_exponential
|
|
11
|
+
from tenacity.stop import stop_after_attempt
|
|
12
|
+
|
|
13
|
+
from ai.backend.appproxy.common.defs import PERMIT_COOKIE_NAME
|
|
14
|
+
from ai.backend.appproxy.common.errors import (
|
|
15
|
+
InvalidAPIParameters,
|
|
16
|
+
ServerMisconfiguredError,
|
|
17
|
+
)
|
|
18
|
+
from ai.backend.appproxy.common.types import (
|
|
19
|
+
CORSOptions,
|
|
20
|
+
FrontendMode,
|
|
21
|
+
ProxyProtocol,
|
|
22
|
+
PydanticResponse,
|
|
23
|
+
WebMiddleware,
|
|
24
|
+
)
|
|
25
|
+
from ai.backend.appproxy.common.types import SerializableCircuit as Circuit
|
|
26
|
+
from ai.backend.appproxy.common.utils import calculate_permit_hash, pydantic_api_handler
|
|
27
|
+
|
|
28
|
+
from ..config import (
|
|
29
|
+
PortProxyConfig,
|
|
30
|
+
TraefikPortProxyConfig,
|
|
31
|
+
TraefikWildcardDomainConfig,
|
|
32
|
+
WildcardDomainConfig,
|
|
33
|
+
)
|
|
34
|
+
from ..coordinator_client import get_circuit_info
|
|
35
|
+
from ..errors import MissingPortConfigError
|
|
36
|
+
from ..types import FrontendServerMode, InteractiveAppInfo, RootContext
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def generate_proxy_url(
|
|
40
|
+
config: PortProxyConfig
|
|
41
|
+
| WildcardDomainConfig
|
|
42
|
+
| TraefikPortProxyConfig
|
|
43
|
+
| TraefikWildcardDomainConfig,
|
|
44
|
+
protocol: str,
|
|
45
|
+
circuit: Circuit,
|
|
46
|
+
redirect_path: str | None = None,
|
|
47
|
+
) -> str:
|
|
48
|
+
# Generate base URL based on config type
|
|
49
|
+
match config:
|
|
50
|
+
case PortProxyConfig():
|
|
51
|
+
base_url = f"{protocol}://{config.advertised_host or config.bind_host}:{circuit.port}"
|
|
52
|
+
case TraefikPortProxyConfig():
|
|
53
|
+
base_url = f"{protocol}://{config.advertised_host}:{circuit.port}"
|
|
54
|
+
case WildcardDomainConfig():
|
|
55
|
+
base_url = f"{protocol}://{circuit.subdomain}{config.domain}:{config.advertised_port or config.bind_addr.port}"
|
|
56
|
+
case TraefikWildcardDomainConfig():
|
|
57
|
+
base_url = f"{protocol}://{circuit.subdomain}{config.domain}:{config.advertised_port}"
|
|
58
|
+
|
|
59
|
+
# Append redirect path if provided
|
|
60
|
+
if redirect_path:
|
|
61
|
+
# Ensure redirect path starts with /
|
|
62
|
+
if not redirect_path.startswith("/"):
|
|
63
|
+
redirect_path = f"/{redirect_path}"
|
|
64
|
+
return f"{base_url}{redirect_path}"
|
|
65
|
+
|
|
66
|
+
return base_url
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def ensure_traefik_route_set_up(traefik_api_port: int, circuit: Circuit) -> None:
|
|
70
|
+
match circuit.protocol:
|
|
71
|
+
case ProxyProtocol.TCP:
|
|
72
|
+
proto = "tcp"
|
|
73
|
+
case _:
|
|
74
|
+
proto = "http"
|
|
75
|
+
path = f"/api/{proto}/routers/{circuit.traefik_router_name}"
|
|
76
|
+
|
|
77
|
+
base_url = yarl.URL("http://127.0.0.1").with_port(traefik_api_port)
|
|
78
|
+
async for attempt in AsyncRetrying(
|
|
79
|
+
wait=wait_exponential(multiplier=0.02, min=0.02, max=5.0),
|
|
80
|
+
stop=stop_after_attempt(20),
|
|
81
|
+
retry=retry_if_exception_type(TryAgain),
|
|
82
|
+
):
|
|
83
|
+
with attempt:
|
|
84
|
+
async with aiohttp.ClientSession(base_url=base_url) as sess:
|
|
85
|
+
async with sess.get(path) as resp:
|
|
86
|
+
if resp.status == 404:
|
|
87
|
+
raise TryAgain
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ProxySetupRequestModel(BaseModel):
|
|
91
|
+
token: str
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ProxySetupResponseModel(BaseModel):
|
|
95
|
+
redirect: AnyUrl
|
|
96
|
+
redirectURI: AnyUrl
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pydantic_api_handler(ProxySetupRequestModel)
|
|
100
|
+
async def setup(
|
|
101
|
+
request: web.Request, params: ProxySetupRequestModel
|
|
102
|
+
) -> web.StreamResponse | PydanticResponse[ProxySetupResponseModel]:
|
|
103
|
+
root_ctx: RootContext = request.app["_root.context"]
|
|
104
|
+
jwt_body = jwt.decode(
|
|
105
|
+
params.token, root_ctx.local_config.secrets.jwt_secret, algorithms=["HS256"]
|
|
106
|
+
)
|
|
107
|
+
requested_circuit_id = UUID(jwt_body["circuit"])
|
|
108
|
+
|
|
109
|
+
config = root_ctx.local_config.proxy_worker
|
|
110
|
+
port_config = config.port_proxy # As a default fallback
|
|
111
|
+
circuit = await get_circuit_info(root_ctx, request["request_id"], str(requested_circuit_id))
|
|
112
|
+
|
|
113
|
+
match config.frontend_mode:
|
|
114
|
+
case FrontendServerMode.TRAEFIK:
|
|
115
|
+
if config.traefik is None:
|
|
116
|
+
raise ServerMisconfiguredError("proxy_worker: Missing 'traefik' config section")
|
|
117
|
+
match config.traefik.frontend_mode:
|
|
118
|
+
case FrontendMode.PORT:
|
|
119
|
+
port_config = config.traefik.port_proxy
|
|
120
|
+
if port_config is None:
|
|
121
|
+
raise ServerMisconfiguredError(
|
|
122
|
+
"proxy_worker.traefik: Missing 'port_proxy' config section"
|
|
123
|
+
)
|
|
124
|
+
case FrontendMode.WILDCARD_DOMAIN:
|
|
125
|
+
port_config = config.traefik.wildcard_domain
|
|
126
|
+
if port_config is None:
|
|
127
|
+
raise ServerMisconfiguredError(
|
|
128
|
+
"proxy_worker.traefik: Missing 'wildcard_domain' config section"
|
|
129
|
+
)
|
|
130
|
+
await ensure_traefik_route_set_up(config.traefik.api_port, circuit)
|
|
131
|
+
case FrontendServerMode.PORT:
|
|
132
|
+
port_config = config.port_proxy
|
|
133
|
+
if port_config is None:
|
|
134
|
+
raise ServerMisconfiguredError(
|
|
135
|
+
"proxy_worker: Missing root-level 'port_proxy' config section"
|
|
136
|
+
)
|
|
137
|
+
case FrontendServerMode.WILDCARD_DOMAIN:
|
|
138
|
+
port_config = config.wildcard_domain
|
|
139
|
+
if port_config is None:
|
|
140
|
+
raise ServerMisconfiguredError(
|
|
141
|
+
"proxy_worker: Missing root-level 'wildcard_domain' config section"
|
|
142
|
+
)
|
|
143
|
+
case _:
|
|
144
|
+
raise ServerMisconfiguredError(
|
|
145
|
+
f"proxy_worker: Invalid root-level 'frontend_mode': {config.frontend_mode}"
|
|
146
|
+
)
|
|
147
|
+
if port_config is None:
|
|
148
|
+
raise MissingPortConfigError("Port configuration is required")
|
|
149
|
+
|
|
150
|
+
use_tls = config.tls_advertised or config.tls_listen
|
|
151
|
+
if not isinstance(circuit.app_info, InteractiveAppInfo):
|
|
152
|
+
raise InvalidAPIParameters("E20011: Not supported for inference apps")
|
|
153
|
+
|
|
154
|
+
# Web browsers block redirect between cross-origins if Access-Control-Allow-Origin value is set to a concrete Origin instead of wildcard;
|
|
155
|
+
# Hence we need to send "*" as allowed origin manually, instead of benefiting from aiohttp-cors
|
|
156
|
+
cors_headers = {
|
|
157
|
+
"Access-Control-Allow-Origin": "*",
|
|
158
|
+
"Access-Control-Allow-Headers": "*",
|
|
159
|
+
"Access-Control-Expose-Headers": "*",
|
|
160
|
+
}
|
|
161
|
+
match circuit.protocol:
|
|
162
|
+
case ProxyProtocol.HTTP:
|
|
163
|
+
protocol = "https" if use_tls else "http"
|
|
164
|
+
redirect_path = jwt_body.get("redirect", "")
|
|
165
|
+
proxy_url = generate_proxy_url(port_config, protocol, circuit, redirect_path)
|
|
166
|
+
response = web.HTTPPermanentRedirect(proxy_url, headers=cors_headers)
|
|
167
|
+
cookie_domain = None
|
|
168
|
+
if circuit.frontend_mode == FrontendMode.WILDCARD_DOMAIN:
|
|
169
|
+
wildcard_info = config.wildcard_domain
|
|
170
|
+
if not wildcard_info:
|
|
171
|
+
raise ServerMisconfiguredError("worker:proxy-worker.wildcard-domain")
|
|
172
|
+
cookie_domain = wildcard_info.domain
|
|
173
|
+
response.set_cookie(
|
|
174
|
+
PERMIT_COOKIE_NAME,
|
|
175
|
+
calculate_permit_hash(root_ctx.local_config.permit_hash, circuit.app_info.user_id),
|
|
176
|
+
domain=cookie_domain,
|
|
177
|
+
httponly=True,
|
|
178
|
+
secure=use_tls,
|
|
179
|
+
samesite="Lax",
|
|
180
|
+
max_age=604800, # 7 days
|
|
181
|
+
)
|
|
182
|
+
return response
|
|
183
|
+
case ProxyProtocol.TCP:
|
|
184
|
+
protocol = "tcp"
|
|
185
|
+
queryparams = {
|
|
186
|
+
"directTCP": "true",
|
|
187
|
+
"auth": params.token,
|
|
188
|
+
"proto": protocol,
|
|
189
|
+
"gateway": generate_proxy_url(port_config, protocol, circuit, redirect_path=None),
|
|
190
|
+
}
|
|
191
|
+
if jwt_body["redirect"]:
|
|
192
|
+
return web.HTTPPermanentRedirect(
|
|
193
|
+
f"http://localhost:45678/start?{urllib.parse.urlencode(queryparams)}",
|
|
194
|
+
headers=cors_headers,
|
|
195
|
+
)
|
|
196
|
+
else:
|
|
197
|
+
return PydanticResponse(
|
|
198
|
+
ProxySetupResponseModel(
|
|
199
|
+
redirect=AnyUrl(
|
|
200
|
+
f"http://localhost:45678/start?{urllib.parse.urlencode(queryparams)}"
|
|
201
|
+
),
|
|
202
|
+
redirectURI=AnyUrl(
|
|
203
|
+
f"http://localhost:45678/start?{urllib.parse.urlencode(queryparams)}"
|
|
204
|
+
),
|
|
205
|
+
),
|
|
206
|
+
headers=cors_headers,
|
|
207
|
+
)
|
|
208
|
+
case _:
|
|
209
|
+
raise InvalidAPIParameters("E20002: Protocol not available as interactive app")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
async def init(app: web.Application) -> None:
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
async def shutdown(app: web.Application) -> None:
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def create_app(
|
|
221
|
+
default_cors_options: CORSOptions,
|
|
222
|
+
) -> tuple[web.Application, Iterable[WebMiddleware]]:
|
|
223
|
+
app = web.Application()
|
|
224
|
+
app["prefix"] = "setup"
|
|
225
|
+
app.on_startup.append(init)
|
|
226
|
+
app.on_shutdown.append(shutdown)
|
|
227
|
+
add_route = app.router.add_route
|
|
228
|
+
add_route("GET", "", setup)
|
|
229
|
+
return app, []
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from ai.backend.common.cli import LazyGroup
|
|
7
|
+
|
|
8
|
+
from .context import CLIContext
|
|
9
|
+
|
|
10
|
+
# LogLevel values for click.Choice - avoid importing ai.backend.logging at module level
|
|
11
|
+
_LOG_LEVELS = ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG", "TRACE", "NOTSET"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.group(invoke_without_command=False, context_settings={"help_option_names": ["-h", "--help"]})
|
|
15
|
+
@click.option(
|
|
16
|
+
"-f",
|
|
17
|
+
"--config-path",
|
|
18
|
+
"--config",
|
|
19
|
+
type=click.Path(
|
|
20
|
+
file_okay=True,
|
|
21
|
+
dir_okay=False,
|
|
22
|
+
exists=True,
|
|
23
|
+
path_type=Path,
|
|
24
|
+
),
|
|
25
|
+
default=None,
|
|
26
|
+
help="The config file path. (default: ./app-proxy-worker.toml)",
|
|
27
|
+
)
|
|
28
|
+
@click.option(
|
|
29
|
+
"--debug",
|
|
30
|
+
is_flag=True,
|
|
31
|
+
help="Set the logging level to DEBUG",
|
|
32
|
+
)
|
|
33
|
+
@click.option(
|
|
34
|
+
"--log-level",
|
|
35
|
+
type=click.Choice(_LOG_LEVELS, case_sensitive=False),
|
|
36
|
+
default="INFO",
|
|
37
|
+
help="Set the logging verbosity level",
|
|
38
|
+
)
|
|
39
|
+
@click.pass_context
|
|
40
|
+
def main(
|
|
41
|
+
ctx: click.Context,
|
|
42
|
+
debug: bool,
|
|
43
|
+
log_level: str,
|
|
44
|
+
config_path: Optional[Path] = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Proxy Worker Administration CLI
|
|
48
|
+
"""
|
|
49
|
+
from setproctitle import setproctitle
|
|
50
|
+
|
|
51
|
+
from ai.backend.logging.types import LogLevel
|
|
52
|
+
|
|
53
|
+
setproctitle("backend.ai: proxy-worker.cli")
|
|
54
|
+
if debug:
|
|
55
|
+
log_level = "DEBUG"
|
|
56
|
+
ctx.obj = ctx.with_resource(CLIContext(config_path=config_path, log_level=LogLevel(log_level)))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@main.command()
|
|
60
|
+
@click.option(
|
|
61
|
+
"--output",
|
|
62
|
+
"-o",
|
|
63
|
+
default="-",
|
|
64
|
+
type=click.Path(dir_okay=False, writable=True),
|
|
65
|
+
help="Output file path (default: stdout)",
|
|
66
|
+
)
|
|
67
|
+
def generate_example_configuration(output: Path) -> None:
|
|
68
|
+
"""
|
|
69
|
+
Generates example TOML configuration file for Backend.AI Proxy Worker.
|
|
70
|
+
"""
|
|
71
|
+
import tomli_w
|
|
72
|
+
|
|
73
|
+
from ai.backend.appproxy.common.config import generate_example_json
|
|
74
|
+
from ai.backend.appproxy.common.utils import ensure_json_serializable
|
|
75
|
+
|
|
76
|
+
from ..config import ServerConfig
|
|
77
|
+
|
|
78
|
+
generated_example = generate_example_json(ServerConfig)
|
|
79
|
+
if output == "-" or output is None:
|
|
80
|
+
print(tomli_w.dumps(ensure_json_serializable(generated_example)))
|
|
81
|
+
else:
|
|
82
|
+
with open(output, mode="w") as fw:
|
|
83
|
+
fw.write(tomli_w.dumps(ensure_json_serializable(generated_example)))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def _generate() -> dict[str, Any]:
|
|
87
|
+
import importlib
|
|
88
|
+
|
|
89
|
+
import aiohttp_cors
|
|
90
|
+
from aiohttp import web
|
|
91
|
+
|
|
92
|
+
from ai.backend.appproxy.common.openapi import generate_openapi
|
|
93
|
+
|
|
94
|
+
from ..server import global_subapp_pkgs
|
|
95
|
+
|
|
96
|
+
cors_options = {
|
|
97
|
+
"*": aiohttp_cors.ResourceOptions(
|
|
98
|
+
allow_credentials=False, expose_headers="*", allow_headers="*"
|
|
99
|
+
),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
subapps: list[web.Application] = []
|
|
103
|
+
for subapp in global_subapp_pkgs:
|
|
104
|
+
pkg = importlib.import_module("ai.backend.appproxy.worker.api" + subapp)
|
|
105
|
+
app, _ = pkg.create_app(cors_options)
|
|
106
|
+
subapps.append(app)
|
|
107
|
+
return generate_openapi("Proxy Worker", subapps, verbose=True)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@main.command()
|
|
111
|
+
@click.option(
|
|
112
|
+
"--output",
|
|
113
|
+
"-o",
|
|
114
|
+
default="-",
|
|
115
|
+
type=click.Path(dir_okay=False, writable=True),
|
|
116
|
+
help="Output file path (default: stdout)",
|
|
117
|
+
)
|
|
118
|
+
def generate_openapi_spec(output: Path) -> None:
|
|
119
|
+
"""
|
|
120
|
+
Generates OpenAPI specification of Backend.AI API.
|
|
121
|
+
"""
|
|
122
|
+
import asyncio
|
|
123
|
+
import json
|
|
124
|
+
|
|
125
|
+
openapi = asyncio.run(_generate())
|
|
126
|
+
if output == "-" or output is None:
|
|
127
|
+
print(json.dumps(openapi, ensure_ascii=False, indent=2))
|
|
128
|
+
else:
|
|
129
|
+
with open(output, mode="w") as fw:
|
|
130
|
+
fw.write(json.dumps(openapi, ensure_ascii=False, indent=2))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@main.group(cls=LazyGroup, import_name="ai.backend.appproxy.worker.cli.dependencies:cli")
|
|
134
|
+
def dependencies():
|
|
135
|
+
"""Command set for dependency verification and validation."""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@main.group(cls=LazyGroup, import_name="ai.backend.appproxy.worker.cli.health:cli")
|
|
139
|
+
def health():
|
|
140
|
+
"""Command set for health checking."""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == "__main__":
|
|
144
|
+
main()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import AbstractContextManager
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Optional, Self
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from ai.backend.logging import AbstractLogger
|
|
11
|
+
from ai.backend.logging.types import LogLevel
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CLIContext(AbstractContextManager):
|
|
15
|
+
_logger: AbstractLogger
|
|
16
|
+
|
|
17
|
+
def __init__(self, log_level: LogLevel, config_path: Optional[Path] = None) -> None:
|
|
18
|
+
self.config_path = config_path
|
|
19
|
+
self.log_level = log_level
|
|
20
|
+
|
|
21
|
+
def __enter__(self) -> Self:
|
|
22
|
+
from ai.backend.logging import LocalLogger
|
|
23
|
+
|
|
24
|
+
# The "start-server" command is injected by ai.backend.cli from the entrypoint
|
|
25
|
+
# and it has its own multi-process-aware logging initialization.
|
|
26
|
+
# If we duplicate the local logging with it, the process termination may hang.
|
|
27
|
+
click_ctx = click.get_current_context()
|
|
28
|
+
if click_ctx.invoked_subcommand != "start-server":
|
|
29
|
+
self._logger = LocalLogger(log_level=self.log_level)
|
|
30
|
+
self._logger.__enter__()
|
|
31
|
+
return self
|
|
32
|
+
|
|
33
|
+
def __exit__(self, *exc_info: Any) -> None:
|
|
34
|
+
click_ctx = click.get_current_context()
|
|
35
|
+
if click_ctx.invoked_subcommand != "start-server":
|
|
36
|
+
self._logger.__exit__()
|