Skip to content

feat(gateway): expose workspace service route snapshots and watches - #4941

Open
TaylorMutch wants to merge 3 commits into
mainfrom
feat/4667-service-discovery/TaylorMutch
Open

TaylorMutch wants to merge 3 commits into
mainfrom
feat/4667-service-discovery/TaylorMutch

Conversation

@TaylorMutch

@TaylorMutch TaylorMutch commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Expose accepted supervisor installations through GetServiceRouteSnapshot and WatchServiceRoutes for one explicit authorized workspace. Independent proxies and infrastructure controllers consume exact hostnames and trusted supervisor endpoints through the same API.

Based on main at cb836ea9d.

Related Issue

Closes #4667. Part of RFC 0017.

Changes

  • Gate snapshot and watch RPCs on supervisor_app_listener.enabled; disabled listeners return FAILED_PRECONDITION before collecting routes or allocating a watch.

  • Publish accepted installation records from feat(supervisor): advertise accepted application route installations #4940, which resolves trusted supervisor endpoints through the private ResolveSupervisorAppEndpoint compute RPC. External consumers use the public discovery RPCs; ordinary GetSandbox observations do not resolve routes.

  • Return complete replacements with installation versions, declaration revisions, expiry, and current owner endpoints.

  • Recheck session, runtime, credential epoch, and shared pool authority before publishing routes.

  • Bound collection, watches, buffering, send time, and lifetime; periodically reauthorize watches and fail closed on storage errors.

  • Document client cache expiry and stable installation versions across lease-only renewal.

Release Notes

Discovery and Kubernetes request flow

Discovery returns an application hostname and a trusted supervisor endpoint
(host and port), together with installation identity and expiry. name is the
sandbox route's service name; an empty value identifies the unnamed/default
service. It is not a Kubernetes Service resource name.

The endpoint is the owning supervisor Pod IP and app-listener port. Consumers
preserve the application hostname as TLS SNI and HTTP Host/authority. Multiple
routes can share one supervisor endpoint; the hostname selects the sandbox
instance and service. The authoritative public application URL is separate and
can use a different public port.

flowchart TB
    subgraph Control["Control plane: build and publish discovery"]
        SUP["Supervisor<br/>Reports installed routes against a lease"]
        GW["OpenShell gateway<br/>Validates installation and ownership"]
        KD["Kubernetes compute driver<br/>ResolveSupervisorAppEndpoint"]
        KUBE["Kubernetes objects + supervisor ownership<br/>Find and validate owning supervisor Pod"]
        API["Discovery API<br/>hostname + Pod IP:8443 + expiry"]
        CONSUMER["Sandbox proxy or<br/>example Kubernetes HTTPRoute controller"]

        SUP -->|"Installed-route advertisement"| GW
        GW -->|"Resolve trusted endpoint"| KD
        KD --> KUBE
        KUBE -->|"Validated Pod IP and listener port"| KD
        KD --> GW
        GW --> API
        API -->|"Snapshot / watch"| CONSUMER
    end

    subgraph Data["Application data plane"]
        CLIENT["Client<br/>https://team--demo--web.apps.example.com"]
        EDGE["Configured proxy or Kubernetes Gateway<br/>Public listener, e.g. port 443"]
        LISTENER["Owning supervisor Pod<br/>10.42.1.27:8443<br/>TLS app listener"]
        DISPATCH["Select sandbox instance + named service<br/>using SNI / Host"]
        CONNECTOR["Selected instance's sandbox connector"]
        APP["Application inside sandbox<br/>127.0.0.1:8080"]

        CLIENT --> EDGE
        EDGE -->|"Connect to discovered endpoint<br/>preserve application SNI / Host"| LISTENER
        LISTENER --> DISPATCH
        DISPATCH --> CONNECTOR
        CONNECTOR --> APP
    end

    CONSUMER -.->|"Configure forwarding"| EDGE
Loading

Addresses, domain names, and ports above are illustrative. Discovery describes
accepted installations, not application health or external ingress readiness.
The Kubernetes driver resolves trusted endpoints; a separate consumer configures
HTTPRoutes or proxy forwarding. Application traffic bypasses the OpenShell
gateway. The proxy and example controller are separate consumers, not part of
this PR's implementation.

Testing

  • 40 focused combined-stack Rust regression tests cover listener routing, route-limit concurrency, shutdown fencing, advertisements, discovery, and proxy behavior.
  • Local k3d validated the Service removal with dedicated push/poll, abrupt shared-owner takeover, HTTP/WebSockets, network-policy denial, freshness/withdrawal, outage recovery, and disabled-listener gateway access. Workload identity and memory survived owner changes.
  • Discovery API snapshots/watches and workspace-restricted discovery credentials drove two proxy replicas; both followed Pod-IP endpoints, graceful owner handover, and route withdrawal without per-sandbox app Services.
  • Helm lint/unit/parser/coherence/scaling checks, Python lint/format/type checks, and topology assertions passed.

Live Kubernetes validation preceded integration of the latest listener hardening;
focused Rust regressions validate that final integration. New-head CI is pending.

Qualification limits

Live HA validation exercised supervisor process/Pod loss on a local k3d cluster. Whole-node failure, real network partitions, metric-driven HPA, sustained production load, and published release artifacts remain unqualified. Grant-file projection contributes to revocation propagation time. Main now includes #4842 for gateway-mediated hashed hostnames. Direct application-listener/discovery hostnames remain readable; extending hashed naming to that path and installation-status presentation (#4670) remain separate work.

The example Kubernetes HTTPRoute controller is a deferred compatibility POC outside this stack.

Checklist

  • Follows Conventional Commits
  • Commits are signed off (DCO)
  • Relevant implementation, configuration, and API documentation updated

PR dependency order: #4941 → #4956 → #4963 → #4964 → #4957.

@copy-pr-bot

copy-pr-bot Bot commented Oct 26, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@github-actions

Copy link
Copy Markdown

@TaylorMutch
TaylorMutch added this pull request to stack #4942 October 26, 2026 22:35
@TaylorMutch
TaylorMutch force-pushed the feat/4667-service-discovery/TaylorMutch branch from c724036 to 932643d Compare October 27, 2026 03:15
@TaylorMutch
TaylorMutch force-pushed the feat/4667-service-discovery/TaylorMutch branch from 932643d to 6c83b66 Compare October 27, 2026 05:42
@TaylorMutch
TaylorMutch removed this pull request from stack #4942 October 27, 2026 05:45
@TaylorMutch
TaylorMutch added this pull request to stack #4965 October 27, 2026 05:46
@TaylorMutch
TaylorMutch removed this pull request from stack #4965 October 27, 2026 05:47
@TaylorMutch
TaylorMutch added this pull request to stack #4966 October 27, 2026 06:24
@TaylorMutch
TaylorMutch force-pushed the feat/4667-service-discovery/TaylorMutch branch from 6c83b66 to 755e4d1 Compare October 27, 2026 06:24
@TaylorMutch
TaylorMutch force-pushed the feat/4667-service-discovery/TaylorMutch branch from 755e4d1 to 0944ea2 Compare October 27, 2026 06:25
@TaylorMutch
TaylorMutch force-pushed the feat/4667-service-discovery/TaylorMutch branch from 0944ea2 to 8504067 Compare October 27, 2026 06:46
@TaylorMutch
TaylorMutch force-pushed the feat/4667-service-discovery/TaylorMutch branch from 8504067 to feac6b4 Compare October 27, 2026 21:06
@TaylorMutch
TaylorMutch force-pushed the feat/4667-service-discovery/TaylorMutch branch from d43f1ae to b8e0f49 Compare October 30, 2026 17:57
@TaylorMutch
TaylorMutch force-pushed the feat/4667-service-discovery/TaylorMutch branch 2 times, most recently from 088c1c3 to facbf94 Compare October 30, 2026 23:00
@TaylorMutch
TaylorMutch marked this pull request as ready for review October 31, 2026 04:24
@github-actions

Copy link
Copy Markdown

Compatibility review (advisory)

New or updated breaking changes; see the full report for all findings.

1. rust-sdk — No explicit risk acceptance found

openshell_sdk::raw::proto::open_shell_server::OpenShell gains required get_service_route_snapshot, watch_service_routes, and WatchServiceRoutesStream items.

2. go-sdk — No explicit risk acceptance found

openshellv1.OpenShellClient gains required GetServiceRouteSnapshot and WatchServiceRoutes methods.

  • Impact: Custom mocks and adapters implementing the complete baseline interface no longer satisfy OpenShellClient; clients returned by NewOpenShellClient remain compatible.
  • Action: Keep OpenShellClient unchanged for v0.1.7 and expose discovery through a separate generated service interface.
  • PR: feat(gateway): expose workspace service route snapshots and watches #4941

3. typescript-sdk — No explicit risk acceptance found

Regenerating Client&lt;typeof OpenShell&gt; adds required getServiceRouteSnapshot and watchServiceRoutes members to the public injected-client parameter.

  • Impact: Complete baseline mocks passed to new SandboxClient(transport, mock) or new SandboxTemplateClient(transport, mock) stop type-checking; ordinary generated clients remain compatible.
  • Action: Preserve the baseline injected-client contract in v0.1.7 by separating discovery into a new service or accepting the baseline method subset.
  • PR: feat(gateway): expose workspace service route snapshots and watches #4941
Review details
  • Execution: complete
  • Candidate: PR #4941 (8c255b3833607d57856f022214ae51fdd40ed9a3)
  • Baseline: 385d228c9cf1ac2c9e3060ccee3723d2592ecbac (385d228c9cf1ac2c9e3060ccee3723d2592ecbac)
  • Reviewed: rust-sdk, python-sdk, go-sdk, typescript-sdk, api-behavior, cli, configuration, policy, helm, state

Advisory review; publication gates are unchanged.

Branch check run

@TaylorMutch
TaylorMutch force-pushed the feat/4667-service-discovery/TaylorMutch branch 2 times, most recently from 7a4aaca to bce60fb Compare November 2, 2026 17:37
@TaylorMutch
TaylorMutch force-pushed the feat/4667-service-discovery/TaylorMutch branch 2 times, most recently from b65481b to 9cf95fd Compare November 3, 2026 19:09
@TaylorMutch
TaylorMutch force-pushed the feat/4667-service-discovery/TaylorMutch branch from 9cf95fd to 2f2a42f Compare November 4, 2026 18:43
@TaylorMutch
TaylorMutch force-pushed the feat/4667-service-discovery/TaylorMutch branch from 2f2a42f to 7423a9c Compare November 4, 2026 18:52
@copy-pr-bot

copy-pr-bot Bot commented Nov 4, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@TaylorMutch

Copy link
Copy Markdown
Collaborator Author

/ok to test 7423a9c

Base automatically changed from feat/4939-service-advertisements/TaylorMutch to main November 4, 2026 21:48
@TaylorMutch
TaylorMutch force-pushed the feat/4667-service-discovery/TaylorMutch branch from 7423a9c to 9a4abc9 Compare November 4, 2026 21:48
@TaylorMutch

Copy link
Copy Markdown
Collaborator Author

/ok to test 9a4abc9

@TaylorMutch

Copy link
Copy Markdown
Collaborator Author

/ok to test 2178482

@TaylorMutch
TaylorMutch force-pushed the feat/4667-service-discovery/TaylorMutch branch from 2178482 to 6d23a9b Compare November 6, 2026 17:27

@pimlock pimlock left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inline comments cover the main findings. The first one, on collect (snapshots failing under lease churn), is the one I'd fix before merge.

Suggestion: a separate service. Putting the two RPCs on service OpenShell means every generated OpenShell server implementation has to add them: the 8 mock stubs in this PR, and the Rust SDK break the compatibility bot flagged. service SandboxUpdates was split out for exactly this reason (proto/openshell.proto:879-881). A ServiceDiscovery service with the same messages would avoid that. It's cheapest to do before #4956 onward and external consumers bind the RPC path.

Follow-ups and nits:

  • Token expiry during a watch: the watch rechecks workspace membership every second, but not token expiry or revocation, so a revoked token can keep reading for up to 60 s. Other gateway streams also check authority only when the stream opens, so this probably belongs in a broader stream-auth follow-up rather than this PR.
  • User docs: the server README lists the limits (1,000 records, 4,096 routes, 4 MiB, 32 watches, 1 s refresh). configuration.mdx lists neither the limits nor the error codes clients see when they hit them (RESOURCE_EXHAUSTED, UNAVAILABLE).
  • Duplicated hostname format: discovery formats {workspace}--{sandbox}[--{service}] itself (service_discovery.rs:210-214), duplicating service_routing.rs:203-207. A shared helper plus a test that the published hostname equals the served one would keep them in sync. The label.len() > 63 branch can't trigger, because names are capped at 19 characters (61 at most).
  • Watch loop: the closed/deadline select! appears three times. Wrapping the loop in one tokio::time::timeout(WATCH_LIFETIME, …) and selecting on tx.closed() around the refresh and send would be shorter and easier to follow.
  • Double decode: stored routes are decoded twice per record, once in is_current and again in collect.
  • Visibility: the discovery_watches field on ServerState is pub, and service_advertisement::tests became pub mod to share a fixture. pub(crate) and a cfg(test) test-support module would be narrower.

async fn collect(state: &ServerState, workspace: &str) -> Result<ServiceRouteSnapshot, Status> {
// Verify the installation collection did not change across the scan.
// This is a bounded verified view, not a cross-resource transaction.
for _ in 0..2 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: discovery fails on every call once a workspace has about 50 installations.

Lease renewal rewrites every installation every 5 s (REPORT_INTERVAL, driven from supervisor_session.rs:2935), and each put_if bumps resource_version. Validating one record with is_current costs about 2.7 ms here, because it builds a full config snapshot. A scan of N records therefore takes about N × 2.7 ms. Once that exceeds 5 s / N, some record always changes between the two records() calls, both attempts fail, and the call returns UNAVAILABLE. A watch's initial snapshot fails the same way, so watchers never connect, and proxies drop every route when the 15 s expiry passes.

I reproduced this with a scratch test (debug build, SQLite). It renewed N installation records at the 5 s cadence and called snapshot() in a loop for 8 s:

Installations Snapshots failed
10 0%
25 0%
50 100% (28/28)
100 100%

A release build or Postgres moves the threshold, but the failure rate still grows with N².

Suggested fix: drop the second scan and the retry. Each record is already validated on its own, and the revision already covers expiry. If you still want a membership check, compare IDs only, not resource versions. A regression test with renewals running during the scan would cover it.

}
let refresh = async {
authorize(&state, &principal, &workspace).await?;
snapshot(&state, &workspace).await

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Total discovery cost has no bound. Each watch reruns the full validated collection every second. That is two list scans plus is_current per record (sandbox read, owner read, pool check, config snapshot build). The revision includes expires_at, so on a busy workspace it changes almost every tick, and every watcher receives a full snapshot of up to 4 MiB nearly every second. Unary GetServiceRouteSnapshot has no concurrency cap beyond the 10 s timeout.

Rough numbers: at about 2.7 ms per record (debug, SQLite), 50 installations × 32 watches is about 4.5 s of store work per second. On a local gateway with an empty store, 50 snapshot loops plus 32 watches raised ListSandboxes p99 from 5 ms to 74 ms. That measurement is noisy, because the load ran on the same host.

Suggestion: share one collection per workspace per refresh tick across watchers, or cache the snapshot for the tick, and bound concurrent unary snapshots.

let permit = state
.discovery_watches
.clone()
.try_acquire_owned()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One user can take the whole watch pool. discovery_watches is a single 32-permit semaphore per gateway process, with no per-user or per-workspace share. A user in one workspace can hold all 32 watches and reopen them every minute. Ingress for every other workspace on that replica then gets RESOURCE_EXHAUSTED. I confirmed that the 33rd watch is refused immediately. I didn't demonstrate the cross-workspace starvation itself, because my test gateway had only one identity.

Suggestion: a per-principal cap (for example 4) and a per-workspace cap under the global one.

))
}

#[cfg(test)]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests miss the documented limits and the identity hash. No test covers the 4,096-route limit or the response-size limit. No test covers the changed-during-collection retry either; that is the path that fails under lease churn in the comment on collect. installation_version stability is checked for lease renewal, but no test shows that it changes when an identity input changes (supervisor instance, connection or ownership epoch, host, port, route bytes). That is the field proxies key on. A table test over those inputs, plus one test per limit, would pin them down.

Signed-off-by: Taylor Mutch <taylormutch@gmail.com>
Signed-off-by: Taylor Mutch <taylormutch@gmail.com>
…nsion

Signed-off-by: Taylor Mutch <taylormutch@gmail.com>
@TaylorMutch
TaylorMutch force-pushed the feat/4667-service-discovery/TaylorMutch branch from 6d23a9b to fcb9be2 Compare November 6, 2026 22:02

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(gateway): expose workspace service route snapshots and watches

2 participants