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
5 changes: 5 additions & 0 deletions .github/workflows/e2e-suites.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ jobs:
{"environment":"ubuntu-docker-rootful","installer":"deb","family":"features","package":"oci-image","gateway":"openshell","runner":"ubuntu-24.04"},
{"environment":"fedora-podman-rootful","installer":"rpm","family":"features","package":"oci-image","gateway":"openshell","runner":"ubuntu-24.04"},
{"environment":"fedora-podman-rootless","installer":"rpm","family":"features","package":"oci-image","gateway":"openshell","runner":"ubuntu-24.04"},
{"environment":"ubuntu-docker-rootful","installer":"deb","family":"features","package":"host-gateway","gateway":"openshell","runner":"ubuntu-24.04"},
{"environment":"fedora-podman-rootful","installer":"rpm","family":"features","package":"host-gateway","gateway":"openshell","runner":"ubuntu-24.04"},
{"environment":"fedora-podman-rootless","installer":"rpm","family":"features","package":"host-gateway","gateway":"openshell","runner":"ubuntu-24.04"},
{"environment":"ubuntu-k3s","installer":"k3s","family":"features","package":"host-gateway","gateway":"tmachine","runner":"ubuntu-24.04"},
{"environment":"ubuntu-k3s","installer":"k3s-ha","family":"features","package":"host-gateway","gateway":"tmachine","runner":"ubuntu-24.04"},
{"environment":"fedora-podman-rootful-keycloak","installer":"rpm","family":"features","package":"keycloak-provider-refresh","gateway":"openshell","runner":"ubuntu-24.04"},
{"environment":"fedora-podman-rootless-keycloak","installer":"rpm","family":"features","package":"keycloak-provider-refresh","gateway":"openshell","runner":"ubuntu-24.04"},
{"environment":"fedora-podman-rootful-userns-default","installer":"binaries","family":"drivers/podman","gateway":"tmachine","runner":"ubuntu-24.04"},
Expand Down
7 changes: 7 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,13 @@ mise run e2e:oci-image:podman
Both tasks set `OPENSHELL_TEST_CONTAINER_ENGINE`, the command that builds test
images into the gateway's image store.

The `host-gateway` feature suite (`tests/suites/features/host-gateway`) checks
that a sandbox from the Python test image reaches a server the test runs on the
host through `host.openshell.internal`. CI runs it on Docker rootful, Podman
rootful and rootless, and the `k3s` and `k3s-ha` installers. The tests run on
the K3s node, so the K3s installers set the chart's `server.hostGatewayIP` to
the node's address.

Run the VM-backed Rust CLI e2e suite:

```shell
Expand Down
9 changes: 9 additions & 0 deletions tests/suites/features/Cargo.lock

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

2 changes: 1 addition & 1 deletion tests/suites/features/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@

[workspace]
resolver = "2"
members = ["keycloak-provider-refresh", "kubernetes-updates", "oci-image"]
members = ["host-gateway", "keycloak-provider-refresh", "kubernetes-updates", "oci-image"]
17 changes: 17 additions & 0 deletions tests/suites/features/host-gateway/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

[package]
name = "host-gateway"
description = "Features suite: sandboxes reach a host server through host.openshell.internal"
version = "0.0.0"
edition = "2024"
rust-version = "1.94"
license = "Apache-2.0"
repository = "https://github.com/NVIDIA/OpenShell"
publish = false

[dependencies]
openshell-testkit = { path = "../../../testkit/testkit" }
tempfile = "3"
tokio = { version = "1.43", features = ["io-util", "net", "rt"] }
209 changes: 209 additions & 0 deletions tests/suites/features/host-gateway/tests/host_gateway.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! `host.openshell.internal` names the host from inside a sandbox, on every
//! compute driver.
//!
//! The test starts an HTTP server where the tests run, which is the host the
//! gateway runs its sandboxes on, and runs a client in a sandbox from the
//! Python test image ([`testkit::python_image`]) that requests a page from that
//! server through the name.

use testkit::{Context, Error, Result, ResultExt as _};
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
use tokio::net::{TcpListener, TcpStream};
use tokio::task::JoinHandle;

/// The image's system Python, which needs nothing beyond its standard library.
/// The policy names its real path, `/usr/bin/python3.*`, because a rule matches
/// the executable behind a symlink.
const PYTHON: &str = "/usr/bin/python3";

/// Requests `/` from `host.openshell.internal` on the port in `argv[1]` and
/// prints the response. A failed connection is retried for 20 seconds, so a
/// slow start doesn't fail the test, and reported on stderr.
const CLIENT: &str = r#"
import socket, sys, time

host, port = "host.openshell.internal", int(sys.argv[1])
deadline = time.monotonic() + 20
while True:
try:
conn = socket.create_connection((host, port), timeout=10)
break
except OSError as error:
print(f"connect {host}:{port}: {error}", file=sys.stderr, flush=True)
if time.monotonic() > deadline:
sys.exit(1)
time.sleep(1)
with conn:
conn.sendall(f"GET / HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n".encode())
response = b""
while chunk := conn.recv(4096):
response += chunk
print(response.decode(errors="replace"))
"#;

/// A sandbox from the Python test image resolves `host.openshell.internal`
/// and gets a response from a server the test runs on the host. The default
/// policy denies all network access, so the test passes one that lets only the
/// image's Python reach the server's port.
Comment thread
elezar marked this conversation as resolved.
Resolved
#[testkit::test]
async fn python_sandbox_reaches_host_server(cx: &mut Context) -> testkit::Result {
let marker = format!("host-gateway-{}", cx.run_id());
let server = HostServer::start(marker.clone()).await?;
let policy = PolicyFile::new(&policy(server.port))?;
let port = server.port.to_string();
let image = testkit::python_image();

// The client runs through `sandbox exec` in a Ready sandbox, not as the
// main process. A gateway with several replicas (k3s-ha) can refuse to
// attach to a main process that already finished with "sandbox is not
// ready" when the request reaches a replica that doesn't hold the
// sandbox's session.
Comment thread
elezar marked this conversation as resolved.
Resolved
let name = cx.name("hg");
cx.track(&name);
cx.cli([
"sandbox",
"create",
"--name",
&name,
"--from",
&image,
"--policy",
policy.path()?,
"--detach",
"--no-tty",
"--",
"sh",
"-c",
"exec sleep infinity",
])
.await?
.success()
.context(format!("create a sandbox from {image}"))?;
cx.adopt(&name).await?.wait_ready().await?;

let client = cx
.cli([
"sandbox", "exec", "--name", &name, "--no-tty", "--", PYTHON, "-c", CLIENT, &port,
])
.await?
.success()
.context("run the client in the sandbox")?;
testkit::ensure!(
client.stdout.contains(&marker),
"the client did not get the host server's response:\n{}{}",
client.stdout,
client.stderr
);
Ok(())
}

/// A sandbox policy that allows the image's system Python ([`PYTHON`]) to
/// connect to `host.openshell.internal` on `port`. The endpoint names only the
/// host and port, as the policy docs describe, so the driver's address for the
/// name is what makes it reachable, not an `allowed_ips` range.
fn policy(port: u16) -> String {
format!(
r#"version: 1

filesystem_policy:
include_workdir: true
read_only: [/usr, /lib, /lib64, /proc, /dev/urandom, /etc]
read_write: [/sandbox, /tmp, /dev/null]
landlock:
compatibility: best_effort

process:
run_as_user: sandbox
run_as_group: sandbox

network_policies:
host_server:
name: host_server
endpoints:
- host: host.openshell.internal
port: {port}
binaries:
- path: /usr/bin/python3.*
"#
)
}

/// An HTTP server on the host that answers every request with `body`. It
/// stops when dropped.
struct HostServer {
port: u16,
task: JoinHandle<()>,
}

impl HostServer {
/// Listen on every host address, since drivers map
/// `host.openshell.internal` to different ones.
async fn start(body: String) -> Result<Self> {
let listener = TcpListener::bind(("0.0.0.0", 0))
.await
.map_err(|error| Error::from_error(&error))
.context("bind the host server")?;
let port = listener
.local_addr()
.map_err(|error| Error::from_error(&error))
.context("read the host server's address")?
.port();
let task = tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
tokio::spawn(respond(stream, body.clone()));
}
});
Ok(Self { port, task })
}
}

impl Drop for HostServer {
fn drop(&mut self) {
self.task.abort();
}
}

/// Read one request's headers from `stream` and answer with `body`.
async fn respond(mut stream: TcpStream, body: String) {
let _ = stream.set_nodelay(true);
let mut request = Vec::new();
let mut buf = [0_u8; 1024];
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
match stream.read(&mut buf).await {
Ok(read) if read > 0 && request.len() < 64 * 1024 => {
request.extend_from_slice(&buf[..read]);
}
_ => return,
}
}
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.shutdown().await;
}

/// A sandbox policy written to a temporary file for `sandbox create --policy`.
struct PolicyFile(tempfile::NamedTempFile);

impl PolicyFile {
fn new(contents: &str) -> Result<Self> {
let file = tempfile::NamedTempFile::new()
.map_err(|error| Error::from_error(&error))
.context("create policy file")?;
std::fs::write(file.path(), contents)
.map_err(|error| Error::from_error(&error))
.context(format!("write {}", file.path().display()))?;
Ok(Self(file))
}

fn path(&self) -> Result<&str> {
let path = self.0.path();
path.to_str()
.ok_or_else(|| Error::msg(format!("{} is not UTF-8", path.display())))
}
}
4 changes: 4 additions & 0 deletions tests/testkit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ fails. A killed test process skips cleanup, so names are unique to the run.
compute driver. Driver suites call it first.
- `testkit::base_image()` for the image to create sandboxes from or build on:
`OPENSHELL_TEST_BASE_IMAGE`, or OpenShell's default sandbox image.
- `testkit::python_image()` for sandboxes that run `python3`, curl, git, or
openssl: `OPENSHELL_TEST_PYTHON_IMAGE`, or `openshell/e2e-python:dev`
(`e2e/python/Dockerfile.workload`). The tmachine installers load it into
every environment; `mise run e2e:workload:build` builds it locally.
- `cx.cli([...])` to run the installed CLI against the same gateway, with
`success()`, `failure()`, and `json()` on the output. `cx.cli_handle()`
returns that CLI for code that can't borrow `cx`, such as a cleanup hook.
Expand Down
2 changes: 1 addition & 1 deletion tests/testkit/testkit/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ use crate::gateway::{ENDPOINT_ENV, GATEWAY_ENV, Gateway, WORKSPACE_ENV};
/// How long one CLI invocation may take.
pub const CLI_TIMEOUT: Duration = Duration::from_mins(5);
/// The CLI binary to run. Defaults to `openshell` on PATH.
pub const BIN_ENV: &str = "OPENSHELL_BIN";
pub(crate) const BIN_ENV: &str = "OPENSHELL_BIN";

/// Runs the CLI against the gateway and workspace the tests use.
#[derive(Clone, Debug)]
Expand Down
6 changes: 3 additions & 3 deletions tests/testkit/testkit/src/gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ use openshell_sdk::{ClientConfig, ClientIdentity, Health, OpenShellClient, Servi

use crate::error::{Error, Result, ResultExt};

pub const ENDPOINT_ENV: &str = "OPENSHELL_GATEWAY_ENDPOINT";
pub const GATEWAY_ENV: &str = "OPENSHELL_GATEWAY";
pub const WORKSPACE_ENV: &str = "OPENSHELL_WORKSPACE";
pub(crate) const ENDPOINT_ENV: &str = "OPENSHELL_GATEWAY_ENDPOINT";
pub(crate) const GATEWAY_ENV: &str = "OPENSHELL_GATEWAY";
pub(crate) const WORKSPACE_ENV: &str = "OPENSHELL_WORKSPACE";
const DEFAULT_WORKSPACE: &str = "default";

// Mirrors the SDK's endpoint settings for channels testkit builds itself.
Expand Down
27 changes: 22 additions & 5 deletions tests/testkit/testkit/src/image.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,35 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! The base image tests create sandboxes from or build their images on.
//! The images tests create sandboxes from or build their images on.

/// Overrides [`DEFAULT_BASE_IMAGE`], e.g. with a mirror.
pub const BASE_IMAGE_ENV: &str = "OPENSHELL_TEST_BASE_IMAGE";
const BASE_IMAGE_ENV: &str = "OPENSHELL_TEST_BASE_IMAGE";
/// OpenShell's built-in default sandbox image. Pulling it needs access to
/// nvcr.io.
pub const DEFAULT_BASE_IMAGE: &str = "nvcr.io/nvidia/base/ubuntu:24.04";
const DEFAULT_BASE_IMAGE: &str = "nvcr.io/nvidia/base/ubuntu:24.04";

/// Overrides [`DEFAULT_PYTHON_IMAGE`].
const PYTHON_IMAGE_ENV: &str = "OPENSHELL_TEST_PYTHON_IMAGE";
/// The workload image for tests that run `python3`, curl, git or openssl in a
/// sandbox, built from `e2e/python/Dockerfile.workload`. It isn't published:
/// the tmachine installers load it into every environment, and
/// `mise run e2e:workload:build` builds it locally.
const DEFAULT_PYTHON_IMAGE: &str = "openshell/e2e-python:dev";

/// The image named by [`BASE_IMAGE_ENV`], or [`DEFAULT_BASE_IMAGE`].
pub fn base_image() -> String {
std::env::var(BASE_IMAGE_ENV)
image_from_env(BASE_IMAGE_ENV, DEFAULT_BASE_IMAGE)
}

/// The image named by [`PYTHON_IMAGE_ENV`], or [`DEFAULT_PYTHON_IMAGE`].
pub fn python_image() -> String {
image_from_env(PYTHON_IMAGE_ENV, DEFAULT_PYTHON_IMAGE)
}

fn image_from_env(var: &str, default: &str) -> String {
std::env::var(var)
.ok()
.filter(|image| !image.trim().is_empty())
.unwrap_or_else(|| DEFAULT_BASE_IMAGE.to_string())
.unwrap_or_else(|| default.to_string())
}
4 changes: 2 additions & 2 deletions tests/testkit/testkit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,14 @@ mod registry;
mod run;

pub use cleanup::CLEANUP_TIMEOUT;
pub use cli::{BIN_ENV, CLI_TIMEOUT, Cli, CliOutput};
pub use cli::{CLI_TIMEOUT, Cli, CliOutput};
pub use context::{
Context, DELETE_TIMEOUT, EXEC_TIMEOUT, ExecOutput, READY_TIMEOUT, STOP_TIMEOUT, Sandbox,
SandboxBuilder,
};
pub use error::{Error, Result, ResultExt};
pub use gateway::Gateway;
pub use image::{BASE_IMAGE_ENV, DEFAULT_BASE_IMAGE, base_image};
pub use image::{base_image, python_image};
pub use openshell_sdk as sdk;
pub use poll::{Poll, poll_until};
pub use program::main;
Expand Down
Loading
Loading