Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 100 additions & 4 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,11 @@ pub async fn sandbox_create(
)),
await_main_process_attachment,
workload_template: template.unwrap_or_default().to_string(),
forward_ports: forward
.as_ref()
.map(|spec| u32::from(spec.port))
.into_iter()
.collect(),
service_exposures: expose
.map(|target_port| SandboxServiceExposure {
service: String::new(),
Expand Down Expand Up @@ -1688,6 +1693,33 @@ where
println!(" {} {}", "Id:".dimmed(), id);
println!(" {} {}", "Name:".dimmed(), name);
println!(" {} {}", "Phase:".dimmed(), phase_name(sandbox.phase()));
if let Some(ingress) = sandbox
.status
.as_ref()
.and_then(|status| status.ingress_admission.as_ref())
{
let ports = if ingress.all_ports {
"all".to_string()
} else {
ingress
.ports
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(", ")
};
println!(
" {} {}; ports: {ports}",
"Ingress:".dimmed(),
ingress_scope_name(ingress.scope)
);
} else {
println!(
" {} legacy driver (admission unavailable)",
"Ingress:".dimmed()
);
}

if let Some(status) = sandbox.status.as_ref() {
for condition in &status.conditions {
if matches!(
Expand Down Expand Up @@ -2169,8 +2201,12 @@ pub async fn service_forward_tcp(
let (bind_addr, bind_port) = parse_tcp_forward_spec(local, target_port)?;
let mut client = grpc_client(server, tls).await?;

let mut replica = fetch_ready_sandbox_for_forward(&mut client, name, workspace).await?;
let (mut replica, scope) =
fetch_ready_sandbox_for_forward(&mut client, name, workspace, target_port).await?;

if scope == openshell_core::proto::IngressScope::SharedHostLoopback {
eprintln!("Target is an admitted shared-host loopback service.");
}
let listener = tokio::net::TcpListener::bind((bind_addr.as_str(), bind_port))
.await
.into_diagnostic()
Expand Down Expand Up @@ -2203,7 +2239,7 @@ pub async fn service_forward_tcp(
}

_ = health_check.tick() => {
replica = fetch_ready_sandbox_for_forward(&mut client, name, workspace).await?;
replica = fetch_ready_sandbox_for_forward(&mut client, name, workspace, target_port).await?.0;
}

accepted = listener.accept() => {
Expand Down Expand Up @@ -2274,7 +2310,11 @@ async fn fetch_ready_sandbox_for_forward(
client: &mut crate::tls::GrpcClient,
name: &str,
workspace: &str,
) -> Result<Option<AsciiMetadataValue>> {
target_port: u16,
) -> Result<(
Option<AsciiMetadataValue>,
openshell_core::proto::IngressScope,
)> {
let response = match client
.get_sandbox(with_owner_hint(GetSandboxRequest {
name: name.to_string(),
Expand Down Expand Up @@ -2307,7 +2347,17 @@ async fn fetch_ready_sandbox_for_forward(
));
}

Ok(replica)
let scope = openshell_core::ingress::admit_port(
sandbox
.status
.as_ref()
.and_then(|status| status.ingress_admission.as_ref()),
u32::from(target_port),
false,
)
.map_err(|error| miette::miette!("{error}"))?;

Ok((replica, scope))
}

#[derive(Debug)]
Expand Down Expand Up @@ -3012,6 +3062,16 @@ fn configuration_failure_message(sandbox: &Sandbox) -> Option<&str> {
.filter(|message| !message.is_empty())
}

fn ingress_scope_name(scope: i32) -> &'static str {
use openshell_core::proto::IngressScope;
match IngressScope::try_from(scope) {
Ok(IngressScope::IsolatedLoopback) => "isolated_loopback",
Ok(IngressScope::SharedHostLoopback) => "shared_host_loopback",
Ok(IngressScope::Unsupported) => "unsupported",
Ok(IngressScope::Unspecified) | Err(_) => "unknown",
}
}

fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value {
use openshell_core::proto::ConfigurationAdmissionState;

Expand Down Expand Up @@ -3123,6 +3183,11 @@ fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value {
"conditions": conditions,
"endpoint_statuses": endpoint_statuses,
"last_token_grant_failure": last_token_grant_failure,
"ingress_admission": sandbox.status.as_ref().and_then(|status| status.ingress_admission.as_ref()).map(|ingress| serde_json::json!({
"scope": ingress_scope_name(ingress.scope),
"all_ports": ingress.all_ports,
"ports": ingress.ports,
})),
"configuration_admission": admission,
"provisioning": provisioning,
"created_from_workload_template": created_from_workload_template,
Expand Down Expand Up @@ -8829,6 +8894,37 @@ mod tests {
.error
.clear();
assert_eq!(super::configuration_failure_message(&sandbox), None);
assert!(super::sandbox_to_json(&sandbox)["ingress_admission"].is_null());
for (scope, expected) in [
(
openshell_core::proto::IngressScope::IsolatedLoopback as i32,
"isolated_loopback",
),
(
openshell_core::proto::IngressScope::SharedHostLoopback as i32,
"shared_host_loopback",
),
(
openshell_core::proto::IngressScope::Unsupported as i32,
"unsupported",
),
(0, "unknown"),
(99, "unknown"),
] {
sandbox.status.as_mut().unwrap().ingress_admission =
Some(openshell_core::proto::SandboxIngressAdmission {
scope,
ports: vec![8080],
all_ports: false,
});
let output = super::sandbox_to_json(&sandbox);
assert_eq!(output["ingress_admission"]["scope"], expected);
assert_eq!(
output["ingress_admission"]["ports"],
serde_json::json!([8080])
);
assert_eq!(output["ingress_admission"]["all_ports"], false);
}
}

#[test]
Expand Down
19 changes: 17 additions & 2 deletions crates/openshell-cli/src/ssh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ struct SshSessionConfig {
gateway_url: String,
token: String,
main_terminal: bool,
ingress_admission: Option<openshell_core::proto::SandboxIngressAdmission>,
replica: Option<AsciiMetadataValue>,
}

Expand Down Expand Up @@ -203,6 +204,10 @@ async fn ssh_session_config(
);

Ok(SshSessionConfig {
ingress_admission: sandbox
.status
.as_ref()
.and_then(|status| status.ingress_admission.clone()),
proxy_command,
sandbox_id: session.sandbox_id.clone(),
sandbox_name: name.to_string(),
Expand Down Expand Up @@ -742,9 +747,18 @@ pub async fn sandbox_forward(
tls: &TlsOptions,
workspace: &str,
) -> Result<()> {
openshell_core::forward::check_port_available(spec)?;

let session = ssh_session_config(server, name, tls, workspace, None).await?;
let scope = openshell_core::ingress::admit_port(
session.ingress_admission.as_ref(),
u32::from(spec.port),
false,
)
.map_err(|error| miette::miette!("{error}"))?;
if scope == openshell_core::proto::IngressScope::SharedHostLoopback {
eprintln!("Target is an admitted shared-host loopback service.");
}

openshell_core::forward::check_port_available(spec)?;

let mut command = TokioCommand::from(ssh_forward_command(
&session.proxy_command,
Expand Down Expand Up @@ -2548,6 +2562,7 @@ mod tests {
async move {
if setup {
let session = SshSessionConfig {
ingress_admission: None,
proxy_command: String::new(),
sandbox_id: String::new(),
sandbox_name: String::new(),
Expand Down
3 changes: 3 additions & 0 deletions crates/openshell-core/src/extension_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ pub const COMPUTE_SUSPEND_RESUME: &str = "openshell.compute.suspend-resume";
/// Optional stopped-state workload configuration updates retaining the resource and storage.
pub const COMPUTE_UPDATE_SANDBOX_CONFIGURATION: &str =
"openshell.compute.update-sandbox-configuration";
/// Immutable per-sandbox forwarding and service exposure admission.
pub const COMPUTE_INGRESS_ADMISSION: &str = "openshell.compute.ingress-admission";

const MAX_IMPLEMENTATION_NAME_BYTES: usize = 128;
const MAX_IMPLEMENTATION_VERSION_BYTES: usize = 128;
Expand Down Expand Up @@ -133,6 +135,7 @@ pub fn gateway_metadata(family: ExtensionFamily) -> PeerMetadata {
supported_capabilities.push(COMPUTE_LAUNCH_AUTHENTICATION.to_string());
supported_capabilities.push(COMPUTE_ISOLATION_BACKEND.to_string());
supported_capabilities.push(COMPUTE_UPDATE_SANDBOX_CONFIGURATION.to_string());
supported_capabilities.push(COMPUTE_INGRESS_ADMISSION.to_string());
}
PeerMetadata {
protocol_version: Some(ProtocolVersion {
Expand Down
61 changes: 61 additions & 0 deletions crates/openshell-core/src/ingress.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Shared validation of compute-admitted ingress; the backend enforces targets.

use crate::proto::{IngressScope, SandboxIngressAdmission};
use std::collections::BTreeSet;

/// Validate a negotiated descriptor without treating unknown evidence as legacy.
pub fn validate(admission: &SandboxIngressAdmission) -> Result<IngressScope, &'static str> {
let scope =
IngressScope::try_from(admission.scope).map_err(|_| "unknown compute ingress scope")?;
if scope == IngressScope::Unspecified {
return Err(
"compute ingress admission is unavailable; recreate the sandbox with an ingress-aware driver",
);
}
if (admission.all_ports && !admission.ports.is_empty())
|| (scope == IngressScope::Unsupported
&& (admission.all_ports || !admission.ports.is_empty()))
|| admission.ports.len() > 65535
{
return Err("invalid compute ingress envelope");
}
let mut seen = BTreeSet::new();
for port in &admission.ports {
if !(1..=65535).contains(port) || !seen.insert(port) {
return Err("compute ingress ports must be unique and between 1 and 65535");
}
}
Ok(scope)
}

/// Authorize before listeners, service persistence, or any pooled upstream use.
/// Legacy absence is permitted only when the gateway has not negotiated ingress.
pub fn admit_port(
admission: Option<&SandboxIngressAdmission>,
port: u32,
required: bool,
) -> Result<IngressScope, &'static str> {
if !(1..=65535).contains(&port) {
return Err("target port must be between 1 and 65535");
}
let Some(admission) = admission else {
return if required {
Err("compute ingress admission is missing; recreate the sandbox")
} else {
Ok(IngressScope::Unspecified)
};
};
let scope = validate(admission)?;
if scope == IngressScope::Unsupported {
return Err("the compute driver does not support sandbox ingress");
}
if !admission.all_ports && !admission.ports.contains(&port) {
return Err(
"target port was not admitted by the compute driver; recreate the sandbox with that service port",
);
}
Ok(scope)
}
1 change: 1 addition & 0 deletions crates/openshell-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ pub mod gpu;
pub mod grpc_client;
pub mod host_pattern;
pub mod image;
pub mod ingress;
pub mod isolation_registration;
pub mod jwt;
pub mod local_api_socket;
Expand Down
10 changes: 8 additions & 2 deletions crates/openshell-driver-docker/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1094,7 +1094,7 @@ impl DockerComputeDriver {
openshell_core::extension_protocol::ExtensionFamily::Compute,
"openshell/docker",
openshell_core::VERSION,
[],
[openshell_core::extension_protocol::COMPUTE_INGRESS_ADMISSION.to_string()],
)),
}
}
Expand Down Expand Up @@ -3354,7 +3354,13 @@ impl ComputeDriver for DockerComputeDriver {
CdiGpuDefaultSelector::peek_device_ids,
)
.await?;
Ok(Response::new(ValidateSandboxCreateResponse {}))
Ok(Response::new(ValidateSandboxCreateResponse {
ingress_admission: Some(openshell_core::proto::SandboxIngressAdmission {
scope: openshell_core::proto::IngressScope::IsolatedLoopback as i32,
all_ports: true,
ports: Vec::new(),
}),
}))
}

async fn get_sandbox(
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-driver-kubernetes/src/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1330,6 +1330,7 @@ impl KubernetesComputeDriver {
[
openshell_core::extension_protocol::COMPUTE_UPDATE_SANDBOX_CONFIGURATION
.to_string(),
openshell_core::extension_protocol::COMPUTE_INGRESS_ADMISSION.to_string(),
],
)),
})
Expand Down
8 changes: 7 additions & 1 deletion crates/openshell-driver-kubernetes/src/grpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,13 @@ impl ComputeDriver for ComputeDriverService {
.sandbox
.ok_or_else(|| Status::invalid_argument("sandbox is required"))?;
self.driver.validate_sandbox_create(&sandbox).await?;
Ok(Response::new(ValidateSandboxCreateResponse {}))
Ok(Response::new(ValidateSandboxCreateResponse {
ingress_admission: Some(openshell_core::proto::SandboxIngressAdmission {
scope: openshell_core::proto::IngressScope::IsolatedLoopback as i32,
all_ports: true,
ports: Vec::new(),
}),
}))
})
.await
}
Expand Down
2 changes: 1 addition & 1 deletion crates/openshell-driver-mxc/src/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,7 @@ impl MxcComputeBackend {
openshell_core::extension_protocol::ExtensionFamily::Compute,
"openshell/mxc",
openshell_core::VERSION,
[],
[openshell_core::extension_protocol::COMPUTE_INGRESS_ADMISSION.to_string()],
)),
}
}
Expand Down
8 changes: 7 additions & 1 deletion crates/openshell-driver-mxc/src/grpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,13 @@ impl ComputeDriver for ComputeDriverService {
.sandbox
.ok_or_else(|| Status::invalid_argument("sandbox is required"))?;
self.backend.validate_sandbox_create(&sandbox)?;
Ok(Response::new(ValidateSandboxCreateResponse {}))
Ok(Response::new(ValidateSandboxCreateResponse {
ingress_admission: Some(openshell_core::proto::SandboxIngressAdmission {
scope: openshell_core::proto::IngressScope::Unsupported as i32,
all_ports: false,
ports: Vec::new(),
}),
}))
}

async fn get_sandbox(
Expand Down
2 changes: 1 addition & 1 deletion crates/openshell-driver-podman/src/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -744,7 +744,7 @@ impl PodmanComputeDriver {
openshell_core::extension_protocol::ExtensionFamily::Compute,
"openshell/podman",
openshell_core::VERSION,
[],
[openshell_core::extension_protocol::COMPUTE_INGRESS_ADMISSION.to_string()],
)),
})
}
Expand Down
8 changes: 7 additions & 1 deletion crates/openshell-driver-podman/src/grpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,13 @@ impl ComputeDriver for ComputeDriverService {
.validate_sandbox_create(&sandbox)
.await
.map_err(Status::from)?;
Ok(Response::new(ValidateSandboxCreateResponse {}))
Ok(Response::new(ValidateSandboxCreateResponse {
ingress_admission: Some(openshell_core::proto::SandboxIngressAdmission {
scope: openshell_core::proto::IngressScope::IsolatedLoopback as i32,
all_ports: true,
ports: Vec::new(),
}),
}))
})
.await
}
Expand Down
Loading
Loading