diff --git a/.cargo/config.toml b/.cargo/config.toml index 0005fc2bd0..8ba7dbaa7e 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -6,3 +6,6 @@ # the header lives in /usr/include/z3/ rather than /usr/include/. The extra -I # is harmless on systems where the path doesn't exist. BINDGEN_EXTRA_CLANG_ARGS = "-I/usr/include/z3" + +[alias] +xtask = "run --package xtask --" diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index d8f33f7016..a6f26ebdf1 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -121,24 +121,15 @@ jobs: run: ${{ matrix.cmd }} e2e-podman-rootless: - name: E2E (rust-podman-rootless, ${{ matrix.runner }}) - # Run directly on the Ubuntu host so the test observes the host's AppArmor - # and unprivileged-user-namespace policy. A privileged job container masks - # the restrictions that production rootless Podman installations enforce. - runs-on: ${{ matrix.runner }} - timeout-minutes: 30 + name: E2E (rust-podman-rootless, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 90 strategy: fail-fast: false matrix: - include: - # Ubuntu 24.04 matches the environment reported in #2069 and ships - # Podman 4.x. The probe records whether AppArmor blocks the drop. - - runner: ubuntu-24.04 - podman_major: "4" - # Ubuntu 26.04 provides the supported Podman 5.x coverage for - # comparison with the Ubuntu 24.04 environment. - - runner: ubuntu-26.04 - podman_major: "5" + os: + - ubuntu-24.04 + - ubuntu-26.04 env: IMAGE_TAG: ${{ inputs.image-tag }} MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -175,76 +166,9 @@ jobs: - name: Install tools run: mise install --locked - - name: Install Podman and build dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential \ - clang \ - fuse-overlayfs \ - libssl-dev \ - libz3-dev \ - openssh-client \ - passt \ - pkg-config \ - podman \ - slirp4netns \ - uidmap - - - name: Configure rootless Podman - run: | - set -euo pipefail - if ! grep -q "^${USER}:" /etc/subuid; then - sudo usermod --add-subuids 100000-165535 "$USER" - fi - if ! grep -q "^${USER}:" /etc/subgid; then - sudo usermod --add-subgids 100000-165535 "$USER" - fi - runtime_dir="/run/user/$(id -u)" - sudo install -d -m 0700 -o "$(id -u)" -g "$(id -g)" "$runtime_dir" - echo "XDG_RUNTIME_DIR=$runtime_dir" >> "$GITHUB_ENV" - - - name: Verify rootless Podman environment - run: | - set -euo pipefail - podman_version="$(podman version --format '{{.Client.Version}}')" - case "$podman_version" in - "${{ matrix.podman_major }}".*) ;; - *) echo "ERROR: expected Podman ${{ matrix.podman_major }}.x, found $podman_version" >&2; exit 1 ;; - esac - test "$(podman info --format '{{.Host.Security.Rootless}}')" = "true" - test "$(sudo sysctl -n kernel.apparmor_restrict_unprivileged_userns)" = "1" - echo "=== host ===" - uname -a - echo "=== AppArmor ===" - cat /proc/self/attr/current - sudo aa-status || true - echo "=== Podman ===" - podman version - podman info --debug - - - name: Probe rootless capability bounding set - run: | - set -euo pipefail - probe="$RUNNER_TEMP/openshell-capbset-probe" - cc -static -O2 -Wall -Wextra -Werror \ - e2e/support/capbset-probe.c \ - -o "$probe" - podman run --rm \ - --cap-add=SETPCAP \ - --volume "$probe:/openshell-capbset-probe:ro" \ - docker.io/library/alpine:3.22 \ - /openshell-capbset-probe - - - name: Log in to GHCR with Podman - run: echo "${{ secrets.GITHUB_TOKEN }}" | podman login ghcr.io -u "${{ github.actor }}" --password-stdin - - name: Run rootless Podman E2E - run: mise run --no-deps --skip-deps e2e:podman:rootless - - - name: Print AppArmor denials - if: always() - run: sudo dmesg | grep -E 'apparmor=.*DENIED|profile="unprivileged_userns"' | tail -100 || true + timeout-minutes: 90 + run: cargo xtask e2e --suite podman --setup --os "${{ matrix.os }}" e2e-vm: name: E2E (rust-vm) diff --git a/Cargo.lock b/Cargo.lock index 268da3982d..ffdfe00e3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8220,6 +8220,10 @@ version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" +[[package]] +name = "xtask" +version = "0.0.0" + [[package]] name = "xterm-color" version = "1.0.2" diff --git a/crates/xtask/Cargo.toml b/crates/xtask/Cargo.toml new file mode 100644 index 0000000000..e049023504 --- /dev/null +++ b/crates/xtask/Cargo.toml @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "xtask" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] diff --git a/crates/xtask/scripts/machine/development/ubuntu.sh b/crates/xtask/scripts/machine/development/ubuntu.sh new file mode 100644 index 0000000000..cc1f2c021f --- /dev/null +++ b/crates/xtask/scripts/machine/development/ubuntu.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -Eeuo pipefail + +echo "==> Installing OpenShell development dependencies on Ubuntu" +sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y \ + build-essential \ + ca-certificates \ + clang \ + cmake \ + curl \ + git \ + jq \ + libclang-dev \ + libssl-dev \ + libz3-dev \ + musl-tools \ + openssh-client \ + pkg-config \ + python3 \ + python3-venv \ + rsync \ + socat \ + unzip \ + xz-utils \ + zstd diff --git a/crates/xtask/scripts/machine/os/ubuntu.sh b/crates/xtask/scripts/machine/os/ubuntu.sh new file mode 100644 index 0000000000..beb4c2ef32 --- /dev/null +++ b/crates/xtask/scripts/machine/os/ubuntu.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -Eeuo pipefail + +if [ ! -r /etc/os-release ]; then + echo "cannot detect guest OS: /etc/os-release is missing" >&2 + exit 1 +fi + +# shellcheck disable=SC1091 +. /etc/os-release + +if [ "${ID:-}" != "ubuntu" ]; then + echo "expected an Ubuntu system, found ${ID:-unknown}" >&2 + exit 1 +fi + +if [ -n "${OPENSHELL_EXPECTED_OS:-}" ] \ + && [ "ubuntu-${VERSION_ID:-unknown}" != "${OPENSHELL_EXPECTED_OS}" ]; then + echo "expected ${OPENSHELL_EXPECTED_OS}, found ubuntu-${VERSION_ID:-unknown}" >&2 + exit 1 +fi + +echo "==> Preparing Ubuntu ${VERSION_ID:-unknown}" +sudo apt-get update diff --git a/crates/xtask/scripts/machine/suites/podman/common.sh b/crates/xtask/scripts/machine/suites/podman/common.sh new file mode 100644 index 0000000000..67c92ca3be --- /dev/null +++ b/crates/xtask/scripts/machine/suites/podman/common.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -Eeuo pipefail + +echo "==> Preparing rootless Podman" +if ! grep -q "^${USER}:" /etc/subuid; then + sudo usermod --add-subuids 100000-165535 "${USER}" +fi +if ! grep -q "^${USER}:" /etc/subgid; then + sudo usermod --add-subgids 100000-165535 "${USER}" +fi + +runtime_dir="/run/user/$(id -u)" +sudo install -d -m 0700 -o "$(id -u)" -g "$(id -g)" "${runtime_dir}" +export XDG_RUNTIME_DIR="${runtime_dir}" + +echo "==> Configuring rootless Podman" +containers_config_dir="${HOME}/.config/containers" +mkdir -p "${containers_config_dir}" +cat >"${containers_config_dir}/containers.conf" <<'EOF' +[containers] +# Use file-backed logs so Docker-compatible log reads work reliably. +log_driver = "k8s-file" + +[engine] +# Use file-backed events so open Libpod streams receive lifecycle events reliably. +events_logger = "file" +EOF + +if [ "${OPENSHELL_SKIP_PODMAN_SOCKET:-0}" != "1" ]; then + echo "==> Enabling the rootless Podman socket" + sudo loginctl enable-linger "$USER" + systemctl --user daemon-reload + systemctl --user enable --now podman.socket + if ! systemctl --user is-active --quiet podman.socket; then + echo "rootless Podman socket did not become active" >&2 + systemctl --user status --no-pager podman.socket >&2 || true + exit 1 + fi +fi + +require_podman_info() { + local format="$1" + local expected="$2" + local description="$3" + local actual + + if ! actual="$(podman info --format "${format}")"; then + echo "could not inspect Podman ${description}" >&2 + podman info >&2 || true + exit 1 + fi + if [ "${actual}" != "${expected}" ]; then + echo "expected Podman ${description} to be ${expected}, found ${actual:-}" >&2 + exit 1 + fi +} + +require_podman_info '{{.Host.Security.Rootless}}' true "rootless mode" +require_podman_info '{{.Host.LogDriver}}' k8s-file "log driver" +require_podman_info '{{.Host.EventLogger}}' file "event logger" diff --git a/crates/xtask/scripts/machine/suites/podman/ubuntu.sh b/crates/xtask/scripts/machine/suites/podman/ubuntu.sh new file mode 100644 index 0000000000..8cc000a8e1 --- /dev/null +++ b/crates/xtask/scripts/machine/suites/podman/ubuntu.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -Eeuo pipefail + +echo "==> Installing rootless Podman on Ubuntu" +sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y \ + ca-certificates \ + curl \ + podman diff --git a/crates/xtask/scripts/machine/validation/ubuntu-apparmor-diagnostics.sh b/crates/xtask/scripts/machine/validation/ubuntu-apparmor-diagnostics.sh new file mode 100644 index 0000000000..ddd4922d96 --- /dev/null +++ b/crates/xtask/scripts/machine/validation/ubuntu-apparmor-diagnostics.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +echo "=== recent AppArmor denials ===" +sudo dmesg \ + | grep -E 'apparmor=.*DENIED|profile="unprivileged_userns"' \ + | tail -100 \ + || true diff --git a/crates/xtask/scripts/machine/validation/ubuntu-podman-rootless-preflight.sh b/crates/xtask/scripts/machine/validation/ubuntu-podman-rootless-preflight.sh new file mode 100644 index 0000000000..66b4942cdc --- /dev/null +++ b/crates/xtask/scripts/machine/validation/ubuntu-podman-rootless-preflight.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -Eeuo pipefail + +case "${OPENSHELL_EXPECTED_OS:-}" in + ubuntu-24.04) expected_podman_major=4 ;; + ubuntu-26.04) expected_podman_major=5 ;; + *) + echo "cannot determine the expected Podman version for ${OPENSHELL_EXPECTED_OS:-unknown OS}" >&2 + exit 1 + ;; +esac + +echo "=== host ===" +uname -a +echo "=== AppArmor ===" +cat /proc/self/attr/current +sudo aa-status || true +echo "=== Podman ===" +podman version +podman info --debug + +podman_version="$(podman version --format '{{.Client.Version}}')" +case "${podman_version}" in + "${expected_podman_major}".*) ;; + *) + echo "expected Podman ${expected_podman_major}.x, found ${podman_version}" >&2 + exit 1 + ;; +esac + +apparmor_restrict_userns="$(sudo sysctl -n kernel.apparmor_restrict_unprivileged_userns)" +if [ "${apparmor_restrict_userns}" != "1" ]; then + echo "expected kernel.apparmor_restrict_unprivileged_userns=1, found ${apparmor_restrict_userns}" >&2 + exit 1 +fi + +echo "==> Probing the rootless capability bounding set" +capbset_probe="$(mktemp "${TMPDIR:-/tmp}/openshell-capbset-probe.XXXXXX")" +cleanup_capbset_probe() { + rm -f "${capbset_probe}" +} +trap cleanup_capbset_probe EXIT +cc -static -O2 -Wall -Wextra -Werror \ + e2e/support/capbset-probe.c \ + -o "${capbset_probe}" +podman run --rm \ + --cap-add=SETPCAP \ + --volume "${capbset_probe}:/openshell-capbset-probe:ro" \ + docker.io/library/alpine:3.22 \ + /openshell-capbset-probe +cleanup_capbset_probe +trap - EXIT diff --git a/crates/xtask/src/e2e.rs b/crates/xtask/src/e2e.rs new file mode 100644 index 0000000000..2e267e2754 --- /dev/null +++ b/crates/xtask/src/e2e.rs @@ -0,0 +1,259 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::ffi::{OsStr, OsString}; +use std::process::{Command, ExitCode, ExitStatus}; + +use crate::platform::{MachineOs, parse_machine_os}; +use crate::tasks::{TaskResult, exit_code, print_help_if_requested}; + +const HELP: &str = "Run an e2e suite locally or on a prepared host. + +Usage: + cargo xtask e2e --suite [--test ] [--setup --os ] + +Execution behavior: + omit --setup and --os to run locally without preparing or validating the host + --setup requires --os and prepares and validates the current host"; + +pub fn run(args: impl Iterator) -> TaskResult { + let mut args = args.peekable(); + if print_help_if_requested(&mut args, HELP) { + return Ok(ExitCode::SUCCESS); + } + + let command = E2eCommand::parse(args)?; + let status = match command.execution { + E2eExecution::Local => run_local(&command.selection), + E2eExecution::PreparedHost { os } => crate::e2e_host::run(&command.selection, os), + }?; + Ok(exit_code(status)) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum E2eSuite { + Podman, +} + +impl E2eSuite { + pub(crate) const fn id(self) -> &'static str { + match self { + Self::Podman => "podman", + } + } + + fn parse(value: &OsStr) -> Result { + match value.to_str() { + Some("podman") => Ok(Self::Podman), + Some(value) => Err(format!("unsupported e2e suite: {value} (expected podman)")), + None => Err("--suite must be valid UTF-8".to_owned()), + } + } + + pub(crate) const fn script(self) -> &'static str { + match self { + Self::Podman => "e2e/rust/e2e-podman.sh", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct E2eSelection { + pub(crate) suite: E2eSuite, + pub(crate) test: Option, +} + +impl E2eSelection { + pub(crate) const fn suite(&self) -> E2eSuite { + self.suite + } + + pub(crate) fn test(&self) -> Option<&str> { + self.test.as_deref() + } +} + +struct E2eCommand { + selection: E2eSelection, + execution: E2eExecution, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum E2eExecution { + Local, + PreparedHost { os: MachineOs }, +} + +impl E2eCommand { + fn parse(mut args: impl Iterator) -> Result { + let mut suite = None; + let mut test = None; + let mut setup = false; + let mut os = None; + + while let Some(argument) = args.next() { + match argument.to_str() { + Some("--suite") => { + let value = args + .next() + .ok_or_else(|| "--suite requires a value".to_owned())?; + if suite.replace(E2eSuite::parse(&value)?).is_some() { + return Err("--suite may only be specified once".to_owned()); + } + } + Some("--test") => { + let value = args + .next() + .ok_or_else(|| "--test requires a value".to_owned())?; + let value = value + .into_string() + .map_err(|_| "--test must be valid UTF-8".to_owned())?; + if value.is_empty() { + return Err("--test may not be empty".to_owned()); + } + if test.replace(value).is_some() { + return Err("--test may only be specified once".to_owned()); + } + } + Some("--setup") => { + if setup { + return Err("--setup may only be specified once".to_owned()); + } + setup = true; + } + Some("--os") => { + let value = args + .next() + .ok_or_else(|| "--os requires a value".to_owned())?; + if os.replace(parse_machine_os(&value, "--os")?).is_some() { + return Err("--os may only be specified once".to_owned()); + } + } + Some(value) => return Err(format!("unknown e2e option: {value}")), + None => return Err("e2e options must be valid UTF-8".to_owned()), + } + } + + let execution = match (setup, os) { + (true, Some(os)) => E2eExecution::PreparedHost { os }, + (true, None) => return Err("--setup requires --os".to_owned()), + (false, Some(_)) => return Err("--os requires --setup".to_owned()), + (false, None) => E2eExecution::Local, + }; + + Ok(Self { + selection: E2eSelection { + suite: suite.ok_or_else(|| "e2e requires --suite ".to_owned())?, + test, + }, + execution, + }) + } +} + +fn run_local(selection: &E2eSelection) -> Result { + local_command(selection).status().map_err(|error| { + format!( + "failed to run the {} e2e suite: {error}", + selection.suite.id() + ) + }) +} + +fn local_command(selection: &E2eSelection) -> Command { + let mut command = Command::new("mise"); + command.args(["exec", "--", selection.suite.script()]); + if let Some(test) = selection.test() { + command.args(["--test", test]); + } + command +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_a_local_e2e_selection() { + let command = E2eCommand::parse( + ["--suite", "podman", "--test", "smoke"] + .into_iter() + .map(OsString::from), + ) + .expect("local e2e command should parse"); + + assert_eq!(command.selection.suite, E2eSuite::Podman); + assert_eq!(command.selection.test.as_deref(), Some("smoke")); + assert_eq!(command.execution, E2eExecution::Local); + } + + #[test] + fn parses_a_prepared_host_selection() { + let command = E2eCommand::parse( + ["--suite", "podman", "--setup", "--os", "ubuntu-24.04"] + .into_iter() + .map(OsString::from), + ) + .expect("prepared-host e2e command should parse"); + + assert_eq!( + command.execution, + E2eExecution::PreparedHost { + os: MachineOs::Ubuntu24_04 + } + ); + } + + #[test] + fn requires_an_os_for_host_setup() { + let error = E2eCommand::parse( + ["--suite", "podman", "--setup"] + .into_iter() + .map(OsString::from), + ) + .err() + .expect("host setup without an OS should fail"); + + assert_eq!(error, "--setup requires --os"); + } + + #[test] + fn rejects_an_os_without_host_setup() { + let error = E2eCommand::parse( + ["--suite", "podman", "--os", "ubuntu-24.04"] + .into_iter() + .map(OsString::from), + ) + .err() + .expect("an OS without host setup should fail"); + + assert_eq!(error, "--os requires --setup"); + } + + #[test] + fn ubuntu_workflow_prepares_the_host_explicitly() { + let workflow = include_str!("../../../.github/workflows/e2e-test.yml"); + assert!( + workflow.contains(r#"cargo xtask e2e --suite podman --setup --os "${{ matrix.os }}""#) + ); + assert!(!workflow.contains("--provider host")); + } + + #[test] + fn configures_the_selected_local_test() { + let command = local_command(&E2eSelection { + suite: E2eSuite::Podman, + test: Some("smoke".to_owned()), + }); + + assert_eq!( + command.get_args().collect::>(), + ["exec", "--", "e2e/rust/e2e-podman.sh", "--test", "smoke"] + ); + assert!( + command + .get_envs() + .all(|(name, _)| { name != "OPENSHELL_E2E_PODMAN_TEST" }) + ); + } +} diff --git a/crates/xtask/src/e2e_host.rs b/crates/xtask/src/e2e_host.rs new file mode 100644 index 0000000000..326eba9426 --- /dev/null +++ b/crates/xtask/src/e2e_host.rs @@ -0,0 +1,198 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitStatus, Stdio}; + +use crate::e2e::{E2eSelection, E2eSuite}; +use crate::platform::MachineOs; + +const UBUNTU_OS_SETUP: &str = include_str!("../scripts/machine/os/ubuntu.sh"); +const UBUNTU_DEVELOPMENT_SETUP: &str = include_str!("../scripts/machine/development/ubuntu.sh"); +const UBUNTU_PODMAN_SETUP: &str = include_str!("../scripts/machine/suites/podman/ubuntu.sh"); +const COMMON_PODMAN_SETUP: &str = include_str!("../scripts/machine/suites/podman/common.sh"); +const UBUNTU_PODMAN_ROOTLESS_PREFLIGHT: &str = + include_str!("../scripts/machine/validation/ubuntu-podman-rootless-preflight.sh"); +const UBUNTU_APPARMOR_DIAGNOSTICS: &str = + include_str!("../scripts/machine/validation/ubuntu-apparmor-diagnostics.sh"); + +pub(crate) fn run(selection: &E2eSelection, os: MachineOs) -> Result { + let source = project_root().canonicalize().map_err(|error| { + format!( + "cannot resolve the OpenShell checkout at {}: {error}", + project_root().display() + ) + })?; + let script = host_script(selection, os, &source)?; + println!( + "==> Preparing this host and running the {} e2e suite on {}", + selection.suite().id(), + os.id() + ); + run_script(&script) +} + +fn host_script(selection: &E2eSelection, os: MachineOs, source: &Path) -> Result { + let setup = match selection.suite() { + E2eSuite::Podman => [ + UBUNTU_OS_SETUP, + UBUNTU_DEVELOPMENT_SETUP, + UBUNTU_PODMAN_SETUP, + COMMON_PODMAN_SETUP, + ] + .join("\n"), + }; + let selected_test = selection + .test() + .map_or_else(String::new, |test| format!(" --test {}", shell_quote(test))); + let command = format!( + "mise exec -- {}{selected_test}", + shell_quote(selection.suite().script()) + ); + let invocation = format!( + "{UBUNTU_PODMAN_ROOTLESS_PREFLIGHT}\n\ + set +e\n\ + {command}\n\ + e2e_status=$?\n\ + set -e\n\ + {UBUNTU_APPARMOR_DIAGNOSTICS}\n\ + exit \"${{e2e_status}}\"" + ); + + Ok(format!( + "export OPENSHELL_EXPECTED_OS={}\n\ + export OPENSHELL_SKIP_PODMAN_SOCKET=1\n\ + {setup}\n\ + if [ -n \"${{OPENSHELL_REGISTRY_HOST:-}}\" ] && \\ + [ -n \"${{OPENSHELL_REGISTRY_USERNAME:-}}\" ] && \\ + [ -n \"${{OPENSHELL_REGISTRY_PASSWORD:-}}\" ]; then\n\ + printf '%s' \"${{OPENSHELL_REGISTRY_PASSWORD}}\" | \\ + podman login \"${{OPENSHELL_REGISTRY_HOST}}\" \\ + --username \"${{OPENSHELL_REGISTRY_USERNAME}}\" --password-stdin\n\ + fi\n\ + cd {}\n\ + mise trust mise.toml\n\ + mise install --locked\n\ + {invocation}\n", + shell_quote(os.id()), + shell_quote_path(source)?, + )) +} + +fn run_script(script: &str) -> Result { + let mut child = Command::new("bash") + .arg("-s") + .stdin(Stdio::piped()) + .spawn() + .map_err(|error| format!("failed to start prepared-host e2e setup: {error}"))?; + child + .stdin + .take() + .ok_or_else(|| "failed to open stdin for prepared-host e2e setup".to_owned())? + .write_all(script.as_bytes()) + .map_err(|error| format!("failed to send prepared-host e2e setup: {error}"))?; + child + .wait() + .map_err(|error| format!("failed to wait for prepared-host e2e: {error}")) +} + +fn project_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn shell_quote_path(path: &Path) -> Result { + let value = path + .to_str() + .ok_or_else(|| format!("source path is not valid UTF-8: {}", path.display()))?; + Ok(shell_quote(value)) +} + +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn podman_selection(test: Option<&str>) -> E2eSelection { + E2eSelection { + suite: E2eSuite::Podman, + test: test.map(str::to_owned), + } + } + + #[test] + fn composes_ubuntu_host_setup_before_the_test() { + let script = host_script( + &podman_selection(Some("smoke'name")), + MachineOs::Ubuntu24_04, + Path::new("/work/OpenShell"), + ) + .expect("Ubuntu host setup should compose"); + + let os_setup = script.find("Preparing Ubuntu").expect("OS setup"); + let development = script + .find("Installing OpenShell development dependencies") + .expect("development setup"); + let podman = script + .find("Installing rootless Podman") + .expect("Podman setup"); + let rootless = script + .find("Preparing rootless Podman") + .expect("rootless Podman setup"); + let preflight = script + .find("Probing the rootless capability bounding set") + .expect("rootless Podman preflight"); + let test = script + .find("mise exec -- 'e2e/rust/e2e-podman.sh'") + .expect("test invocation"); + let diagnostics = script + .find("recent AppArmor denials") + .expect("AppArmor diagnostics"); + assert!(os_setup < development); + assert!(development < podman); + assert!(podman < rootless); + assert!(rootless < preflight); + assert!(preflight < test); + assert!(rootless < test); + assert!(test < diagnostics); + assert!(script.contains("export OPENSHELL_EXPECTED_OS='ubuntu-24.04'")); + assert!(script.contains("export OPENSHELL_SKIP_PODMAN_SOCKET=1")); + assert!(script.contains("mise exec -- 'e2e/rust/e2e-podman.sh' --test 'smoke'\"'\"'name'")); + assert!(!script.contains("OPENSHELL_E2E_PODMAN_TEST")); + assert!(!script.contains("Enabling the rootless Podman socket\nsudo loginctl")); + assert!(script.contains("expected_podman_major=4")); + assert!(script.contains("expected_podman_major=5")); + assert!(script.contains("kernel.apparmor_restrict_unprivileged_userns")); + assert!(script.contains("e2e/support/capbset-probe.c")); + } + + #[test] + fn conditionally_logs_into_the_registry_without_interpolating_secrets() { + let script = host_script( + &podman_selection(None), + MachineOs::Ubuntu26_04, + Path::new("/work/OpenShell"), + ) + .expect("Ubuntu host setup should compose"); + + assert!(script.contains("[ -n \"${OPENSHELL_REGISTRY_PASSWORD:-}\" ]")); + assert!(script.contains("printf '%s' \"${OPENSHELL_REGISTRY_PASSWORD}\"")); + assert!(script.contains("--password-stdin")); + } + + #[test] + fn generated_host_scripts_are_valid_bash() { + for os in [MachineOs::Ubuntu24_04, MachineOs::Ubuntu26_04] { + let script = host_script(&podman_selection(Some("smoke")), os, Path::new("/work")) + .expect("host setup should compose"); + let status = Command::new("bash") + .args(["-n", "-c", &script]) + .status() + .expect("bash syntax validation should run"); + assert!(status.success(), "generated script for {}", os.id()); + } + } +} diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs new file mode 100644 index 0000000000..692038dfae --- /dev/null +++ b/crates/xtask/src/main.rs @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::env; +use std::process::ExitCode; + +mod e2e; +mod e2e_host; +mod platform; +pub mod tasks; + +fn main() -> ExitCode { + let mut args = env::args_os().skip(1); + let result = match args.next() { + None => { + tasks::print_help(); + return ExitCode::SUCCESS; + } + Some(task) => match task.to_str() { + Some("help" | "-h" | "--help") => { + tasks::print_help(); + return ExitCode::SUCCESS; + } + Some("e2e") => tasks::e2e(args), + Some(invalid) => Err(format!("invalid task name: {invalid}")), + None => Err("task name must be valid UTF-8".to_owned()), + }, + }; + + match result { + Ok(exit_code) => exit_code, + Err(message) => { + eprintln!("error: {message}"); + tasks::print_help(); + ExitCode::FAILURE + } + } +} diff --git a/crates/xtask/src/platform.rs b/crates/xtask/src/platform.rs new file mode 100644 index 0000000000..b3c2616094 --- /dev/null +++ b/crates/xtask/src/platform.rs @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::ffi::OsStr; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MachineOs { + Ubuntu24_04, + Ubuntu26_04, +} + +impl MachineOs { + pub const fn id(self) -> &'static str { + match self { + Self::Ubuntu24_04 => "ubuntu-24.04", + Self::Ubuntu26_04 => "ubuntu-26.04", + } + } +} + +pub fn parse_machine_os(value: &OsStr, option: &str) -> Result { + match value.to_str() { + Some("ubuntu-24.04") => Ok(MachineOs::Ubuntu24_04), + Some("ubuntu-26.04") => Ok(MachineOs::Ubuntu26_04), + Some(value) => Err(format!( + "unsupported machine OS: {value} (expected ubuntu-24.04 or ubuntu-26.04)" + )), + None => Err(format!("{option} must be valid UTF-8")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_supported_target_operating_systems() { + assert_eq!( + parse_machine_os(OsStr::new("ubuntu-24.04"), "--os"), + Ok(MachineOs::Ubuntu24_04) + ); + assert_eq!( + parse_machine_os(OsStr::new("ubuntu-26.04"), "--os"), + Ok(MachineOs::Ubuntu26_04) + ); + } + + #[test] + fn rejects_unsupported_target_operating_systems() { + let error = parse_machine_os(OsStr::new("debian-13"), "--os") + .expect_err("unsupported machine OS should fail"); + assert!(error.contains("unsupported machine OS: debian-13")); + assert!(error.contains("expected ubuntu-24.04 or ubuntu-26.04")); + } +} diff --git a/crates/xtask/src/tasks/mod.rs b/crates/xtask/src/tasks/mod.rs new file mode 100644 index 0000000000..97c5dfdba1 --- /dev/null +++ b/crates/xtask/src/tasks/mod.rs @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::ffi::OsString; +use std::iter::Peekable; +use std::process::{ExitCode, ExitStatus}; + +pub use crate::e2e::run as e2e; + +const HELP: &str = "OpenShell development tasks + +Usage: + cargo xtask [options] + +Commands: + e2e Run an e2e suite locally or on a prepared host + +Run `cargo xtask --help` for command-specific usage."; + +pub type TaskResult = Result; + +pub fn print_help() { + println!("{HELP}"); +} + +pub(crate) fn print_help_if_requested(args: &mut Peekable, help: &str) -> bool +where + I: Iterator, +{ + if args.peek().is_some_and(is_help_argument) { + println!("{help}"); + return true; + } + + false +} + +fn is_help_argument(argument: &OsString) -> bool { + argument == "-h" || argument == "--help" +} + +pub(crate) fn exit_code(status: ExitStatus) -> ExitCode { + match status.code() { + Some(code) => ExitCode::from(u8::try_from(code).unwrap_or(1)), + None => ExitCode::FAILURE, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recognizes_command_help_arguments() { + assert!(is_help_argument(&OsString::from("-h"))); + assert!(is_help_argument(&OsString::from("--help"))); + assert!(!is_help_argument(&OsString::from("help"))); + } +} diff --git a/e2e/rust/e2e-podman.sh b/e2e/rust/e2e-podman.sh index 16796c0562..3aa73ccd4c 100755 --- a/e2e/rust/e2e-podman.sh +++ b/e2e/rust/e2e-podman.sh @@ -13,6 +13,23 @@ E2E_TEST="${OPENSHELL_E2E_PODMAN_TEST:-}" E2E_FEATURES="${OPENSHELL_E2E_PODMAN_FEATURES:-e2e-podman}" DEFAULT_WORKLOAD_MANIFEST="${ROOT}/e2e/gpu/images/.build/workloads.yaml" +while [ "$#" -gt 0 ]; do + case "$1" in + --test) + if [ "$#" -lt 2 ]; then + echo "ERROR: --test requires a value" >&2 + exit 2 + fi + E2E_TEST="$2" + shift 2 + ;; + *) + echo "ERROR: unknown Podman e2e option: $1" >&2 + exit 2 + ;; + esac +done + if [ "${E2E_TEST}" = "gpu" ] && [ -z "${OPENSHELL_E2E_WORKLOAD_MANIFEST:-}" ] && [ ! -f "${DEFAULT_WORKLOAD_MANIFEST}" ]; then echo "note: running Podman GPU e2e without a workload manifest; workload validation will log an explicit skip. Build one with 'CONTAINER_ENGINE=podman mise run e2e:workloads:build' or set OPENSHELL_E2E_WORKLOAD_MANIFEST." fi