Skip to content

Commit c82eba1

Browse files
committed
Add app URL resolving for shiny proxy apps
1 parent 6cfae47 commit c82eba1

6 files changed

Lines changed: 296 additions & 5 deletions

File tree

serve_event_listener/app_urls.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from typing import Optional
5+
from urllib.parse import urlunparse
6+
7+
from serve_event_listener.el_types import StatusRecord
8+
9+
10+
def _host_for(service: str, namespace: str) -> str:
11+
"""Build host according to DNS mode env settings."""
12+
mode = (os.getenv("APP_URL_DNS_MODE", "short") or "short").lower()
13+
suffix = os.getenv("APP_URL_DNS_SUFFIX")
14+
if mode == "fqdn":
15+
return f"{service}.{namespace}.svc.cluster.local"
16+
if suffix:
17+
return f"{service}.{namespace}.{suffix}"
18+
# default short form: service.namespace
19+
return f"{service}.{namespace}"
20+
21+
22+
def _port() -> str:
23+
return os.getenv("APP_URL_PORT", "80")
24+
25+
26+
def _scheme() -> str:
27+
return os.getenv("APP_URL_SCHEME", "http")
28+
29+
30+
def resolve_app_url(
31+
rec: StatusRecord, *, fallback_namespace: Optional[str] = None
32+
) -> Optional[str]:
33+
"""
34+
Return a cluster-internal HTTP URL for the given StatusRecord, or None if unknown.
35+
36+
Currently supports:
37+
- app-type == 'shiny-proxy':
38+
service: <release>-<SHINYPROXY_SERVICE_SUFFIX>
39+
host: per DNS mode (short/fqdn/custom suffix)
40+
path: <SHINYPROXY_PATH_PREFIX>/<release>/
41+
"""
42+
app_type = (rec.get("app-type") or "").lower()
43+
if not app_type:
44+
return None
45+
46+
release = rec.get("release")
47+
if not release:
48+
return None
49+
50+
namespace = rec.get("namespace") or fallback_namespace or "default"
51+
52+
if app_type == "shiny-proxy":
53+
suffix = os.getenv("SHINYPROXY_SERVICE_SUFFIX", "shinyproxyapp")
54+
path_prefix = os.getenv("SHINYPROXY_PATH_PREFIX", "/app").rstrip("/")
55+
service = f"{release}-{suffix}"
56+
host = _host_for(service, namespace)
57+
path = f"{path_prefix}/{release}/"
58+
netloc = f"{host}:{_port()}"
59+
return urlunparse((_scheme(), netloc, path, "", "", ""))
60+
61+
# Other app types are not yet supported
62+
return None

serve_event_listener/event_listener.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -194,9 +194,6 @@ def listen(self) -> None:
194194

195195
record: StatusRecord = self.status_data.get_status_record()
196196

197-
# TODO: If you know the per-release URL here, add it now so the probe can use it:
198-
# record["app-url"] = app_url_resolver(record["release"], ...)
199-
200197
# Add to queue. Queue handles post and return codes
201198
self._status_queue.add(record)
202199

@@ -276,7 +273,6 @@ def check_serve_api_status(self) -> bool:
276273
timeout=self.timeout,
277274
backoff_seconds=self.backoff_seconds,
278275
)
279-
# response = self.get(url=BASE_URL + "/openapi/v1/are-you-there")
280276

281277
return bool(response and response.status_code == 200)
282278

serve_event_listener/status_data.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
from kubernetes.client.exceptions import ApiException
88
from kubernetes.client.models import V1PodStatus
99

10-
from serve_event_listener.el_types import AppType, PostPayload, StatusRecord
10+
from serve_event_listener.app_urls import resolve_app_url
11+
from serve_event_listener.el_types import AppType, StatusRecord
1112

1213
logger = logging.getLogger(__name__)
1314

@@ -51,7 +52,15 @@ class StatusData:
5152
"""Logic to process k8s event information into app status."""
5253

5354
def __init__(self, namespace: str = "default"):
55+
# TODO: In the future, we will also refactor status_data to
56+
# instead hold an Internal store: release -> StatusRecord
57+
# self._by_release: Dict[str, StatusRecord] = {}
58+
# Track which release was last updated (used by get_status_record())
59+
# self._last_release: Optional[str] = None
60+
61+
# But for now we continue to use the current dict status_data:
5462
self.status_data = {}
63+
5564
self.k8s_api_client = None
5665
self.namespace: str = namespace
5766

@@ -300,6 +309,13 @@ def update(self, event: dict) -> None:
300309
self.status_data[release]["app-type"] = pod_message
301310
logger.info("Detected this pod's app type to be = %s", app_type)
302311

312+
if app_type:
313+
# Only set app-url if we can resolve it
314+
rec = self.get_status_record()
315+
url = resolve_app_url(rec, fallback_namespace=self.namespace)
316+
if url:
317+
self.status_data[release]["app-url"] = url
318+
303319
self.status_data[release]["pod-msg"] = pod_message
304320
self.status_data[release]["container-msg"] = container_message
305321

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Integration tests for app URL resolution (shiny-proxy)."""
2+
3+
import os
4+
import socket
5+
import unittest
6+
from unittest.mock import patch
7+
from urllib.parse import urlparse
8+
9+
from serve_event_listener.app_urls import resolve_app_url
10+
from serve_event_listener.el_types import StatusRecord
11+
from serve_event_listener.http_client import get as http_get
12+
from serve_event_listener.http_client import make_session
13+
from tests.integration.base import IntegrationTestCase
14+
15+
16+
class TestAppUrlResolverIntegration(IntegrationTestCase):
17+
"""Resolve URLs for a given release/namespace and optionally smoke-check connectivity."""
18+
19+
def setUp(self):
20+
"""Build a minimal StatusRecord from env (release + namespace required)."""
21+
release = os.getenv("PROBE_RELEASE") or os.getenv("RELEASE_UNDER_TEST")
22+
namespace = os.getenv("NAMESPACE_UNDER_TEST") or "default"
23+
if not release:
24+
raise unittest.SkipTest(
25+
"Set PROBE_RELEASE or RELEASE_UNDER_TEST for resolver tests"
26+
)
27+
28+
# Minimal StatusRecord; resolver only needs release/app-type/namespace
29+
self.rec: StatusRecord = {
30+
"release": release,
31+
"new-status": "Running",
32+
"event-ts": "2025-09-26T00:00:00.000000Z",
33+
"app-type": "shiny-proxy",
34+
"namespace": namespace,
35+
}
36+
37+
@patch.dict(
38+
os.environ, {"APP_URL_DNS_MODE": "short", "APP_URL_PORT": "80"}, clear=False
39+
)
40+
def test_builds_expected_short_dns_url(self):
41+
"""URL should follow service.namespace form with default port/path."""
42+
url = resolve_app_url(self.rec)
43+
self.assertIsNotNone(url)
44+
# Example: http://<release>-shinyproxyapp.<ns>:80/app/<release>/
45+
release = self.rec["release"]
46+
ns = self.rec["namespace"] # type: ignore[index]
47+
expected_host = f"{release}-shinyproxyapp.{ns}"
48+
parsed = urlparse(url) # type: ignore[arg-type]
49+
self.assertEqual(parsed.scheme, "http")
50+
self.assertTrue(parsed.hostname.startswith(expected_host))
51+
self.assertTrue(parsed.path.endswith(f"/app/{release}/"))
52+
53+
def test_dns_resolution_behavior(self):
54+
"""DNS may or may not resolve here; in either case we should not crash."""
55+
url = resolve_app_url(self.rec)
56+
self.assertIsNotNone(
57+
url, "Resolver returned None, should return a url for shiny-proxy"
58+
)
59+
60+
host = urlparse(url).hostname
61+
self.assertIsNotNone(host, "URL lacks hostname")
62+
63+
# Either it resolves or it doesn't — both are acceptable outcomes.
64+
try:
65+
socket.getaddrinfo(host, None)
66+
resolved = True
67+
except socket.gaierror:
68+
resolved = False
69+
70+
# Assert we captured one of the two states (this is mostly a sanity check)
71+
self.assertIn(resolved, (True, False))
72+
73+
def test_http_smoke_result(self):
74+
"""HTTP GET should either return a Response or None (on error/timeout)."""
75+
url = resolve_app_url(self.rec)
76+
self.assertIsNotNone(
77+
url, "Resolver returned None, should return a url for shiny-proxy"
78+
)
79+
80+
session = make_session(total_retries=1)
81+
resp = http_get(session, url, timeout=(0.5, 1.0), backoff_seconds=(0.2,))
82+
self.assertIn(type(resp).__name__, ("NoneType", "Response"))
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Integration test: StatusData.update attaches app-url for shiny-proxy pods."""
2+
3+
import os
4+
import unittest
5+
from unittest.mock import patch
6+
7+
from kubernetes import client, config
8+
9+
from serve_event_listener.el_types import StatusRecord
10+
from serve_event_listener.status_data import StatusData
11+
from tests.integration.base import IntegrationTestCase
12+
13+
14+
class TestStatusDataUrlAttachmentIntegration(IntegrationTestCase):
15+
"""Fetch a real shiny-proxy pod, feed it to StatusData.update, and verify app-url is set."""
16+
17+
@classmethod
18+
def setUpClass(cls):
19+
# Try to load cluster config; skip if not available
20+
kubeconfig = os.getenv("KUBECONFIG")
21+
try:
22+
if kubeconfig and os.path.exists(kubeconfig):
23+
config.load_kube_config(kubeconfig)
24+
else:
25+
# try in-cluster; skip if not applicable
26+
config.incluster_config.load_incluster_config()
27+
except Exception:
28+
raise unittest.SkipTest(
29+
"No K8s config available for StatusData integration"
30+
)
31+
32+
def _find_shinyproxy_pod(self, namespace: str):
33+
v1 = client.CoreV1Api()
34+
# No 'contains' selector; list and filter in Python
35+
pods = v1.list_namespaced_pod(namespace=namespace, limit=200, watch=False)
36+
for pod in pods.items:
37+
labels = getattr(pod.metadata, "labels", {}) or {}
38+
app_label = str(labels.get("app", "")).lower()
39+
if "shinyproxy" in app_label and labels.get("release"):
40+
return pod
41+
return None
42+
43+
@patch.dict(
44+
os.environ, {"APP_URL_DNS_MODE": "short", "APP_URL_PORT": "80"}, clear=False
45+
)
46+
def test_update_sets_app_url_for_shinyproxy(self):
47+
"""StatusData.update should set app-type=shiny-proxy and app-url for a shiny-proxy pod."""
48+
namespace = os.getenv("NAMESPACE_UNDER_TEST") or "default"
49+
pod = self._find_shinyproxy_pod(namespace)
50+
if pod is None:
51+
raise unittest.SkipTest(
52+
f"No shiny-proxy pod with a 'release' label found in namespace {namespace}"
53+
)
54+
55+
print("/nFound a shiny pod: ", pod.metadata.name)
56+
sd = StatusData(namespace=namespace)
57+
# Simulate a k8s watch event shape
58+
sd.update({"object": pod})
59+
# TODO: This test fails. Add a robust check here:
60+
self.assertEqual(sd.status_data.get("app-type"), "shiny-proxy")
61+
62+
rec: StatusRecord = sd.get_status_record()
63+
self.assertEqual(
64+
rec.get("app-type"),
65+
"shiny-proxy",
66+
"app-type should be detected as shiny-proxy",
67+
)
68+
self.assertTrue(
69+
rec.get("app-url"), "app-url should be set for shiny-proxy pods"
70+
)
71+
72+
# Sanity-check the host/path shape
73+
release = rec["release"]
74+
url = rec["app-url"] # type: ignore[index]
75+
self.assertIn(f"{release}-shinyproxyapp.{namespace}", url)
76+
self.assertTrue(url.endswith(f"/app/{release}/"))

tests/unit/test_app_urls.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import os
2+
import unittest
3+
from unittest.mock import patch
4+
5+
from serve_event_listener.app_urls import resolve_app_url
6+
from serve_event_listener.el_types import StatusRecord
7+
8+
9+
class TestResolveAppUrl(unittest.TestCase):
10+
def setUp(self):
11+
self.rec: StatusRecord = {
12+
"release": "sp-status",
13+
"new-status": "Running",
14+
"event-ts": "2025-09-26T12:00:00.000000Z",
15+
"app-type": "shiny-proxy",
16+
"namespace": "default",
17+
}
18+
19+
@patch.dict(os.environ, {}, clear=True)
20+
def test_default_short_dns(self):
21+
url = resolve_app_url(self.rec)
22+
self.assertEqual(
23+
url, "http://sp-status-shinyproxyapp.default:80/app/sp-status/"
24+
)
25+
26+
@patch.dict(os.environ, {"APP_URL_DNS_MODE": "fqdn"}, clear=True)
27+
def test_fqdn_dns(self):
28+
url = resolve_app_url(self.rec)
29+
self.assertEqual(
30+
url,
31+
"http://sp-status-shinyproxyapp.default.svc.cluster.local:80/app/sp-status/",
32+
)
33+
34+
@patch.dict(
35+
os.environ, {"APP_URL_DNS_SUFFIX": "serve-dev.svc.cluster.local"}, clear=True
36+
)
37+
def test_custom_suffix(self):
38+
url = resolve_app_url(self.rec)
39+
self.assertEqual(
40+
url,
41+
"http://sp-status-shinyproxyapp.default.serve-dev.svc.cluster.local:80/app/sp-status/",
42+
)
43+
44+
@patch.dict(
45+
os.environ,
46+
{"SHINYPROXY_SERVICE_SUFFIX": "shinyproxyapp", "APP_URL_PORT": "8080"},
47+
clear=True,
48+
)
49+
def test_custom_port(self):
50+
url = resolve_app_url(self.rec)
51+
self.assertEqual(
52+
url, "http://sp-status-shinyproxyapp.default:8080/app/sp-status/"
53+
)
54+
55+
@patch.dict(os.environ, {}, clear=True)
56+
def test_missing_app_type_returns_none(self):
57+
bad = dict(self.rec)
58+
bad.pop("app-type", None)
59+
self.assertIsNone(resolve_app_url(bad)) # type: ignore[arg-type]

0 commit comments

Comments
 (0)