Skip to content

Commit 340ebbd

Browse files
mhotanclaude
andcommitted
Add flyte proxy app — authenticated localhost proxy into a Flyte App
Adds a `proxy app NAME` command: an authenticated localhost reverse proxy into a no-auth-inside Flyte App, reusing the CLI's own authenticator (auto-refresh) to inject a fresh bearer on every request. A local HTTP client — a Grafana or Prometheus MCP, curl, a browser — then reaches an edge-gated app with no token handling. Think kubectl port-forward for Flyte Apps. - Resolves the target by app name (App.get) or an explicit --url. - Binds 127.0.0.1 by default; warns on non-loopback (ambient authority). - Streams responses unbuffered (SSE-safe for MCP streamable-HTTP / Grafana Live). - --emit-mcp-config prints a generic HTTP-MCP client block for the local port. - Refreshes and retries once when the edge bounces an expired token to /login. WebSocket upgrade proxying is deferred to a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7ad3ec8 commit 340ebbd

2 files changed

Lines changed: 206 additions & 0 deletions

File tree

src/flyte/cli/_proxy.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import asyncio
2+
import json
3+
import sys
4+
5+
import rich_click as click
6+
7+
import flyte.remote as remote
8+
9+
from . import _common as common
10+
11+
# Headers that must not be forwarded verbatim across a proxy hop.
12+
_HOP_BY_HOP = {
13+
"connection",
14+
"keep-alive",
15+
"proxy-authenticate",
16+
"proxy-authorization",
17+
"te",
18+
"trailer",
19+
"transfer-encoding",
20+
"upgrade",
21+
"host",
22+
"content-length",
23+
}
24+
25+
26+
@click.group(name="proxy")
27+
def proxy():
28+
"""Proxy a local port into a Flyte App through the authenticated app edge."""
29+
30+
31+
@proxy.command(cls=common.CommandBase)
32+
@click.argument("name", type=str, required=False)
33+
@click.option("--url", "url", type=str, default=None, help="Proxy this app URL directly; skip name resolution.")
34+
@click.option("--port", type=int, default=8600, help="Local port to listen on (0 = pick a free port).")
35+
@click.option("--address", type=str, default="127.0.0.1", help="Local bind address; non-loopback triggers a warning.")
36+
@click.option(
37+
"--emit-mcp-config", is_flag=True, default=False, help="Print a generic HTTP-MCP config block for the local endpoint."
38+
)
39+
@click.option("-v", "--verbose", is_flag=True, default=False, help="Log each proxied request (never the token).")
40+
@click.pass_obj
41+
def app(
42+
cfg: common.CLIConfig,
43+
name: str | None = None,
44+
project: str | None = None,
45+
domain: str | None = None,
46+
url: str | None = None,
47+
port: int = 8600,
48+
address: str = "127.0.0.1",
49+
emit_mcp_config: bool = False,
50+
verbose: bool = False,
51+
):
52+
"""
53+
Open an authenticated localhost proxy into a no-auth Flyte App.
54+
55+
Reuses the same Union auth (with auto-refresh) the CLI uses, injecting a fresh bearer on every
56+
request, so a local HTTP client — a Grafana/Prometheus MCP, curl, a browser — reaches an
57+
edge-gated app with no token handling. Think: kubectl port-forward for Flyte Apps.
58+
59+
Foreground; Ctrl-C to stop.
60+
"""
61+
cfg.init(project=project, domain=domain)
62+
63+
if url:
64+
target = url
65+
elif name:
66+
target = remote.App.get(name=name).endpoint
67+
else:
68+
raise click.UsageError("Provide an app NAME or --url.")
69+
target = target.rstrip("/")
70+
71+
label = name or target
72+
try:
73+
asyncio.run(_serve(cfg, target, label, address, port, emit_mcp_config, verbose))
74+
except KeyboardInterrupt:
75+
pass
76+
77+
78+
def _build_authenticator(cfg: common.CLIConfig):
79+
from flyte.remote._client.auth._authenticators.factory import get_async_authenticator
80+
from flyte.remote._client.auth._client_config import RemoteClientConfigStore
81+
from flyte.remote._client.auth._session import normalize_rpc_endpoint
82+
83+
plat = cfg.config.platform
84+
insecure = getattr(plat, "insecure", False)
85+
# Config endpoint is gRPC-style (bare host); the OIDC-metadata client needs an http(s) base.
86+
endpoint = normalize_rpc_endpoint(plat.endpoint, insecure=insecure)
87+
auth_type = getattr(plat, "auth_mode", None) or "Pkce"
88+
return get_async_authenticator(
89+
endpoint=endpoint,
90+
cfg_store=RemoteClientConfigStore(endpoint),
91+
auth_type=auth_type,
92+
insecure_skip_verify=getattr(plat, "insecure_skip_verify", False),
93+
ca_cert_file_path=getattr(plat, "ca_cert_file_path", None),
94+
)
95+
96+
97+
def _filter_request_headers(headers) -> dict:
98+
return {k: v for k, v in headers.items() if k.lower() not in _HOP_BY_HOP and k.lower() != "authorization"}
99+
100+
101+
def _filter_response_headers(headers) -> dict:
102+
return {k: v for k, v in headers.items() if k.lower() not in _HOP_BY_HOP}
103+
104+
105+
async def _serve(cfg, target, label, address, port, emit_mcp_config, verbose):
106+
from aiohttp import ClientSession, ClientTimeout, web
107+
108+
authenticator = _build_authenticator(cfg)
109+
# Prime credentials (load from keyring / run the auth flow) before we start serving.
110+
if authenticator.get_credentials() is None:
111+
await authenticator.refresh_credentials()
112+
113+
# auto_decompress=False -> stream upstream bytes verbatim (Content-Encoding preserved).
114+
# total=None -> allow long-lived SSE streams (MCP streamable-HTTP, Grafana Live).
115+
upstream = ClientSession(timeout=ClientTimeout(total=None), auto_decompress=False)
116+
117+
async def handle(request: "web.Request"):
118+
body = await request.read()
119+
fwd = _filter_request_headers(request.headers)
120+
up_url = target + request.raw_path
121+
122+
async def send(refresh: bool):
123+
if refresh:
124+
await authenticator.refresh_credentials()
125+
ah = await authenticator.get_auth_headers()
126+
hdrs = dict(fwd)
127+
if ah:
128+
hdrs.update(ah.headers)
129+
return await upstream.request(
130+
request.method, up_url, headers=hdrs, data=body or None, allow_redirects=False
131+
)
132+
133+
resp = await send(refresh=False)
134+
# Stale token: the edge 401s or bounces to /login. Refresh once and retry.
135+
bounced = resp.status in (302, 307) and "/login" in resp.headers.get("Location", "")
136+
if resp.status in (401, 403) or bounced:
137+
resp.release()
138+
resp = await send(refresh=True)
139+
140+
out = web.StreamResponse(status=resp.status, headers=_filter_response_headers(resp.headers))
141+
await out.prepare(request)
142+
async for chunk in resp.content.iter_any():
143+
await out.write(chunk)
144+
await out.write_eof()
145+
resp.release()
146+
if verbose:
147+
click.echo(f"{request.method} {request.path} -> {resp.status}", err=True)
148+
return out
149+
150+
server = web.Application()
151+
server.router.add_route("*", "/{tail:.*}", handle)
152+
runner = web.AppRunner(server)
153+
await runner.setup()
154+
site = web.TCPSite(runner, address, port)
155+
await site.start()
156+
157+
actual = port
158+
if port == 0:
159+
actual = runner.addresses[0][1]
160+
local = f"http://{address}:{actual}"
161+
162+
if address not in ("127.0.0.1", "localhost", "::1"):
163+
click.secho(
164+
f"WARNING: binding {address} exposes your Union identity to anything that can reach it; prefer 127.0.0.1.",
165+
fg="yellow",
166+
err=True,
167+
)
168+
identity = _identity(authenticator)
169+
click.echo(f"Proxying {target} -> {local}", err=True)
170+
click.echo(f" authenticating as: {identity}", err=True)
171+
click.echo(" Ctrl-C to stop.", err=True)
172+
if emit_mcp_config:
173+
_emit_mcp_config(label, local)
174+
175+
try:
176+
while True:
177+
await asyncio.sleep(3600)
178+
finally:
179+
await upstream.close()
180+
await runner.cleanup()
181+
182+
183+
def _identity(authenticator) -> str:
184+
creds = authenticator.get_credentials()
185+
if not creds or not creds.access_token:
186+
return "<unknown>"
187+
# Best-effort: decode the JWT payload's sub/email without verifying (display only).
188+
try:
189+
import base64
190+
191+
payload = creds.access_token.split(".")[1]
192+
payload += "=" * (-len(payload) % 4)
193+
claims = json.loads(base64.urlsafe_b64decode(payload))
194+
return claims.get("email") or claims.get("sub") or "<token>"
195+
except Exception:
196+
return "<token>"
197+
198+
199+
def _emit_mcp_config(name: str, local: str):
200+
block = {"mcpServers": {name: {"type": "http", "url": local}}}
201+
click.echo("", err=True)
202+
click.echo("# MCP client config (generic HTTP transport) — point your client at the local proxy:", err=True)
203+
sys.stdout.write(json.dumps(block, indent=2) + "\n")
204+
sys.stdout.flush()

src/flyte/cli/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from ._get import get
1717
from ._plugins import discover_and_register_plugins
1818
from ._prefetch import prefetch
19+
from ._proxy import proxy
1920
from ._rerun import rerun
2021
from ._run import run
2122
from ._serve import serve
@@ -303,6 +304,7 @@ def main(
303304
main.add_command(stop) # type: ignore
304305
main.add_command(prefetch) # type: ignore
305306
main.add_command(edit) # type: ignore
307+
main.add_command(proxy) # type: ignore
306308

307309
# Discover and register CLI plugins from installed packages
308310
discover_and_register_plugins(main)

0 commit comments

Comments
 (0)