mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-19 05:50:41 +08:00
* feat(workspace): implement workspace model (Phase 1 of RFC 0011) Implements workspace and membership model providing hard isolation boundaries for multi-player OpenShell deployments. Workspace CRUD with Kubernetes-style Terminating phase for graceful deletion. All resources scoped by workspace via ObjectMeta. Membership RPCs for workspace access control. Persistence migration shifts name uniqueness to (object_type, workspace, name). Provider profiles support platform and workspace scoping. Service routing uses workspace-prefixed DNS labels. Inference routes renamed and workspace-scoped with DeleteInferenceRoute RPC. Python SDK with WorkspaceClient, two-method list pattern (workspace-scoped and for_all_workspaces), and workspace parameter on all methods. CLI workspace flags, TUI workspace cycling. K8s driver filters unmanaged CRs and uses delete preconditions. Podman driver uses immutable container IDs. Label serialization fixed across all put_if call sites. Signed-off-by: Derek Carr <decarr@redhat.com> * fix(cli): delegate sandbox upload command to existing upload function The standalone `sandbox upload` command reimplemented upload logic inline with two bugs: it used `Path::exists()` which follows symlinks (rejecting dangling symlinks), and it ran git-aware filtering on symlink sources. The `run::sandbox_upload()` function already handles both cases correctly via `sandbox_upload_plan()`. Replace the inline logic with a call to the existing function. Signed-off-by: Derek Carr <decarr@redhat.com> * fix(e2e): shorten sandbox names and fix test compatibility Shorten the sandbox name in initial_sparse_policy_is_acknowledged_as_loaded from 'e2e-2159-sparse-enrich' (22 chars) to 'e2e-sparse-enrich' (17 chars) to comply with MAX_ROUTABLE_NAME_LEN (19 chars). Also capture stderr in create_keep_with_args so future sandbox creation failures include the actual CLI error instead of reporting empty output. Signed-off-by: Derek Carr <decarr@redhat.com> * test(workspace): add test coverage for workspace CRUD and persistence isolation Add unit tests for workspace create happy path, get round-trip, get not-found, get empty-name rejection, already-exists error, and resolve_workspace not-found. Add persistence test proving cross-workspace name uniqueness (same name in different workspaces produces separate records). Add workspace name max-length boundary tests. Fix e2e harness to include stderr in name-parse-failure error path. Align Python e2e test_workspace_crud with try/finally pattern. Document provider profile catalog workspace scoping gap in RFC 0011. Signed-off-by: Derek Carr <decarr@redhat.com> * fix(examples): update examples for workspace model compatibility Shorten sandbox names in demo scripts to fit the 19-character MAX_ROUTABLE_NAME_LEN limit: policy-demo prefix to pd-, multi-agent notepad derives a short SANDBOX_TAG from the run ID, governance interceptor uses gs-PID-RANDOM. Update vscode-remote-sandbox.md SSH host aliases from openshell-{name} to openshell-{name}.{workspace} format. Signed-off-by: Derek Carr <decarr@redhat.com> * feat(sdk): add workspace-scoped client and workspace CRUD Add WorkspaceScopedClient modeled after kube::Api::namespaced — captures workspace once and injects it into every sandbox request. Add workspace CRUD methods (create, get, list, delete) and list_sandboxes_all_workspaces on OpenShellClient. Extend SandboxRef with workspace field and add WorkspaceRef type. Include mock tests for all new operations. Signed-off-by: Derek Carr <decarr@redhat.com> * fix(lint): resolve clippy warnings in workspace test assertions Signed-off-by: Derek Carr <decarr@redhat.com> * fix(docs): convert indented code blocks to fenced in RFC 0011 Signed-off-by: Derek Carr <decarr@redhat.com> * fix(lint): resolve clippy warnings and apply cargo fmt across workspace Auto-format with cargo fmt and fix clippy warnings exposed by the reformat: unnecessary qualifications, map_unwrap_or, identical match arms, unused variable prefix, dead code annotations, and let-unit-value in e2e harness. Signed-off-by: Derek Carr <decarr@redhat.com> * fix(workspace): address workspace scoping issues from review - Add workspace field to settings JSON output (CLI) - Skip Podman containers missing workspace label instead of defaulting to empty string, matching K8s driver behavior - Add resource_version to list_by_scope SELECT in both SQLite and Postgres backends, with regression test - Gate PolicyLocalContext proposal/lookup routes on workspace readiness, returning 503 when workspace is not yet discovered - Block sandbox and provider creation in TUI all-workspaces mode - Clear workspace vectors in TUI reset_sandbox_state Signed-off-by: Derek Carr <decarr@redhat.com> * fix(workspace): make provider profile catalog workspace-aware Thread workspace through snapshot_catalog so the EffectiveProviderProfileCatalog enforces workspace boundaries on both read and write paths. UserProviderProfileSource now loads platform-scoped profiles (workspace "") plus the target workspace's profiles, preventing cross-workspace duplicate profile ID collisions that previously caused global catalog failures. Update RFC 0011 to reflect catalog scoping is implemented in Phase 1 rather than deferred to future work. Signed-off-by: Derek Carr <decarr@redhat.com> * fix(persistence): include workspace column in atomic policy revision INSERT put_policy_revision_atomic omitted the workspace column from the INSERT into the objects table in both SQLite and Postgres backends, causing atomically-written policy revisions to lose their workspace association. Add workspace field to AtomicPolicyRevisionWrite and thread it through both backend INSERT statements, matching the non-atomic put_policy_revision path which already included it. Signed-off-by: Derek Carr <decarr@redhat.com> * fix(proxy): skip ancestor walk when socket owner is the entrypoint collect_ancestor_identities walked the entire process tree above the entrypoint when the connecting process was the entrypoint itself, SHA256-hashing every ancestor binary (IDE, shell, container runtime). On dev machines with large binaries in the ancestor chain this exceeded the 30-second test timeout. When start_pid == stop_pid there are no intermediate ancestors to verify, so return an empty list immediately. Signed-off-by: Derek Carr <decarr@redhat.com> * fix(workspace): make provider profile catalog scope-aware Allow the same profile ID at platform and workspace scopes by introducing layered catalog entries where workspace profiles shadow platform profiles. Add source and scope fields to the ProviderProfile proto and CLI output. Migrate List/Get handlers to the catalog, fixing divergence with runtime profile resolution. Signed-off-by: Derek Carr <decarr@redhat.com> * fix(e2e): align podman e2e labels with centralized driver constants The podman driver moved its container labels to the centralized openshell.ai/ prefix, but the e2e test harness and cleanup script still referenced the old openshell.sandbox-* keys, causing the local_driver_token_restart test to fail on container lookup. Signed-off-by: Derek Carr <decarr@redhat.com> * fix(e2e): align python profile isolation test with scope-aware catalog Platform profiles are now visible in workspace listings as fallbacks per the layered catalog design. Update the assertion to match. Signed-off-by: Derek Carr <decarr@redhat.com> * fix(workspace): honor profile_workspace in runtime profile resolution Runtime profile lookups now consult provider.profile_workspace via get_type_profile_for_scope. Providers created with --global-profile (profile_workspace="") resolve to the platform profile even when a workspace profile shadows the same ID. All 6 runtime call sites updated; type-only call sites remain scope-agnostic. Signed-off-by: Derek Carr <decarr@redhat.com> --------- Signed-off-by: Derek Carr <decarr@redhat.com>
1501 lines
53 KiB
Rust
1501 lines
53 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
use std::collections::HashMap;
|
|
use std::pin::Pin;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use tokio::sync::{mpsc, oneshot};
|
|
use tokio_stream::wrappers::ReceiverStream;
|
|
use tonic::{Request, Response, Status};
|
|
use tracing::{info, warn};
|
|
use uuid::Uuid;
|
|
|
|
use openshell_core::proto::{
|
|
GatewayMessage, RelayFrame, RelayInit, RelayOpen, Sandbox, SessionAccepted, SshRelayTarget,
|
|
SupervisorMessage, gateway_message, relay_open, supervisor_message,
|
|
};
|
|
|
|
use crate::ServerState;
|
|
use crate::auth::principal::Principal;
|
|
|
|
const HEARTBEAT_INTERVAL_SECS: u32 = 15;
|
|
const RELAY_PENDING_TIMEOUT: Duration = Duration::from_secs(10);
|
|
/// Initial backoff between session-availability polls in `wait_for_session`.
|
|
const SESSION_WAIT_INITIAL_BACKOFF: Duration = Duration::from_millis(100);
|
|
/// Maximum backoff between session-availability polls in `wait_for_session`.
|
|
const SESSION_WAIT_MAX_BACKOFF: Duration = Duration::from_secs(2);
|
|
/// Upper bound on unclaimed relay channels across all sandboxes. Caps the
|
|
/// memory a misbehaving caller can pin by calling `open_relay` repeatedly
|
|
/// while the supervisor never claims (or isn't responding). Sized generously
|
|
/// so normal bursts pass through; exceeding it returns `ResourceExhausted`.
|
|
const MAX_PENDING_RELAYS: usize = 256;
|
|
/// Upper bound on concurrent unclaimed relay channels for a single sandbox.
|
|
/// Enforces the same shape per sandbox so one misbehaving sandbox can't
|
|
/// consume the entire global budget. Sits above the SSH-tunnel per-sandbox
|
|
/// cap (20) so tunnel-specific limits still fire first for that caller.
|
|
const MAX_PENDING_RELAYS_PER_SANDBOX: usize = 32;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Session registry
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A live supervisor session handle.
|
|
struct LiveSession {
|
|
#[allow(dead_code)]
|
|
sandbox_id: String,
|
|
/// Uniquely identifies this session instance. Used by cleanup to avoid
|
|
/// removing a session that has since been superseded by a reconnect.
|
|
session_id: String,
|
|
tx: mpsc::Sender<GatewayMessage>,
|
|
/// Fires when this session is superseded by a reconnect so the old session
|
|
/// task can exit promptly — dropping its own `tx` clone and closing the
|
|
/// outbound stream. Without this, a concurrent `open_relay` that grabbed
|
|
/// the old session's `tx` just before supersede could still enqueue a
|
|
/// `RelayOpen` onto the stale stream and sit until the relay timeout.
|
|
shutdown: oneshot::Sender<()>,
|
|
#[allow(dead_code)]
|
|
connected_at: Instant,
|
|
}
|
|
|
|
/// Holds a oneshot sender that will deliver the upgraded relay stream or a
|
|
/// target-open failure reported by the supervisor.
|
|
type RelayStreamSender = oneshot::Sender<Result<tokio::io::DuplexStream, Status>>;
|
|
|
|
impl openshell_driver_docker::SupervisorReadiness for SupervisorSessionRegistry {
|
|
fn is_supervisor_connected(&self, sandbox_id: &str) -> bool {
|
|
Self::is_connected(self, sandbox_id)
|
|
}
|
|
}
|
|
|
|
/// Registry of active supervisor sessions and pending relay channels.
|
|
#[derive(Default)]
|
|
pub struct SupervisorSessionRegistry {
|
|
/// `sandbox_id` -> live session handle.
|
|
sessions: Mutex<HashMap<String, LiveSession>>,
|
|
/// `channel_id` -> oneshot sender for the reverse CONNECT stream.
|
|
pending_relays: Mutex<HashMap<String, PendingRelay>>,
|
|
}
|
|
|
|
struct PendingRelay {
|
|
sender: RelayStreamSender,
|
|
sandbox_id: String,
|
|
relay_open: RelayOpen,
|
|
created_at: Instant,
|
|
}
|
|
|
|
impl std::fmt::Debug for SupervisorSessionRegistry {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
let session_count = self.sessions.lock().unwrap().len();
|
|
let pending_count = self.pending_relays.lock().unwrap().len();
|
|
f.debug_struct("SupervisorSessionRegistry")
|
|
.field("sessions", &session_count)
|
|
.field("pending_relays", &pending_count)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl SupervisorSessionRegistry {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Register a live supervisor session for the given sandbox.
|
|
///
|
|
/// If a previous session exists for the same sandbox, its shutdown signal
|
|
/// is fired so the old session task exits promptly. Returns `true` iff a
|
|
/// previous session was superseded.
|
|
pub fn register(
|
|
&self,
|
|
sandbox_id: String,
|
|
session_id: String,
|
|
tx: mpsc::Sender<GatewayMessage>,
|
|
shutdown: oneshot::Sender<()>,
|
|
) -> bool {
|
|
let mut sessions = self.sessions.lock().unwrap();
|
|
let previous = sessions.remove(&sandbox_id);
|
|
sessions.insert(
|
|
sandbox_id.clone(),
|
|
LiveSession {
|
|
sandbox_id,
|
|
session_id,
|
|
tx,
|
|
shutdown,
|
|
connected_at: Instant::now(),
|
|
},
|
|
);
|
|
match previous {
|
|
Some(prev) => {
|
|
// Best-effort — the old task may have already exited.
|
|
let _ = prev.shutdown.send(());
|
|
true
|
|
}
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
/// Report whether a live supervisor session is registered for a sandbox.
|
|
///
|
|
/// Used by compute drivers that need to surface "supervisor relay ready"
|
|
/// through the Ready condition without polling the sandbox runtime.
|
|
pub fn is_connected(&self, sandbox_id: &str) -> bool {
|
|
self.sessions.lock().unwrap().contains_key(sandbox_id)
|
|
}
|
|
|
|
/// Remove the session for a sandbox.
|
|
fn remove(&self, sandbox_id: &str) {
|
|
self.sessions.lock().unwrap().remove(sandbox_id);
|
|
}
|
|
|
|
/// Remove the session only if its `session_id` matches the one we are
|
|
/// cleaning up. Returns `true` if the entry was removed.
|
|
///
|
|
/// This guards against the supersede race: an old session's task may
|
|
/// finish long after a new session has taken its place. The old task's
|
|
/// cleanup must not evict the new registration.
|
|
fn remove_if_current(&self, sandbox_id: &str, session_id: &str) -> bool {
|
|
let mut sessions = self.sessions.lock().unwrap();
|
|
let is_current = sessions
|
|
.get(sandbox_id)
|
|
.is_some_and(|s| s.session_id == session_id);
|
|
if is_current {
|
|
sessions.remove(sandbox_id);
|
|
}
|
|
is_current
|
|
}
|
|
|
|
/// Look up the sender for a supervisor session, waiting up to `timeout`
|
|
/// for it to appear if absent.
|
|
///
|
|
/// Uses exponential backoff (100ms → 2s) while polling the sessions map.
|
|
async fn wait_for_session(
|
|
&self,
|
|
sandbox_id: &str,
|
|
timeout: Duration,
|
|
) -> Result<mpsc::Sender<GatewayMessage>, Status> {
|
|
let deadline = Instant::now() + timeout;
|
|
let mut backoff = SESSION_WAIT_INITIAL_BACKOFF;
|
|
|
|
loop {
|
|
if let Some(tx) = self.lookup_session(sandbox_id) {
|
|
return Ok(tx);
|
|
}
|
|
if Instant::now() + backoff > deadline {
|
|
return Err(Status::unavailable("supervisor session not connected"));
|
|
}
|
|
tokio::time::sleep(backoff).await;
|
|
backoff = (backoff * 2).min(SESSION_WAIT_MAX_BACKOFF);
|
|
}
|
|
}
|
|
|
|
fn lookup_session(&self, sandbox_id: &str) -> Option<mpsc::Sender<GatewayMessage>> {
|
|
self.sessions
|
|
.lock()
|
|
.unwrap()
|
|
.get(sandbox_id)
|
|
.map(|s| s.tx.clone())
|
|
}
|
|
|
|
pub fn has_session(&self, sandbox_id: &str) -> bool {
|
|
self.sessions.lock().unwrap().contains_key(sandbox_id)
|
|
}
|
|
|
|
fn pending_channel_ids(&self, sandbox_id: &str) -> Vec<String> {
|
|
self.pending_relays
|
|
.lock()
|
|
.unwrap()
|
|
.iter()
|
|
.filter(|(_, pending)| pending.sandbox_id == sandbox_id)
|
|
.map(|(channel_id, _)| channel_id.clone())
|
|
.collect()
|
|
}
|
|
|
|
/// Open a relay channel and return a receiver for the supervisor-side
|
|
/// stream.
|
|
///
|
|
/// Sends `RelayOpen` over the supervisor's gRPC session and returns a
|
|
/// oneshot receiver that resolves once the supervisor opens its reverse
|
|
/// HTTP CONNECT to `/relay/{channel_id}`.
|
|
///
|
|
/// If the session is not currently registered, this method waits up to
|
|
/// `session_wait_timeout` for it to appear. A session may be temporarily
|
|
/// absent for several reasons — all of which look identical from here:
|
|
///
|
|
/// - startup race: the sandbox just reported Ready but the supervisor's
|
|
/// `ConnectSupervisor` gRPC handshake hasn't completed yet
|
|
/// - transient disconnect: the session was up but got dropped (network
|
|
/// blip, gateway restart, supervisor restart) and the supervisor is
|
|
/// in its reconnect backoff loop
|
|
///
|
|
/// Callers pick the timeout based on how much patience the caller needs.
|
|
/// A first `sandbox connect` right after `sandbox create` may need to
|
|
/// wait for the supervisor's initial TLS + gRPC handshake (tens of
|
|
/// seconds on a slow cluster), while mid-lifetime calls typically just
|
|
/// need to cover a short reconnect window.
|
|
pub async fn open_relay(
|
|
&self,
|
|
sandbox_id: &str,
|
|
session_wait_timeout: Duration,
|
|
) -> Result<
|
|
(
|
|
String,
|
|
oneshot::Receiver<Result<tokio::io::DuplexStream, Status>>,
|
|
),
|
|
Status,
|
|
> {
|
|
self.open_relay_with_target(
|
|
sandbox_id,
|
|
relay_open::Target::Ssh(SshRelayTarget {}),
|
|
String::new(),
|
|
session_wait_timeout,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn open_relay_with_target(
|
|
&self,
|
|
sandbox_id: &str,
|
|
target: relay_open::Target,
|
|
service_id: String,
|
|
session_wait_timeout: Duration,
|
|
) -> Result<
|
|
(
|
|
String,
|
|
oneshot::Receiver<Result<tokio::io::DuplexStream, Status>>,
|
|
),
|
|
Status,
|
|
> {
|
|
let tx = self
|
|
.wait_for_session(sandbox_id, session_wait_timeout)
|
|
.await?;
|
|
|
|
let channel_id = Uuid::new_v4().to_string();
|
|
let relay_open = RelayOpen {
|
|
channel_id: channel_id.clone(),
|
|
target: Some(target),
|
|
service_id,
|
|
};
|
|
|
|
// Register the pending relay before sending RelayOpen to avoid a race.
|
|
// Both caps are checked and the insert happens under a single lock hold
|
|
// so two concurrent calls can't both observe "under the cap" and then
|
|
// both insert past it.
|
|
let (relay_tx, relay_rx) = oneshot::channel();
|
|
{
|
|
let mut pending = self.pending_relays.lock().unwrap();
|
|
if pending.len() >= MAX_PENDING_RELAYS {
|
|
return Err(Status::resource_exhausted(format!(
|
|
"gateway relay capacity reached ({MAX_PENDING_RELAYS} in flight)"
|
|
)));
|
|
}
|
|
let per_sandbox = pending
|
|
.values()
|
|
.filter(|p| p.sandbox_id == sandbox_id)
|
|
.count();
|
|
if per_sandbox >= MAX_PENDING_RELAYS_PER_SANDBOX {
|
|
return Err(Status::resource_exhausted(format!(
|
|
"per-sandbox relay limit reached ({MAX_PENDING_RELAYS_PER_SANDBOX} in flight for {sandbox_id})"
|
|
)));
|
|
}
|
|
pending.insert(
|
|
channel_id.clone(),
|
|
PendingRelay {
|
|
sender: relay_tx,
|
|
sandbox_id: sandbox_id.to_string(),
|
|
relay_open: relay_open.clone(),
|
|
created_at: Instant::now(),
|
|
},
|
|
);
|
|
}
|
|
|
|
let msg = GatewayMessage {
|
|
payload: Some(gateway_message::Payload::RelayOpen(relay_open)),
|
|
};
|
|
|
|
if tx.send(msg).await.is_err() {
|
|
// Session dropped between our lookup and send.
|
|
self.pending_relays.lock().unwrap().remove(&channel_id);
|
|
return Err(Status::unavailable("supervisor session disconnected"));
|
|
}
|
|
|
|
Ok((channel_id, relay_rx))
|
|
}
|
|
|
|
pub fn fail_pending_relay(&self, channel_id: &str, error: String) -> bool {
|
|
let pending = self.pending_relays.lock().unwrap().remove(channel_id);
|
|
if let Some(pending) = pending {
|
|
let _ = pending.sender.send(Err(Status::unavailable(error)));
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Claim a pending relay channel. Called by the `/relay/{channel_id}` HTTP handler
|
|
/// when the supervisor's reverse CONNECT arrives.
|
|
///
|
|
/// Returns the `DuplexStream` half that the supervisor side should read/write.
|
|
// `tonic::Status` is large but is the API surface of gRPC handlers.
|
|
#[allow(clippy::result_large_err)]
|
|
pub fn claim_relay(
|
|
&self,
|
|
channel_id: &str,
|
|
principal: Option<&Principal>,
|
|
) -> Result<tokio::io::DuplexStream, Status> {
|
|
let pending = {
|
|
let mut map = self.pending_relays.lock().unwrap();
|
|
let pending = map
|
|
.get(channel_id)
|
|
.ok_or_else(|| Status::not_found("unknown or expired relay channel"))?;
|
|
|
|
if let Some(principal) = principal
|
|
&& let Err(status) = crate::auth::guard::ensure_sandbox_principal_scope(
|
|
principal,
|
|
&pending.sandbox_id,
|
|
)
|
|
{
|
|
info!(
|
|
channel_id = %channel_id,
|
|
sandbox_id = %pending.sandbox_id,
|
|
"relay stream: rejecting cross-sandbox claim"
|
|
);
|
|
return Err(status);
|
|
}
|
|
|
|
if pending.created_at.elapsed() > RELAY_PENDING_TIMEOUT {
|
|
map.remove(channel_id);
|
|
return Err(Status::deadline_exceeded("relay channel timed out"));
|
|
}
|
|
|
|
map.remove(channel_id)
|
|
.expect("pending relay existed before removal")
|
|
};
|
|
|
|
// Create a duplex stream pair: one end for the gateway bridge, one for
|
|
// the supervisor HTTP CONNECT handler.
|
|
let (gateway_stream, supervisor_stream) = tokio::io::duplex(64 * 1024);
|
|
|
|
// Send the gateway-side stream to the waiter (exec handler or forward handler).
|
|
if pending.sender.send(Ok(gateway_stream)).is_err() {
|
|
return Err(Status::internal("relay requester dropped"));
|
|
}
|
|
|
|
Ok(supervisor_stream)
|
|
}
|
|
|
|
/// Remove all pending relays that have exceeded the timeout.
|
|
pub fn reap_expired_relays(&self) {
|
|
let mut map = self.pending_relays.lock().unwrap();
|
|
map.retain(|_, pending| pending.created_at.elapsed() <= RELAY_PENDING_TIMEOUT);
|
|
}
|
|
|
|
/// Clean up all state for a sandbox (session + pending relays).
|
|
pub fn cleanup_sandbox(&self, sandbox_id: &str) {
|
|
self.remove(sandbox_id);
|
|
}
|
|
|
|
pub async fn replay_pending_relays(&self, sandbox_id: &str, tx: &mpsc::Sender<GatewayMessage>) {
|
|
for channel_id in self.pending_channel_ids(sandbox_id) {
|
|
let relay_open = {
|
|
let pending = self.pending_relays.lock().unwrap();
|
|
pending
|
|
.get(&channel_id)
|
|
.map(|pending| pending.relay_open.clone())
|
|
};
|
|
let Some(relay_open) = relay_open else {
|
|
continue;
|
|
};
|
|
let msg = GatewayMessage {
|
|
payload: Some(gateway_message::Payload::RelayOpen(relay_open)),
|
|
};
|
|
if tx.send(msg).await.is_err() {
|
|
warn!(sandbox_id = %sandbox_id, channel_id = %channel_id, "supervisor session: failed to replay pending relay to superseding session");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Spawn a background task that periodically reaps expired pending relay
|
|
/// entries.
|
|
///
|
|
/// Pending entries are normally consumed either when the supervisor opens its
|
|
/// reverse CONNECT (via `claim_relay`) or by the gateway-side waiter timing
|
|
/// out. If neither happens — e.g., the supervisor crashed after acknowledging
|
|
/// `RelayOpen` but before initiating `RelayStream` — the entry would otherwise
|
|
/// sit in the map indefinitely. This sweeper bounds that leak.
|
|
pub fn spawn_relay_reaper(state: Arc<ServerState>, interval: Duration) {
|
|
tokio::spawn(async move {
|
|
loop {
|
|
tokio::time::sleep(interval).await;
|
|
state.supervisor_sessions.reap_expired_relays();
|
|
}
|
|
});
|
|
}
|
|
|
|
async fn require_persisted_sandbox(
|
|
store: &Arc<crate::persistence::Store>,
|
|
sandbox_id: &str,
|
|
) -> Result<(), Status> {
|
|
let sandbox = store
|
|
.get_message::<Sandbox>(sandbox_id)
|
|
.await
|
|
.map_err(|err| Status::internal(format!("failed to load sandbox: {err}")))?;
|
|
|
|
if sandbox.is_none() {
|
|
return Err(Status::not_found("sandbox not found"));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// RelayStream gRPC handler
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Size of chunks read from the gateway-side `DuplexStream` when forwarding
|
|
/// bytes back to the supervisor over the gRPC response stream.
|
|
const RELAY_STREAM_CHUNK_SIZE: usize = 16 * 1024;
|
|
|
|
/// Handle a `RelayStream` RPC from a supervisor.
|
|
///
|
|
/// The first inbound `RelayFrame` must carry a `RelayInit` identifying the
|
|
/// pending relay; subsequent frames carry raw bytes forward to the
|
|
/// gateway-side waiter. Bytes flowing the other way are chunked and sent as
|
|
/// `RelayFrame::data` messages back over the response stream.
|
|
pub async fn handle_relay_stream(
|
|
registry: &SupervisorSessionRegistry,
|
|
request: Request<tonic::Streaming<RelayFrame>>,
|
|
) -> Result<
|
|
Response<
|
|
Pin<Box<dyn tokio_stream::Stream<Item = Result<RelayFrame, Status>> + Send + 'static>>,
|
|
>,
|
|
Status,
|
|
> {
|
|
let principal = request.extensions().get::<Principal>().cloned();
|
|
let mut inbound = request.into_inner();
|
|
|
|
// First frame must identify the channel.
|
|
let first = inbound
|
|
.message()
|
|
.await?
|
|
.ok_or_else(|| Status::invalid_argument("empty RelayStream"))?;
|
|
let channel_id = match first.payload {
|
|
Some(openshell_core::proto::relay_frame::Payload::Init(RelayInit { channel_id }))
|
|
if !channel_id.is_empty() =>
|
|
{
|
|
channel_id
|
|
}
|
|
_ => {
|
|
return Err(Status::invalid_argument(
|
|
"first RelayFrame must be init with non-empty channel_id",
|
|
));
|
|
}
|
|
};
|
|
|
|
// Claim the pending relay. Consumes the entry — it cannot be reused.
|
|
let supervisor_side = registry.claim_relay(&channel_id, principal.as_ref())?;
|
|
info!(channel_id = %channel_id, "relay stream: claimed pending relay, bridging");
|
|
|
|
let (mut read_half, mut write_half) = tokio::io::split(supervisor_side);
|
|
|
|
// Supervisor → gateway: drain `inbound` and write to the DuplexStream.
|
|
let channel_id_in = channel_id.clone();
|
|
tokio::spawn(async move {
|
|
loop {
|
|
match inbound.message().await {
|
|
Ok(Some(frame)) => {
|
|
let Some(openshell_core::proto::relay_frame::Payload::Data(data)) =
|
|
frame.payload
|
|
else {
|
|
warn!(channel_id = %channel_id_in, "relay stream: received non-data frame after init");
|
|
break;
|
|
};
|
|
if data.is_empty() {
|
|
continue;
|
|
}
|
|
if let Err(e) =
|
|
tokio::io::AsyncWriteExt::write_all(&mut write_half, &data).await
|
|
{
|
|
warn!(channel_id = %channel_id_in, error = %e, "relay stream: write to duplex failed");
|
|
break;
|
|
}
|
|
}
|
|
Ok(None) => break,
|
|
Err(e) => {
|
|
warn!(channel_id = %channel_id_in, error = %e, "relay stream: inbound errored");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// Best-effort half-close on the write side so the reader sees EOF.
|
|
let _ = tokio::io::AsyncWriteExt::shutdown(&mut write_half).await;
|
|
});
|
|
|
|
// Gateway → supervisor: read the DuplexStream and emit RelayFrame::data messages.
|
|
let (out_tx, out_rx) = mpsc::channel::<Result<RelayFrame, Status>>(16);
|
|
let channel_id_out = channel_id;
|
|
tokio::spawn(async move {
|
|
let mut buf = vec![0u8; RELAY_STREAM_CHUNK_SIZE];
|
|
loop {
|
|
match tokio::io::AsyncReadExt::read(&mut read_half, &mut buf).await {
|
|
Ok(0) => break,
|
|
Ok(n) => {
|
|
let chunk = RelayFrame {
|
|
payload: Some(openshell_core::proto::relay_frame::Payload::Data(
|
|
buf[..n].to_vec(),
|
|
)),
|
|
};
|
|
if out_tx.send(Ok(chunk)).await.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
warn!(channel_id = %channel_id_out, error = %e, "relay stream: read from duplex failed");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
let stream = ReceiverStream::new(out_rx);
|
|
let stream: Pin<
|
|
Box<dyn tokio_stream::Stream<Item = Result<RelayFrame, Status>> + Send + 'static>,
|
|
> = Box::pin(stream);
|
|
Ok(Response::new(stream))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ConnectSupervisor gRPC handler
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub async fn handle_connect_supervisor(
|
|
state: &Arc<ServerState>,
|
|
request: Request<tonic::Streaming<SupervisorMessage>>,
|
|
) -> Result<
|
|
Response<
|
|
Pin<Box<dyn tokio_stream::Stream<Item = Result<GatewayMessage, Status>> + Send + 'static>>,
|
|
>,
|
|
Status,
|
|
> {
|
|
let principal = request.extensions().get::<Principal>().cloned();
|
|
let mut inbound = request.into_inner();
|
|
|
|
// Step 1: Wait for SupervisorHello.
|
|
let hello = match inbound.message().await? {
|
|
Some(msg) => match msg.payload {
|
|
Some(supervisor_message::Payload::Hello(hello)) => hello,
|
|
_ => return Err(Status::invalid_argument("expected SupervisorHello")),
|
|
},
|
|
None => return Err(Status::invalid_argument("stream closed before hello")),
|
|
};
|
|
|
|
let sandbox_id = hello.sandbox_id.clone();
|
|
if sandbox_id.is_empty() {
|
|
return Err(Status::invalid_argument("sandbox_id is required"));
|
|
}
|
|
if let Some(principal) = principal.as_ref() {
|
|
crate::auth::guard::ensure_sandbox_principal_scope(principal, &sandbox_id)?;
|
|
}
|
|
require_persisted_sandbox(&state.store, &sandbox_id).await?;
|
|
|
|
let session_id = Uuid::new_v4().to_string();
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
session_id = %session_id,
|
|
instance_id = %hello.instance_id,
|
|
"supervisor session: accepted"
|
|
);
|
|
|
|
// Step 2: Create the outbound channel and register the session.
|
|
let (tx, rx) = mpsc::channel::<GatewayMessage>(64);
|
|
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
|
|
let superseded = state.supervisor_sessions.register(
|
|
sandbox_id.clone(),
|
|
session_id.clone(),
|
|
tx.clone(),
|
|
shutdown_tx,
|
|
);
|
|
if superseded {
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
session_id = %session_id,
|
|
"supervisor session: superseded previous session"
|
|
);
|
|
}
|
|
|
|
// Step 3: Send SessionAccepted.
|
|
let accepted = GatewayMessage {
|
|
payload: Some(gateway_message::Payload::SessionAccepted(SessionAccepted {
|
|
session_id: session_id.clone(),
|
|
heartbeat_interval_secs: HEARTBEAT_INTERVAL_SECS,
|
|
})),
|
|
};
|
|
if tx.send(accepted).await.is_err() {
|
|
// Only evict ourselves — a faster reconnect may already have
|
|
// superseded this registration.
|
|
state
|
|
.supervisor_sessions
|
|
.remove_if_current(&sandbox_id, &session_id);
|
|
return Err(Status::internal("failed to send session accepted"));
|
|
}
|
|
|
|
if superseded {
|
|
state
|
|
.supervisor_sessions
|
|
.replay_pending_relays(&sandbox_id, &tx)
|
|
.await;
|
|
}
|
|
|
|
if let Err(err) = state
|
|
.compute
|
|
.supervisor_session_connected(&sandbox_id)
|
|
.await
|
|
{
|
|
warn!(
|
|
sandbox_id = %sandbox_id,
|
|
session_id = %session_id,
|
|
error = %err,
|
|
"supervisor session: failed to mark sandbox ready"
|
|
);
|
|
} else {
|
|
state.telemetry.sandbox_session_connected(&sandbox_id);
|
|
}
|
|
|
|
// Step 4: Spawn the session loop that reads inbound messages.
|
|
let state_clone = Arc::clone(state);
|
|
let sandbox_id_clone = sandbox_id.clone();
|
|
tokio::spawn(async move {
|
|
run_session_loop(
|
|
&state_clone,
|
|
&sandbox_id_clone,
|
|
&session_id,
|
|
&tx,
|
|
&mut inbound,
|
|
shutdown_rx,
|
|
)
|
|
.await;
|
|
let still_ours = state_clone
|
|
.supervisor_sessions
|
|
.remove_if_current(&sandbox_id_clone, &session_id);
|
|
if still_ours {
|
|
info!(sandbox_id = %sandbox_id_clone, session_id = %session_id, "supervisor session: ended");
|
|
state_clone
|
|
.telemetry
|
|
.sandbox_session_disconnected(&sandbox_id_clone);
|
|
if let Err(err) = state_clone
|
|
.compute
|
|
.supervisor_session_disconnected(&sandbox_id_clone)
|
|
.await
|
|
{
|
|
warn!(
|
|
sandbox_id = %sandbox_id_clone,
|
|
session_id = %session_id,
|
|
error = %err,
|
|
"supervisor session: failed to mark sandbox disconnected"
|
|
);
|
|
}
|
|
} else {
|
|
info!(sandbox_id = %sandbox_id_clone, session_id = %session_id, "supervisor session: ended (already superseded)");
|
|
}
|
|
});
|
|
|
|
// Return the outbound stream.
|
|
let stream = ReceiverStream::new(rx);
|
|
let stream: Pin<
|
|
Box<dyn tokio_stream::Stream<Item = Result<GatewayMessage, Status>> + Send + 'static>,
|
|
> = Box::pin(tokio_stream::StreamExt::map(stream, Ok));
|
|
|
|
Ok(Response::new(stream))
|
|
}
|
|
|
|
async fn run_session_loop(
|
|
state: &Arc<ServerState>,
|
|
sandbox_id: &str,
|
|
session_id: &str,
|
|
tx: &mpsc::Sender<GatewayMessage>,
|
|
inbound: &mut tonic::Streaming<SupervisorMessage>,
|
|
mut shutdown_rx: oneshot::Receiver<()>,
|
|
) {
|
|
let heartbeat_interval = Duration::from_secs(u64::from(HEARTBEAT_INTERVAL_SECS));
|
|
let mut heartbeat_timer = tokio::time::interval(heartbeat_interval);
|
|
// Skip the first immediate tick.
|
|
heartbeat_timer.tick().await;
|
|
|
|
loop {
|
|
tokio::select! {
|
|
_ = &mut shutdown_rx => {
|
|
info!(sandbox_id = %sandbox_id, session_id = %session_id, "supervisor session: superseded by reconnect, shutting down");
|
|
break;
|
|
}
|
|
msg = inbound.message() => {
|
|
match msg {
|
|
Ok(Some(msg)) => {
|
|
handle_supervisor_message(state, sandbox_id, session_id, msg);
|
|
}
|
|
Ok(None) => {
|
|
info!(sandbox_id = %sandbox_id, session_id = %session_id, "supervisor session: stream closed by supervisor");
|
|
break;
|
|
}
|
|
Err(e) => {
|
|
warn!(sandbox_id = %sandbox_id, session_id = %session_id, error = %e, "supervisor session: stream error");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
_ = heartbeat_timer.tick() => {
|
|
let hb = GatewayMessage {
|
|
payload: Some(gateway_message::Payload::Heartbeat(
|
|
openshell_core::proto::GatewayHeartbeat {},
|
|
)),
|
|
};
|
|
if tx.send(hb).await.is_err() {
|
|
info!(sandbox_id = %sandbox_id, session_id = %session_id, "supervisor session: outbound channel closed");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_supervisor_message(
|
|
state: &Arc<ServerState>,
|
|
sandbox_id: &str,
|
|
session_id: &str,
|
|
msg: SupervisorMessage,
|
|
) {
|
|
match msg.payload {
|
|
Some(supervisor_message::Payload::Heartbeat(_)) => {
|
|
// Heartbeat received — nothing to do for now.
|
|
}
|
|
Some(supervisor_message::Payload::RelayOpenResult(result)) => {
|
|
if result.success {
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
session_id = %session_id,
|
|
channel_id = %result.channel_id,
|
|
"supervisor session: relay opened successfully"
|
|
);
|
|
} else {
|
|
let failed = state
|
|
.supervisor_sessions
|
|
.fail_pending_relay(&result.channel_id, result.error.clone());
|
|
warn!(
|
|
sandbox_id = %sandbox_id,
|
|
session_id = %session_id,
|
|
channel_id = %result.channel_id,
|
|
error = %result.error,
|
|
pending_relay_failed = failed,
|
|
"supervisor session: relay open failed"
|
|
);
|
|
}
|
|
}
|
|
Some(supervisor_message::Payload::RelayClose(close)) => {
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
session_id = %session_id,
|
|
channel_id = %close.channel_id,
|
|
reason = %close.reason,
|
|
"supervisor session: relay closed by supervisor"
|
|
);
|
|
}
|
|
_ => {
|
|
warn!(
|
|
sandbox_id = %sandbox_id,
|
|
session_id = %session_id,
|
|
"supervisor session: unexpected message type"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::auth::identity::{Identity, IdentityProvider};
|
|
use crate::auth::principal::{SandboxIdentitySource, SandboxPrincipal, UserPrincipal};
|
|
use crate::persistence::Store;
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
|
|
async fn test_store() -> Arc<Store> {
|
|
Arc::new(crate::persistence::test_store().await)
|
|
}
|
|
|
|
/// Returns a shutdown sender with its receiver immediately dropped. Tests
|
|
/// that don't observe the shutdown signal can use this to satisfy the
|
|
/// `register` signature without the receiver noise.
|
|
fn make_shutdown() -> oneshot::Sender<()> {
|
|
oneshot::channel::<()>().0
|
|
}
|
|
|
|
fn sandbox_record(id: &str, name: &str) -> Sandbox {
|
|
Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: id.to_string(),
|
|
name: name.to_string(),
|
|
created_at_ms: 1_000_000,
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_timestamp_ms: 0,
|
|
}),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn pending_relay(
|
|
sandbox_id: &str,
|
|
relay_tx: RelayStreamSender,
|
|
created_at: Instant,
|
|
) -> PendingRelay {
|
|
PendingRelay {
|
|
sender: relay_tx,
|
|
sandbox_id: sandbox_id.to_string(),
|
|
relay_open: RelayOpen {
|
|
channel_id: "ch-test".to_string(),
|
|
target: Some(relay_open::Target::Ssh(SshRelayTarget {})),
|
|
service_id: String::new(),
|
|
},
|
|
created_at,
|
|
}
|
|
}
|
|
|
|
fn sandbox_principal(sandbox_id: &str) -> Principal {
|
|
Principal::Sandbox(SandboxPrincipal {
|
|
sandbox_id: sandbox_id.to_string(),
|
|
source: SandboxIdentitySource::BootstrapJwt {
|
|
issuer: "openshell-gateway:test".to_string(),
|
|
},
|
|
trust_domain: Some("openshell".to_string()),
|
|
})
|
|
}
|
|
|
|
fn user_principal(subject: &str) -> Principal {
|
|
Principal::User(UserPrincipal {
|
|
identity: Identity {
|
|
subject: subject.to_string(),
|
|
display_name: None,
|
|
roles: vec![],
|
|
scopes: vec![],
|
|
provider: IdentityProvider::Oidc,
|
|
},
|
|
})
|
|
}
|
|
|
|
// ---- registry: register / remove ----
|
|
|
|
#[test]
|
|
fn registry_register_and_lookup() {
|
|
let registry = SupervisorSessionRegistry::new();
|
|
let (tx, _rx) = mpsc::channel(1);
|
|
|
|
assert!(!registry.register(
|
|
"sandbox-1".to_string(),
|
|
"s1".to_string(),
|
|
tx,
|
|
make_shutdown(),
|
|
));
|
|
|
|
let sessions = registry.sessions.lock().unwrap();
|
|
assert!(sessions.contains_key("sandbox-1"));
|
|
}
|
|
|
|
#[test]
|
|
fn registry_supersedes_previous_session() {
|
|
let registry = SupervisorSessionRegistry::new();
|
|
let (tx1, _rx1) = mpsc::channel(1);
|
|
let (tx2, _rx2) = mpsc::channel(1);
|
|
|
|
assert!(!registry.register(
|
|
"sandbox-1".to_string(),
|
|
"s1".to_string(),
|
|
tx1,
|
|
make_shutdown(),
|
|
));
|
|
assert!(registry.register(
|
|
"sandbox-1".to_string(),
|
|
"s2".to_string(),
|
|
tx2,
|
|
make_shutdown(),
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn registry_remove() {
|
|
let registry = SupervisorSessionRegistry::new();
|
|
let (tx, _rx) = mpsc::channel(1);
|
|
registry.register(
|
|
"sandbox-1".to_string(),
|
|
"s1".to_string(),
|
|
tx,
|
|
make_shutdown(),
|
|
);
|
|
|
|
registry.remove("sandbox-1");
|
|
let sessions = registry.sessions.lock().unwrap();
|
|
assert!(!sessions.contains_key("sandbox-1"));
|
|
}
|
|
|
|
#[test]
|
|
fn remove_if_current_removes_matching_session() {
|
|
let registry = SupervisorSessionRegistry::new();
|
|
let (tx, _rx) = mpsc::channel(1);
|
|
registry.register("sbx".to_string(), "s1".to_string(), tx, make_shutdown());
|
|
|
|
assert!(registry.remove_if_current("sbx", "s1"));
|
|
assert!(!registry.sessions.lock().unwrap().contains_key("sbx"));
|
|
}
|
|
|
|
#[test]
|
|
fn remove_if_current_ignores_stale_session_id() {
|
|
let registry = SupervisorSessionRegistry::new();
|
|
let (tx_old, _rx_old) = mpsc::channel(1);
|
|
let (tx_new, _rx_new) = mpsc::channel(1);
|
|
|
|
// Old session registers, then is superseded by a new session.
|
|
registry.register(
|
|
"sbx".to_string(),
|
|
"s-old".to_string(),
|
|
tx_old,
|
|
make_shutdown(),
|
|
);
|
|
registry.register(
|
|
"sbx".to_string(),
|
|
"s-new".to_string(),
|
|
tx_new,
|
|
make_shutdown(),
|
|
);
|
|
|
|
// Cleanup from the old session task runs late. It must NOT evict the
|
|
// newly registered session.
|
|
assert!(!registry.remove_if_current("sbx", "s-old"));
|
|
let sessions = registry.sessions.lock().unwrap();
|
|
assert!(
|
|
sessions.contains_key("sbx"),
|
|
"new session must still be registered"
|
|
);
|
|
assert_eq!(sessions.get("sbx").unwrap().session_id, "s-new");
|
|
}
|
|
|
|
#[test]
|
|
fn remove_if_current_unknown_sandbox_is_noop() {
|
|
let registry = SupervisorSessionRegistry::new();
|
|
assert!(!registry.remove_if_current("sbx-does-not-exist", "s1"));
|
|
}
|
|
|
|
// ---- open_relay: happy path and wait semantics ----
|
|
|
|
#[tokio::test]
|
|
async fn open_relay_sends_relay_open_to_registered_session() {
|
|
let registry = SupervisorSessionRegistry::new();
|
|
let (tx, mut rx) = mpsc::channel(4);
|
|
registry.register("sbx".to_string(), "s1".to_string(), tx, make_shutdown());
|
|
|
|
let (channel_id, _relay_rx) = registry
|
|
.open_relay("sbx", Duration::from_secs(1))
|
|
.await
|
|
.expect("open_relay should succeed when session is live");
|
|
|
|
let msg = rx.recv().await.expect("relay open should be delivered");
|
|
match msg.payload {
|
|
Some(gateway_message::Payload::RelayOpen(open)) => {
|
|
assert_eq!(open.channel_id, channel_id);
|
|
assert!(matches!(open.target, Some(relay_open::Target::Ssh(_))));
|
|
}
|
|
other => panic!("expected RelayOpen, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn open_relay_times_out_without_session() {
|
|
let registry = SupervisorSessionRegistry::new();
|
|
let err = registry
|
|
.open_relay("missing", Duration::from_millis(50))
|
|
.await
|
|
.expect_err("open_relay should time out");
|
|
assert_eq!(err.code(), tonic::Code::Unavailable);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn open_relay_waits_for_session_to_appear() {
|
|
let registry = Arc::new(SupervisorSessionRegistry::new());
|
|
let registry_for_register = Arc::clone(®istry);
|
|
|
|
// Register the session after a small delay, shorter than the wait.
|
|
tokio::spawn(async move {
|
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
|
let (tx, mut rx) = mpsc::channel::<GatewayMessage>(4);
|
|
// Keep the receiver alive so the send in open_relay succeeds.
|
|
tokio::spawn(async move { while rx.recv().await.is_some() {} });
|
|
registry_for_register.register(
|
|
"sbx".to_string(),
|
|
"s1".to_string(),
|
|
tx,
|
|
make_shutdown(),
|
|
);
|
|
});
|
|
|
|
let result = registry.open_relay("sbx", Duration::from_secs(2)).await;
|
|
assert!(
|
|
result.is_ok(),
|
|
"open_relay should succeed when session arrives mid-wait: {result:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn open_relay_fails_when_session_receiver_dropped() {
|
|
let registry = SupervisorSessionRegistry::new();
|
|
let (tx, rx) = mpsc::channel::<GatewayMessage>(4);
|
|
registry.register("sbx".to_string(), "s1".to_string(), tx, make_shutdown());
|
|
|
|
// Simulate the supervisor's stream going away between lookup and send:
|
|
// the receiver held by `ReceiverStream` is dropped.
|
|
drop(rx);
|
|
|
|
let err = registry
|
|
.open_relay("sbx", Duration::from_secs(1))
|
|
.await
|
|
.expect_err("open_relay should fail when mpsc is closed");
|
|
assert_eq!(err.code(), tonic::Code::Unavailable);
|
|
// The pending-relay entry must have been cleaned up on failure.
|
|
assert!(registry.pending_relays.lock().unwrap().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn open_relay_rejects_when_global_cap_reached() {
|
|
let registry = SupervisorSessionRegistry::new();
|
|
let (tx, _rx) = mpsc::channel::<GatewayMessage>(8);
|
|
registry.register(
|
|
"sbx-a".to_string(),
|
|
"s-a".to_string(),
|
|
tx.clone(),
|
|
make_shutdown(),
|
|
);
|
|
registry.register("sbx-b".to_string(), "s-b".to_string(), tx, make_shutdown());
|
|
|
|
// Pre-seed pending_relays to exactly the global cap, split across two
|
|
// sandboxes so neither hits the per-sandbox cap first.
|
|
{
|
|
let mut pending = registry.pending_relays.lock().unwrap();
|
|
for i in 0..MAX_PENDING_RELAYS {
|
|
let (oneshot_tx, _) = oneshot::channel();
|
|
let sandbox_id = if i % 2 == 0 { "sbx-a" } else { "sbx-b" };
|
|
pending.insert(
|
|
format!("channel-{i}"),
|
|
pending_relay(sandbox_id, oneshot_tx, Instant::now()),
|
|
);
|
|
}
|
|
}
|
|
|
|
let err = registry
|
|
.open_relay("sbx-a", Duration::from_millis(50))
|
|
.await
|
|
.expect_err("open_relay should reject once global cap is reached");
|
|
assert_eq!(err.code(), tonic::Code::ResourceExhausted);
|
|
assert!(err.message().contains("gateway relay capacity"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn open_relay_rejects_when_per_sandbox_cap_reached() {
|
|
let registry = SupervisorSessionRegistry::new();
|
|
let (tx, _rx) = mpsc::channel::<GatewayMessage>(8);
|
|
registry.register("sbx".to_string(), "s".to_string(), tx, make_shutdown());
|
|
|
|
{
|
|
let mut pending = registry.pending_relays.lock().unwrap();
|
|
for i in 0..MAX_PENDING_RELAYS_PER_SANDBOX {
|
|
let (oneshot_tx, _) = oneshot::channel();
|
|
pending.insert(
|
|
format!("channel-{i}"),
|
|
pending_relay("sbx", oneshot_tx, Instant::now()),
|
|
);
|
|
}
|
|
}
|
|
|
|
let err = registry
|
|
.open_relay("sbx", Duration::from_millis(50))
|
|
.await
|
|
.expect_err("open_relay should reject when per-sandbox cap is reached");
|
|
assert_eq!(err.code(), tonic::Code::ResourceExhausted);
|
|
assert!(err.message().contains("per-sandbox relay limit"));
|
|
|
|
// A different sandbox still has headroom.
|
|
let (tx2, _rx2) = mpsc::channel::<GatewayMessage>(8);
|
|
registry.register(
|
|
"sbx-other".to_string(),
|
|
"s-other".to_string(),
|
|
tx2,
|
|
make_shutdown(),
|
|
);
|
|
registry
|
|
.open_relay("sbx-other", Duration::from_millis(50))
|
|
.await
|
|
.expect("different sandbox should still accept new relays");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn open_relay_uses_newest_session_after_supersede() {
|
|
use tokio::sync::mpsc::error::TryRecvError;
|
|
|
|
let registry = SupervisorSessionRegistry::new();
|
|
let (tx_old, mut rx_old) = mpsc::channel::<GatewayMessage>(4);
|
|
let (tx_new, mut rx_new) = mpsc::channel(4);
|
|
|
|
// Hold a clone of the old sender so supersede doesn't close the old
|
|
// channel — that way try_recv distinguishes "no message sent" from
|
|
// "channel closed".
|
|
let _tx_old_alive = tx_old.clone();
|
|
|
|
registry.register(
|
|
"sbx".to_string(),
|
|
"s-old".to_string(),
|
|
tx_old,
|
|
make_shutdown(),
|
|
);
|
|
registry.register(
|
|
"sbx".to_string(),
|
|
"s-new".to_string(),
|
|
tx_new,
|
|
make_shutdown(),
|
|
);
|
|
|
|
let (_channel_id, _relay_rx) = registry
|
|
.open_relay("sbx", Duration::from_secs(1))
|
|
.await
|
|
.expect("open_relay should succeed");
|
|
|
|
let msg = rx_new
|
|
.recv()
|
|
.await
|
|
.expect("new session should receive RelayOpen");
|
|
assert!(matches!(
|
|
msg.payload,
|
|
Some(gateway_message::Payload::RelayOpen(_))
|
|
));
|
|
|
|
// The old session must have received no messages — the channel is
|
|
// still open but empty.
|
|
match rx_old.try_recv() {
|
|
Err(TryRecvError::Empty) => {}
|
|
other => panic!("expected Empty on superseded session, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn register_signals_shutdown_to_previous_session() {
|
|
let registry = SupervisorSessionRegistry::new();
|
|
let (tx_old, _rx_old) = mpsc::channel::<GatewayMessage>(1);
|
|
let (tx_new, _rx_new) = mpsc::channel::<GatewayMessage>(1);
|
|
|
|
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
|
|
registry.register("sbx".to_string(), "s-old".to_string(), tx_old, shutdown_tx);
|
|
|
|
// Supersede with a new session — register must fire the old session's
|
|
// shutdown signal so its task can exit and drop its tx clone.
|
|
let superseded = registry.register(
|
|
"sbx".to_string(),
|
|
"s-new".to_string(),
|
|
tx_new,
|
|
make_shutdown(),
|
|
);
|
|
assert!(superseded, "second register should report supersede");
|
|
|
|
// The old session's shutdown receiver must now resolve.
|
|
shutdown_rx
|
|
.await
|
|
.expect("shutdown signal should arrive at superseded session");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replay_pending_relays_reissues_open_to_superseding_session() {
|
|
let registry = SupervisorSessionRegistry::new();
|
|
let (tx_old, mut rx_old) = mpsc::channel::<GatewayMessage>(4);
|
|
let (tx_new, mut rx_new) = mpsc::channel::<GatewayMessage>(4);
|
|
|
|
registry.register(
|
|
"sbx".to_string(),
|
|
"s-old".to_string(),
|
|
tx_old,
|
|
make_shutdown(),
|
|
);
|
|
|
|
let (channel_id, _relay_rx) = registry
|
|
.open_relay("sbx", Duration::from_secs(1))
|
|
.await
|
|
.expect("open_relay should succeed");
|
|
|
|
let original = rx_old
|
|
.recv()
|
|
.await
|
|
.expect("old session should receive initial RelayOpen");
|
|
assert!(matches!(
|
|
original.payload,
|
|
Some(gateway_message::Payload::RelayOpen(_))
|
|
));
|
|
|
|
let superseded = registry.register(
|
|
"sbx".to_string(),
|
|
"s-new".to_string(),
|
|
tx_new,
|
|
make_shutdown(),
|
|
);
|
|
assert!(superseded);
|
|
|
|
registry
|
|
.replay_pending_relays("sbx", ®istry.lookup_session("sbx").unwrap())
|
|
.await;
|
|
|
|
let replayed = rx_new
|
|
.recv()
|
|
.await
|
|
.expect("new session should receive replayed RelayOpen");
|
|
match replayed.payload {
|
|
Some(gateway_message::Payload::RelayOpen(open)) => {
|
|
assert_eq!(open.channel_id, channel_id);
|
|
}
|
|
other => panic!("expected RelayOpen on replay, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn require_persisted_sandbox_rejects_missing_sandbox() {
|
|
let store = test_store().await;
|
|
|
|
let err = require_persisted_sandbox(&store, "missing")
|
|
.await
|
|
.expect_err("missing sandbox should be rejected");
|
|
|
|
assert_eq!(err.code(), tonic::Code::NotFound);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn require_persisted_sandbox_accepts_existing_sandbox() {
|
|
let store = test_store().await;
|
|
store
|
|
.put_message(&sandbox_record("sbx-1", "sandbox-one"))
|
|
.await
|
|
.expect("sandbox should persist");
|
|
|
|
require_persisted_sandbox(&store, "sbx-1")
|
|
.await
|
|
.expect("persisted sandbox should be accepted");
|
|
}
|
|
|
|
// ---- claim_relay: expiry, drop, wiring ----
|
|
|
|
#[test]
|
|
fn claim_relay_unknown_channel() {
|
|
let registry = SupervisorSessionRegistry::new();
|
|
let principal = sandbox_principal("sbx-test");
|
|
let err = registry
|
|
.claim_relay("nonexistent", Some(&principal))
|
|
.expect_err("should err");
|
|
assert_eq!(err.code(), tonic::Code::NotFound);
|
|
}
|
|
|
|
#[test]
|
|
fn claim_relay_success() {
|
|
let registry = SupervisorSessionRegistry::new();
|
|
let (relay_tx, _relay_rx) = oneshot::channel();
|
|
registry.pending_relays.lock().unwrap().insert(
|
|
"ch-1".to_string(),
|
|
pending_relay("sbx-test", relay_tx, Instant::now()),
|
|
);
|
|
|
|
let principal = sandbox_principal("sbx-test");
|
|
let result = registry.claim_relay("ch-1", Some(&principal));
|
|
assert!(result.is_ok());
|
|
assert!(!registry.pending_relays.lock().unwrap().contains_key("ch-1"));
|
|
}
|
|
|
|
#[test]
|
|
fn claim_relay_rejects_cross_sandbox_principal_without_consuming_channel() {
|
|
let registry = SupervisorSessionRegistry::new();
|
|
let (relay_tx, _relay_rx) = oneshot::channel();
|
|
registry.pending_relays.lock().unwrap().insert(
|
|
"ch-cross".to_string(),
|
|
pending_relay("sbx-owner", relay_tx, Instant::now()),
|
|
);
|
|
|
|
let attacker = sandbox_principal("sbx-attacker");
|
|
let err = registry
|
|
.claim_relay("ch-cross", Some(&attacker))
|
|
.expect_err("cross-sandbox relay claim must fail");
|
|
assert_eq!(err.code(), tonic::Code::PermissionDenied);
|
|
assert!(
|
|
registry
|
|
.pending_relays
|
|
.lock()
|
|
.unwrap()
|
|
.contains_key("ch-cross"),
|
|
"failed cross-sandbox claim must not consume the channel"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn claim_relay_rejects_user_principal() {
|
|
let registry = SupervisorSessionRegistry::new();
|
|
let (relay_tx, _relay_rx) = oneshot::channel();
|
|
registry.pending_relays.lock().unwrap().insert(
|
|
"ch-user".to_string(),
|
|
pending_relay("sbx-owner", relay_tx, Instant::now()),
|
|
);
|
|
|
|
let err = registry
|
|
.claim_relay("ch-user", Some(&user_principal("alice")))
|
|
.expect_err("users are not supervisor identities");
|
|
assert_eq!(err.code(), tonic::Code::PermissionDenied);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn relay_open_failure_completes_pending_waiter() {
|
|
let registry = SupervisorSessionRegistry::new();
|
|
let (relay_tx, relay_rx) = oneshot::channel();
|
|
registry.pending_relays.lock().unwrap().insert(
|
|
"ch-fail".to_string(),
|
|
pending_relay("sbx-test", relay_tx, Instant::now()),
|
|
);
|
|
|
|
assert!(registry.fail_pending_relay("ch-fail", "target refused".to_string()));
|
|
assert!(
|
|
!registry
|
|
.pending_relays
|
|
.lock()
|
|
.unwrap()
|
|
.contains_key("ch-fail")
|
|
);
|
|
|
|
let result = relay_rx.await.expect("failure should wake waiter");
|
|
let status = result.expect_err("waiter should receive status failure");
|
|
assert_eq!(status.code(), tonic::Code::Unavailable);
|
|
assert_eq!(status.message(), "target refused");
|
|
}
|
|
|
|
#[test]
|
|
fn claim_relay_expired_returns_deadline_exceeded() {
|
|
let registry = SupervisorSessionRegistry::new();
|
|
let (relay_tx, _relay_rx) = oneshot::channel();
|
|
registry.pending_relays.lock().unwrap().insert(
|
|
"ch-old".to_string(),
|
|
pending_relay(
|
|
"sbx-test",
|
|
relay_tx,
|
|
Instant::now()
|
|
.checked_sub(Duration::from_secs(60))
|
|
.expect("test duration should be before now"),
|
|
),
|
|
);
|
|
|
|
let err = registry
|
|
.claim_relay("ch-old", Some(&sandbox_principal("sbx-test")))
|
|
.expect_err("expired entry must fail");
|
|
assert_eq!(err.code(), tonic::Code::DeadlineExceeded);
|
|
// Entry must have been consumed regardless.
|
|
assert!(
|
|
!registry
|
|
.pending_relays
|
|
.lock()
|
|
.unwrap()
|
|
.contains_key("ch-old")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn claim_relay_receiver_dropped_returns_internal() {
|
|
let registry = SupervisorSessionRegistry::new();
|
|
let (relay_tx, relay_rx) = oneshot::channel::<Result<tokio::io::DuplexStream, Status>>();
|
|
drop(relay_rx); // Gateway-side waiter has given up already.
|
|
registry.pending_relays.lock().unwrap().insert(
|
|
"ch-1".to_string(),
|
|
pending_relay("sbx-test", relay_tx, Instant::now()),
|
|
);
|
|
|
|
let err = registry
|
|
.claim_relay("ch-1", Some(&sandbox_principal("sbx-test")))
|
|
.expect_err("should err when receiver is gone");
|
|
assert_eq!(err.code(), tonic::Code::Internal);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn claim_relay_connects_both_ends() {
|
|
let registry = SupervisorSessionRegistry::new();
|
|
let (relay_tx, relay_rx) = oneshot::channel::<Result<tokio::io::DuplexStream, Status>>();
|
|
registry.pending_relays.lock().unwrap().insert(
|
|
"ch-io".to_string(),
|
|
pending_relay("sbx-test", relay_tx, Instant::now()),
|
|
);
|
|
|
|
let mut supervisor_side = registry
|
|
.claim_relay("ch-io", Some(&sandbox_principal("sbx-test")))
|
|
.expect("claim should succeed");
|
|
let mut gateway_side = relay_rx
|
|
.await
|
|
.expect("gateway side should receive result")
|
|
.expect("gateway side should receive stream");
|
|
|
|
// Supervisor side writes → gateway side reads.
|
|
supervisor_side.write_all(b"hello").await.unwrap();
|
|
let mut buf = [0u8; 5];
|
|
gateway_side.read_exact(&mut buf).await.unwrap();
|
|
assert_eq!(&buf, b"hello");
|
|
|
|
// Gateway side writes → supervisor side reads.
|
|
gateway_side.write_all(b"world").await.unwrap();
|
|
let mut buf = [0u8; 5];
|
|
supervisor_side.read_exact(&mut buf).await.unwrap();
|
|
assert_eq!(&buf, b"world");
|
|
}
|
|
|
|
// ---- reap_expired_relays ----
|
|
|
|
#[test]
|
|
fn reap_expired_relays_removes_old_entries() {
|
|
let registry = SupervisorSessionRegistry::new();
|
|
let (relay_tx, _relay_rx) = oneshot::channel();
|
|
registry.pending_relays.lock().unwrap().insert(
|
|
"ch-old".to_string(),
|
|
pending_relay(
|
|
"sbx-test",
|
|
relay_tx,
|
|
Instant::now()
|
|
.checked_sub(Duration::from_secs(60))
|
|
.expect("test duration should be before now"),
|
|
),
|
|
);
|
|
|
|
registry.reap_expired_relays();
|
|
assert!(
|
|
!registry
|
|
.pending_relays
|
|
.lock()
|
|
.unwrap()
|
|
.contains_key("ch-old")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reap_expired_relays_keeps_fresh_entries() {
|
|
let registry = SupervisorSessionRegistry::new();
|
|
let (relay_tx, _relay_rx) = oneshot::channel();
|
|
registry.pending_relays.lock().unwrap().insert(
|
|
"ch-fresh".to_string(),
|
|
pending_relay("sbx-test", relay_tx, Instant::now()),
|
|
);
|
|
|
|
registry.reap_expired_relays();
|
|
assert!(
|
|
registry
|
|
.pending_relays
|
|
.lock()
|
|
.unwrap()
|
|
.contains_key("ch-fresh")
|
|
);
|
|
}
|
|
}
|