Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .tekton/odh-openshell-cli-pull-request.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ spec:
value: |
[
{"type": "cargo", "path": "."},
{"type": "rpm", "path": "deploy/konflux/cli"},
{"type": "generic", "path": "deploy/konflux/cli", "lockfile": "generic-fetcher.yaml"}
{"type": "cargo", "path": "deploy/konflux/cargo-auditable"},
{"type": "rpm", "path": "deploy/konflux/cli"}
]

pipelineRef:
Expand Down
4 changes: 2 additions & 2 deletions .tekton/odh-openshell-gateway-pull-request.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ spec:
value: |
[
{"type": "cargo", "path": "."},
{"type": "rpm", "path": "deploy/konflux/gateway"},
{"type": "generic", "path": "deploy/konflux/gateway", "lockfile": "generic-fetcher.yaml"}
{"type": "cargo", "path": "deploy/konflux/cargo-auditable"},
{"type": "rpm", "path": "deploy/konflux/gateway"}
]

pipelineRef:
Expand Down
4 changes: 2 additions & 2 deletions .tekton/odh-openshell-sandbox-pull-request.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ spec:
value: |
[
{"type": "cargo", "path": "."},
{"type": "rpm", "path": "deploy/konflux/sandbox"},
{"type": "generic", "path": "deploy/konflux/sandbox", "lockfile": "generic-fetcher.yaml"}
{"type": "cargo", "path": "deploy/konflux/cargo-auditable"},
{"type": "rpm", "path": "deploy/konflux/sandbox"}
]
pipelineRef:
params:
Expand Down
4 changes: 2 additions & 2 deletions .tekton/odh-openshell-supervisor-pull-request.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ spec:
value: |
[
{"type": "cargo", "path": "."},
{"type": "rpm", "path": "deploy/konflux/supervisor"},
{"type": "generic", "path": "deploy/konflux/supervisor", "lockfile": "generic-fetcher.yaml"}
{"type": "cargo", "path": "deploy/konflux/cargo-auditable"},
{"type": "rpm", "path": "deploy/konflux/supervisor"}
]

pipelineRef:
Expand Down
15 changes: 15 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ tokio = { version = "1.43", features = ["full"] }
# gRPC/Protobuf
tonic = "0.14"
tonic-types = "0.14"
tonic-reflection = "0.14"
tonic-prost = "0.14"
tonic-prost-build = "0.14"
prost = "0.14"
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,23 @@ The installer sets up the CLI and a local gateway. The default sandbox image is
- [Tutorials](https://docs.nvidia.com/openshell/latest/tutorials/first-network-policy): step-by-step policy and agent walkthroughs.
- [Prerelease and development builds](https://docs.nvidia.com/openshell/latest/about/installation#prerelease-and-development-builds): try an upcoming release or the latest commit on `main`.

### Test the gRPC API with grpcurl

The gateway serves the gRPC reflection v1 protocol. After starting a local
plaintext gateway, use `grpcurl` without checking out or supplying the proto
files:

```shell
grpcurl -plaintext localhost:18080 list
grpcurl -plaintext localhost:18080 describe openshell.v1.OpenShell
grpcurl -plaintext -d '{}' localhost:18080 openshell.v1.OpenShell/Health
```

The service list contains the public `openshell.v1.OpenShell` API. Reflection
does not advertise the gateway's internal compute-driver, credential-driver,
interceptor, or middleware services. For a TLS gateway, omit `-plaintext` and
supply the CA and client certificate options required by the deployment.

## Agent Skills

Install the public OpenShell skills for your coding agent:
Expand Down
90 changes: 76 additions & 14 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4183,14 +4183,10 @@ async fn expose_service_endpoint(
)),
})
.await
.map_err(service_expose_status_error)
.map_err(|status| service_status_error("expose service", "sandbox:write", sandbox, status))
.map(tonic::Response::into_inner)
}

fn service_expose_status_error(status: Status) -> miette::Report {
service_status_error("expose service", "sandbox:write", status)
}

#[allow(clippy::too_many_arguments)] // user-facing CLI command
pub async fn service_list(
server: &str,
Expand All @@ -4215,7 +4211,14 @@ pub async fn service_list(
}),
})
.await
.map_err(|status| service_status_error("list services", "sandbox:read", status))?
.map_err(|status| {
service_status_error(
"list services",
"sandbox:read",
sandbox.unwrap_or_default(),
status,
)
})?
.into_inner();

let next_page_token = response.next_page_token.clone();
Expand Down Expand Up @@ -4265,7 +4268,7 @@ pub async fn service_get(
name: service.to_string(),
})
.await
.map_err(|status| service_status_error("get service", "sandbox:read", status))?
.map_err(|status| service_status_error("get service", "sandbox:read", sandbox, status))?
.into_inner();

print_service_endpoint_table(&[response], server, false);
Expand All @@ -4291,7 +4294,7 @@ pub async fn service_delete(
)),
})
.await
.map_err(|status| service_status_error("delete service", "sandbox:write", status))?
.map_err(|status| service_status_error("delete service", "sandbox:write", sandbox, status))?
.into_inner();

if !deletion_completed(response.outcome)? {
Expand All @@ -4315,13 +4318,23 @@ pub async fn service_delete(
Ok(())
}

fn service_status_error(action: &str, required_scope: &str, status: Status) -> miette::Report {
fn service_status_error(
action: &str,
required_scope: &str,
sandbox: &str,
status: Status,
) -> miette::Report {
let message = status.message();
match status.code() {
Code::PermissionDenied => {
miette!("{action} failed: permission denied (requires {required_scope})")
}
Code::Unauthenticated => miette!("{action} failed: authentication required"),
Code::NotFound if message == "sandbox not found" && !sandbox.is_empty() => {
miette!(
"{action} failed: sandbox \"{sandbox}\" not found (the first argument is the sandbox name, not the service name)"
)
}
Code::NotFound if message == "sandbox not found" => {
miette!("{action} failed: sandbox not found")
}
Expand Down Expand Up @@ -6902,7 +6915,7 @@ mod tests {
policy_revision_to_json, proto_execution_timeout, provisioning_timeout_message,
ready_false_condition_message, relay_local_socket, resolve_from,
rootfs_tar_sources_supported_for_gateway, sandbox_should_persist, sandbox_upload_plan,
service_endpoint_to_json, service_expose_status_error, service_url_for_gateway,
service_endpoint_to_json, service_status_error, service_url_for_gateway,
workspace_member_to_json,
};
use openshell_core::proto::TcpForwardFrame;
Expand Down Expand Up @@ -7847,17 +7860,66 @@ mod tests {
}

#[test]
fn service_expose_status_error_mentions_required_scope() {
let report = service_expose_status_error(Status::permission_denied(
"scope 'sandbox:write' required",
));
fn service_status_error_mentions_required_scope() {
let report = service_status_error(
"expose service",
"sandbox:write",
"demo",
Status::permission_denied("scope 'sandbox:write' required"),
);

assert_eq!(
report.to_string(),
"expose service failed: permission denied (requires sandbox:write)"
);
}

#[test]
fn service_status_error_names_missing_sandbox() {
let report = service_status_error(
"delete service",
"sandbox:write",
"web-http",
Status::not_found("sandbox not found"),
);

assert_eq!(
report.to_string(),
"delete service failed: sandbox \"web-http\" not found \
(the first argument is the sandbox name, not the service name)"
);
}

#[test]
fn service_status_error_omits_empty_sandbox_name() {
let report = service_status_error(
"list services",
"sandbox:read",
"",
Status::not_found("sandbox not found"),
);

assert_eq!(
report.to_string(),
"list services failed: sandbox not found"
);
}

#[test]
fn service_status_error_keeps_missing_service_message() {
let report = service_status_error(
"get service",
"sandbox:read",
"demo",
Status::not_found("service endpoint not found"),
);

assert_eq!(
report.to_string(),
"get service failed: service endpoint not found"
);
}

#[test]
fn ready_false_condition_message_prefers_reason_and_message() {
let status = SandboxStatus {
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ libc = "0.2"

# gRPC
tonic = { workspace = true, features = ["channel", "tls-native-roots"] }
tonic-reflection = { workspace = true }
prost = { workspace = true }
prost-reflect = { workspace = true }
prost-types = { workspace = true }
Expand Down
10 changes: 7 additions & 3 deletions crates/openshell-server/src/auth/oidc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ use tracing::{debug, error, info, warn};
/// These are structural bypasses for gRPC infrastructure that doesn't map to a
/// single RPC method. Per-method bypasses (e.g. `Health`) are declared at the
/// handler with `auth_mode: "unauthenticated"` in the proto annotation.
const UNAUTHENTICATED_PREFIXES: &[&str] = &["/grpc.reflection.", "/grpc.health."];
const UNAUTHENTICATED_PREFIXES: &[&str] =
&[crate::multiplex::REFLECTION_PATH_PREFIX, "/grpc.health."];

/// Returns `true` if the method needs no authentication at all.
pub fn is_unauthenticated_method(path: &str) -> bool {
Expand Down Expand Up @@ -1205,10 +1206,13 @@ mod tests {
#[test]
fn reflection_is_unauthenticated() {
assert!(is_unauthenticated_method(
"/grpc.reflection.v1.ServerReflection/ServerReflectionInfo"
));
assert!(!is_unauthenticated_method(
"/grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo"
));
assert!(is_unauthenticated_method(
"/grpc.reflection.v1.ServerReflection/ServerReflectionInfo"
assert!(!is_unauthenticated_method(
"/grpc.reflection.v2.ServerReflection/ServerReflectionInfo"
));
}

Expand Down
69 changes: 69 additions & 0 deletions crates/openshell-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub(crate) mod policy_store;
mod provider_profile_sources;
mod provider_refresh;
mod readiness;
mod reflection;
mod sandbox_index;
mod sandbox_watch;
mod service_routing;
Expand Down Expand Up @@ -349,6 +350,9 @@ pub struct ServerState {
/// Gateway-wide gRPC request rate limiter shared by every multiplex path.
pub(crate) grpc_rate_limiter: Option<multiplex::GrpcRateLimiter>,

/// Immutable public reflection index and its per-query rate limiter.
pub(crate) reflection_service: reflection::GatewayReflectionServer,

/// Per-sandbox bound on extension credential minting, which resolves the
/// caller's effective policy on every request.
pub(crate) extension_mint_limiter: auth::extension_mint_limit::ExtensionMintLimiter,
Expand Down Expand Up @@ -426,6 +430,8 @@ impl ServerState {
compute.configure_ssh_identities(credentials.clone());
let peer_endpoint = derive_peer_endpoint(&config);
let grpc_rate_limiter = multiplex::GrpcRateLimiter::from_config(&config);
let reflection_service = reflection::build_gateway_reflection_service(&config)
.expect("compiled public gateway descriptors must be valid");
let admin_role = config
.oidc
.as_ref()
Expand Down Expand Up @@ -458,6 +464,7 @@ impl ServerState {
compute_driver_authenticator: None,
peer_authenticator: None,
grpc_rate_limiter,
reflection_service,
gateway_interceptors: None,
provider_profile_sources:
provider_profile_sources::ProviderProfileSources::with_default_sources(),
Expand Down Expand Up @@ -2219,6 +2226,68 @@ mod tests {
(listen_addr, shutdown_tx, handle, tls_dir)
}

async fn start_plaintext_gateway_listener()
-> (SocketAddr, watch::Sender<bool>, tokio::task::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("failed to bind test listener");
let listen_addr = listener.local_addr().expect("failed to read local addr");
let state = test_state(listen_addr, false).await;
let service = MultiplexService::new(state);
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let handle = tokio::spawn(serve_gateway_listener(
BoundGatewayListener {
listener,
address: listen_addr,
},
service,
None,
false,
shutdown_rx,
));
(listen_addr, shutdown_tx, handle)
}

#[tokio::test]
async fn production_gateway_listener_serves_reflection() {
use tonic_reflection::pb::v1::{
ServerReflectionRequest, server_reflection_client::ServerReflectionClient,
server_reflection_request::MessageRequest, server_reflection_response::MessageResponse,
};

let (addr, shutdown, handle) = start_plaintext_gateway_listener().await;
let channel = tonic::transport::Channel::from_shared(format!("http://{addr}"))
.unwrap()
.connect()
.await
.unwrap();
let mut client = ServerReflectionClient::new(channel);
let request = ServerReflectionRequest {
host: String::new(),
message_request: Some(MessageRequest::ListServices(String::new())),
};
let mut responses = client
.server_reflection_info(tokio_stream::iter([request]))
.await
.unwrap()
.into_inner();
let response = responses.message().await.unwrap().unwrap();
let Some(MessageResponse::ListServicesResponse(services)) = response.message_response
else {
panic!("expected a reflection list-services response");
};
assert_eq!(
services
.service
.iter()
.map(|service| service.name.as_str())
.collect::<Vec<_>>(),
["openshell.v1.OpenShell"]
);

stop_listener(shutdown, handle).await;
}

async fn send_plain_http(addr: SocketAddr, request: String) -> String {
let connect_addr: SocketAddr = format!("127.0.0.1:{}", addr.port())
.parse()
Expand Down
Loading
Loading