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
1 change: 1 addition & 0 deletions .github/workflows/branch-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ jobs:
test-matrix: >-
[
{"environment":"fedora-podman-rootful","installer":"binaries","testsuite":"driver-podman"},
{"environment":"fedora-podman-rootless","installer":"binaries","testsuite":"e2e-podman"},
{"environment":"fedora-podman-rootless","installer":"binaries","testsuite":"driver-podman"}
]

Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/prepare-integration-inputs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ jobs:
- name: Build test archives
run: nix run .#build-artifacts-test-archives

- name: Build test workload images
run: nix run .#build-artifacts-test-images

- name: Upload integration inputs
id: upload-integration-inputs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
Expand Down
25 changes: 25 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,31 @@ Rust tests that still apply. Run the Podman-backed Rust CLI e2e suite:
mise run e2e:podman
```

Run the portable subset in a disposable rootless Podman guest:

```shell
nix run .#build-artifacts
nix run .#tmachine -- test fedora-podman-rootless binaries e2e-podman
nix run .#tmachine -- test fedora-podman-rootless binaries driver-podman
```

Print the exact tmachine archive selection as a shell `PODMAN_CI_TESTS` array:

```shell
nix run .#generate-podman-e2e-ci-tests
```

The `e2e-podman` testsuite runs a nextest archive built with the corresponding
Rust feature and preloads its Python workload image into the rootless Podman
store. The separate `driver-podman` testsuite compares OpenShell and direct
Podman user-namespace mappings for the default, `auto`, `keep-id`, and private
profiles. The E2E archive excludes binaries that still depend on wrapper-owned
gateway controls, host fixtures, missing guest tools, or nondeterministic relay
setup. The `driver-podman` suite replaces the removed `podman_userns` E2E
binary. `tests/artifacts.nix` keeps the follow-up exclusions explicit and uses
the same filter for the generated inventory, so excluded binaries cannot appear
as false passes or silently re-enter the archive.

Run the VM-backed Rust CLI e2e suite:

```shell
Expand Down
4 changes: 3 additions & 1 deletion architecture/build.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,9 @@ for explicit publication.

CLI conformance runs after target provisioning and operates only through the
configured OpenShell CLI. The smoke scenario verifies the black-box sandbox
lifecycle by creating, inspecting, executing in, and deleting a sandbox.
lifecycle by creating, inspecting, executing in, and deleting a sandbox. The
file-transfer scenario verifies portable upload and download behavior, Git-aware
filtering, and sandbox workspace path safety.
Feature suites use the same disposable guest but may provision isolated
dependencies after installation. The Keycloak provider-refresh suite starts a
guest-local Keycloak realm and verifies a successful OAuth refresh followed by
Expand Down
5 changes: 3 additions & 2 deletions architecture/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -858,8 +858,9 @@ with it.
fetches the current object, applies a mutation closure, and writes with a
`MatchResourceVersion` condition. On conflict the persistence layer returns a
`Conflict` error, which gRPC handlers map to `ABORTED` status so the client
(or the next watch/reconcile event) can retry with fresh state. There is no
automatic retry loop.
(or the next watch/reconcile event) can retry with fresh state. Provider
attach/detach handlers retry bounded server-driven conflicts around this helper;
an explicit client version still fails on its first conflict.

The helper accepts an `expected_version` parameter that selects between two
modes:
Expand Down
179 changes: 179 additions & 0 deletions crates/openshell-cli/tests/cli_help_integration.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Rendered help and command-shape checks for the `openshell` binary.

mod common;

use common::run_isolated;

#[test]
fn root_help_shows_top_level_commands() {
let output = run_isolated(&["--help"]);
assert_eq!(output.code, 0, "openshell --help:\n{}", output.combined);

for command in ["gateway", "status", "sandbox", "forward", "logs", "policy"] {
assert!(
output.combined.contains(command),
"expected '{command}' in openshell --help:\n{}",
output.combined
);
}
}

#[test]
fn gateway_help_shows_registration_commands_and_omits_lifecycle_commands() {
let output = run_isolated(&["gateway", "--help"]);
assert_eq!(output.code, 0, "gateway --help:\n{}", output.combined);

for command in ["add", "remove", "login", "logout", "select", "info", "list"] {
assert!(
output.combined.contains(command),
"expected '{command}' in gateway --help:\n{}",
output.combined
);
}
for removed in ["start", "stop", "destroy"] {
assert!(
!output.combined.contains(removed),
"unexpected removed command '{removed}' in gateway --help:\n{}",
output.combined
);
}
}

#[test]
fn sandbox_help_shows_transfer_and_lifecycle_commands() {
let output = run_isolated(&["sandbox", "--help"]);
assert_eq!(output.code, 0, "sandbox --help:\n{}", output.combined);

for command in [
"upload", "download", "create", "get", "list", "delete", "connect",
] {
assert!(
output.combined.contains(command),
"expected '{command}' in sandbox --help:\n{}",
output.combined
);
}
}

#[test]
fn sandbox_create_help_shows_creation_flags() {
let output = run_isolated(&["sandbox", "create", "--help"]);
assert_eq!(
output.code, 0,
"sandbox create --help:\n{}",
output.combined
);

for flag in [
"--gpu",
"--upload",
"--no-git-ignore",
"--editor",
"--auto-providers",
"--no-auto-providers",
] {
assert!(
output.combined.contains(flag),
"expected '{flag}' in sandbox create --help:\n{}",
output.combined
);
}
}

#[test]
fn sandbox_connect_help_shows_editor_flag() {
let output = run_isolated(&["sandbox", "connect", "--help"]);
assert_eq!(
output.code, 0,
"sandbox connect --help:\n{}",
output.combined
);
assert!(output.combined.contains("--editor"), "{}", output.combined);
}

#[test]
fn gateway_add_help_shows_endpoint_and_gateway_type_flags() {
let output = run_isolated(&["gateway", "add", "--help"]);
assert_eq!(output.code, 0, "gateway add --help:\n{}", output.combined);

for expected in ["--name", "--remote", "--local"] {
assert!(
output.combined.contains(expected),
"expected '{expected}' in gateway add --help:\n{}",
output.combined
);
}
assert!(
output.combined.contains("endpoint") || output.combined.contains("<ENDPOINT>"),
"expected endpoint argument in gateway add --help:\n{}",
output.combined
);
}

#[test]
fn gateway_login_help_describes_authentication() {
let output = run_isolated(&["gateway", "login", "--help"]);
assert_eq!(output.code, 0, "gateway login --help:\n{}", output.combined);

let help = output.combined.to_lowercase();
assert!(
["authenticat", "cloudflare", "login", "browser"]
.iter()
.any(|term| help.contains(term)),
"expected auth-related gateway login help:\n{}",
output.combined
);
}

#[test]
fn removed_gateway_lifecycle_subcommands_fail_to_parse() {
for command in ["start", "stop", "destroy"] {
let output = run_isolated(&["gateway", command, "--help"]);
assert_ne!(
output.code, 0,
"gateway {command} should fail after lifecycle command removal"
);
assert!(
output.combined.contains("unrecognized subcommand")
|| output.combined.contains("error:"),
"expected parser error for gateway {command}:\n{}",
output.combined
);
}
}

#[test]
fn gateway_add_rejects_conflicting_type_flags() {
let conflicting = run_isolated(&[
"gateway",
"add",
"https://example.com",
"--remote",
"user@host",
"--local",
]);
assert_ne!(
conflicting.code, 0,
"--remote and --local should conflict:\n{}",
conflicting.combined
);
}

#[test]
fn gateway_add_rejects_removed_ssh_key_flag() {
let removed = run_isolated(&[
"gateway",
"add",
"https://example.com",
"--ssh-key",
"/tmp/fake-key",
]);
assert_ne!(
removed.code, 0,
"removed --ssh-key flag should fail:\n{}",
removed.combined
);
}
95 changes: 95 additions & 0 deletions crates/openshell-cli/tests/cloudflare_gateway_integration.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Cloudflare gateway registration behavior that needs no external network.

mod common;

use common::{run, run_isolated};

#[test]
fn gateway_add_creates_cloudflare_metadata_and_selects_gateway() {
let config_dir = tempfile::tempdir().expect("create user config dir");
let system_dir = tempfile::tempdir().expect("create system config dir");

let output = run(
config_dir.path(),
system_dir.path(),
&[
"gateway",
"add",
"https://my-gateway.example.com",
"--name",
"test-cf-gw",
],
);
assert_eq!(output.code, 0, "gateway add:\n{}", output.combined);

let metadata_path = config_dir
.path()
.join("openshell/gateways/test-cf-gw/metadata.json");
let metadata: serde_json::Value = serde_json::from_slice(
&std::fs::read(&metadata_path).expect("read Cloudflare gateway metadata"),
)
.expect("parse Cloudflare gateway metadata");
assert_eq!(metadata["auth_mode"], "cloudflare_jwt");
assert_eq!(
metadata["gateway_endpoint"],
"https://my-gateway.example.com"
);
assert_eq!(metadata["name"], "test-cf-gw");
assert_eq!(metadata["is_remote"], true);

let active = std::fs::read_to_string(config_dir.path().join("openshell/active_gateway"))
.expect("read active gateway");
assert_eq!(active.trim(), "test-cf-gw");
assert!(
output.combined.contains("test-cf-gw") && output.combined.contains("added"),
"{}",
output.combined
);
}

#[test]
fn gateway_add_derives_cloudflare_name_from_hostname() {
let config_dir = tempfile::tempdir().expect("create user config dir");
let system_dir = tempfile::tempdir().expect("create system config dir");

let output = run(
config_dir.path(),
system_dir.path(),
&["gateway", "add", "https://my-special-gateway.brevlab.com"],
);
assert_eq!(output.code, 0, "gateway add:\n{}", output.combined);
assert!(
config_dir
.path()
.join("openshell/gateways/my-special-gateway.brevlab.com/metadata.json")
.exists()
);
}

#[test]
fn ssh_gateway_shorthand_conflicts_with_local_type() {
let local = run_isolated(&["gateway", "add", "ssh://user@host:8080", "--local"]);
assert_ne!(local.code, 0, "ssh:// with --local should fail");
}

#[test]
fn ssh_gateway_shorthand_conflicts_with_explicit_remote() {
let remote = run_isolated(&[
"gateway",
"add",
"ssh://user@host:8080",
"--remote",
"user@host",
]);
assert_ne!(remote.code, 0, "ssh:// with --remote should fail");
}

#[test]
fn ssh_gateway_shorthand_requires_port() {
let output = run_isolated(&["gateway", "add", "ssh://user@host"]);
assert_ne!(output.code, 0, "ssh:// without port should fail");
assert!(output.combined.contains("port"), "{}", output.combined);
}
40 changes: 40 additions & 0 deletions crates/openshell-cli/tests/common/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

use std::path::Path;
use std::process::{Command, Stdio};

pub struct CliOutput {
#[allow(dead_code)]
pub stdout: String,
pub combined: String,
pub code: i32,
}

pub fn run(config_dir: &Path, system_dir: &Path, args: &[&str]) -> CliOutput {
let output = Command::new(env!("CARGO_BIN_EXE_openshell"))
.args(args)
.env("XDG_CONFIG_HOME", config_dir)
.env("HOME", config_dir)
.env("OPENSHELL_SYSTEM_GATEWAY_DIR", system_dir)
.env("OPENSHELL_NO_BROWSER", "1")
.env_remove("OPENSHELL_GATEWAY")
.env_remove("OPENSHELL_GATEWAY_ENDPOINT")
.stdin(Stdio::null())
.output()
.expect("run openshell");

let stdout = String::from_utf8(output.stdout).expect("stdout is UTF-8");
let stderr = String::from_utf8(output.stderr).expect("stderr is UTF-8");
CliOutput {
combined: format!("{stdout}{stderr}"),
stdout,
code: output.status.code().unwrap_or(-1),
}
}

pub fn run_isolated(args: &[&str]) -> CliOutput {
let config_dir = tempfile::tempdir().expect("create isolated user config dir");
let system_dir = tempfile::tempdir().expect("create isolated system config dir");
run(config_dir.path(), system_dir.path(), args)
}
Loading
Loading