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.
Files changed (56) hide show
  1. backend_ai_appproxy_worker-25.19.1/MANIFEST.in +1 -0
  2. backend_ai_appproxy_worker-25.19.1/PKG-INFO +164 -0
  3. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/VERSION +1 -0
  4. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/__init__.py +3 -0
  5. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/api/health.py +78 -0
  6. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/api/setup.py +229 -0
  7. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/cli/__main__.py +144 -0
  8. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/cli/context.py +36 -0
  9. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/cli/dependencies.py +71 -0
  10. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/cli/health.py +202 -0
  11. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/cli/start_server.py +60 -0
  12. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/config.py +422 -0
  13. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/coordinator_client.py +225 -0
  14. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/dependencies/__init__.py +9 -0
  15. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/dependencies/bootstrap/__init__.py +9 -0
  16. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/dependencies/bootstrap/composer.py +56 -0
  17. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/dependencies/bootstrap/config.py +38 -0
  18. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/dependencies/composer.py +56 -0
  19. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/dependencies/infrastructure/__init__.py +11 -0
  20. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/dependencies/infrastructure/composer.py +39 -0
  21. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/dependencies/infrastructure/redis.py +75 -0
  22. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/errors/__init__.py +38 -0
  23. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/errors/circuit.py +55 -0
  24. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/errors/config.py +97 -0
  25. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/errors/process.py +41 -0
  26. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/metrics.py +421 -0
  27. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/backend/__init__.py +9 -0
  28. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/backend/base.py +57 -0
  29. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/backend/h2.py +29 -0
  30. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/backend/http.py +333 -0
  31. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/backend/tcp.py +111 -0
  32. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/backend/traefik.py +14 -0
  33. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/frontend/__init__.py +17 -0
  34. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/frontend/base.py +97 -0
  35. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/frontend/h2/base.py +44 -0
  36. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/frontend/h2/port.py +107 -0
  37. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/frontend/h2/subdomain.py +103 -0
  38. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/frontend/http/base.py +165 -0
  39. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/frontend/http/port.py +106 -0
  40. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/frontend/http/subdomain.py +103 -0
  41. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/frontend/tcp.py +133 -0
  42. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/proxy/frontend/traefik.py +185 -0
  43. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/py.typed +1 -0
  44. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/server.py +1028 -0
  45. backend_ai_appproxy_worker-25.19.1/ai/backend/appproxy/worker/types.py +650 -0
  46. backend_ai_appproxy_worker-25.19.1/backend.ai_appproxy_worker.egg-info/PKG-INFO +164 -0
  47. backend_ai_appproxy_worker-25.19.1/backend.ai_appproxy_worker.egg-info/SOURCES.txt +54 -0
  48. backend_ai_appproxy_worker-25.19.1/backend.ai_appproxy_worker.egg-info/dependency_links.txt +1 -0
  49. backend_ai_appproxy_worker-25.19.1/backend.ai_appproxy_worker.egg-info/entry_points.txt +3 -0
  50. backend_ai_appproxy_worker-25.19.1/backend.ai_appproxy_worker.egg-info/namespace_packages.txt +1 -0
  51. backend_ai_appproxy_worker-25.19.1/backend.ai_appproxy_worker.egg-info/not-zip-safe +1 -0
  52. backend_ai_appproxy_worker-25.19.1/backend.ai_appproxy_worker.egg-info/requires.txt +26 -0
  53. backend_ai_appproxy_worker-25.19.1/backend.ai_appproxy_worker.egg-info/top_level.txt +1 -0
  54. backend_ai_appproxy_worker-25.19.1/backend_shim.py +31 -0
  55. backend_ai_appproxy_worker-25.19.1/setup.cfg +4 -0
  56. 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,3 @@
1
+ from pathlib import Path
2
+
3
+ __version__ = (Path(__file__).parent / "VERSION").read_text().strip()
@@ -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__()