From a7869ff5f5350f31b8ae539de364c134ecfc0d75 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Mon, 19 Oct 2026 23:27:55 -0700 Subject: [PATCH 01/10] build(cargo): add a check that aligned workspaces lock shared crates like the root `cargo --locked` only checks a lockfile against its own manifest. A workspace outside the root that builds root crates through path dependencies, such as the testkit suites building `openshell-sdk`, has its own Cargo.lock, so the two lockfiles can resolve a shared crate to different versions without any check failing. That is how aws-lc-sys 0.46 was picked up in #4629, which does not link against the AWS-LC that Nix provides. check_cargo_lockfile_alignment.py fails when an aligned workspace locks a registry crate that the root lockfile also contains at a version the root lockfile does not have. With --fix it re-locks the workspace starting from the root lockfile. The script and its tests come from #4629 (a37ebe917). The aligned workspaces are tests/testkit and tests/suites/sandbox instead of the old conformance suites. The check is wired into the lockfile task and CI in the next commit. Co-authored-by: Evan Lezar Signed-off-by: Matthew Grossman --- .../scripts/check_cargo_lockfile_alignment.py | 169 ++++++++++++++++++ .../check_cargo_lockfile_alignment_test.py | 85 +++++++++ 2 files changed, 254 insertions(+) create mode 100644 tasks/scripts/check_cargo_lockfile_alignment.py create mode 100644 tasks/scripts/check_cargo_lockfile_alignment_test.py diff --git a/tasks/scripts/check_cargo_lockfile_alignment.py b/tasks/scripts/check_cargo_lockfile_alignment.py new file mode 100644 index 0000000000..1cf953711a --- /dev/null +++ b/tasks/scripts/check_cargo_lockfile_alignment.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# /// script +# requires-python = ">=3.11" +# /// + +"""Check that aligned Cargo workspaces lock shared crates like the root does. + +Some workspaces outside the root build root crates through path +dependencies, for example the testkit suites build `openshell-sdk`. Each +workspace has its own Cargo.lock, and `cargo --locked` only checks a lockfile +against its own manifest, so nothing stops the two lockfiles from resolving +the same crate to different versions. The root's versions are the ones CI and +the Nix toolchain are built for; a newer `aws-lc-sys`, for example, does not +link against the AWS-LC that Nix provides. + +This check fails when an aligned workspace locks a registry crate that the +root lockfile also contains at a version the root lockfile does not have. +With `--fix`, it re-locks each aligned workspace starting from the root +lockfile. +""" + +from __future__ import annotations + +import argparse +import re +import shutil +import subprocess +import sys +import tomllib +from dataclasses import dataclass +from pathlib import Path + +# Workspaces whose shared crates must match the root lockfile. Add a +# workspace here once its lockfile has been aligned with `--fix`. +ALIGNED_WORKSPACES = ("tests/testkit", "tests/suites/sandbox") + + +@dataclass(frozen=True) +class Mismatch: + workspace: str + name: str + version: str + root_versions: tuple[str, ...] + + def target(self) -> str | None: + """The root version semver-compatible with the locked one, if any.""" + key = compat_key(self.version) + candidates = [v for v in self.root_versions if compat_key(v) == key] + return max(candidates, key=version_tuple) if candidates else None + + +def version_tuple(version: str) -> tuple[int, ...]: + core = re.split(r"[-+]", version, maxsplit=1)[0] + return tuple(int(part) for part in core.split(".")) + + +def compat_key(version: str) -> tuple[int, ...]: + """Cargo's caret-compatibility class: the leftmost non-zero component.""" + major, minor, patch = (*version_tuple(version), 0, 0, 0)[:3] + if major: + return (major,) + if minor: + return (0, minor) + return (0, 0, patch) + + +def registry_versions(lockfile: Path) -> dict[str, set[str]]: + """Versions of registry (non-path) packages in a lockfile, by name.""" + data = tomllib.loads(lockfile.read_text()) + versions: dict[str, set[str]] = {} + for package in data.get("package", []): + if "source" in package: + versions.setdefault(package["name"], set()).add(package["version"]) + return versions + + +def mismatches(root: Path, workspace: str) -> list[Mismatch]: + root_versions = registry_versions(root / "Cargo.lock") + found = [] + for name, versions in sorted( + registry_versions(root / workspace / "Cargo.lock").items() + ): + shared = root_versions.get(name) + if shared is None: + continue + for version in sorted(versions - shared, key=version_tuple): + found.append( + Mismatch( + workspace, name, version, tuple(sorted(shared, key=version_tuple)) + ) + ) + return found + + +def fix(root: Path, workspace: str) -> list[Mismatch]: + """Re-lock the workspace starting from the root lockfile. + + Coupled crates such as the `futures-*` family pin each other, so they + cannot be moved one at a time. Seeding the workspace lockfile with the + root's lets Cargo keep every root version that still fits, add only the + crates the root does not have, and drop the rest. Returns what is still + misaligned afterwards. + """ + shutil.copyfile(root / "Cargo.lock", root / workspace / "Cargo.lock") + subprocess.run( + [ + "cargo", + "metadata", + "--format-version", + "1", + "--manifest-path", + str(root / workspace / "Cargo.toml"), + ], + check=True, + stdout=subprocess.DEVNULL, + ) + return mismatches(root, workspace) + + +def report(problems: list[Mismatch]) -> None: + for m in problems: + target = m.target() + hint = ( + f"; root has {target}" + if target + else f"; root has {', '.join(m.root_versions)}" + ) + print( + f"error: {m.workspace}: {m.name} {m.version} is not in the root Cargo.lock{hint}", + file=sys.stderr, + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--fix", action="store_true", help="update aligned lockfiles to root versions" + ) + args = parser.parse_args(argv) + + root = Path( + subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + ) + problems: list[Mismatch] = [] + for workspace in ALIGNED_WORKSPACES: + print(f"Checking {workspace}/Cargo.lock against Cargo.lock") + problems += fix(root, workspace) if args.fix else mismatches(root, workspace) + + if problems: + report(problems) + if not args.fix: + print( + "Run `uv run tasks/scripts/check_cargo_lockfile_alignment.py --fix` to align them.", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tasks/scripts/check_cargo_lockfile_alignment_test.py b/tasks/scripts/check_cargo_lockfile_alignment_test.py new file mode 100644 index 0000000000..955f31db9e --- /dev/null +++ b/tasks/scripts/check_cargo_lockfile_alignment_test.py @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the Cargo lockfile alignment check.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +_SPEC = importlib.util.spec_from_file_location( + "check_cargo_lockfile_alignment", + Path(__file__).with_name("check_cargo_lockfile_alignment.py"), +) +assert _SPEC and _SPEC.loader +alignment = importlib.util.module_from_spec(_SPEC) +sys.modules[_SPEC.name] = alignment +_SPEC.loader.exec_module(alignment) + +REGISTRY = 'source = "registry+https://github.com/rust-lang/crates.io-index"' + + +def lockfile(path: Path, packages: list[tuple[str, str, bool]]) -> None: + """Write a lockfile of (name, version, from_registry) packages.""" + entries = [] + for name, version, from_registry in packages: + entry = f'[[package]]\nname = "{name}"\nversion = "{version}"\n' + if from_registry: + entry += REGISTRY + "\n" + entries.append(entry) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("version = 4\n\n" + "\n".join(entries)) + + +def test_compat_key_follows_cargo_caret_rules() -> None: + assert alignment.compat_key("1.52.1") == (1,) + assert alignment.compat_key("0.44.0") == (0, 44) + assert alignment.compat_key("0.0.3") == (0, 0, 3) + assert alignment.compat_key("1.0.4+wasi-0.2.12") == (1,) + + +def test_reports_shared_registry_crates_at_other_versions(tmp_path: Path) -> None: + lockfile( + tmp_path / "Cargo.lock", + [ + ("tokio", "1.52.1", True), + ("rand", "0.8.6", True), + ("rand", "0.9.4", True), + ("openshell-sdk", "0.0.0", False), + ], + ) + lockfile( + tmp_path / "ws" / "Cargo.lock", + [ + ("tokio", "1.53.1", True), + ("rand", "0.9.5", True), + ("rand", "0.8.6", True), + ("only-here", "2.0.0", True), + ("openshell-sdk", "0.0.0", False), + ], + ) + + found = alignment.mismatches(tmp_path, "ws") + + assert [(m.name, m.version, m.target()) for m in found] == [ + ("rand", "0.9.5", "0.9.4"), + ("tokio", "1.53.1", "1.52.1"), + ] + + +def test_no_target_without_a_compatible_root_version(tmp_path: Path) -> None: + lockfile(tmp_path / "Cargo.lock", [("syn", "1.0.109", True)]) + lockfile(tmp_path / "ws" / "Cargo.lock", [("syn", "2.0.119", True)]) + + (found,) = alignment.mismatches(tmp_path, "ws") + + assert found.target() is None + + +def test_aligned_lockfiles_pass(tmp_path: Path) -> None: + lockfile(tmp_path / "Cargo.lock", [("tokio", "1.52.1", True)]) + lockfile(tmp_path / "ws" / "Cargo.lock", [("tokio", "1.52.1", True)]) + + assert alignment.mismatches(tmp_path, "ws") == [] From 38670c73a44559223bd8cba3686cea9a1dc88eab Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Mon, 19 Oct 2026 23:27:56 -0700 Subject: [PATCH 02/10] test(sandbox): add the first testkit suite and run it in Branch E2E Give new end-to-end tests a home and a CI lane. New tests go in testkit; the old suites take fixes only. - tests/suites/sandbox is a testkit suite workspace. It starts with the long_running_exec_delete test, adapted from the sandbox-lifecycle suite in #4629. Its README holds the guidance on what a test may assert. - The suite builds into a Nix test archive with `--locked`, and a tmachine testsuite and playbook run it against the gateway the installer registered. testkit finds that gateway the way the CLI does, so no target file is needed. The playbook waits for the gateway first, so an unavailable gateway is one environment failure rather than N test failures. It runs nextest serially with a slow-timeout that terminates hung tests, and fails the lane when fewer tests run than expected. - Branch E2E runs it in a `testkit-integration` job on Ubuntu with Docker and the Debian package. It isn't part of the Core E2E result yet. - The alignment check runs in `rust:lockfiles:check` and the Cargo lockfiles CI job, with its tests in `mise run test`, and enrolls tests/testkit and the new suite. A PR that bumps a shared crate in the root Cargo.lock now needs `uv run tasks/scripts/check_cargo_lockfile_alignment.py --fix`. - `mise run e2e:testkit` (and `e2e:testkit:podman`) run the suite against an ephemeral local gateway. - fmt and clippy cover the new workspace in Branch Checks and the mise tasks. Co-authored-by: Evan Lezar Signed-off-by: Matthew Grossman --- .github/workflows/branch-checks.yml | 4 +- .github/workflows/branch-e2e.yml | 18 +++ TESTING.md | 29 +++- tasks/rust.toml | 7 +- tasks/test.toml | 9 ++ .../playbooks/suites/sandbox-lifecycle.yaml | 143 ++++++++++++++++++ tests/suites/sandbox/Cargo.lock | 8 +- tests/suites/sandbox/README.md | 16 +- tests/tmachine/artifacts.nix | 11 ++ tests/tmachine/config.nix | 7 + 10 files changed, 236 insertions(+), 16 deletions(-) create mode 100644 tests/ansible/playbooks/suites/sandbox-lifecycle.yaml diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index a72ce705e8..11de9e42eb 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -240,7 +240,9 @@ jobs: - name: Verify Cargo lockfiles shell: nix develop -c bash -euo pipefail {0} - run: tasks/scripts/check-cargo-lockfiles.sh + run: | + tasks/scripts/check-cargo-lockfiles.sh + uv run tasks/scripts/check_cargo_lockfile_alignment.py rust-lint: name: Rust lint (${{ matrix.system }}) diff --git a/.github/workflows/branch-e2e.yml b/.github/workflows/branch-e2e.yml index 0323d07ac5..7d0c7f7ec9 100644 --- a/.github/workflows/branch-e2e.yml +++ b/.github/workflows/branch-e2e.yml @@ -261,6 +261,24 @@ jobs: {"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"conformance"} ] + # Run suites written on testkit (tests/testkit). New end-to-end tests go here; + # the older suites above take fixes only. + testkit-integration: + needs: prepare-integration + permissions: + actions: read + contents: read + packages: read + uses: ./.github/workflows/integration-runner.yml + with: + category: testkit + source-sha: ${{ needs.prepare-integration.outputs.source_sha }} + integration-inputs-artifact-id: ${{ needs.prepare-integration.outputs.integration_inputs_artifact_id }} + test-matrix: >- + [ + {"environment":"ubuntu-docker-rootful","installer":"deb","testsuite":"sandbox-lifecycle"} + ] + # Run feature-specific integration tests: feature-specific-integration: needs: prepare-integration diff --git a/TESTING.md b/TESTING.md index ff3247598a..f90a096887 100644 --- a/TESTING.md +++ b/TESTING.md @@ -17,6 +17,7 @@ python/openshell/ # Python unit tests (*_test.py suffix) e2e/python/ # Python E2E tests (test_*.py prefix) e2e/rust/ # Rust CLI E2E tests tests/testkit/ # Support library for SDK-driven E2E suites +tests/suites/sandbox/ # E2E suites written on testkit ``` ## Rust Tests @@ -140,10 +141,30 @@ by `OPENSHELL_BIN`. See [tests/testkit/README.md](tests/testkit/README.md) for how to write and run tests. -The `testkit-e2e` job in Branch E2E (`test:e2e` label) runs the suites under -`tests/suites/sandbox` against a Docker-backed gateway with -`mise run e2e:testkit`. The same task works locally. It isn't part of the Core -E2E result yet. +**New end-to-end tests go in testkit.** The older suites under +`tests/suites/conformance`, `features` and `drivers` and the `e2e/` suites take +fixes only. Suites live under `tests/suites//`, for example +`tests/suites/sandbox/`, and each family is its own Cargo workspace with its own +lockfile. Run them against a local Docker gateway with: + +```shell +mise run e2e:testkit +``` + +CI runs them in two Branch E2E jobs (label `test:e2e`). `testkit-e2e` runs +`mise run e2e:testkit` against a Docker-backed gateway. `testkit-integration` +runs them through a tmachine lane that installs the Debian package. The suite's playbook +fails the lane if fewer tests run than it expects, so raise +`suite_expected_tests` in +`tests/ansible/playbooks/suites/sandbox-lifecycle.yaml` when you add a +test. + +Workspaces that build root crates through path dependencies must lock shared +crates at the versions in the root `Cargo.lock`; otherwise CI can build against +dependencies the Nix toolchain doesn't support. `mise run rust:lockfiles:check` +verifies this, and `uv run tasks/scripts/check_cargo_lockfile_alignment.py --fix` +re-locks a workspace from the root lockfile. Add new testkit workspaces to +`ALIGNED_WORKSPACES` in that script. ### Python E2E (`e2e/python/`) diff --git a/tasks/rust.toml b/tasks/rust.toml index 88479e49a1..cf25697e14 100644 --- a/tasks/rust.toml +++ b/tasks/rust.toml @@ -18,8 +18,11 @@ run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts hide = true ["rust:lockfiles:check"] -description = "Verify all tracked Cargo lockfiles are current" -run = "tasks/scripts/check-cargo-lockfiles.sh" +description = "Verify all tracked Cargo lockfiles are current and aligned with the root" +run = [ + "tasks/scripts/check-cargo-lockfiles.sh", + "uv run tasks/scripts/check_cargo_lockfile_alignment.py", +] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/check-cargo-lockfiles.ps1" hide = true diff --git a/tasks/test.toml b/tasks/test.toml index b3927d8feb..3e642feb96 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -23,6 +23,7 @@ depends = [ "test:docs-website", "test:docs-nav", "test:maintainer-approval", + "test:cargo-lockfile-alignment", ] ["test:docs-website"] @@ -31,6 +32,10 @@ description = "Test the docs-website sync script" # dependencies and the script's runtime dependency, which live outside the project env. run = "uv run --no-project --with pytest --with pytest-asyncio --with pyyaml pytest tasks/scripts/sync_docs_website_test.py" +["test:cargo-lockfile-alignment"] +description = "Test the Cargo lockfile alignment check" +run = "uv run --no-project --with pytest pytest tasks/scripts/check_cargo_lockfile_alignment_test.py" + ["test:docs-nav"] description = "Test the docs navigation check" run = "uv run --no-project --with pytest --with pytest-asyncio --with pyyaml pytest tasks/scripts/check_docs_nav_test.py" @@ -164,6 +169,10 @@ run = "OPENSHELL_TEST_CONTAINER_ENGINE=docker e2e/with-docker-gateway.sh cargo t description = "Run the shared OCI image feature suite against a Podman-backed gateway" run = "OPENSHELL_TEST_CONTAINER_ENGINE=podman e2e/with-podman-gateway.sh cargo test --locked --manifest-path tests/suites/features/Cargo.toml -p openshell-test-feature-oci-image -- --test-threads 1 --nocapture" +["e2e:testkit:podman"] +description = "Run the testkit suites against a Podman-backed gateway" +run = "e2e/with-podman-gateway.sh cargo test --locked --manifest-path tests/suites/sandbox/Cargo.toml --workspace -- --test-threads 1 --nocapture" + ["e2e:rust:push"] description = "Run CLI conformance and policy e2e tests against a Docker gateway that pushes configuration" depends = ["e2e:conformance:build"] diff --git a/tests/ansible/playbooks/suites/sandbox-lifecycle.yaml b/tests/ansible/playbooks/suites/sandbox-lifecycle.yaml new file mode 100644 index 0000000000..4686d5cca5 --- /dev/null +++ b/tests/ansible/playbooks/suites/sandbox-lifecycle.yaml @@ -0,0 +1,143 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +# Runs the testkit sandbox-lifecycle suite against the gateway the installer +# registered. testkit finds that gateway the way the CLI does, so no target +# file is needed. +- name: Run the sandbox lifecycle suite + hosts: all + gather_facts: false + vars: + suite_root: /var/lib/openshell-sandbox-lifecycle/tests + # Fail when the run executes fewer tests than this, so a dropped or + # filtered-out test cannot pass silently. Raise it when a test is added. + suite_expected_tests: 1 + suite_failed: >- + {{ gateway_status is failed or suite_result.rc != 0 or + (suite_tests_run | int) < (suite_expected_tests | int) }} + suite_tests_run: >- + {{ (suite_result.stderr | default('') | regex_findall('Summary \[[^\]]*\]\s+(\d+) tests? run') | first | default(0)) }} + tasks: + - name: Wait for SSH + ansible.builtin.wait_for_connection: + + - name: Create suite directory + become: true + ansible.builtin.file: + path: "{{ suite_root }}" + state: directory + owner: tmachine + group: tmachine + mode: "0700" + + - name: Extract suite bundle + become: true + ansible.builtin.unarchive: + src: "{{ sandbox_lifecycle_test_bundle }}" + dest: "{{ suite_root }}" + owner: tmachine + group: tmachine + + - name: Check suite nextest archive + ansible.builtin.stat: + path: "{{ suite_root }}/tests.tar.zst" + register: suite_archive + + - name: Require suite nextest archive + ansible.builtin.assert: + that: + - suite_archive.stat.isreg | default(false) + fail_msg: Sandbox lifecycle test bundle did not contain tests.tar.zst + + - name: Resolve installed OpenShell CLI + ansible.builtin.command: + argv: + - /bin/sh + - -c + - command -v openshell + register: openshell_cli + changed_when: false + + # Report a gateway that never came up as one environment failure instead of + # as every test failing. + - name: Wait for the registered gateway + ansible.builtin.command: + argv: [timeout, 10s, "{{ openshell_cli.stdout }}", status, --output, json] + register: gateway_status + changed_when: false + until: >- + gateway_status.rc == 0 and + (gateway_status.stdout | from_json).status == 'connected' + retries: 60 + delay: 5 + ignore_errors: true + + # The bundle carries no nextest config, so set the timeouts here. A hung + # test is killed after two minutes instead of holding the lane until the job + # times out. + - name: Write nextest config + ansible.builtin.copy: + dest: "{{ suite_root }}/nextest.toml" + mode: "0600" + content: | + [profile.default] + slow-timeout = { period = "30s", terminate-after = 4 } + become: true + become_user: tmachine + + - name: Run the suite + ansible.builtin.command: + argv: + - cargo-nextest + - nextest + - run + - --config-file + - "{{ suite_root }}/nextest.toml" + - --archive-file + - "{{ suite_root }}/tests.tar.zst" + - --workspace-remap + - "{{ suite_root }}" + - --no-capture + # Tests share one gateway and the guest has 4 GiB; run them serially + # whatever --no-capture implies. + - --test-threads + - "1" + - --no-fail-fast + environment: + HOME: /home/tmachine + OPENSHELL_BIN: "{{ openshell_cli.stdout }}" + register: suite_result + changed_when: false + failed_when: false + when: gateway_status is succeeded + + - name: Show suite diagnostics + ansible.builtin.debug: + var: suite_result + when: suite_failed | bool + + - name: Collect OpenShell gateway diagnostics + ansible.builtin.include_role: + name: openshell_diagnostics + when: suite_failed | bool + + - name: Require a connected gateway + ansible.builtin.assert: + that: + - gateway_status is succeeded + fail_msg: OpenShell gateway did not connect before the suite; see gateway diagnostics + + - name: Require suite success + ansible.builtin.assert: + that: + - suite_result.rc == 0 + fail_msg: Sandbox lifecycle suite failed + + - name: Require the expected number of tests + ansible.builtin.assert: + that: + - (suite_tests_run | int) >= (suite_expected_tests | int) + fail_msg: >- + Sandbox lifecycle suite ran {{ suite_tests_run }} tests; expected at + least {{ suite_expected_tests }} diff --git a/tests/suites/sandbox/Cargo.lock b/tests/suites/sandbox/Cargo.lock index 4051697fd8..849af0838a 100644 --- a/tests/suites/sandbox/Cargo.lock +++ b/tests/suites/sandbox/Cargo.lock @@ -2638,9 +2638,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.16" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -3884,9 +3884,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.10" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] diff --git a/tests/suites/sandbox/README.md b/tests/suites/sandbox/README.md index e5e02cc698..ae0531d48e 100644 --- a/tests/suites/sandbox/README.md +++ b/tests/suites/sandbox/README.md @@ -2,18 +2,22 @@ End-to-end suites for sandbox behavior, written on [testkit](../../testkit/README.md). Each member crate is one suite. Tests run -against whichever gateway the environment provides, so the same test covers +against whichever gateway the environment registered, so the same test covers every compute driver. | Suite | Covers | |---|---| | `lifecycle` | Create, wait for ready, exec, delete | -Run them against a local Docker gateway with `mise run e2e:testkit`. CI runs the -same task in the `testkit-e2e` job of Branch E2E (`test:e2e` label). +Run them against a local Docker gateway with `mise run e2e:testkit`, or against +Podman with `mise run e2e:testkit:podman`. -Add a test as a `#[testkit::test]` function in an existing suite, or add a -member crate to `Cargo.toml` for a new area. +## Adding a test + +Add a `#[testkit::test]` function to an existing suite, or a new member crate to +`Cargo.toml` for a new area. A new crate also needs its own entry in +`tests/artifacts.nix` and `tests/config.nix`, and a playbook +that checks the test count. Copy `ansible/playbooks/suites/sandbox-lifecycle.yaml`. ## What a test may assert @@ -24,6 +28,8 @@ leave driver behavior alone. published docs. - Assert `phase` and `exit_code`. Don't assert condition reasons. - Check that something did not happen by reading state, not by sleeping. +- If a behavior is left to the driver, it needs a capability. Until testkit has + capabilities, keep that test out of this suite. - Use the CLI for upload, download, connect and forward. Rely on its exit code and `--output json`. - Add a testkit helper instead of calling raw gRPC from a test. diff --git a/tests/tmachine/artifacts.nix b/tests/tmachine/artifacts.nix index 05102f1568..c6a6d0e978 100644 --- a/tests/tmachine/artifacts.nix +++ b/tests/tmachine/artifacts.nix @@ -58,6 +58,7 @@ let mkdir -p "$(dirname "$output")" cd "$root" cargo nextest archive \ + --locked \ --manifest-path "$manifest_path" \ --target ${target} \ -p ${package} \ @@ -90,6 +91,14 @@ let target = muslToolchain.target; output = "artifacts/test-archives/${muslToolchain.target}/openshell-conformance-tests.tar"; }; + sandboxLifecycleArchive = mkTestArchive { + name = "sandbox-lifecycle"; + workspacePath = "tests/suites/sandbox"; + manifestPath = "tests/suites/sandbox/Cargo.toml"; + package = "openshell-test-sandbox-lifecycle"; + target = muslToolchain.target; + output = "artifacts/test-archives/${muslToolchain.target}/sandbox-lifecycle-tests.tar"; + }; providerRefreshKeycloakArchive = mkTestArchive { name = "provider-refresh-keycloak"; workspacePath = "tests/suites/features"; @@ -317,6 +326,7 @@ rec { name = "build-artifacts-test-archives"; runtimeInputs = [ conformanceTestArchive + sandboxLifecycleArchive providerRefreshKeycloakArchive ociImageArchive podmanDriverArchive @@ -324,6 +334,7 @@ rec { ]; text = '' build-openshell-conformance-test-archive + build-sandbox-lifecycle-test-archive build-provider-refresh-keycloak-test-archive build-oci-image-test-archive build-podman-driver-test-archive diff --git a/tests/tmachine/config.nix b/tests/tmachine/config.nix index 8f048e16af..8490c27f20 100644 --- a/tests/tmachine/config.nix +++ b/tests/tmachine/config.nix @@ -162,6 +162,13 @@ let openshell_conformance_test_bundle = "../../artifacts/test-archives/${muslTarget}/openshell-conformance-tests.tar"; }; } + { + name = "sandbox-lifecycle"; + playbooks = [ "ansible/playbooks/suites/sandbox-lifecycle.yaml" ]; + inputs = { + sandbox_lifecycle_test_bundle = "../artifacts/test-archives/${muslTarget}/sandbox-lifecycle-tests.tar"; + }; + } { name = "policy-advisor"; playbooks = [ "ansible/playbooks/conformance/policy-advisor.yaml" ]; From f44d1edd3649eb4f1ebbb66aa6be2364bd2a3527 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Tue, 20 Oct 2026 19:08:50 -0700 Subject: [PATCH 03/10] ci(tests): key the tmachine disk cache on the playbooks and keep JUnit results tmachine keys its disk layers on a hash of the Ansible tree, and actions/cache does not save after an exact key hit, so the fixed cache key rebuilt the changed layers on every run. Key the cache on the playbooks and fall back to the newest entry for the same runner. The sandbox-lifecycle playbook now has nextest write a JUnit report and fetches it into artifacts/tmachine-diagnostics, which the integration runner already uploads whether or not the lane passes. Signed-off-by: Matthew Grossman --- .github/workflows/integration-runner.yml | 8 ++++++- .../playbooks/suites/sandbox-lifecycle.yaml | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/.github/workflows/integration-runner.yml b/.github/workflows/integration-runner.yml index 98d8feb301..52670a4fbb 100644 --- a/.github/workflows/integration-runner.yml +++ b/.github/workflows/integration-runner.yml @@ -73,7 +73,13 @@ jobs: uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: ~/.cache/tmachine - key: tmachine-${{ runner.os }}-${{ runner.arch }} + # tmachine keys its disk layers on a hash of the Ansible tree. + # actions/cache doesn't save after an exact key hit, so a fixed key + # would rebuild the changed layers on every run. Key on the playbooks + # and fall back to the newest entry for the same runner. + key: tmachine-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('tests/ansible/**') }} + restore-keys: | + tmachine-${{ runner.os }}-${{ runner.arch }}- - name: Download integration inputs uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/tests/ansible/playbooks/suites/sandbox-lifecycle.yaml b/tests/ansible/playbooks/suites/sandbox-lifecycle.yaml index 4686d5cca5..3a3e2c0fa0 100644 --- a/tests/ansible/playbooks/suites/sandbox-lifecycle.yaml +++ b/tests/ansible/playbooks/suites/sandbox-lifecycle.yaml @@ -83,6 +83,9 @@ content: | [profile.default] slow-timeout = { period = "30s", terminate-after = 4 } + + [profile.default.junit] + path = "junit.xml" become: true become_user: tmachine @@ -112,6 +115,24 @@ failed_when: false when: gateway_status is succeeded + # Keep the JUnit report with the run. The integration runner uploads + # artifacts/tmachine-diagnostics whether or not the lane passes. + - name: Find the JUnit report + ansible.builtin.find: + paths: "{{ suite_root }}" + patterns: junit.xml + recurse: true + register: suite_junit + failed_when: false + + - name: Fetch the JUnit report + ansible.builtin.fetch: + src: "{{ item.path }}" + dest: "{{ playbook_dir }}/../../../../artifacts/tmachine-diagnostics/junit/sandbox-lifecycle-{{ inventory_hostname }}.xml" + flat: true + loop: "{{ suite_junit.files | default([]) }}" + failed_when: false + - name: Show suite diagnostics ansible.builtin.debug: var: suite_result From 7855b5d2e0e8e94bc806c716e2b3551b612af375 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Wed, 21 Oct 2026 13:28:18 -0700 Subject: [PATCH 04/10] fix(tests): use the tmachine paths for the first testkit lane The tmachine move landed on main, so the sandbox-lifecycle playbook belongs under tests/tmachine/ansible. Move it there, point the test bundle input and the JUnit fetch at the new locations, and key the disk cache on the new playbook path. Signed-off-by: Matthew Grossman --- .github/workflows/integration-runner.yml | 2 +- TESTING.md | 2 +- .../ansible/playbooks/suites/sandbox-lifecycle.yaml | 2 +- tests/tmachine/config.nix | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename tests/{ => tmachine}/ansible/playbooks/suites/sandbox-lifecycle.yaml (97%) diff --git a/.github/workflows/integration-runner.yml b/.github/workflows/integration-runner.yml index 52670a4fbb..239f66d595 100644 --- a/.github/workflows/integration-runner.yml +++ b/.github/workflows/integration-runner.yml @@ -77,7 +77,7 @@ jobs: # actions/cache doesn't save after an exact key hit, so a fixed key # would rebuild the changed layers on every run. Key on the playbooks # and fall back to the newest entry for the same runner. - key: tmachine-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('tests/ansible/**') }} + key: tmachine-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('tests/tmachine/ansible/**') }} restore-keys: | tmachine-${{ runner.os }}-${{ runner.arch }}- diff --git a/TESTING.md b/TESTING.md index f90a096887..c0d9c0d269 100644 --- a/TESTING.md +++ b/TESTING.md @@ -156,7 +156,7 @@ CI runs them in two Branch E2E jobs (label `test:e2e`). `testkit-e2e` runs runs them through a tmachine lane that installs the Debian package. The suite's playbook fails the lane if fewer tests run than it expects, so raise `suite_expected_tests` in -`tests/ansible/playbooks/suites/sandbox-lifecycle.yaml` when you add a +`tests/tmachine/ansible/playbooks/suites/sandbox-lifecycle.yaml` when you add a test. Workspaces that build root crates through path dependencies must lock shared diff --git a/tests/ansible/playbooks/suites/sandbox-lifecycle.yaml b/tests/tmachine/ansible/playbooks/suites/sandbox-lifecycle.yaml similarity index 97% rename from tests/ansible/playbooks/suites/sandbox-lifecycle.yaml rename to tests/tmachine/ansible/playbooks/suites/sandbox-lifecycle.yaml index 3a3e2c0fa0..dd76d69170 100644 --- a/tests/ansible/playbooks/suites/sandbox-lifecycle.yaml +++ b/tests/tmachine/ansible/playbooks/suites/sandbox-lifecycle.yaml @@ -128,7 +128,7 @@ - name: Fetch the JUnit report ansible.builtin.fetch: src: "{{ item.path }}" - dest: "{{ playbook_dir }}/../../../../artifacts/tmachine-diagnostics/junit/sandbox-lifecycle-{{ inventory_hostname }}.xml" + dest: "{{ playbook_dir }}/../../../../../artifacts/tmachine-diagnostics/junit/sandbox-lifecycle-{{ inventory_hostname }}.xml" flat: true loop: "{{ suite_junit.files | default([]) }}" failed_when: false diff --git a/tests/tmachine/config.nix b/tests/tmachine/config.nix index 8490c27f20..c8b1bf9708 100644 --- a/tests/tmachine/config.nix +++ b/tests/tmachine/config.nix @@ -166,7 +166,7 @@ let name = "sandbox-lifecycle"; playbooks = [ "ansible/playbooks/suites/sandbox-lifecycle.yaml" ]; inputs = { - sandbox_lifecycle_test_bundle = "../artifacts/test-archives/${muslTarget}/sandbox-lifecycle-tests.tar"; + sandbox_lifecycle_test_bundle = "../../artifacts/test-archives/${muslTarget}/sandbox-lifecycle-tests.tar"; }; } { From 63f414102a04be33bdade2c2716807816b388ff7 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Wed, 21 Oct 2026 13:42:58 -0700 Subject: [PATCH 05/10] build(cargo): realign the testkit and sandbox lockfiles with the root lock Main bumped shared crates in the root Cargo.lock after this branch was last aligned. Re-lock tests/testkit and tests/suites/sandbox from it with check_cargo_lockfile_alignment.py --fix. Signed-off-by: Matthew Grossman --- tests/suites/sandbox/Cargo.lock | 8 ++++---- tests/testkit/Cargo.lock | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/suites/sandbox/Cargo.lock b/tests/suites/sandbox/Cargo.lock index 849af0838a..4051697fd8 100644 --- a/tests/suites/sandbox/Cargo.lock +++ b/tests/suites/sandbox/Cargo.lock @@ -2638,9 +2638,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -3884,9 +3884,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] diff --git a/tests/testkit/Cargo.lock b/tests/testkit/Cargo.lock index b0fa677e1b..1206cc7d68 100644 --- a/tests/testkit/Cargo.lock +++ b/tests/testkit/Cargo.lock @@ -2631,9 +2631,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -3877,9 +3877,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] From dec35b84eb14aa027970b231a693bef9f308b131 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Wed, 21 Oct 2026 13:28:58 -0700 Subject: [PATCH 06/10] test(suites): move the conformance, oci-image, provider-refresh and podman suites to testkit Cut the release lanes over to suites written on testkit and delete the old conformance, features and drivers suites. See the PR description for the old test to new test mapping. The sandbox-lifecycle, file-transfer and policy-advisor ports start from #4629 by Evan Lezar. Co-authored-by: Evan Lezar Signed-off-by: Matthew Grossman --- .github/workflows/branch-e2e.yml | 30 +- .github/workflows/integration-runner.yml | 10 +- .github/workflows/integration-test.yml | 10 +- .github/workflows/release-dev.yml | 10 +- .github/workflows/release-qualification.yml | 10 +- AGENTS.md | 1 - Cargo.lock | 11 - TESTING.md | 43 +- crates/openshell-conformance/Cargo.toml | 21 - crates/openshell-conformance/src/executor.rs | 64 - crates/openshell-conformance/src/lib.rs | 1068 ---- .../src/scenarios/file_transfer.rs | 764 --- .../src/scenarios/mod.rs | 19 - .../src/scenarios/policy_behavior.rs | 579 --- .../src/scenarios/sandbox_lifecycle.rs | 347 -- .../src/scenarios/smoke.rs | 205 - e2e/rust/tests/podman_oci_identity.rs | 2 +- e2e/support/conformance.sh | 19 +- .../scripts/check_cargo_lockfile_alignment.py | 7 +- tasks/test.toml | 8 +- tests/suites/conformance/Cargo.lock | 437 -- tests/suites/conformance/Cargo.toml | 6 - tests/suites/conformance/cli/Cargo.toml | 14 - .../conformance/cli/tests/file_transfer.rs | 40 - .../suites/conformance/cli/tests/lifecycle.rs | 22 - .../conformance/cli/tests/policy_advisor.rs | 37 - tests/suites/conformance/cli/tests/smoke.rs | 24 - tests/suites/drivers/Cargo.lock | 438 -- tests/suites/drivers/Cargo.toml | 6 - tests/suites/drivers/podman/Cargo.toml | 15 - .../drivers/podman/tests/default_userns.rs | 140 - .../drivers/podman/tests/support/mod.rs | 64 - tests/suites/features/Cargo.toml | 6 - tests/suites/features/oci-image/Cargo.toml | 13 - .../provider-refresh/keycloak/Cargo.toml | 14 - .../keycloak/tests/provider_refresh.rs | 340 -- tests/suites/{features => podman}/Cargo.lock | 1636 +++++-- tests/suites/podman/Cargo.toml | 9 + tests/suites/podman/README.md | 20 + .../podman/fixtures/userns-auto.toml | 0 .../podman/fixtures/userns-keep-id.toml | 0 .../podman/fixtures/userns-private.toml | 0 tests/suites/podman/userns/Cargo.toml | 16 + .../podman/userns/tests/default_userns.rs | 132 + .../suites/podman/userns/tests/support/mod.rs | 54 + tests/suites/providers/Cargo.lock | 4279 +++++++++++++++++ tests/suites/providers/Cargo.toml | 8 + tests/suites/providers/README.md | 17 + .../providers/refresh-keycloak/Cargo.toml | 18 + .../tests/refresh_keycloak.rs | 297 ++ tests/suites/sandbox/Cargo.lock | 27 + tests/suites/sandbox/Cargo.toml | 2 +- tests/suites/sandbox/README.md | 21 +- tests/suites/sandbox/file-transfer/Cargo.toml | 16 + .../file-transfer/tests/file_transfer.rs | 420 ++ tests/suites/sandbox/lifecycle/Cargo.toml | 1 + tests/suites/sandbox/lifecycle/tests/cli.rs | 170 + .../sandbox/lifecycle/tests/lifecycle.rs | 130 +- .../lifecycle/tests/persistent_workspace.rs | 64 + tests/suites/sandbox/oci-image/Cargo.toml | 17 + .../oci-image/tests/oci_image.rs | 562 +-- .../suites/sandbox/policy-advisor/Cargo.toml | 17 + .../policy-advisor/tests/policy_advisor.rs | 423 ++ tests/testkit/README.md | 8 +- tests/testkit/testkit/src/cli.rs | 16 + tests/testkit/testkit/src/context.rs | 52 + .../playbooks/conformance/policy-advisor.yaml | 7 - .../playbooks/drivers/podman/tests.yaml | 8 + .../ansible/playbooks/features/oci-image.yaml | 7 + .../features/provider-refresh/keycloak.yaml | 7 + .../{conformance => suites}/k3s-ready.yaml | 0 .../cli.yaml => suites/nextest.yaml} | 93 +- .../playbooks/suites/sandbox-lifecycle.yaml | 164 - tests/tmachine/artifacts.nix | 90 +- tests/tmachine/config.nix | 39 +- 75 files changed, 7897 insertions(+), 5794 deletions(-) delete mode 100644 crates/openshell-conformance/Cargo.toml delete mode 100644 crates/openshell-conformance/src/executor.rs delete mode 100644 crates/openshell-conformance/src/lib.rs delete mode 100644 crates/openshell-conformance/src/scenarios/file_transfer.rs delete mode 100644 crates/openshell-conformance/src/scenarios/mod.rs delete mode 100644 crates/openshell-conformance/src/scenarios/policy_behavior.rs delete mode 100644 crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs delete mode 100644 crates/openshell-conformance/src/scenarios/smoke.rs delete mode 100644 tests/suites/conformance/Cargo.lock delete mode 100644 tests/suites/conformance/Cargo.toml delete mode 100644 tests/suites/conformance/cli/Cargo.toml delete mode 100644 tests/suites/conformance/cli/tests/file_transfer.rs delete mode 100644 tests/suites/conformance/cli/tests/lifecycle.rs delete mode 100644 tests/suites/conformance/cli/tests/policy_advisor.rs delete mode 100644 tests/suites/conformance/cli/tests/smoke.rs delete mode 100644 tests/suites/drivers/Cargo.lock delete mode 100644 tests/suites/drivers/Cargo.toml delete mode 100644 tests/suites/drivers/podman/Cargo.toml delete mode 100644 tests/suites/drivers/podman/tests/default_userns.rs delete mode 100644 tests/suites/drivers/podman/tests/support/mod.rs delete mode 100644 tests/suites/features/Cargo.toml delete mode 100644 tests/suites/features/oci-image/Cargo.toml delete mode 100644 tests/suites/features/provider-refresh/keycloak/Cargo.toml delete mode 100644 tests/suites/features/provider-refresh/keycloak/tests/provider_refresh.rs rename tests/suites/{features => podman}/Cargo.lock (68%) create mode 100644 tests/suites/podman/Cargo.toml create mode 100644 tests/suites/podman/README.md rename tests/suites/{drivers => }/podman/fixtures/userns-auto.toml (100%) rename tests/suites/{drivers => }/podman/fixtures/userns-keep-id.toml (100%) rename tests/suites/{drivers => }/podman/fixtures/userns-private.toml (100%) create mode 100644 tests/suites/podman/userns/Cargo.toml create mode 100644 tests/suites/podman/userns/tests/default_userns.rs create mode 100644 tests/suites/podman/userns/tests/support/mod.rs create mode 100644 tests/suites/providers/Cargo.lock create mode 100644 tests/suites/providers/Cargo.toml create mode 100644 tests/suites/providers/README.md create mode 100644 tests/suites/providers/refresh-keycloak/Cargo.toml create mode 100644 tests/suites/providers/refresh-keycloak/tests/refresh_keycloak.rs create mode 100644 tests/suites/sandbox/file-transfer/Cargo.toml create mode 100644 tests/suites/sandbox/file-transfer/tests/file_transfer.rs create mode 100644 tests/suites/sandbox/lifecycle/tests/cli.rs create mode 100644 tests/suites/sandbox/lifecycle/tests/persistent_workspace.rs create mode 100644 tests/suites/sandbox/oci-image/Cargo.toml rename tests/suites/{features => sandbox}/oci-image/tests/oci_image.rs (52%) create mode 100644 tests/suites/sandbox/policy-advisor/Cargo.toml create mode 100644 tests/suites/sandbox/policy-advisor/tests/policy_advisor.rs delete mode 100644 tests/tmachine/ansible/playbooks/conformance/policy-advisor.yaml rename tests/tmachine/ansible/playbooks/{conformance => suites}/k3s-ready.yaml (100%) rename tests/tmachine/ansible/playbooks/{conformance/cli.yaml => suites/nextest.yaml} (54%) delete mode 100644 tests/tmachine/ansible/playbooks/suites/sandbox-lifecycle.yaml diff --git a/.github/workflows/branch-e2e.yml b/.github/workflows/branch-e2e.yml index 7d0c7f7ec9..44273f3ef0 100644 --- a/.github/workflows/branch-e2e.yml +++ b/.github/workflows/branch-e2e.yml @@ -240,7 +240,7 @@ jobs: deb-artifact-name: deb-linux-amd64 rpm-artifact-name: rpm-linux-x86_64 - # Run driver-independent conformance tests. + # Run the driver-independent sandbox suites, written on testkit (tests/testkit). conformance-integration: needs: prepare-integration permissions: @@ -254,29 +254,11 @@ jobs: integration-inputs-artifact-id: ${{ needs.prepare-integration.outputs.integration_inputs_artifact_id }} test-matrix: >- [ - {"environment":"ubuntu-docker-rootful","installer":"deb","testsuite":"conformance"}, - {"environment":"ubuntu-k3s","installer":"k3s","testsuite":"conformance"}, - {"environment":"ubuntu-k3s","installer":"k3s-ha","testsuite":"conformance"}, - {"environment":"fedora-podman-rootful","installer":"rpm","testsuite":"conformance"}, - {"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"conformance"} - ] - - # Run suites written on testkit (tests/testkit). New end-to-end tests go here; - # the older suites above take fixes only. - testkit-integration: - needs: prepare-integration - permissions: - actions: read - contents: read - packages: read - uses: ./.github/workflows/integration-runner.yml - with: - category: testkit - source-sha: ${{ needs.prepare-integration.outputs.source_sha }} - integration-inputs-artifact-id: ${{ needs.prepare-integration.outputs.integration_inputs_artifact_id }} - test-matrix: >- - [ - {"environment":"ubuntu-docker-rootful","installer":"deb","testsuite":"sandbox-lifecycle"} + {"environment":"ubuntu-docker-rootful","installer":"deb","testsuite":"sandbox"}, + {"environment":"ubuntu-k3s","installer":"k3s","testsuite":"sandbox"}, + {"environment":"ubuntu-k3s","installer":"k3s-ha","testsuite":"sandbox"}, + {"environment":"fedora-podman-rootful","installer":"rpm","testsuite":"sandbox"}, + {"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"sandbox"} ] # Run feature-specific integration tests: diff --git a/.github/workflows/integration-runner.yml b/.github/workflows/integration-runner.yml index 239f66d595..95f32f12a6 100644 --- a/.github/workflows/integration-runner.yml +++ b/.github/workflows/integration-runner.yml @@ -24,11 +24,11 @@ on: type: string default: >- [ - {"environment":"ubuntu-docker-rootful","installer":"deb","testsuite":"conformance"}, - {"environment":"ubuntu-k3s","installer":"k3s","testsuite":"conformance"}, - {"environment":"ubuntu-k3s","installer":"k3s-ha","testsuite":"conformance"}, - {"environment":"fedora-podman-rootful","installer":"rpm","testsuite":"conformance"}, - {"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"conformance"} + {"environment":"ubuntu-docker-rootful","installer":"deb","testsuite":"sandbox"}, + {"environment":"ubuntu-k3s","installer":"k3s","testsuite":"sandbox"}, + {"environment":"ubuntu-k3s","installer":"k3s-ha","testsuite":"sandbox"}, + {"environment":"fedora-podman-rootful","installer":"rpm","testsuite":"sandbox"}, + {"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"sandbox"} ] permissions: diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 9c46b27410..78125a351e 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -27,11 +27,11 @@ on: type: string default: >- [ - {"environment":"ubuntu-docker-rootful","installer":"deb","testsuite":"conformance"}, - {"environment":"ubuntu-k3s","installer":"k3s","testsuite":"conformance"}, - {"environment":"ubuntu-k3s","installer":"k3s-ha","testsuite":"conformance"}, - {"environment":"fedora-podman-rootful","installer":"rpm","testsuite":"conformance"}, - {"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"conformance"} + {"environment":"ubuntu-docker-rootful","installer":"deb","testsuite":"sandbox"}, + {"environment":"ubuntu-k3s","installer":"k3s","testsuite":"sandbox"}, + {"environment":"ubuntu-k3s","installer":"k3s-ha","testsuite":"sandbox"}, + {"environment":"fedora-podman-rootful","installer":"rpm","testsuite":"sandbox"}, + {"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"sandbox"} ] permissions: diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index aec77972ee..5587008891 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -125,11 +125,11 @@ jobs: integration-inputs-artifact-id: ${{ needs.prepare-integration.outputs.integration_inputs_artifact_id }} test-matrix: >- [ - {"environment":"ubuntu-docker-rootful","installer":"deb","testsuite":"conformance"}, - {"environment":"ubuntu-k3s","installer":"k3s","testsuite":"conformance"}, - {"environment":"ubuntu-k3s","installer":"k3s-ha","testsuite":"conformance"}, - {"environment":"fedora-podman-rootful","installer":"rpm","testsuite":"conformance"}, - {"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"conformance"} + {"environment":"ubuntu-docker-rootful","installer":"deb","testsuite":"sandbox"}, + {"environment":"ubuntu-k3s","installer":"k3s","testsuite":"sandbox"}, + {"environment":"ubuntu-k3s","installer":"k3s-ha","testsuite":"sandbox"}, + {"environment":"fedora-podman-rootful","installer":"rpm","testsuite":"sandbox"}, + {"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"sandbox"} ] feature-specific-integration: diff --git a/.github/workflows/release-qualification.yml b/.github/workflows/release-qualification.yml index 3dc0ca3c9e..8a2f4a9a04 100644 --- a/.github/workflows/release-qualification.yml +++ b/.github/workflows/release-qualification.yml @@ -78,11 +78,11 @@ jobs: integration-inputs-artifact-id: ${{ needs.prepare-integration.outputs.integration_inputs_artifact_id }} test-matrix: >- [ - {"environment":"ubuntu-docker-rootful","installer":"deb","testsuite":"conformance"}, - {"environment":"ubuntu-k3s","installer":"k3s","testsuite":"conformance"}, - {"environment":"ubuntu-k3s","installer":"k3s-ha","testsuite":"conformance"}, - {"environment":"fedora-podman-rootful","installer":"rpm","testsuite":"conformance"}, - {"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"conformance"} + {"environment":"ubuntu-docker-rootful","installer":"deb","testsuite":"sandbox"}, + {"environment":"ubuntu-k3s","installer":"k3s","testsuite":"sandbox"}, + {"environment":"ubuntu-k3s","installer":"k3s-ha","testsuite":"sandbox"}, + {"environment":"fedora-podman-rootful","installer":"rpm","testsuite":"sandbox"}, + {"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"sandbox"} ] feature-specific-integration: diff --git a/AGENTS.md b/AGENTS.md index a4c000c01b..89e635e890 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,6 @@ Do not rely on this file for a full inventory. The detailed public and contribut | Path | Components | Purpose | |------|-----------|---------| | `crates/openshell-cli/` | CLI binary | User-facing command-line interface | -| `crates/openshell-conformance/` | CLI conformance library | Reusable driver-agnostic scenarios and command runner | | `crates/openshell-server/` | Gateway server | Control-plane API, sandbox lifecycle, auth boundary | | `crates/openshell-sandbox/` | Sandbox runtime | Capability-free workload launcher, process identity, and seccomp-mediated I/O | | `crates/openshell-supervisor/` | Supervisor runtime | Gateway session, policy evaluation, credentials, and upstream networking | diff --git a/Cargo.lock b/Cargo.lock index 0a2fcb8c92..d3e5efe47c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4356,17 +4356,6 @@ dependencies = [ "url", ] -[[package]] -name = "openshell-conformance" -version = "0.0.0" -dependencies = [ - "rand 0.9.4", - "serde", - "serde_json", - "tempfile", - "tokio", -] - [[package]] name = "openshell-core" version = "0.0.0" diff --git a/TESTING.md b/TESTING.md index c0d9c0d269..dc4a522af1 100644 --- a/TESTING.md +++ b/TESTING.md @@ -141,23 +141,22 @@ by `OPENSHELL_BIN`. See [tests/testkit/README.md](tests/testkit/README.md) for how to write and run tests. -**New end-to-end tests go in testkit.** The older suites under -`tests/suites/conformance`, `features` and `drivers` and the `e2e/` suites take -fixes only. Suites live under `tests/suites//`, for example -`tests/suites/sandbox/`, and each family is its own Cargo workspace with its own -lockfile. Run them against a local Docker gateway with: +**New end-to-end tests go in testkit.** The `e2e/` suites take fixes only. +Suites live under `tests/suites//` (`sandbox`, `providers`, `podman`), +and each family is its own Cargo workspace with its own lockfile. Run the +sandbox suites against a local Docker gateway with: ```shell mise run e2e:testkit ``` -CI runs them in two Branch E2E jobs (label `test:e2e`). `testkit-e2e` runs -`mise run e2e:testkit` against a Docker-backed gateway. `testkit-integration` -runs them through a tmachine lane that installs the Debian package. The suite's playbook -fails the lane if fewer tests run than it expects, so raise -`suite_expected_tests` in -`tests/tmachine/ansible/playbooks/suites/sandbox-lifecycle.yaml` when you add a -test. +CI runs the sandbox suites as the `sandbox` testsuite in the conformance lanes +of Branch E2E (label `test:e2e`), Release Dev and release qualification, through +tmachine guests that install the packages. The playbook fails a lane if fewer +tests run than the testsuite's `suite_expected_tests` in +`tests/tmachine/config.nix`, so raise that number when you add a test. +The `testkit-e2e` job in Branch E2E also runs `mise run e2e:testkit` against a +Docker-backed gateway without a VM. Workspaces that build root crates through path dependencies must lock shared crates at the versions in the root `Cargo.lock`; otherwise CI can build against @@ -272,12 +271,12 @@ HTTP `Host`, TLS SNI, and mTLS handling. Suites: - Common suite (`--features e2e`) - driver-neutral CLI behavior, sandbox lifecycle, sync, port forwarding, policy, and provider tests. -- CLI conformance (`tests/suites/conformance`) - portable Cargo tests for - lifecycle, mechanistic drafts, file transfer, and the sandbox-local API, - including agent-authored permission requests. Driver E2E runs the complete - Cargo test package. The installed-artifact conformance suite runs the same - tests from a nextest archive and offers a focused `policy-advisor` testsuite - for manual integration runs. +- Sandbox suites (`tests/suites/sandbox`) - driver-neutral testkit tests for + lifecycle, CLI create/exec/list/stop/delete, file transfer, and policy + advisor drafts and the sandbox-local API. Driver E2E runs the lifecycle, + file-transfer and policy-advisor packages. The installed-artifact `sandbox` + testsuite runs the same tests from a nextest archive and offers a focused + `policy-advisor` testsuite for manual integration runs. - Driver suites (`--features e2e-docker`, `e2e-podman`, `e2e-kubernetes`, or `e2e-vm`) - CLI conformance plus the common and driver-specific coverage for the selected deployment. @@ -323,8 +322,8 @@ cargo build --package openshell-cli OPENSHELL_BIN="$PWD/target/debug/openshell" \ cargo test \ --locked \ - --manifest-path tests/suites/conformance/Cargo.toml \ - --package openshell-test-conformance-cli \ + --manifest-path tests/suites/sandbox/Cargo.toml \ + --workspace \ --no-fail-fast \ -- \ --test-threads=1 \ @@ -407,12 +406,12 @@ 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, SPIFFE fixtures, the caller driver-config setting, or other -guest setup. The `driver-podman` suite replaces the removed `podman_userns` E2E +guest setup. The `driver-podman` suite (`tests/suites/podman`) replaces the removed `podman_userns` E2E binary. `tests/tmachine/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. -The `oci-image` feature testsuite (`tests/suites/features/oci-image`) checks +The `oci-image` testsuite (`tests/suites/sandbox/oci-image`) checks OCI image identity and working-directory behavior shared by the Docker and Podman drivers against installed deb packages on Ubuntu and rpm packages on Fedora. CI runs it on Docker rootful, Podman rootful, and Podman rootless guests: diff --git a/crates/openshell-conformance/Cargo.toml b/crates/openshell-conformance/Cargo.toml deleted file mode 100644 index fbe777a7aa..0000000000 --- a/crates/openshell-conformance/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -[package] -name = "openshell-conformance" -description = "Reusable OpenShell CLI conformance scenarios and runner" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true - -[dependencies] -rand.workspace = true -serde.workspace = true -serde_json.workspace = true -tempfile = "3" -tokio.workspace = true - -[lints] -workspace = true diff --git a/crates/openshell-conformance/src/executor.rs b/crates/openshell-conformance/src/executor.rs deleted file mode 100644 index 5c5e5844c9..0000000000 --- a/crates/openshell-conformance/src/executor.rs +++ /dev/null @@ -1,64 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Process execution boundary for the CLI conformance runner. - -use std::future::Future; -use std::path::PathBuf; -use std::pin::Pin; -use std::process::{Output, Stdio}; -use std::time::Duration; - -use tokio::time::timeout; - -pub type CliExecution<'a> = - Pin> + Send + 'a>>; - -pub trait CliExecutor: Send + Sync { - fn execute( - &self, - args: Vec, - environment: Vec<(String, String)>, - command_timeout: Duration, - ) -> CliExecution<'_>; -} - -pub enum CliExecutionError { - Spawn(std::io::Error), - Timeout, -} - -pub struct ProcessCli { - binary: PathBuf, -} - -impl ProcessCli { - pub fn new(binary: PathBuf) -> Self { - Self { binary } - } -} - -impl CliExecutor for ProcessCli { - fn execute( - &self, - args: Vec, - environment: Vec<(String, String)>, - command_timeout: Duration, - ) -> CliExecution<'_> { - Box::pin(async move { - let mut process = tokio::process::Command::new(&self.binary); - process - .args(&args) - .envs(environment) - .env("NO_COLOR", "1") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true); - - timeout(command_timeout, process.output()) - .await - .map_err(|_| CliExecutionError::Timeout)? - .map_err(CliExecutionError::Spawn) - }) - } -} diff --git a/crates/openshell-conformance/src/lib.rs b/crates/openshell-conformance/src/lib.rs deleted file mode 100644 index 4d1fcc1308..0000000000 --- a/crates/openshell-conformance/src/lib.rs +++ /dev/null @@ -1,1068 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Reusable support for portable `OpenShell` CLI conformance scenarios. - -pub mod executor; -mod scenarios; - -use std::collections::BTreeSet; -use std::error::Error; -use std::fmt; -use std::future::Future; -use std::path::PathBuf; -use std::pin::Pin; -use std::process::ExitStatus; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use rand::Rng; -use serde::Deserialize; -use serde::de::DeserializeOwned; -use tokio::time::sleep; - -use self::executor::{CliExecutionError, CliExecutor, ProcessCli}; - -pub use scenarios::{ - FILE_TRANSFER_GIT_FILTERING_SCENARIO, FILE_TRANSFER_PATH_SAFETY_SCENARIO, - FILE_TRANSFER_ROUND_TRIP_SCENARIO, MECHANISTIC_PROPOSAL_SCENARIO, - NEW_HOSTNAME_PROPOSAL_SCENARIO, POLICY_LOCAL_SCENARIO, SANDBOX_LIFECYCLE_SCENARIO, - SMOKE_SCENARIO, -}; - -/// A portable conformance scenario exercised by the Cargo test suite. -#[derive(Debug)] -pub struct Scenario { - pub name: &'static str, - run: for<'a> fn(&'a mut OpenShellRunner) -> ScenarioFuture<'a>, -} - -pub type ScenarioFuture<'a> = Pin> + Send + 'a>>; - -impl Scenario { - pub async fn run(&self, runner: &mut OpenShellRunner) -> Result<(), String> { - (self.run)(runner).await - } -} - -const CLEANUP_TIMEOUT: Duration = Duration::from_mins(2); -pub const STATUS_TIMEOUT: Duration = Duration::from_secs(30); -const GATEWAY_STATUS_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(10); -const GATEWAY_STATUS_INTERVAL: Duration = Duration::from_secs(2); - -/// Generate the suite-owned identifier used in resource names and diagnostics. -fn generate_run_id() -> String { - const ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz"; - let mut rng = rand::rng(); - (0..10) - .map(|_| ALPHABET[rng.random_range(0..ALPHABET.len())] as char) - .collect() -} - -/// The completed outcome of one `OpenShell` CLI process. -#[derive(Debug)] -pub struct CommandResult { - run_id: String, - scenario: String, - step: String, - expectation: String, - command: String, - status: ExitStatus, - elapsed: Duration, - stdout: String, - stderr: String, -} - -impl CommandResult { - pub fn success(&self) -> bool { - self.status.success() - } - - pub fn exit_code(&self) -> Option { - self.status.code() - } - - pub fn stdout(&self) -> &str { - &self.stdout - } - - pub fn stderr(&self) -> &str { - &self.stderr - } - - pub fn elapsed(&self) -> Duration { - self.elapsed - } - - pub fn json(&self) -> Result { - serde_json::from_str(&self.stdout).map_err(|source| RunnerError::InvalidJson { - context: self.context(), - source, - stdout: self.stdout.clone(), - stderr: self.stderr.clone(), - }) - } - - pub fn require_success(&self) -> Result<(), String> { - if self.success() { - return Ok(()); - } - Err(self.failure_diagnostic(&self.expectation)) - } - - pub fn failure_diagnostic(&self, expectation: &str) -> String { - format!( - "{}\nexpected: {expectation}\nactual: exit {} after {:.1?}\ncommand: {}\nstdout:\n{}\nstderr:\n{}", - self.context(), - exit_description(self.status), - self.elapsed, - self.command, - self.stdout, - self.stderr, - ) - } - - fn context(&self) -> String { - format!("[run {}][{}/{}]", self.run_id, self.scenario, self.step) - } -} - -/// Failures in reusable runner mechanics rather than scenario assertions. -#[derive(Debug)] -pub enum RunnerError { - BinaryUnavailable(String), - Spawn { - context: String, - command: String, - source: std::io::Error, - }, - Timeout { - context: String, - command: String, - timeout: Duration, - }, - InvalidJson { - context: String, - source: serde_json::Error, - stdout: String, - stderr: String, - }, - PollTimeout { - context: String, - timeout: Duration, - last_observation: String, - }, - ObservationFailed { - context: String, - diagnostic: String, - }, -} - -impl fmt::Display for RunnerError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::BinaryUnavailable(message) => write!(f, "{message}"), - Self::Spawn { - context, - command, - source, - } => write!( - f, - "{context} failed to spawn command: {source}\ncommand: {command}" - ), - Self::Timeout { - context, - command, - timeout, - } => write!( - f, - "{context} timed out after {timeout:.1?}\ncommand: {command}" - ), - Self::InvalidJson { - context, - source, - stdout, - stderr, - } => write!( - f, - "{context} returned invalid JSON: {source}\nstdout:\n{stdout}\nstderr:\n{stderr}" - ), - Self::PollTimeout { - context, - timeout, - last_observation, - } => write!( - f, - "{context} did not satisfy the observation within {timeout:.1?}\nlast observation:\n{last_observation}" - ), - Self::ObservationFailed { - context, - diagnostic, - } => write!(f, "{context} observation failed early:\n{diagnostic}"), - } - } -} - -impl Error for RunnerError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::Spawn { source, .. } => Some(source), - Self::InvalidJson { source, .. } => Some(source), - _ => None, - } - } -} - -/// Result of one read-only polling observation. -pub enum Poll { - Ready(T), - Pending(String), - Failed(String), -} - -#[derive(Debug, Deserialize)] -struct StatusOutput { - gateway: Option, - server: Option, - status: String, - version: Option, - authentication: Option, -} - -#[derive(Debug, Deserialize)] -struct AuthenticationOutput { - status: String, -} - -/// Runs `OpenShell` commands for one conformance scenario and owns its cleanup. -pub struct OpenShellRunner { - cli: Arc, - run_id: String, - scenario: String, - known_sandboxes: BTreeSet, - known_providers: BTreeSet, - known_provider_profiles: BTreeSet, - finished: bool, -} - -/// A runner command with diagnostic context but no timeout yet. -pub struct CommandStep<'a> { - runner: &'a OpenShellRunner, - step: String, - description: Option, -} - -/// A fully configured runner command ready to execute. -pub struct OpenShellCommand<'a> { - runner: &'a OpenShellRunner, - step: String, - description: String, - timeout: Duration, -} - -impl OpenShellRunner { - /// Uses an explicit `openshell` binary rather than resolving it on `PATH`. - pub fn with_binary(binary: PathBuf, scenario: &str) -> Result { - if !binary.is_file() { - return Err(RunnerError::BinaryUnavailable(format!( - "OpenShell CLI binary not found at {}", - binary.display() - ))); - } - Ok(Self::with_executor( - Arc::new(ProcessCli::new(binary)), - scenario, - )) - } - - /// Uses the candidate CLI selected by the archive test runner. - /// - /// Archive-based tests set `OPENSHELL_BIN` to the candidate artifact - /// installed in the guest. Requiring it here prevents a test from silently - /// resolving a different `openshell` binary from `PATH`. - pub fn from_env(scenario: &str) -> Result { - let binary = std::env::var_os("OPENSHELL_BIN") - .map(PathBuf::from) - .ok_or_else(|| { - RunnerError::BinaryUnavailable( - "OPENSHELL_BIN must name the candidate openshell CLI".to_string(), - ) - })?; - Self::with_binary(binary, scenario) - } - - /// Creates a runner with an injected executor. This is useful for harness tests. - pub fn with_executor(cli: Arc, scenario: &str) -> Self { - Self { - cli, - run_id: generate_run_id(), - scenario: scenario.to_string(), - known_sandboxes: BTreeSet::new(), - known_providers: BTreeSet::new(), - known_provider_profiles: BTreeSet::new(), - finished: false, - } - } - - pub fn id(&self) -> &str { - &self.run_id - } - - pub fn step(&self, step: impl Into) -> CommandStep<'_> { - CommandStep { - runner: self, - step: step.into(), - description: None, - } - } - - /// Confirm that the configured gateway is reachable before a scenario runs. - pub async fn check_gateway_status(&mut self) -> Result<(), String> { - let status = self - .poll_until( - "preflight", - STATUS_TIMEOUT, - GATEWAY_STATUS_INTERVAL, - async |runner| match runner - .step("status/json") - .description("machine-readable gateway status succeeds") - .with_timeout(GATEWAY_STATUS_ATTEMPT_TIMEOUT) - .run(&["status", "--output", "json"]) - .await - { - Ok(result) if !result.success() => Poll::Pending( - result.failure_diagnostic("gateway status command succeeds"), - ), - Ok(result) => match result.json::() { - Ok(status) if status.status == "connected" => Poll::Ready(status), - Ok(status) if status.status == "not_configured" => Poll::Failed( - "no active gateway; register and select a gateway before running conformance" - .to_string(), - ), - Ok(status) => Poll::Pending(format!( - "gateway status is {:?}; expected \"connected\"\nstderr:\n{}", - status.status, - result.stderr() - )), - Err(error) => Poll::Failed(error.to_string()), - }, - Err(error) => Poll::Pending(error.to_string()), - }, - ) - .await - .map_err(|error| error.to_string())?; - - eprintln!( - "gateway preflight connected: gateway={}, server={}, version={}, authentication={}", - status.gateway.as_deref().unwrap_or("unknown"), - status.server.as_deref().unwrap_or("unknown"), - status.version.as_deref().unwrap_or("unknown"), - status - .authentication - .as_ref() - .map_or("unknown", |authentication| authentication.status.as_str()), - ); - Ok(()) - } - - /// Register a sandbox name for cleanup. - /// - /// Call this before running the command that may create the sandbox. The - /// scenario remains responsible for constructing and running that command. - pub fn track_sandbox(&mut self, name: &str) { - self.known_sandboxes.insert(name.to_string()); - } - - /// Stop tracking a sandbox after the scenario confirms it is absent. - pub fn forget_sandbox(&mut self, name: &str) { - self.known_sandboxes.remove(name); - } - - /// Register a provider name for cleanup. - pub fn track_provider(&mut self, name: &str) { - self.known_providers.insert(name.to_string()); - } - - /// Register a provider profile ID for cleanup. - pub fn track_provider_profile(&mut self, id: &str) { - self.known_provider_profiles.insert(id.to_string()); - } - - pub async fn poll_until( - &mut self, - step: &str, - poll_timeout: Duration, - interval: Duration, - mut observe: F, - ) -> Result - where - F: AsyncFnMut(&mut Self) -> Poll, - { - let started = Instant::now(); - let context = self.context(step); - - loop { - match observe(self).await { - Poll::Ready(value) => return Ok(value), - Poll::Pending(diagnostic) => { - if started.elapsed() >= poll_timeout { - return Err(RunnerError::PollTimeout { - context, - timeout: poll_timeout, - last_observation: diagnostic, - }); - } - } - Poll::Failed(diagnostic) => { - return Err(RunnerError::ObservationFailed { - context, - diagnostic, - }); - } - } - sleep(interval).await; - } - } - - pub async fn finish(mut self, scenario_result: Result<(), String>) -> Result<(), String> { - let cleanup_result = self.cleanup().await; - self.finished = true; - combine_results(scenario_result, cleanup_result) - } - - async fn run_strings( - &self, - step: &str, - expectation: &str, - args: Vec, - environment: Vec<(String, String)>, - command_timeout: Duration, - ) -> Result { - let context = self.context(step); - let command = sanitized_command(&args); - eprintln!("{context} running: {command}"); - - let started = Instant::now(); - let output = self - .cli - .execute(args, environment, command_timeout) - .await - .map_err(|error| match error { - CliExecutionError::Timeout => RunnerError::Timeout { - context: context.clone(), - command: command.clone(), - timeout: command_timeout, - }, - CliExecutionError::Spawn(source) => RunnerError::Spawn { - context: context.clone(), - command: command.clone(), - source, - }, - })?; - let elapsed = started.elapsed(); - eprintln!( - "{context} completed in {:.1?}: exit {}", - elapsed, - exit_description(output.status) - ); - - Ok(CommandResult { - run_id: self.run_id.clone(), - scenario: self.scenario.clone(), - step: step.to_string(), - expectation: expectation.to_string(), - command, - status: output.status, - elapsed, - stdout: String::from_utf8_lossy(&output.stdout).into_owned(), - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), - }) - } - - async fn cleanup(&self) -> Result<(), String> { - if self.known_sandboxes.is_empty() - && self.known_providers.is_empty() - && self.known_provider_profiles.is_empty() - { - return Ok(()); - } - - let cleanup_started = Instant::now(); - let mut failures = Vec::new(); - for name in self.known_sandboxes.clone() { - let remaining = remaining_cleanup_time(cleanup_started); - if remaining.is_zero() { - failures.push(format!( - "{} cleanup budget expired before deleting sandbox '{name}'", - self.context("cleanup/delete") - )); - break; - } - match self - .step("cleanup/delete") - .description(format!("sandbox '{name}' is deleted or already absent")) - .with_timeout(remaining) - .run(&["sandbox", "delete", &name]) - .await - { - Ok(result) if result.success() || output_reports_not_found(&result) => {} - Ok(result) => { - failures.push(result.failure_diagnostic(&format!( - "sandbox '{name}' is deleted or already absent" - ))); - } - Err(error) => failures.push(error.to_string()), - } - } - - for name in self.known_providers.clone() { - let remaining = remaining_cleanup_time(cleanup_started); - if remaining.is_zero() { - failures.push(format!( - "{} cleanup budget expired before deleting provider '{name}'", - self.context("cleanup/delete-provider") - )); - break; - } - match self - .step("cleanup/delete-provider") - .description(format!("provider '{name}' is deleted or already absent")) - .with_timeout(remaining) - .run(&["provider", "delete", &name]) - .await - { - Ok(result) if result.success() || output_reports_not_found(&result) => {} - Ok(result) => failures.push(result.failure_diagnostic(&format!( - "provider '{name}' is deleted or already absent" - ))), - Err(error) => failures.push(error.to_string()), - } - } - - for id in self.known_provider_profiles.clone() { - let remaining = remaining_cleanup_time(cleanup_started); - if remaining.is_zero() { - failures.push(format!( - "{} cleanup budget expired before deleting provider profile '{id}'", - self.context("cleanup/delete-provider-profile") - )); - break; - } - match self - .step("cleanup/delete-provider-profile") - .description(format!( - "provider profile '{id}' is deleted or already absent" - )) - .with_timeout(remaining) - .run(&["provider", "profile", "delete", &id]) - .await - { - Ok(result) if result.success() || output_reports_not_found(&result) => {} - Ok(result) => failures.push(result.failure_diagnostic(&format!( - "provider profile '{id}' is deleted or already absent" - ))), - Err(error) => failures.push(error.to_string()), - } - } - - if failures.is_empty() { - Ok(()) - } else { - Err(failures.join("\n\n")) - } - } - - fn context(&self, step: &str) -> String { - format!("[run {}][{}/{}]", self.run_id, self.scenario, step) - } -} - -impl<'a> CommandStep<'a> { - #[must_use] - pub fn description(mut self, description: impl Into) -> Self { - self.description = Some(description.into()); - self - } - - pub fn with_timeout(self, timeout: Duration) -> OpenShellCommand<'a> { - let description = self - .description - .unwrap_or_else(|| format!("step '{}' succeeds", self.step)); - OpenShellCommand { - runner: self.runner, - step: self.step, - description, - timeout, - } - } -} - -impl OpenShellCommand<'_> { - pub async fn run(&self, args: &[&str]) -> Result { - self.run_with_env(args, &[]).await - } - - /// Run a command with environment values that are excluded from diagnostics. - pub async fn run_with_env( - &self, - args: &[&str], - environment: &[(&str, &str)], - ) -> Result { - self.runner - .run_strings( - &self.step, - &self.description, - args.iter().map(|arg| (*arg).to_string()).collect(), - environment - .iter() - .map(|(name, value)| ((*name).to_string(), (*value).to_string())) - .collect(), - self.timeout, - ) - .await - } -} - -impl Drop for OpenShellRunner { - fn drop(&mut self) { - if self.finished { - return; - } - let resources = if self.known_sandboxes.is_empty() { - "none explicitly tracked".to_string() - } else { - self.known_sandboxes - .iter() - .cloned() - .collect::>() - .join(", ") - }; - eprintln!( - "[run {}][{}] WARNING: runner dropped without finish(); known sandboxes: {}", - self.run_id, self.scenario, resources - ); - } -} - -fn combine_results( - scenario_result: Result<(), String>, - cleanup_result: Result<(), String>, -) -> Result<(), String> { - match (scenario_result, cleanup_result) { - (Ok(()), Ok(())) => Ok(()), - (Err(functional), Ok(())) => Err(functional), - (Ok(()), Err(cleanup)) => Err(format!("cleanup failed:\n{cleanup}")), - (Err(functional), Err(cleanup)) => Err(format!( - "{functional}\n\nsecondary cleanup failure:\n{cleanup}" - )), - } -} - -fn remaining_cleanup_time(started: Instant) -> Duration { - CLEANUP_TIMEOUT.saturating_sub(started.elapsed()) -} - -fn output_reports_not_found(result: &CommandResult) -> bool { - result.stderr().to_ascii_lowercase().contains("not found") - || result.stdout().to_ascii_lowercase().contains("not found") -} - -fn exit_description(status: ExitStatus) -> String { - status.code().map_or_else( - || "terminated by signal".to_string(), - |code| code.to_string(), - ) -} - -fn sanitized_command(args: &[String]) -> String { - let mut redact_next = false; - let rendered = args.iter().map(|arg| { - let rendered = if redact_next { - redact_next = false; - "".to_string() - } else if sensitive_flag(arg) { - redact_next = !arg.contains('='); - arg.split_once('=') - .map_or_else(|| arg.clone(), |(flag, _)| format!("{flag}=")) - } else { - arg.clone() - }; - shell_escape(&rendered) - }); - std::iter::once("openshell".to_string()) - .chain(rendered) - .collect::>() - .join(" ") -} - -fn sensitive_flag(arg: &str) -> bool { - let flag = arg.split_once('=').map_or(arg, |(flag, _)| flag); - matches!( - flag, - "--credential" - | "--credentials" - | "--env" - | "--header" - | "--material" - | "--password" - | "--secret" - | "--token" - ) || flag.contains("client-secret") - || flag.contains("private-key") - || flag.ends_with("-token") -} - -fn shell_escape(arg: &str) -> String { - if arg - .chars() - .all(|character| character.is_ascii_alphanumeric() || "-_./:@=,".contains(character)) - { - return arg.to_string(); - } - format!("'{}'", arg.replace('\'', "'\\''")) -} - -#[cfg(all(test, unix))] -mod tests { - use std::collections::VecDeque; - use std::os::unix::process::ExitStatusExt; - use std::process::Output; - use std::sync::Mutex; - - use serde::Deserialize; - - use super::executor::CliExecution; - use super::*; - - struct MockCli { - state: Mutex, - } - - struct MockCliState { - responses: VecDeque, - invocations: Vec>, - } - - enum MockResponse { - Output { - exit_code: i32, - stdout: String, - stderr: String, - }, - Timeout, - } - - impl MockResponse { - fn output(exit_code: i32, stdout: &str, stderr: &str) -> Self { - Self::Output { - exit_code, - stdout: stdout.to_string(), - stderr: stderr.to_string(), - } - } - - fn success(stdout: &str) -> Self { - Self::output(0, stdout, "") - } - } - - impl MockCli { - fn new(responses: Vec) -> Self { - Self { - state: Mutex::new(MockCliState { - responses: responses.into(), - invocations: Vec::new(), - }), - } - } - - fn invocations(&self) -> Vec> { - self.state - .lock() - .expect("lock mock CLI state") - .invocations - .clone() - } - } - - impl CliExecutor for MockCli { - fn execute( - &self, - args: Vec, - _environment: Vec<(String, String)>, - _command_timeout: Duration, - ) -> CliExecution<'_> { - let response = { - let mut state = self.state.lock().expect("lock mock CLI state"); - state.invocations.push(args); - state - .responses - .pop_front() - .expect("mock CLI received an unexpected invocation") - }; - - Box::pin(async move { - match response { - MockResponse::Output { - exit_code, - stdout, - stderr, - } => Ok(Output { - status: ExitStatus::from_raw(exit_code << 8), - stdout: stdout.into_bytes(), - stderr: stderr.into_bytes(), - }), - MockResponse::Timeout => Err(CliExecutionError::Timeout), - } - }) - } - } - - fn test_runner(responses: Vec) -> (OpenShellRunner, Arc) { - let cli = Arc::new(MockCli::new(responses)); - let runner = OpenShellRunner::with_executor(cli.clone(), "smoke"); - (runner, cli) - } - - #[test] - fn with_binary_rejects_a_missing_file() { - let directory = tempfile::tempdir().expect("create missing CLI directory"); - let Err(error) = OpenShellRunner::with_binary(directory.path().join("missing"), "smoke") - else { - panic!("missing CLI should be rejected"); - }; - - assert!(matches!(error, RunnerError::BinaryUnavailable(_))); - } - - #[test] - fn exposes_generated_identity() { - let (mut runner, _cli) = test_runner(Vec::new()); - - assert_eq!(runner.id().len(), 10); - runner.finished = true; - } - - #[tokio::test] - async fn captures_streams_and_nonzero_exit_as_result() { - let (mut runner, _cli) = test_runner(vec![MockResponse::output(7, "stdout", "stderr")]); - - let result = runner - .step("capture") - .description("sandbox list succeeds") - .with_timeout(Duration::from_secs(1)) - .run(&["sandbox", "list"]) - .await - .expect("completed nonzero exit is a result"); - - assert_eq!(result.exit_code(), Some(7)); - assert_eq!(result.stdout(), "stdout"); - assert_eq!(result.stderr(), "stderr"); - assert!( - result - .require_success() - .expect_err("nonzero result should fail its expectation") - .contains("expected: sandbox list succeeds") - ); - runner.finished = true; - } - - #[tokio::test] - async fn reports_timeout_as_typed_error() { - let (mut runner, _cli) = test_runner(vec![MockResponse::Timeout]); - - let error = runner - .step("timeout") - .with_timeout(Duration::from_millis(10)) - .run(&["status"]) - .await - .expect_err("command should time out"); - - assert!(matches!(error, RunnerError::Timeout { .. })); - runner.finished = true; - } - - #[derive(Debug, Deserialize, PartialEq, Eq)] - struct JsonFixture { - status: String, - } - - #[tokio::test] - async fn deserializes_stdout_json_without_stderr() { - let (mut runner, _cli) = test_runner(vec![MockResponse::output( - 0, - "{\"status\":\"connected\"}", - "diagnostic", - )]); - - let result = runner - .step("json") - .with_timeout(Duration::from_secs(1)) - .run(&["status"]) - .await - .expect("run fake CLI"); - - assert_eq!( - result.json::().expect("parse JSON"), - JsonFixture { - status: "connected".to_string() - } - ); - runner.finished = true; - } - - #[tokio::test] - async fn accepts_connected_gateway_status() { - let (mut runner, _cli) = - test_runner(vec![MockResponse::success("{\"status\":\"connected\"}")]); - - runner - .check_gateway_status() - .await - .expect("connected gateway should satisfy preflight"); - - runner.finished = true; - } - - #[tokio::test] - async fn polling_returns_ready_value() { - let (mut runner, _cli) = test_runner(Vec::new()); - let mut attempts = 0; - - let value = runner - .poll_until( - "poll", - Duration::from_secs(1), - Duration::from_millis(1), - async |_runner| { - attempts += 1; - if attempts == 2 { - Poll::Ready("ready") - } else { - Poll::Pending("not ready".to_string()) - } - }, - ) - .await - .expect("poll should become ready"); - - assert_eq!(value, "ready"); - runner.finished = true; - } - - #[tokio::test] - async fn polling_timeout_preserves_last_observation() { - let (mut runner, _cli) = test_runner(Vec::new()); - - let error = runner - .poll_until( - "poll", - Duration::from_millis(1), - Duration::from_millis(1), - async |_runner| Poll::<()>::Pending("still pending".to_string()), - ) - .await - .expect_err("poll should time out"); - - assert!(matches!( - error, - RunnerError::PollTimeout { - last_observation, - .. - } if last_observation == "still pending" - )); - runner.finished = true; - } - - #[tokio::test] - async fn finish_skips_cleanup_commands_without_registered_resources() { - let (runner, cli) = test_runner(Vec::new()); - - let error = runner - .finish(Err("preflight failed".to_string())) - .await - .expect_err("functional failure should be preserved"); - - assert_eq!(error, "preflight failed"); - assert!(cli.invocations().is_empty()); - } - - #[test] - fn tracks_sandbox_for_cleanup() { - let (mut runner, _cli) = test_runner(Vec::new()); - - runner.track_sandbox("ct-0123456789-01"); - - assert!(runner.known_sandboxes.contains("ct-0123456789-01")); - runner.finished = true; - } - - #[test] - fn forgets_exact_name_cleanup() { - let (mut runner, _cli) = test_runner(Vec::new()); - - runner.track_sandbox("ct-0123456789-01"); - runner.forget_sandbox("ct-0123456789-01"); - - assert!(runner.known_sandboxes.is_empty()); - runner.finished = true; - } - - #[tokio::test] - async fn finish_deletes_explicitly_tracked_resources() { - let (mut runner, cli) = test_runner(vec![MockResponse::success("")]); - runner.track_sandbox("ct-0123456789-01"); - - runner - .finish(Ok(())) - .await - .expect("owned resource cleanup should succeed"); - - assert_eq!( - cli.invocations(), - vec![vec![ - "sandbox".to_string(), - "delete".to_string(), - "ct-0123456789-01".to_string(), - ]] - ); - } - - #[test] - fn functional_failure_remains_primary_when_cleanup_also_fails() { - let error = combine_results( - Err("functional failure".to_string()), - Err("cleanup failure".to_string()), - ) - .expect_err("combined result should fail"); - - assert!(error.starts_with("functional failure")); - assert!(error.contains("secondary cleanup failure:\ncleanup failure")); - } - - #[test] - fn generated_run_id_is_portable_and_compact() { - let run_id = generate_run_id(); - assert_eq!(run_id.len(), 10); - assert!( - run_id - .chars() - .all(|character| character.is_ascii_lowercase() || character.is_ascii_digit()) - ); - } - - #[test] - fn sanitizes_sensitive_cli_arguments() { - let command = sanitized_command(&[ - "provider".to_string(), - "create".to_string(), - "--credential".to_string(), - "TOKEN=secret".to_string(), - "--client-secret=hunter2".to_string(), - ]); - - assert!(!command.contains("TOKEN=secret")); - assert!(!command.contains("hunter2")); - assert!(command.contains("")); - } -} diff --git a/crates/openshell-conformance/src/scenarios/file_transfer.rs b/crates/openshell-conformance/src/scenarios/file_transfer.rs deleted file mode 100644 index 0af908008c..0000000000 --- a/crates/openshell-conformance/src/scenarios/file_transfer.rs +++ /dev/null @@ -1,764 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Portable CLI file-transfer conformance scenario. - -use std::fs; -use std::path::Path; -use std::process::Stdio; -use std::time::Duration; - -use tokio::process::Command; - -use crate::{CommandResult, OpenShellRunner, Scenario, ScenarioFuture}; - -const CREATE_TIMEOUT: Duration = Duration::from_mins(10); -const COMMAND_TIMEOUT: Duration = Duration::from_mins(2); -const TRANSFER_TIMEOUT: Duration = Duration::from_mins(5); -const LARGE_FILE_SIZE: usize = 512 * 1024; - -/// Certify basic file and directory upload and download behavior. -pub const FILE_TRANSFER_ROUND_TRIP_SCENARIO: Scenario = Scenario { - name: "file-transfer/round-trip", - run: run_round_trip, -}; - -/// Certify Git-aware upload filtering and explicit unfiltered uploads. -pub const FILE_TRANSFER_GIT_FILTERING_SCENARIO: Scenario = Scenario { - name: "file-transfer/git-filtering", - run: run_git_filtering, -}; - -/// Certify file-transfer workspace boundary and filename safety behavior. -pub const FILE_TRANSFER_PATH_SAFETY_SCENARIO: Scenario = Scenario { - name: "file-transfer/path-safety", - run: run_path_safety, -}; - -fn run_round_trip(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { - Box::pin(async move { - let (sandbox_name, remote_root, local) = prepare_sandbox(runner, "round-trip").await?; - upload_existing_directories(runner, &sandbox_name, &remote_root, local.path()).await?; - round_trip(runner, &sandbox_name, &remote_root, local.path()).await?; - download_file(runner, &sandbox_name, &remote_root, local.path()).await?; - download_directory(runner, &sandbox_name, &remote_root, local.path()).await?; - delete_sandbox(runner, &sandbox_name).await - }) -} - -fn run_git_filtering(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { - Box::pin(async move { - let (sandbox_name, remote_root, local) = prepare_sandbox(runner, "git-filtering").await?; - gitignore_filtering(runner, &sandbox_name, &remote_root, local.path()).await?; - single_file_from_git_repo(runner, &sandbox_name, &remote_root, local.path()).await?; - gitignored_directory_requires_override(runner, &sandbox_name, &remote_root, local.path()) - .await?; - delete_sandbox(runner, &sandbox_name).await - }) -} - -fn run_path_safety(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { - Box::pin(async move { - let (sandbox_name, remote_root, local) = prepare_sandbox(runner, "path-safety").await?; - reject_workspace_escape(runner, &sandbox_name, &remote_root, local.path()).await?; - download_dash_leading_name(runner, &sandbox_name, &remote_root, local.path()).await?; - delete_sandbox(runner, &sandbox_name).await - }) -} - -async fn prepare_sandbox( - runner: &mut OpenShellRunner, - group: &str, -) -> Result<(String, String, tempfile::TempDir), String> { - let suffix = match group { - "round-trip" => "fr", - "git-filtering" => "fg", - "path-safety" => "fs", - _ => return Err(format!("unknown file-transfer group {group:?}")), - }; - let sandbox_name = format!("ct-{}-{suffix}", runner.id()); - let remote_root = format!("/sandbox/file-transfer-{}-{suffix}", runner.id()); - let local = - tempfile::tempdir().map_err(|error| format!("create temporary directory: {error}"))?; - - runner.track_sandbox(&sandbox_name); - let create = runner - .step(format!("{group}/create")) - .description(format!("sandbox '{sandbox_name}' is created")) - .with_timeout(CREATE_TIMEOUT) - .run(&["sandbox", "create", "--name", &sandbox_name, "--detach"]) - .await - .map_err(|error| error.to_string())?; - create.require_success()?; - - exec( - runner, - &sandbox_name, - &format!("{group}/prepare"), - &format!("mkdir -p '{remote_root}'"), - ) - .await?; - - Ok((sandbox_name, remote_root, local)) -} - -async fn delete_sandbox(runner: &mut OpenShellRunner, sandbox_name: &str) -> Result<(), String> { - let delete = runner - .step("delete") - .description(format!("sandbox '{sandbox_name}' is deleted")) - .with_timeout(COMMAND_TIMEOUT) - .run(&["sandbox", "delete", sandbox_name]) - .await - .map_err(|error| error.to_string())?; - delete.require_success()?; - runner.forget_sandbox(sandbox_name); - Ok(()) -} - -/// Check remote destination types, not just successful transfer status: tar -/// can report success after replacing an empty directory with a regular file. -async fn upload_existing_directories( - runner: &OpenShellRunner, - sandbox: &str, - remote_root: &str, - local_root: &Path, -) -> Result<(), String> { - let source = local_root.join("config.json"); - fs::write(&source, "upload-destination-payload") - .map_err(fs_error("write destination fixture"))?; - let root = format!("{remote_root}/upload destinations"); - exec( - runner, - sandbox, - "upload-destinations/prepare", - &format!( - "mkdir -p '{root}/empty' '{root}/populated' '{root}/target' '{root}/slash' && \ - printf keep > '{root}/populated/keep.txt' && \ - ln -s target '{root}/link'" - ), - ) - .await?; - - for directory in ["empty", "populated", "link", "slash/"] { - let destination = format!("{root}/{directory}"); - upload( - runner, - sandbox, - &format!("upload-destinations/{directory}/upload"), - &source, - &destination, - true, - ) - .await?; - exec( - runner, - sandbox, - &format!("upload-destinations/{directory}/verify"), - &format!( - "test -d '{destination}' && \ - test \"$(cat '{destination}/config.json')\" = upload-destination-payload && \ - test \"$(cat '{root}/populated/keep.txt')\" = keep && \ - test -L '{root}/link'" - ), - ) - .await?; - } - - // A missing destination remains an intentional rename; uploading again - // must replace that file rather than treating it as a directory. - let renamed = format!("{root}/renamed.json"); - for pass in ["rename", "overwrite"] { - upload( - runner, - sandbox, - &format!("upload-destinations/{pass}/upload"), - &source, - &renamed, - true, - ) - .await?; - exec( - runner, - sandbox, - &format!("upload-destinations/{pass}/verify"), - &format!( - "test -f '{renamed}' && test \"$(cat '{renamed}')\" = upload-destination-payload" - ), - ) - .await?; - } - Ok(()) -} - -async fn round_trip( - runner: &OpenShellRunner, - sandbox: &str, - remote_root: &str, - local_root: &Path, -) -> Result<(), String> { - let source = local_root.join("roundtrip-upload"); - fs::create_dir_all(source.join("subdir")).map_err(fs_error("create round-trip source"))?; - fs::write(source.join("greeting.txt"), "hello-from-local") - .map_err(fs_error("write greeting.txt"))?; - fs::write(source.join("subdir/nested.txt"), "nested-content") - .map_err(fs_error("write nested.txt"))?; - - let large = (0u8..=250) - .cycle() - .take(LARGE_FILE_SIZE) - .collect::>(); - fs::write(source.join("large.bin"), &large).map_err(fs_error("write large.bin"))?; - - let remote = format!("{remote_root}/roundtrip"); - let result = - upload_result(runner, sandbox, "roundtrip/upload", &source, &remote, false).await?; - result.require_success()?; - if !result.stderr().contains("outside a Git work tree") - || !result.stderr().contains(".gitignore rules are not applied") - { - return Err( - result.failure_diagnostic("upload outside Git warns that filtering is disabled") - ); - } - - let destination = local_root.join("roundtrip-download"); - fs::create_dir(&destination).map_err(fs_error("create round-trip destination"))?; - let remote_source = format!("{remote}/roundtrip-upload"); - download( - runner, - sandbox, - "roundtrip/download", - &remote_source, - &destination, - ) - .await?; - - require_text( - &destination.join("greeting.txt"), - "hello-from-local", - "round-trip greeting", - )?; - require_text( - &destination.join("subdir/nested.txt"), - "nested-content", - "round-trip nested file", - )?; - let actual = fs::read(destination.join("large.bin")).map_err(fs_error("read large.bin"))?; - if actual != large { - return Err(format!( - "large file changed during round trip: expected {} bytes, received {} bytes", - large.len(), - actual.len() - )); - } - - let single = local_root.join("single.txt"); - fs::write(&single, "single-file-payload").map_err(fs_error("write single.txt"))?; - let remote_single = format!("{remote_root}/single.txt"); - upload( - runner, - sandbox, - "single/upload", - &single, - &remote_single, - false, - ) - .await?; - let single_destination = local_root.join("single-download"); - fs::create_dir(&single_destination).map_err(fs_error("create single-file destination"))?; - download( - runner, - sandbox, - "single/download", - &remote_single, - &single_destination, - ) - .await?; - require_text( - &single_destination.join("single.txt"), - "single-file-payload", - "single-file round trip", - ) -} - -async fn gitignore_filtering( - runner: &OpenShellRunner, - sandbox: &str, - remote_root: &str, - local_root: &Path, -) -> Result<(), String> { - let repository = local_root.join("filter-repo"); - fs::create_dir(&repository).map_err(fs_error("create filter repository"))?; - git_init(&repository).await?; - fs::write(repository.join(".gitignore"), "*.log\nbuild/\n") - .map_err(fs_error("write filter .gitignore"))?; - fs::write(repository.join("tracked.txt"), "i-am-tracked") - .map_err(fs_error("write tracked.txt"))?; - fs::write(repository.join("ignored.log"), "i-should-be-filtered") - .map_err(fs_error("write ignored.log"))?; - fs::create_dir(repository.join("build")).map_err(fs_error("create ignored build directory"))?; - fs::write(repository.join("build/output.bin"), "build-artifact") - .map_err(fs_error("write ignored build artifact"))?; - git(&repository, &["add", "."]).await?; - - let remote = format!("{remote_root}/filtered"); - upload( - runner, - sandbox, - "gitignore/upload", - &repository, - &remote, - false, - ) - .await?; - - let destination = local_root.join("filter-download"); - fs::create_dir(&destination).map_err(fs_error("create filter destination"))?; - download(runner, sandbox, "gitignore/download", &remote, &destination).await?; - let uploaded = destination.join("filter-repo"); - require_text( - &uploaded.join("tracked.txt"), - "i-am-tracked", - "Git-aware tracked file", - )?; - require_exists(&uploaded.join(".gitignore"), "uploaded .gitignore")?; - require_absent(&uploaded.join("ignored.log"), "Git-ignored file")?; - require_absent(&uploaded.join("build"), "Git-ignored directory") -} - -async fn single_file_from_git_repo( - runner: &OpenShellRunner, - sandbox: &str, - remote_root: &str, - local_root: &Path, -) -> Result<(), String> { - let repository = local_root.join("single-repo"); - fs::create_dir_all(repository.join("nested")) - .map_err(fs_error("create single-file repository"))?; - git_init(&repository).await?; - fs::write(repository.join(".gitignore"), "*.log\n") - .map_err(fs_error("write single-file .gitignore"))?; - fs::write( - repository.join("nested/config.txt"), - "single-file-from-repo", - ) - .map_err(fs_error("write repository config.txt"))?; - fs::write(repository.join("tracked.txt"), "should-not-upload") - .map_err(fs_error("write unrelated tracked.txt"))?; - fs::write(repository.join("ignored.log"), "ignored") - .map_err(fs_error("write repository ignored.log"))?; - - let remote = format!("{remote_root}/single-from-repo"); - upload( - runner, - sandbox, - "single-from-repo/upload", - &repository.join("nested/config.txt"), - &remote, - false, - ) - .await?; - let destination = local_root.join("single-repo-download"); - fs::create_dir(&destination).map_err(fs_error("create single-repo destination"))?; - download( - runner, - sandbox, - "single-from-repo/download", - &remote, - &destination, - ) - .await?; - require_text( - &destination.join("config.txt"), - "single-file-from-repo", - "single file selected from repository", - )?; - require_absent( - &destination.join("tracked.txt"), - "unselected repository file", - )?; - require_absent(&destination.join("ignored.log"), "ignored repository file") -} - -async fn download_file( - runner: &OpenShellRunner, - sandbox: &str, - remote_root: &str, - local_root: &Path, -) -> Result<(), String> { - let remote = format!("{remote_root}/download-file.txt"); - exec( - runner, - sandbox, - "download-file/seed", - &format!("printf greeting-payload > '{remote}'"), - ) - .await?; - let destination = local_root.join("download-file"); - fs::create_dir(&destination).map_err(fs_error("create file-download destination"))?; - download( - runner, - sandbox, - "download-file/download", - &remote, - &destination, - ) - .await?; - require_text( - &destination.join("download-file.txt"), - "greeting-payload", - "downloaded file", - ) -} - -async fn download_directory( - runner: &OpenShellRunner, - sandbox: &str, - remote_root: &str, - local_root: &Path, -) -> Result<(), String> { - let remote = format!("{remote_root}/tree"); - exec( - runner, - sandbox, - "download-directory/seed", - &format!( - "mkdir -p '{remote}/sub' && printf top-level > '{remote}/root.txt' && printf nested > '{remote}/sub/child.txt'" - ), - ) - .await?; - let destination = local_root.join("download-directory"); - fs::create_dir(&destination).map_err(fs_error("create directory-download destination"))?; - download( - runner, - sandbox, - "download-directory/download", - &remote, - &destination, - ) - .await?; - require_text( - &destination.join("root.txt"), - "top-level", - "downloaded directory root file", - )?; - require_text( - &destination.join("sub/child.txt"), - "nested", - "downloaded directory nested file", - ) -} - -async fn reject_workspace_escape( - runner: &OpenShellRunner, - sandbox: &str, - remote_root: &str, - local_root: &Path, -) -> Result<(), String> { - let etc_link = format!("{remote_root}/etc-link"); - let passwd_link = format!("{remote_root}/passwd-link"); - exec( - runner, - sandbox, - "workspace-escape/seed", - &format!("ln -s /etc '{etc_link}' && ln -s /etc/passwd '{passwd_link}'"), - ) - .await?; - let destination = local_root.join("workspace-escape"); - fs::create_dir(&destination).map_err(fs_error("create workspace-escape destination"))?; - - for (step, source) in [ - ("directory-link", etc_link.clone()), - ("file-link", passwd_link), - ("linked-component", format!("{etc_link}/passwd")), - ] { - let result = download_result( - runner, - sandbox, - &format!("workspace-escape/{step}"), - &source, - &destination, - ) - .await?; - if result.success() { - return Err(format!( - "download unexpectedly accepted sandbox path {source:?} that resolves outside the workspace" - )); - } - let diagnostic = format!("{}\n{}", result.stdout(), result.stderr()); - if !diagnostic.contains("resolves to") - || !diagnostic.contains("outside the") - || !diagnostic.contains("sandbox workspace") - { - return Err(result.failure_diagnostic( - "download is rejected because the resolved source is outside the sandbox workspace", - )); - } - } - require_absent(&destination.join("passwd"), "escaped passwd file")?; - require_absent(&destination.join("etc-link"), "escaped /etc directory") -} - -async fn download_dash_leading_name( - runner: &OpenShellRunner, - sandbox: &str, - remote_root: &str, - local_root: &Path, -) -> Result<(), String> { - let remote = format!("{remote_root}/--checkpoint-action=evil"); - exec( - runner, - sandbox, - "dash-leading/seed", - &format!("printf dash-payload > '{remote}'"), - ) - .await?; - let destination = local_root.join("dash-leading"); - fs::create_dir(&destination).map_err(fs_error("create dash-leading destination"))?; - download( - runner, - sandbox, - "dash-leading/download", - &remote, - &destination, - ) - .await?; - require_text( - &destination.join("--checkpoint-action=evil"), - "dash-payload", - "dash-leading file", - ) -} - -async fn gitignored_directory_requires_override( - runner: &OpenShellRunner, - sandbox: &str, - remote_root: &str, - local_root: &Path, -) -> Result<(), String> { - let remote_seed = format!("{remote_root}/runs/test.json"); - exec( - runner, - sandbox, - "gitignored-override/seed", - &format!("mkdir -p '{remote_root}/runs' && printf downloaded-payload > '{remote_seed}'"), - ) - .await?; - - let repository = local_root.join("override-repo"); - fs::create_dir(&repository).map_err(fs_error("create override repository"))?; - git_init(&repository).await?; - fs::write(repository.join(".gitignore"), "runs/\n") - .map_err(fs_error("write override .gitignore"))?; - let runs = repository.join("runs"); - fs::create_dir(&runs).map_err(fs_error("create ignored runs directory"))?; - download( - runner, - sandbox, - "gitignored-override/download-seed", - &remote_seed, - &runs, - ) - .await?; - require_exists(&runs.join("test.json"), "downloaded ignored file")?; - - let remote = format!("{remote_root}/reuploaded"); - let rejected = upload_result( - runner, - sandbox, - "gitignored-override/reject-upload", - &runs, - &remote, - false, - ) - .await?; - let output = format!("{}\n{}", rejected.stdout(), rejected.stderr()); - if rejected.success() - || !output.contains("filtering selected no files") - || !output.contains("--no-git-ignore") - { - return Err(rejected.failure_diagnostic( - "upload rejects an empty Git selection and explains the explicit override", - )); - } - exec( - runner, - sandbox, - "gitignored-override/no-transfer", - &format!("test ! -e '{remote}'"), - ) - .await?; - upload( - runner, - sandbox, - "gitignored-override/upload", - &runs, - &remote, - true, - ) - .await?; - - let destination = local_root.join("override-download"); - fs::create_dir(&destination).map_err(fs_error("create override destination"))?; - download( - runner, - sandbox, - "gitignored-override/download", - &remote, - &destination, - ) - .await?; - require_text( - &destination.join("runs/test.json"), - "downloaded-payload", - "re-uploaded Git-ignored file", - ) -} - -async fn upload( - runner: &OpenShellRunner, - sandbox: &str, - step: &str, - source: &Path, - destination: &str, - no_git_ignore: bool, -) -> Result<(), String> { - let result = upload_result(runner, sandbox, step, source, destination, no_git_ignore).await?; - result.require_success() -} - -async fn upload_result( - runner: &OpenShellRunner, - sandbox: &str, - step: &str, - source: &Path, - destination: &str, - no_git_ignore: bool, -) -> Result { - let source = source - .to_str() - .ok_or_else(|| format!("local upload path is not UTF-8: {}", source.display()))?; - let mut args = vec!["sandbox", "upload", sandbox, source, destination]; - if no_git_ignore { - args.push("--no-git-ignore"); - } - runner - .step(step) - .description(format!("upload {source:?} to {destination:?}")) - .with_timeout(TRANSFER_TIMEOUT) - .run(&args) - .await - .map_err(|error| error.to_string()) -} - -async fn download( - runner: &OpenShellRunner, - sandbox: &str, - step: &str, - source: &str, - destination: &Path, -) -> Result<(), String> { - let result = download_result(runner, sandbox, step, source, destination).await?; - result.require_success() -} - -async fn download_result( - runner: &OpenShellRunner, - sandbox: &str, - step: &str, - source: &str, - destination: &Path, -) -> Result { - let destination = destination.to_str().ok_or_else(|| { - format!( - "local download path is not UTF-8: {}", - destination.display() - ) - })?; - runner - .step(step) - .description(format!( - "download {source:?} to {destination:?} has the expected disposition" - )) - .with_timeout(TRANSFER_TIMEOUT) - .run(&["sandbox", "download", sandbox, source, destination]) - .await - .map_err(|error| error.to_string()) -} - -async fn exec( - runner: &OpenShellRunner, - sandbox: &str, - step: &str, - script: &str, -) -> Result<(), String> { - let result = runner - .step(step) - .description(format!("sandbox fixture setup for {step} succeeds")) - .with_timeout(COMMAND_TIMEOUT) - .run(&[ - "sandbox", "exec", "--name", sandbox, "--no-tty", "--", "sh", "-c", script, - ]) - .await - .map_err(|error| error.to_string())?; - result.require_success() -} - -async fn git_init(repository: &Path) -> Result<(), String> { - git(repository, &["init", "--quiet"]).await -} - -async fn git(repository: &Path, args: &[&str]) -> Result<(), String> { - let output = Command::new("git") - .args(args) - .current_dir(repository) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await - .map_err(|error| format!("failed to run git in {}: {error}", repository.display()))?; - if output.status.success() { - return Ok(()); - } - Err(format!( - "git {} failed in {} with status {}\nstdout:\n{}\nstderr:\n{}", - args.join(" "), - repository.display(), - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - )) -} - -fn require_text(path: &Path, expected: &str, label: &str) -> Result<(), String> { - let actual = fs::read_to_string(path) - .map_err(|error| format!("read {label} at {}: {error}", path.display()))?; - if actual == expected { - Ok(()) - } else { - Err(format!( - "{label} content mismatch at {}: expected {expected:?}, received {actual:?}", - path.display() - )) - } -} - -fn require_exists(path: &Path, label: &str) -> Result<(), String> { - if path.exists() { - Ok(()) - } else { - Err(format!("{label} does not exist at {}", path.display())) - } -} - -fn require_absent(path: &Path, label: &str) -> Result<(), String> { - if path.exists() { - Err(format!("{label} unexpectedly exists at {}", path.display())) - } else { - Ok(()) - } -} - -fn fs_error(context: &'static str) -> impl FnOnce(std::io::Error) -> String { - move |error| format!("{context}: {error}") -} diff --git a/crates/openshell-conformance/src/scenarios/mod.rs b/crates/openshell-conformance/src/scenarios/mod.rs deleted file mode 100644 index 72a90925f0..0000000000 --- a/crates/openshell-conformance/src/scenarios/mod.rs +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Portable conformance scenarios exercised by the Cargo test suite. - -mod file_transfer; -mod policy_behavior; -mod sandbox_lifecycle; -mod smoke; - -pub use file_transfer::{ - FILE_TRANSFER_GIT_FILTERING_SCENARIO, FILE_TRANSFER_PATH_SAFETY_SCENARIO, - FILE_TRANSFER_ROUND_TRIP_SCENARIO, -}; -pub use policy_behavior::{ - MECHANISTIC_PROPOSAL_SCENARIO, NEW_HOSTNAME_PROPOSAL_SCENARIO, POLICY_LOCAL_SCENARIO, -}; -pub use sandbox_lifecycle::SANDBOX_LIFECYCLE_SCENARIO; -pub use smoke::SMOKE_SCENARIO; diff --git a/crates/openshell-conformance/src/scenarios/policy_behavior.rs b/crates/openshell-conformance/src/scenarios/policy_behavior.rs deleted file mode 100644 index 386b8e0f0b..0000000000 --- a/crates/openshell-conformance/src/scenarios/policy_behavior.rs +++ /dev/null @@ -1,579 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Portable policy-local and mechanistic proposal checks. - -use std::io::Write as _; -use std::time::{Duration, Instant}; - -use serde::Deserialize; -use serde_json::Value; -use tempfile::NamedTempFile; -use tokio::time::sleep; - -use crate::{OpenShellRunner, Scenario, ScenarioFuture}; - -const CREATE_TIMEOUT: Duration = Duration::from_mins(10); -const COMMAND_TIMEOUT: Duration = Duration::from_secs(45); -const READY_TIMEOUT: Duration = Duration::from_mins(4); -const POLL_INTERVAL: Duration = Duration::from_secs(2); -const PROPOSAL_TIMEOUT: Duration = Duration::from_secs(90); - -#[derive(Deserialize)] -struct SandboxState { - name: String, - phase: String, -} - -pub const POLICY_LOCAL_SCENARIO: Scenario = Scenario { - name: "policy-local", - run: run_policy_local, -}; - -pub const MECHANISTIC_PROPOSAL_SCENARIO: Scenario = Scenario { - name: "mechanistic-proposal", - run: run_mechanistic_proposal, -}; - -pub const NEW_HOSTNAME_PROPOSAL_SCENARIO: Scenario = Scenario { - name: "new-hostname-proposal", - run: run_new_hostname_proposal, -}; - -const EMPTY_NETWORK_POLICY: &[u8] = br"version: 1 -filesystem_policy: - include_workdir: true - read_only: [/usr, /bin, /lib, /lib64, /proc, /dev/urandom, /app, /etc, /var/log] - read_write: [/sandbox, /tmp, /dev/null] -landlock: { compatibility: best_effort } -network_policies: {} -"; - -fn run_policy_local(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { - Box::pin(async move { - let name = format!("ct-{}-pl", runner.id()); - create_sandbox(runner, &name, None).await?; - enable_proposals(runner, &name).await?; - let binary = sandbox_bash_path(runner, &name).await?; - - let started = Instant::now(); - let readiness_path = format!("/v1/proposals/ct-{}-readiness", runner.id()); - loop { - match request_policy_local(runner, &name, "/v1/policy/current").await { - Ok(response) - if response["format"] == "yaml" - && response["policy_yaml"] - .as_str() - .is_some_and(|yaml| yaml.contains("version: 1")) => - { - // The current-policy route is local; proposal submission also - // needs the supervisor's workspace and gateway lookup session. - match request_policy_local_http(runner, &name, "GET", &readiness_path, "", 404) - .await - { - Ok(lookup) if lookup["error"] == "chunk_not_found" => break, - Ok(lookup) => { - return Err(format!( - "policy.local proposal lookup returned an invalid readiness response: {lookup}" - )); - } - Err(error) if started.elapsed() >= READY_TIMEOUT => return Err(error), - Err(_) => {} - } - } - Ok(response) => { - return Err(format!( - "policy.local returned an invalid current policy: {response}" - )); - } - Err(error) => { - if started.elapsed() >= READY_TIMEOUT { - return Err(error); - } - } - } - sleep(POLL_INTERVAL).await; - } - let denials = request_policy_local(runner, &name, "/v1/denials?last=1").await?; - if !denials["denials"].is_array() || !denials["log_available"].is_boolean() { - return Err(format!( - "policy.local returned an invalid denials response: {denials}" - )); - } - - let rule_name = format!("conformance_local_{}", runner.id()); - let payload = serde_json::json!({ - "intent_summary": "Allow Bash to read the conformance path on example.invalid.", - "operations": [{ - "addRule": { - "ruleName": &rule_name, - "rule": { - "name": &rule_name, - "endpoints": [{ - "host": "example.invalid", - "port": 443, - "protocol": "rest", - "enforcement": "enforce", - "rules": [{"allow": {"method": "GET", "path": "/conformance"}}] - }], - "binaries": [{"path": &binary}] - } - } - }] - }) - .to_string(); - let submitted = - request_policy_local_http(runner, &name, "POST", "/v1/proposals", &payload, 202) - .await?; - let chunk_id = submitted["accepted_chunk_ids"] - .as_array() - .filter(|ids| ids.len() == 1) - .and_then(|ids| ids[0].as_str()) - .filter(|id| !id.is_empty()) - .ok_or_else(|| format!("policy.local did not accept one proposal: {submitted}"))?; - if submitted["status"] != "submitted" - || submitted["accepted_chunks"] != 1 - || submitted["rejected_chunks"] != 0 - { - return Err(format!("policy.local did not submit one rule: {submitted}")); - } - - let state = - request_policy_local(runner, &name, &format!("/v1/proposals/{chunk_id}")).await?; - if state["chunk_id"] != chunk_id - || state["rule_name"] != rule_name - || state["binary"] != binary - || !matches!(state["status"].as_str(), Some("pending" | "approved")) - { - return Err(format!("policy.local returned the wrong proposal: {state}")); - } - - let review = runner - .step("reviewer-inbox") - .description("the requested rule is visible to the reviewer") - .with_timeout(COMMAND_TIMEOUT) - .run(&["rule", "get", &name]) - .await - .map_err(|error| error.to_string())?; - review.require_success()?; - if !review.stdout().contains(&format!("Chunk: {chunk_id}")) - || !review.stdout().contains(&format!("Rule: {rule_name}")) - { - return Err(review.failure_diagnostic("the submitted rule is in the reviewer inbox")); - } - Ok(()) - }) -} - -async fn sandbox_bash_path(runner: &OpenShellRunner, name: &str) -> Result { - let result = runner - .step("bash-binary") - .description("the sandbox's Bash executable has a canonical path") - .with_timeout(COMMAND_TIMEOUT) - .run(&[ - "sandbox", - "exec", - "--name", - name, - "--no-tty", - "--", - "bash", - "-c", - "printf '%s\\n' \"$(readlink -f /proc/$$/exe)\"", - ]) - .await - .map_err(|error| error.to_string())?; - result.require_success()?; - let binary = result.stdout().trim(); - if !binary.starts_with('/') - || binary.contains('\n') - || binary.rsplit('/').next() != Some("bash") - { - return Err(result.failure_diagnostic("one absolute Bash executable path ending in /bash")); - } - Ok(binary.to_string()) -} - -async fn enable_proposals(runner: &OpenShellRunner, name: &str) -> Result<(), String> { - let set = runner - .step("enable-policy-advisor") - .description("sandbox-scoped policy advisor setting is enabled") - .with_timeout(COMMAND_TIMEOUT) - .run(&[ - "settings", - "set", - name, - "--key", - "agent_policy_proposals_enabled", - "--value", - "true", - ]) - .await - .map_err(|error| error.to_string())?; - set.require_success()?; - - let settings = runner - .step("effective-settings") - .description("policy advisor setting is effectively enabled") - .with_timeout(COMMAND_TIMEOUT) - .run(&["settings", "get", name, "--json"]) - .await - .map_err(|error| error.to_string())?; - settings.require_success()?; - let value: Value = settings.json().map_err(|error| error.to_string())?; - if value["settings"]["agent_policy_proposals_enabled"]["value"] != "true" { - return Err(settings.failure_diagnostic( - "effective agent_policy_proposals_enabled is true; check for a global override", - )); - } - Ok(()) -} - -async fn request_policy_local( - runner: &OpenShellRunner, - sandbox: &str, - path: &str, -) -> Result { - request_policy_local_http(runner, sandbox, "GET", path, "", 200).await -} - -async fn request_policy_local_http( - runner: &OpenShellRunner, - sandbox: &str, - method: &str, - path: &str, - body: &str, - expected_status: u16, -) -> Result { - let script = "method=$1; path=$2; body=$3; exec 3<>/dev/tcp/policy.local/80 || exit 1; printf '%s %s HTTP/1.1\\r\\nHost: policy.local\\r\\nContent-Type: application/json\\r\\nContent-Length: %s\\r\\nConnection: close\\r\\n\\r\\n' \"$method\" \"$path\" \"${#body}\" >&3; printf '%s' \"$body\" >&3; cat <&3"; - let result = runner - .step(format!("policy-local-{method}{path}")) - .description(format!( - "{method} http://policy.local{path} succeeds from the sandbox" - )) - .with_timeout(COMMAND_TIMEOUT) - .run(&[ - "sandbox", - "exec", - "--name", - sandbox, - "--no-tty", - "--", - "bash", - "-c", - script, - "policy-local-http", - method, - path, - body, - ]) - .await - .map_err(|error| error.to_string())?; - result.require_success()?; - let (headers, body) = result.stdout().split_once("\r\n\r\n").ok_or_else(|| { - result.failure_diagnostic("a complete HTTP response with headers and JSON body") - })?; - if !headers.starts_with(&format!("HTTP/1.1 {expected_status} ")) - || !headers.lines().any(|line| { - line.to_ascii_lowercase() - .starts_with("content-type: application/json") - }) - { - return Err( - result.failure_diagnostic(&format!("HTTP {expected_status} with JSON Content-Type")) - ); - } - serde_json::from_str(body) - .map_err(|error| result.failure_diagnostic(&format!("valid JSON body: {error}"))) -} - -fn run_mechanistic_proposal(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { - Box::pin(async move { - let mut policy = NamedTempFile::new().map_err(|error| error.to_string())?; - policy - .write_all(EMPTY_NETWORK_POLICY) - .map_err(|error| error.to_string())?; - let policy_path = policy - .path() - .to_str() - .ok_or("temporary policy path is not UTF-8")?; - let name = format!("ct-{}-mp", runner.id()); - create_sandbox(runner, &name, Some(policy_path)).await?; - enable_proposals(runner, &name).await?; - - let effective = runner - .step("effective-policy") - .description("sandbox has no network allow rules before the probe") - .with_timeout(COMMAND_TIMEOUT) - .run(&["policy", "get", &name, "--full", "--output", "json"]) - .await - .map_err(|error| error.to_string())?; - effective.require_success()?; - let value: Value = effective.json().map_err(|error| error.to_string())?; - let network_rules = &value["policy"]["network_policies"]; - if !network_rules.is_null() - && !network_rules - .as_object() - .is_some_and(serde_json::Map::is_empty) - { - return Err(effective.failure_diagnostic("effective network_policies is empty")); - } - - let probe = runner - .step("denied-tcp-open") - .description("one Bash TCP open to 1.1.1.1:443 is denied by policy") - .with_timeout(COMMAND_TIMEOUT) - .run(&[ - "sandbox", - "exec", - "--name", - &name, - "--no-tty", - "--", - "bash", - "-c", - "printf 'BINARY=%s\\n' \"$(readlink -f /proc/$$/exe)\"; if exec 3<>/dev/tcp/1.1.1.1/443; then echo UNEXPECTED_ALLOWED; exit 1; else echo DENIED; fi", - ]) - .await - .map_err(|error| error.to_string())?; - probe.require_success()?; - let binary = probe - .stdout() - .lines() - .find_map(|line| line.strip_prefix("BINARY=")) - .filter(|binary| binary.starts_with('/') && binary.rsplit('/').next() == Some("bash")) - .ok_or_else(|| probe.failure_diagnostic("canonical Bash executable path is reported"))? - .to_string(); - if !probe.stdout().lines().any(|line| line == "DENIED") { - return Err(probe.failure_diagnostic("TCP open is denied before any upstream dial")); - } - - await_mechanistic_draft( - runner, - &name, - &ExpectedDraft { - rule: "allow_1_1_1_1_443", - endpoint: "1.1.1.1:443", - binary: &binary, - }, - probe.stderr(), - ) - .await - }) -} - -fn run_new_hostname_proposal(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { - Box::pin(async move { - let mut policy = NamedTempFile::new().map_err(|error| error.to_string())?; - policy - .write_all(EMPTY_NETWORK_POLICY) - .map_err(|error| error.to_string())?; - let policy_path = policy - .path() - .to_str() - .ok_or("temporary policy path is not UTF-8")?; - let name = format!("ct-{}-nh", runner.id()); - create_sandbox(runner, &name, Some(policy_path)).await?; - let binary = sandbox_bash_path(runner, &name).await?; - - let probe = runner - .step("denied-new-hostname") - .description("Bash cannot connect to pypi.org:80 before approval") - .with_timeout(COMMAND_TIMEOUT) - .run(&[ - "sandbox", - "exec", - "--name", - &name, - "--no-tty", - "--", - "bash", - "-c", - "if exec 3<>/dev/tcp/pypi.org/80; then echo UNEXPECTED_ALLOWED; exit 1; else echo DENIED; fi", - ]) - .await - .map_err(|error| error.to_string())?; - probe.require_success()?; - if !probe.stdout().lines().any(|line| line == "DENIED") { - return Err(probe.failure_diagnostic("new hostname stays denied before approval")); - } - - await_mechanistic_draft( - runner, - &name, - &ExpectedDraft { - rule: "allow_pypi_org_80", - endpoint: "pypi.org:80", - binary: &binary, - }, - probe.stderr(), - ) - .await - }) -} - -/// The single L4 mechanistic draft expected for one denied endpoint. -struct ExpectedDraft<'a> { - rule: &'a str, - endpoint: &'a str, - binary: &'a str, -} - -/// Poll the reviewer inbox until a draft appears, tolerating transient read -/// failures, then require that it is the single expected draft. -async fn await_mechanistic_draft( - runner: &OpenShellRunner, - sandbox: &str, - expected: &ExpectedDraft<'_>, - probe_stderr: &str, -) -> Result<(), String> { - let started = Instant::now(); - loop { - let draft = runner - .step("mechanistic-draft") - .description("a single scoped mechanistic draft appears") - .with_timeout(COMMAND_TIMEOUT) - .run(&["rule", "get", sandbox]) - .await - .map_err(|error| error.to_string())?; - if !draft.success() { - if started.elapsed() >= PROPOSAL_TIMEOUT { - return Err(draft.failure_diagnostic("the reviewer inbox is readable")); - } - sleep(POLL_INTERVAL).await; - continue; - } - if !draft.stdout().contains("Chunk:") { - if started.elapsed() >= PROPOSAL_TIMEOUT { - return Err(draft.failure_diagnostic(&format!( - "one mechanistic draft for {} and {}; probe stderr:\n{probe_stderr}", - expected.endpoint, expected.binary - ))); - } - sleep(POLL_INTERVAL).await; - continue; - } - return assert_mechanistic_draft(draft.stdout(), expected) - .map_err(|error| draft.failure_diagnostic(&error)); - } -} - -fn assert_mechanistic_draft(output: &str, expected: &ExpectedDraft<'_>) -> Result<(), String> { - let fields = output.lines().map(str::trim).collect::>(); - let field = |name: &str| { - fields - .iter() - .find_map(|line| line.strip_prefix(name).map(str::trim)) - }; - let endpoints = format!("{} [L4]", expected.endpoint); - if fields - .iter() - .filter(|line| line.starts_with("Chunk:")) - .count() - != 1 - || !matches!(field("Status:"), Some("pending" | "approved")) - || field("Rule:") != Some(expected.rule) - || field("Binary:") != Some(expected.binary) - || field("Binaries:") != Some(expected.binary) - || field("Endpoints:") != Some(endpoints.as_str()) - || !field("Rationale:").is_some_and(|value| value.contains(expected.endpoint)) - { - return Err(format!( - "expected one pending or approved L4 mechanistic draft scoped to {} and {}", - expected.binary, expected.endpoint - )); - } - Ok(()) -} - -async fn create_sandbox( - runner: &mut OpenShellRunner, - name: &str, - policy_path: Option<&str>, -) -> Result<(), String> { - runner.track_sandbox(name); - let mut args = vec![ - "sandbox", - "create", - "--name", - name, - "--detach", - "--no-tty", - "--no-auto-providers", - ]; - if let Some(path) = policy_path { - args.extend(["--policy", path]); - } - args.extend(["--", "sh", "-c", "exec sleep infinity"]); - let create = runner - .step("create") - .description(format!("sandbox '{name}' is created")) - .with_timeout(CREATE_TIMEOUT) - .run(&args) - .await - .map_err(|error| error.to_string())?; - create.require_success()?; - - let started = Instant::now(); - loop { - let get = runner - .step("ready") - .description(format!("sandbox '{name}' reaches Ready")) - .with_timeout(COMMAND_TIMEOUT) - .run(&["sandbox", "get", name, "--output", "json"]) - .await - .map_err(|error| error.to_string())?; - if get.success() { - let state: SandboxState = get.json().map_err(|error| error.to_string())?; - if state.name != name { - return Err(get.failure_diagnostic("sandbox get returns the created name")); - } - if state.phase == "Ready" { - return Ok(()); - } - if state.phase == "Failed" { - return Err(get.failure_diagnostic("sandbox reaches Ready instead of Failed")); - } - } - if started.elapsed() >= READY_TIMEOUT { - return Err(get.failure_diagnostic("sandbox reaches Ready before timeout")); - } - sleep(POLL_INTERVAL).await; - } -} - -#[cfg(test)] -mod tests { - use super::{ExpectedDraft, assert_mechanistic_draft}; - - const EXPECTED: ExpectedDraft<'static> = ExpectedDraft { - rule: "allow_pypi_org_80", - endpoint: "pypi.org:80", - binary: "/usr/bin/bash", - }; - - fn draft(binary: &str) -> String { - format!( - "Chunk: id\nStatus: pending\nRule: allow_pypi_org_80\nBinary: {binary}\nRationale: Allow {binary} to connect to pypi.org:80 (HTTP).\nEndpoints: pypi.org:80 [L4]\nBinaries: {binary}\n" - ) - } - - #[test] - fn draft_assertion_accepts_a_hostname_scoped_draft() { - assert!(assert_mechanistic_draft(&draft("/usr/bin/bash"), &EXPECTED).is_ok()); - } - - #[test] - fn draft_assertion_rejects_unrelated_binary() { - assert!(assert_mechanistic_draft(&draft("/usr/bin/sh"), &EXPECTED).is_err()); - } - - #[test] - fn draft_assertion_rejects_fields_spread_across_drafts() { - let drafts = format!( - "{}Chunk: other\nStatus: pending\nRule: allow_1_1_1_1_443\n", - draft("/usr/bin/bash") - ); - assert!(assert_mechanistic_draft(&drafts, &EXPECTED).is_err()); - } -} diff --git a/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs b/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs deleted file mode 100644 index a0b1c0016d..0000000000 --- a/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs +++ /dev/null @@ -1,347 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Portable sandbox lifecycle conformance scenarios. - -use std::collections::HashSet; -use std::time::{Duration, Instant}; - -use serde::Deserialize; - -use crate::{OpenShellRunner, Poll, Scenario, ScenarioFuture}; - -const CREATE_TIMEOUT: Duration = Duration::from_mins(10); -const COMMAND_TIMEOUT: Duration = Duration::from_mins(2); -const TRANSITION_TIMEOUT: Duration = Duration::from_mins(4); -const TRANSITION_INTERVAL: Duration = Duration::from_secs(2); - -#[derive(Debug, Deserialize)] -struct SandboxState { - id: String, - name: String, - phase: String, -} - -#[derive(Debug, Deserialize)] -struct SandboxListPage { - sandboxes: Vec, - next_page_token: String, -} - -/// Certify sandbox stop, start, and deletion lifecycle behavior. -pub const SANDBOX_LIFECYCLE_SCENARIO: Scenario = Scenario { - name: "sandbox-lifecycle", - run: run_sandbox_lifecycle, -}; - -fn run_sandbox_lifecycle(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { - Box::pin(async move { - stop_start_preserves_workspace(runner).await?; - stopped_can_be_deleted(runner).await - }) -} - -async fn stop_start_preserves_workspace(runner: &mut OpenShellRunner) -> Result<(), String> { - let sandbox_name = format!("ct-{}-ss", runner.id()); - let sentinel = format!("openshell-stop-start-{}", runner.id()); - let sentinel_path = "/sandbox/.openshell-stop-start-sentinel"; - let run_count_path = "/sandbox/.openshell-main-run-count"; - let main = format!( - "count=0; test ! -f '{run_count_path}' || count=$(cat '{run_count_path}'); \ - count=$((count + 1)); printf '%s\\n' \"$count\" > '{run_count_path}'; \ - exec sleep infinity" - ); - - create_running_sandbox(runner, &sandbox_name, &main, "stop-start").await?; - exec_expect_exact( - runner, - &sandbox_name, - "write-sentinel", - &[ - "sh", - "-lc", - &format!("printf '%s\\n' '{sentinel}' > '{sentinel_path}' && sync"), - ], - "", - ) - .await?; - - run_lifecycle_command(runner, "stop", &sandbox_name, "stop-start/stop").await?; - wait_for_phase(runner, &sandbox_name, "Stopped", "stop-start/stopped").await?; - - let stopped_exec = runner - .step("stop-start/exec-while-stopped") - .description(format!( - "sandbox '{sandbox_name}' rejects exec while stopped" - )) - .with_timeout(COMMAND_TIMEOUT) - .run(&[ - "sandbox", - "exec", - "--name", - &sandbox_name, - "--no-tty", - "--", - "cat", - sentinel_path, - ]) - .await - .map_err(|error| error.to_string())?; - if stopped_exec.success() { - return Err( - stopped_exec.failure_diagnostic("sandbox exec fails while the sandbox is stopped") - ); - } - - run_lifecycle_command(runner, "start", &sandbox_name, "stop-start/start").await?; - wait_for_phase(runner, &sandbox_name, "Ready", "stop-start/restarted").await?; - - exec_expect_exact( - runner, - &sandbox_name, - "read-sentinel", - &["cat", sentinel_path], - &format!("{sentinel}\n"), - ) - .await?; - exec_expect_exact( - runner, - &sandbox_name, - "read-main-run-count", - &["cat", run_count_path], - "2\n", - ) - .await -} - -async fn stopped_can_be_deleted(runner: &mut OpenShellRunner) -> Result<(), String> { - let sandbox_name = format!("ct-{}-sd", runner.id()); - create_running_sandbox( - runner, - &sandbox_name, - "exec sleep infinity", - "stopped-delete", - ) - .await?; - - run_lifecycle_command(runner, "stop", &sandbox_name, "stopped-delete/stop").await?; - let sandbox = - wait_for_phase(runner, &sandbox_name, "Stopped", "stopped-delete/stopped").await?; - run_lifecycle_command(runner, "delete", &sandbox_name, "stopped-delete/delete").await?; - wait_for_absence(runner, &sandbox.id, &sandbox_name, "stopped-delete/deleted").await?; - runner.forget_sandbox(&sandbox_name); - Ok(()) -} - -async fn create_running_sandbox( - runner: &mut OpenShellRunner, - sandbox_name: &str, - main: &str, - step: &str, -) -> Result<(), String> { - runner.track_sandbox(sandbox_name); - let create = runner - .step(format!("{step}/create")) - .description(format!("sandbox '{sandbox_name}' is created")) - .with_timeout(CREATE_TIMEOUT) - .run(&[ - "sandbox", - "create", - "--name", - sandbox_name, - "--detach", - "--no-tty", - "--", - "sh", - "-lc", - main, - ]) - .await - .map_err(|error| error.to_string())?; - create.require_success()?; - wait_for_phase(runner, sandbox_name, "Ready", &format!("{step}/ready")) - .await - .map(|_| ()) -} - -async fn run_lifecycle_command( - runner: &OpenShellRunner, - operation: &str, - sandbox_name: &str, - step: &str, -) -> Result<(), String> { - let result = runner - .step(step) - .description(format!("sandbox '{sandbox_name}' {operation} succeeds")) - .with_timeout(COMMAND_TIMEOUT) - .run(&["sandbox", operation, sandbox_name]) - .await - .map_err(|error| error.to_string())?; - result.require_success() -} - -async fn exec_expect_exact( - runner: &OpenShellRunner, - sandbox_name: &str, - step: &str, - command: &[&str], - expected_stdout: &str, -) -> Result<(), String> { - let mut args = vec!["sandbox", "exec", "--name", sandbox_name, "--no-tty", "--"]; - args.extend_from_slice(command); - let result = runner - .step(format!("stop-start/{step}")) - .description(format!("sandbox '{sandbox_name}' exec {step} succeeds")) - .with_timeout(COMMAND_TIMEOUT) - .run(&args) - .await - .map_err(|error| error.to_string())?; - result.require_success()?; - if result.stdout() == expected_stdout { - Ok(()) - } else { - Err(result.failure_diagnostic(&format!("stdout is exactly {expected_stdout:?}"))) - } -} - -async fn wait_for_phase( - runner: &mut OpenShellRunner, - sandbox_name: &str, - expected_phase: &str, - step: &str, -) -> Result { - let sandbox_name = sandbox_name.to_string(); - let expected_phase = expected_phase.to_string(); - let step = step.to_string(); - let poll_step = step.clone(); - runner - .poll_until( - &poll_step, - TRANSITION_TIMEOUT, - TRANSITION_INTERVAL, - async move |runner| { - let result = runner - .step(format!("{step}/get")) - .description(format!( - "sandbox '{sandbox_name}' reaches phase {expected_phase}" - )) - .with_timeout(COMMAND_TIMEOUT) - .run(&["sandbox", "get", &sandbox_name, "--output", "json"]) - .await; - match result { - Ok(result) if !result.success() => { - Poll::Pending(result.failure_diagnostic(&format!( - "sandbox '{sandbox_name}' can be retrieved" - ))) - } - Ok(result) => match result.json::() { - Ok(state) if state.name != sandbox_name => Poll::Failed(format!( - "sandbox get returned {:?}; expected '{sandbox_name}'", - state.name - )), - Ok(state) if state.phase == expected_phase => Poll::Ready(state), - Ok(state) => Poll::Pending(format!( - "sandbox '{sandbox_name}' phase is {:?}; expected {expected_phase:?}", - state.phase - )), - Err(error) => Poll::Failed(error.to_string()), - }, - Err(error) => Poll::Pending(error.to_string()), - } - }, - ) - .await - .map_err(|error| error.to_string()) -} - -async fn wait_for_absence( - runner: &mut OpenShellRunner, - sandbox_id: &str, - sandbox_name: &str, - step: &str, -) -> Result<(), String> { - let sandbox_id = sandbox_id.to_string(); - let sandbox_name = sandbox_name.to_string(); - let step = step.to_string(); - let poll_step = step.clone(); - runner - .poll_until( - &poll_step, - TRANSITION_TIMEOUT, - TRANSITION_INTERVAL, - async move |runner| match sandbox_is_listed(runner, &sandbox_id, &sandbox_name, &step) - .await - { - Ok(false) => Poll::Ready(()), - Ok(true) => Poll::Pending(format!( - "sandbox '{sandbox_name}' with ID '{sandbox_id}' is still listed" - )), - Err(error) => Poll::Pending(error), - }, - ) - .await - .map_err(|error| error.to_string()) -} - -async fn sandbox_is_listed( - runner: &OpenShellRunner, - sandbox_id: &str, - sandbox_name: &str, - step: &str, -) -> Result { - let deadline = Instant::now() + COMMAND_TIMEOUT; - let mut seen_page_tokens = HashSet::new(); - let mut page_token = String::new(); - let mut page = 0u32; - loop { - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - return Err(format!( - "sandbox list observation for '{sandbox_name}' exceeded its {COMMAND_TIMEOUT:?} deadline" - )); - } - - let result = runner - .step(format!("{step}/list/{page}")) - .description(format!( - "sandbox list confirms whether '{sandbox_name}' with ID '{sandbox_id}' still exists" - )) - .with_timeout(remaining) - .run(&[ - "sandbox", - "list", - "--page-size", - "1000", - "--page-token", - &page_token, - "--output", - "json", - ]) - .await - .map_err(|error| error.to_string())?; - result.require_success()?; - - let response = result - .json::() - .map_err(|error| error.to_string())?; - if response - .sandboxes - .iter() - .any(|sandbox| sandbox.id == sandbox_id) - { - return Ok(true); - } - if response.next_page_token.is_empty() { - return Ok(false); - } - if !seen_page_tokens.insert(response.next_page_token.clone()) { - return Err(format!( - "sandbox list returned a repeated page token while looking for '{sandbox_name}'" - )); - } - page_token = response.next_page_token; - page = page - .checked_add(1) - .ok_or_else(|| "sandbox list page counter overflowed".to_string())?; - } -} diff --git a/crates/openshell-conformance/src/scenarios/smoke.rs b/crates/openshell-conformance/src/scenarios/smoke.rs deleted file mode 100644 index 0f627b7fa2..0000000000 --- a/crates/openshell-conformance/src/scenarios/smoke.rs +++ /dev/null @@ -1,205 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Portable phase-1 CLI conformance scenario. - -use std::time::{Duration, Instant}; - -use crate::{OpenShellRunner, STATUS_TIMEOUT, Scenario, ScenarioFuture}; -use serde::Deserialize; -use tokio::time::sleep; - -const CREATE_TIMEOUT: Duration = Duration::from_mins(10); -const LIST_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(10); -const LIST_PAGE_SIZE: u32 = 1_000; -const EXEC_TIMEOUT: Duration = Duration::from_mins(2); -const DELETE_TIMEOUT: Duration = Duration::from_mins(2); -const DELETE_POLL_INTERVAL: Duration = Duration::from_secs(1); - -#[derive(Debug, Deserialize)] -struct SandboxListEntry { - name: String, - phase: String, -} - -#[derive(Debug, Deserialize)] -struct SandboxListPage { - sandboxes: Vec, - next_page_token: String, -} - -/// Certify status -> create -> list Ready -> exec -> delete -> list empty. -pub const SMOKE_SCENARIO: Scenario = Scenario { - name: "smoke", - run: run_smoke, -}; - -fn run_smoke(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { - Box::pin(async move { run_smoke_inner(runner).await }) -} - -async fn run_smoke_inner(runner: &mut OpenShellRunner) -> Result<(), String> { - let status = runner - .step("status") - .description("openshell status succeeds") - .with_timeout(STATUS_TIMEOUT) - .run(&["status"]) - .await - .map_err(|error| error.to_string())?; - status.require_success()?; - - let sandbox_name = format!("ct-{}-01", runner.id()); - runner.track_sandbox(&sandbox_name); - let create = runner - .step("create") - .description("sandbox creation succeeds") - .with_timeout(CREATE_TIMEOUT) - .run(&["sandbox", "create", "--name", &sandbox_name, "--detach"]) - .await - .map_err(|error| error.to_string())?; - create.require_success()?; - - let get = runner - .step("get-ready") - .description(format!("sandbox '{sandbox_name}' can be retrieved")) - .with_timeout(LIST_ATTEMPT_TIMEOUT) - .run(&["sandbox", "get", &sandbox_name, "--output", "json"]) - .await - .map_err(|error| error.to_string())?; - get.require_success()?; - - let sandbox = get - .json::() - .map_err(|error| error.to_string())?; - if sandbox.name != sandbox_name { - return Err(format!( - "sandbox get returned {:?}; expected sandbox '{sandbox_name}'", - sandbox.name - )); - } - if sandbox.phase != "Ready" { - return Err(format!( - "sandbox '{sandbox_name}' is in phase {:?}; expected Ready", - sandbox.phase - )); - } - - check_sandbox_listed(runner, &sandbox_name).await?; - - let marker = format!("openshell-conformance-{}", runner.id()); - let exec = runner - .step("exec") - .description("sandbox exec exits successfully") - .with_timeout(EXEC_TIMEOUT) - .run(&[ - "sandbox", - "exec", - "--name", - &sandbox_name, - "--no-tty", - "--", - "echo", - &marker, - ]) - .await - .map_err(|error| error.to_string())?; - exec.require_success()?; - let expected_stdout = format!("{marker}\n"); - if exec.stdout() != expected_stdout { - return Err(exec.failure_diagnostic(&format!("stdout is exactly {expected_stdout:?}"))); - } - - let delete = runner - .step("delete") - .description("sandbox deletion succeeds") - .with_timeout(DELETE_TIMEOUT) - .run(&["sandbox", "delete", &sandbox_name]) - .await - .map_err(|error| error.to_string())?; - delete.require_success()?; - - check_empty_list(runner, &sandbox_name).await?; - runner.forget_sandbox(&sandbox_name); - Ok(()) -} - -async fn check_sandbox_listed(runner: &OpenShellRunner, sandbox_name: &str) -> Result<(), String> { - if find_sandbox(runner, sandbox_name, "list-visible") - .await? - .is_some() - { - return Ok(()); - } - Err(format!( - "sandbox '{sandbox_name}' does not appear in sandbox list" - )) -} - -async fn check_empty_list(runner: &OpenShellRunner, sandbox_name: &str) -> Result<(), String> { - let started = Instant::now(); - - loop { - match find_sandbox(runner, sandbox_name, "list-empty/query").await? { - None => return Ok(()), - Some(sandbox) if started.elapsed() >= DELETE_TIMEOUT => { - return Err(format!( - "sandbox '{sandbox_name}' remains listed in phase {:?} after {DELETE_TIMEOUT:.1?}", - sandbox.phase - )); - } - Some(_) => sleep(DELETE_POLL_INTERVAL).await, - } - } -} - -async fn find_sandbox( - runner: &OpenShellRunner, - sandbox_name: &str, - step: &str, -) -> Result, String> { - let mut page_token = String::new(); - let mut page = 0u32; - - loop { - let page_size = LIST_PAGE_SIZE.to_string(); - let result = runner - .step(format!("{step}/{page}")) - .description(format!("sandbox list page {page} succeeds")) - .with_timeout(LIST_ATTEMPT_TIMEOUT) - .run(&[ - "sandbox", - "list", - "--page-size", - &page_size, - "--page-token", - &page_token, - "--output", - "json", - ]) - .await - .map_err(|error| error.to_string())?; - result.require_success()?; - - let response = result - .json::() - .map_err(|error| error.to_string())?; - if let Some(sandbox) = response - .sandboxes - .iter() - .find(|sandbox| sandbox.name == sandbox_name) - { - return Ok(Some(SandboxListEntry { - name: sandbox.name.clone(), - phase: sandbox.phase.clone(), - })); - } - if response.next_page_token.is_empty() { - return Ok(None); - } - - page_token = response.next_page_token; - page = page - .checked_add(1) - .ok_or_else(|| "sandbox list page counter overflowed".to_string())?; - } -} diff --git a/e2e/rust/tests/podman_oci_identity.rs b/e2e/rust/tests/podman_oci_identity.rs index 6b137d1a2b..020d8f48c1 100644 --- a/e2e/rust/tests/podman_oci_identity.rs +++ b/e2e/rust/tests/podman_oci_identity.rs @@ -11,7 +11,7 @@ //! from it, and inspects the real supervisor container. The workload's image //! ID, identity, network, and mounts, and what the main process and //! `sandbox exec` see, are shared with Docker and covered by the `oci-image` -//! feature suite in `tests/suites/features`. +//! suite in `tests/suites/sandbox/oci-image`. use std::process::Stdio; diff --git a/e2e/support/conformance.sh b/e2e/support/conformance.sh index 9fc898b057..0c42643135 100644 --- a/e2e/support/conformance.sh +++ b/e2e/support/conformance.sh @@ -2,8 +2,9 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Shared helpers for running conformance suites against an already -# configured e2e gateway. +# Shared helpers for running the testkit sandbox suites against an already +# configured e2e gateway. testkit finds the gateway the way the CLI does, through +# OPENSHELL_GATEWAY_ENDPOINT, OPENSHELL_GATEWAY, or the active gateway. e2e_require_openshell_bin() { if [ -z "${OPENSHELL_BIN:-}" ]; then @@ -30,15 +31,15 @@ e2e_run_openshell_conformance() { e2e_require_openshell_bin || return - echo "==> Running conformance suites against the ${gateway_label} gateway" - cargo test \ + echo "==> Running testkit sandbox suites against the ${gateway_label} gateway" + cargo nextest run \ --locked \ - --manifest-path "${root}/tests/suites/conformance/Cargo.toml" \ - --package openshell-test-conformance-cli \ + --manifest-path "${root}/tests/suites/sandbox/Cargo.toml" \ + --package openshell-test-sandbox-lifecycle \ + --package openshell-test-sandbox-file-transfer \ + --package openshell-test-sandbox-policy-advisor \ --no-fail-fast \ - -- \ - --test-threads=1 \ - --nocapture + --test-threads 1 } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then diff --git a/tasks/scripts/check_cargo_lockfile_alignment.py b/tasks/scripts/check_cargo_lockfile_alignment.py index 1cf953711a..227617bd80 100644 --- a/tasks/scripts/check_cargo_lockfile_alignment.py +++ b/tasks/scripts/check_cargo_lockfile_alignment.py @@ -35,7 +35,12 @@ # Workspaces whose shared crates must match the root lockfile. Add a # workspace here once its lockfile has been aligned with `--fix`. -ALIGNED_WORKSPACES = ("tests/testkit", "tests/suites/sandbox") +ALIGNED_WORKSPACES = ( + "tests/testkit", + "tests/suites/sandbox", + "tests/suites/providers", + "tests/suites/podman", +) @dataclass(frozen=True) diff --git a/tasks/test.toml b/tasks/test.toml index 3e642feb96..6cff1fd784 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -159,19 +159,19 @@ run = "e2e/rust/e2e-docker.sh" ["e2e:testkit"] description = "Run the testkit suites against a Docker-backed gateway" -run = "e2e/with-docker-gateway.sh cargo test --locked --manifest-path tests/suites/sandbox/Cargo.toml --workspace -- --test-threads 1 --nocapture" +run = "e2e/with-docker-gateway.sh cargo test --locked --manifest-path tests/suites/sandbox/Cargo.toml --workspace --exclude openshell-test-sandbox-oci-image -- --test-threads 1 --nocapture" ["e2e:oci-image"] description = "Run the shared OCI image feature suite against a Docker-backed gateway" -run = "OPENSHELL_TEST_CONTAINER_ENGINE=docker e2e/with-docker-gateway.sh cargo test --locked --manifest-path tests/suites/features/Cargo.toml -p openshell-test-feature-oci-image -- --test-threads 1 --nocapture" +run = "OPENSHELL_TEST_CONTAINER_ENGINE=docker e2e/with-docker-gateway.sh cargo nextest run --locked --manifest-path tests/suites/sandbox/Cargo.toml -p openshell-test-sandbox-oci-image --test-threads 1 --no-fail-fast" ["e2e:oci-image:podman"] description = "Run the shared OCI image feature suite against a Podman-backed gateway" -run = "OPENSHELL_TEST_CONTAINER_ENGINE=podman e2e/with-podman-gateway.sh cargo test --locked --manifest-path tests/suites/features/Cargo.toml -p openshell-test-feature-oci-image -- --test-threads 1 --nocapture" +run = "OPENSHELL_TEST_CONTAINER_ENGINE=podman e2e/with-podman-gateway.sh cargo nextest run --locked --manifest-path tests/suites/sandbox/Cargo.toml -p openshell-test-sandbox-oci-image --test-threads 1 --no-fail-fast" ["e2e:testkit:podman"] description = "Run the testkit suites against a Podman-backed gateway" -run = "e2e/with-podman-gateway.sh cargo test --locked --manifest-path tests/suites/sandbox/Cargo.toml --workspace -- --test-threads 1 --nocapture" +run = "e2e/with-podman-gateway.sh cargo test --locked --manifest-path tests/suites/sandbox/Cargo.toml --workspace --exclude openshell-test-sandbox-oci-image -- --test-threads 1 --nocapture" ["e2e:rust:push"] description = "Run CLI conformance and policy e2e tests against a Docker gateway that pushes configuration" diff --git a/tests/suites/conformance/Cargo.lock b/tests/suites/conformance/Cargo.lock deleted file mode 100644 index 89d8c0d392..0000000000 --- a/tests/suites/conformance/Cargo.lock +++ /dev/null @@ -1,437 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "bitflags" -version = "2.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "mio" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" -dependencies = [ - "libc", - "wasi", - "windows-sys", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "openshell-conformance" -version = "0.0.0" -dependencies = [ - "rand", - "serde", - "serde_json", - "tempfile", - "tokio", -] - -[[package]] -name = "openshell-test-conformance-cli" -version = "0.0.0" -dependencies = [ - "openshell-conformance", - "tokio", -] - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "rustix" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "smallvec" -version = "1.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" - -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom", - "once_cell", - "rustix", - "windows-sys", -] - -[[package]] -name = "tokio" -version = "1.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys", -] - -[[package]] -name = "tokio-macros" -version = "2.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "zerocopy" -version = "0.8.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tests/suites/conformance/Cargo.toml b/tests/suites/conformance/Cargo.toml deleted file mode 100644 index 1c6b001096..0000000000 --- a/tests/suites/conformance/Cargo.toml +++ /dev/null @@ -1,6 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -[workspace] -resolver = "2" -members = ["cli"] diff --git a/tests/suites/conformance/cli/Cargo.toml b/tests/suites/conformance/cli/Cargo.toml deleted file mode 100644 index 5edcefe687..0000000000 --- a/tests/suites/conformance/cli/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -[package] -name = "openshell-test-conformance-cli" -version = "0.0.0" -edition = "2024" -rust-version = "1.94" -license = "Apache-2.0" -repository = "https://github.com/NVIDIA/OpenShell" - -[dependencies] -openshell-conformance = { path = "../../../../crates/openshell-conformance" } -tokio = { version = "1.43", features = ["macros", "rt"] } diff --git a/tests/suites/conformance/cli/tests/file_transfer.rs b/tests/suites/conformance/cli/tests/file_transfer.rs deleted file mode 100644 index 167506b87b..0000000000 --- a/tests/suites/conformance/cli/tests/file_transfer.rs +++ /dev/null @@ -1,40 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Driver-agnostic sandbox file-transfer conformance tests. - -use openshell_conformance::{ - FILE_TRANSFER_GIT_FILTERING_SCENARIO, FILE_TRANSFER_PATH_SAFETY_SCENARIO, - FILE_TRANSFER_ROUND_TRIP_SCENARIO, OpenShellRunner, Scenario, -}; - -/// Exercise file and directory round trips through the candidate CLI. -#[tokio::test] -async fn round_trip() { - run(FILE_TRANSFER_ROUND_TRIP_SCENARIO).await; -} - -/// Exercise Git-aware upload filtering through the candidate CLI. -#[tokio::test] -async fn git_filtering() { - run(FILE_TRANSFER_GIT_FILTERING_SCENARIO).await; -} - -/// Exercise workspace boundary and filename safety through the candidate CLI. -#[tokio::test] -async fn path_safety() { - run(FILE_TRANSFER_PATH_SAFETY_SCENARIO).await; -} - -async fn run(scenario: Scenario) { - let mut runner = OpenShellRunner::from_env(scenario.name) - .expect("candidate openshell CLI is available"); - let result = async { - runner.check_gateway_status().await?; - scenario.run(&mut runner).await - } - .await; - if let Err(error) = runner.finish(result).await { - panic!("{} conformance scenario failed:\n{error}", scenario.name); - } -} diff --git a/tests/suites/conformance/cli/tests/lifecycle.rs b/tests/suites/conformance/cli/tests/lifecycle.rs deleted file mode 100644 index 1a18adef86..0000000000 --- a/tests/suites/conformance/cli/tests/lifecycle.rs +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Driver-agnostic sandbox lifecycle conformance tests. - -use openshell_conformance::{OpenShellRunner, SANDBOX_LIFECYCLE_SCENARIO}; - -/// Exercise stop, start, and stopped-deletion behavior through the candidate CLI. -#[tokio::test] -async fn sandbox_lifecycle() { - let mut runner = OpenShellRunner::from_env(SANDBOX_LIFECYCLE_SCENARIO.name) - .expect("candidate openshell CLI is available"); - - let result = async { - runner.check_gateway_status().await?; - SANDBOX_LIFECYCLE_SCENARIO.run(&mut runner).await - } - .await; - if let Err(error) = runner.finish(result).await { - panic!("sandbox lifecycle conformance scenario failed:\n{error}"); - } -} diff --git a/tests/suites/conformance/cli/tests/policy_advisor.rs b/tests/suites/conformance/cli/tests/policy_advisor.rs deleted file mode 100644 index 7da2186dd7..0000000000 --- a/tests/suites/conformance/cli/tests/policy_advisor.rs +++ /dev/null @@ -1,37 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Installed-artifact policy advisor conformance. - -use openshell_conformance::{ - MECHANISTIC_PROPOSAL_SCENARIO, NEW_HOSTNAME_PROPOSAL_SCENARIO, OpenShellRunner, - POLICY_LOCAL_SCENARIO, Scenario, -}; - -async fn run(scenario: &'static Scenario) { - let mut runner = - OpenShellRunner::from_env(scenario.name).expect("candidate openshell CLI is available"); - let result = async { - runner.check_gateway_status().await?; - scenario.run(&mut runner).await - } - .await; - if let Err(error) = runner.finish(result).await { - panic!("{} conformance scenario failed:\n{error}", scenario.name); - } -} - -#[tokio::test] -async fn mechanistic_proposal() { - run(&MECHANISTIC_PROPOSAL_SCENARIO).await; -} - -#[tokio::test] -async fn new_hostname_proposal() { - run(&NEW_HOSTNAME_PROPOSAL_SCENARIO).await; -} - -#[tokio::test] -async fn policy_local() { - run(&POLICY_LOCAL_SCENARIO).await; -} diff --git a/tests/suites/conformance/cli/tests/smoke.rs b/tests/suites/conformance/cli/tests/smoke.rs deleted file mode 100644 index 55306e852f..0000000000 --- a/tests/suites/conformance/cli/tests/smoke.rs +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Driver-agnostic `OpenShell` CLI conformance tests. - -use openshell_conformance::{OpenShellRunner, SMOKE_SCENARIO}; - -/// Exercise the public CLI against a provisioned `OpenShell` gateway. -/// -/// The test runner supplies the candidate CLI explicitly so the same archive -/// can validate artifacts installed into any supported test guest. -#[tokio::test] -async fn smoke() { - let mut runner = OpenShellRunner::from_env(SMOKE_SCENARIO.name) - .expect("candidate openshell CLI is available"); - let result = async { - runner.check_gateway_status().await?; - SMOKE_SCENARIO.run(&mut runner).await - } - .await; - if let Err(error) = runner.finish(result).await { - panic!("conformance smoke scenario failed:\n{error}"); - } -} diff --git a/tests/suites/drivers/Cargo.lock b/tests/suites/drivers/Cargo.lock deleted file mode 100644 index 6b013452fe..0000000000 --- a/tests/suites/drivers/Cargo.lock +++ /dev/null @@ -1,438 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "bitflags" -version = "2.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "mio" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" -dependencies = [ - "libc", - "wasi", - "windows-sys", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "openshell-conformance" -version = "0.0.0" -dependencies = [ - "rand", - "serde", - "serde_json", - "tempfile", - "tokio", -] - -[[package]] -name = "openshell-test-suite-podman" -version = "0.0.0" -dependencies = [ - "openshell-conformance", - "serde", - "tokio", -] - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "rustix" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "smallvec" -version = "1.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" - -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom", - "once_cell", - "rustix", - "windows-sys", -] - -[[package]] -name = "tokio" -version = "1.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys", -] - -[[package]] -name = "tokio-macros" -version = "2.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "zerocopy" -version = "0.8.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tests/suites/drivers/Cargo.toml b/tests/suites/drivers/Cargo.toml deleted file mode 100644 index 0ec76752b2..0000000000 --- a/tests/suites/drivers/Cargo.toml +++ /dev/null @@ -1,6 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -[workspace] -resolver = "2" -members = ["podman"] diff --git a/tests/suites/drivers/podman/Cargo.toml b/tests/suites/drivers/podman/Cargo.toml deleted file mode 100644 index 1a06b69e8d..0000000000 --- a/tests/suites/drivers/podman/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -[package] -name = "openshell-test-suite-podman" -version = "0.0.0" -edition = "2024" -rust-version = "1.94" -license = "Apache-2.0" -repository = "https://github.com/NVIDIA/OpenShell" - -[dependencies] -openshell-conformance = { path = "../../../../crates/openshell-conformance" } -serde = { version = "1", features = ["derive"] } -tokio = { version = "1.43", features = ["macros", "rt"] } diff --git a/tests/suites/drivers/podman/tests/default_userns.rs b/tests/suites/drivers/podman/tests/default_userns.rs deleted file mode 100644 index 95cb8c814f..0000000000 --- a/tests/suites/drivers/podman/tests/default_userns.rs +++ /dev/null @@ -1,140 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Podman-driver user-namespace integration tests. - -mod support; - -use openshell_conformance::OpenShellRunner; -use std::fs; -use std::path::PathBuf; -use std::time::Duration; - -use support::assert_podman_gateway; - -const SANDBOX_TIMEOUT: Duration = Duration::from_secs(300); -const PODMAN_TEST_INPUT_DIR_ENV: &str = "OPENSHELL_TEST_INPUT_DIR"; -const PODMAN_TEST_IMAGE_ENV: &str = "OPENSHELL_PODMAN_TEST_IMAGE"; - -const WORKSPACE_AND_UID_MAP_PROBE: &str = r#"set -eu -workload_owner="$(id -u):$(id -g)" -workspace_owner="$(stat -c '%u:%g' /sandbox)" -printf 'workload-owner=%s\nworkspace-owner=%s\n' "$workload_owner" "$workspace_owner" -test "$(id -u)" -ne 0 -test "$workspace_owner" = "$workload_owner" -probe=$(mktemp /sandbox/userns-probe.XXXXXX) -printf 'workspace probe\n' > "$probe" -rm "$probe" -echo podman-userns-workspace-ok -cat /proc/self/uid_map -"#; - -/// Verify that the gateway's user-namespace configuration matches Podman's -/// direct behavior for the same profile and preserves workspace access. -/// -/// The test runs a short-lived sandbox command and compares its user-namespace -/// mapping with the direct-Podman reference stored at -/// `OPENSHELL_TEST_INPUT_DIR/reference-uid-map`. The tmachine pre-test -/// playbook creates that reference in the same gateway-user context. This deliberately -/// avoids baking a particular Podman mapping into OpenShell's test contract. -/// The workload also verifies that the managed workspace is owned by its -/// non-root UID/GID and that it can create, write, and remove a file there. -#[tokio::test] -async fn configured_userns_matches_podman_reference() { - let mut runner = - OpenShellRunner::from_env("podman-userns").expect("candidate openshell CLI is available"); - let result = async { - runner.check_gateway_status().await?; - assert_podman_gateway(&runner).await?; - - let test_input_dir = std::env::var_os(PODMAN_TEST_INPUT_DIR_ENV) - .map(PathBuf::from) - .ok_or_else(|| format!("{PODMAN_TEST_INPUT_DIR_ENV} must name the Podman test-input directory"))?; - let expected_path = test_input_dir.join("reference-uid-map"); - let expected_uid_map = fs::read_to_string(&expected_path).map_err(|error| { - format!( - "could not read Podman reference UID map {}: {error}", - expected_path.display() - ) - })?; - let expected_uid_map = normalize_uid_map(&expected_uid_map).ok_or_else(|| { - format!( - "Podman reference UID map {} contains no mappings", - expected_path.display() - ) - })?; - - let workload_image = std::env::var(PODMAN_TEST_IMAGE_ENV) - .ok() - .filter(|image| !image.trim().is_empty()); - let sandbox_name = format!("pu-{}", runner.id()); - runner.track_sandbox(&sandbox_name); - let mut create_args = vec!["sandbox", "create", "--name", &sandbox_name]; - if let Some(image) = workload_image.as_deref() { - create_args.extend(["--from", image]); - } - create_args.extend(["--no-tty", "--", "sh", "-c", WORKSPACE_AND_UID_MAP_PROBE]); - let run = runner - .step("userns/workspace-and-uid-map") - .description("sandbox can write to its owned workspace and exposes its UID map") - .with_timeout(SANDBOX_TIMEOUT) - .run(&create_args) - .await - .map_err(|error| error.to_string())?; - run.require_success()?; - if !run.stdout().contains("podman-userns-workspace-ok") { - return Err(run.failure_diagnostic("non-root workload owns and can write to /sandbox")); - } - let sandbox_uid_map = normalize_uid_map(run.stdout()).ok_or_else(|| { - run.failure_diagnostic("sandbox returns a non-empty UID map") - })?; - if sandbox_uid_map != expected_uid_map { - return Err(format!( - "sandbox UID map differs from the direct Podman reference:\nexpected:\n{expected_uid_map}\nactual:\n{sandbox_uid_map}" - )); - } - Ok(()) - } - .await; - - if let Err(error) = runner.finish(result).await { - panic!("Podman userns test failed:\n{error}"); - } -} - -fn normalize_uid_map(value: &str) -> Option { - let mut mappings: Vec<(u64, u64, u64)> = Vec::new(); - for line in value.lines() { - let fields = line - .split_whitespace() - .map(str::parse::) - .collect::, _>>(); - let Ok(fields) = fields else { continue }; - let [inside, outside, length] = fields.as_slice() else { - continue; - }; - if let Some(previous) = mappings.last_mut() - && previous.0.checked_add(previous.2) == Some(*inside) - && previous.1.checked_add(previous.2) == Some(*outside) - { - previous.2 += *length; - } else { - mappings.push((*inside, *outside, *length)); - } - } - (!mappings.is_empty()).then(|| { - mappings - .iter() - .map(|(inside, outside, length)| format!("{inside} {outside} {length}")) - .collect::>() - .join("\n") - }) -} - -#[test] -fn adjacent_uid_ranges_match_a_combined_mapping() { - assert_eq!( - normalize_uid_map("0 0 1\n1 1 65535\n"), - normalize_uid_map("0 0 65536\n") - ); -} diff --git a/tests/suites/drivers/podman/tests/support/mod.rs b/tests/suites/drivers/podman/tests/support/mod.rs deleted file mode 100644 index 5bde19b0c1..0000000000 --- a/tests/suites/drivers/podman/tests/support/mod.rs +++ /dev/null @@ -1,64 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -use openshell_conformance::OpenShellRunner; -use serde::Deserialize; -use std::time::Duration; - -#[derive(Debug, Deserialize)] -struct GatewayInfo { - status: String, - compute_drivers: Vec, -} - -#[derive(Debug, Deserialize)] -struct ComputeDriver { - name: String, - capabilities: ComputeDriverCapabilities, -} - -#[derive(Debug, Deserialize)] -struct ComputeDriverCapabilities { - driver_name: String, -} - -/// Require the target gateway to use Podman as its only compute driver. -/// -/// This interrogates the running gateway rather than accepting a runner -/// environment variable. A driver-specific suite must fail, rather than skip, -/// when it is pointed at the wrong gateway. -pub async fn assert_podman_gateway(runner: &OpenShellRunner) -> Result<(), String> { - let result = runner - .step("preflight/driver-podman") - .description("gateway reports Podman as its only compute driver") - .with_timeout(Duration::from_secs(10)) - .run(&["gateway", "info", "--output", "json"]) - .await - .map_err(|error| format!("could not query gateway compute drivers: {error}"))?; - result.require_success()?; - - let info = result - .json::() - .map_err(|error| format!("gateway returned invalid driver information: {error}"))?; - if info.status != "healthy" { - return Err(format!( - "Podman test suite requires a healthy gateway; gateway status is {:?}", - info.status - )); - } - - let driver_names = info - .compute_drivers - .iter() - .map(|driver| driver.name.as_str()) - .collect::>(); - let podman_only = matches!(info.compute_drivers.as_slice(), [driver] - if driver.name == "podman" && driver.capabilities.driver_name == "podman"); - if !podman_only { - return Err(format!( - "Podman test suite requires exactly one Podman compute driver; gateway reported {driver_names:?}" - )); - } - - Ok(()) -} diff --git a/tests/suites/features/Cargo.toml b/tests/suites/features/Cargo.toml deleted file mode 100644 index 9259450a16..0000000000 --- a/tests/suites/features/Cargo.toml +++ /dev/null @@ -1,6 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -[workspace] -resolver = "2" -members = ["oci-image", "provider-refresh/keycloak"] diff --git a/tests/suites/features/oci-image/Cargo.toml b/tests/suites/features/oci-image/Cargo.toml deleted file mode 100644 index 9ec58feccd..0000000000 --- a/tests/suites/features/oci-image/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -[package] -name = "openshell-test-feature-oci-image" -version = "0.0.0" -edition = "2024" - -[dependencies] -openshell-conformance = { path = "../../../../crates/openshell-conformance" } -serde_json = "1" -tempfile = "3" -tokio = { version = "1.43", features = ["macros", "rt"] } diff --git a/tests/suites/features/provider-refresh/keycloak/Cargo.toml b/tests/suites/features/provider-refresh/keycloak/Cargo.toml deleted file mode 100644 index f42a1c8bd7..0000000000 --- a/tests/suites/features/provider-refresh/keycloak/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -[package] -name = "openshell-test-feature-provider-refresh-keycloak" -version = "0.0.0" -edition = "2024" - -[dependencies] -openshell-conformance = { path = "../../../../../crates/openshell-conformance" } -openshell-e2e = { path = "../../../../../e2e/rust" } -serde_json = "1" -tempfile = "3" -tokio = { version = "1.43", features = ["macros", "process", "io-util", "rt"] } diff --git a/tests/suites/features/provider-refresh/keycloak/tests/provider_refresh.rs b/tests/suites/features/provider-refresh/keycloak/tests/provider_refresh.rs deleted file mode 100644 index 5bc12cf8bb..0000000000 --- a/tests/suites/features/provider-refresh/keycloak/tests/provider_refresh.rs +++ /dev/null @@ -1,340 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -#![cfg(target_os = "linux")] - -//! Provider OAuth refresh recovery against Keycloak. -//! -//! OpenShell itself uses the local gateway's mTLS authentication. Keycloak is -//! only the provider token issuer: the test refreshes a valid grant, revokes -//! its Keycloak session, and verifies that the gateway reports the next -//! refresh as requiring user reauthorization. - -use std::io::Write as _; -use std::process::{Output, Stdio}; - -use openshell_e2e::harness::binary::openshell_cmd; -use serde_json::Value; -use tempfile::{Builder as TempFileBuilder, NamedTempFile}; -use tokio::io::AsyncWriteExt as _; -use tokio::process::Command; - -const PROVIDER_NAME: &str = "e2e-keycloak-refresh"; -const PROFILE_ID: &str = "e2e-keycloak-refresh"; -const CREDENTIAL_KEY: &str = "KEYCLOAK_ACCESS_TOKEN"; - -fn combined_output(output: &Output) -> String { - format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ) -} - -async fn run_cli(args: &[&str], env: &[(&str, &str)]) -> Result { - openshell_cmd() - .args(args) - .env("NO_COLOR", "1") - .envs(env.iter().copied()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await - .map_err(|error| format!("run openshell command: {error}")) -} - -async fn run_cli_success(args: &[&str], env: &[(&str, &str)]) -> Result { - let output = run_cli(args, env).await?; - let combined = combined_output(&output); - if !output.status.success() { - return Err(format!( - "openshell command failed (exit {:?}):\n{combined}", - output.status.code() - )); - } - Ok(combined) -} - -async fn acquire_keycloak_grant( - issuer: &str, - username: &str, - password: &str, -) -> Result<(String, String), String> { - let token_endpoint = format!("{issuer}/protocol/openid-connect/token"); - let username_form = format!("username={username}"); - let password_form = format!("password={password}"); - let output = Command::new("curl") - .args([ - "--fail", - "--silent", - "--show-error", - "--request", - "POST", - &token_endpoint, - "--data-urlencode", - "grant_type=password", - "--data-urlencode", - "client_id=openshell-cli", - "--data-urlencode", - &username_form, - "--data-urlencode", - &password_form, - "--data-urlencode", - "scope=openid", - ]) - .output() - .await - .map_err(|error| format!("request Keycloak grant: {error}"))?; - if !output.status.success() { - return Err(format!( - "Keycloak grant request failed (exit {:?}): {}", - output.status.code(), - String::from_utf8_lossy(&output.stderr) - )); - } - - let response: Value = serde_json::from_slice(&output.stdout) - .map_err(|error| format!("decode Keycloak grant response: {error}"))?; - let access_token = response - .get("access_token") - .and_then(Value::as_str) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "Keycloak grant response omitted access_token".to_string())?; - let refresh_token = response - .get("refresh_token") - .and_then(Value::as_str) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "Keycloak grant response omitted refresh_token".to_string())?; - Ok((access_token.to_string(), refresh_token.to_string())) -} - -async fn revoke_keycloak_grant(issuer: &str, refresh_token: &str) -> Result<(), String> { - let logout_endpoint = format!("{issuer}/protocol/openid-connect/logout"); - let mut child = Command::new("curl") - .args([ - "--fail", - "--silent", - "--show-error", - "--output", - "/dev/null", - "--request", - "POST", - &logout_endpoint, - "--data-urlencode", - "client_id=openshell-cli", - "--data-urlencode", - "refresh_token@-", - ]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|error| format!("start Keycloak logout request: {error}"))?; - child - .stdin - .take() - .ok_or_else(|| "Keycloak logout stdin was not piped".to_string())? - .write_all(refresh_token.as_bytes()) - .await - .map_err(|error| format!("write Keycloak logout request: {error}"))?; - let output = child - .wait_with_output() - .await - .map_err(|error| format!("wait for Keycloak logout request: {error}"))?; - if !output.status.success() { - return Err(format!( - "Keycloak logout failed (exit {:?}): {}", - output.status.code(), - String::from_utf8_lossy(&output.stderr) - )); - } - Ok(()) -} - -fn write_profile(issuer: &str) -> Result { - let mut file = TempFileBuilder::new() - .suffix(".yaml") - .tempfile() - .map_err(|error| format!("create provider profile: {error}"))?; - let profile = format!( - r#"id: {PROFILE_ID} -display_name: Keycloak provider refresh E2E -category: other -credentials: - - name: access_token - env_vars: [{CREDENTIAL_KEY}] - required: true - auth_style: bearer - header_name: authorization - refresh: - strategy: oauth2_refresh_token - token_url: {issuer}/protocol/openid-connect/token - scopes: [openid] - refresh_before_seconds: 60 - max_lifetime_seconds: 3600 - material: - - name: client_id - required: true - - name: refresh_token - required: true - secret: true -endpoints: - - host: keycloak.test.invalid - port: 443 - protocol: rest - access: read-only - enforcement: enforce -binaries: - - /usr/bin/curl -"# - ); - file.write_all(profile.as_bytes()) - .map_err(|error| format!("write provider profile: {error}"))?; - file.flush() - .map_err(|error| format!("flush provider profile: {error}"))?; - Ok(file) -} - -async fn delete_provider_resources() { - let _ = run_cli(&["provider", "delete", PROVIDER_NAME], &[]).await; - let _ = run_cli(&["profile", "delete", PROFILE_ID], &[]).await; -} - -#[tokio::test] -async fn revoked_refresh_grant_requires_user_reauthorization() -> Result<(), String> { - let issuer = std::env::var("OPENSHELL_E2E_OIDC_ISSUER") - .map_err(|_| "OPENSHELL_E2E_OIDC_ISSUER is required".to_string())?; - let username = std::env::var("OPENSHELL_E2E_OIDC_USERNAME") - .map_err(|_| "OPENSHELL_E2E_OIDC_USERNAME is required".to_string())?; - let password = std::env::var("OPENSHELL_E2E_OIDC_PASSWORD") - .map_err(|_| "OPENSHELL_E2E_OIDC_PASSWORD is required".to_string())?; - let (access_token, refresh_token) = - acquire_keycloak_grant(&issuer, &username, &password).await?; - let profile = write_profile(&issuer)?; - let profile_path = profile.path().to_string_lossy().into_owned(); - - delete_provider_resources().await; - let result = async { - run_cli_success( - &["profile", "import", "--file", &profile_path], - &[], - ) - .await?; - run_cli_success( - &[ - "provider", - "create", - "--name", - PROVIDER_NAME, - "--type", - PROFILE_ID, - "--credential", - CREDENTIAL_KEY, - ], - &[(CREDENTIAL_KEY, &access_token)], - ) - .await?; - run_cli_success( - &[ - "provider", - "refresh", - "configure", - PROVIDER_NAME, - "--credential-key", - CREDENTIAL_KEY, - "--strategy", - "oauth2-refresh-token", - "--material", - "client_id=openshell-cli", - "--secret-material-env", - "refresh_token=KEYCLOAK_REFRESH_TOKEN", - ], - &[("KEYCLOAK_REFRESH_TOKEN", &refresh_token)], - ) - .await?; - - run_cli_success( - &[ - "provider", - "refresh", - "rotate", - PROVIDER_NAME, - "--credential-key", - CREDENTIAL_KEY, - ], - &[], - ) - .await?; - let valid_status = run_cli_success( - &[ - "provider", - "refresh", - "status", - PROVIDER_NAME, - "--credential-key", - CREDENTIAL_KEY, - ], - &[], - ) - .await?; - if !valid_status.contains("refreshed") { - return Err(format!( - "valid Keycloak refresh did not reach refreshed state:\n{valid_status}" - )); - } - - revoke_keycloak_grant(&issuer, &refresh_token).await?; - let failed_rotation = run_cli( - &[ - "provider", - "refresh", - "rotate", - PROVIDER_NAME, - "--credential-key", - CREDENTIAL_KEY, - ], - &[], - ) - .await?; - let failed_rotation_output = combined_output(&failed_rotation); - if failed_rotation.status.success() || !failed_rotation_output.contains("invalid_grant") { - return Err(format!( - "revoked Keycloak refresh did not fail with invalid_grant:\n{failed_rotation_output}" - )); - } - - let revoked_status = run_cli_success( - &[ - "provider", - "refresh", - "status", - PROVIDER_NAME, - "--credential-key", - CREDENTIAL_KEY, - ], - &[], - ) - .await?; - for expected in [ - "reauthorization_required", - "reauthorize", - "oauth_invalid_grant", - ] { - if !revoked_status.contains(expected) { - return Err(format!( - "revoked refresh status omitted {expected}:\n{revoked_status}" - )); - } - } - if revoked_status.contains("292278994") { - return Err(format!( - "parked refresh rendered the i64::MAX scheduling sentinel as a date:\n{revoked_status}" - )); - } - Ok(()) - } - .await; - - delete_provider_resources().await; - result -} diff --git a/tests/suites/features/Cargo.lock b/tests/suites/podman/Cargo.lock similarity index 68% rename from tests/suites/features/Cargo.lock rename to tests/suites/podman/Cargo.lock index 3ed9b09a0f..2a306f0fd2 100644 --- a/tests/suites/features/Cargo.lock +++ b/tests/suites/podman/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.12" @@ -17,9 +32,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.5" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -30,11 +45,20 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" -version = "1.0.104" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "asn1-rs" @@ -48,7 +72,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror", + "thiserror 2.0.20", "time", ] @@ -60,8 +84,8 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", - "synstructure 0.13.2", + "syn 2.0.117", + "synstructure", ] [[package]] @@ -72,7 +96,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -106,18 +130,18 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "async-trait" -version = "0.1.92" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 2.0.117", ] [[package]] @@ -137,15 +161,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.18.1" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -154,9 +178,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.45.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ "cc", "cmake", @@ -219,6 +243,30 @@ dependencies = [ "tokio", ] +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + [[package]] name = "base64" version = "0.22.1" @@ -260,61 +308,18 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" dependencies = [ "hybrid-array", ] -[[package]] -name = "bollard" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4" -dependencies = [ - "base64 0.22.1", - "bollard-stubs", - "bytes", - "futures-core", - "futures-util", - "hex", - "http", - "http-body-util", - "hyper", - "hyper-named-pipe", - "hyper-util", - "hyperlocal", - "log", - "pin-project-lite", - "serde", - "serde_derive", - "serde_json", - "serde_urlencoded", - "thiserror", - "tokio", - "tokio-util", - "tower-service", - "url", - "winapi", -] - -[[package]] -name = "bollard-stubs" -version = "1.52.1-rc.29.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066" -dependencies = [ - "serde", - "serde_json", - "serde_repr", -] - [[package]] name = "bumpalo" -version = "3.20.3" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "byteorder" @@ -330,9 +335,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.4.6" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", "jobserver", @@ -348,9 +353,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.2" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" @@ -359,7 +364,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.1", + "cpufeatures 0.3.0", "rand_core 0.10.1", ] @@ -369,8 +374,12 @@ version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ + "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", + "windows-link", ] [[package]] @@ -388,6 +397,15 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -415,9 +433,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ "libc", ] @@ -433,24 +451,24 @@ dependencies = [ [[package]] name = "crc-catalog" -version = "2.5.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" [[package]] name = "crossbeam-queue" -version = "0.3.14" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.23" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crypto-common" @@ -501,7 +519,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -512,14 +530,14 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "data-encoding" -version = "2.11.1" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "der-parser" @@ -540,6 +558,9 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] [[package]] name = "digest" @@ -553,24 +574,24 @@ dependencies = [ [[package]] name = "digest" -version = "0.11.3" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" dependencies = [ - "block-buffer 0.12.1", + "block-buffer 0.12.0", "crypto-common 0.2.2", "ctutils", ] [[package]] name = "displaydoc" -version = "0.2.7" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 2.0.117", ] [[package]] @@ -600,14 +621,14 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "either" -version = "1.18.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" dependencies = [ "serde", ] @@ -660,10 +681,11 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.2" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ + "concurrent-queue", "parking", "pin-project-lite", ] @@ -680,15 +702,21 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.5.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "find-msvc-tools" -version = "0.1.12" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flume" @@ -736,12 +764,13 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", + "futures-executor", "futures-io", "futures-sink", "futures-task", @@ -750,9 +779,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -760,15 +789,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -788,38 +817,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -849,8 +878,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -867,16 +898,30 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.3" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasip2", + "wasip3", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "gloo-timers" version = "0.3.0" @@ -932,9 +977,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] name = "hashlink" @@ -957,7 +1002,7 @@ dependencies = [ "http", "httpdate", "mime", - "sha1 0.10.7", + "sha1 0.10.6", ] [[package]] @@ -996,7 +1041,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest 0.11.3", + "digest 0.11.2", ] [[package]] @@ -1021,9 +1066,9 @@ dependencies = [ [[package]] name = "http" -version = "1.5.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", "itoa", @@ -1031,9 +1076,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.1.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", "http", @@ -1041,9 +1086,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.5" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", @@ -1066,18 +1111,18 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.15" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.11.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -1114,20 +1159,6 @@ dependencies = [ "tower-service", ] -[[package]] -name = "hyper-named-pipe" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf" -dependencies = [ - "hex", - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - [[package]] name = "hyper-rustls" version = "0.27.9" @@ -1164,13 +1195,16 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64 0.22.1", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", "socket2", "tokio", @@ -1179,25 +1213,34 @@ dependencies = [ ] [[package]] -name = "hyperlocal" -version = "0.9.1" +name = "iana-time-zone" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ - "hex", - "http-body-util", - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", ] [[package]] name = "icu_collections" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", @@ -1209,9 +1252,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1222,9 +1265,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1236,17 +1279,16 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1257,15 +1299,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.3.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -1276,6 +1318,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -1295,9 +1343,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.2" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", @@ -1305,16 +1353,38 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.2" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.1", + "hashbrown 0.17.0", "serde", "serde_core", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "itertools" version = "0.14.0" @@ -1332,11 +1402,11 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jobserver" -version = "0.1.35" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom 0.4.3", + "getrandom 0.3.4", "libc", ] @@ -1360,7 +1430,7 @@ dependencies = [ "jsonptr", "serde", "serde_json", - "thiserror", + "thiserror 2.0.20", ] [[package]] @@ -1373,7 +1443,7 @@ dependencies = [ "pest_derive", "regex", "serde_json", - "thiserror", + "thiserror 2.0.20", ] [[package]] @@ -1388,9 +1458,9 @@ dependencies = [ [[package]] name = "jsonwebtoken" -version = "10.4.0" +version = "10.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" dependencies = [ "aws-lc-rs", "base64 0.22.1", @@ -1401,7 +1471,6 @@ dependencies = [ "serde_json", "signature", "simple_asn1", - "zeroize", ] [[package]] @@ -1459,7 +1528,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "thiserror", + "thiserror 2.0.20", "tokio", "tokio-util", "tower", @@ -1482,7 +1551,7 @@ dependencies = [ "serde", "serde-value", "serde_json", - "thiserror", + "thiserror 2.0.20", ] [[package]] @@ -1496,7 +1565,7 @@ dependencies = [ "quote", "serde", "serde_json", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -1521,7 +1590,7 @@ dependencies = [ "pin-project", "serde", "serde_json", - "thiserror", + "thiserror 2.0.20", "tokio", "tokio-util", "tracing", @@ -1533,6 +1602,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.189" @@ -1547,9 +1622,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libsqlite3-sys" -version = "0.37.0" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ "cc", "pkg-config", @@ -1564,9 +1639,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.3" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -1596,14 +1671,44 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest 0.11.3", + "digest 0.11.2", ] [[package]] name = "memchr" -version = "2.8.3" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "mime" @@ -1617,17 +1722,32 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + [[package]] name = "mio" -version = "1.2.3" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "nix" version = "0.29.0" @@ -1656,7 +1776,7 @@ version = "0.0.53" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "962838abbd8dd2cd3844d2208dc502458bba6985f8bf1dc21761b30260fcbd7f" dependencies = [ - "hashbrown 0.17.1", + "hashbrown 0.17.0", "indexmap", "libm", "memchr", @@ -1667,9 +1787,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.8" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", @@ -1677,15 +1797,15 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.2" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-integer" -version = "0.1.47" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ "num-traits", ] @@ -1699,6 +1819,35 @@ dependencies = [ "autocfg", ] +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.17", + "http", + "rand 0.8.6", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2 0.10.9", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "oid-registry" version = "0.8.1" @@ -1715,86 +1864,154 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "openshell-conformance" +name = "openshell-bootstrap" version = "0.0.0" dependencies = [ - "rand 0.9.5", + "bytes", + "miette", + "openshell-core", + "openshell-crypto", + "rcgen", "serde", "serde_json", "tempfile", "tokio", + "tracing", ] [[package]] -name = "openshell-crypto" +name = "openshell-core" version = "0.0.0" dependencies = [ - "aws-lc-rs", - "jsonwebtoken", - "kube", - "pem 3.0.6", - "rcgen", + "async-trait", + "base64 0.22.1", + "chrono", + "glob", + "ipnet", + "miette", + "nix", + "openshell-crypto", + "openshell-extension-core", + "openshell-policy-schema", + "prost", + "prost-types", + "protoc-bin-vendored", + "reqwest", + "rustix", "rustls", + "rustls-pemfile", "serde", - "sqlx", - "thiserror", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.20", + "tokio", + "tokio-stream", "tonic", - "x509-parser", + "tonic-prost", + "tonic-prost-build", + "tonic-types", + "tracing", + "url", + "uuid", ] [[package]] -name = "openshell-e2e" -version = "0.1.0" +name = "openshell-crypto" +version = "0.0.0" dependencies = [ - "base64 0.22.1", - "bollard", - "bytes", - "futures-util", - "hex", - "http-body-util", - "hyper", - "hyper-util", + "aws-lc-rs", "jsonwebtoken", - "nix", + "kube", + "pem 3.0.6", + "rcgen", + "rustls", + "serde", + "sqlx", + "thiserror 2.0.20", + "tonic", + "x509-parser", +] + +[[package]] +name = "openshell-extension-core" +version = "0.0.0" +dependencies = [ + "hyper-util", + "serde", + "thiserror 2.0.20", + "tokio", + "tonic", + "tower", +] + +[[package]] +name = "openshell-policy-schema" +version = "0.0.0" +dependencies = [ + "miette", "noyalib", + "serde", + "serde_json", + "serde_path_to_error", + "thiserror 2.0.20", +] + +[[package]] +name = "openshell-sdk" +version = "0.0.0" +dependencies = [ + "async-stream", + "async-trait", + "futures", + "hyper", + "hyper-util", + "miette", + "oauth2", + "openshell-core", "openshell-crypto", - "prost", - "rand 0.9.5", + "reqwest", "rustls", - "rustls-pemfile", "serde", - "serde_json", - "sha1 0.10.7", - "sha2 0.10.9", - "tempfile", + "thiserror 2.0.20", "tokio", "tokio-rustls", - "tokio-stream", + "tokio-tungstenite", "tonic", - "tonic-prost", "tower", - "url", + "tracing", ] [[package]] -name = "openshell-test-feature-oci-image" +name = "openshell-test-podman-userns" version = "0.0.0" dependencies = [ - "openshell-conformance", - "serde_json", - "tempfile", - "tokio", + "openshell-testkit", + "serde", ] [[package]] -name = "openshell-test-feature-provider-refresh-keycloak" +name = "openshell-testkit" version = "0.0.0" dependencies = [ - "openshell-conformance", - "openshell-e2e", + "futures", + "openshell-bootstrap", + "openshell-core", + "openshell-sdk", + "openshell-testkit-macros", + "rand 0.9.4", + "serde", "serde_json", - "tempfile", "tokio", + "tonic", +] + +[[package]] +name = "openshell-testkit-macros" +version = "0.0.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -1812,6 +2029,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + [[package]] name = "parking" version = "2.2.1" @@ -1869,9 +2092,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.9.1" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" dependencies = [ "memchr", "ucd-trie", @@ -1879,9 +2102,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.9.1" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" dependencies = [ "pest", "pest_generator", @@ -1889,44 +2112,56 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.9.1" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "pest_meta" -version = "2.9.1" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", + "sha2 0.10.9", +] + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", ] [[package]] name = "pin-project" -version = "1.1.13" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.13" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -1937,15 +2172,15 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "potential_utf" -version = "0.1.6" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -1965,43 +2200,167 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + [[package]] name = "proc-macro2" -version = "1.0.107" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "prost" -version = "0.14.4" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" dependencies = [ "bytes", "prost-derive", ] +[[package]] +name = "prost-build" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn 2.0.117", + "tempfile", +] + [[package]] name = "prost-derive" -version = "0.14.4" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", "itertools", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", +] + +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost", +] + +[[package]] +name = "protoc-bin-vendored" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" + +[[package]] +name = "pulldown-cmark" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +dependencies = [ + "pulldown-cmark", ] [[package]] name = "quote" -version = "1.0.47" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -2020,11 +2379,22 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.5" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] @@ -2035,10 +2405,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", - "getrandom 0.4.3", + "getrandom 0.4.2", "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -2098,9 +2478,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.13.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -2110,9 +2490,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.18" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -2121,9 +2501,48 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.11" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] [[package]] name = "ring" @@ -2139,11 +2558,17 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "rustc-hash" -version = "2.1.3" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rusticata-macros" @@ -2156,9 +2581,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.5" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags", "errno", @@ -2169,9 +2594,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "log", @@ -2184,9 +2609,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.4" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -2205,18 +2630,18 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.15.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.16" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -2226,9 +2651,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.23" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" @@ -2266,7 +2691,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -2307,6 +2732,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -2355,14 +2786,14 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", @@ -2372,14 +2803,14 @@ dependencies = [ ] [[package]] -name = "serde_repr" -version = "0.1.21" +name = "serde_path_to_error" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", + "itoa", + "serde", + "serde_core", ] [[package]] @@ -2409,9 +2840,9 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.7" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -2425,8 +2856,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.3.1", - "digest 0.11.3", + "cpufeatures 0.3.0", + "digest 0.11.2", ] [[package]] @@ -2447,15 +2878,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.1", - "digest 0.11.3", + "cpufeatures 0.3.0", + "digest 0.11.2", ] [[package]] name = "shlex" -version = "2.0.1" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" @@ -2484,7 +2915,7 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror", + "thiserror 2.0.20", "time", ] @@ -2496,18 +2927,18 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" dependencies = [ "serde", ] [[package]] name = "socket2" -version = "0.6.5" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", "windows-sys 0.61.2", @@ -2563,7 +2994,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror", + "thiserror 2.0.20", "tokio", "tokio-stream", "tracing", @@ -2581,7 +3012,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -2603,8 +3034,8 @@ dependencies = [ "sqlx-core", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.119", - "thiserror", + "syn 2.0.117", + "thiserror 2.0.20", "tokio", "url", ] @@ -2619,7 +3050,7 @@ dependencies = [ "byteorder", "bytes", "crc", - "digest 0.11.3", + "digest 0.11.2", "dotenvy", "either", "futures-core", @@ -2631,7 +3062,7 @@ dependencies = [ "sha1 0.11.0", "sha2 0.11.0", "sqlx-core", - "thiserror", + "thiserror 2.0.20", "tracing", ] @@ -2665,7 +3096,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.20", "tracing", "whoami", ] @@ -2689,7 +3120,7 @@ dependencies = [ "percent-encoding", "serde", "sqlx-core", - "thiserror", + "thiserror 2.0.20", "tracing", "url", ] @@ -2723,11 +3154,32 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + [[package]] name = "syn" -version = "2.0.119" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -2750,27 +3202,19 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "futures-core", ] [[package]] name = "synstructure" -version = "0.14.0" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 2.0.117", ] [[package]] @@ -2780,19 +3224,59 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", ] +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -2808,11 +3292,12 @@ dependencies = [ [[package]] name = "time" -version = "0.3.55" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "num-conv", "powerfmt", "serde_core", @@ -2822,15 +3307,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.9" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.32" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -2838,9 +3323,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.4" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -2848,15 +3333,24 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.13.3" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.53.1" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", @@ -2871,20 +3365,20 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.2" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 2.0.117", ] [[package]] name = "tokio-rustls" -version = "0.26.6" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ "rustls", "tokio", @@ -2892,15 +3386,31 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.19" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ "futures-core", "pin-project-lite", "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -2935,6 +3445,7 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", + "rustls-native-certs", "socket2", "sync_wrapper", "tokio", @@ -2946,6 +3457,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tonic-prost" version = "0.14.6" @@ -2957,6 +3480,33 @@ dependencies = [ "tonic", ] +[[package]] +name = "tonic-prost-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.117", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tonic-types" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8" +dependencies = [ + "prost", + "prost-types", + "tonic", +] + [[package]] name = "tower" version = "0.5.3" @@ -2978,17 +3528,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.11" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "base64 0.22.1", "bitflags", "bytes", + "futures-util", "http", "http-body", + "iri-string", "mime", "pin-project-lite", + "tower", "tower-layer", "tower-service", "tracing", @@ -3026,7 +3579,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -3044,6 +3597,25 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "rustls", + "rustls-pki-types", + "sha1 0.10.6", + "thiserror 2.0.20", + "utf-8", +] + [[package]] name = "typenum" version = "1.20.1" @@ -3056,6 +3628,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -3068,6 +3646,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-normalization" version = "0.1.25" @@ -3083,6 +3667,24 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -3111,14 +3713,32 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -3148,11 +3768,20 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.4+wasi-0.2.12" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] @@ -3168,6 +3797,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.128" @@ -3200,11 +3839,55 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" -version = "1.0.10" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] @@ -3216,26 +3899,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" [[package]] -name = "winapi" -version = "0.3.9" +name = "windows-core" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" +name = "windows-implement" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" +name = "windows-interface" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "windows-link" @@ -3243,6 +3939,24 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -3325,17 +4039,105 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" -version = "0.6.4" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "x509-parser" @@ -3351,7 +4153,7 @@ dependencies = [ "nom", "oid-registry", "rusticata-macros", - "thiserror", + "thiserror 2.0.20", "time", ] @@ -3367,9 +4169,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -3378,55 +4180,55 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.3" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", - "synstructure 0.14.0", + "syn 2.0.117", + "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.57" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.57" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "zerofrom" -version = "0.1.8" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.8" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", - "synstructure 0.14.0", + "syn 2.0.117", + "synstructure", ] [[package]] @@ -3434,26 +4236,12 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] [[package]] name = "zerotrie" -version = "0.2.5" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -3462,9 +4250,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -3473,17 +4261,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 2.0.117", ] [[package]] name = "zmij" -version = "1.0.23" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tests/suites/podman/Cargo.toml b/tests/suites/podman/Cargo.toml new file mode 100644 index 0000000000..3e887eb4f6 --- /dev/null +++ b/tests/suites/podman/Cargo.toml @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# End-to-end suites for the Podman compute driver, written on testkit. Each +# member is one suite crate. Unlike the driver-agnostic suites, these require a +# gateway whose only compute driver is Podman. +[workspace] +resolver = "2" +members = ["userns"] diff --git a/tests/suites/podman/README.md b/tests/suites/podman/README.md new file mode 100644 index 0000000000..835d27b5db --- /dev/null +++ b/tests/suites/podman/README.md @@ -0,0 +1,20 @@ +# Podman driver suites + +End-to-end suites for behavior specific to the Podman compute driver, written on +[testkit](../../testkit/README.md). Each member crate is one suite. These +suites fail, rather than skip, when the gateway's only compute driver is not +Podman. + +| Suite | Covers | +|---|---| +| `userns` | The sandbox's user-namespace mapping matches direct Podman for the configured profile, and the workload can write to its workspace | + +`fixtures/` holds the gateway configuration for each user-namespace profile +(`auto`, `keep-id`, `private`). The tmachine playbooks apply one fixture, record +Podman's own UID map as the reference, and then run the suite. The suite reads +these environment variables: + +| Variable | Meaning | +|---|---| +| `OPENSHELL_TEST_INPUT_DIR` | Directory holding `reference-uid-map`, written by the playbook | +| `OPENSHELL_PODMAN_TEST_IMAGE` | Optional sandbox image, passed as `--from` | diff --git a/tests/suites/drivers/podman/fixtures/userns-auto.toml b/tests/suites/podman/fixtures/userns-auto.toml similarity index 100% rename from tests/suites/drivers/podman/fixtures/userns-auto.toml rename to tests/suites/podman/fixtures/userns-auto.toml diff --git a/tests/suites/drivers/podman/fixtures/userns-keep-id.toml b/tests/suites/podman/fixtures/userns-keep-id.toml similarity index 100% rename from tests/suites/drivers/podman/fixtures/userns-keep-id.toml rename to tests/suites/podman/fixtures/userns-keep-id.toml diff --git a/tests/suites/drivers/podman/fixtures/userns-private.toml b/tests/suites/podman/fixtures/userns-private.toml similarity index 100% rename from tests/suites/drivers/podman/fixtures/userns-private.toml rename to tests/suites/podman/fixtures/userns-private.toml diff --git a/tests/suites/podman/userns/Cargo.toml b/tests/suites/podman/userns/Cargo.toml new file mode 100644 index 0000000000..b1008ffc01 --- /dev/null +++ b/tests/suites/podman/userns/Cargo.toml @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-test-podman-userns" +description = "End-to-end suite: Podman driver user namespaces" +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" } +serde = { version = "1", features = ["derive"] } diff --git a/tests/suites/podman/userns/tests/default_userns.rs b/tests/suites/podman/userns/tests/default_userns.rs new file mode 100644 index 0000000000..d325b33ea0 --- /dev/null +++ b/tests/suites/podman/userns/tests/default_userns.rs @@ -0,0 +1,132 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Podman-driver user-namespace integration tests. + +mod support; + +use std::fs; +use std::path::PathBuf; + +use support::assert_podman_gateway; +use testkit::{Context, Error}; + +const PODMAN_TEST_INPUT_DIR_ENV: &str = "OPENSHELL_TEST_INPUT_DIR"; +const PODMAN_TEST_IMAGE_ENV: &str = "OPENSHELL_PODMAN_TEST_IMAGE"; + +const WORKSPACE_AND_UID_MAP_PROBE: &str = r#"set -eu +workload_owner="$(id -u):$(id -g)" +workspace_owner="$(stat -c '%u:%g' /sandbox)" +printf 'workload-owner=%s\nworkspace-owner=%s\n' "$workload_owner" "$workspace_owner" +test "$(id -u)" -ne 0 +test "$workspace_owner" = "$workload_owner" +probe=$(mktemp /sandbox/userns-probe.XXXXXX) +printf 'workspace probe\n' > "$probe" +rm "$probe" +echo podman-userns-workspace-ok +cat /proc/self/uid_map +"#; + +/// Verify that the gateway's user-namespace configuration matches Podman's +/// direct behavior for the same profile and preserves workspace access. +/// +/// The test runs a short-lived sandbox command and compares its user-namespace +/// mapping with the direct-Podman reference stored at +/// `OPENSHELL_TEST_INPUT_DIR/reference-uid-map`. The tmachine pre-test +/// playbook creates that reference in the same gateway-user context. This deliberately +/// avoids baking a particular Podman mapping into OpenShell's test contract. +/// The workload also verifies that the managed workspace is owned by its +/// non-root UID/GID and that it can create, write, and remove a file there. +#[testkit::test] +async fn configured_userns_matches_podman_reference(cx: &mut Context) -> testkit::Result { + assert_podman_gateway(cx).await?; + + let test_input_dir = std::env::var_os(PODMAN_TEST_INPUT_DIR_ENV) + .map(PathBuf::from) + .ok_or_else(|| { + Error::msg(format!( + "{PODMAN_TEST_INPUT_DIR_ENV} must name the Podman test-input directory" + )) + })?; + let expected_path = test_input_dir.join("reference-uid-map"); + let expected_uid_map = fs::read_to_string(&expected_path).map_err(|error| { + Error::msg(format!( + "could not read Podman reference UID map {}: {error}", + expected_path.display() + )) + })?; + let expected_uid_map = normalize_uid_map(&expected_uid_map).ok_or_else(|| { + Error::msg(format!( + "Podman reference UID map {} contains no mappings", + expected_path.display() + )) + })?; + + let workload_image = std::env::var(PODMAN_TEST_IMAGE_ENV) + .ok() + .filter(|image| !image.trim().is_empty()); + // The sandbox is created through the CLI, so testkit does not know about + // it. Track it for cleanup before creating it. + let sandbox_name = cx.name("pu"); + cx.track(&sandbox_name); + let mut create_args = vec!["sandbox", "create", "--name", &sandbox_name]; + if let Some(image) = workload_image.as_deref() { + create_args.extend(["--from", image]); + } + create_args.extend(["--no-tty", "--", "sh", "-c", WORKSPACE_AND_UID_MAP_PROBE]); + let run = cx.cli(create_args).await?.success()?; + testkit::ensure!( + run.stdout.contains("podman-userns-workspace-ok"), + "non-root workload did not own and write to /sandbox\nstdout:\n{}\nstderr:\n{}", + run.stdout, + run.stderr + ); + let sandbox_uid_map = normalize_uid_map(&run.stdout).ok_or_else(|| { + Error::msg(format!( + "sandbox did not return a non-empty UID map\nstdout:\n{}\nstderr:\n{}", + run.stdout, run.stderr + )) + })?; + testkit::ensure!( + sandbox_uid_map == expected_uid_map, + "sandbox UID map differs from the direct Podman reference:\nexpected:\n{expected_uid_map}\nactual:\n{sandbox_uid_map}" + ); + Ok(()) +} + +fn normalize_uid_map(value: &str) -> Option { + let mut mappings: Vec<(u64, u64, u64)> = Vec::new(); + for line in value.lines() { + let fields = line + .split_whitespace() + .map(str::parse::) + .collect::, _>>(); + let Ok(fields) = fields else { continue }; + let [inside, outside, length] = fields.as_slice() else { + continue; + }; + if let Some(previous) = mappings.last_mut() + && previous.0.checked_add(previous.2) == Some(*inside) + && previous.1.checked_add(previous.2) == Some(*outside) + { + previous.2 += *length; + } else { + mappings.push((*inside, *outside, *length)); + } + } + (!mappings.is_empty()).then(|| { + mappings + .iter() + .map(|(inside, outside, length)| format!("{inside} {outside} {length}")) + .collect::>() + .join("\n") + }) +} + +#[test] +fn adjacent_uid_ranges_match_a_combined_mapping() { + assert_eq!( + normalize_uid_map("0 0 1\n1 1 65535\n"), + normalize_uid_map("0 0 65536\n") + ); +} diff --git a/tests/suites/podman/userns/tests/support/mod.rs b/tests/suites/podman/userns/tests/support/mod.rs new file mode 100644 index 0000000000..128238aaee --- /dev/null +++ b/tests/suites/podman/userns/tests/support/mod.rs @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use serde::Deserialize; +use testkit::Context; + +#[derive(Debug, Deserialize)] +struct GatewayInfo { + status: String, + compute_drivers: Vec, +} + +#[derive(Debug, Deserialize)] +struct ComputeDriver { + name: String, + capabilities: ComputeDriverCapabilities, +} + +#[derive(Debug, Deserialize)] +struct ComputeDriverCapabilities { + driver_name: String, +} + +/// Require the target gateway to use Podman as its only compute driver. +/// +/// This interrogates the running gateway rather than accepting an environment +/// variable. A driver-specific suite must fail, rather than skip, when it is +/// pointed at the wrong gateway. +pub async fn assert_podman_gateway(cx: &Context) -> testkit::Result { + let result = cx + .cli(["gateway", "info", "--output", "json"]) + .await? + .success()?; + let info = result.json::()?; + testkit::ensure!( + info.status == "healthy", + "Podman test suite requires a healthy gateway; gateway status is {:?}", + info.status + ); + + let driver_names = info + .compute_drivers + .iter() + .map(|driver| driver.name.as_str()) + .collect::>(); + let podman_only = matches!(info.compute_drivers.as_slice(), [driver] + if driver.name == "podman" && driver.capabilities.driver_name == "podman"); + testkit::ensure!( + podman_only, + "Podman test suite requires exactly one Podman compute driver; gateway reported {driver_names:?}" + ); + + Ok(()) +} diff --git a/tests/suites/providers/Cargo.lock b/tests/suites/providers/Cargo.lock new file mode 100644 index 0000000000..742101f359 --- /dev/null +++ b/tests/suites/providers/Cargo.lock @@ -0,0 +1,4279 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "enum-ordinalize" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1 0.10.6", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.2", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-http-proxy" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd1d471ea2f65ba45eddb1d6ab7d58ac2671d2d4ac14da5c6516ae1d97e1327a" +dependencies = [ + "bytes", + "futures-util", + "headers", + "http", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "log", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "jsonpath-rust" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c00ae348f9f8fd2d09f82a98ca381c60df9e0820d8d79fce43e649b4dc3128b" +dependencies = [ + "pest", + "pest_derive", + "regex", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "jsonptr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "jsonwebtoken" +version = "10.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "getrandom 0.2.17", + "js-sys", + "pem 3.0.6", + "serde", + "serde_json", + "signature", + "simple_asn1", +] + +[[package]] +name = "k8s-openapi" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c75b990324f09bef15e791606b7b7a296d02fc88a344f6eba9390970a870ad5" +dependencies = [ + "base64 0.22.1", + "chrono", + "serde", + "serde-value", + "serde_json", +] + +[[package]] +name = "kube" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a4eb20010536b48abe97fec37d23d43069bcbe9686adcf9932202327bc5ca6e" +dependencies = [ + "k8s-openapi", + "kube-client", + "kube-core", + "kube-derive", + "kube-runtime", +] + +[[package]] +name = "kube-client" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fc2ed952042df20d15ac2fe9614d0ec14b6118eab89633985d4b36e688dccf1" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "either", + "futures", + "home", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-http-proxy", + "hyper-rustls", + "hyper-timeout", + "hyper-util", + "jsonpath-rust", + "k8s-openapi", + "kube-core", + "pem 3.0.6", + "rustls", + "secrecy", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tracing", +] + +[[package]] +name = "kube-core" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff0d0793db58e70ca6d689489183816cb3aa481673e7433dc618cf7e8007c675" +dependencies = [ + "chrono", + "form_urlencoded", + "http", + "json-patch", + "k8s-openapi", + "schemars", + "serde", + "serde-value", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "kube-derive" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c562f58dc9f7ca5feac8a6ee5850ca221edd6f04ce0dd2ee873202a88cd494c9" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 2.0.117", +] + +[[package]] +name = "kube-runtime" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88f34cfab9b4bd8633062e0e85edb81df23cb09f159f2e31c60b069ae826ffdc" +dependencies = [ + "ahash", + "async-broadcast", + "async-stream", + "async-trait", + "backon", + "educe", + "futures", + "hashbrown 0.15.5", + "hostname", + "json-patch", + "k8s-openapi", + "kube-client", + "parking_lot", + "pin-project", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.2", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "noyalib" +version = "0.0.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962838abbd8dd2cd3844d2208dc502458bba6985f8bf1dc21761b30260fcbd7f" +dependencies = [ + "hashbrown 0.17.0", + "indexmap", + "libm", + "memchr", + "rustc-hash", + "serde_core", + "smallvec", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.17", + "http", + "rand 0.8.6", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2 0.10.9", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openshell-bootstrap" +version = "0.0.0" +dependencies = [ + "bytes", + "miette", + "openshell-core", + "openshell-crypto", + "rcgen", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", +] + +[[package]] +name = "openshell-core" +version = "0.0.0" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "glob", + "ipnet", + "miette", + "nix", + "openshell-crypto", + "openshell-extension-core", + "openshell-policy-schema", + "prost", + "prost-types", + "protoc-bin-vendored", + "reqwest", + "rustix", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tonic-types", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "openshell-crypto" +version = "0.0.0" +dependencies = [ + "aws-lc-rs", + "jsonwebtoken", + "kube", + "pem 3.0.6", + "rcgen", + "rustls", + "serde", + "sqlx", + "thiserror 2.0.20", + "tonic", + "x509-parser", +] + +[[package]] +name = "openshell-extension-core" +version = "0.0.0" +dependencies = [ + "hyper-util", + "serde", + "thiserror 2.0.20", + "tokio", + "tonic", + "tower", +] + +[[package]] +name = "openshell-policy-schema" +version = "0.0.0" +dependencies = [ + "miette", + "noyalib", + "serde", + "serde_json", + "serde_path_to_error", + "thiserror 2.0.20", +] + +[[package]] +name = "openshell-sdk" +version = "0.0.0" +dependencies = [ + "async-stream", + "async-trait", + "futures", + "hyper", + "hyper-util", + "miette", + "oauth2", + "openshell-core", + "openshell-crypto", + "reqwest", + "rustls", + "serde", + "thiserror 2.0.20", + "tokio", + "tokio-rustls", + "tokio-tungstenite", + "tonic", + "tower", + "tracing", +] + +[[package]] +name = "openshell-test-providers-refresh-keycloak" +version = "0.0.0" +dependencies = [ + "openshell-testkit", + "serde_json", + "tempfile", + "tokio", +] + +[[package]] +name = "openshell-testkit" +version = "0.0.0" +dependencies = [ + "futures", + "openshell-bootstrap", + "openshell-core", + "openshell-sdk", + "openshell-testkit-macros", + "rand 0.9.4", + "serde", + "serde_json", + "tokio", + "tonic", +] + +[[package]] +name = "openshell-testkit-macros" +version = "0.0.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2 0.10.9", +] + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn 2.0.117", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost", +] + +[[package]] +name = "protoc-bin-vendored" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" + +[[package]] +name = "pulldown-cmark" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rcgen" +version = "0.14.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" +dependencies = [ + "aws-lc-rs", + "pem 4.0.0", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64 0.22.1", + "bytes", + "cfg-if", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap", + "log", + "memchr", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.117", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.117", + "thiserror 2.0.20", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags", + "byteorder", + "bytes", + "crc", + "digest 0.11.2", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "serde", + "sha1 0.11.0", + "sha2 0.11.0", + "sqlx-core", + "thiserror 2.0.20", + "tracing", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "rand 0.10.2", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.20", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror 2.0.20", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "slab", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "rustls-native-certs", + "socket2", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.117", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tonic-types" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8" +dependencies = [ + "prost", + "prost-types", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "base64 0.22.1", + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "mime", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "rustls", + "rustls-pki-types", + "sha1 0.10.6", + "thiserror 2.0.20", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "aws-lc-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tests/suites/providers/Cargo.toml b/tests/suites/providers/Cargo.toml new file mode 100644 index 0000000000..b537361b34 --- /dev/null +++ b/tests/suites/providers/Cargo.toml @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# End-to-end suites for provider behavior, written on testkit. Each member is +# one suite crate; tests run against whatever gateway the environment registers. +[workspace] +resolver = "2" +members = ["refresh-keycloak"] diff --git a/tests/suites/providers/README.md b/tests/suites/providers/README.md new file mode 100644 index 0000000000..6b6915e05b --- /dev/null +++ b/tests/suites/providers/README.md @@ -0,0 +1,17 @@ +# Provider suites + +End-to-end suites for provider behavior, written on +[testkit](../../testkit/README.md). Each member crate is one suite. Tests run +against whichever gateway the environment registered. + +| Suite | Covers | +|---|---| +| `refresh-keycloak` | OAuth refresh of a provider credential against Keycloak, including a revoked grant requiring reauthorization | + +`refresh-keycloak` needs a Keycloak issuer and reads +`OPENSHELL_E2E_OIDC_ISSUER`, `OPENSHELL_E2E_OIDC_USERNAME`, and +`OPENSHELL_E2E_OIDC_PASSWORD`. Keycloak is only the provider token issuer; the +gateway itself can use mTLS. Linux only. + +Providers and profiles created through the CLI are not cleaned up by testkit. +A test must delete them itself, including when it fails. diff --git a/tests/suites/providers/refresh-keycloak/Cargo.toml b/tests/suites/providers/refresh-keycloak/Cargo.toml new file mode 100644 index 0000000000..66a043f6ee --- /dev/null +++ b/tests/suites/providers/refresh-keycloak/Cargo.toml @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-test-providers-refresh-keycloak" +description = "End-to-end suite: provider OAuth refresh against Keycloak" +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" } +serde_json = "1" +tempfile = "3" +tokio = { version = "1.43", features = ["macros", "process", "io-util", "rt"] } diff --git a/tests/suites/providers/refresh-keycloak/tests/refresh_keycloak.rs b/tests/suites/providers/refresh-keycloak/tests/refresh_keycloak.rs new file mode 100644 index 0000000000..ba7f38c8ca --- /dev/null +++ b/tests/suites/providers/refresh-keycloak/tests/refresh_keycloak.rs @@ -0,0 +1,297 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(target_os = "linux")] + +//! Provider OAuth refresh recovery against Keycloak. +//! +//! OpenShell itself uses the local gateway's mTLS authentication. Keycloak is +//! only the provider token issuer: the test refreshes a valid grant, revokes +//! its Keycloak session, and verifies that the gateway reports the next +//! refresh as requiring user reauthorization. + +use std::io::Write as _; +use std::process::Stdio; + +use serde_json::Value; +use tempfile::{Builder as TempFileBuilder, NamedTempFile}; +use testkit::{CliOutput, Context, Error, ResultExt as _}; +use tokio::io::AsyncWriteExt as _; +use tokio::process::Command; + +const CREDENTIAL_KEY: &str = "KEYCLOAK_ACCESS_TOKEN"; + +fn combined_output(output: &CliOutput) -> String { + format!("{}{}", output.stdout, output.stderr) +} + +fn required_env(name: &str) -> testkit::Result { + std::env::var(name).map_err(|_| Error::msg(format!("{name} is required"))) +} + +/// Run the CLI and return stdout and stderr together; fail on a non-zero exit. +async fn cli_success(cx: &Context, args: &[&str], env: &[(&str, &str)]) -> testkit::Result { + let output = cx.cli_env(env, args.iter().copied()).await?.success()?; + Ok(combined_output(&output)) +} + +async fn acquire_keycloak_grant( + issuer: &str, + username: &str, + password: &str, +) -> testkit::Result<(String, String)> { + let token_endpoint = format!("{issuer}/protocol/openid-connect/token"); + let username_form = format!("username={username}"); + let password_form = format!("password={password}"); + let output = Command::new("curl") + .args([ + "--fail", + "--silent", + "--show-error", + "--request", + "POST", + &token_endpoint, + "--data-urlencode", + "grant_type=password", + "--data-urlencode", + "client_id=openshell-cli", + "--data-urlencode", + &username_form, + "--data-urlencode", + &password_form, + "--data-urlencode", + "scope=openid", + ]) + .output() + .await + .map_err(|error| Error::from_error(&error)) + .context("request Keycloak grant")?; + testkit::ensure!( + output.status.success(), + "Keycloak grant request failed (exit {:?}): {}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); + + let response: Value = serde_json::from_slice(&output.stdout) + .map_err(|error| Error::from_error(&error)) + .context("decode Keycloak grant response")?; + let access_token = response + .get("access_token") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| Error::msg("Keycloak grant response omitted access_token"))?; + let refresh_token = response + .get("refresh_token") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| Error::msg("Keycloak grant response omitted refresh_token"))?; + Ok((access_token.to_string(), refresh_token.to_string())) +} + +async fn revoke_keycloak_grant(issuer: &str, refresh_token: &str) -> testkit::Result { + let logout_endpoint = format!("{issuer}/protocol/openid-connect/logout"); + let mut child = Command::new("curl") + .args([ + "--fail", + "--silent", + "--show-error", + "--output", + "/dev/null", + "--request", + "POST", + &logout_endpoint, + "--data-urlencode", + "client_id=openshell-cli", + "--data-urlencode", + "refresh_token@-", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| Error::from_error(&error)) + .context("start Keycloak logout request")?; + child + .stdin + .take() + .ok_or_else(|| Error::msg("Keycloak logout stdin was not piped"))? + .write_all(refresh_token.as_bytes()) + .await + .map_err(|error| Error::from_error(&error)) + .context("write Keycloak logout request")?; + let output = child + .wait_with_output() + .await + .map_err(|error| Error::from_error(&error)) + .context("wait for Keycloak logout request")?; + testkit::ensure!( + output.status.success(), + "Keycloak logout failed (exit {:?}): {}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) +} + +fn write_profile(profile_id: &str, issuer: &str) -> testkit::Result { + let mut file = TempFileBuilder::new() + .suffix(".yaml") + .tempfile() + .map_err(|error| Error::from_error(&error)) + .context("create provider profile")?; + let profile = format!( + r#"id: {profile_id} +display_name: Keycloak provider refresh E2E +category: other +credentials: + - name: access_token + env_vars: [{CREDENTIAL_KEY}] + required: true + auth_style: bearer + header_name: authorization + refresh: + strategy: oauth2_refresh_token + token_url: {issuer}/protocol/openid-connect/token + scopes: [openid] + refresh_before_seconds: 60 + max_lifetime_seconds: 3600 + material: + - name: client_id + required: true + - name: refresh_token + required: true + secret: true +endpoints: + - host: keycloak.test.invalid + port: 443 + protocol: rest + access: read-only + enforcement: enforce +binaries: + - /usr/bin/curl +"# + ); + file.write_all(profile.as_bytes()) + .map_err(|error| Error::from_error(&error)) + .context("write provider profile")?; + file.flush() + .map_err(|error| Error::from_error(&error)) + .context("flush provider profile")?; + Ok(file) +} + +/// Testkit does not clean up providers or profiles, so delete them here. A +/// failure to delete is ignored: after a failed run they may not exist. +async fn delete_provider_resources(cx: &Context, provider: &str, profile_id: &str) { + let _ = cx.cli(["provider", "delete", provider]).await; + let _ = cx.cli(["profile", "delete", profile_id]).await; +} + +/// A refresh grant revoked in Keycloak makes the next rotation fail with +/// invalid_grant, and the gateway then reports the credential as requiring +/// user reauthorization. +#[testkit::test] +async fn revoked_refresh_grant_requires_user_reauthorization(cx: &mut Context) -> testkit::Result { + let issuer = required_env("OPENSHELL_E2E_OIDC_ISSUER")?; + let username = required_env("OPENSHELL_E2E_OIDC_USERNAME")?; + let password = required_env("OPENSHELL_E2E_OIDC_PASSWORD")?; + let (access_token, refresh_token) = + acquire_keycloak_grant(&issuer, &username, &password).await?; + let provider = cx.name("keycloak-refresh"); + let profile_id = provider.clone(); + let profile = write_profile(&profile_id, &issuer)?; + let profile_path = profile.path().to_string_lossy().into_owned(); + + delete_provider_resources(cx, &provider, &profile_id).await; + let result = async { + cli_success(cx, &["profile", "import", "--file", &profile_path], &[]).await?; + cli_success( + cx, + &[ + "provider", + "create", + "--name", + &provider, + "--type", + &profile_id, + "--credential", + CREDENTIAL_KEY, + ], + &[(CREDENTIAL_KEY, &access_token)], + ) + .await?; + cli_success( + cx, + &[ + "provider", + "refresh", + "configure", + &provider, + "--credential-key", + CREDENTIAL_KEY, + "--strategy", + "oauth2-refresh-token", + "--material", + "client_id=openshell-cli", + "--secret-material-env", + "refresh_token=KEYCLOAK_REFRESH_TOKEN", + ], + &[("KEYCLOAK_REFRESH_TOKEN", &refresh_token)], + ) + .await?; + + let rotate = [ + "provider", + "refresh", + "rotate", + provider.as_str(), + "--credential-key", + CREDENTIAL_KEY, + ]; + let status = [ + "provider", + "refresh", + "status", + provider.as_str(), + "--credential-key", + CREDENTIAL_KEY, + ]; + + cli_success(cx, &rotate, &[]).await?; + let valid_status = cli_success(cx, &status, &[]).await?; + testkit::ensure!( + valid_status.contains("refreshed"), + "valid Keycloak refresh did not reach refreshed state:\n{valid_status}" + ); + + revoke_keycloak_grant(&issuer, &refresh_token).await?; + let failed_rotation = cx.cli(rotate).await?; + let failed_rotation_output = combined_output(&failed_rotation); + testkit::ensure!( + failed_rotation.exit_code != 0 && failed_rotation_output.contains("invalid_grant"), + "revoked Keycloak refresh did not fail with invalid_grant:\n{failed_rotation_output}" + ); + + let revoked_status = cli_success(cx, &status, &[]).await?; + for expected in [ + "reauthorization_required", + "reauthorize", + "oauth_invalid_grant", + ] { + testkit::ensure!( + revoked_status.contains(expected), + "revoked refresh status omitted {expected}:\n{revoked_status}" + ); + } + testkit::ensure!( + !revoked_status.contains("292278994"), + "parked refresh rendered the i64::MAX scheduling sentinel as a date:\n{revoked_status}" + ); + Ok(()) + } + .await; + + delete_provider_resources(cx, &provider, &profile_id).await; + result +} diff --git a/tests/suites/sandbox/Cargo.lock b/tests/suites/sandbox/Cargo.lock index 4051697fd8..474df6044f 100644 --- a/tests/suites/sandbox/Cargo.lock +++ b/tests/suites/sandbox/Cargo.lock @@ -1981,11 +1981,38 @@ dependencies = [ "tracing", ] +[[package]] +name = "openshell-test-sandbox-file-transfer" +version = "0.0.0" +dependencies = [ + "openshell-testkit", + "tempfile", +] + [[package]] name = "openshell-test-sandbox-lifecycle" version = "0.0.0" dependencies = [ "openshell-testkit", + "serde_json", +] + +[[package]] +name = "openshell-test-sandbox-policy-advisor" +version = "0.0.0" +dependencies = [ + "openshell-testkit", + "serde_json", + "tokio", +] + +[[package]] +name = "openshell-test-sandbox-oci-image" +version = "0.0.0" +dependencies = [ + "openshell-testkit", + "serde_json", + "tempfile", ] [[package]] diff --git a/tests/suites/sandbox/Cargo.toml b/tests/suites/sandbox/Cargo.toml index 5bf0cdb544..b8685ac610 100644 --- a/tests/suites/sandbox/Cargo.toml +++ b/tests/suites/sandbox/Cargo.toml @@ -5,4 +5,4 @@ # one suite crate; tests run against whatever gateway the environment registers. [workspace] resolver = "2" -members = ["lifecycle"] +members = ["file-transfer", "lifecycle", "oci-image", "policy-advisor"] diff --git a/tests/suites/sandbox/README.md b/tests/suites/sandbox/README.md index ae0531d48e..4b190cfc95 100644 --- a/tests/suites/sandbox/README.md +++ b/tests/suites/sandbox/README.md @@ -7,17 +7,28 @@ every compute driver. | Suite | Covers | |---|---| -| `lifecycle` | Create, wait for ready, exec, delete | +| `lifecycle` | Create, ready, exec, stop, start and delete through the SDK, plus the same flow through the CLI | +| `file-transfer` | Upload and download round trips, Git filtering, and path safety | +| `policy-advisor` | Denied connections produce draft rules; `policy.local` accepts proposals | +| `oci-image` | Image user and working-directory behavior on the Docker and Podman drivers | Run them against a local Docker gateway with `mise run e2e:testkit`, or against -Podman with `mise run e2e:testkit:podman`. +Podman with `mise run e2e:testkit:podman`. `oci-image` needs +`OPENSHELL_TEST_CONTAINER_ENGINE`; use `mise run e2e:oci-image`. + +Driver-specific tests live in their own test file so a playbook can exclude +them with a nextest filter. `lifecycle/tests/persistent_workspace.rs` needs a +driver that keeps `/sandbox` across stop and start. ## Adding a test Add a `#[testkit::test]` function to an existing suite, or a new member crate to -`Cargo.toml` for a new area. A new crate also needs its own entry in -`tests/artifacts.nix` and `tests/config.nix`, and a playbook -that checks the test count. Copy `ansible/playbooks/suites/sandbox-lifecycle.yaml`. +`Cargo.toml` for a new area. The `sandbox` tmachine testsuite runs the +`lifecycle`, `file-transfer` and `policy-advisor` crates from one nextest +archive, so a test added there runs in every conformance lane. Raise +`suite_expected_tests` in `tests/tmachine/config.nix` when you add one. A crate +with its own environment needs, like `oci-image`, gets its own archive in +`tests/tmachine/artifacts.nix` and its own testsuite in `config.nix`. ## What a test may assert diff --git a/tests/suites/sandbox/file-transfer/Cargo.toml b/tests/suites/sandbox/file-transfer/Cargo.toml new file mode 100644 index 0000000000..a288a7932e --- /dev/null +++ b/tests/suites/sandbox/file-transfer/Cargo.toml @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-test-sandbox-file-transfer" +description = "End-to-end suite: sandbox file-transfer" +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" diff --git a/tests/suites/sandbox/file-transfer/tests/file_transfer.rs b/tests/suites/sandbox/file-transfer/tests/file_transfer.rs new file mode 100644 index 0000000000..5bd24563c0 --- /dev/null +++ b/tests/suites/sandbox/file-transfer/tests/file_transfer.rs @@ -0,0 +1,420 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Sandbox file-transfer suite. +//! +//! Adapted from the `file-transfer` suite in NVIDIA/OpenShell#4629 by Evan Lezar. +//! +//! Upload and download exist only in the CLI, which moves files over the +//! sandbox's SSH session, so these tests act through `cx.cli`. They depend on +//! the supervisor, the sandbox image, and the driver's filesystem, which is +//! why they run in every environment. + +use std::fs; +use std::path::{Path, PathBuf}; + +use tempfile::TempDir; +use testkit::{CliOutput, Context, Error, Sandbox}; + +const LARGE_FILE_SIZE: usize = 512 * 1024; + +/// Files and directories round-trip through upload and download unchanged, +/// uploads into existing directories land inside them, and downloads of +/// single files and directories reproduce them locally. +#[testkit::test] +async fn upload_download_round_trip(cx: &mut Context) -> testkit::Result { + let ft = Transfer::new(cx, "fr").await?; + ft.upload_into_existing_directories().await?; + ft.round_trip().await?; + ft.download_file().await?; + ft.download_directory().await?; + ft.sandbox.delete().await +} + +/// Uploads from a Git work tree skip ignored files, a single file uploads +/// alone, and an upload whose selection is entirely ignored is rejected +/// unless `--no-git-ignore` is given. +#[testkit::test] +async fn upload_respects_gitignore(cx: &mut Context) -> testkit::Result { + let ft = Transfer::new(cx, "fg").await?; + ft.gitignore_filtering().await?; + ft.single_file_from_git_repo().await?; + ft.ignored_directory_requires_override().await?; + ft.sandbox.delete().await +} + +/// Downloads refuse sandbox paths that resolve outside the workspace, and +/// file names that look like command-line options download as plain files. +#[testkit::test] +async fn download_stays_inside_workspace(cx: &mut Context) -> testkit::Result { + let ft = Transfer::new(cx, "fs").await?; + ft.reject_workspace_escape().await?; + ft.download_dash_leading_name().await?; + ft.sandbox.delete().await +} + +/// A sandbox, a directory in its workspace, and a local scratch directory. +struct Transfer<'a> { + cx: &'a Context, + sandbox: Sandbox, + remote_root: String, + local: TempDir, +} + +impl<'a> Transfer<'a> { + async fn new(cx: &'a Context, suffix: &str) -> testkit::Result { + let sandbox = cx + .sandbox(suffix) + .command(["sleep", "infinity"]) + .create() + .await?; + sandbox.wait_ready().await?; + let remote_root = format!("/sandbox/file-transfer-{}-{suffix}", cx.run_id()); + let local = tempfile::tempdir() + .map_err(|error| Error::msg(format!("create a local scratch directory: {error}")))?; + let ft = Self { + cx, + sandbox, + remote_root, + local, + }; + ft.sh(&format!("mkdir -p '{}'", ft.remote_root)).await?; + Ok(ft) + } + + fn local(&self, name: &str) -> PathBuf { + self.local.path().join(name) + } + + /// Run a shell script in the sandbox and require it to succeed. + async fn sh(&self, script: &str) -> testkit::Result { + let output = self.sandbox.exec(["sh", "-c", script]).await?; + testkit::ensure!( + output.exit_code == 0, + "`{script}` exited with {}; stderr: {}", + output.exit_code, + output.stderr + ); + Ok(()) + } + + async fn upload( + &self, + source: &Path, + destination: &str, + no_git_ignore: bool, + ) -> testkit::Result { + let mut args = vec![ + "sandbox".to_string(), + "upload".to_string(), + self.sandbox.name().to_string(), + source.display().to_string(), + destination.to_string(), + ]; + if no_git_ignore { + args.push("--no-git-ignore".to_string()); + } + self.cx.cli(args).await + } + + async fn download(&self, source: &str, destination: &Path) -> testkit::Result { + self.cx + .cli([ + "sandbox", + "download", + self.sandbox.name(), + source, + &destination.display().to_string(), + ]) + .await + } + + /// Upload into existing directories, including a symlink to one and one + /// named with a trailing slash; the file must land inside each. Check the + /// destination's type, not only the exit status: tar can succeed after + /// replacing an empty directory with a file. + async fn upload_into_existing_directories(&self) -> testkit::Result { + let source = self.local("config.json"); + write(&source, "upload-destination-payload")?; + let root = format!("{}/upload destinations", self.remote_root); + self.sh(&format!( + "mkdir -p '{root}/empty' '{root}/populated' '{root}/target' '{root}/slash' && \ + printf keep > '{root}/populated/keep.txt' && \ + ln -s target '{root}/link'" + )) + .await?; + + for directory in ["empty", "populated", "link", "slash/"] { + let destination = format!("{root}/{directory}"); + self.upload(&source, &destination, true).await?.success()?; + self.sh(&format!( + "test -d '{destination}' && \ + test \"$(cat '{destination}/config.json')\" = upload-destination-payload && \ + test \"$(cat '{root}/populated/keep.txt')\" = keep && \ + test -L '{root}/link'" + )) + .await?; + } + + // A missing destination is a rename; uploading again must replace + // that file rather than treat it as a directory. + let renamed = format!("{root}/renamed.json"); + for _ in ["rename", "overwrite"] { + self.upload(&source, &renamed, true).await?.success()?; + self.sh(&format!( + "test -f '{renamed}' && test \"$(cat '{renamed}')\" = upload-destination-payload" + )) + .await?; + } + Ok(()) + } + + /// A directory with a nested file and a large binary file, and a single + /// file, survive upload then download. + async fn round_trip(&self) -> testkit::Result { + let source = self.local("roundtrip-upload"); + create_dir_all(&source.join("subdir"))?; + write(&source.join("greeting.txt"), "hello-from-local")?; + write(&source.join("subdir/nested.txt"), "nested-content")?; + let large: Vec = (0u8..=250).cycle().take(LARGE_FILE_SIZE).collect(); + fs::write(source.join("large.bin"), &large) + .map_err(|error| Error::msg(format!("write large.bin: {error}")))?; + + let remote = format!("{}/roundtrip", self.remote_root); + let uploaded = self.upload(&source, &remote, false).await?.success()?; + let warning = message(&uploaded); + testkit::ensure!( + warning.contains("outside a Git work tree") + && warning.contains(".gitignore rules are not applied"), + "upload outside a Git work tree did not warn that filtering is off: {warning}" + ); + + let destination = self.local("roundtrip-download"); + create_dir_all(&destination)?; + self.download(&format!("{remote}/roundtrip-upload"), &destination) + .await? + .success()?; + require_text(&destination.join("greeting.txt"), "hello-from-local")?; + require_text(&destination.join("subdir/nested.txt"), "nested-content")?; + let actual = fs::read(destination.join("large.bin")) + .map_err(|error| Error::msg(format!("read large.bin: {error}")))?; + testkit::ensure!( + actual == large, + "large file changed in the round trip: sent {} bytes, received {}", + large.len(), + actual.len() + ); + + let single = self.local("single.txt"); + write(&single, "single-file-payload")?; + let remote_single = format!("{}/single.txt", self.remote_root); + self.upload(&single, &remote_single, false) + .await? + .success()?; + let single_destination = self.local("single-download"); + create_dir_all(&single_destination)?; + self.download(&remote_single, &single_destination) + .await? + .success()?; + require_text( + &single_destination.join("single.txt"), + "single-file-payload", + ) + } + + async fn download_file(&self) -> testkit::Result { + let remote = format!("{}/download-file.txt", self.remote_root); + self.sh(&format!("printf greeting-payload > '{remote}'")) + .await?; + let destination = self.local("download-file"); + create_dir_all(&destination)?; + self.download(&remote, &destination).await?.success()?; + require_text(&destination.join("download-file.txt"), "greeting-payload") + } + + async fn download_directory(&self) -> testkit::Result { + let remote = format!("{}/tree", self.remote_root); + self.sh(&format!( + "mkdir -p '{remote}/sub' && printf top-level > '{remote}/root.txt' && \ + printf nested > '{remote}/sub/child.txt'" + )) + .await?; + let destination = self.local("download-directory"); + create_dir_all(&destination)?; + self.download(&remote, &destination).await?.success()?; + require_text(&destination.join("root.txt"), "top-level")?; + require_text(&destination.join("sub/child.txt"), "nested") + } + + async fn gitignore_filtering(&self) -> testkit::Result { + let repository = self.local("filter-repo"); + create_dir_all(&repository.join("build"))?; + git(&repository, &["init", "--quiet"])?; + write(&repository.join(".gitignore"), "*.log\nbuild/\n")?; + write(&repository.join("tracked.txt"), "i-am-tracked")?; + write(&repository.join("ignored.log"), "i-should-be-filtered")?; + write(&repository.join("build/output.bin"), "build-artifact")?; + git(&repository, &["add", "."])?; + + let remote = format!("{}/filtered", self.remote_root); + self.upload(&repository, &remote, false).await?.success()?; + let destination = self.local("filter-download"); + create_dir_all(&destination)?; + self.download(&remote, &destination).await?.success()?; + let uploaded = destination.join("filter-repo"); + require_text(&uploaded.join("tracked.txt"), "i-am-tracked")?; + require_exists(&uploaded.join(".gitignore"))?; + require_absent(&uploaded.join("ignored.log"))?; + require_absent(&uploaded.join("build")) + } + + async fn single_file_from_git_repo(&self) -> testkit::Result { + let repository = self.local("single-repo"); + create_dir_all(&repository.join("nested"))?; + git(&repository, &["init", "--quiet"])?; + write(&repository.join(".gitignore"), "*.log\n")?; + write( + &repository.join("nested/config.txt"), + "single-file-from-repo", + )?; + write(&repository.join("tracked.txt"), "should-not-upload")?; + write(&repository.join("ignored.log"), "ignored")?; + + let remote = format!("{}/single-from-repo", self.remote_root); + self.upload(&repository.join("nested/config.txt"), &remote, false) + .await? + .success()?; + let destination = self.local("single-repo-download"); + create_dir_all(&destination)?; + self.download(&remote, &destination).await?.success()?; + require_text(&destination.join("config.txt"), "single-file-from-repo")?; + require_absent(&destination.join("tracked.txt"))?; + require_absent(&destination.join("ignored.log")) + } + + async fn ignored_directory_requires_override(&self) -> testkit::Result { + let remote_seed = format!("{}/runs/test.json", self.remote_root); + self.sh(&format!( + "mkdir -p '{}/runs' && printf downloaded-payload > '{remote_seed}'", + self.remote_root + )) + .await?; + + let repository = self.local("override-repo"); + let runs = repository.join("runs"); + create_dir_all(&runs)?; + git(&repository, &["init", "--quiet"])?; + write(&repository.join(".gitignore"), "runs/\n")?; + self.download(&remote_seed, &runs).await?.success()?; + require_exists(&runs.join("test.json"))?; + + let remote = format!("{}/reuploaded", self.remote_root); + let rejected = self.upload(&runs, &remote, false).await?.failure()?; + let output = message(&rejected); + testkit::ensure!( + output.contains("filtering selected no files") && output.contains("--no-git-ignore"), + "the rejected upload did not explain the --no-git-ignore override: {output}" + ); + self.sh(&format!("test ! -e '{remote}'")).await?; + + self.upload(&runs, &remote, true).await?.success()?; + let destination = self.local("override-download"); + create_dir_all(&destination)?; + self.download(&remote, &destination).await?.success()?; + require_text(&destination.join("runs/test.json"), "downloaded-payload") + } + + async fn reject_workspace_escape(&self) -> testkit::Result { + let etc_link = format!("{}/etc-link", self.remote_root); + let passwd_link = format!("{}/passwd-link", self.remote_root); + self.sh(&format!( + "ln -s /etc '{etc_link}' && ln -s /etc/passwd '{passwd_link}'" + )) + .await?; + let destination = self.local("workspace-escape"); + create_dir_all(&destination)?; + + for source in [etc_link.clone(), passwd_link, format!("{etc_link}/passwd")] { + let rejected = self.download(&source, &destination).await?.failure()?; + let output = message(&rejected); + testkit::ensure!( + output.contains("resolves to") + && output.contains("outside the") + && output.contains("sandbox workspace"), + "download of {source:?} was not rejected as outside the workspace: {output}" + ); + } + require_absent(&destination.join("passwd"))?; + require_absent(&destination.join("etc-link")) + } + + async fn download_dash_leading_name(&self) -> testkit::Result { + let remote = format!("{}/--checkpoint-action=evil", self.remote_root); + self.sh(&format!("printf dash-payload > '{remote}'")) + .await?; + let destination = self.local("dash-leading"); + create_dir_all(&destination)?; + self.download(&remote, &destination).await?.success()?; + require_text( + &destination.join("--checkpoint-action=evil"), + "dash-payload", + ) + } +} + +/// The CLI's stdout and stderr as one line of words. Error reports wrap to +/// the terminal width and draw a gutter, so match against this instead. +fn message(output: &CliOutput) -> String { + format!("{} {}", output.stdout, output.stderr) + .replace(['│', '×', '╰', '─', '╭', '├'], " ") + .split_whitespace() + .collect::>() + .join(" ") +} + +fn git(repository: &Path, args: &[&str]) -> testkit::Result { + let output = std::process::Command::new("git") + .args(args) + .current_dir(repository) + .output() + .map_err(|error| Error::msg(format!("run git {}: {error}", args.join(" "))))?; + testkit::ensure!( + output.status.success(), + "git {} failed in {}: {}", + args.join(" "), + repository.display(), + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) +} + +fn write(path: &Path, contents: &str) -> testkit::Result { + fs::write(path, contents) + .map_err(|error| Error::msg(format!("write {}: {error}", path.display()))) +} + +fn create_dir_all(path: &Path) -> testkit::Result { + fs::create_dir_all(path) + .map_err(|error| Error::msg(format!("create {}: {error}", path.display()))) +} + +fn require_text(path: &Path, expected: &str) -> testkit::Result { + let actual = fs::read_to_string(path) + .map_err(|error| Error::msg(format!("read {}: {error}", path.display())))?; + testkit::ensure!( + actual == expected, + "{} contains {actual:?}; expected {expected:?}", + path.display() + ); + Ok(()) +} + +fn require_exists(path: &Path) -> testkit::Result { + testkit::ensure!(path.exists(), "{} does not exist", path.display()); + Ok(()) +} + +fn require_absent(path: &Path) -> testkit::Result { + testkit::ensure!(!path.exists(), "{} exists but should not", path.display()); + Ok(()) +} diff --git a/tests/suites/sandbox/lifecycle/Cargo.toml b/tests/suites/sandbox/lifecycle/Cargo.toml index e71d509b45..827cacbf9e 100644 --- a/tests/suites/sandbox/lifecycle/Cargo.toml +++ b/tests/suites/sandbox/lifecycle/Cargo.toml @@ -13,3 +13,4 @@ publish = false [dependencies] openshell-testkit = { path = "../../../testkit/testkit" } +serde_json = "1" diff --git a/tests/suites/sandbox/lifecycle/tests/cli.rs b/tests/suites/sandbox/lifecycle/tests/cli.rs new file mode 100644 index 0000000000..d26eed9bb5 --- /dev/null +++ b/tests/suites/sandbox/lifecycle/tests/cli.rs @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Sandbox lifecycle through the `openshell` CLI. +//! +//! Ported from the `smoke` and `sandbox-lifecycle` scenarios of the old +//! `conformance/cli` suite. The other lifecycle tests act through the SDK; these +//! check that the CLI commands for create, get, list, exec, stop, start, and +//! delete work against the same gateway. + +use serde_json::Value; +use testkit::Context; + +const PAGE_SIZE: &str = "1000"; +/// More pages than any test gateway should have; stops a repeated token from +/// looping forever. +const MAX_PAGES: usize = 1000; + +/// `openshell status` succeeds, and a sandbox created with the CLI is +/// retrievable and Ready, appears in `sandbox list`, runs `sandbox exec`, and is +/// gone from `sandbox list` after `sandbox delete`. +#[testkit::test] +async fn cli_create_exec_list_delete(cx: &mut Context) -> testkit::Result { + cx.cli(["status"]).await?.success()?; + + let name = cx.name("cs"); + cx.track(&name); + cx.cli(["sandbox", "create", "--name", &name, "--detach"]) + .await? + .success()?; + + let sandbox = cx.adopt(&name).await?; + let got: Value = cx + .cli(["sandbox", "get", &name, "--output", "json"]) + .await? + .success()? + .json()?; + testkit::ensure!( + got["name"] == name.as_str(), + "sandbox get returned {:?}; expected {name:?}", + got["name"] + ); + testkit::ensure!( + got["phase"] == "Ready", + "sandbox {name} is in phase {}; expected Ready", + got["phase"] + ); + + let listed = find_listed(cx, &name).await?; + testkit::ensure!(listed.is_some(), "sandbox {name} is not in sandbox list"); + + let marker = format!("openshell-e2e-{}", cx.run_id()); + let exec = cx + .cli([ + "sandbox", "exec", "--name", &name, "--no-tty", "--", "echo", &marker, + ]) + .await? + .success()?; + testkit::ensure!( + exec.stdout == format!("{marker}\n"), + "sandbox exec stdout was {:?}; expected {:?}", + exec.stdout, + format!("{marker}\n") + ); + + cx.cli(["sandbox", "delete", &name]).await?.success()?; + wait_unlisted(cx, &name).await?; + cx.expect_absent(&sandbox).await +} + +/// A sandbox created with the CLI can be stopped, rejects `sandbox exec` while +/// stopped, starts again, and can be stopped and deleted. +#[testkit::test] +async fn cli_stop_start_delete(cx: &mut Context) -> testkit::Result { + let name = cx.name("cl"); + cx.track(&name); + cx.cli([ + "sandbox", + "create", + "--name", + &name, + "--detach", + "--no-tty", + "--", + "sh", + "-c", + "exec sleep infinity", + ]) + .await? + .success()?; + let sandbox = cx.adopt(&name).await?; + sandbox.wait_ready().await?; + + cx.cli(["sandbox", "stop", &name]).await?.success()?; + sandbox + .wait_phase(testkit::sdk::SandboxPhase::Stopped, testkit::STOP_TIMEOUT) + .await?; + cx.cli(["sandbox", "exec", "--name", &name, "--no-tty", "--", "true"]) + .await? + .failure()?; + + cx.cli(["sandbox", "start", &name]).await?.success()?; + sandbox.wait_ready().await?; + cx.cli(["sandbox", "stop", &name]).await?.success()?; + sandbox + .wait_phase(testkit::sdk::SandboxPhase::Stopped, testkit::STOP_TIMEOUT) + .await?; + + cx.cli(["sandbox", "delete", &name]).await?.success()?; + wait_unlisted(cx, &name).await?; + cx.expect_absent(&sandbox).await +} + +/// Wait until `sandbox list` no longer shows the sandbox. +async fn wait_unlisted(cx: &Context, name: &str) -> testkit::Result { + testkit::poll_until( + &format!("sandbox {name} to leave sandbox list"), + testkit::DELETE_TIMEOUT, + std::time::Duration::from_secs(1), + async || { + Ok(match find_listed(cx, name).await? { + None => testkit::Poll::Ready(()), + Some(phase) => testkit::Poll::Pending(format!("still listed in phase {phase}")), + }) + }, + ) + .await +} + +/// The phase of the sandbox in `sandbox list`, if it is listed. Follows every +/// page. +async fn find_listed(cx: &Context, name: &str) -> testkit::Result> { + let mut token = String::new(); + for _ in 0..MAX_PAGES { + let page: Value = cx + .cli([ + "sandbox", + "list", + "--page-size", + PAGE_SIZE, + "--page-token", + &token, + "--output", + "json", + ]) + .await? + .success()? + .json()?; + if let Some(entry) = page["sandboxes"] + .as_array() + .into_iter() + .flatten() + .find(|entry| entry["name"] == name) + { + return Ok(Some( + entry["phase"].as_str().unwrap_or_default().to_string(), + )); + } + token = page["next_page_token"] + .as_str() + .unwrap_or_default() + .to_string(); + if token.is_empty() { + return Ok(None); + } + } + Err(testkit::Error::msg(format!( + "sandbox list has more than {MAX_PAGES} pages" + ))) +} diff --git a/tests/suites/sandbox/lifecycle/tests/lifecycle.rs b/tests/suites/sandbox/lifecycle/tests/lifecycle.rs index a517f378d4..02e4f6f117 100644 --- a/tests/suites/sandbox/lifecycle/tests/lifecycle.rs +++ b/tests/suites/sandbox/lifecycle/tests/lifecycle.rs @@ -3,10 +3,12 @@ //! Sandbox lifecycle suite. //! -//! The first test is adapted from the `sandbox-lifecycle` suite in -//! NVIDIA/OpenShell#4629 by Evan Lezar. +//! These tests are adapted from the `sandbox-lifecycle` suite in +//! NVIDIA/OpenShell#4629 by Evan Lezar. Tests that need a driver with a +//! persistent workspace live in `persistent_workspace.rs`. use testkit::Context; +use testkit::sdk::{SandboxPhase, SandboxRestartPolicy}; /// A long-running sandbox becomes ready, runs a command with exec, and is gone /// from get and list after delete. @@ -37,3 +39,127 @@ async fn long_running_exec_delete(cx: &mut Context) -> testkit::Result { sandbox.delete().await?; cx.expect_absent(&sandbox).await } + +/// A sandbox whose main command exits 0, with restart policy Never, reaches +/// Completed with exit code 0 and is not restarted. It is gone from get and +/// list after delete. +#[testkit::test] +async fn run_to_completion_exit_delete(cx: &mut Context) -> testkit::Result { + let sandbox = cx + .sandbox("rtc") + .command(["sh", "-c", "exit 0"]) + .restart_policy(SandboxRestartPolicy::Never) + .create() + .await?; + + let finished = sandbox.wait_finished().await?; + testkit::ensure!( + finished.phase == SandboxPhase::Completed, + "sandbox finished in phase {:?} with exit code {:?}; expected Completed", + finished.phase, + finished.exit_code + ); + testkit::ensure!( + finished.exit_code == Some(0), + "exit code was {:?}; expected Some(0)", + finished.exit_code + ); + testkit::ensure!( + finished.restart_count == 0, + "restart count was {}; expected 0", + finished.restart_count + ); + + // Completed is final under Never: a later read must not show a restart. + let again = sandbox.get().await?; + testkit::ensure!( + again.phase == SandboxPhase::Completed && again.restart_count == 0, + "sandbox left Completed: phase {:?}, restart count {}", + again.phase, + again.restart_count + ); + + sandbox.delete().await?; + cx.expect_absent(&sandbox).await +} + +/// A sandbox created with the gateway's default command becomes Ready, is +/// listed as Ready, runs a command with exec, and is gone after delete. +#[testkit::test] +async fn default_command_sandbox_is_listed_ready(cx: &mut Context) -> testkit::Result { + let sandbox = cx.sandbox("dc").create().await?; + let ready = sandbox.wait_ready().await?; + testkit::ensure!( + ready.name == sandbox.name(), + "get returned sandbox {:?}; expected {:?}", + ready.name, + sandbox.name() + ); + cx.expect_listed(&sandbox, SandboxPhase::Ready).await?; + + let marker = format!("openshell-e2e-{}", cx.run_id()); + let output = sandbox.exec(["echo", marker.as_str()]).await?; + testkit::ensure!( + output.exit_code == 0 && output.stdout == format!("{marker}\n"), + "exec exited {} with stdout {:?}; stderr: {}", + output.exit_code, + output.stdout, + output.stderr + ); + + sandbox.delete().await?; + cx.expect_absent(&sandbox).await +} + +/// A stopped sandbox rejects exec, accepts a second stop, and returns to +/// Ready when started. +#[testkit::test] +async fn restart_stopped_sandbox(cx: &mut Context) -> testkit::Result { + let sandbox = cx + .sandbox("rs") + .command(["sleep", "infinity"]) + .create() + .await?; + sandbox.wait_ready().await?; + + sandbox.stop().await?; + match sandbox.exec(["true"]).await { + Err(_) => {} + Ok(output) => testkit::ensure!( + output.exit_code != 0, + "exec succeeded while the sandbox was stopped" + ), + } + // Stop is idempotent. + let again = sandbox.stop().await?; + testkit::ensure!( + again.phase == SandboxPhase::Stopped, + "a second stop left the sandbox {:?}", + again.phase + ); + + sandbox.start().await?; + let output = sandbox.exec(["echo", "started"]).await?; + testkit::ensure!( + output.exit_code == 0 && output.stdout == "started\n", + "exec after start exited {} with stdout {:?}; stderr: {}", + output.exit_code, + output.stdout, + output.stderr + ); + Ok(()) +} + +/// A stopped sandbox can be deleted, and is then gone from get and list. +#[testkit::test] +async fn stopped_sandbox_can_be_deleted(cx: &mut Context) -> testkit::Result { + let sandbox = cx + .sandbox("sd") + .command(["sleep", "infinity"]) + .create() + .await?; + sandbox.wait_ready().await?; + sandbox.stop().await?; + sandbox.delete().await?; + cx.expect_absent(&sandbox).await +} diff --git a/tests/suites/sandbox/lifecycle/tests/persistent_workspace.rs b/tests/suites/sandbox/lifecycle/tests/persistent_workspace.rs new file mode 100644 index 0000000000..54c780a70a --- /dev/null +++ b/tests/suites/sandbox/lifecycle/tests/persistent_workspace.rs @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Lifecycle tests that need a compute driver with a persistent workspace. +//! +//! Adapted from the `sandbox-lifecycle` suite in NVIDIA/OpenShell#4629 by Evan +//! Lezar. Docker, Podman, Kubernetes, and MicroVM keep `/sandbox` across stop +//! and start. The Microsoft MXC driver cannot start a stopped sandbox at all, +//! so exclude these tests there with the nextest filter +//! `-E 'not binary(persistent_workspace)'`. + +use testkit::Context; + +/// Files in the workspace survive stop and start, and start runs the main +/// command again. +#[testkit::test] +async fn persistent_workspace_survives_stop_start(cx: &mut Context) -> testkit::Result { + const SENTINEL: &str = "/sandbox/.openshell-stop-start-sentinel"; + const RUN_COUNT: &str = "/sandbox/.openshell-main-run-count"; + let main = format!( + "count=0; test ! -f '{RUN_COUNT}' || count=$(cat '{RUN_COUNT}'); \ + count=$((count + 1)); printf '%s\\n' \"$count\" > '{RUN_COUNT}'; \ + exec sleep infinity" + ); + let sandbox = cx + .sandbox("kw") + .command(["sh", "-c", main.as_str()]) + .create() + .await?; + sandbox.wait_ready().await?; + + let sentinel = format!("openshell-stop-start-{}", cx.run_id()); + let write = sandbox + .exec([ + "sh", + "-c", + &format!("printf '%s\\n' '{sentinel}' > '{SENTINEL}' && sync"), + ]) + .await?; + testkit::ensure!( + write.exit_code == 0, + "writing the sentinel exited {}; stderr: {}", + write.exit_code, + write.stderr + ); + + sandbox.stop().await?; + sandbox.start().await?; + + let read = sandbox.exec(["cat", SENTINEL]).await?; + testkit::ensure!( + read.stdout == format!("{sentinel}\n"), + "sentinel after restart is {:?}; stderr: {}", + read.stdout, + read.stderr + ); + let runs = sandbox.exec(["cat", RUN_COUNT]).await?; + testkit::ensure!( + runs.stdout == "2\n", + "main command ran {:?} times; expected 2", + runs.stdout.trim() + ); + Ok(()) +} diff --git a/tests/suites/sandbox/oci-image/Cargo.toml b/tests/suites/sandbox/oci-image/Cargo.toml new file mode 100644 index 0000000000..02b556431e --- /dev/null +++ b/tests/suites/sandbox/oci-image/Cargo.toml @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-test-sandbox-oci-image" +description = "End-to-end suite: OCI image behavior" +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" } +serde_json = "1" +tempfile = "3" diff --git a/tests/suites/features/oci-image/tests/oci_image.rs b/tests/suites/sandbox/oci-image/tests/oci_image.rs similarity index 52% rename from tests/suites/features/oci-image/tests/oci_image.rs rename to tests/suites/sandbox/oci-image/tests/oci_image.rs index a145782e4b..47677f83b4 100644 --- a/tests/suites/features/oci-image/tests/oci_image.rs +++ b/tests/suites/sandbox/oci-image/tests/oci_image.rs @@ -4,24 +4,21 @@ //! OCI image behavior shared by the Docker and Podman compute drivers. //! //! Each test builds a small image with the container engine that backs the -//! target gateway, creates a sandbox from it through the candidate CLI, and -//! checks the process identity, privileges, workspace, and image content seen -//! by both the sandbox main process and `sandbox exec`. One scenario also -//! inspects the containers the driver created, through the same engine. +//! target gateway, creates a sandbox from it through the CLI, and checks the +//! process identity, privileges, workspace, and image content seen by both the +//! sandbox main process and `sandbox exec`. One scenario also inspects the +//! containers the driver created, through the same engine. //! -//! `OPENSHELL_BIN` names the candidate CLI. `OPENSHELL_TEST_CONTAINER_ENGINE` -//! is the command for the gateway's container engine and image store, such as -//! `docker`, `podman`, or `sudo -n podman`. +//! `OPENSHELL_TEST_CONTAINER_ENGINE` is the command for the gateway's container +//! engine and image store, such as `docker`, `podman`, or `sudo -n podman`. +//! These tests need a gateway backed by one of those engines. use std::process::Command; -use std::time::Duration; -use openshell_conformance::OpenShellRunner; +use testkit::{Context, Error, Result}; const BASE_IMAGE: &str = "nvcr.io/nvidia/base/ubuntu:24.04"; const ENGINE_ENV: &str = "OPENSHELL_TEST_CONTAINER_ENGINE"; -const CREATE_TIMEOUT: Duration = Duration::from_mins(10); -const COMMAND_TIMEOUT: Duration = Duration::from_mins(2); /// Sandbox condition reason for an image `WORKDIR` the sandbox identity /// cannot use. const WORKSPACE_VALIDATION_FAILED: &str = "WorkspaceValidationFailed"; @@ -41,34 +38,31 @@ network_policies: {} /// A named image user that owns its custom `WORKDIR`. Existing image content /// keeps its ownership. -#[tokio::test] -async fn custom_workdir_with_named_user() { - run( - "oci-image/custom-workdir-named-user", - async |runner, engine| { - let image = engine.build_image( - "named-workdir", - &format!( - "FROM {BASE_IMAGE} +#[testkit::test] +async fn custom_workdir_with_named_user(cx: &mut Context) -> Result { + scenario(cx, async |cx, engine| { + let image = engine.build_image( + "named-workdir", + &format!( + "FROM {BASE_IMAGE} RUN groupadd -g 1235 appstaff && useradd -m -u 1234 -g appstaff app WORKDIR /workspace/project -RUN printf root-owned > root-owned.txt && chown app:appstaff . && \ +RUN printf root-owned > root-owned.txt && chown app:appstaff . && \\ printf custom-image-e2e-marker > /etc/oci-image-marker USER app " - ), - )?; - let checks = format!( - "{} test \"$(stat -c %u:%g .)\" = 1234:1235; \ + ), + )?; + let checks = format!( + "{} test \"$(stat -c %u:%g .)\" = 1234:1235; \ test \"$(cat /etc/oci-image-marker)\" = custom-image-e2e-marker; \ test \"$(stat -c %u:%g /etc/oci-image-marker)\" = 0:0;", - workspace_checks("1234:1235", "/workspace/project", true) - ); - let sandbox = create_sandbox(runner, "nu", &image, None, &checks).await?; - file_transfer_uses_workspace(runner, &sandbox).await - }, - ) - .await; + workspace_checks("1234:1235", "/workspace/project", true) + ); + let sandbox = create_sandbox(cx, "nu", &image, None, &checks).await?; + file_transfer_uses_workspace(cx, &sandbox).await + }) + .await } /// A numeric image user without passwd entries can reach a custom `WORKDIR` @@ -76,15 +70,13 @@ USER app /// `process`, so the image `USER` is the only source of the sandbox identity. /// The driver's containers launch the inspected image and keep the /// supervisor separate from the workload. -#[tokio::test] -async fn custom_workdir_with_numeric_user_and_private_parents() { - run( - "oci-image/custom-workdir-numeric-user", - async |runner, engine| { - let image = engine.build_image( - "numeric-workdir", - &format!( - "FROM {BASE_IMAGE} +#[testkit::test] +async fn custom_workdir_with_numeric_user_and_private_parents(cx: &mut Context) -> Result { + scenario(cx, async |cx, engine| { + let image = engine.build_image( + "numeric-workdir", + &format!( + "FROM {BASE_IMAGE} RUN mkdir -p /home/app/project && \\ chown 2345:2346 /home/app /home/app/project && \\ chmod 0700 /home/app /home/app/project @@ -92,42 +84,40 @@ WORKDIR /home/app/project RUN printf root-owned > root-owned.txt USER 2345:2346 " - ), - )?; - let policy = PolicyFile::new(IMAGE_IDENTITY_POLICY)?; - let checks = workspace_checks("2345:2346", "/home/app/project", true); - let sandbox = - create_sandbox(runner, "pu", &image, Some(policy.path()?), &checks).await?; - container_checks(engine, &sandbox, &image, "2345:2346") - }, - ) - .await; + ), + )?; + let policy = PolicyFile::new(IMAGE_IDENTITY_POLICY)?; + let checks = workspace_checks("2345:2346", "/home/app/project", true); + let sandbox = create_sandbox(cx, "pu", &image, Some(policy.path()?), &checks).await?; + container_checks(engine, &sandbox, &image, "2345:2346") + }) + .await } /// An image without `USER`, with no policy identity, runs as the numeric /// default `1000:1000`. The base image has an account with UID 1000 in other /// groups, such as `sudo`, and the default identity does not get them. -#[tokio::test] -async fn userless_image_uses_default_identity() { - run("oci-image/userless-default", async |runner, engine| { +#[testkit::test] +async fn userless_image_uses_default_identity(cx: &mut Context) -> Result { + scenario(cx, async |cx, engine| { let image = engine.build_image("userless", &format!("FROM {BASE_IMAGE}\n"))?; let policy = PolicyFile::new(IMAGE_IDENTITY_POLICY)?; let checks = format!( "{} test \"$(id -G)\" = 1000;", workspace_checks("1000:1000", "/sandbox", false) ); - let sandbox = create_sandbox(runner, "ud", &image, Some(policy.path()?), &checks).await?; + let sandbox = create_sandbox(cx, "ud", &image, Some(policy.path()?), &checks).await?; container_checks(engine, &sandbox, &image, "1000:1000") }) - .await; + .await } /// A policy `run_as_user` and `run_as_group` replace an image's `USER root`. /// The image has no `WORKDIR`, so the managed `/sandbox` workspace is /// writable by the policy identity and only identity resolution is tested. -#[tokio::test] -async fn policy_identity_overrides_root_image_user() { - run("oci-image/policy-over-root", async |runner, engine| { +#[testkit::test] +async fn policy_identity_overrides_root_image_user(cx: &mut Context) -> Result { + scenario(cx, async |cx, engine| { let image = engine.build_image( "root-user", &format!( @@ -148,35 +138,34 @@ process: "{} test \"$(id -G)\" = 4235;", workspace_checks("4234:4235", "/sandbox", false) ); - let sandbox = create_sandbox(runner, "pr", &image, Some(policy.path()?), &checks).await?; + let sandbox = create_sandbox(cx, "pr", &image, Some(policy.path()?), &checks).await?; container_checks(engine, &sandbox, &image, "4234:4235") }) - .await; + .await } /// An image without a `WORKDIR` uses the managed `/sandbox` workspace, owned /// by the image user, even when the image does not contain `/sandbox`. -#[tokio::test] -async fn default_workdir_uses_managed_workspace() { - run("oci-image/default-workdir", async |runner, engine| { +#[testkit::test] +async fn default_workdir_uses_managed_workspace(cx: &mut Context) -> Result { + scenario(cx, async |cx, engine| { let image = engine.build_image( "default-workdir", &format!("FROM {BASE_IMAGE}\nUSER 2345:2346\n"), )?; let checks = workspace_checks("2345:2346", "/sandbox", false); - create_sandbox(runner, "dw", &image, None, &checks) - .await - .map(drop) + create_sandbox(cx, "dw", &image, None, &checks).await?; + Ok(()) }) - .await; + .await } /// OpenShell rejects a custom `WORKDIR` that the image user cannot write /// instead of granting the user new access to it, and reports that reason /// rather than a generic startup failure. -#[tokio::test] -async fn unwritable_custom_workdir_is_rejected() { - run("oci-image/unwritable-workdir", async |runner, engine| { +#[testkit::test] +async fn unwritable_custom_workdir_is_rejected(cx: &mut Context) -> Result { + scenario(cx, async |cx, engine| { // First prove that this user, workspace and command can run. Only the // directory owner differs between the control and rejected image. for (suffix, owner, writable) in [("wc", "3234:3235", true), ("uw", "0:0", false)] { @@ -191,16 +180,9 @@ USER app " ), )?; - let name = track_sandbox(runner, suffix); - let create = runner - .step(format!("{suffix}/create")) - .description(if writable { - "writable control runs the same command successfully" - } else { - "sandbox creation fails before the command runs" - }) - .with_timeout(CREATE_TIMEOUT) - .run(&[ + let name = cx.track_sandbox(suffix); + let create = cx + .cli([ "sandbox", "create", "--name", @@ -213,86 +195,81 @@ USER app "-c", "echo workspace-access-marker", ]) - .await - .map_err(|error| error.to_string())?; + .await?; if writable { - create.require_success()?; - if !create.stdout().contains("workspace-access-marker") { - return Err( - create.failure_diagnostic("writable control executes the command marker") - ); - } - continue; - } - let printed = - |text: &str| create.stdout().contains(text) || create.stderr().contains(text); - if create.success() || printed("workspace-access-marker") { - return Err( - create.failure_diagnostic("sandbox creation fails before the command runs") + let create = create.success()?; + testkit::ensure!( + create.stdout.contains("workspace-access-marker"), + "writable control did not execute the command marker; `{}` stdout: {}", + create.command, + create.stdout.trim() ); + continue; } + let printed = |text: &str| create.stdout.contains(text) || create.stderr.contains(text); + testkit::ensure!( + create.exit_code != 0 && !printed("workspace-access-marker"), + "sandbox creation must fail before the command runs; `{}` exited {}\nstdout: {}\nstderr: {}", + create.command, + create.exit_code, + create.stdout.trim(), + create.stderr.trim() + ); // Require the condition reason, so another provisioning failure // cannot pass as a workspace rejection. - let sandbox = runner - .step("uw/get") - .description("rejected sandbox reports the workspace validation reason") - .with_timeout(COMMAND_TIMEOUT) - .run(&["sandbox", "get", &name, "--output", "json"]) - .await - .map_err(|error| error.to_string())?; - sandbox.require_success()?; - let reasons: Vec = sandbox - .json::() - .map_err(|error| error.to_string())?["conditions"] + let sandbox = cx + .cli(["sandbox", "get", &name, "--output", "json"]) + .await? + .success()?; + let reasons: Vec = sandbox.json::()?["conditions"] .as_array() .into_iter() .flatten() .filter_map(|condition| condition["reason"].as_str().map(str::to_string)) .collect(); - if !reasons - .iter() - .any(|reason| reason == WORKSPACE_VALIDATION_FAILED) - { - return Err(sandbox.failure_diagnostic(&format!( - "sandbox condition reason is {WORKSPACE_VALIDATION_FAILED}, got {reasons:?}" - ))); - } + testkit::ensure!( + reasons + .iter() + .any(|reason| reason == WORKSPACE_VALIDATION_FAILED), + "sandbox condition reason is {WORKSPACE_VALIDATION_FAILED}, got {reasons:?}; `{}` stdout: {}", + sandbox.command, + sandbox.stdout.trim() + ); } Ok(()) }) - .await; + .await } -async fn run( - scenario: &str, - test: impl AsyncFnOnce(&mut OpenShellRunner, &mut Engine) -> Result<(), String>, -) { - let mut runner = - OpenShellRunner::from_env(scenario).expect("candidate openshell CLI is available"); - let mut engine = Engine::from_env(); - let result = async { - runner.check_gateway_status().await?; - test(&mut runner, &mut engine).await - } - .await; - // Delete sandboxes before their images. Non-forced image removal must - // report remaining users rather than deleting workloads behind the - // gateway's back. Keep functional and sandbox failures first. - let mut errors: Vec<_> = runner.finish(result).await.err().into_iter().collect(); +/// Run `test` with a container engine, then delete the sandboxes before +/// removing the images they use. Non-forced image removal must report remaining +/// users rather than deleting workloads behind the gateway's back. The test's +/// own failure is reported first. +async fn scenario( + cx: &mut Context, + test: impl AsyncFnOnce(&mut Context, &mut Engine) -> Result, +) -> Result { + let mut engine = Engine::from_env(cx.run_id())?; + let mut errors: Vec = test(cx, &mut engine) + .await + .err() + .map(|error| error.to_string()) + .into_iter() + .collect(); + errors.extend( + cx.delete_sandboxes() + .await + .err() + .map(|error| error.to_string()), + ); errors.extend(engine.remove_images()); - if !errors.is_empty() { - panic!("{scenario} failed:\n{}", errors.join("\n")); + if errors.is_empty() { + Ok(()) + } else { + Err(Error::msg(errors.join("\n"))) } } -/// Name a sandbox for this scenario and register it for cleanup. Sandbox -/// names are limited to 19 characters on some drivers. -fn track_sandbox(runner: &mut OpenShellRunner, short: &str) -> String { - let name = format!("oi-{}-{short}", runner.id()); - runner.track_sandbox(&name); - name -} - /// Shell checks for the identity, working directory, and `HOME` of a sandbox /// child. The image user gains no capabilities and cannot read the sandbox's /// control credentials. A custom workspace is the image directory itself, not @@ -326,14 +303,15 @@ fn workspace_checks(identity: &str, workspace: &str, image_file: bool) -> String /// Create a detached sandbox whose main process runs `checks` and writes to /// the workspace, then run the same checks and a write through `sandbox exec`. +/// Returns the sandbox name. async fn create_sandbox( - runner: &mut OpenShellRunner, + cx: &Context, short: &str, image: &TestImage, policy: Option<&str>, checks: &str, -) -> Result { - let name = track_sandbox(runner, short); +) -> Result { + let name = cx.track_sandbox(short); let main = format!( "(set -eu; {checks} touch main-write) >/tmp/oci-main.log 2>&1; \ echo $? >/tmp/oci-main.status; exec sleep infinity" @@ -343,14 +321,7 @@ async fn create_sandbox( args.extend(["--policy", policy]); } args.extend(["--detach", "--", "sh", "-c", &main]); - run_ok( - runner, - format!("{short}/create"), - "sandbox starts from the test image", - CREATE_TIMEOUT, - &args, - ) - .await?; + cx.cli(args).await?.success()?; let script = format!( "set -eu; i=0; \ @@ -363,96 +334,77 @@ async fn create_sandbox( fi; \ test -f main-write; {checks} touch exec-write" ); - exec( - runner, - &name, - format!("{short}/exec"), - "main process and exec children see the image workspace", - &script, - ) - .await?; + exec(cx, &name, &script).await?; Ok(name) } /// Upload and download default to paths relative to the image workspace. -async fn file_transfer_uses_workspace( - runner: &OpenShellRunner, - sandbox: &str, -) -> Result<(), String> { - let local = tempfile::tempdir().map_err(|error| format!("create temp dir: {error}"))?; +async fn file_transfer_uses_workspace(cx: &Context, sandbox: &str) -> Result { + let local = tempfile::tempdir().map_err(|error| Error::from_error(&error))?; let upload = local.path().join("oci-transfer.txt"); - std::fs::write(&upload, "oci-transfer-ok").map_err(|error| format!("write upload: {error}"))?; - let upload = upload.to_str().ok_or("upload path is not UTF-8")?; - run_ok( - runner, - "named/upload", - "upload without a destination writes to the workspace", - COMMAND_TIMEOUT, - &["sandbox", "upload", sandbox, upload, "--no-git-ignore"], - ) - .await?; + std::fs::write(&upload, "oci-transfer-ok").map_err(|error| Error::from_error(&error))?; + // Upload without a destination writes to the workspace. + cx.cli([ + "sandbox", + "upload", + sandbox, + utf8(&upload)?, + "--no-git-ignore", + ]) + .await? + .success()?; + // The uploaded file is in the workspace. exec( - runner, + cx, sandbox, - "named/uploaded", - "uploaded file is in the workspace", "test \"$(cat oci-transfer.txt)\" = oci-transfer-ok", ) .await?; + // Download resolves relative paths in the workspace. let download = local.path().join("downloaded.txt"); - let download_path = download.to_str().ok_or("download path is not UTF-8")?; - run_ok( - runner, - "named/download", - "download resolves relative paths in the workspace", - COMMAND_TIMEOUT, - &[ - "sandbox", - "download", - sandbox, - "oci-transfer.txt", - download_path, - ], - ) - .await?; + cx.cli([ + "sandbox", + "download", + sandbox, + "oci-transfer.txt", + utf8(&download)?, + ]) + .await? + .success()?; let downloaded = - std::fs::read_to_string(&download).map_err(|error| format!("read download: {error}"))?; - if downloaded != "oci-transfer-ok" { - return Err(format!( - "downloaded file has unexpected content: {downloaded:?}" - )); - } + std::fs::read_to_string(&download).map_err(|error| Error::from_error(&error))?; + testkit::ensure!( + downloaded == "oci-transfer-ok", + "downloaded file has unexpected content: {downloaded:?}" + ); // A directory upload merges into an existing workspace directory. let merge = local.path().join("merge-upload"); - std::fs::create_dir(&merge).map_err(|error| format!("create merge dir: {error}"))?; + std::fs::create_dir(&merge).map_err(|error| Error::from_error(&error))?; std::fs::write(merge.join("conflict.txt"), "local-conflict") .and_then(|()| std::fs::write(merge.join("added.txt"), "local-added")) - .map_err(|error| format!("write merge files: {error}"))?; - let merge = merge.to_str().ok_or("merge path is not UTF-8")?; + .map_err(|error| Error::from_error(&error))?; exec( - runner, + cx, sandbox, - "named/merge-seed", - "seed an existing workspace directory", "mkdir merge-upload && printf remote-conflict > merge-upload/conflict.txt \ && printf remote-preserved > merge-upload/unrelated.txt", ) .await?; - run_ok( - runner, - "named/merge-upload", - "directory upload merges into the existing directory", - COMMAND_TIMEOUT, - &["sandbox", "upload", sandbox, merge, "--no-git-ignore"], - ) - .await?; + cx.cli([ + "sandbox", + "upload", + sandbox, + utf8(&merge)?, + "--no-git-ignore", + ]) + .await? + .success()?; + // Upload overwrites conflicts and keeps unrelated files. exec( - runner, + cx, sandbox, - "named/merged", - "upload overwrites conflicts and keeps unrelated files", "test \"$(cat merge-upload/conflict.txt)\" = local-conflict \ && test \"$(cat merge-upload/added.txt)\" = local-added \ && test \"$(cat merge-upload/unrelated.txt)\" = remote-preserved", @@ -465,35 +417,30 @@ async fn file_transfer_uses_workspace( /// starts directly as the sandbox `identity` without a network or the /// supervisor's credentials. The supervisor runs in a separate non-root /// container. -fn container_checks( - engine: &Engine, - sandbox: &str, - image: &TestImage, - identity: &str, -) -> Result<(), String> { +fn container_checks(engine: &Engine, sandbox: &str, image: &TestImage, identity: &str) -> Result { let workload = engine.container_for_role(sandbox, "sandbox")?; let supervisor = engine.container_for_role(sandbox, "supervisor")?; - if workload == supervisor { - return Err(format!("{sandbox} has no separate supervisor container")); - } + testkit::ensure!( + workload != supervisor, + "{sandbox} has no separate supervisor container" + ); let launched = engine.inspect_container(&workload, "{{.Image}}")?; let digest = |id: &str| id.trim_start_matches("sha256:").to_string(); - if digest(&launched) != digest(&image.id) { - return Err(format!( - "workload launched image {launched}, expected the built image {}", - image.id - )); - } + testkit::ensure!( + digest(&launched) == digest(&image.id), + "workload launched image {launched}, expected the built image {}", + image.id + ); let user = engine.inspect_container(&workload, "{{.Config.User}}")?; - if user != identity { - return Err(format!("workload starts as {user:?}, expected {identity}")); - } + testkit::ensure!( + user == identity, + "workload starts as {user:?}, expected {identity}" + ); let network = engine.inspect_container(&workload, "{{.HostConfig.NetworkMode}}")?; - if network != "none" { - return Err(format!( - "workload network mode is {network:?}, expected none" - )); - } + testkit::ensure!( + network == "none", + "workload network mode is {network:?}, expected none" + ); let mounts = engine.inspect_container( &workload, "{{range .Mounts}}{{println .Destination}}{{end}}", @@ -501,73 +448,48 @@ fn container_checks( if let Some(mount) = mounts.lines().find(|mount| { mount.starts_with("/etc/openshell/tls") || mount.starts_with("/.openshell/supervisor") }) { - return Err(format!("workload mounts supervisor credentials at {mount}")); + return Err(Error::msg(format!( + "workload mounts supervisor credentials at {mount}" + ))); } let supervisor_user = engine.inspect_container(&supervisor, "{{.Config.User}}")?; - if matches!( - supervisor_user.split(':').next(), - None | Some("" | "0" | "root") - ) { - return Err(format!("supervisor runs as root ({supervisor_user:?})")); - } + testkit::ensure!( + !matches!( + supervisor_user.split(':').next(), + None | Some("" | "0" | "root") + ), + "supervisor runs as root ({supervisor_user:?})" + ); Ok(()) } -/// Run a CLI step that must succeed. -async fn run_ok( - runner: &OpenShellRunner, - step: impl Into, - description: &str, - timeout: Duration, - args: &[&str], -) -> Result<(), String> { - runner - .step(step) - .description(description) - .with_timeout(timeout) - .run(args) - .await - .map_err(|error| error.to_string())? - .require_success() +/// Run a shell script in `sandbox` through `sandbox exec`. +async fn exec(cx: &Context, sandbox: &str, script: &str) -> Result { + cx.cli([ + "sandbox", "exec", "--name", sandbox, "--no-tty", "--", "sh", "-c", script, + ]) + .await? + .success()?; + Ok(()) } -/// Run a shell script in `sandbox` through `sandbox exec`. -async fn exec( - runner: &OpenShellRunner, - sandbox: &str, - step: impl Into, - description: &str, - script: &str, -) -> Result<(), String> { - run_ok( - runner, - step, - description, - COMMAND_TIMEOUT, - &[ - "sandbox", "exec", "--name", sandbox, "--no-tty", "--", "sh", "-c", script, - ], - ) - .await +fn utf8(path: &std::path::Path) -> Result<&str> { + path.to_str() + .ok_or_else(|| Error::msg(format!("path {} is not UTF-8", path.display()))) } /// A sandbox policy written to a temporary file for `sandbox create --policy`. struct PolicyFile(tempfile::NamedTempFile); impl PolicyFile { - fn new(contents: &str) -> Result { - let file = tempfile::NamedTempFile::new() - .map_err(|error| format!("create policy file: {error}"))?; - std::fs::write(file.path(), contents) - .map_err(|error| format!("write policy file: {error}"))?; + fn new(contents: &str) -> Result { + let file = tempfile::NamedTempFile::new().map_err(|error| Error::from_error(&error))?; + std::fs::write(file.path(), contents).map_err(|error| Error::from_error(&error))?; Ok(Self(file)) } - fn path(&self) -> Result<&str, String> { - self.0 - .path() - .to_str() - .ok_or_else(|| "policy path is not UTF-8".to_string()) + fn path(&self) -> Result<&str> { + utf8(self.0.path()) } } @@ -578,40 +500,45 @@ struct TestImage { } /// The gateway's container engine. Images it builds for a scenario are -/// removed by `run` after sandbox cleanup. +/// removed by `scenario` after sandbox cleanup. struct Engine { command: Vec, + run_id: String, images: Vec, } impl Engine { - fn from_env() -> Self { + fn from_env(run_id: &str) -> Result { let command: Vec = std::env::var(ENGINE_ENV) - .unwrap_or_else(|_| panic!("{ENGINE_ENV} must name the gateway's container engine")) + .map_err(|_| { + Error::msg(format!( + "{ENGINE_ENV} must name the gateway's container engine" + )) + })? .split_whitespace() .map(str::to_string) .collect(); - assert!(!command.is_empty(), "{ENGINE_ENV} is empty"); - Self { + testkit::ensure!(!command.is_empty(), "{ENGINE_ENV} is empty"); + Ok(Self { command, + run_id: run_id.to_string(), images: Vec::new(), - } + }) } /// Build `containerfile` and capture its image ID before sandbox creation. - fn build_image(&mut self, name: &str, containerfile: &str) -> Result { - let context = tempfile::tempdir().map_err(|error| format!("create context: {error}"))?; + fn build_image(&mut self, name: &str, containerfile: &str) -> Result { + let context = tempfile::tempdir().map_err(|error| Error::from_error(&error))?; let file = context.path().join("Containerfile"); - std::fs::write(&file, containerfile) - .map_err(|error| format!("write Containerfile: {error}"))?; - let tag = format!("localhost/openshell-test-oci-{name}:{}", std::process::id()); + std::fs::write(&file, containerfile).map_err(|error| Error::from_error(&error))?; + let tag = format!("localhost/openshell-test-oci-{name}:{}", self.run_id); self.output(&[ "build", "--file", - file.to_str().ok_or("Containerfile path is not UTF-8")?, + utf8(&file)?, "--tag", &tag, - context.path().to_str().ok_or("context path is not UTF-8")?, + utf8(context.path())?, ])?; // A failed build leaves no tag to remove. self.images.push(tag.clone()); @@ -630,7 +557,7 @@ impl Engine { /// The one container with isolation `role` for `sandbox`. Docker and /// Podman label sandbox containers the same way. - fn container_for_role(&self, sandbox: &str, role: &str) -> Result { + fn container_for_role(&self, sandbox: &str, role: &str) -> Result { let ids = self.output(&[ "ps", "-aq", @@ -641,32 +568,31 @@ impl Engine { ])?; match ids.split_whitespace().collect::>().as_slice() { [id] => Ok((*id).to_string()), - ids => Err(format!( + ids => Err(Error::msg(format!( "expected one {role} container for {sandbox}, found {ids:?}" - )), + ))), } } - fn inspect_container(&self, id: &str, format: &str) -> Result { + fn inspect_container(&self, id: &str, format: &str) -> Result { self.output(&["container", "inspect", "--format", format, id]) } /// Run the engine and return its trimmed standard output. - fn output(&self, args: &[&str]) -> Result { + fn output(&self, args: &[&str]) -> Result { let command = format!("{} {}", self.command.join(" "), args.join(" ")); let output = Command::new(&self.command[0]) .args(&self.command[1..]) .args(args) .output() - .map_err(|error| format!("failed to run {command}: {error}"))?; - if !output.status.success() { - return Err(format!( - "{command} failed ({}):\n{}{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - )); - } + .map_err(|error| Error::msg(format!("failed to run {command}: {error}")))?; + testkit::ensure!( + output.status.success(), + "{command} failed ({}):\n{}{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } } diff --git a/tests/suites/sandbox/policy-advisor/Cargo.toml b/tests/suites/sandbox/policy-advisor/Cargo.toml new file mode 100644 index 0000000000..1e2ac24ab1 --- /dev/null +++ b/tests/suites/sandbox/policy-advisor/Cargo.toml @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-test-sandbox-policy-advisor" +description = "End-to-end suite: sandbox policy-advisor" +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" } +serde_json = "1" +tokio = { version = "1", features = ["time"] } diff --git a/tests/suites/sandbox/policy-advisor/tests/policy_advisor.rs b/tests/suites/sandbox/policy-advisor/tests/policy_advisor.rs new file mode 100644 index 0000000000..1a545ca188 --- /dev/null +++ b/tests/suites/sandbox/policy-advisor/tests/policy_advisor.rs @@ -0,0 +1,423 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Sandbox policy-advisor suite. +//! +//! Adapted from the `policy-advisor` suite in NVIDIA/OpenShell#4629 by Evan Lezar. +//! +//! The sandbox's `policy.local` API and the draft policy a denied connection +//! produces. Requests to `policy.local` run inside the sandbox; settings and +//! drafts go through the gateway's gRPC API. + +use std::time::{Duration, Instant}; + +use serde_json::Value; +use testkit::sdk::raw::proto; +use testkit::{Context, Error, Sandbox}; + +const READY_TIMEOUT: Duration = Duration::from_mins(4); +const PROPOSAL_TIMEOUT: Duration = Duration::from_secs(90); +const POLL_INTERVAL: Duration = Duration::from_secs(2); +const PROPOSALS_ENABLED: &str = "agent_policy_proposals_enabled"; + +/// With proposals enabled, `policy.local` serves the current policy and +/// recent denials, accepts a proposed rule, reports its state, and the rule +/// reaches the reviewer's draft policy. +#[testkit::test] +async fn policy_local_accepts_proposals(cx: &mut Context) -> testkit::Result { + let sandbox = create(cx, "pl", None).await?; + enable_proposals(&sandbox).await?; + let binary = bash_path(&sandbox).await?; + + // The current-policy route is local; proposal lookup also needs the + // supervisor's session with the gateway, so wait for both. + let started = Instant::now(); + let readiness = format!("/v1/proposals/test-{}-readiness", cx.run_id()); + loop { + let ready = async { + let current = policy_local(&sandbox, "GET", "/v1/policy/current", "", 200).await?; + testkit::ensure!( + current["format"] == "yaml" + && current["policy_yaml"] + .as_str() + .is_some_and(|yaml| yaml.contains("version: 1")), + "policy.local returned an invalid current policy: {current}" + ); + let lookup = policy_local(&sandbox, "GET", &readiness, "", 404).await?; + testkit::ensure!( + lookup["error"] == "chunk_not_found", + "policy.local proposal lookup returned {lookup}" + ); + Ok(()) + } + .await; + match ready { + Ok(()) => break, + Err(error) if started.elapsed() >= READY_TIMEOUT => return Err(error), + Err(_) => tokio::time::sleep(POLL_INTERVAL).await, + } + } + + let denials = policy_local(&sandbox, "GET", "/v1/denials?last=1", "", 200).await?; + testkit::ensure!( + denials["denials"].is_array() && denials["log_available"].is_boolean(), + "policy.local returned an invalid denials response: {denials}" + ); + + let rule_name = format!("e2e_local_{}", cx.run_id()); + let payload = serde_json::json!({ + "intent_summary": "Allow Bash to read the e2e path on example.invalid.", + "operations": [{ + "addRule": { + "ruleName": &rule_name, + "rule": { + "name": &rule_name, + "endpoints": [{ + "host": "example.invalid", + "port": 443, + "protocol": "rest", + "enforcement": "enforce", + "rules": [{"allow": {"method": "GET", "path": "/e2e"}}] + }], + "binaries": [{"path": &binary}] + } + } + }] + }) + .to_string(); + let submitted = policy_local(&sandbox, "POST", "/v1/proposals", &payload, 202).await?; + let chunk_id = submitted["accepted_chunk_ids"] + .as_array() + .filter(|ids| ids.len() == 1) + .and_then(|ids| ids[0].as_str()) + .filter(|id| !id.is_empty()) + .ok_or_else(|| { + Error::msg(format!( + "policy.local did not accept one proposal: {submitted}" + )) + })? + .to_string(); + testkit::ensure!( + submitted["status"] == "submitted" + && submitted["accepted_chunks"] == 1 + && submitted["rejected_chunks"] == 0, + "policy.local did not submit one rule: {submitted}" + ); + + let state = policy_local( + &sandbox, + "GET", + &format!("/v1/proposals/{chunk_id}"), + "", + 200, + ) + .await?; + testkit::ensure!( + state["chunk_id"] == chunk_id.as_str() + && state["rule_name"] == rule_name.as_str() + && state["binary"] == binary.as_str() + && matches!(state["status"].as_str(), Some("pending" | "approved")), + "policy.local returned the wrong proposal: {state}" + ); + + let draft = sandbox.draft_policy().await?; + testkit::ensure!( + draft + .chunks + .iter() + .any(|chunk| chunk.id == chunk_id && chunk.rule_name == rule_name), + "the submitted rule {rule_name} ({chunk_id}) is not in the draft policy: {:?}", + draft + .chunks + .iter() + .map(|chunk| (&chunk.id, &chunk.rule_name)) + .collect::>() + ); + + // The reviewer's CLI shows the same rule. + let inbox = cx.cli(["rule", "get", sandbox.name()]).await?.success()?; + testkit::ensure!( + inbox.stdout.contains(&format!("Chunk: {chunk_id}")) + && inbox.stdout.contains(&format!("Rule: {rule_name}")), + "`{}` does not show chunk {chunk_id} and rule {rule_name}; stdout: {}", + inbox.command, + inbox.stdout + ); + Ok(()) +} + +/// In a sandbox with no network rules, a denied connection to an IP address +/// produces one mechanistic L4 draft rule for that endpoint and binary. +#[testkit::test] +async fn denied_address_produces_draft_rule(cx: &mut Context) -> testkit::Result { + let sandbox = create(cx, "mp", Some(no_network_policy())).await?; + enable_proposals(&sandbox).await?; + + let config = sandbox.config().await?; + let rules = config + .policy + .as_ref() + .map(|policy| policy.network_policies.len()) + .unwrap_or_default(); + testkit::ensure!( + rules == 0, + "the effective policy has {rules} network rules; expected none" + ); + + let probe = sandbox + .exec([ + "bash", + "-c", + "printf 'BINARY=%s\\n' \"$(readlink -f /proc/$$/exe)\"; \ + if exec 3<>/dev/tcp/1.1.1.1/443; then echo UNEXPECTED_ALLOWED; exit 1; else echo DENIED; fi", + ]) + .await?; + testkit::ensure!( + probe.exit_code == 0 && probe.stdout.lines().any(|line| line == "DENIED"), + "the connection to 1.1.1.1:443 was not denied: stdout {:?}, stderr {:?}", + probe.stdout, + probe.stderr + ); + let binary = probe + .stdout + .lines() + .find_map(|line| line.strip_prefix("BINARY=")) + .filter(|binary| is_bash_path(binary)) + .ok_or_else(|| Error::msg(format!("no Bash path in probe output: {:?}", probe.stdout)))? + .to_string(); + + expect_draft( + &sandbox, + "allow_1_1_1_1_443", + ("1.1.1.1", 443), + &binary, + &probe.stderr, + ) + .await +} + +/// In a sandbox with no network rules, a denied connection to a new hostname +/// stays denied and produces one mechanistic L4 draft rule for it. +#[testkit::test] +async fn denied_hostname_produces_draft_rule(cx: &mut Context) -> testkit::Result { + let sandbox = create(cx, "nh", Some(no_network_policy())).await?; + let binary = bash_path(&sandbox).await?; + + let probe = sandbox + .exec([ + "bash", + "-c", + "if exec 3<>/dev/tcp/pypi.org/80; then echo UNEXPECTED_ALLOWED; exit 1; else echo DENIED; fi", + ]) + .await?; + testkit::ensure!( + probe.exit_code == 0 && probe.stdout.lines().any(|line| line == "DENIED"), + "the connection to pypi.org:80 was not denied: stdout {:?}, stderr {:?}", + probe.stdout, + probe.stderr + ); + + expect_draft( + &sandbox, + "allow_pypi_org_80", + ("pypi.org", 80), + &binary, + &probe.stderr, + ) + .await +} + +async fn create( + cx: &Context, + suffix: &str, + policy: Option, +) -> testkit::Result { + let mut builder = cx + .sandbox(suffix) + .command(["sh", "-c", "exec sleep infinity"]); + if let Some(policy) = policy { + builder = builder.policy(policy); + } + let sandbox = builder.create().await?; + sandbox.wait_ready().await?; + Ok(sandbox) +} + +/// Filesystem access the tests need, and no network rules. +fn no_network_policy() -> proto::SandboxPolicy { + let paths = |paths: &[&str]| paths.iter().map(ToString::to_string).collect(); + proto::SandboxPolicy { + version: 1, + filesystem: Some(proto::FilesystemPolicy { + include_workdir: true, + read_only: paths(&[ + "/usr", + "/bin", + "/lib", + "/lib64", + "/proc", + "/dev/urandom", + "/app", + "/etc", + "/var/log", + ]), + read_write: paths(&["/sandbox", "/tmp", "/dev/null"]), + }), + landlock: Some(proto::LandlockPolicy { + compatibility: "best_effort".to_string(), + }), + ..proto::SandboxPolicy::default() + } +} + +/// Enable proposals for the sandbox and check the effective value, which a +/// global override could change. +async fn enable_proposals(sandbox: &Sandbox) -> testkit::Result { + sandbox + .set_setting( + PROPOSALS_ENABLED, + proto::setting_value::Value::BoolValue(true), + ) + .await?; + let config = sandbox.config().await?; + let value = config + .settings + .get(PROPOSALS_ENABLED) + .and_then(|setting| setting.value.as_ref()) + .and_then(|value| value.value.clone()); + testkit::ensure!( + value == Some(proto::setting_value::Value::BoolValue(true)), + "effective {PROPOSALS_ENABLED} is {value:?}; check for a global override" + ); + Ok(()) +} + +async fn bash_path(sandbox: &Sandbox) -> testkit::Result { + let output = sandbox + .exec([ + "bash", + "-c", + "printf '%s\\n' \"$(readlink -f /proc/$$/exe)\"", + ]) + .await?; + let path = output.stdout.trim(); + testkit::ensure!( + output.exit_code == 0 && is_bash_path(path), + "expected one absolute Bash path; got {:?}, stderr {:?}", + output.stdout, + output.stderr + ); + Ok(path.to_string()) +} + +fn is_bash_path(path: &str) -> bool { + path.starts_with('/') && !path.contains('\n') && path.rsplit('/').next() == Some("bash") +} + +/// Send an HTTP request to `policy.local` from inside the sandbox and parse +/// the JSON body, requiring `expected_status`. +async fn policy_local( + sandbox: &Sandbox, + method: &str, + path: &str, + body: &str, + expected_status: u16, +) -> testkit::Result { + const SCRIPT: &str = "method=$1; path=$2; body=$3; exec 3<>/dev/tcp/policy.local/80 || exit 1; \ + printf '%s %s HTTP/1.1\\r\\nHost: policy.local\\r\\nContent-Type: application/json\\r\\nContent-Length: %s\\r\\nConnection: close\\r\\n\\r\\n' \"$method\" \"$path\" \"${#body}\" >&3; \ + printf '%s' \"$body\" >&3; cat <&3"; + let output = sandbox + .exec([ + "bash", + "-c", + SCRIPT, + "policy-local-http", + method, + path, + body, + ]) + .await?; + testkit::ensure!( + output.exit_code == 0, + "{method} http://policy.local{path} exited {}; stderr: {}", + output.exit_code, + output.stderr + ); + let (headers, json) = output.stdout.split_once("\r\n\r\n").ok_or_else(|| { + Error::msg(format!( + "{method} http://policy.local{path} returned no complete HTTP response: {:?}", + output.stdout + )) + })?; + testkit::ensure!( + headers.starts_with(&format!("HTTP/1.1 {expected_status} ")) + && headers.lines().any(|line| { + line.to_ascii_lowercase() + .starts_with("content-type: application/json") + }), + "{method} http://policy.local{path} did not return {expected_status} with JSON: {headers}" + ); + serde_json::from_str(json).map_err(|error| { + Error::msg(format!( + "{method} http://policy.local{path} returned invalid JSON ({error}): {json}" + )) + }) +} + +/// Wait for the draft policy to contain a chunk, then require exactly one: +/// pending or approved, for `rule`, scoped to `binary` and an L4 `endpoint`. +async fn expect_draft( + sandbox: &Sandbox, + rule: &str, + endpoint: (&str, u32), + binary: &str, + probe_stderr: &str, +) -> testkit::Result { + let started = Instant::now(); + let chunks = loop { + match sandbox.draft_policy().await { + Ok(draft) if !draft.chunks.is_empty() => break draft.chunks, + Ok(_) | Err(_) if started.elapsed() < PROPOSAL_TIMEOUT => { + tokio::time::sleep(POLL_INTERVAL).await; + } + Ok(_) => { + return Err(Error::msg(format!( + "no draft rule for {}:{} after {PROPOSAL_TIMEOUT:?}; probe stderr: {probe_stderr}", + endpoint.0, endpoint.1 + ))); + } + Err(error) => return Err(error), + } + }; + + testkit::ensure!( + chunks.len() == 1, + "expected one draft rule; got {chunks:#?}" + ); + let chunk = &chunks[0]; + let proposed = chunk.proposed_rule.as_ref(); + let endpoints: Vec<(&str, u32, &str)> = proposed + .map(|rule| { + rule.endpoints + .iter() + .map(|e| (e.host.as_str(), e.port, e.protocol.as_str())) + .collect() + }) + .unwrap_or_default(); + let binaries: Vec<&str> = proposed + .map(|rule| rule.binaries.iter().map(|b| b.path.as_str()).collect()) + .unwrap_or_default(); + testkit::ensure!( + matches!(chunk.status.as_str(), "pending" | "approved") + && chunk.rule_name == rule + && chunk.binary == binary + && binaries == [binary] + // An empty protocol is an L4 rule. + && endpoints == [(endpoint.0, endpoint.1, "")] + && chunk.rationale.contains(&format!("{}:{}", endpoint.0, endpoint.1)), + "expected one pending or approved L4 draft rule {rule} for {binary} and {}:{}; got {chunk:#?}", + endpoint.0, + endpoint.1 + ); + Ok(()) +} diff --git a/tests/testkit/README.md b/tests/testkit/README.md index fd6897013d..3a7add6f33 100644 --- a/tests/testkit/README.md +++ b/tests/testkit/README.md @@ -48,9 +48,15 @@ test creates, such as providers, in the test itself. - `cx.sandbox(suffix)` to build and create a sandbox with a run-unique name. The returned `Sandbox` can wait for ready or finished, stop, start, exec, read its config and draft policy, set a setting, and delete. +- `cx.track_sandbox(suffix)` to register a sandbox created some other way, such + as through the CLI, for cleanup, and `cx.delete_sandboxes()` to delete the + tracked sandboxes before the test ends. - `cx.expect_listed` and `cx.expect_absent` to check a sandbox in list and get. - `cx.cli([...])` to run the installed CLI against the same gateway, with - `success()`, `failure()`, and `json()` on the output. + `success()`, `failure()`, and `json()` on the output. `cx.cli_env` adds + environment variables to one command. +- `cx.track_sandbox(name)` to clean up a sandbox created some other way, such + as with `openshell sandbox create`. - `cx.client()` for SDK calls testkit does not wrap. - `poll_until` for waiting on other state. diff --git a/tests/testkit/testkit/src/cli.rs b/tests/testkit/testkit/src/cli.rs index 7bedd9a3f2..103ee47d74 100644 --- a/tests/testkit/testkit/src/cli.rs +++ b/tests/testkit/testkit/src/cli.rs @@ -77,6 +77,21 @@ impl Cli { /// Like [`Cli::run`], with `dir` as the working directory. pub async fn run_in(&self, dir: Option<&Path>, args: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + self.run_with(dir, &[], args).await + } + + /// Like [`Cli::run_in`], with extra environment variables for the command, + /// such as a secret the CLI reads from the environment. + pub async fn run_with( + &self, + dir: Option<&Path>, + env: &[(&str, &str)], + args: I, + ) -> Result where I: IntoIterator, S: AsRef, @@ -95,6 +110,7 @@ impl Cli { process .args(&args) .env("NO_COLOR", "1") + .envs(env.iter().copied()) .env(WORKSPACE_ENV, &self.gateway.workspace) .stdin(Stdio::null()) .kill_on_drop(true); diff --git a/tests/testkit/testkit/src/context.rs b/tests/testkit/testkit/src/context.rs index 1980ff3f94..34f0797c6c 100644 --- a/tests/testkit/testkit/src/context.rs +++ b/tests/testkit/testkit/src/context.rs @@ -72,6 +72,15 @@ impl Context { self.cli.run_in(Some(dir), args).await } + /// Like [`Context::cli`], with extra environment variables for the command. + pub async fn cli_env(&self, env: &[(&str, &str)], args: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + self.cli.run_with(None, env, args).await + } + /// The ID unique to this test run, used in resource names. pub fn run_id(&self) -> &str { &self.run_id @@ -101,6 +110,49 @@ impl Context { } } + /// Track a sandbox the test is about to create through the CLI, so cleanup + /// deletes it even if the test fails after the gateway accepted the create. + /// Call this before the CLI command; use [`Context::adopt`] afterwards. + pub fn track(&self, name: &str) { + lock(&self.sandboxes).insert(name.to_string()); + } + + /// A handle to a sandbox created outside the builder, e.g. by the CLI. + /// It is tracked for cleanup like a built one. + pub async fn adopt(&self, name: &str) -> Result { + self.track(name); + let found = self + .client + .get_sandbox(name) + .await + .context(format!("get sandbox {name}"))?; + Ok(Sandbox { + client: self.client.clone(), + grpc: self.grpc.clone(), + sandboxes: Arc::clone(&self.sandboxes), + name: name.to_string(), + id: found.id, + }) + } + + /// Register the sandbox `cx.name(suffix)` for cleanup and return its name, + /// for a sandbox the test creates outside [`Context::sandbox`], such as + /// through the CLI. Call it before creating, so a create that fails after + /// the gateway accepted it is still cleaned up. + pub fn track_sandbox(&self, suffix: &str) -> String { + let name = self.name(suffix); + lock(&self.sandboxes).insert(name.clone()); + name + } + + /// Delete every tracked sandbox now, instead of after the test. For a test + /// that must remove something the sandboxes use, such as an image, first. + pub async fn delete_sandboxes(&self) -> Result { + let failures = self.cleanup().await; + crate::ensure!(failures.is_empty(), "{}", failures.join("; ")); + Ok(()) + } + /// Fail unless the sandbox is gone from both get and list. pub async fn expect_absent(&self, sandbox: &Sandbox) -> Result { let name = sandbox.name(); diff --git a/tests/tmachine/ansible/playbooks/conformance/policy-advisor.yaml b/tests/tmachine/ansible/playbooks/conformance/policy-advisor.yaml deleted file mode 100644 index f9a4cfbf8c..0000000000 --- a/tests/tmachine/ansible/playbooks/conformance/policy-advisor.yaml +++ /dev/null @@ -1,7 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - ---- -- import_playbook: cli.yaml - vars: - conformance_filter: binary(policy_advisor) diff --git a/tests/tmachine/ansible/playbooks/drivers/podman/tests.yaml b/tests/tmachine/ansible/playbooks/drivers/podman/tests.yaml index abe2f6e553..d554a5ca69 100644 --- a/tests/tmachine/ansible/playbooks/drivers/podman/tests.yaml +++ b/tests/tmachine/ansible/playbooks/drivers/podman/tests.yaml @@ -54,6 +54,7 @@ argv: [cargo-nextest, nextest, run, --archive-file, "{{ podman_test_root }}/tests.tar.zst", --workspace-remap, "{{ podman_test_root }}", --no-capture] environment: OPENSHELL_BIN: /usr/local/bin/openshell + OPENSHELL_GATEWAY: tmachine OPENSHELL_TEST_INPUT_DIR: "{{ podman_test_input_dir }}" OPENSHELL_PODMAN_TEST_IMAGE: "{{ openshell_podman_test_image | default('') }}" register: podman_test_result @@ -163,3 +164,10 @@ ansible.builtin.assert: that: [podman_test_result.rc == 0] fail_msg: Podman archive tests failed + + - name: Require the expected number of tests + ansible.builtin.assert: + that: + - (podman_test_result.stderr | default('') | regex_findall('Summary \\[[^\\]]*\\]\\s+(\\d+) tests? run') | first | default(0) | int) >= (suite_expected_tests | int) + fail_msg: >- + Podman userns suite ran fewer tests than the {{ suite_expected_tests }} expected; see the nextest summary diff --git a/tests/tmachine/ansible/playbooks/features/oci-image.yaml b/tests/tmachine/ansible/playbooks/features/oci-image.yaml index 6c6361263a..a7c27e20fc 100644 --- a/tests/tmachine/ansible/playbooks/features/oci-image.yaml +++ b/tests/tmachine/ansible/playbooks/features/oci-image.yaml @@ -123,3 +123,10 @@ that: - oci_image_result.rc == 0 fail_msg: OCI image feature tests failed + + - name: Require the expected number of tests + ansible.builtin.assert: + that: + - (oci_image_result.stderr | default('') | regex_findall('Summary \\[[^\\]]*\\]\\s+(\\d+) tests? run') | first | default(0) | int) >= (suite_expected_tests | int) + fail_msg: >- + OCI image suite ran fewer tests than the {{ suite_expected_tests }} expected; see the nextest summary diff --git a/tests/tmachine/ansible/playbooks/features/provider-refresh/keycloak.yaml b/tests/tmachine/ansible/playbooks/features/provider-refresh/keycloak.yaml index 1aca72a488..649e22acf3 100644 --- a/tests/tmachine/ansible/playbooks/features/provider-refresh/keycloak.yaml +++ b/tests/tmachine/ansible/playbooks/features/provider-refresh/keycloak.yaml @@ -108,3 +108,10 @@ ansible.builtin.assert: that: provider_refresh_result.rc == 0 fail_msg: Keycloak provider refresh test failed + + - name: Require the expected number of tests + ansible.builtin.assert: + that: + - (provider_refresh_result.stderr | default('') | regex_findall('Summary \\[[^\\]]*\\]\\s+(\\d+) tests? run') | first | default(0) | int) >= (suite_expected_tests | int) + fail_msg: >- + Keycloak provider refresh suite ran fewer tests than the {{ suite_expected_tests }} expected; see the nextest summary diff --git a/tests/tmachine/ansible/playbooks/conformance/k3s-ready.yaml b/tests/tmachine/ansible/playbooks/suites/k3s-ready.yaml similarity index 100% rename from tests/tmachine/ansible/playbooks/conformance/k3s-ready.yaml rename to tests/tmachine/ansible/playbooks/suites/k3s-ready.yaml diff --git a/tests/tmachine/ansible/playbooks/conformance/cli.yaml b/tests/tmachine/ansible/playbooks/suites/nextest.yaml similarity index 54% rename from tests/tmachine/ansible/playbooks/conformance/cli.yaml rename to tests/tmachine/ansible/playbooks/suites/nextest.yaml index e841a11442..583e6e56d9 100644 --- a/tests/tmachine/ansible/playbooks/conformance/cli.yaml +++ b/tests/tmachine/ansible/playbooks/suites/nextest.yaml @@ -2,54 +2,61 @@ # SPDX-License-Identifier: Apache-2.0 --- -- name: Run OpenShell conformance tests +# Runs a testkit suite from a nextest archive against the gateway the installer +# registered. testkit finds that gateway the way the CLI does, so no target file +# is needed. Inputs: suite_bundle, suite_name, suite_expected_tests, and an +# optional suite_filter. +- name: Run a testkit suite hosts: all gather_facts: false vars: - conformance_failed: >- + suite_failed: >- {{ gateway_status is failed or (controller_readiness_failed | default(false)) or - conformance_result.rc != 0 }} + suite_result.rc != 0 or + (suite_tests_run | int) < (suite_expected_tests | int) }} + suite_tests_run: >- + {{ (suite_result.stderr | default('') | regex_findall('Summary \\[[^\\]]*\\]\\s+(\\d+) tests? run') | first | default(0)) }} tasks: - name: Wait for SSH ansible.builtin.wait_for_connection: - - name: Install conformance test dependencies + - name: Install suite dependencies become: true ansible.builtin.package: name: - git state: present - - name: Create OpenShell conformance test directory + - name: Create the suite test directory become: true ansible.builtin.file: - path: /var/lib/openshell-conformance/tests + path: /var/lib/openshell-suite/{{ suite_name }} state: directory owner: tmachine group: tmachine mode: "0700" - - name: Extract OpenShell conformance test bundle + - name: Extract the suite test bundle become: true ansible.builtin.unarchive: - src: "{{ openshell_conformance_test_bundle }}" - dest: /var/lib/openshell-conformance/tests + src: "{{ suite_bundle }}" + dest: /var/lib/openshell-suite/{{ suite_name }} owner: tmachine group: tmachine # Report a malformed outer bundle directly instead of failing later with # an opaque cargo-nextest missing-archive error. - - name: Check OpenShell conformance nextest archive + - name: Check the suite nextest archive ansible.builtin.stat: - path: /var/lib/openshell-conformance/tests/tests.tar.zst - register: conformance_archive + path: /var/lib/openshell-suite/{{ suite_name }}/tests.tar.zst + register: suite_archive - - name: Require OpenShell conformance nextest archive + - name: Require the suite nextest archive ansible.builtin.assert: that: - - conformance_archive.stat.isreg | default(false) - fail_msg: OpenShell conformance test bundle did not contain tests.tar.zst + - suite_archive.stat.isreg | default(false) + fail_msg: the suite test bundle did not contain tests.tar.zst - name: Resolve installed OpenShell CLI ansible.builtin.command: @@ -77,10 +84,10 @@ - name: Check for K3s ansible.builtin.stat: path: /usr/local/bin/k3s - register: conformance_k3s + register: suite_k3s - name: Prepare the Kubernetes controller - when: gateway_status is succeeded and conformance_k3s.stat.exists + when: gateway_status is succeeded and suite_k3s.stat.exists block: - name: Verify Agent Sandbox reconciliation ansible.builtin.include_tasks: k3s-ready.yaml @@ -89,56 +96,80 @@ ansible.builtin.set_fact: controller_readiness_failed: true - - name: Run OpenShell conformance archive + # The bundle carries no nextest config, so set the timeouts here. A hung + # test is killed after two minutes instead of holding the lane until the job + # times out. + - name: Write nextest config + become: true + become_user: tmachine + ansible.builtin.copy: + dest: "/var/lib/openshell-suite/{{ suite_name }}/nextest.toml" + mode: "0600" + content: | + [profile.default] + slow-timeout = { period = "30s", terminate-after = 4 } + + - name: Run the suite archive ansible.builtin.command: argv: - cargo-nextest - nextest - run + - --config-file + - /var/lib/openshell-suite/{{ suite_name }}/nextest.toml - --archive-file - - /var/lib/openshell-conformance/tests/tests.tar.zst + - /var/lib/openshell-suite/{{ suite_name }}/tests.tar.zst - --workspace-remap - - /var/lib/openshell-conformance/tests + - /var/lib/openshell-suite/{{ suite_name }} - --no-capture # The guest has 4 GiB; keep sandbox creates from competing for it. - --test-threads - "1" - --filterset - - "{{ conformance_filter | default('all()') }}" + - "{{ suite_filter | default('all()') }}" - --no-fail-fast environment: + HOME: /home/tmachine OPENSHELL_BIN: "{{ openshell_cli.stdout }}" - register: conformance_result + register: suite_result changed_when: false failed_when: false when: gateway_status is succeeded and not (controller_readiness_failed | default(false)) # Preserve both streams for failures without adding passing-test output to # every tmachine run. - - name: Show OpenShell conformance diagnostics + - name: Show the suite diagnostics ansible.builtin.debug: - var: conformance_result - when: conformance_failed + var: suite_result + when: suite_failed - name: Collect OpenShell gateway diagnostics ansible.builtin.include_role: name: openshell_diagnostics - when: conformance_failed + when: suite_failed - name: Require a connected gateway ansible.builtin.assert: that: - gateway_status is succeeded - fail_msg: OpenShell gateway did not connect before conformance; see gateway diagnostics + fail_msg: OpenShell gateway did not connect before the suite; see gateway diagnostics - name: Require a ready Kubernetes controller ansible.builtin.assert: that: - not (controller_readiness_failed | default(false)) - fail_msg: Agent Sandbox reconciliation did not become ready before conformance; see controller diagnostics + fail_msg: Agent Sandbox reconciliation did not become ready before the suite; see controller diagnostics + + - name: Require the suite success + ansible.builtin.assert: + that: + - suite_result.rc == 0 + fail_msg: the suite test failed - - name: Require OpenShell conformance success + - name: Require the expected number of tests ansible.builtin.assert: that: - - conformance_result.rc == 0 - fail_msg: OpenShell conformance test failed + - (suite_tests_run | int) >= (suite_expected_tests | int) + fail_msg: >- + Suite {{ suite_name }} ran {{ suite_tests_run }} tests; expected at + least {{ suite_expected_tests }} diff --git a/tests/tmachine/ansible/playbooks/suites/sandbox-lifecycle.yaml b/tests/tmachine/ansible/playbooks/suites/sandbox-lifecycle.yaml deleted file mode 100644 index dd76d69170..0000000000 --- a/tests/tmachine/ansible/playbooks/suites/sandbox-lifecycle.yaml +++ /dev/null @@ -1,164 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - ---- -# Runs the testkit sandbox-lifecycle suite against the gateway the installer -# registered. testkit finds that gateway the way the CLI does, so no target -# file is needed. -- name: Run the sandbox lifecycle suite - hosts: all - gather_facts: false - vars: - suite_root: /var/lib/openshell-sandbox-lifecycle/tests - # Fail when the run executes fewer tests than this, so a dropped or - # filtered-out test cannot pass silently. Raise it when a test is added. - suite_expected_tests: 1 - suite_failed: >- - {{ gateway_status is failed or suite_result.rc != 0 or - (suite_tests_run | int) < (suite_expected_tests | int) }} - suite_tests_run: >- - {{ (suite_result.stderr | default('') | regex_findall('Summary \[[^\]]*\]\s+(\d+) tests? run') | first | default(0)) }} - tasks: - - name: Wait for SSH - ansible.builtin.wait_for_connection: - - - name: Create suite directory - become: true - ansible.builtin.file: - path: "{{ suite_root }}" - state: directory - owner: tmachine - group: tmachine - mode: "0700" - - - name: Extract suite bundle - become: true - ansible.builtin.unarchive: - src: "{{ sandbox_lifecycle_test_bundle }}" - dest: "{{ suite_root }}" - owner: tmachine - group: tmachine - - - name: Check suite nextest archive - ansible.builtin.stat: - path: "{{ suite_root }}/tests.tar.zst" - register: suite_archive - - - name: Require suite nextest archive - ansible.builtin.assert: - that: - - suite_archive.stat.isreg | default(false) - fail_msg: Sandbox lifecycle test bundle did not contain tests.tar.zst - - - name: Resolve installed OpenShell CLI - ansible.builtin.command: - argv: - - /bin/sh - - -c - - command -v openshell - register: openshell_cli - changed_when: false - - # Report a gateway that never came up as one environment failure instead of - # as every test failing. - - name: Wait for the registered gateway - ansible.builtin.command: - argv: [timeout, 10s, "{{ openshell_cli.stdout }}", status, --output, json] - register: gateway_status - changed_when: false - until: >- - gateway_status.rc == 0 and - (gateway_status.stdout | from_json).status == 'connected' - retries: 60 - delay: 5 - ignore_errors: true - - # The bundle carries no nextest config, so set the timeouts here. A hung - # test is killed after two minutes instead of holding the lane until the job - # times out. - - name: Write nextest config - ansible.builtin.copy: - dest: "{{ suite_root }}/nextest.toml" - mode: "0600" - content: | - [profile.default] - slow-timeout = { period = "30s", terminate-after = 4 } - - [profile.default.junit] - path = "junit.xml" - become: true - become_user: tmachine - - - name: Run the suite - ansible.builtin.command: - argv: - - cargo-nextest - - nextest - - run - - --config-file - - "{{ suite_root }}/nextest.toml" - - --archive-file - - "{{ suite_root }}/tests.tar.zst" - - --workspace-remap - - "{{ suite_root }}" - - --no-capture - # Tests share one gateway and the guest has 4 GiB; run them serially - # whatever --no-capture implies. - - --test-threads - - "1" - - --no-fail-fast - environment: - HOME: /home/tmachine - OPENSHELL_BIN: "{{ openshell_cli.stdout }}" - register: suite_result - changed_when: false - failed_when: false - when: gateway_status is succeeded - - # Keep the JUnit report with the run. The integration runner uploads - # artifacts/tmachine-diagnostics whether or not the lane passes. - - name: Find the JUnit report - ansible.builtin.find: - paths: "{{ suite_root }}" - patterns: junit.xml - recurse: true - register: suite_junit - failed_when: false - - - name: Fetch the JUnit report - ansible.builtin.fetch: - src: "{{ item.path }}" - dest: "{{ playbook_dir }}/../../../../../artifacts/tmachine-diagnostics/junit/sandbox-lifecycle-{{ inventory_hostname }}.xml" - flat: true - loop: "{{ suite_junit.files | default([]) }}" - failed_when: false - - - name: Show suite diagnostics - ansible.builtin.debug: - var: suite_result - when: suite_failed | bool - - - name: Collect OpenShell gateway diagnostics - ansible.builtin.include_role: - name: openshell_diagnostics - when: suite_failed | bool - - - name: Require a connected gateway - ansible.builtin.assert: - that: - - gateway_status is succeeded - fail_msg: OpenShell gateway did not connect before the suite; see gateway diagnostics - - - name: Require suite success - ansible.builtin.assert: - that: - - suite_result.rc == 0 - fail_msg: Sandbox lifecycle suite failed - - - name: Require the expected number of tests - ansible.builtin.assert: - that: - - (suite_tests_run | int) >= (suite_expected_tests | int) - fail_msg: >- - Sandbox lifecycle suite ran {{ suite_tests_run }} tests; expected at - least {{ suite_expected_tests }} diff --git a/tests/tmachine/artifacts.nix b/tests/tmachine/artifacts.nix index c6a6d0e978..00a598912c 100644 --- a/tests/tmachine/artifacts.nix +++ b/tests/tmachine/artifacts.nix @@ -24,7 +24,7 @@ let name, workspacePath, manifestPath, - package, + packages, target, output, features ? null, @@ -61,7 +61,7 @@ let --locked \ --manifest-path "$manifest_path" \ --target ${target} \ - -p ${package} \ + ${pkgs.lib.concatMapStringsSep " " (package: "-p ${package}") packages} \ ${pkgs.lib.optionalString (features != null) "--features ${pkgs.lib.escapeShellArg features}"} \ -E ${pkgs.lib.escapeShellArg filter} \ --archive-file "$bundle_dir/tests.tar.zst" @@ -83,37 +83,35 @@ let ''; }; - conformanceTestArchive = mkTestArchive { - name = "openshell-conformance"; - workspacePath = "tests/suites/conformance"; - manifestPath = "tests/suites/conformance/Cargo.toml"; - package = "openshell-test-conformance-cli"; - target = muslToolchain.target; - output = "artifacts/test-archives/${muslToolchain.target}/openshell-conformance-tests.tar"; - }; - sandboxLifecycleArchive = mkTestArchive { - name = "sandbox-lifecycle"; + # Suites written on testkit. Each archive is one tmachine testsuite; the + # suites find the gateway the installer registered, like the CLI does. + sandboxArchive = mkTestArchive { + name = "sandbox"; workspacePath = "tests/suites/sandbox"; manifestPath = "tests/suites/sandbox/Cargo.toml"; - package = "openshell-test-sandbox-lifecycle"; + packages = [ + "openshell-test-sandbox-lifecycle" + "openshell-test-sandbox-file-transfer" + "openshell-test-sandbox-policy-advisor" + ]; target = muslToolchain.target; - output = "artifacts/test-archives/${muslToolchain.target}/sandbox-lifecycle-tests.tar"; + output = "artifacts/test-archives/${muslToolchain.target}/sandbox-tests.tar"; }; - providerRefreshKeycloakArchive = mkTestArchive { - name = "provider-refresh-keycloak"; - workspacePath = "tests/suites/features"; - manifestPath = "tests/suites/features/Cargo.toml"; - package = "openshell-test-feature-provider-refresh-keycloak"; + sandboxOciImageArchive = mkTestArchive { + name = "sandbox-oci-image"; + workspacePath = "tests/suites/sandbox"; + manifestPath = "tests/suites/sandbox/Cargo.toml"; + packages = [ "openshell-test-sandbox-oci-image" ]; target = muslToolchain.target; - output = "artifacts/test-archives/${muslToolchain.target}/provider-refresh-keycloak-tests.tar"; + output = "artifacts/test-archives/${muslToolchain.target}/sandbox-oci-image-tests.tar"; }; - ociImageArchive = mkTestArchive { - name = "oci-image"; - workspacePath = "tests/suites/features"; - manifestPath = "tests/suites/features/Cargo.toml"; - package = "openshell-test-feature-oci-image"; + providersRefreshKeycloakArchive = mkTestArchive { + name = "providers-refresh-keycloak"; + workspacePath = "tests/suites/providers"; + manifestPath = "tests/suites/providers/Cargo.toml"; + packages = [ "openshell-test-providers-refresh-keycloak" ]; target = muslToolchain.target; - output = "artifacts/test-archives/${muslToolchain.target}/oci-image-tests.tar"; + output = "artifacts/test-archives/${muslToolchain.target}/providers-refresh-keycloak-tests.tar"; }; # Follow-up: binaries that do not yet run in the rootless tmachine guest. Each @@ -191,19 +189,19 @@ let ''; }; - podmanDriverArchive = mkTestArchive { - name = "podman-driver"; - workspacePath = "tests/suites/drivers"; - manifestPath = "tests/suites/drivers/Cargo.toml"; - package = "openshell-test-suite-podman"; + podmanUsernsArchive = mkTestArchive { + name = "podman-userns"; + workspacePath = "tests/suites/podman"; + manifestPath = "tests/suites/podman/Cargo.toml"; + packages = [ "openshell-test-podman-userns" ]; target = muslToolchain.target; - output = "artifacts/test-archives/${muslToolchain.target}/openshell-podman-tests.tar"; + output = "artifacts/test-archives/${muslToolchain.target}/podman-userns-tests.tar"; }; podmanE2eArchive = mkTestArchive { name = "podman-e2e"; workspacePath = "e2e/rust"; manifestPath = "e2e/rust/Cargo.toml"; - package = "openshell-e2e"; + packages = [ "openshell-e2e" ]; target = muslToolchain.target; output = "artifacts/test-archives/${muslToolchain.target}/openshell-podman-e2e-tests.tar"; features = "e2e-podman"; @@ -212,10 +210,10 @@ let in rec { inherit - conformanceTestArchive - providerRefreshKeycloakArchive - ociImageArchive - podmanDriverArchive + sandboxArchive + sandboxOciImageArchive + providersRefreshKeycloakArchive + podmanUsernsArchive podmanE2eArchive podmanE2eCiTests ; @@ -325,19 +323,17 @@ rec { testArchives = pkgs.writeShellApplication { name = "build-artifacts-test-archives"; runtimeInputs = [ - conformanceTestArchive - sandboxLifecycleArchive - providerRefreshKeycloakArchive - ociImageArchive - podmanDriverArchive + sandboxArchive + sandboxOciImageArchive + providersRefreshKeycloakArchive + podmanUsernsArchive podmanE2eArchive ]; text = '' - build-openshell-conformance-test-archive - build-sandbox-lifecycle-test-archive - build-provider-refresh-keycloak-test-archive - build-oci-image-test-archive - build-podman-driver-test-archive + build-sandbox-test-archive + build-sandbox-oci-image-test-archive + build-providers-refresh-keycloak-test-archive + build-podman-userns-test-archive build-podman-e2e-test-archive ''; }; diff --git a/tests/tmachine/config.nix b/tests/tmachine/config.nix index c8b1bf9708..522afec29c 100644 --- a/tests/tmachine/config.nix +++ b/tests/tmachine/config.nix @@ -156,24 +156,24 @@ let interactive = true; } { - name = "conformance"; - playbooks = [ "ansible/playbooks/conformance/cli.yaml" ]; + name = "sandbox"; + playbooks = [ "ansible/playbooks/suites/nextest.yaml" ]; inputs = { - openshell_conformance_test_bundle = "../../artifacts/test-archives/${muslTarget}/openshell-conformance-tests.tar"; - }; - } - { - name = "sandbox-lifecycle"; - playbooks = [ "ansible/playbooks/suites/sandbox-lifecycle.yaml" ]; - inputs = { - sandbox_lifecycle_test_bundle = "../../artifacts/test-archives/${muslTarget}/sandbox-lifecycle-tests.tar"; + suite_bundle = "../../artifacts/test-archives/${muslTarget}/sandbox-tests.tar"; + suite_name = "sandbox"; + # The run fails if fewer tests than this execute, so a dropped or + # filtered test cannot pass silently. Raise it when a test is added. + suite_expected_tests = "14"; }; } { name = "policy-advisor"; - playbooks = [ "ansible/playbooks/conformance/policy-advisor.yaml" ]; + playbooks = [ "ansible/playbooks/suites/nextest.yaml" ]; inputs = { - openshell_conformance_test_bundle = "../../artifacts/test-archives/${muslTarget}/openshell-conformance-tests.tar"; + suite_bundle = "../../artifacts/test-archives/${muslTarget}/sandbox-tests.tar"; + suite_name = "policy-advisor"; + suite_filter = "binary(policy_advisor)"; + suite_expected_tests = "3"; }; } { @@ -181,14 +181,16 @@ let playbooks = [ "ansible/playbooks/features/provider-refresh/keycloak.yaml" ]; inputs = { keycloak_realm_file = "../../scripts/keycloak-realm.json"; - provider_refresh_keycloak_test_bundle = "../../artifacts/test-archives/${muslTarget}/provider-refresh-keycloak-tests.tar"; + provider_refresh_keycloak_test_bundle = "../../artifacts/test-archives/${muslTarget}/providers-refresh-keycloak-tests.tar"; + suite_expected_tests = "1"; }; } { name = "oci-image"; playbooks = [ "ansible/playbooks/features/oci-image.yaml" ]; inputs = { - oci_image_test_bundle = "../../artifacts/test-archives/${muslTarget}/oci-image-tests.tar"; + oci_image_test_bundle = "../../artifacts/test-archives/${muslTarget}/sandbox-oci-image-tests.tar"; + suite_expected_tests = "6"; }; } { @@ -212,13 +214,14 @@ let "ansible/playbooks/drivers/podman/tests.yaml" ]; inputs = { - openshell_podman_test_bundle = "../../artifacts/test-archives/${muslTarget}/openshell-podman-tests.tar"; + openshell_podman_test_bundle = "../../artifacts/test-archives/${muslTarget}/podman-userns-tests.tar"; + suite_expected_tests = "2"; # Match OpenShell's compiled-in default so direct Podman and # OpenShell containers resolve the same workload image metadata. openshell_podman_reference_image = "nvcr.io/nvidia/base/ubuntu:24.04"; - openshell_podman_userns_auto_config = "../suites/drivers/podman/fixtures/userns-auto.toml"; - openshell_podman_userns_keep_id_config = "../suites/drivers/podman/fixtures/userns-keep-id.toml"; - openshell_podman_userns_private_config = "../suites/drivers/podman/fixtures/userns-private.toml"; + openshell_podman_userns_auto_config = "../suites/podman/fixtures/userns-auto.toml"; + openshell_podman_userns_keep_id_config = "../suites/podman/fixtures/userns-keep-id.toml"; + openshell_podman_userns_private_config = "../suites/podman/fixtures/userns-private.toml"; }; } ]; From 66f1b0490b9f42aaee81e03c069ccd51dd904a6d Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Wed, 21 Oct 2026 13:29:07 -0700 Subject: [PATCH 07/10] docs(tests): fix stale suite references, lint every testkit suite, and keep JUnit results - testkit README: describe `track`, `track_sandbox`, `adopt` and `delete_sandboxes` once. - TESTING.md: the local suite command excludes oci-image, which needs a container engine, and names the sandbox suites instead of the deleted conformance ones. - Run fmt and clippy for tests/suites/providers and tests/suites/podman in the mise tasks and Branch Checks. - The generic suites playbook has nextest write a JUnit report and fetches it into artifacts/tmachine-diagnostics, which the integration runner uploads. Signed-off-by: Matthew Grossman --- .github/workflows/branch-checks.yml | 4 ++++ TESTING.md | 12 ++++++----- tasks/rust.toml | 6 ++++++ tests/testkit/README.md | 10 ++++----- .../ansible/playbooks/suites/nextest.yaml | 21 +++++++++++++++++++ 5 files changed, 43 insertions(+), 10 deletions(-) diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index 11de9e42eb..0de7c3a2f2 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -223,6 +223,8 @@ jobs: cargo fmt --manifest-path examples/isolation-backend-v8/Cargo.toml --all -- --check cargo fmt --manifest-path tests/testkit/Cargo.toml --all -- --check cargo fmt --manifest-path tests/suites/sandbox/Cargo.toml --all -- --check + cargo fmt --manifest-path tests/suites/providers/Cargo.toml --all -- --check + cargo fmt --manifest-path tests/suites/podman/Cargo.toml --all -- --check cargo-lockfiles: name: Cargo lockfiles @@ -283,6 +285,8 @@ jobs: cargo clippy --locked --manifest-path examples/isolation-backend-v8/Cargo.toml --all-targets -- -D warnings cargo clippy --locked --manifest-path tests/testkit/Cargo.toml --all-targets -- -D warnings cargo clippy --locked --manifest-path tests/suites/sandbox/Cargo.toml --all-targets -- -D warnings + cargo clippy --locked --manifest-path tests/suites/providers/Cargo.toml --all-targets -- -D warnings + cargo clippy --locked --manifest-path tests/suites/podman/Cargo.toml --all-targets -- -D warnings rust-test: name: Rust test (${{ matrix.system }}) diff --git a/TESTING.md b/TESTING.md index dc4a522af1..6050731b2b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -314,8 +314,9 @@ Run the Docker-backed Rust CLI e2e suite: mise run e2e:docker ``` -Run the portable CLI conformance suite against the gateway selected in your -OpenShell CLI configuration: +Run the sandbox suites against the gateway selected in your OpenShell CLI +configuration. The `oci-image` suite needs `OPENSHELL_TEST_CONTAINER_ENGINE`, so +this excludes it; run it with `mise run e2e:oci-image`: ```shell cargo build --package openshell-cli @@ -324,6 +325,7 @@ OPENSHELL_BIN="$PWD/target/debug/openshell" \ --locked \ --manifest-path tests/suites/sandbox/Cargo.toml \ --workspace \ + --exclude openshell-test-sandbox-oci-image \ --no-fail-fast \ -- \ --test-threads=1 \ @@ -358,7 +360,7 @@ openshell sandbox list --output json openshell sandbox delete ``` -Gateway-backed Rust E2E tasks run the CLI conformance Cargo tests against the +Gateway-backed Rust E2E tasks run the testkit sandbox suites against the configured gateway, then run any lane-specific Rust tests that still apply. Run the Podman-backed Rust CLI e2e suite: @@ -379,11 +381,11 @@ for the host's architecture (`aarch64` or `x86_64`), such as Docker Desktop on macOS. The package version defaults to the development version that branch CI computes; set `OPENSHELL_RPM_VERSION` and `OPENSHELL_RPM_RELEASE` to override it. After `nix run .#build-artifacts-binaries`, `nix run .#build-artifacts-rpm` -rebuilds only the RPMs. Run conformance against the installed RPMs: +rebuilds only the RPMs. Run the sandbox suites against the installed RPMs: ```shell nix run .#build-artifacts -nix run .#tmachine -- test fedora-podman-rootful rpm conformance +nix run .#tmachine -- test fedora-podman-rootful rpm sandbox ``` Run the portable subset in a disposable rootless Podman guest: diff --git a/tasks/rust.toml b/tasks/rust.toml index cf25697e14..a9a3ead1d1 100644 --- a/tasks/rust.toml +++ b/tasks/rust.toml @@ -38,6 +38,8 @@ run = [ "cargo clippy --manifest-path examples/isolation-backend-v8/Cargo.toml --all-targets -- -D warnings", "cargo clippy --manifest-path tests/testkit/Cargo.toml --all-targets -- -D warnings", "cargo clippy --manifest-path tests/suites/sandbox/Cargo.toml --all-targets -- -D warnings", + "cargo clippy --manifest-path tests/suites/providers/Cargo.toml --all-targets -- -D warnings", + "cargo clippy --manifest-path tests/suites/podman/Cargo.toml --all-targets -- -D warnings", ] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 lint native" hide = true @@ -52,6 +54,8 @@ run = [ "cargo fmt --manifest-path examples/isolation-backend-v8/Cargo.toml --all", "cargo fmt --manifest-path tests/testkit/Cargo.toml --all", "cargo fmt --manifest-path tests/suites/sandbox/Cargo.toml --all", + "cargo fmt --manifest-path tests/suites/providers/Cargo.toml --all", + "cargo fmt --manifest-path tests/suites/podman/Cargo.toml --all", ] hide = true @@ -65,6 +69,8 @@ run = [ "cargo fmt --manifest-path examples/isolation-backend-v8/Cargo.toml --all -- --check", "cargo fmt --manifest-path tests/testkit/Cargo.toml --all -- --check", "cargo fmt --manifest-path tests/suites/sandbox/Cargo.toml --all -- --check", + "cargo fmt --manifest-path tests/suites/providers/Cargo.toml --all -- --check", + "cargo fmt --manifest-path tests/suites/podman/Cargo.toml --all -- --check", ] hide = true diff --git a/tests/testkit/README.md b/tests/testkit/README.md index 3a7add6f33..7c4a321617 100644 --- a/tests/testkit/README.md +++ b/tests/testkit/README.md @@ -48,15 +48,15 @@ test creates, such as providers, in the test itself. - `cx.sandbox(suffix)` to build and create a sandbox with a run-unique name. The returned `Sandbox` can wait for ready or finished, stop, start, exec, read its config and draft policy, set a setting, and delete. -- `cx.track_sandbox(suffix)` to register a sandbox created some other way, such - as through the CLI, for cleanup, and `cx.delete_sandboxes()` to delete the - tracked sandboxes before the test ends. +- `cx.track_sandbox(suffix)` or `cx.track(name)` to register a sandbox created + some other way, such as through the CLI, for cleanup. Call it before the + create, so a create that fails after the gateway accepted it is still cleaned + up. `cx.adopt(name)` returns a `Sandbox` handle for one the CLI created, and + `cx.delete_sandboxes()` deletes the tracked sandboxes before the test ends. - `cx.expect_listed` and `cx.expect_absent` to check a sandbox in list and get. - `cx.cli([...])` to run the installed CLI against the same gateway, with `success()`, `failure()`, and `json()` on the output. `cx.cli_env` adds environment variables to one command. -- `cx.track_sandbox(name)` to clean up a sandbox created some other way, such - as with `openshell sandbox create`. - `cx.client()` for SDK calls testkit does not wrap. - `poll_until` for waiting on other state. diff --git a/tests/tmachine/ansible/playbooks/suites/nextest.yaml b/tests/tmachine/ansible/playbooks/suites/nextest.yaml index 583e6e56d9..53e4e1d1dc 100644 --- a/tests/tmachine/ansible/playbooks/suites/nextest.yaml +++ b/tests/tmachine/ansible/playbooks/suites/nextest.yaml @@ -109,6 +109,9 @@ [profile.default] slow-timeout = { period = "30s", terminate-after = 4 } + [profile.default.junit] + path = "junit.xml" + - name: Run the suite archive ansible.builtin.command: argv: @@ -136,6 +139,24 @@ failed_when: false when: gateway_status is succeeded and not (controller_readiness_failed | default(false)) + # Keep the JUnit report with the run. The integration runner uploads + # artifacts/tmachine-diagnostics whether or not the lane passes. + - name: Find the JUnit report + ansible.builtin.find: + paths: "/var/lib/openshell-suite/{{ suite_name }}" + patterns: junit.xml + recurse: true + register: suite_junit + failed_when: false + + - name: Fetch the JUnit report + ansible.builtin.fetch: + src: "{{ item.path }}" + dest: "{{ playbook_dir }}/../../../../../artifacts/tmachine-diagnostics/junit/{{ suite_name }}-{{ inventory_hostname }}.xml" + flat: true + loop: "{{ suite_junit.files | default([]) }}" + failed_when: false + # Preserve both streams for failures without adding passing-test output to # every tmachine run. - name: Show the suite diagnostics From 7c75fbfe34f6cb3cbe442309ccaf68cdccdb61e5 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Wed, 21 Oct 2026 13:43:12 -0700 Subject: [PATCH 08/10] build(cargo): realign the suite lockfiles with the root lock Re-lock tests/suites/{sandbox,providers,podman} from the current root Cargo.lock with check_cargo_lockfile_alignment.py --fix. Signed-off-by: Matthew Grossman --- tests/suites/podman/Cargo.lock | 8 ++++---- tests/suites/providers/Cargo.lock | 8 ++++---- tests/suites/sandbox/Cargo.lock | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/suites/podman/Cargo.lock b/tests/suites/podman/Cargo.lock index 2a306f0fd2..6c18cea843 100644 --- a/tests/suites/podman/Cargo.lock +++ b/tests/suites/podman/Cargo.lock @@ -2639,9 +2639,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -3885,9 +3885,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] diff --git a/tests/suites/providers/Cargo.lock b/tests/suites/providers/Cargo.lock index 742101f359..15c543ace3 100644 --- a/tests/suites/providers/Cargo.lock +++ b/tests/suites/providers/Cargo.lock @@ -2641,9 +2641,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -3887,9 +3887,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] diff --git a/tests/suites/sandbox/Cargo.lock b/tests/suites/sandbox/Cargo.lock index 474df6044f..f52e18b63b 100644 --- a/tests/suites/sandbox/Cargo.lock +++ b/tests/suites/sandbox/Cargo.lock @@ -1998,21 +1998,21 @@ dependencies = [ ] [[package]] -name = "openshell-test-sandbox-policy-advisor" +name = "openshell-test-sandbox-oci-image" version = "0.0.0" dependencies = [ "openshell-testkit", "serde_json", - "tokio", + "tempfile", ] [[package]] -name = "openshell-test-sandbox-oci-image" +name = "openshell-test-sandbox-policy-advisor" version = "0.0.0" dependencies = [ "openshell-testkit", "serde_json", - "tempfile", + "tokio", ] [[package]] From ff22114754274653702c5e9d48160084390c89b6 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Wed, 21 Oct 2026 13:32:43 -0700 Subject: [PATCH 09/10] feat(runner): add the test runner, plans and tmachine Session API Add the test runner (openshell-test), plans, environments and waivers, the tmachine Session API, optional test IDs and `requires` on testkit with a shared testkit-spec crate, and fmt, clippy and nextest for the new crates. Most of the code comes from #4629 by Evan Lezar, adapted to testkit finding the gateway the way the CLI does: the runner sets OPENSHELL_GATEWAY and OPENSHELL_BIN instead of writing a target file. testkit rejects `#[should_panic]` and moves other attributes such as `#[ignore]` to the generated test. The runner's catalog leaves an ignored test out of what a lane expects, and the JUnit check accepts nextest's skipped case for it. Co-authored-by: Evan Lezar Signed-off-by: Matthew Grossman --- .github/workflows/branch-checks.yml | 15 + AGENTS.md | 4 +- CONTRIBUTING.md | 3 + TESTING.md | 71 ++ flake.nix | 3 +- tasks/rust.toml | 6 + tasks/scripts/check_plan_workflow_matrix.py | 114 +++ tasks/test.toml | 10 + tests/environments/docker.yaml | 28 + tests/environments/k3s.yaml | 39 + tests/environments/podman.yaml | 38 + tests/expected-matrix.json | 142 ++++ tests/plans/testkit.yaml | 31 + tests/runner/Cargo.lock | 815 ++++++++++++++++++++ tests/runner/Cargo.toml | 30 + tests/runner/src/bundle.rs | 245 ++++++ tests/runner/src/catalog.rs | 396 ++++++++++ tests/runner/src/environments.rs | 454 +++++++++++ tests/runner/src/execute.rs | 484 ++++++++++++ tests/runner/src/github.rs | 389 ++++++++++ tests/runner/src/jobs.rs | 536 +++++++++++++ tests/runner/src/lib.rs | 17 + tests/runner/src/main.rs | 418 ++++++++++ tests/runner/src/matrix.rs | 290 +++++++ tests/runner/src/plan.rs | 106 +++ tests/runner/src/report.rs | 452 +++++++++++ tests/runner/src/waivers.rs | 224 ++++++ tests/suites/podman/Cargo.lock | 9 + tests/suites/providers/Cargo.lock | 9 + tests/suites/sandbox/Cargo.lock | 9 + tests/suites/sandbox/README.md | 5 +- tests/testkit/Cargo.lock | 10 + tests/testkit/Cargo.toml | 2 +- tests/testkit/README.md | 50 ++ tests/testkit/testkit-macros/Cargo.toml | 1 + tests/testkit/testkit-macros/src/lib.rs | 130 ++-- tests/testkit/testkit-spec/Cargo.toml | 19 + tests/testkit/testkit-spec/src/lib.rs | 403 ++++++++++ tests/testkit/testkit/Cargo.toml | 3 + tests/testkit/testkit/src/capabilities.rs | 202 +++++ tests/testkit/testkit/src/lib.rs | 6 +- tests/testkit/testkit/src/run.rs | 29 +- tests/testkit/testkit/tests/requirements.rs | 56 ++ tests/tmachine/Cargo.lock | 1 + tests/tmachine/Cargo.toml | 3 +- tests/tmachine/config.nix | 35 +- tests/tmachine/src/ansible.rs | 28 +- tests/tmachine/src/lib.rs | 17 + tests/tmachine/src/main.rs | 15 +- tests/tmachine/src/qemu/cleanup.rs | 127 +++ tests/tmachine/src/qemu/layer.rs | 10 +- tests/tmachine/src/qemu/mod.rs | 5 + tests/tmachine/src/qemu/session.rs | 327 ++++++++ tests/tmachine/src/qemu/test.rs | 53 +- tests/tmachine/src/qemu/vm.rs | 197 ++++- tests/waivers.yaml | 11 + 56 files changed, 6982 insertions(+), 150 deletions(-) create mode 100644 tasks/scripts/check_plan_workflow_matrix.py create mode 100644 tests/environments/docker.yaml create mode 100644 tests/environments/k3s.yaml create mode 100644 tests/environments/podman.yaml create mode 100644 tests/expected-matrix.json create mode 100644 tests/plans/testkit.yaml create mode 100644 tests/runner/Cargo.lock create mode 100644 tests/runner/Cargo.toml create mode 100644 tests/runner/src/bundle.rs create mode 100644 tests/runner/src/catalog.rs create mode 100644 tests/runner/src/environments.rs create mode 100644 tests/runner/src/execute.rs create mode 100644 tests/runner/src/github.rs create mode 100644 tests/runner/src/jobs.rs create mode 100644 tests/runner/src/lib.rs create mode 100644 tests/runner/src/main.rs create mode 100644 tests/runner/src/matrix.rs create mode 100644 tests/runner/src/plan.rs create mode 100644 tests/runner/src/report.rs create mode 100644 tests/runner/src/waivers.rs create mode 100644 tests/testkit/testkit-spec/Cargo.toml create mode 100644 tests/testkit/testkit-spec/src/lib.rs create mode 100644 tests/testkit/testkit/src/capabilities.rs create mode 100644 tests/testkit/testkit/tests/requirements.rs create mode 100644 tests/tmachine/src/lib.rs create mode 100644 tests/tmachine/src/qemu/cleanup.rs create mode 100644 tests/tmachine/src/qemu/session.rs create mode 100644 tests/waivers.yaml diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index 0de7c3a2f2..b90b7e9086 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -222,6 +222,8 @@ jobs: cargo fmt --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all -- --check cargo fmt --manifest-path examples/isolation-backend-v8/Cargo.toml --all -- --check cargo fmt --manifest-path tests/testkit/Cargo.toml --all -- --check + cargo fmt --manifest-path tests/tmachine/Cargo.toml --all -- --check + cargo fmt --manifest-path tests/runner/Cargo.toml --all -- --check cargo fmt --manifest-path tests/suites/sandbox/Cargo.toml --all -- --check cargo fmt --manifest-path tests/suites/providers/Cargo.toml --all -- --check cargo fmt --manifest-path tests/suites/podman/Cargo.toml --all -- --check @@ -284,6 +286,8 @@ jobs: cargo clippy --locked --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all-targets -- -D warnings cargo clippy --locked --manifest-path examples/isolation-backend-v8/Cargo.toml --all-targets -- -D warnings cargo clippy --locked --manifest-path tests/testkit/Cargo.toml --all-targets -- -D warnings + cargo clippy --locked --manifest-path tests/tmachine/Cargo.toml --all-targets -- -D warnings + cargo clippy --locked --manifest-path tests/runner/Cargo.toml --all-targets -- -D warnings cargo clippy --locked --manifest-path tests/suites/sandbox/Cargo.toml --all-targets -- -D warnings cargo clippy --locked --manifest-path tests/suites/providers/Cargo.toml --all-targets -- -D warnings cargo clippy --locked --manifest-path tests/suites/podman/Cargo.toml --all-targets -- -D warnings @@ -336,6 +340,17 @@ jobs: cargo nextest run --locked --profile ci --workspace --features openshell-server/test-support cargo nextest run --locked --config-file .config/nextest.toml --profile ci --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml cargo nextest run --locked --config-file .config/nextest.toml --profile ci --manifest-path tests/testkit/Cargo.toml + cargo nextest run --locked --config-file .config/nextest.toml --profile ci --manifest-path tests/tmachine/Cargo.toml + cargo nextest run --locked --config-file .config/nextest.toml --profile ci --manifest-path tests/runner/Cargo.toml + + # What runs where is generated from tests/plans, tests/environments and + # the tests themselves. A change to any of them must update the checked-in + # tests/expected-matrix.json, so it shows up as a diff in review. + - name: Check expected test matrix + shell: nix develop -c bash -euo pipefail {0} + run: | + cargo run --locked --quiet --manifest-path tests/runner/Cargo.toml --bin openshell-test -- check-matrix + uv run tasks/scripts/check_plan_workflow_matrix.py rust-postgres-test: name: Rust PostgreSQL tests diff --git a/AGENTS.md b/AGENTS.md index 89e635e890..5053d24f7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,9 @@ Do not rely on this file for a full inventory. The detailed public and contribut | `python/openshell/` | Python SDK | Python bindings and CLI packaging | | `sdk/typescript/` | TypeScript SDK | Native Connect client, curated sandbox API, and generated protobuf types | | `proto/` | Protobuf definitions | gRPC service contracts | -| `tests/testkit/` | E2E testkit | Support library and `#[testkit::test]` attribute for SDK-driven E2E suites | +| `tests/testkit/` | E2E testkit | Support library, `#[testkit::test]` attribute, and `testkit-spec` parser for SDK-driven E2E suites | +| `tests/tmachine/` | Test machines | QEMU machines provisioned with Ansible, and the `Session` API that drives them | +| `tests/runner/` | Test runner | `openshell-test`: plans, environments, capabilities, waivers, JUnit result checks, CI matrix generation | | `deploy/` | Docker, Helm, K8s | Dockerfiles, Helm chart, manifests | | `docs/` | Published docs | MDX pages, navigation, and content assets | | `fern/` | Docs site config | Fern site config, components, and theme assets | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4dfea77b67..6c5e3d5caf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -345,6 +345,9 @@ These are the primary `mise` tasks for day-to-day development: | `proto/` | Protocol buffer definitions and [public API conventions](proto/README.md) | | `tasks/` | `mise` task definitions and build scripts | | `tests/testkit/` | Support library for SDK-driven end-to-end suites | +| `tests/tmachine/` | QEMU test machines provisioned with Ansible, and the `Session` API that drives them | +| `tests/runner/` | `openshell-test`: expands test plans into jobs, runs them in tmachine environments, and checks the results | +| `tests/plans/`, `tests/environments/` | Test plans, and the environments they run in with the capabilities each provides | | `deploy/` | Dockerfiles, Helm chart, Kubernetes manifests | | `docs/` | Published Fern docs source, navigation, and content assets | | `fern/` | Fern site config, components, and theme assets | diff --git a/TESTING.md b/TESTING.md index 6050731b2b..804000439a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -18,6 +18,12 @@ e2e/python/ # Python E2E tests (test_*.py prefix) e2e/rust/ # Rust CLI E2E tests tests/testkit/ # Support library for SDK-driven E2E suites tests/suites/sandbox/ # E2E suites written on testkit +tests/tmachine/ # QEMU test machines, Ansible playbooks, and the Session API +tests/runner/ # openshell-test: runs test plans in environments +tests/plans/ # Test plans: which suites run in which environments +tests/environments/ # Environment definitions and the capabilities they provide +tests/waivers.yaml # Known bugs that skip a test in some environments +tests/expected-matrix.json # What the plans run where, checked in ``` ## Rust Tests @@ -165,6 +171,71 @@ verifies this, and `uv run tasks/scripts/check_cargo_lockfile_alignment.py --fix re-locks a workspace from the root lockfile. Add new testkit workspaces to `ALIGNED_WORKSPACES` in that script. +### Test plans and the runner (`tests/runner/`) + +`openshell-test` runs testkit suites in provisioned environments. It is the +runner and plan layer from NVIDIA/OpenShell#4629 by Evan Lezar, on top of the +testkit and tmachine here. Tests say what they need, environments say what they +provide, and a plan only selects which suites run where; nobody lists test and +environment combinations by hand. + +- **Plans** (`tests/plans/*.yaml`) pair a family (a workspace under + `tests/suites/`, such as `sandbox`) and optionally some of its suites with + environment IDs. The `testkit` plan is what the `testkit-integration` job of + Branch E2E runs. +- **Environments** (`tests/environments/*.yaml`) name the tmachine environment and + installer that provision them, the gateway name the installer registers, and + their capabilities: `compute_driver`, `rootless`, `gpu`, + `persistent_workspace` and `service_exposure`. The runner passes them to the + tests in `OPENSHELL_TEST_CAPABILITIES`, and points the tests at the registered + gateway and the installed CLI with `OPENSHELL_GATEWAY` and `OPENSHELL_BIN`. +- **Capabilities** are optional on a test: + `#[testkit::test(requires = [persistent_workspace])]`. The runner leaves the + test out of environments without it. `id = "t-0123abcd"` is optional too and + gives a test an identity that survives renames. See + [tests/testkit/README.md](tests/testkit/README.md#capabilities). +- **Waivers** (`tests/waivers.yaml`) skip a test in some environments because of + a known bug. Each names the test (by ID, or by function name when unique), an + issue link and a reason, and the runner lists what it skipped. +- **Results** are checked, not trusted. A job passes only if nextest succeeds and + its JUnit report has a passing case for exactly the tests the runner selected. + A test missing from the report, skipped, or not selected fails the job. +- **Strict mode** (`--strict`, or `OPENSHELL_TEST_STRICT=1`) is for CI. It fails + instead of skipping an environment the host can't provision, an environment + where no test is left to run, or a plan that selects nothing. Without it the + runner reports those and exits successfully, so a laptop can run the part of a + plan it can boot. + +```shell +nix run .#test-runner -- test --plan tests/plans/testkit.yaml --dry-run +nix run .#test-runner -- test --plan tests/plans/testkit.yaml --strict +nix run .#test-runner -- matrix --host # every test against every environment +``` + +`nix run .#test-runner` cross-builds the suites for the guest, boots each +environment through tmachine (this needs KVM, or HVF on macOS), runs the suites +over SSH, and writes JUnit reports, logs and gateway diagnostics to +`artifacts/test-results//`. Every SSH and nextest step has a timeout, and +the runner kills its VMs if it receives SIGTERM. Two runs on one host need +different `TMACHINE_SSH_PORT` values (default 2222). + +**The CI matrix comes from the plan.** +`openshell-test test --plan tests/plans/.yaml --emit-github-matrix` prints +the `{"include": [...]}` a workflow reads with `fromJSON`: one job per +environment with the runner label that can boot it and the number of tests it +must run. `tests/expected-matrix.json` records that output, and the tests that +run in each environment, for every plan. Dropping a capability, adding a test +that needs one, adding a waiver or changing a plan changes the generated file, so +the change shows up as a diff. Branch Checks and `mise run test:expected-matrix` +fail until you review the change and regenerate the file: + +```shell +mise run test:expected-matrix -- --write +``` + +The existing E2E job matrices in `.github/workflows/` are still written by hand; +they switch to this matrix when the suites move onto the runner. + ### Python E2E (`e2e/python/`) `mise run e2e:python` builds `openshell/e2e-python:dev` from diff --git a/flake.nix b/flake.nix index 7fac354cac..0180c7b96d 100644 --- a/flake.nix +++ b/flake.nix @@ -127,7 +127,7 @@ firmwarePkgs = testGuestPkgs; }; testMachines = import ./tests/tmachine/config.nix { - inherit pkgs toolchains; + inherit pkgs toolchains rustToolchain; qemuPkgs = tmachineRuntimePkgs; firmwarePkgs = tmachineRuntimePkgs; }; @@ -203,6 +203,7 @@ tmachine = testMachines.package; tmachine-config = testMachines.config; tmachine-unwrapped = testMachines.unwrapped; + test-runner = testMachines.testRunner; }; devShells = { diff --git a/tasks/rust.toml b/tasks/rust.toml index a9a3ead1d1..dc1ef63ce1 100644 --- a/tasks/rust.toml +++ b/tasks/rust.toml @@ -37,6 +37,8 @@ run = [ "cargo clippy --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all-targets -- -D warnings", "cargo clippy --manifest-path examples/isolation-backend-v8/Cargo.toml --all-targets -- -D warnings", "cargo clippy --manifest-path tests/testkit/Cargo.toml --all-targets -- -D warnings", + "cargo clippy --manifest-path tests/tmachine/Cargo.toml --all-targets -- -D warnings", + "cargo clippy --manifest-path tests/runner/Cargo.toml --all-targets -- -D warnings", "cargo clippy --manifest-path tests/suites/sandbox/Cargo.toml --all-targets -- -D warnings", "cargo clippy --manifest-path tests/suites/providers/Cargo.toml --all-targets -- -D warnings", "cargo clippy --manifest-path tests/suites/podman/Cargo.toml --all-targets -- -D warnings", @@ -53,6 +55,8 @@ run = [ "cargo fmt --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all", "cargo fmt --manifest-path examples/isolation-backend-v8/Cargo.toml --all", "cargo fmt --manifest-path tests/testkit/Cargo.toml --all", + "cargo fmt --manifest-path tests/tmachine/Cargo.toml --all", + "cargo fmt --manifest-path tests/runner/Cargo.toml --all", "cargo fmt --manifest-path tests/suites/sandbox/Cargo.toml --all", "cargo fmt --manifest-path tests/suites/providers/Cargo.toml --all", "cargo fmt --manifest-path tests/suites/podman/Cargo.toml --all", @@ -68,6 +72,8 @@ run = [ "cargo fmt --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all -- --check", "cargo fmt --manifest-path examples/isolation-backend-v8/Cargo.toml --all -- --check", "cargo fmt --manifest-path tests/testkit/Cargo.toml --all -- --check", + "cargo fmt --manifest-path tests/tmachine/Cargo.toml --all -- --check", + "cargo fmt --manifest-path tests/runner/Cargo.toml --all -- --check", "cargo fmt --manifest-path tests/suites/sandbox/Cargo.toml --all -- --check", "cargo fmt --manifest-path tests/suites/providers/Cargo.toml --all -- --check", "cargo fmt --manifest-path tests/suites/podman/Cargo.toml --all -- --check", diff --git a/tasks/scripts/check_plan_workflow_matrix.py b/tasks/scripts/check_plan_workflow_matrix.py new file mode 100644 index 0000000000..0a09060dea --- /dev/null +++ b/tasks/scripts/check_plan_workflow_matrix.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# /// script +# requires-python = ">=3.11" +# dependencies = ["pyyaml"] +# /// + +"""Check that the release workflow matrices match the testkit plan. + +The tmachine lanes in the release workflows list `environment`, `installer` and +`testsuite` by hand. tests/plans/testkit.yaml says which suites run in which +environments, and tests/environments/*.yaml say how each environment is +provisioned. This check derives the lane triples from the plan and fails when a +workflow matrix lists something different, so the two cannot drift apart. + +Each plan entry's suite maps to a tmachine testsuite name below. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import yaml + +PLAN = "tests/plans/testkit.yaml" +WORKFLOWS = ( + ".github/workflows/release-qualification.yml", + ".github/workflows/release-dev.yml", +) +# (plan family, suite or None for the family default) -> tmachine testsuite. +TESTSUITES = { + ("sandbox", "oci-image"): "oci-image", + ("sandbox", None): "sandbox", + ("providers", None): "provider-refresh", +} + + +def root() -> Path: + return Path( + subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + ) + + +def environments(base: Path) -> dict[str, tuple[str, str]]: + found = {} + for path in sorted((base / "tests/environments").glob("*.yaml")): + for env in yaml.safe_load(path.read_text())["environments"]: + tm = env["tmachine"] + found[env["id"]] = (tm["environment"], tm["installer"]) + return found + + +def from_plan(base: Path) -> set[tuple[str, str, str]]: + envs = environments(base) + plan = yaml.safe_load((base / PLAN).read_text()) + triples = set() + for entry in plan["matrix"]: + family, suites = entry["type"], entry.get("suites") or [None] + names = { + TESTSUITES.get((family, suite if suite == "oci-image" else None)) + for suite in suites + } + for name in names: + if name is None: + raise SystemExit(f"error: no tmachine testsuite for {family} {suites}") + for env_id in entry["environments"]: + environment, installer = envs[env_id] + triples.add((environment, installer, name)) + return triples + + +def from_workflow(path: Path) -> set[tuple[str, str, str]]: + triples = set() + managed = set(TESTSUITES.values()) + for job in yaml.safe_load(path.read_text())["jobs"].values(): + matrix = (job.get("with") or {}).get("test-matrix") + if not matrix: + continue + for item in json.loads(matrix): + if item["testsuite"] in managed: + triples.add((item["environment"], item["installer"], item["testsuite"])) + return triples + + +def main() -> int: + base = root() + expected = from_plan(base) + failed = False + for workflow in WORKFLOWS: + actual = from_workflow(base / workflow) + # Release Dev runs a subset of the plan; it must not run anything the + # plan does not list. + extra = actual - expected + missing = expected - actual if workflow.endswith("qualification.yml") else set() + for triple in sorted(extra): + print(f"error: {workflow} runs {triple}, which {PLAN} does not list", file=sys.stderr) + for triple in sorted(missing): + print(f"error: {workflow} is missing {triple} from {PLAN}", file=sys.stderr) + failed |= bool(extra or missing) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tasks/test.toml b/tasks/test.toml index 6cff1fd784..568bd5c123 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -24,6 +24,14 @@ depends = [ "test:docs-nav", "test:maintainer-approval", "test:cargo-lockfile-alignment", + "test:expected-matrix", +] + +["test:expected-matrix"] +description = "Check tests/expected-matrix.json and the release workflow matrices against the test plans" +run = [ + "cargo run --locked --quiet --manifest-path tests/runner/Cargo.toml --bin openshell-test -- check-matrix", + "uv run tasks/scripts/check_plan_workflow_matrix.py", ] ["test:docs-website"] @@ -125,6 +133,8 @@ run = [ "cargo test -p openshell-server --features test-support", "cargo nextest run --config-file .config/nextest.toml --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml", "cargo nextest run --config-file .config/nextest.toml --manifest-path tests/testkit/Cargo.toml", + "cargo nextest run --config-file .config/nextest.toml --manifest-path tests/tmachine/Cargo.toml", + "cargo nextest run --config-file .config/nextest.toml --manifest-path tests/runner/Cargo.toml", ] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-precommit native" hide = true diff --git a/tests/environments/docker.yaml b/tests/environments/docker.yaml new file mode 100644 index 0000000000..e37b2ee1c1 --- /dev/null +++ b/tests/environments/docker.yaml @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Docker-backed environments, by Evan Lezar (NVIDIA/OpenShell#4629). +# +# The ID must equal .-... +# `tmachine` names the environment and installer in tests/tmachine/config.nix +# that provision it. `gateway` is the name the installer registers the gateway +# under with the CLI (`openshell gateway add --name`); the packaged installers +# register `openshell`. `capabilities` is what the environment gives tests; see +# tests/testkit/README.md. +environments: + - id: tmachine.ubuntu2404-amd64.docker-rootful.deb + backend: tmachine + machine: { os: ubuntu, version: "24.04", arch: amd64 } + runtime: docker-rootful + installer: deb + tmachine: { environment: ubuntu-docker-rootful, installer: deb } + gateway: openshell + capabilities: { compute_driver: docker, rootless: false, gpu: false, persistent_workspace: true } + - id: tmachine.ubuntu2404-arm64.docker-rootful.deb + backend: tmachine + machine: { os: ubuntu, version: "24.04", arch: arm64 } + runtime: docker-rootful + installer: deb + tmachine: { environment: ubuntu-docker-rootful, installer: deb } + gateway: openshell + capabilities: { compute_driver: docker, rootless: false, gpu: false, persistent_workspace: true } diff --git a/tests/environments/k3s.yaml b/tests/environments/k3s.yaml new file mode 100644 index 0000000000..ae398b9183 --- /dev/null +++ b/tests/environments/k3s.yaml @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Kubernetes (k3s) environments, by Evan Lezar (NVIDIA/OpenShell#4629). See +# docker.yaml for the fields. The k3s installers register the gateway as +# `tmachine`. +environments: + - id: tmachine.ubuntu2404-amd64.k3s.k3s + backend: tmachine + machine: { os: ubuntu, version: "24.04", arch: amd64 } + runtime: k3s + installer: k3s + tmachine: { environment: ubuntu-k3s, installer: k3s } + gateway: tmachine + capabilities: { compute_driver: kubernetes, rootless: false, gpu: false, persistent_workspace: true } + - id: tmachine.ubuntu2404-arm64.k3s.k3s + backend: tmachine + machine: { os: ubuntu, version: "24.04", arch: arm64 } + runtime: k3s + installer: k3s + tmachine: { environment: ubuntu-k3s, installer: k3s } + gateway: tmachine + capabilities: { compute_driver: kubernetes, rootless: false, gpu: false, persistent_workspace: true } + - id: tmachine.ubuntu2404-amd64.k3s.k3s-ha + backend: tmachine + machine: { os: ubuntu, version: "24.04", arch: amd64 } + runtime: k3s + installer: k3s-ha + tmachine: { environment: ubuntu-k3s, installer: k3s-ha } + gateway: tmachine + capabilities: { compute_driver: kubernetes, rootless: false, gpu: false, persistent_workspace: true } + - id: tmachine.ubuntu2404-arm64.k3s.k3s-ha + backend: tmachine + machine: { os: ubuntu, version: "24.04", arch: arm64 } + runtime: k3s + installer: k3s-ha + tmachine: { environment: ubuntu-k3s, installer: k3s-ha } + gateway: tmachine + capabilities: { compute_driver: kubernetes, rootless: false, gpu: false, persistent_workspace: true } diff --git a/tests/environments/podman.yaml b/tests/environments/podman.yaml new file mode 100644 index 0000000000..50a9228f18 --- /dev/null +++ b/tests/environments/podman.yaml @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Podman-backed environments, by Evan Lezar (NVIDIA/OpenShell#4629). See +# docker.yaml for the fields. +environments: + - id: tmachine.fedora44-amd64.podman-rootful.rpm + backend: tmachine + machine: { os: fedora, version: "44", arch: amd64 } + runtime: podman-rootful + installer: rpm + tmachine: { environment: fedora-podman-rootful, installer: rpm } + gateway: openshell + capabilities: { compute_driver: podman, rootless: false, gpu: false, persistent_workspace: true } + - id: tmachine.fedora44-arm64.podman-rootful.rpm + backend: tmachine + machine: { os: fedora, version: "44", arch: arm64 } + runtime: podman-rootful + installer: rpm + tmachine: { environment: fedora-podman-rootful, installer: rpm } + gateway: openshell + capabilities: { compute_driver: podman, rootless: false, gpu: false, persistent_workspace: true } + - id: tmachine.fedora44-amd64.podman-rootless.rpm + backend: tmachine + machine: { os: fedora, version: "44", arch: amd64 } + runtime: podman-rootless + installer: rpm + tmachine: { environment: fedora-podman-rootless, installer: rpm } + gateway: openshell + capabilities: { compute_driver: podman, rootless: true, gpu: false, persistent_workspace: true } + - id: tmachine.fedora44-arm64.podman-rootless.rpm + backend: tmachine + machine: { os: fedora, version: "44", arch: arm64 } + runtime: podman-rootless + installer: rpm + tmachine: { environment: fedora-podman-rootless, installer: rpm } + gateway: openshell + capabilities: { compute_driver: podman, rootless: true, gpu: false, persistent_workspace: true } diff --git a/tests/expected-matrix.json b/tests/expected-matrix.json new file mode 100644 index 0000000000..cb33aa4865 --- /dev/null +++ b/tests/expected-matrix.json @@ -0,0 +1,142 @@ +{ + "plans": { + "testkit": { + "github_matrix": { + "include": [ + { + "plan": "testkit", + "environment": "tmachine.fedora44-amd64.podman-rootful.rpm", + "runner": "ubuntu-24.04", + "expected_tests": 21 + }, + { + "plan": "testkit", + "environment": "tmachine.fedora44-amd64.podman-rootless.rpm", + "runner": "ubuntu-24.04", + "expected_tests": 21 + }, + { + "plan": "testkit", + "environment": "tmachine.ubuntu2404-amd64.docker-rootful.deb", + "runner": "ubuntu-24.04", + "expected_tests": 20 + }, + { + "plan": "testkit", + "environment": "tmachine.ubuntu2404-amd64.k3s.k3s", + "runner": "ubuntu-24.04", + "expected_tests": 14 + }, + { + "plan": "testkit", + "environment": "tmachine.ubuntu2404-amd64.k3s.k3s-ha", + "runner": "ubuntu-24.04", + "expected_tests": 14 + } + ] + }, + "tests": { + "tmachine.fedora44-amd64.podman-rootful.rpm": [ + "providers/refresh-keycloak/revoked_refresh_grant_requires_user_reauthorization", + "sandbox/cli/cli_create_exec_list_delete", + "sandbox/cli/cli_stop_start_delete", + "sandbox/file-transfer/download_stays_inside_workspace", + "sandbox/file-transfer/upload_download_round_trip", + "sandbox/file-transfer/upload_respects_gitignore", + "sandbox/lifecycle/default_command_sandbox_is_listed_ready", + "sandbox/lifecycle/long_running_exec_delete", + "sandbox/lifecycle/restart_stopped_sandbox", + "sandbox/lifecycle/run_to_completion_exit_delete", + "sandbox/lifecycle/stopped_sandbox_can_be_deleted", + "sandbox/oci-image/custom_workdir_with_named_user", + "sandbox/oci-image/custom_workdir_with_numeric_user_and_private_parents", + "sandbox/oci-image/default_workdir_uses_managed_workspace", + "sandbox/oci-image/policy_identity_overrides_root_image_user", + "sandbox/oci-image/unwritable_custom_workdir_is_rejected", + "sandbox/oci-image/userless_image_uses_default_identity", + "sandbox/persistent-workspace/persistent_workspace_survives_stop_start", + "sandbox/policy-advisor/denied_address_produces_draft_rule", + "sandbox/policy-advisor/denied_hostname_produces_draft_rule", + "sandbox/policy-advisor/policy_local_accepts_proposals" + ], + "tmachine.fedora44-amd64.podman-rootless.rpm": [ + "providers/refresh-keycloak/revoked_refresh_grant_requires_user_reauthorization", + "sandbox/cli/cli_create_exec_list_delete", + "sandbox/cli/cli_stop_start_delete", + "sandbox/file-transfer/download_stays_inside_workspace", + "sandbox/file-transfer/upload_download_round_trip", + "sandbox/file-transfer/upload_respects_gitignore", + "sandbox/lifecycle/default_command_sandbox_is_listed_ready", + "sandbox/lifecycle/long_running_exec_delete", + "sandbox/lifecycle/restart_stopped_sandbox", + "sandbox/lifecycle/run_to_completion_exit_delete", + "sandbox/lifecycle/stopped_sandbox_can_be_deleted", + "sandbox/oci-image/custom_workdir_with_named_user", + "sandbox/oci-image/custom_workdir_with_numeric_user_and_private_parents", + "sandbox/oci-image/default_workdir_uses_managed_workspace", + "sandbox/oci-image/policy_identity_overrides_root_image_user", + "sandbox/oci-image/unwritable_custom_workdir_is_rejected", + "sandbox/oci-image/userless_image_uses_default_identity", + "sandbox/persistent-workspace/persistent_workspace_survives_stop_start", + "sandbox/policy-advisor/denied_address_produces_draft_rule", + "sandbox/policy-advisor/denied_hostname_produces_draft_rule", + "sandbox/policy-advisor/policy_local_accepts_proposals" + ], + "tmachine.ubuntu2404-amd64.docker-rootful.deb": [ + "sandbox/cli/cli_create_exec_list_delete", + "sandbox/cli/cli_stop_start_delete", + "sandbox/file-transfer/download_stays_inside_workspace", + "sandbox/file-transfer/upload_download_round_trip", + "sandbox/file-transfer/upload_respects_gitignore", + "sandbox/lifecycle/default_command_sandbox_is_listed_ready", + "sandbox/lifecycle/long_running_exec_delete", + "sandbox/lifecycle/restart_stopped_sandbox", + "sandbox/lifecycle/run_to_completion_exit_delete", + "sandbox/lifecycle/stopped_sandbox_can_be_deleted", + "sandbox/oci-image/custom_workdir_with_named_user", + "sandbox/oci-image/custom_workdir_with_numeric_user_and_private_parents", + "sandbox/oci-image/default_workdir_uses_managed_workspace", + "sandbox/oci-image/policy_identity_overrides_root_image_user", + "sandbox/oci-image/unwritable_custom_workdir_is_rejected", + "sandbox/oci-image/userless_image_uses_default_identity", + "sandbox/persistent-workspace/persistent_workspace_survives_stop_start", + "sandbox/policy-advisor/denied_address_produces_draft_rule", + "sandbox/policy-advisor/denied_hostname_produces_draft_rule", + "sandbox/policy-advisor/policy_local_accepts_proposals" + ], + "tmachine.ubuntu2404-amd64.k3s.k3s": [ + "sandbox/cli/cli_create_exec_list_delete", + "sandbox/cli/cli_stop_start_delete", + "sandbox/file-transfer/download_stays_inside_workspace", + "sandbox/file-transfer/upload_download_round_trip", + "sandbox/file-transfer/upload_respects_gitignore", + "sandbox/lifecycle/default_command_sandbox_is_listed_ready", + "sandbox/lifecycle/long_running_exec_delete", + "sandbox/lifecycle/restart_stopped_sandbox", + "sandbox/lifecycle/run_to_completion_exit_delete", + "sandbox/lifecycle/stopped_sandbox_can_be_deleted", + "sandbox/persistent-workspace/persistent_workspace_survives_stop_start", + "sandbox/policy-advisor/denied_address_produces_draft_rule", + "sandbox/policy-advisor/denied_hostname_produces_draft_rule", + "sandbox/policy-advisor/policy_local_accepts_proposals" + ], + "tmachine.ubuntu2404-amd64.k3s.k3s-ha": [ + "sandbox/cli/cli_create_exec_list_delete", + "sandbox/cli/cli_stop_start_delete", + "sandbox/file-transfer/download_stays_inside_workspace", + "sandbox/file-transfer/upload_download_round_trip", + "sandbox/file-transfer/upload_respects_gitignore", + "sandbox/lifecycle/default_command_sandbox_is_listed_ready", + "sandbox/lifecycle/long_running_exec_delete", + "sandbox/lifecycle/restart_stopped_sandbox", + "sandbox/lifecycle/run_to_completion_exit_delete", + "sandbox/lifecycle/stopped_sandbox_can_be_deleted", + "sandbox/persistent-workspace/persistent_workspace_survives_stop_start", + "sandbox/policy-advisor/denied_address_produces_draft_rule", + "sandbox/policy-advisor/denied_hostname_produces_draft_rule", + "sandbox/policy-advisor/policy_local_accepts_proposals" + ] + } + } + } +} diff --git a/tests/plans/testkit.yaml b/tests/plans/testkit.yaml new file mode 100644 index 0000000000..f22a261541 --- /dev/null +++ b/tests/plans/testkit.yaml @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# The testkit suites under tests/suites, in the environments the conformance and +# feature lanes of Branch E2E, Release Dev and release qualification run today. +# `type` names a family under tests/suites/; omit `suites` to run every suite in +# it, or list suite crate directories to run only those. +matrix: + # Driver-neutral sandbox behavior: the `sandbox` tmachine testsuite. + - type: sandbox + suites: [lifecycle, cli, persistent-workspace, file-transfer, policy-advisor] + environments: + - tmachine.ubuntu2404-amd64.docker-rootful.deb + - tmachine.ubuntu2404-amd64.k3s.k3s + - tmachine.ubuntu2404-amd64.k3s.k3s-ha + - tmachine.fedora44-amd64.podman-rootful.rpm + - tmachine.fedora44-amd64.podman-rootless.rpm + + # Image identity and working directory; Docker and Podman drivers only. + - type: sandbox + suites: [oci-image] + environments: + - tmachine.ubuntu2404-amd64.docker-rootful.deb + - tmachine.fedora44-amd64.podman-rootful.rpm + - tmachine.fedora44-amd64.podman-rootless.rpm + + # Provider credential refresh against Keycloak. + - type: providers + environments: + - tmachine.fedora44-amd64.podman-rootful.rpm + - tmachine.fedora44-amd64.podman-rootless.rpm diff --git a/tests/runner/Cargo.lock b/tests/runner/Cargo.lock new file mode 100644 index 0000000000..ef57d0fe2e --- /dev/null +++ b/tests/runner/Cargo.lock @@ -0,0 +1,815 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "annotate-snippets" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "encoding_rs_io" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "granit-parser" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e20f99e46474f56bd905c56e817ebddcf377a611f94c53ac4649e4d3fa3c0cd0" +dependencies = [ + "arraydeque", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "noyalib" +version = "0.0.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962838abbd8dd2cd3844d2208dc502458bba6985f8bf1dc21761b30260fcbd7f" +dependencies = [ + "hashbrown", + "indexmap", + "libm", + "memchr", + "rustc-hash", + "serde_core", + "smallvec", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openshell-test-runner" +version = "0.0.0" +dependencies = [ + "anyhow", + "clap", + "noyalib", + "openshell-testkit-spec", + "serde", + "serde_json", + "tempfile", + "tmachine", + "tokio", + "toml", +] + +[[package]] +name = "openshell-testkit-spec" +version = "0.0.0" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-saphyr" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8050abb251097357e24aff63ba2c52a6309ecb7d23a5474023df960a02694d8" +dependencies = [ + "annotate-snippets", + "encoding_rs_io", + "granit-parser", + "num-traits", + "serde_core", + "smallvec", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tmachine" +version = "0.1.0" +dependencies = [ + "anyhow", + "blake3", + "clap", + "directories", + "libc", + "serde", + "serde-saphyr", + "tempfile", + "tokio", +] + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tests/runner/Cargo.toml b/tests/runner/Cargo.toml new file mode 100644 index 0000000000..826f7eb465 --- /dev/null +++ b/tests/runner/Cargo.toml @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-test-runner" +description = "Runs OpenShell test plans against provisioned environments" +version = "0.0.0" +edition = "2024" +rust-version = "1.94" +license = "Apache-2.0" +repository = "https://github.com/NVIDIA/OpenShell" +publish = false + +[[bin]] +name = "openshell-test" +path = "src/main.rs" + +[dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive", "env"] } +openshell-testkit-spec = { path = "../testkit/testkit-spec" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yml = { package = "noyalib", version = "0.0.53", default-features = false, features = ["std", "compat-serde-yaml"] } +tempfile = "3" +tmachine = { path = "../tmachine" } +tokio = { version = "1", features = ["macros", "rt"] } +toml = "0.9" + +[workspace] diff --git a/tests/runner/src/bundle.rs b/tests/runner/src/bundle.rs new file mode 100644 index 0000000000..83114a65ce --- /dev/null +++ b/tests/runner/src/bundle.rs @@ -0,0 +1,245 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Building a family's test bundle for the guest. +//! +//! A bundle is a tar holding a nextest archive built for the guest target, +//! the family workspace's `Cargo.toml` files (for `--workspace-remap`), a +//! `nextest` config with the runner's `JUnit` profile, and `testkit-ready`, +//! which waits for the gateway before the tests run. The guest needs only the +//! `cargo-nextest` binary to run it. +//! +//! Adapted from the bundle builder in Evan Lezar's NVIDIA/OpenShell#4629. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result, bail, ensure}; +use serde::Deserialize; +use tempfile::TempDir; + +use crate::catalog::Family; +use crate::environments::Arch; + +/// The nextest profile the runner uses in the guest. +pub const PROFILE: &str = "openshell"; + +/// The readiness check every bundle carries, at the bundle root. +pub const READY_BINARY: &str = "testkit-ready"; + +/// Nextest flags a test as slow after the period and terminates it after +/// `terminate-after` periods, so a hung test fails after two minutes instead +/// of holding the lane until the job times out. +const NEXTEST_CONFIG: &str = r#"# Written by the OpenShell test runner. +[profile.openshell] +fail-fast = false +slow-timeout = { period = "30s", terminate-after = 4 } + +[profile.openshell.junit] +path = "junit.xml" +"#; + +pub struct Bundle { + pub path: PathBuf, + _dir: TempDir, +} + +/// The Rust target for guests of `arch`. Static musl binaries don't depend +/// on the guest's libc. +pub const fn guest_target(arch: Arch) -> &'static str { + match arch { + Arch::Amd64 => "x86_64-unknown-linux-musl", + Arch::Arm64 => "aarch64-unknown-linux-musl", + } +} + +/// Build `testkit-ready` for the guest and return its path. +pub fn build_ready(root: &Path, target: &str) -> Result { + let output = Command::new("cargo") + .args(["build", "--locked", "--manifest-path"]) + .arg(root.join("tests/testkit/Cargo.toml")) + .args(["-p", "openshell-testkit", "--bin", READY_BINARY]) + .args([ + "--target", + target, + "--message-format", + "json-render-diagnostics", + ]) + .stderr(std::process::Stdio::inherit()) + .output() + .context("run cargo build")?; + ensure!( + output.status.success(), + "build {READY_BINARY} exited with {}", + output.status + ); + built_executable(&output.stdout, READY_BINARY) + .with_context(|| format!("cargo build did not report a {READY_BINARY} executable")) +} + +/// The executable cargo reports building for the binary target `name`. +fn built_executable(messages: &[u8], name: &str) -> Option { + #[derive(Deserialize)] + struct Message { + reason: String, + target: Option, + executable: Option, + } + #[derive(Deserialize)] + struct Target { + name: String, + } + + serde_json::Deserializer::from_slice(messages) + .into_iter::() + .filter_map(std::result::Result::ok) + .filter(|message| message.reason == "compiler-artifact") + .filter(|message| { + message + .target + .as_ref() + .is_some_and(|target| target.name == name) + }) + .find_map(|message| message.executable) +} + +/// Build the bundle for `packages`, and check that the archive contains a +/// binary for every suite in `binaries`. `ready` is the guest's +/// `testkit-ready`. +pub fn build( + family: &Family, + packages: &BTreeSet, + binaries: &BTreeSet, + target: &str, + ready: &Path, +) -> Result { + let dir = tempfile::tempdir().context("create bundle directory")?; + let stage = dir.path().join("stage"); + std::fs::create_dir(&stage)?; + let manifest = family.workspace.join("Cargo.toml"); + let archive = stage.join("tests.tar.zst"); + + let mut command = Command::new("cargo"); + command + .args(["nextest", "archive", "--locked", "--manifest-path"]) + .arg(&manifest) + .args(["--target", target, "--archive-file"]) + .arg(&archive); + for package in packages { + command.args(["-p", package]); + } + run(&mut command).with_context(|| format!("build {} test archive", family.name))?; + + let archived = archived_binaries(&manifest, &archive)?; + let missing: Vec<_> = binaries.difference(&archived).collect(); + ensure!( + missing.is_empty(), + "the {} catalog lists suites the archive has no test binary for: {missing:?}; archived: {archived:?}", + family.name + ); + + copy_manifests(&family.workspace, &stage)?; + std::fs::write(stage.join("nextest.toml"), NEXTEST_CONFIG)?; + std::fs::copy(ready, stage.join(READY_BINARY)) + .with_context(|| format!("copy {}", ready.display()))?; + + let path = dir.path().join(format!("{}-tests.tar", family.name)); + run(Command::new("tar") + .arg("-C") + .arg(&stage) + .arg("-cf") + .arg(&path) + .arg(".")) + .context("pack test bundle")?; + Ok(Bundle { path, _dir: dir }) +} + +#[derive(Deserialize)] +struct ListOutput { + #[serde(rename = "rust-binaries")] + rust_binaries: std::collections::BTreeMap, +} + +#[derive(Deserialize)] +struct ListSuite { + #[serde(rename = "binary-name")] + binary_name: String, + kind: String, +} + +/// Names of the test binaries in an archive, read without executing them. +fn archived_binaries(manifest: &Path, archive: &Path) -> Result> { + let output = Command::new("cargo") + .args(["nextest", "list", "--manifest-path"]) + .arg(manifest) + .arg("--archive-file") + .arg(archive) + .args(["--list-type", "binaries-only", "--message-format", "json"]) + .output() + .context("run cargo nextest list")?; + ensure!( + output.status.success(), + "cargo nextest list exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + let list: ListOutput = + serde_json::from_slice(&output.stdout).context("parse cargo nextest list output")?; + Ok(list + .rust_binaries + .into_values() + .filter(|suite| suite.kind == "test") + .map(|suite| suite.binary_name) + .collect()) +} + +/// Copy every `Cargo.toml` under `workspace` into `stage`, keeping relative +/// paths and skipping build output. +fn copy_manifests(workspace: &Path, stage: &Path) -> Result<()> { + fn walk(root: &Path, dir: &Path, stage: &Path) -> Result<()> { + for entry in std::fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? { + let path = entry?.path(); + let name = path.file_name().unwrap_or_default(); + if path.is_dir() { + if name != "target" && name != ".git" { + walk(root, &path, stage)?; + } + } else if name == "Cargo.toml" { + let dest = stage.join(path.strip_prefix(root)?); + std::fs::create_dir_all(dest.parent().unwrap_or(stage))?; + std::fs::copy(&path, &dest).with_context(|| format!("copy {}", path.display()))?; + } + } + Ok(()) + } + walk(workspace, workspace, stage) +} + +fn run(command: &mut Command) -> Result<()> { + let status = command + .status() + .with_context(|| format!("run {command:?}"))?; + if !status.success() { + bail!("{command:?} exited with {status}"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn built_executable_finds_the_named_binary() { + let messages = br#"{"reason":"compiler-artifact","target":{"name":"testkit"},"executable":null} +{"reason":"compiler-artifact","target":{"name":"testkit-ready"},"executable":"/t/debug/testkit-ready"} +{"reason":"build-finished","success":true} +"#; + assert_eq!( + built_executable(messages, "testkit-ready"), + Some(PathBuf::from("/t/debug/testkit-ready")) + ); + assert_eq!(built_executable(messages, "other"), None); + } +} diff --git a/tests/runner/src/catalog.rs b/tests/runner/src/catalog.rs new file mode 100644 index 0000000000..97f88b39c9 --- /dev/null +++ b/tests/runner/src/catalog.rs @@ -0,0 +1,396 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The test catalog: every family, suite, and test, read from source. +//! +//! A family is a Cargo workspace directly under `tests/suites/` that has at +//! least one testkit suite. A suite is one test binary +//! (`/tests/.rs`) containing `#[testkit::test]` functions; +//! the suite name is the binary name with `_` replaced by `-`. Tests are +//! parsed with `testkit-spec`, the same parser the macro uses, so the runner +//! never executes test binaries on the host to learn about them. Workspaces +//! whose tests are plain `#[test]` functions have no testkit suites and are +//! not families. +//! +//! A test's identity is its optional stable ID, and its function name +//! otherwise. IDs are unique across every family, so a test can move between +//! suites and families without changing its ID. +//! +//! Adapted from the catalog in Evan Lezar's NVIDIA/OpenShell#4629. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; + +/// Where families live, relative to the repository root. +const SUITES_DIR: &str = "tests/suites"; + +#[derive(Debug, Serialize)] +pub struct Family { + pub name: String, + #[serde(skip)] + pub workspace: PathBuf, + pub suites: BTreeMap, +} + +#[derive(Clone, Debug, Serialize)] +pub struct Suite { + pub suite: String, + pub binary: String, + pub package: String, + pub tests: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct Test { + /// The stable test ID, if the test declares one. + pub id: Option, + /// The function name, which is also the nextest test name. + pub name: String, + pub description: String, + pub requires: Vec, +} + +impl Test { + /// How to name the test to a reader: the function name, with the ID + /// when there is one. + pub fn label(&self) -> String { + match &self.id { + Some(id) => format!("{} ({id})", self.name), + None => self.name.clone(), + } + } +} + +/// Every known family, keyed by name. +#[derive(Debug, Serialize)] +pub struct Catalog { + pub families: BTreeMap, +} + +impl Catalog { + pub fn load(root: &Path) -> Result { + let suites_dir = root.join(SUITES_DIR); + let mut workspaces: Vec<_> = std::fs::read_dir(&suites_dir) + .with_context(|| format!("read {}", suites_dir.display()))? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>() + .with_context(|| format!("read {}", suites_dir.display()))?; + workspaces.retain(|path| path.join("Cargo.toml").is_file()); + workspaces.sort(); + + let mut families = BTreeMap::new(); + for workspace in workspaces { + let name = workspace + .file_name() + .and_then(|name| name.to_str()) + .with_context(|| format!("{} is not a UTF-8 name", workspace.display()))? + .to_string(); + let family = Family::load(&name, &workspace) + .with_context(|| format!("load {name} test catalog"))?; + if !family.suites.is_empty() { + families.insert(name, family); + } + } + let catalog = Self { families }; + catalog.check_unique()?; + Ok(catalog) + } + + /// Every test, with its family and suite. + pub fn tests(&self) -> impl Iterator { + self.families.values().flat_map(|family| { + family + .suites + .values() + .flat_map(move |suite| suite.tests.iter().map(move |test| (family, suite, test))) + }) + } + + /// Fail if two tests anywhere in the catalog share an ID. + fn check_unique(&self) -> Result<()> { + let mut seen: BTreeMap<&str, String> = BTreeMap::new(); + for (family, suite, test) in self.tests() { + let Some(id) = &test.id else { continue }; + let location = format!("{}/{}/{}", family.name, suite.suite, test.name); + if let Some(first) = seen.insert(id, location.clone()) { + bail!("test ID {id} is used by both {first} and {location}"); + } + } + Ok(()) + } + + pub fn family(&self, name: &str) -> Result<&Family> { + self.families.get(name).with_context(|| { + format!( + "unknown test family {name:?}; known families: {}", + self.families.keys().cloned().collect::>().join(", ") + ) + }) + } + + /// Resolve how a waiver names a test: a stable ID, or a function name + /// that is unique in the catalog. + pub fn resolve(&self, reference: &str) -> Result<&Test> { + let matches: Vec<&Test> = self + .tests() + .map(|(_, _, test)| test) + .filter(|test| test.id.as_deref() == Some(reference) || test.name == reference) + .collect(); + let ids: BTreeSet<_> = matches.iter().map(|test| test.label()).collect(); + match matches.as_slice() { + [test] => Ok(test), + [] => bail!("no test has the ID or name {reference:?}"), + _ => bail!( + "{reference:?} names more than one test ({}); use the test ID", + ids.into_iter().collect::>().join(", ") + ), + } + } +} + +#[derive(Deserialize)] +struct WorkspaceManifest { + workspace: Option, +} + +#[derive(Deserialize)] +struct WorkspaceSection { + #[serde(default)] + members: Vec, +} + +#[derive(Deserialize)] +struct PackageManifest { + package: PackageSection, +} + +#[derive(Deserialize)] +struct PackageSection { + name: String, +} + +impl Family { + fn load(name: &str, workspace: &Path) -> Result { + let manifest: WorkspaceManifest = read_toml(&workspace.join("Cargo.toml"))?; + let members = manifest.workspace.map(|w| w.members).unwrap_or_default(); + let mut suites = BTreeMap::new(); + for member in members { + let member_dir = workspace.join(&member); + for suite in member_suites(&member_dir)? { + if let Some(existing) = suites.insert(suite.suite.clone(), suite) { + bail!( + "suite {:?} is defined more than once (again in {member})", + existing.suite + ); + } + } + } + Ok(Self { + name: name.to_string(), + workspace: workspace.to_path_buf(), + suites, + }) + } + + pub fn suite(&self, name: &str) -> Result<&Suite> { + self.suites.get(name).with_context(|| { + format!( + "unknown {} suite {name:?}; known suites: {}", + self.name, + self.suites.keys().cloned().collect::>().join(", ") + ) + }) + } + + /// Packages containing the given suites. + pub fn packages<'a>(&self, suites: impl IntoIterator) -> BTreeSet { + suites + .into_iter() + .filter_map(|suite| self.suites.get(suite)) + .map(|suite| suite.package.clone()) + .collect() + } +} + +/// Suites in one workspace member: its `tests/*.rs` files that contain +/// `#[testkit::test]` functions. +fn member_suites(member_dir: &Path) -> Result> { + let tests_dir = member_dir.join("tests"); + if !tests_dir.is_dir() { + return Ok(Vec::new()); + } + let package: PackageManifest = read_toml(&member_dir.join("Cargo.toml"))?; + + let mut files: Vec<_> = std::fs::read_dir(&tests_dir) + .with_context(|| format!("read {}", tests_dir.display()))? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>()?; + files.retain(|path| path.extension().is_some_and(|ext| ext == "rs")); + files.sort(); + + let mut suites = Vec::new(); + for file in files { + let source = + std::fs::read_to_string(&file).with_context(|| format!("read {}", file.display()))?; + // Skip files that cannot contain a test before parsing them. + if !source.contains("testkit::test") { + continue; + } + // An `#[ignore]` test only runs with `--run-ignored`, so it is not part + // of what a lane expects. nextest reports it as skipped, which the + // JUnit check accepts for a test that was never expected. + let specs: Vec<_> = testkit_spec::scan(&source) + .map_err(|error| anyhow::anyhow!("{}: {error}", file.display()))? + .into_iter() + .filter(|spec| !spec.ignored) + .collect(); + if specs.is_empty() { + continue; + } + let binary = file + .file_stem() + .and_then(|stem| stem.to_str()) + .with_context(|| format!("test file name {} is not UTF-8", file.display()))? + .to_string(); + suites.push(Suite { + suite: binary.replace('_', "-"), + binary, + package: package.package.name.clone(), + tests: specs + .into_iter() + .map(|spec| Test { + id: spec.id, + name: spec.name.to_string(), + description: spec.description, + requires: spec.requires.iter().map(ToString::to_string).collect(), + }) + .collect(), + }); + } + Ok(suites) +} + +fn read_toml(path: &Path) -> Result { + let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + toml::from_str(&text).with_context(|| format!("parse {}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") + } + + fn test(id: Option<&str>, name: &str) -> Test { + Test { + id: id.map(str::to_string), + name: name.to_string(), + description: "d".to_string(), + requires: Vec::new(), + } + } + + fn catalog_of(tests: Vec<(&str, Test)>) -> Catalog { + let mut families: BTreeMap = BTreeMap::new(); + for (family, test) in tests { + let entry = families + .entry(family.to_string()) + .or_insert_with(|| Family { + name: family.to_string(), + workspace: PathBuf::new(), + suites: BTreeMap::from([( + "s".to_string(), + Suite { + suite: "s".to_string(), + binary: "s".to_string(), + package: "p".to_string(), + tests: Vec::new(), + }, + )]), + }); + entry.suites.get_mut("s").unwrap().tests.push(test); + } + Catalog { families } + } + + #[test] + fn repository_catalog_has_the_sandbox_lifecycle_suite() { + let catalog = Catalog::load(&repo_root()).unwrap(); + let family = catalog.family("sandbox").unwrap(); + let suite = family.suite("lifecycle").unwrap(); + assert_eq!(suite.binary, "lifecycle"); + assert_eq!(suite.package, "openshell-test-sandbox-lifecycle"); + assert!( + suite + .tests + .iter() + .all(|test| !test.name.is_empty() && !test.description.is_empty()) + ); + assert!(!suite.tests.is_empty()); + } + + #[test] + fn workspaces_without_testkit_suites_are_not_families() { + let catalog = Catalog::load(&repo_root()).unwrap(); + for family in catalog.families.values() { + assert!(!family.suites.is_empty(), "{}", family.name); + } + } + + #[test] + fn duplicate_ids_are_rejected_across_families() { + let catalog = catalog_of(vec![ + ("a", test(Some("t-00000001"), "first")), + ("b", test(Some("t-00000001"), "second")), + ]); + let err = catalog.check_unique().unwrap_err().to_string(); + assert!( + err.contains("a/s/first") && err.contains("b/s/second"), + "{err}" + ); + } + + #[test] + fn tests_without_ids_may_share_a_name_across_families() { + let catalog = catalog_of(vec![("a", test(None, "same")), ("b", test(None, "same"))]); + catalog.check_unique().unwrap(); + let err = catalog.resolve("same").unwrap_err().to_string(); + assert!(err.contains("more than one test"), "{err}"); + } + + #[test] + fn resolves_tests_by_id_or_unique_name() { + let catalog = catalog_of(vec![ + ("a", test(Some("t-00000001"), "first")), + ("a", test(None, "second")), + ]); + assert_eq!(catalog.resolve("t-00000001").unwrap().name, "first"); + assert_eq!(catalog.resolve("first").unwrap().name, "first"); + assert_eq!(catalog.resolve("second").unwrap().name, "second"); + assert!(catalog.resolve("third").is_err()); + } + + #[test] + fn labels_include_the_id_when_there_is_one() { + assert_eq!(test(None, "n").label(), "n"); + assert_eq!(test(Some("t-00000001"), "n").label(), "n (t-00000001)"); + } + + #[test] + fn unknown_names_list_known_ones() { + let catalog = Catalog::load(&repo_root()).unwrap(); + let err = catalog.family("no-such-family").unwrap_err(); + assert!(err.to_string().contains("sandbox"), "{err}"); + let err = catalog + .family("sandbox") + .unwrap() + .suite("lifecyle") + .unwrap_err(); + assert!(err.to_string().contains("lifecycle"), "{err}"); + } +} diff --git a/tests/runner/src/environments.rs b/tests/runner/src/environments.rs new file mode 100644 index 0000000000..e728804a7d --- /dev/null +++ b/tests/runner/src/environments.rs @@ -0,0 +1,454 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Environment definitions: `tests/environments/*.yaml`. +//! +//! Each entry describes one environment a plan can name: the machine, the +//! container runtime, the installer, how the backend provisions it, the name +//! the installer registers the gateway under, and the capabilities it gives +//! tests. The runner hands capabilities to the tests in +//! `OPENSHELL_TEST_CAPABILITIES`; a capability set to a boolean must be one +//! testkit knows, so a typo fails here instead of silently dropping tests. +//! +//! Adapted from the environment definitions in Evan Lezar's +//! NVIDIA/OpenShell#4629. + +use std::collections::BTreeMap; +use std::fmt; +use std::path::Path; + +use anyhow::{Context, Result, bail, ensure}; +use serde::Deserialize; + +/// All environments, keyed by ID. +#[derive(Debug, Default)] +pub struct Catalog { + environments: BTreeMap, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct File { + environments: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Environment { + pub id: String, + pub backend: Backend, + pub machine: Machine, + pub runtime: String, + pub installer: String, + /// Backend parameters when `backend` is `tmachine`. + #[serde(default)] + pub tmachine: Option, + /// The name the installer registers the gateway under with the CLI, which + /// the runner passes to tests as `OPENSHELL_GATEWAY`. Without one, tests + /// use the CLI's active gateway. + #[serde(default)] + pub gateway: Option, + #[serde(default)] + pub capabilities: BTreeMap, +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Backend { + Tmachine, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Machine { + pub os: String, + pub version: String, + pub arch: Arch, +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Arch { + Amd64, + Arm64, +} + +/// The tmachine environment and installer that provision this environment. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Tmachine { + pub environment: String, + pub installer: String, +} + +/// Selects environments by their fields. An omitted field matches any value, +/// so an empty selector matches every environment. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Selector { + pub id: Option, + pub backend: Option, + pub os: Option, + pub version: Option, + pub arch: Option, + pub runtime: Option, + pub installer: Option, +} + +impl Selector { + pub fn matches(&self, environment: &Environment) -> bool { + self.id.as_ref().is_none_or(|id| *id == environment.id) + && self + .backend + .is_none_or(|backend| backend == environment.backend) + && self + .os + .as_ref() + .is_none_or(|os| *os == environment.machine.os) + && self + .version + .as_ref() + .is_none_or(|version| *version == environment.machine.version) + && self + .arch + .is_none_or(|arch| arch == environment.machine.arch) + && self + .runtime + .as_ref() + .is_none_or(|runtime| *runtime == environment.runtime) + && self + .installer + .as_ref() + .is_none_or(|installer| *installer == environment.installer) + } +} + +impl fmt::Display for Selector { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let fields: Vec = [ + ("id", self.id.clone()), + ("backend", self.backend.map(|backend| backend.to_string())), + ("os", self.os.clone()), + ("version", self.version.clone()), + ("arch", self.arch.map(|arch| arch.to_string())), + ("runtime", self.runtime.clone()), + ("installer", self.installer.clone()), + ] + .into_iter() + .filter_map(|(name, value)| value.map(|value| format!("{name}={value}"))) + .collect(); + if fields.is_empty() { + f.write_str("all environments") + } else { + f.write_str(&fields.join(", ")) + } + } +} + +impl fmt::Display for Backend { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Tmachine => "tmachine", + }) + } +} + +impl fmt::Display for Arch { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Amd64 => "amd64", + Self::Arm64 => "arm64", + }) + } +} + +impl Arch { + /// The architecture of the machine running this process. + pub const fn host() -> Option { + if cfg!(target_arch = "x86_64") { + Some(Self::Amd64) + } else if cfg!(target_arch = "aarch64") { + Some(Self::Arm64) + } else { + None + } + } +} + +impl Environment { + /// The ID implied by the environment's fields. + pub fn expected_id(&self) -> String { + format!( + "{}.{}{}-{}.{}.{}", + self.backend, + self.machine.os, + self.machine.version.replace('.', ""), + self.machine.arch, + self.runtime, + self.installer + ) + } + + fn validate(&self) -> Result<()> { + let expected = self.expected_id(); + ensure!( + self.id == expected, + "environment {:?} does not match its fields; expected ID {expected:?}", + self.id + ); + match self.backend { + Backend::Tmachine => ensure!( + self.tmachine.is_some(), + "environment {:?} uses the tmachine backend but has no `tmachine` section", + self.id + ), + } + for (name, value) in &self.capabilities { + if matches!(value, serde_yml::Value::Bool(_)) { + ensure!( + testkit_spec::CAPABILITIES.contains(&name.as_str()), + "environment {:?} sets unknown capability {name:?}; testkit knows: {}", + self.id, + testkit_spec::CAPABILITIES.join(", ") + ); + } + } + Ok(()) + } + + /// The environment's capabilities in the form tests read from + /// `OPENSHELL_TEST_CAPABILITIES`: `name` for a capability it provides, + /// `name=value` for a descriptive value, nothing for one it lacks. The + /// machine's architecture is always included as `arch`. + pub fn capabilities_env(&self) -> String { + let mut entries: Vec = Vec::new(); + if !self.capabilities.contains_key("arch") { + entries.push(format!("arch={}", self.machine.arch)); + } + for (name, value) in &self.capabilities { + match value { + serde_yml::Value::Bool(true) => entries.push(name.clone()), + serde_yml::Value::Bool(false) | serde_yml::Value::Null => {} + serde_yml::Value::String(value) => entries.push(format!("{name}={value}")), + other => entries.push(format!("{name}={}", scalar_to_string(other))), + } + } + entries.sort(); + entries.join(",") + } + + /// Why the backend can't provision this environment on this host, if it + /// can't. tmachine boots guests of the host's architecture only. + pub fn unsupported_reason(&self, host: Option) -> Option { + match self.backend { + Backend::Tmachine if host != Some(self.machine.arch) => Some(format!( + "tmachine boots {} guests only on {} hosts", + self.machine.arch, self.machine.arch + )), + Backend::Tmachine => None, + } + } +} + +fn scalar_to_string(value: &serde_yml::Value) -> String { + serde_yml::to_string(value) + .map(|text| text.trim().to_string()) + .unwrap_or_default() +} + +impl Catalog { + /// Load every `*.yaml` file in `dir`. + pub fn load_dir(dir: &Path) -> Result { + let mut paths: Vec<_> = std::fs::read_dir(dir) + .with_context(|| format!("read environments directory {}", dir.display()))? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>() + .with_context(|| format!("read environments directory {}", dir.display()))?; + paths.retain(|path| path.extension().is_some_and(|ext| ext == "yaml")); + paths.sort(); + + let mut catalog = Self::default(); + for path in paths { + let yaml = std::fs::read_to_string(&path) + .with_context(|| format!("read {}", path.display()))?; + catalog + .add_yaml(&yaml) + .with_context(|| format!("load environments from {}", path.display()))?; + } + Ok(catalog) + } + + fn add_yaml(&mut self, yaml: &str) -> Result<()> { + let file: File = serde_yml::from_str(yaml)?; + for environment in file.environments { + environment.validate()?; + if self.environments.contains_key(&environment.id) { + bail!("environment {:?} is defined more than once", environment.id); + } + self.environments + .insert(environment.id.clone(), environment); + } + Ok(()) + } + + pub fn get(&self, id: &str) -> Option<&Environment> { + self.environments.get(id) + } + + pub fn iter(&self) -> impl Iterator { + self.environments.values() + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + const UBUNTU_DOCKER: &str = " +environments: + - id: tmachine.ubuntu2404-amd64.docker-rootful.deb + backend: tmachine + machine: { os: ubuntu, version: \"24.04\", arch: amd64 } + runtime: docker-rootful + installer: deb + tmachine: { environment: ubuntu-docker-rootful, installer: deb } + capabilities: { compute_driver: docker, gpu: false } +"; + + fn catalog(yaml: &str) -> Result { + let mut catalog = Catalog::default(); + catalog.add_yaml(yaml)?; + Ok(catalog) + } + + #[test] + fn loads_environment() { + let catalog = catalog(UBUNTU_DOCKER).unwrap(); + let env = catalog + .get("tmachine.ubuntu2404-amd64.docker-rootful.deb") + .unwrap(); + assert_eq!(env.machine.arch, Arch::Amd64); + assert_eq!( + env.tmachine, + Some(Tmachine { + environment: "ubuntu-docker-rootful".to_string(), + installer: "deb".to_string(), + }) + ); + assert_eq!( + env.capabilities.get("compute_driver"), + Some(&serde_yml::Value::from("docker")) + ); + } + + #[test] + fn describes_capabilities_for_tests() { + let yaml = UBUNTU_DOCKER.replace( + "{ compute_driver: docker, gpu: false }", + "{ compute_driver: docker, gpu: false, persistent_workspace: true, rootless: true, replicas: 3 }", + ); + let catalog = catalog(&yaml).unwrap(); + let env = catalog + .get("tmachine.ubuntu2404-amd64.docker-rootful.deb") + .unwrap(); + assert_eq!( + env.capabilities_env(), + "arch=amd64,compute_driver=docker,persistent_workspace,replicas=3,rootless" + ); + } + + #[test] + fn rejects_unknown_boolean_capabilities() { + let yaml = UBUNTU_DOCKER.replace("gpu: false", "gpuu: true"); + let err = catalog(&yaml).unwrap_err(); + assert!( + err.to_string().contains("unknown capability \"gpuu\""), + "{err}" + ); + } + + #[test] + fn reads_the_registered_gateway_name() { + let yaml = UBUNTU_DOCKER.replace(" runtime:", " gateway: openshell\n runtime:"); + let catalog = catalog(&yaml).unwrap(); + let env = catalog + .get("tmachine.ubuntu2404-amd64.docker-rootful.deb") + .unwrap(); + assert_eq!(env.gateway.as_deref(), Some("openshell")); + } + + #[test] + fn rejects_id_that_does_not_match_fields() { + let yaml = UBUNTU_DOCKER.replace("docker-rootful.deb", "docker-rootless.deb"); + let err = catalog(&yaml).unwrap_err(); + assert!( + err.to_string().contains("does not match its fields"), + "{err}" + ); + } + + #[test] + fn rejects_tmachine_environment_without_tmachine_section() { + let yaml = UBUNTU_DOCKER.replace( + " tmachine: { environment: ubuntu-docker-rootful, installer: deb }\n", + "", + ); + let err = catalog(&yaml).unwrap_err(); + assert!(err.to_string().contains("no `tmachine` section"), "{err}"); + } + + #[test] + fn rejects_unknown_fields() { + let yaml = UBUNTU_DOCKER.replace("runtime:", "runtme:"); + assert!(catalog(&yaml).is_err()); + } + + #[test] + fn rejects_duplicate_ids() { + let mut catalog = catalog(UBUNTU_DOCKER).unwrap(); + let err = catalog.add_yaml(UBUNTU_DOCKER).unwrap_err(); + assert!(err.to_string().contains("more than once"), "{err}"); + } + + #[test] + fn reports_arch_mismatch_as_unsupported() { + let catalog = catalog(UBUNTU_DOCKER).unwrap(); + let env = catalog + .get("tmachine.ubuntu2404-amd64.docker-rootful.deb") + .unwrap(); + assert_eq!(env.unsupported_reason(Some(Arch::Amd64)), None); + assert!(env.unsupported_reason(Some(Arch::Arm64)).is_some()); + } + + #[test] + fn selectors_match_by_field() { + let catalog = catalog(UBUNTU_DOCKER).unwrap(); + let env = catalog + .get("tmachine.ubuntu2404-amd64.docker-rootful.deb") + .unwrap(); + let select = |yaml: &str| serde_yml::from_str::(yaml).unwrap(); + assert!(select("{}").matches(env)); + assert!(select("{ runtime: docker-rootful, arch: amd64 }").matches(env)); + assert!(!select("{ runtime: docker-rootful, arch: arm64 }").matches(env)); + assert!(!select("{ installer: rpm }").matches(env)); + assert_eq!(select("{}").to_string(), "all environments"); + assert_eq!( + select("{ arch: amd64, runtime: docker-rootful }").to_string(), + "arch=amd64, runtime=docker-rootful" + ); + assert!(serde_yml::from_str::("{ runtme: x }").is_err()); + } + + #[test] + fn repository_environments_are_valid() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../environments"); + let catalog = Catalog::load_dir(&dir).unwrap(); + assert!( + catalog + .get("tmachine.ubuntu2404-amd64.docker-rootful.deb") + .is_some() + ); + } +} diff --git a/tests/runner/src/execute.rs b/tests/runner/src/execute.rs new file mode 100644 index 0000000000..ba93ac9b0b --- /dev/null +++ b/tests/runner/src/execute.rs @@ -0,0 +1,484 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Running a job: provision the environment, run each family's bundle in it, +//! and bring the results back. +//! +//! Tests find the gateway the way the CLI does, so the runner points them at +//! the one the installer registered with `OPENSHELL_GATEWAY` and at the +//! installed CLI with `OPENSHELL_BIN`, and describes the environment in +//! `OPENSHELL_TEST_CAPABILITIES`. There is no target file. +//! +//! Adapted from the executor in Evan Lezar's NVIDIA/OpenShell#4629. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; +use std::process::Output; +use std::time::Duration; + +use anyhow::{Context, Result, ensure}; +use tmachine::Session; +use tmachine::config::Config; + +use crate::bundle::{Bundle, PROFILE, READY_BINARY}; +use crate::jobs::{FamilyJob, Job}; +use crate::matrix::Exclusion; +use crate::report::{Outcome, Status, annotate_junit, verify_junit}; + +const GUEST_ROOT: &str = "/var/tmp/openshell-tests"; +/// How long the gateway has to become healthy after the environment is up. +// Matches the installers' own wait: an HA k3s gateway booted from a cached +// disk can take minutes to report healthy. +const READY_TIMEOUT_SECS: u64 = 300; +/// How long one family's nextest run may take in total. Individual tests are +/// bounded by the nextest profile's slow-timeout; this catches a run that +/// stops making progress some other way. +const RUN_TIMEOUT: Duration = Duration::from_mins(45); +/// Slack beyond the readiness check's own timeout for SSH and process start. +const READY_SLACK: Duration = Duration::from_mins(1); + +pub struct Options<'a> { + pub tmachine: &'a Config, + pub bundles: &'a BTreeMap, + pub results: &'a Path, + pub keep: bool, +} + +fn count(family: &FamilyJob, kind: impl Fn(&Exclusion) -> bool) -> usize { + family + .suites + .iter() + .flat_map(|suite| &suite.excluded) + .filter(|test| kind(&test.reason)) + .count() +} + +/// Run one job. Every family gets an outcome, even when provisioning fails. +pub async fn run(job: &Job, options: &Options<'_>) -> Vec { + let env_id = &job.environment.id; + let outcome = |family: &FamilyJob, status| Outcome { + environment: env_id.clone(), + family: family.family.clone(), + status, + unsupported_tests: count(family, |reason| matches!(reason, Exclusion::Missing(_))), + waived_tests: count(family, |reason| matches!(reason, Exclusion::Waived(_))), + }; + + if let Some(reason) = &job.unsupported { + return job + .families + .iter() + .map(|family| outcome(family, Status::Unsupported(reason.clone()))) + .collect(); + } + if !job.families.iter().any(FamilyJob::has_tests) { + return job + .families + .iter() + .map(|family| outcome(family, Status::NothingToRun)) + .collect(); + } + + eprintln!("==> {env_id}: provisioning"); + let session = match provision(job, options.tmachine).await { + Ok(session) => session, + Err(error) => { + let message = format!("provision environment: {error:#}"); + eprintln!("==> {env_id}: {message}"); + return job + .families + .iter() + .map(|family| outcome(family, Status::Error(message.clone()))) + .collect(); + } + }; + + let mut outcomes = Vec::new(); + for family in &job.families { + if !family.has_tests() { + outcomes.push(outcome(family, Status::NothingToRun)); + continue; + } + let dir = options.results.join(env_id).join(&family.family); + eprintln!( + "==> {env_id}: running {} ({})", + family.family, + family.filterset() + ); + let status = match run_family(&session, job, family, options, &dir).await { + Ok(status) => status, + Err(error) => Status::Error(format!("{error:#}")), + }; + if status.is_failure() + && let Err(error) = session.collect_diagnostics(&dir.join("diagnostics")).await + { + eprintln!("==> {env_id}: collecting diagnostics failed: {error:#}"); + } + eprintln!("==> {env_id}: {} {status:?}", family.family); + outcomes.push(outcome(family, status)); + } + + if options.keep { + eprintln!( + "==> {env_id}: keeping the VM. Connect with `ssh -p {} tmachine@127.0.0.1` (password `tmachine`). Press Enter to tear it down.", + tmachine::ssh_port() + ); + let mut line = String::new(); + let _ = std::io::stdin().read_line(&mut line); + } + eprintln!("==> {env_id}: tearing down"); + session.down().await; + outcomes +} + +async fn provision(job: &Job, config: &Config) -> Result { + let params = job + .environment + .tmachine + .as_ref() + .context("environment has no tmachine section")?; + let environment = config + .environments + .iter() + .find(|candidate| candidate.name == params.environment) + .with_context(|| { + format!( + "tmachine environment {:?} is not defined", + params.environment + ) + })?; + let machine = config + .machines + .iter() + .find(|candidate| candidate.name == environment.machine) + .with_context(|| format!("tmachine machine {:?} is not defined", environment.machine))?; + let installer = config + .installers + .iter() + .find(|candidate| candidate.name == params.installer) + .with_context(|| format!("tmachine installer {:?} is not defined", params.installer))?; + Session::up(machine, environment, installer).await +} + +/// The environment variables that point the tests at the system under test: +/// the registered gateway (when the environment names one), the installed +/// CLI, and what the environment provides. +pub fn guest_env(job: &Job, openshell_bin: &str) -> Vec { + let mut vars = Vec::new(); + if let Some(gateway) = &job.environment.gateway { + vars.push(format!("OPENSHELL_GATEWAY={gateway}")); + } + vars.push(format!("OPENSHELL_BIN={openshell_bin}")); + vars.push(format!( + "OPENSHELL_TEST_CAPABILITIES={}", + job.environment.capabilities_env() + )); + vars +} + +/// Decide a family's status from nextest's exit status and its `JUnit` +/// report. The run passes only if nextest succeeded and the report has a +/// passing case for exactly the tests that were selected. +pub fn decide_status( + nextest_succeeded: bool, + junit: Option<&str>, + expected: &BTreeSet<(String, String)>, + log: &Path, +) -> Status { + let Some(xml) = junit else { + return Status::Error(format!( + "nextest produced no JUnit report (exit {}); see {}", + if nextest_succeeded { + "success" + } else { + "failure" + }, + log.display() + )); + }; + let verification = verify_junit(xml, expected); + if let Some(mismatch) = verification.mismatch() { + return Status::Error(format!( + "the JUnit report does not match the selected tests: {mismatch}; see {}", + log.display() + )); + } + let failures = u32::try_from(verification.failed.len()).unwrap_or(u32::MAX); + if failures > 0 { + Status::Failed { + tests: verification.tests, + failures, + } + } else if nextest_succeeded { + Status::Passed { + tests: verification.tests, + } + } else { + Status::Error(format!( + "nextest failed, but the JUnit report shows no failed test; see {}", + log.display() + )) + } +} + +async fn run_family( + session: &Session, + job: &Job, + family: &FamilyJob, + options: &Options<'_>, + dir: &Path, +) -> Result { + if dir.exists() { + std::fs::remove_dir_all(dir).with_context(|| format!("clear {}", dir.display()))?; + } + std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; + let bundle = options + .bundles + .get(&family.family) + .with_context(|| format!("no bundle was built for {}", family.family))?; + + let openshell_bin = checked( + "find the installed openshell CLI", + session.exec(&["sh", "-c", "command -v openshell"]).await?, + )?; + let openshell_bin = openshell_bin.trim(); + ensure!( + !openshell_bin.is_empty(), + "the installer did not put openshell on the guest's PATH" + ); + let env = guest_env(job, openshell_bin); + + let guest = format!("{GUEST_ROOT}/{}", family.family); + checked( + "prepare guest directory", + session + .exec(&[ + "sh", + "-c", + &format!("rm -rf {guest} && mkdir -p {guest}/extract"), + ]) + .await?, + )?; + session + .copy_in(&bundle.path, &format!("{guest}/bundle.tar")) + .await?; + checked( + "unpack bundle", + session + .exec(&["tar", "-C", &guest, "-xf", &format!("{guest}/bundle.tar")]) + .await?, + )?; + + // A booted guest restarts its gateway, so wait for it once here rather + // than in every test. Failing here is an environment error, not a test + // failure. + let ready_binary = format!("{guest}/{READY_BINARY}"); + let ready_timeout = READY_TIMEOUT_SECS.to_string(); + let mut ready = vec!["env"]; + ready.extend(env.iter().map(String::as_str)); + ready.extend([ready_binary.as_str(), ready_timeout.as_str()]); + checked( + "wait for the gateway", + session + .exec_timeout( + &ready, + Duration::from_secs(READY_TIMEOUT_SECS) + READY_SLACK, + ) + .await?, + )?; + + let filterset = family.filterset(); + let archive = format!("{guest}/tests.tar.zst"); + let extract = format!("{guest}/extract"); + let config = format!("{guest}/nextest.toml"); + let mut nextest = vec!["env"]; + nextest.extend(env.iter().map(String::as_str)); + nextest.extend([ + "cargo-nextest", + "nextest", + "run", + "--archive-file", + &archive, + "--workspace-remap", + &guest, + "--extract-to", + &extract, + "--extract-overwrite", + "--config-file", + &config, + "--profile", + PROFILE, + "--no-capture", + // Tests share one gateway and the guest has 4 GiB; run them serially + // whatever --no-capture implies. + "--test-threads", + "1", + "-E", + &filterset, + ]); + let output = session.exec_timeout(&nextest, RUN_TIMEOUT).await?; + let mut log = output.stdout.clone(); + log.extend_from_slice(&output.stderr); + let log_path = dir.join("nextest.log"); + std::fs::write(&log_path, &log)?; + + let junit = dir.join("junit.xml"); + let found = checked( + "find JUnit report", + session + .exec(&["find", &guest, "-name", "junit.xml"]) + .await?, + )?; + let xml = match found.lines().next() { + Some(path) => { + session.copy_out(path, &junit).await?; + let xml = annotate_junit(&std::fs::read_to_string(&junit)?, &family.test_ids()); + std::fs::write(&junit, &xml)?; + Some(xml) + } + None => None, + }; + + Ok(decide_status( + output.status.success(), + xml.as_deref(), + &family.expected_tests(), + &log_path, + )) +} + +fn checked(step: &str, output: Output) -> Result { + ensure!( + output.status.success(), + "{step}: exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + String::from_utf8(output.stdout).with_context(|| format!("{step}: output is not UTF-8")) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::environments::Catalog as Environments; + + fn expected(names: &[&str]) -> BTreeSet<(String, String)> { + names + .iter() + .map(|name| ("p::b".to_string(), (*name).to_string())) + .collect() + } + + fn report(cases: &[(&str, &str)]) -> String { + let body: String = cases + .iter() + .map(|(name, child)| { + format!( + "{child}" + ) + }) + .collect(); + format!("{body}") + } + + fn log() -> PathBuf { + PathBuf::from("nextest.log") + } + + #[test] + fn passes_only_when_every_selected_test_passed() { + let xml = report(&[("a", ""), ("b", "")]); + match decide_status(true, Some(&xml), &expected(&["a", "b"]), &log()) { + Status::Passed { tests } => assert_eq!(tests, 2), + other => panic!("{other:?}"), + } + } + + #[test] + fn a_missing_test_fails_the_lane_even_though_nextest_succeeded() { + let xml = report(&[("a", "")]); + match decide_status(true, Some(&xml), &expected(&["a", "b"]), &log()) { + Status::Error(message) => { + assert!(message.contains("p::b b"), "{message}"); + assert!(message.contains("does not match"), "{message}"); + } + other => panic!("{other:?}"), + } + } + + #[test] + fn an_unselected_test_in_the_report_fails_the_lane() { + let xml = report(&[("a", ""), ("stray", "")]); + assert!(matches!( + decide_status(true, Some(&xml), &expected(&["a"]), &log()), + Status::Error(_) + )); + } + + #[test] + fn failures_are_reported_as_failed() { + let xml = report(&[("a", ""), ("b", "")]); + match decide_status(false, Some(&xml), &expected(&["a", "b"]), &log()) { + Status::Failed { tests, failures } => assert_eq!((tests, failures), (2, 1)), + other => panic!("{other:?}"), + } + } + + #[test] + fn a_missing_report_is_an_error() { + assert!(matches!( + decide_status(true, None, &expected(&["a"]), &log()), + Status::Error(_) + )); + assert!(matches!( + decide_status(false, None, &expected(&["a"]), &log()), + Status::Error(_) + )); + } + + #[test] + fn nextest_failing_without_a_failed_case_is_an_error() { + let xml = report(&[("a", "")]); + assert!(matches!( + decide_status(false, Some(&xml), &expected(&["a"]), &log()), + Status::Error(_) + )); + } + + #[test] + fn points_tests_at_the_gateway_the_cli_and_the_environment() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("e.yaml"), + "environments:\n - id: tmachine.ubuntu2404-amd64.docker-rootful.deb\n backend: tmachine\n machine: { os: ubuntu, version: \"24.04\", arch: amd64 }\n runtime: docker-rootful\n installer: deb\n tmachine: { environment: ubuntu-docker-rootful, installer: deb }\n gateway: openshell\n capabilities: { compute_driver: docker, persistent_workspace: true, gpu: false }\n", + ) + .unwrap(); + let environments = Environments::load_dir(dir.path()).unwrap(); + let job = Job { + environment: environments + .get("tmachine.ubuntu2404-amd64.docker-rootful.deb") + .unwrap() + .clone(), + unsupported: None, + families: Vec::new(), + }; + assert_eq!( + guest_env(&job, "/usr/bin/openshell"), + [ + "OPENSHELL_GATEWAY=openshell", + "OPENSHELL_BIN=/usr/bin/openshell", + "OPENSHELL_TEST_CAPABILITIES=arch=amd64,compute_driver=docker,persistent_workspace", + ] + ); + + let mut job = job; + job.environment.gateway = None; + assert!( + !guest_env(&job, "/usr/bin/openshell") + .iter() + .any(|var| var.starts_with("OPENSHELL_GATEWAY=")) + ); + } +} diff --git a/tests/runner/src/github.rs b/tests/runner/src/github.rs new file mode 100644 index 0000000000..aaa4fa3829 --- /dev/null +++ b/tests/runner/src/github.rs @@ -0,0 +1,389 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The CI matrix, generated from plans. +//! +//! A workflow runs one job per entry of `openshell-test test --plan +//! --emit-github-matrix`, so which environments run is decided by the plan and +//! by what each environment provides, not by a list copied into a workflow. +//! +//! `tests/expected-matrix.json` checks that list in: for every plan, the +//! generated job entries and, for each environment, the tests that run there. +//! Dropping a capability from an environment, adding a test that needs one, or +//! changing a plan changes the generated file, so the change shows up as a diff +//! in review and `openshell-test check-matrix` fails until the file is +//! regenerated with `--write`. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; + +use crate::catalog::Catalog; +use crate::environments::{Arch, Catalog as Environments}; +use crate::jobs::{self, Narrowing}; +use crate::plan::Plan; +use crate::waivers::Waivers; + +/// The checked-in expected matrix, relative to the repository root. +pub const EXPECTED_MATRIX: &str = "tests/expected-matrix.json"; +/// Where plans live, relative to the repository root. +pub const PLANS_DIR: &str = "tests/plans"; + +/// One CI job: a plan run in one environment. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct GithubJob { + pub plan: String, + /// The environment ID, for `openshell-test test --env`. + pub environment: String, + /// The GitHub runner label that can boot the environment. + pub runner: String, + /// How many tests the job must run, as a quick check in the workflow. + pub expected_tests: usize, +} + +/// The strategy matrix a workflow reads with `fromJSON`. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] +pub struct GithubMatrix { + pub include: Vec, +} + +/// What the checked-in file records for one plan. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] +pub struct ExpectedPlan { + pub github_matrix: GithubMatrix, + /// For each environment, the tests that run there as + /// `family/suite/test`. + pub tests: BTreeMap>, +} + +/// The checked-in file: the expected matrix of every plan, by plan name. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] +pub struct Expected { + pub plans: BTreeMap, +} + +/// The GitHub-hosted runner that can boot guests of `arch`: tmachine boots +/// guests of the host's architecture only. +pub const fn runner_label(arch: Arch) -> &'static str { + match arch { + Arch::Amd64 => "ubuntu-24.04", + Arch::Arm64 => "ubuntu-24.04-arm", + } +} + +/// Expand a plan the way a lane would and describe what it runs, ignoring +/// which host generates the matrix. +/// +/// # Errors +/// +/// Fails if the plan names an unknown family, suite or environment, or if the +/// plan selects an environment where no test is left to run: a CI lane that +/// would pass without testing anything. +pub fn expected_plan( + plan_name: &str, + plan: &Plan, + catalog: &Catalog, + environments: &Environments, + waivers: &Waivers, +) -> Result { + let jobs = jobs::expand( + plan, + catalog, + environments, + waivers, + None, + &Narrowing::default(), + )?; + if jobs.is_empty() { + bail!("plan {plan_name} selects no tests"); + } + + let mut expected = ExpectedPlan::default(); + for job in &jobs { + let id = &job.environment.id; + let mut tests = Vec::new(); + for family in &job.families { + if !family.has_tests() { + bail!( + "plan {plan_name} selects {id} / {}, but every selected test is excluded there (missing capabilities or waivers)", + family.family + ); + } + for suite in &family.suites { + tests.extend( + suite + .run + .iter() + .map(|name| format!("{}/{}/{name}", family.family, suite.suite.suite)), + ); + } + } + tests.sort(); + expected.github_matrix.include.push(GithubJob { + plan: plan_name.to_string(), + environment: id.clone(), + runner: runner_label(job.environment.machine.arch).to_string(), + expected_tests: tests.len(), + }); + expected.tests.insert(id.clone(), tests); + } + expected + .github_matrix + .include + .sort_by(|a, b| a.environment.cmp(&b.environment)); + Ok(expected) +} + +/// The expected matrix of every plan in `plans_dir`. +/// +/// # Errors +/// +/// Fails if a plan cannot be read or does not expand to a runnable job in +/// every environment it names. +pub fn expected( + plans_dir: &Path, + catalog: &Catalog, + environments: &Environments, + waivers: &Waivers, +) -> Result { + let mut paths: Vec = std::fs::read_dir(plans_dir) + .with_context(|| format!("read {}", plans_dir.display()))? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>() + .with_context(|| format!("read {}", plans_dir.display()))?; + paths.retain(|path| path.extension().is_some_and(|ext| ext == "yaml")); + paths.sort(); + + let mut plans = BTreeMap::new(); + for path in paths { + let name = path + .file_stem() + .and_then(|stem| stem.to_str()) + .with_context(|| format!("plan file name {} is not UTF-8", path.display()))? + .to_string(); + let plan = Plan::load(&path)?; + let entry = expected_plan(&name, &plan, catalog, environments, waivers) + .with_context(|| format!("expand plan {name}"))?; + plans.insert(name, entry); + } + Ok(Expected { plans }) +} + +/// The file's contents for an expected matrix: pretty JSON ending in a newline. +/// +/// # Errors +/// +/// Fails only if the matrix cannot be serialized. +pub fn render(expected: &Expected) -> Result { + let mut text = serde_json::to_string_pretty(expected)?; + text.push('\n'); + Ok(text) +} + +/// How the checked-in file differs from the generated one, as `-`/`+` lines +/// (checked in / generated). Empty when they match. +pub fn diff(checked_in: &str, generated: &str) -> Vec { + use std::collections::BTreeSet; + + if checked_in == generated { + return Vec::new(); + } + let old: BTreeSet<&str> = checked_in.lines().collect(); + let new: BTreeSet<&str> = generated.lines().collect(); + let mut lines: Vec = Vec::new(); + lines.extend( + checked_in + .lines() + .filter(|line| !new.contains(line)) + .map(|line| format!("- {line}")), + ); + lines.extend( + generated + .lines() + .filter(|line| !old.contains(line)) + .map(|line| format!("+ {line}")), + ); + if lines.is_empty() { + // The same lines in a different order, or whitespace only. + lines.push("the files differ in ordering or whitespace".to_string()); + } + lines +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + use crate::catalog::{Family, Suite, Test}; + + const AMD64: &str = "tmachine.ubuntu2404-amd64.docker-rootful.deb"; + const ARM64: &str = "tmachine.ubuntu2404-arm64.docker-rootful.deb"; + + fn environments(capabilities: &str) -> Environments { + let mut yaml = String::from("environments:\n"); + for arch in ["amd64", "arm64"] { + yaml.push_str(&format!( + " - id: tmachine.ubuntu2404-{arch}.docker-rootful.deb\n backend: tmachine\n machine: {{ os: ubuntu, version: \"24.04\", arch: {arch} }}\n runtime: docker-rootful\n installer: deb\n tmachine: {{ environment: ubuntu-docker-rootful, installer: deb }}\n capabilities: {capabilities}\n" + )); + } + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("docker.yaml"), yaml).unwrap(); + Environments::load_dir(dir.path()).unwrap() + } + + fn catalog() -> Catalog { + let test = |name: &str, requires: &[&str]| Test { + id: None, + name: name.to_string(), + description: "d".to_string(), + requires: requires.iter().map(ToString::to_string).collect(), + }; + let suite = Suite { + suite: "lifecycle".to_string(), + binary: "lifecycle".to_string(), + package: "pkg".to_string(), + tests: vec![ + test("plain", &[]), + test("keeps_workspace", &["persistent_workspace"]), + ], + }; + Catalog { + families: BTreeMap::from([( + "sandbox".to_string(), + Family { + name: "sandbox".to_string(), + workspace: PathBuf::new(), + suites: BTreeMap::from([("lifecycle".to_string(), suite)]), + }, + )]), + } + } + + fn plan() -> Plan { + Plan::parse(&format!( + "matrix:\n - type: sandbox\n environments: [{AMD64}, {ARM64}]\n" + )) + .unwrap() + } + + fn generate(capabilities: &str) -> ExpectedPlan { + expected_plan( + "p", + &plan(), + &catalog(), + &environments(capabilities), + &Waivers::default(), + ) + .unwrap() + } + + #[test] + fn generates_a_job_per_environment_with_its_runner_and_test_count() { + let expected = generate("{ persistent_workspace: true }"); + assert_eq!( + expected.github_matrix.include, + [ + GithubJob { + plan: "p".to_string(), + environment: AMD64.to_string(), + runner: "ubuntu-24.04".to_string(), + expected_tests: 2, + }, + GithubJob { + plan: "p".to_string(), + environment: ARM64.to_string(), + runner: "ubuntu-24.04-arm".to_string(), + expected_tests: 2, + }, + ] + ); + assert_eq!( + expected.tests[AMD64], + [ + "sandbox/lifecycle/keeps_workspace", + "sandbox/lifecycle/plain" + ] + ); + } + + #[test] + fn dropping_a_capability_changes_the_generated_matrix() { + let with = render(&Expected { + plans: BTreeMap::from([("p".to_string(), generate("{ persistent_workspace: true }"))]), + }) + .unwrap(); + let without = render(&Expected { + plans: BTreeMap::from([("p".to_string(), generate("{ persistent_workspace: false }"))]), + }) + .unwrap(); + let changes = diff(&with, &without); + assert!( + changes + .iter() + .any(|line| line.starts_with("- ") + && line.contains("sandbox/lifecycle/keeps_workspace")), + "{changes:#?}" + ); + assert!( + changes + .iter() + .any(|line| line.contains("\"expected_tests\": 1")), + "{changes:#?}" + ); + assert!(diff(&with, &with).is_empty()); + } + + #[test] + fn an_environment_with_nothing_left_to_run_is_an_error() { + let mut catalog = catalog(); + catalog + .families + .get_mut("sandbox") + .unwrap() + .suites + .get_mut("lifecycle") + .unwrap() + .tests + .remove(0); + let err = expected_plan( + "p", + &plan(), + &catalog, + &environments("{ persistent_workspace: false }"), + &Waivers::default(), + ) + .unwrap_err(); + assert!( + err.to_string().contains("every selected test is excluded"), + "{err}" + ); + } + + #[test] + fn the_matrix_does_not_depend_on_the_host() { + // `expand` is given no host, so no environment is dropped as + // unsupported; both architectures appear. + assert_eq!(generate("{}").github_matrix.include.len(), 2); + } + + #[test] + fn the_checked_in_matrix_is_up_to_date() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let catalog = Catalog::load(&root).unwrap(); + let environments = Environments::load_dir(&root.join("tests/environments")).unwrap(); + let waivers = + Waivers::load(&root.join("tests/waivers.yaml"), &catalog, &environments).unwrap(); + let generated = + render(&expected(&root.join(PLANS_DIR), &catalog, &environments, &waivers).unwrap()) + .unwrap(); + let checked_in = std::fs::read_to_string(root.join(EXPECTED_MATRIX)).unwrap(); + assert!( + diff(&checked_in, &generated).is_empty(), + "{EXPECTED_MATRIX} is out of date; run `cargo run --manifest-path tests/runner/Cargo.toml -- check-matrix --write`:\n{}", + diff(&checked_in, &generated).join("\n") + ); + } +} diff --git a/tests/runner/src/jobs.rs b/tests/runner/src/jobs.rs new file mode 100644 index 0000000000..717579fcf8 --- /dev/null +++ b/tests/runner/src/jobs.rs @@ -0,0 +1,536 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Expanding a plan into jobs. +//! +//! Jobs are grouped by environment so each environment is provisioned once +//! and runs every family and suite the plan selects for it. Tests whose +//! requirements the environment lacks, or that a waiver skips there, are +//! excluded by exact name and reported with the reason; libtest has no +//! runtime skip, so this is the only place they can be skipped honestly. + +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::{Result, bail}; + +use crate::catalog::{Catalog, Suite}; +use crate::environments::{Arch, Catalog as Environments, Environment}; +use crate::matrix::{Exclusion, exclusion}; +use crate::plan::Plan; +use crate::waivers::Waivers; + +/// Everything the plan runs in one environment. +#[derive(Debug)] +pub struct Job { + pub environment: Environment, + /// Why the environment can't be provisioned on this host, if it can't. + pub unsupported: Option, + pub families: Vec, +} + +/// The suites of one family selected for an environment. +#[derive(Debug)] +pub struct FamilyJob { + pub family: String, + pub suites: Vec, +} + +#[derive(Debug)] +pub struct SuiteJob { + pub suite: Suite, + /// Names of the tests to run. + pub run: Vec, + /// Tests excluded in this environment, with the reason. + pub excluded: Vec, +} + +#[derive(Debug)] +pub struct ExcludedTest { + pub name: String, + pub reason: Exclusion, +} + +/// Filters applied on top of the plan, from the command line. +#[derive(Debug, Default)] +pub struct Narrowing { + pub environments: Vec, + pub suites: Vec, +} + +/// Validate the plan against the catalogs and expand it into jobs. +pub fn expand( + plan: &Plan, + catalog: &Catalog, + environments: &Environments, + waivers: &Waivers, + host: Option, + narrowing: &Narrowing, +) -> Result> { + // environment ID -> family -> suite names, in plan order where it matters. + let mut selected: BTreeMap>> = BTreeMap::new(); + + for entry in &plan.matrix { + let family = catalog.family(&entry.family)?; + let suites: Vec = match &entry.suites { + Some(suites) => { + for suite in suites { + family.suite(suite)?; + } + suites.clone() + } + None => family.suites.keys().cloned().collect(), + }; + for id in &entry.environments { + if environments.get(id).is_none() { + bail!( + "unknown environment {id:?}; known environments: {}", + environments + .iter() + .map(|env| env.id.as_str()) + .collect::>() + .join(", ") + ); + } + let family_suites = selected + .entry(id.clone()) + .or_default() + .entry(entry.family.clone()) + .or_default(); + for suite in &suites { + if !family_suites.contains(suite) { + family_suites.push(suite.clone()); + } + } + } + } + + for id in &narrowing.environments { + if !selected.contains_key(id) { + bail!("--env {id:?} is not in the plan"); + } + } + for suite in &narrowing.suites { + let in_plan = selected + .values() + .flat_map(BTreeMap::values) + .any(|suites| suites.contains(suite)); + if !in_plan { + bail!("--suite {suite:?} is not in the plan"); + } + } + + let mut jobs = Vec::new(); + for (id, families) in selected { + if !narrowing.environments.is_empty() && !narrowing.environments.contains(&id) { + continue; + } + let environment = environments.get(&id).expect("validated above").clone(); + let mut family_jobs = Vec::new(); + for (family_name, suites) in families { + let family = catalog.family(&family_name)?; + let suites: Vec = suites + .iter() + .filter(|suite| narrowing.suites.is_empty() || narrowing.suites.contains(suite)) + .map(|suite| { + suite_job( + family.suite(suite).expect("validated above"), + &environment, + waivers, + ) + }) + .collect(); + if !suites.is_empty() { + family_jobs.push(FamilyJob { + family: family_name, + suites, + }); + } + } + if family_jobs.is_empty() { + continue; + } + jobs.push(Job { + unsupported: environment.unsupported_reason(host), + environment, + families: family_jobs, + }); + } + Ok(jobs) +} + +/// What strict mode (CI) refuses to run: nothing selected at all, an +/// environment this host can't provision, and an environment or family where +/// no test is left to run. Locally these are reported and skipped, because a +/// laptop can't boot every environment; in CI each one means a lane that would +/// pass without testing what it claims to. +pub fn strict_problems(jobs: &[Job]) -> Vec { + if jobs.is_empty() { + return vec!["the plan selects no tests".to_string()]; + } + let mut problems = Vec::new(); + for job in jobs { + let id = &job.environment.id; + if let Some(reason) = &job.unsupported { + problems.push(format!("{id} is unsupported on this host: {reason}")); + continue; + } + for family in &job.families { + if !family.has_tests() { + problems.push(format!( + "{id} / {}: every selected test is excluded (missing capabilities or waivers), so nothing would run", + family.family + )); + } + } + } + problems +} + +fn suite_job(suite: &Suite, environment: &Environment, waivers: &Waivers) -> SuiteJob { + let mut run = Vec::new(); + let mut excluded = Vec::new(); + for test in &suite.tests { + match exclusion(test, environment, waivers) { + None => run.push(test.name.clone()), + Some(reason) => excluded.push(ExcludedTest { + name: test.name.clone(), + reason, + }), + } + } + SuiteJob { + suite: suite.clone(), + run, + excluded, + } +} + +/// The `classname` nextest gives a suite's tests in `JUnit` output, which is +/// the suite's nextest binary ID. +fn junit_classname(suite: &Suite) -> String { + format!("{}::{}", suite.package, suite.binary) +} + +impl FamilyJob { + /// Whether any test will run. + pub fn has_tests(&self) -> bool { + self.suites.iter().any(|suite| !suite.run.is_empty()) + } + + /// Stable test IDs keyed by nextest `JUnit` `(classname, name)`, for the + /// tests that have one. + pub fn test_ids(&self) -> BTreeMap<(String, String), String> { + self.suites + .iter() + .flat_map(|job| { + let classname = junit_classname(&job.suite); + job.suite.tests.iter().filter_map(move |test| { + test.id + .clone() + .map(|id| ((classname.clone(), test.name.clone()), id)) + }) + }) + .collect() + } + + /// The tests the report must contain, as nextest `JUnit` + /// `(classname, name)` pairs: every test selected to run. A missing one + /// fails the job. + pub fn expected_tests(&self) -> BTreeSet<(String, String)> { + self.suites + .iter() + .flat_map(|job| { + let classname = junit_classname(&job.suite); + job.run + .iter() + .map(move |name| (classname.clone(), name.clone())) + }) + .collect() + } + + /// The nextest filterset selecting exactly the tests to run. + pub fn filterset(&self) -> String { + self.suites + .iter() + .filter(|suite| !suite.run.is_empty()) + .map(|suite| { + // Name the package too: other packages in the family can have test + // binaries with the same name. + let binary = format!( + "binary_id(={}::{})", + suite.suite.package, suite.suite.binary + ); + if suite.excluded.is_empty() { + binary + } else { + let tests = suite + .run + .iter() + .map(|id| format!("test(={id})")) + .collect::>() + .join(" | "); + format!("({binary} & ({tests}))") + } + }) + .collect::>() + .join(" | ") + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::catalog::{Family, Test}; + + fn catalog() -> Catalog { + let suite = |name: &str, tests: &[(&str, &[&str])]| Suite { + suite: name.to_string(), + binary: name.replace('-', "_"), + package: format!("pkg-{name}"), + tests: tests + .iter() + .enumerate() + .map(|(index, (name, requires))| Test { + id: Some(format!("t-{index:08}")), + name: (*name).to_string(), + description: "d".to_string(), + requires: requires.iter().map(ToString::to_string).collect(), + }) + .collect(), + }; + let family = Family { + name: "conformance".to_string(), + workspace: PathBuf::new(), + suites: [ + suite( + "sandbox-lifecycle", + &[("long_running", &[]), ("expose", &["service_exposure"])], + ), + suite("providers", &[("create_provider", &[])]), + ] + .into_iter() + .map(|suite| (suite.suite.clone(), suite)) + .collect(), + }; + Catalog { + families: [("conformance".to_string(), family)].into(), + } + } + + fn environments() -> Environments { + use std::fmt::Write as _; + + let mut yaml = String::from("environments:\n"); + for arch in ["amd64", "arm64"] { + let _ = write!( + yaml, + " - id: tmachine.ubuntu2404-{arch}.docker-rootful.deb\n backend: tmachine\n machine: {{ os: ubuntu, version: \"24.04\", arch: {arch} }}\n runtime: docker-rootful\n installer: deb\n tmachine: {{ environment: ubuntu-docker-rootful, installer: deb }}\n capabilities: {{ compute_driver: docker }}\n" + ); + } + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("docker.yaml"), yaml).unwrap(); + Environments::load_dir(dir.path()).unwrap() + } + + const AMD64: &str = "tmachine.ubuntu2404-amd64.docker-rootful.deb"; + const ARM64: &str = "tmachine.ubuntu2404-arm64.docker-rootful.deb"; + + fn expand_plan(yaml: &str, narrowing: &Narrowing) -> Result> { + expand( + &Plan::parse(yaml).unwrap(), + &catalog(), + &environments(), + &Waivers::default(), + Some(Arch::Amd64), + narrowing, + ) + } + + #[test] + fn excludes_tests_with_unmet_requirements() { + let jobs = expand_plan( + &format!("matrix:\n - type: conformance\n suites: [sandbox-lifecycle]\n environments: [{AMD64}]\n"), + &Narrowing::default(), + ) + .unwrap(); + assert_eq!(jobs.len(), 1); + let suite = &jobs[0].families[0].suites[0]; + assert_eq!(suite.run, ["long_running"]); + assert_eq!(suite.excluded[0].name, "expose"); + assert_eq!( + suite.excluded[0].reason, + Exclusion::Missing(vec!["service_exposure".to_string()]) + ); + assert_eq!( + jobs[0].families[0].filterset(), + "(binary_id(=pkg-sandbox-lifecycle::sandbox_lifecycle) & (test(=long_running)))" + ); + } + + #[test] + fn omitted_suites_select_the_whole_family() { + let jobs = expand_plan( + &format!("matrix:\n - type: conformance\n environments: [{AMD64}]\n"), + &Narrowing::default(), + ) + .unwrap(); + let suites: Vec<_> = jobs[0].families[0] + .suites + .iter() + .map(|suite| suite.suite.suite.as_str()) + .collect(); + assert_eq!(suites, ["providers", "sandbox-lifecycle"]); + assert_eq!( + jobs[0].families[0].filterset(), + "binary_id(=pkg-providers::providers) | (binary_id(=pkg-sandbox-lifecycle::sandbox_lifecycle) & (test(=long_running)))" + ); + } + + #[test] + fn groups_entries_by_environment() { + let jobs = expand_plan( + &format!( + "matrix:\n - type: conformance\n suites: [providers]\n environments: [{AMD64}, {ARM64}]\n - type: conformance\n suites: [providers, sandbox-lifecycle]\n environments: [{AMD64}]\n" + ), + &Narrowing::default(), + ) + .unwrap(); + assert_eq!(jobs.len(), 2); + let amd64 = jobs.iter().find(|job| job.environment.id == AMD64).unwrap(); + assert_eq!(amd64.families.len(), 1); + assert_eq!(amd64.families[0].suites.len(), 2); + assert!(amd64.unsupported.is_none()); + let arm64 = jobs.iter().find(|job| job.environment.id == ARM64).unwrap(); + assert!(arm64.unsupported.is_some()); + } + + #[test] + fn narrows_to_environment_and_suite() { + let narrowing = Narrowing { + environments: vec![AMD64.to_string()], + suites: vec!["providers".to_string()], + }; + let jobs = expand_plan( + &format!("matrix:\n - type: conformance\n environments: [{AMD64}, {ARM64}]\n"), + &narrowing, + ) + .unwrap(); + assert_eq!(jobs.len(), 1); + assert_eq!(jobs[0].families[0].suites.len(), 1); + assert_eq!( + jobs[0].families[0].filterset(), + "binary_id(=pkg-providers::providers)" + ); + } + + #[test] + fn rejects_unknown_names() { + let unknown_env = expand_plan( + "matrix:\n - type: conformance\n environments: [nope]\n", + &Narrowing::default(), + ) + .unwrap_err(); + assert!( + unknown_env.to_string().contains("unknown environment"), + "{unknown_env}" + ); + + let unknown_suite = expand_plan( + &format!("matrix:\n - type: conformance\n suites: [sandbox-lifecyle]\n environments: [{AMD64}]\n"), + &Narrowing::default(), + ) + .unwrap_err(); + assert!( + unknown_suite + .to_string() + .contains("unknown conformance suite"), + "{unknown_suite}" + ); + + let unknown_family = expand_plan( + &format!("matrix:\n - type: features\n environments: [{AMD64}]\n"), + &Narrowing::default(), + ) + .unwrap_err(); + assert!( + unknown_family.to_string().contains("unknown test family"), + "{unknown_family}" + ); + + let narrowing = Narrowing { + environments: vec![ARM64.to_string()], + suites: vec![], + }; + let not_in_plan = expand_plan( + &format!("matrix:\n - type: conformance\n environments: [{AMD64}]\n"), + &narrowing, + ) + .unwrap_err(); + assert!( + not_in_plan.to_string().contains("not in the plan"), + "{not_in_plan}" + ); + } + + #[test] + fn expected_tests_are_the_ones_selected_to_run() { + let jobs = expand_plan( + &format!("matrix:\n - type: conformance\n suites: [sandbox-lifecycle]\n environments: [{AMD64}]\n"), + &Narrowing::default(), + ) + .unwrap(); + let family = &jobs[0].families[0]; + assert_eq!( + family.expected_tests().into_iter().collect::>(), + [( + "pkg-sandbox-lifecycle::sandbox_lifecycle".to_string(), + "long_running".to_string() + )] + ); + } + + #[test] + fn strict_mode_rejects_unsupported_environments_and_empty_selections() { + let plan = format!( + "matrix:\n - type: conformance\n suites: [sandbox-lifecycle]\n environments: [{AMD64}, {ARM64}]\n" + ); + let jobs = expand_plan(&plan, &Narrowing::default()).unwrap(); + let problems = strict_problems(&jobs); + assert_eq!(problems.len(), 1, "{problems:?}"); + assert!(problems[0].contains(ARM64), "{problems:?}"); + assert!( + problems[0].contains("unsupported on this host"), + "{problems:?}" + ); + + assert_eq!( + strict_problems(&[]), + ["the plan selects no tests".to_string()] + ); + + // Every selected test needs a capability the environment lacks. + let exposed_only = Plan::parse(&format!( + "matrix:\n - type: conformance\n suites: [exposed]\n environments: [{AMD64}]\n" + )) + .unwrap(); + let mut catalog = catalog(); + let family = catalog.families.get_mut("conformance").unwrap(); + let mut suite = family.suites["sandbox-lifecycle"].clone(); + suite.suite = "exposed".to_string(); + suite.tests.remove(0); + family.suites.insert("exposed".to_string(), suite); + let jobs = expand( + &exposed_only, + &catalog, + &environments(), + &Waivers::default(), + Some(Arch::Amd64), + &Narrowing::default(), + ) + .unwrap(); + let problems = strict_problems(&jobs); + assert!(problems[0].contains("nothing would run"), "{problems:?}"); + } +} diff --git a/tests/runner/src/lib.rs b/tests/runner/src/lib.rs new file mode 100644 index 0000000000..2fae3032c2 --- /dev/null +++ b/tests/runner/src/lib.rs @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Runs `OpenShell` test plans: expands a plan into jobs, provisions each +//! environment through its backend, runs the selected suites inside it, and +//! reports the results. + +pub mod bundle; +pub mod catalog; +pub mod environments; +pub mod execute; +pub mod github; +pub mod jobs; +pub mod matrix; +pub mod plan; +pub mod report; +pub mod waivers; diff --git a/tests/runner/src/main.rs b/tests/runner/src/main.rs new file mode 100644 index 0000000000..be6e5018e2 --- /dev/null +++ b/tests/runner/src/main.rs @@ -0,0 +1,418 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! `openshell-test`: run `OpenShell` test plans. +//! +//! Run it through `nix run .#test-runner -- `, which provides the +//! guest cross-compilation toolchain, QEMU, Ansible, and the tmachine +//! configuration. +//! +//! Adapted from the runner in Evan Lezar's NVIDIA/OpenShell#4629. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use anyhow::{Context, Result, bail}; +use clap::{Parser, Subcommand}; +use openshell_test_runner::bundle::{self, Bundle}; +use openshell_test_runner::catalog::Catalog; +use openshell_test_runner::environments::{Arch, Catalog as Environments}; +use openshell_test_runner::execute::{self, Options}; +use openshell_test_runner::github; +use openshell_test_runner::jobs::{self, Job, Narrowing}; +use openshell_test_runner::matrix::{self, Exclusion, Matrix}; +use openshell_test_runner::plan::Plan; +use openshell_test_runner::report; +use openshell_test_runner::waivers::Waivers; + +#[derive(Parser)] +#[command(name = "openshell-test", about = "Run OpenShell test plans")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Run a test plan. + Test { + /// The plan to run, e.g. tests/plans/testkit.yaml. + #[arg(long)] + plan: PathBuf, + /// Print the resolved jobs without building or provisioning anything. + #[arg(long)] + dry_run: bool, + /// Print the GitHub Actions matrix for the plan as JSON, one job per + /// environment, and exit. The matrix does not depend on this host. + #[arg(long)] + emit_github_matrix: bool, + /// CI mode: fail instead of skipping an environment this host can't + /// provision, an environment where no test is left to run, or a plan + /// that selects nothing. + #[arg(long, env = "OPENSHELL_TEST_STRICT")] + strict: bool, + /// Keep each environment running after its tests until Enter is pressed. + #[arg(long)] + keep: bool, + /// Only run these environments from the plan. + #[arg(long = "env", value_name = "ID")] + environments: Vec, + /// Only run these suites from the plan. + #[arg(long = "suite", value_name = "NAME")] + suites: Vec, + /// Results directory [default: artifacts/test-results/]. + #[arg(long)] + results: Option, + /// Run waived tests too, e.g. to check whether a fix landed. + #[arg(long)] + ignore_waivers: bool, + }, + /// Print every test against every environment: where it runs, or why not. + Matrix { + /// Also exclude environments this machine can't provision. + #[arg(long)] + host: bool, + /// Ignore tests/waivers.yaml. + #[arg(long)] + ignore_waivers: bool, + /// Print JSON instead of text. + #[arg(long)] + json: bool, + }, + /// Check tests/expected-matrix.json against what the plans generate, so a + /// change to what runs where shows up as a diff. + CheckMatrix { + /// Rewrite the file instead of failing when it is out of date. + #[arg(long)] + write: bool, + }, + /// Print the test catalog as JSON. + Catalog, +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> ExitCode { + // A SIGTERM from CI cancellation must not leave QEMU holding the SSH port. + tmachine::cleanup_on_signals(); + match run(Cli::parse()).await { + Ok(code) => code, + Err(error) => { + eprintln!("error: {error:#}"); + ExitCode::FAILURE + } + } +} + +async fn run(cli: Cli) -> Result { + let root = repo_root()?; + let catalog = Catalog::load(&root)?; + let environments_catalog = Environments::load_dir(&root.join("tests/environments"))?; + let load_waivers = |ignore: bool| -> Result { + if ignore { + return Ok(Waivers::default()); + } + Waivers::load( + &root.join("tests/waivers.yaml"), + &catalog, + &environments_catalog, + ) + }; + + match cli.command { + Command::Catalog => { + println!("{}", serde_json::to_string_pretty(&catalog)?); + Ok(ExitCode::SUCCESS) + } + Command::Matrix { + host, + ignore_waivers, + json, + } => { + let waivers = load_waivers(ignore_waivers)?; + let matrix = Matrix::build( + &catalog, + &environments_catalog, + &waivers, + host.then(Arch::host), + ); + if json { + println!("{}", serde_json::to_string_pretty(&matrix)?); + } else { + print!("{}", matrix.render()); + } + let problems = matrix::problems(&catalog, &environments_catalog); + for problem in &problems { + eprintln!("error: {problem}"); + } + Ok(if problems.is_empty() { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + }) + } + Command::CheckMatrix { write } => { + let waivers = load_waivers(false)?; + let expected = github::expected( + &root.join(github::PLANS_DIR), + &catalog, + &environments_catalog, + &waivers, + )?; + let generated = github::render(&expected)?; + let path = root.join(github::EXPECTED_MATRIX); + let checked_in = std::fs::read_to_string(&path).unwrap_or_default(); + let changes = github::diff(&checked_in, &generated); + if changes.is_empty() { + eprintln!("{} is up to date", github::EXPECTED_MATRIX); + return Ok(ExitCode::SUCCESS); + } + if write { + std::fs::write(&path, generated) + .with_context(|| format!("write {}", path.display()))?; + eprintln!("updated {}", github::EXPECTED_MATRIX); + return Ok(ExitCode::SUCCESS); + } + eprintln!( + "error: {} is out of date. A plan, an environment, a waiver or a test changed what runs where. Review the change, then run `openshell-test check-matrix --write` and commit it:", + github::EXPECTED_MATRIX + ); + for line in changes { + eprintln!("{line}"); + } + Ok(ExitCode::FAILURE) + } + Command::Test { + plan, + dry_run, + emit_github_matrix, + strict, + keep, + environments, + suites, + results, + ignore_waivers, + } => { + let problems = matrix::problems(&catalog, &environments_catalog); + if !problems.is_empty() { + bail!( + "the test matrix is inconsistent; see `matrix`:\n {}", + problems.join("\n ") + ); + } + let waivers = load_waivers(ignore_waivers)?; + let plan_path = plan; + let plan = Plan::load(&plan_path)?; + + if emit_github_matrix { + let name = plan_path + .file_stem() + .and_then(|stem| stem.to_str()) + .context("plan path has no file name")?; + let expected = + github::expected_plan(name, &plan, &catalog, &environments_catalog, &waivers)?; + println!("{}", serde_json::to_string(&expected.github_matrix)?); + return Ok(ExitCode::SUCCESS); + } + + let host = Arch::host(); + let narrowing = Narrowing { + environments, + suites, + }; + let jobs = jobs::expand( + &plan, + &catalog, + &environments_catalog, + &waivers, + host, + &narrowing, + )?; + print_jobs(&jobs); + if strict { + let problems = jobs::strict_problems(&jobs); + if !problems.is_empty() { + bail!( + "strict mode refuses this plan:\n {}", + problems.join("\n ") + ); + } + } + if dry_run { + return Ok(ExitCode::SUCCESS); + } + + // Absolute, because `load_tmachine` changes the working directory. + let results = match results { + Some(results) => std::path::absolute(&results) + .with_context(|| format!("resolve {}", results.display()))?, + None => root.join("artifacts/test-results").join( + plan_path + .file_stem() + .context("plan path has no file name")?, + ), + }; + std::fs::create_dir_all(&results) + .with_context(|| format!("create {}", results.display()))?; + std::fs::write( + results.join("suites.json"), + serde_json::to_string_pretty(&catalog)?, + )?; + + let bundles = build_bundles(&root, &jobs, &catalog, host)?; + let tmachine = load_tmachine(&root)?; + let options = Options { + tmachine: &tmachine, + bundles: &bundles, + results: &results, + keep, + }; + let mut outcomes = Vec::new(); + for job in &jobs { + outcomes.extend(execute::run(job, &options).await); + } + + eprintln!("{}", report::summary(&outcomes)); + print_waived(&jobs); + eprintln!("Results: {}", results.display()); + Ok( + if outcomes.iter().any(|outcome| outcome.status.is_failure()) { + ExitCode::FAILURE + } else { + ExitCode::SUCCESS + }, + ) + } + } +} + +fn print_jobs(jobs: &[Job]) { + for job in jobs { + eprintln!("{}", job.environment.id); + if let Some(reason) = &job.unsupported { + eprintln!(" unsupported on this host: {reason}"); + } + for family in &job.families { + eprintln!(" {}: -E '{}'", family.family, family.filterset()); + for suite in &family.suites { + eprintln!(" {}: {}", suite.suite.suite, suite.run.join(", ")); + for test in &suite.excluded { + eprintln!( + " {}: {} excluded ({})", + suite.suite.suite, test.name, test.reason + ); + } + } + } + } +} + +/// List the waivers that skipped tests, so they stay in view. +fn print_waived(jobs: &[Job]) { + let waived: Vec = jobs + .iter() + .filter(|job| job.unsupported.is_none()) + .flat_map(|job| { + job.families.iter().flat_map(move |family| { + family.suites.iter().flat_map(move |suite| { + suite + .excluded + .iter() + .filter_map(move |test| match &test.reason { + Exclusion::Waived(waiver) => Some(format!( + " {} / {}: {} ({})", + job.environment.id, test.name, waiver.reason, waiver.issue + )), + _ => None, + }) + }) + }) + }) + .collect(); + if !waived.is_empty() { + eprintln!("Waived (skipped; run with --ignore-waivers to check fixes):"); + for line in waived { + eprintln!("{line}"); + } + eprintln!(); + } +} + +/// Build one bundle per family, covering every suite that will run. +fn build_bundles( + root: &Path, + jobs: &[Job], + catalog: &Catalog, + host: Option, +) -> Result> { + let mut suites: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); + for job in jobs.iter().filter(|job| job.unsupported.is_none()) { + for family in &job.families { + for suite in family.suites.iter().filter(|suite| !suite.run.is_empty()) { + suites + .entry(family.family.as_str()) + .or_default() + .insert(suite.suite.suite.as_str()); + } + } + } + + let mut bundles = BTreeMap::new(); + if suites.is_empty() { + return Ok(bundles); + } + let target = bundle::guest_target(host.context("the host architecture is not supported")?); + eprintln!("==> building {} for {target}", bundle::READY_BINARY); + let ready = bundle::build_ready(root, target)?; + for (family_name, suite_names) in suites { + let family = catalog.family(family_name)?; + let binaries = suite_names + .iter() + .map(|name| family.suite(name).map(|suite| suite.binary.clone())) + .collect::>()?; + eprintln!("==> building {family_name} bundle for {target}"); + let bundle = bundle::build( + family, + &family.packages(suite_names.iter().copied()), + &binaries, + target, + &ready, + )?; + bundles.insert(family_name.to_string(), bundle); + } + Ok(bundles) +} + +/// Load the tmachine configuration and prepare the process to drive it. +/// +/// tmachine configs name playbooks and inputs relative to `tests/tmachine/`, +/// and its Ansible runs read `ANSIBLE_CONFIG`. +fn load_tmachine(root: &Path) -> Result { + let path = std::env::var_os("TMACHINE_CONFIG").context( + "TMACHINE_CONFIG is not set; run through `nix run .#test-runner`, which provides it", + )?; + let config = tmachine::config::Config::load(Path::new(&path))?; + let tmachine_dir = root.join("tests/tmachine"); + std::env::set_current_dir(&tmachine_dir) + .with_context(|| format!("enter {}", tmachine_dir.display()))?; + if std::env::var_os("ANSIBLE_CONFIG").is_none() { + // SAFETY: single-threaded at this point; no other thread reads the + // environment concurrently. + unsafe { + std::env::set_var("ANSIBLE_CONFIG", tmachine_dir.join("ansible/ansible.cfg")); + }; + } + Ok(config) +} + +fn repo_root() -> Result { + let output = std::process::Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .output() + .context("run git rev-parse")?; + anyhow::ensure!( + output.status.success(), + "not inside the OpenShell repository" + ); + Ok(PathBuf::from(String::from_utf8(output.stdout)?.trim())) +} diff --git a/tests/runner/src/matrix.rs b/tests/runner/src/matrix.rs new file mode 100644 index 0000000000..99cccc97e5 --- /dev/null +++ b/tests/runner/src/matrix.rs @@ -0,0 +1,290 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The expected matrix: every test against every defined environment. +//! +//! A test is expected in every environment that provides the capabilities +//! it requires. Nobody lists combinations: tests say what they need, +//! environments say what they provide. Waivers then skip known bugs, and +//! the host check excludes environments this machine can't boot. Plans only +//! select from the result; they never decide what is expected. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::{self, Write as _}; + +use serde::Serialize; + +use crate::catalog::{Catalog, Test}; +use crate::environments::{Arch, Catalog as Environments, Environment}; +use crate::waivers::{Waiver, Waivers}; + +/// Why a test does not run in an environment. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Exclusion { + /// The environment lacks capabilities the test requires. + Missing(Vec), + /// A waiver skips the test there. + Waived(Waiver), + /// This machine can't provision the environment. + Host(String), +} + +impl fmt::Display for Exclusion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Missing(capabilities) => write!(f, "needs {}", capabilities.join(", ")), + Self::Waived(waiver) => write!(f, "waived: {} ({})", waiver.reason, waiver.issue), + Self::Host(reason) => write!(f, "host: {reason}"), + } + } +} + +/// Capabilities the test requires that the environment doesn't provide. +/// Requirements name boolean capabilities. +pub fn missing_capabilities(test: &Test, environment: &Environment) -> Vec { + test.requires + .iter() + .filter(|capability| { + environment.capabilities.get(capability.as_str()) != Some(&serde_yml::Value::Bool(true)) + }) + .cloned() + .collect() +} + +/// Why the test doesn't run in the environment, ignoring the host: a missing +/// capability first, then a waiver. +pub fn exclusion(test: &Test, environment: &Environment, waivers: &Waivers) -> Option { + let missing = missing_capabilities(test, environment); + if !missing.is_empty() { + return Some(Exclusion::Missing(missing)); + } + waivers + .find(test, environment) + .map(|waiver| Exclusion::Waived(waiver.clone())) +} + +/// Problems that make the matrix inconsistent, judged by capabilities alone: +/// a test no defined environment can run, and a capability tests require +/// that no environment declares. Waivers and the host don't count; a test +/// only needs to be runnable somewhere in principle. +pub fn problems(catalog: &Catalog, environments: &Environments) -> Vec { + let declared: BTreeSet<&str> = environments + .iter() + .flat_map(|env| { + env.capabilities + .iter() + .filter(|(_, value)| **value == serde_yml::Value::Bool(true)) + .map(|(name, _)| name.as_str()) + }) + .collect(); + + let mut undeclared: BTreeMap<&str, Vec> = BTreeMap::new(); + let mut problems = Vec::new(); + for (family, suite, test) in catalog.tests() { + let location = format!("{}/{}/{}", family.name, suite.suite, test.label()); + for capability in &test.requires { + if !declared.contains(capability.as_str()) { + undeclared + .entry(capability) + .or_default() + .push(location.clone()); + } + } + if !environments + .iter() + .any(|env| missing_capabilities(test, env).is_empty()) + { + problems.push(format!( + "{location} runs in no defined environment; requires {}", + test.requires.join(", ") + )); + } + } + for (capability, tests) in undeclared { + problems.push(format!( + "capability {capability} is required by {} but no environment declares it", + tests.join(", ") + )); + } + problems +} + +/// Every test against every environment. +#[derive(Debug, Serialize)] +pub struct Matrix { + pub environments: Vec, + pub tests: Vec, +} + +#[derive(Debug, Serialize)] +pub struct Row { + pub family: String, + pub suite: String, + pub name: String, + pub id: Option, + /// Keyed by environment ID: `run`, or why the test doesn't run there. + pub cells: BTreeMap, +} + +impl Matrix { + /// Build the matrix. With `host`, also exclude environments that machine + /// can't provision. + pub fn build( + catalog: &Catalog, + environments: &Environments, + waivers: &Waivers, + host: Option>, + ) -> Self { + let tests = catalog + .tests() + .map(|(family, suite, test)| Row { + family: family.name.clone(), + suite: suite.suite.clone(), + name: test.name.clone(), + id: test.id.clone(), + cells: environments + .iter() + .map(|env| { + let exclusion = exclusion(test, env, waivers).or_else(|| { + host.and_then(|host| env.unsupported_reason(host).map(Exclusion::Host)) + }); + let cell = exclusion.map_or_else(|| "run".to_string(), |e| e.to_string()); + (env.id.clone(), cell) + }) + .collect(), + }) + .collect(); + Self { + environments: environments.iter().map(|env| env.id.clone()).collect(), + tests, + } + } + + /// The matrix as text: each test, then where it runs or why not. + pub fn render(&self) -> String { + let width = self.environments.iter().map(String::len).max().unwrap_or(0); + let mut out = String::new(); + let mut runs = 0; + let mut excluded = 0; + for row in &self.tests { + let id = row + .id + .as_ref() + .map_or(String::new(), |id| format!(" ({id})")); + let _ = writeln!(out, "{}/{}/{}{id}", row.family, row.suite, row.name); + for (env, cell) in &row.cells { + let _ = writeln!(out, " {env:width$} {cell}"); + if cell == "run" { + runs += 1; + } else { + excluded += 1; + } + } + } + let _ = writeln!( + out, + "\n{} tests x {} environments: {runs} run, {excluded} excluded", + self.tests.len(), + self.environments.len() + ); + out + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::catalog::{Family, Suite}; + + fn environments(capabilities: &str) -> Environments { + let yaml = format!( + "environments:\n - id: tmachine.ubuntu2404-amd64.docker-rootful.deb\n backend: tmachine\n machine: {{ os: ubuntu, version: \"24.04\", arch: amd64 }}\n runtime: docker-rootful\n installer: deb\n tmachine: {{ environment: ubuntu-docker-rootful, installer: deb }}\n capabilities: {capabilities}\n" + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("docker.yaml"), yaml).unwrap(); + Environments::load_dir(dir.path()).unwrap() + } + + fn catalog(tests: &[(&str, &[&str])]) -> Catalog { + let suite = Suite { + suite: "s".to_string(), + binary: "s".to_string(), + package: "p".to_string(), + tests: tests + .iter() + .enumerate() + .map(|(index, (name, requires))| Test { + id: Some(format!("t-{index:08}")), + name: (*name).to_string(), + description: "d".to_string(), + requires: requires.iter().map(ToString::to_string).collect(), + }) + .collect(), + }; + let family = Family { + name: "conformance".to_string(), + workspace: PathBuf::new(), + suites: [("s".to_string(), suite)].into(), + }; + Catalog { + families: [("conformance".to_string(), family)].into(), + } + } + + #[test] + fn reports_tests_no_environment_can_run() { + let catalog = catalog(&[("plain", &[]), ("exposed", &["service_exposure"])]); + let found = problems(&catalog, &environments("{ service_exposure: false }")); + assert_eq!(found.len(), 2, "{found:?}"); + assert!( + found[0].contains("conformance/s/exposed (t-00000001) runs in no defined environment"), + "{found:?}" + ); + assert!(found[1].contains("capability service_exposure is required by")); + + assert!(problems(&catalog, &environments("{ service_exposure: true }")).is_empty()); + } + + #[test] + fn cells_give_the_reason_a_test_does_not_run() { + let catalog = catalog(&[("plain", &[]), ("exposed", &["service_exposure"])]); + let environments = environments("{ compute_driver: docker }"); + let env = "tmachine.ubuntu2404-amd64.docker-rootful.deb"; + + let matrix = Matrix::build(&catalog, &environments, &Waivers::default(), None); + assert_eq!(matrix.tests[0].cells[env], "run"); + assert_eq!(matrix.tests[1].cells[env], "needs service_exposure"); + + let on_arm = Matrix::build( + &catalog, + &environments, + &Waivers::default(), + Some(Some(Arch::Arm64)), + ); + assert!( + on_arm.tests[0].cells[env].starts_with("host: "), + "{:?}", + on_arm.tests[0].cells + ); + // A missing capability is reported before the host. + assert_eq!(on_arm.tests[1].cells[env], "needs service_exposure"); + + let text = matrix.render(); + assert!(text.contains("conformance/s/plain (t-00000000)"), "{text}"); + assert!( + text.contains("2 tests x 1 environments: 1 run, 1 excluded"), + "{text}" + ); + } + + #[test] + fn repository_matrix_is_consistent() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let catalog = Catalog::load(&root).unwrap(); + let environments = Environments::load_dir(&root.join("tests/environments")).unwrap(); + let problems = problems(&catalog, &environments); + assert!(problems.is_empty(), "{problems:#?}"); + } +} diff --git a/tests/runner/src/plan.rs b/tests/runner/src/plan.rs new file mode 100644 index 0000000000..ba172cfe70 --- /dev/null +++ b/tests/runner/src/plan.rs @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Test plans: `tests/plans/*.yaml`. +//! +//! ```yaml +//! matrix: +//! - type: conformance +//! suites: [sandbox-lifecycle] # omit for every suite in the family +//! environments: [tmachine.ubuntu2404-amd64.docker-rootful.deb] +//! ``` + +use std::path::Path; + +use anyhow::{Context, Result, ensure}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Plan { + pub matrix: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Entry { + /// The test family, e.g. `conformance`. + #[serde(rename = "type")] + pub family: String, + /// Suites to run. `None` selects every suite in the family. + #[serde(default)] + pub suites: Option>, + pub environments: Vec, +} + +impl Plan { + pub fn load(path: &Path) -> Result { + let yaml = std::fs::read_to_string(path) + .with_context(|| format!("read plan {}", path.display()))?; + Self::parse(&yaml).with_context(|| format!("load plan {}", path.display())) + } + + pub fn parse(yaml: &str) -> Result { + let plan: Self = serde_yml::from_str(yaml)?; + ensure!(!plan.matrix.is_empty(), "plan has an empty matrix"); + for (index, entry) in plan.matrix.iter().enumerate() { + ensure!( + !entry.environments.is_empty(), + "matrix entry {index} ({}) names no environments", + entry.family + ); + ensure!( + entry + .suites + .as_ref() + .is_none_or(|suites| !suites.is_empty()), + "matrix entry {index} ({}) has an empty suite list; omit `suites` to select every suite", + entry.family + ); + } + Ok(plan) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_plan() { + let plan = Plan::parse( + "matrix:\n - type: conformance\n suites: [sandbox-lifecycle]\n environments: [e1, e2]\n - type: conformance\n environments: [e3]\n", + ) + .unwrap(); + assert_eq!(plan.matrix.len(), 2); + assert_eq!(plan.matrix[0].family, "conformance"); + assert_eq!( + plan.matrix[0].suites.as_deref(), + Some(&["sandbox-lifecycle".to_string()][..]) + ); + assert_eq!(plan.matrix[1].suites, None); + } + + #[test] + fn rejects_unknown_fields() { + let err = + Plan::parse("matrix:\n - type: conformance\n suite: [x]\n environments: [e]\n") + .unwrap_err(); + assert!(format!("{err:#}").contains("suite"), "{err:#}"); + } + + #[test] + fn rejects_entry_without_environments() { + let err = + Plan::parse("matrix:\n - type: conformance\n environments: []\n").unwrap_err(); + assert!(err.to_string().contains("names no environments"), "{err}"); + } + + #[test] + fn rejects_empty_suite_list() { + let err = + Plan::parse("matrix:\n - type: conformance\n suites: []\n environments: [e]\n") + .unwrap_err(); + assert!(err.to_string().contains("empty suite list"), "{err}"); + } +} diff --git a/tests/runner/src/report.rs b/tests/runner/src/report.rs new file mode 100644 index 0000000000..79d9da2dfb --- /dev/null +++ b/tests/runner/src/report.rs @@ -0,0 +1,452 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Job outcomes, `JUnit` result checking, and the run summary. +//! +//! The `JUnit` check is what keeps a lane honest: a job passes only if its +//! report has a passing case for every test the runner selected. A test that +//! is missing from the report, skipped, or not expected fails the job, so +//! dropping a test (a bad filter, a renamed function, a crashed binary) +//! cannot pass silently. +//! +//! Adapted from the report in Evan Lezar's NVIDIA/OpenShell#4629. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write as _; + +/// The outcome of one family in one environment. +#[derive(Debug)] +pub struct Outcome { + pub environment: String, + pub family: String, + pub status: Status, + /// Tests excluded because the environment lacks a capability. + pub unsupported_tests: usize, + /// Tests a waiver skips in this environment. + pub waived_tests: usize, +} + +#[derive(Debug)] +pub enum Status { + Passed { + tests: u32, + }, + Failed { + tests: u32, + failures: u32, + }, + /// The environment can't be provisioned on this host. + Unsupported(String), + /// Every selected test needs a capability the environment lacks. + NothingToRun, + /// The runner could not complete the job, or the results did not match + /// what was expected. + Error(String), +} + +impl Status { + pub const fn is_failure(&self) -> bool { + matches!(self, Self::Failed { .. } | Self::Error(_)) + } +} + +/// How one test case ended, from a `JUnit` report. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CaseResult { + Passed, + Failed, + Skipped, +} + +/// One test case in a `JUnit` report. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Case { + pub classname: String, + pub name: String, + pub result: CaseResult, +} + +/// A `JUnit` report compared with the tests that were expected. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct Verification { + /// Test cases in the report. + pub tests: u32, + /// Expected tests that failed. + pub failed: Vec, + /// Expected tests with no passing or failing case: absent from the + /// report, or skipped. + pub missing: Vec, + /// Cases in the report that nothing selected. + pub unexpected: Vec, +} + +impl Verification { + /// Whether the report has a passing case for exactly the expected tests. + pub fn is_clean(&self) -> bool { + self.failed.is_empty() && self.missing.is_empty() && self.unexpected.is_empty() + } + + /// The mismatch between the report and the selection, if any, as a + /// message. Failures are not a mismatch: they are the tests' own result. + pub fn mismatch(&self) -> Option { + let mut parts = Vec::new(); + if !self.missing.is_empty() { + parts.push(format!( + "expected tests missing from the report or skipped: {}", + self.missing.join(", ") + )); + } + if !self.unexpected.is_empty() { + parts.push(format!( + "tests in the report that were not selected: {}", + self.unexpected.join(", ") + )); + } + (!parts.is_empty()).then(|| parts.join("; ")) + } +} + +/// The test cases in a `JUnit` report written by nextest. +pub fn parse_cases(xml: &str) -> Vec { + let mut cases = Vec::new(); + let mut rest = xml; + while let Some(start) = rest.find("') else { + break; + }; + let tag_end = start + len; + let tag = &rest[start..tag_end]; + let (body, next) = if tag.ends_with('/') { + ("", tag_end + 1) + } else { + let close = rest[tag_end..] + .find("") + .map_or(rest.len(), |offset| tag_end + offset); + (&rest[tag_end + 1..close], close) + }; + if let (Some(classname), Some(name)) = (attr(tag, "classname"), attr(tag, "name")) { + let result = if body.contains(") -> Verification { + let cases = parse_cases(xml); + let mut verification = Verification { + tests: u32::try_from(cases.len()).unwrap_or(u32::MAX), + ..Verification::default() + }; + let label = |classname: &str, name: &str| format!("{classname} {name}"); + + let mut seen: BTreeSet<(&str, &str)> = BTreeSet::new(); + for case in &cases { + let key = (case.classname.as_str(), case.name.as_str()); + seen.insert(key); + let is_expected = expected.contains(&(case.classname.clone(), case.name.clone())); + if !is_expected { + // A skipped case that was never expected is an `#[ignore]` test: + // nextest lists it, and it did not run, so it is not a problem. + if case.result != CaseResult::Skipped { + verification + .unexpected + .push(label(&case.classname, &case.name)); + } + continue; + } + match case.result { + CaseResult::Passed => {} + CaseResult::Failed => verification.failed.push(label(&case.classname, &case.name)), + CaseResult::Skipped => verification + .missing + .push(label(&case.classname, &case.name)), + } + } + for (classname, name) in expected { + if !seen.contains(&(classname.as_str(), name.as_str())) { + verification.missing.push(label(classname, name)); + } + } + verification +} + +/// The `JUnit` property that carries a test's stable ID. +pub const TEST_ID_PROPERTY: &str = "test_id"; + +/// Add each test's stable ID to its `` as a property. +/// +/// `ids` maps a nextest `(classname, name)` pair (`::`, test +/// name) to the test's ID. Test cases not in `ids` are left unchanged. The +/// property is the test case's first child, where pytest's +/// `record_property` puts it. +pub fn annotate_junit(xml: &str, ids: &BTreeMap<(String, String), String>) -> String { + let mut out = String::with_capacity(xml.len()); + let mut rest = xml; + while let Some(start) = rest.find("') else { + break; + }; + let end = start + len; + let tag = &rest[start..end]; + out.push_str(&rest[..start]); + let key = attr(tag, "classname").zip(attr(tag, "name")); + match key.and_then(|(classname, name)| ids.get(&(classname.to_string(), name.to_string()))) + { + Some(id) => { + let properties = format!( + "" + ); + match tag.strip_suffix('/') { + Some(open) => { + let _ = write!(out, "{open}>{properties}"); + } + None => { + let _ = write!(out, "{tag}>{properties}"); + } + } + } + None => { + out.push_str(tag); + out.push('>'); + } + } + rest = &rest[end + 1..]; + } + out.push_str(rest); + out +} + +/// An attribute value from a start tag. nextest escapes `"` in values. +fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> { + let key = format!(" {name}=\""); + let start = tag.find(&key)? + key.len(); + let end = start + tag[start..].find('"')?; + Some(&tag[start..end]) +} + +fn unescape(value: &str) -> String { + value + .replace(""", "\"") + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") +} + +/// A human-readable summary of every outcome. +pub fn summary(outcomes: &[Outcome]) -> String { + let mut out = String::from("\nSummary\n"); + for outcome in outcomes { + let status = match &outcome.status { + Status::Passed { tests } => format!("PASS {tests} passed"), + Status::Failed { tests, failures } => { + format!("FAIL {failures} of {tests} failed") + } + Status::Unsupported(reason) => format!("UNSUPPORTED {reason}"), + Status::NothingToRun => { + "UNSUPPORTED every selected test needs a missing capability".to_string() + } + Status::Error(error) => format!("ERROR {error}"), + }; + let mut excluded = Vec::new(); + if outcome.unsupported_tests > 0 { + excluded.push(format!("{} tests unsupported", outcome.unsupported_tests)); + } + if outcome.waived_tests > 0 { + excluded.push(format!("{} waived", outcome.waived_tests)); + } + let unsupported = if excluded.is_empty() { + String::new() + } else { + format!(" ({})", excluded.join(", ")) + }; + let _ = writeln!( + out, + " {} / {}: {status}{unsupported}", + outcome.environment, outcome.family + ); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const REPORT: &str = r#" + + + + + + ok +"#; + + fn expected(names: &[&str]) -> BTreeSet<(String, String)> { + names + .iter() + .map(|name| ("p::b".to_string(), (*name).to_string())) + .collect() + } + + #[test] + fn parses_cases_and_their_results() { + let cases = parse_cases(REPORT); + let results: Vec<_> = cases + .iter() + .map(|case| (case.name.as_str(), case.result)) + .collect(); + assert_eq!( + results, + [ + ("passed", CaseResult::Passed), + ("failed", CaseResult::Failed), + ("skipped", CaseResult::Skipped), + ("extra", CaseResult::Passed), + ] + ); + assert!(cases.iter().all(|case| case.classname == "p::b")); + } + + #[test] + fn a_report_with_exactly_the_expected_passing_tests_is_clean() { + let verification = verify_junit(REPORT, &expected(&["passed", "extra"])); + // `failed` was not expected, so it is unexpected. `skipped` was not + // expected either, which is what an `#[ignore]` test looks like, so it + // is not reported. + assert_eq!(verification.unexpected.len(), 1, "{verification:?}"); + assert!( + verification.unexpected[0].ends_with("failed"), + "{verification:?}" + ); + let clean = verify_junit( + r#""#, + &expected(&["a"]), + ); + assert!(clean.is_clean(), "{clean:?}"); + assert_eq!(clean.tests, 1); + assert_eq!(clean.mismatch(), None); + } + + #[test] + fn a_missing_test_is_a_mismatch() { + let verification = verify_junit(REPORT, &expected(&["passed", "extra", "gone"])); + assert_eq!(verification.missing, ["p::b gone"]); + let message = verification.mismatch().unwrap(); + assert!(message.contains("missing from the report or skipped: p::b gone")); + } + + #[test] + fn a_skipped_test_counts_as_missing() { + let verification = verify_junit(REPORT, &expected(&["passed", "skipped"])); + assert_eq!(verification.missing, ["p::b skipped"]); + } + + #[test] + fn a_failed_test_is_a_failure_not_a_mismatch() { + let verification = verify_junit(REPORT, &expected(&["passed", "failed"])); + assert_eq!(verification.failed, ["p::b failed"]); + assert!(verification.missing.is_empty()); + // `skipped` and `extra` were not expected. + assert!(verification.mismatch().unwrap().contains("not selected")); + } + + #[test] + fn an_empty_report_misses_every_expected_test() { + let verification = verify_junit("", &expected(&["a", "b"])); + assert_eq!(verification.tests, 0); + assert_eq!(verification.missing.len(), 2); + } + + #[test] + fn names_are_unescaped() { + let cases = parse_cases(r#""#); + assert_eq!(cases[0].name, "a&b"); + } + + #[test] + fn annotates_test_cases_with_ids() { + let xml = r#" + + + + "#; + let ids = BTreeMap::from([ + ( + ("p::b".to_string(), "passed".to_string()), + "t-00000001".to_string(), + ), + ( + ("p::b".to_string(), "failed".to_string()), + "t-00000002".to_string(), + ), + ]); + let out = annotate_junit(xml, &ids); + assert!(out.contains( + r#""# + ), "{out}"); + assert!(out.contains( + r#""# + ), "{out}"); + assert!( + out.contains(r#""#), + "{out}" + ); + assert!(out.ends_with(""), "{out}"); + } + + #[test] + fn annotated_reports_still_verify() { + let xml = r#""#; + let ids = BTreeMap::from([( + ("p::b".to_string(), "a".to_string()), + "t-00000001".to_string(), + )]); + let annotated = annotate_junit(xml, &ids); + assert!(verify_junit(&annotated, &expected(&["a"])).is_clean()); + } + + #[test] + fn summarizes_outcomes() { + let text = summary(&[ + Outcome { + environment: "env-a".to_string(), + family: "conformance".to_string(), + status: Status::Passed { tests: 2 }, + unsupported_tests: 1, + waived_tests: 1, + }, + Outcome { + environment: "env-b".to_string(), + family: "conformance".to_string(), + status: Status::Unsupported("arch".to_string()), + unsupported_tests: 0, + waived_tests: 0, + }, + ]); + assert!( + text.contains( + "env-a / conformance: PASS 2 passed (1 tests unsupported, 1 waived)" + ), + "{text}" + ); + assert!( + text.contains("env-b / conformance: UNSUPPORTED arch"), + "{text}" + ); + } +} diff --git a/tests/runner/src/waivers.rs b/tests/runner/src/waivers.rs new file mode 100644 index 0000000000..e142a7ea62 --- /dev/null +++ b/tests/runner/src/waivers.rs @@ -0,0 +1,224 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Waivers: `tests/waivers.yaml`. +//! +//! A waiver excludes a test from the environments it selects because of a +//! known bug, tracked by an issue. It is the only explicit exclusion: a +//! feature an environment doesn't have is a missing capability, a machine +//! that can't boot an environment is the host check, and running less on a +//! given occasion is plan selection. Waived tests are skipped in every plan. +//! +//! A waiver names its test by stable ID, or by function name when the name is +//! unique in the catalog. Adapted from the waivers in Evan Lezar's +//! NVIDIA/OpenShell#4629, which required an ID. + +use std::path::Path; + +use anyhow::{Context, Result, bail, ensure}; +use serde::Deserialize; + +use crate::catalog::{Catalog, Test}; +use crate::environments::{Catalog as Environments, Environment, Selector}; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct File { + #[serde(default)] + waivers: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Waiver { + /// The test's stable ID, or its function name if that is unique. + pub test: String, + /// The environments the test is skipped in. Omit for all. + #[serde(default)] + pub environments: Selector, + /// The issue tracking the bug. + pub issue: String, + pub reason: String, +} + +#[derive(Debug, Default)] +pub struct Waivers { + waivers: Vec, +} + +impl Waivers { + /// Load and validate waivers. A missing file means no waivers. + pub fn load(path: &Path, catalog: &Catalog, environments: &Environments) -> Result { + if !path.exists() { + return Ok(Self::default()); + } + let yaml = + std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + Self::parse(&yaml, catalog, environments) + .with_context(|| format!("load waivers from {}", path.display())) + } + + pub fn parse(yaml: &str, catalog: &Catalog, environments: &Environments) -> Result { + let file: File = serde_yml::from_str(yaml)?; + for (index, waiver) in file.waivers.iter().enumerate() { + let at = format!("waiver for {}", waiver.test); + catalog + .resolve(&waiver.test) + .with_context(|| format!("{at}; remove the waiver if the test is gone"))?; + ensure!( + environments + .iter() + .any(|env| waiver.environments.matches(env)), + "{at}: no environment matches {}", + waiver.environments + ); + ensure!( + waiver.issue.starts_with("https://"), + "{at}: `issue` must link the issue tracking the bug" + ); + ensure!(!waiver.reason.trim().is_empty(), "{at}: `reason` is empty"); + if file.waivers[..index] + .iter() + .any(|other| other.test == waiver.test && other.environments == waiver.environments) + { + bail!("{at}: duplicate waiver for {}", waiver.environments); + } + } + Ok(Self { + waivers: file.waivers, + }) + } + + /// The waiver that skips this test in this environment, if any. + pub fn find(&self, test: &Test, environment: &Environment) -> Option<&Waiver> { + self.waivers.iter().find(|waiver| { + (test.id.as_deref() == Some(waiver.test.as_str()) || test.name == waiver.test) + && waiver.environments.matches(environment) + }) + } + + pub fn iter(&self) -> impl Iterator { + self.waivers.iter() + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::path::PathBuf; + + use super::*; + use crate::catalog::{Family, Suite}; + + fn root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") + } + + fn environments() -> Environments { + Environments::load_dir(&root().join("tests/environments")).unwrap() + } + + fn test(id: Option<&str>, name: &str) -> Test { + Test { + id: id.map(str::to_string), + name: name.to_string(), + description: "d".to_string(), + requires: Vec::new(), + } + } + + fn catalog() -> Catalog { + let suite = Suite { + suite: "s".to_string(), + binary: "s".to_string(), + package: "p".to_string(), + tests: vec![ + test(Some("t-00000001"), "identified"), + test(None, "anonymous"), + ], + }; + Catalog { + families: BTreeMap::from([( + "f".to_string(), + Family { + name: "f".to_string(), + workspace: PathBuf::new(), + suites: BTreeMap::from([("s".to_string(), suite)]), + }, + )]), + } + } + + fn parse(yaml: &str) -> Result { + Waivers::parse(yaml, &catalog(), &environments()) + } + + #[test] + fn finds_waivers_by_id_or_name_and_environment() { + let waivers = parse( + "waivers:\n - test: t-00000001\n environments: { arch: arm64 }\n issue: https://github.com/NVIDIA/OpenShell/issues/1\n reason: broken on arm64\n - test: anonymous\n issue: https://github.com/NVIDIA/OpenShell/issues/2\n reason: broken everywhere\n", + ) + .unwrap(); + let environments = environments(); + let amd64 = environments + .get("tmachine.ubuntu2404-amd64.docker-rootful.deb") + .unwrap(); + let arm64 = environments + .get("tmachine.ubuntu2404-arm64.docker-rootful.deb") + .unwrap(); + let identified = test(Some("t-00000001"), "identified"); + let anonymous = test(None, "anonymous"); + assert!(waivers.find(&identified, amd64).is_none()); + assert_eq!( + waivers.find(&identified, arm64).unwrap().reason, + "broken on arm64" + ); + assert!(waivers.find(&anonymous, amd64).is_some()); + assert!(waivers.find(&test(None, "other"), arm64).is_none()); + } + + #[test] + fn rejects_invalid_waivers() { + let cases = [ + ( + "waivers:\n - test: t-00000000\n issue: https://x\n reason: r\n".to_string(), + "no test has the ID or name", + ), + ( + "waivers:\n - test: identified\n environments: { runtime: nope }\n issue: https://x\n reason: r\n" + .to_string(), + "no environment matches", + ), + ( + "waivers:\n - test: identified\n issue: '#123'\n reason: r\n".to_string(), + "must link the issue", + ), + ( + "waivers:\n - test: identified\n issue: https://x\n reason: ' '\n" + .to_string(), + "`reason` is empty", + ), + ( + "waivers:\n - test: identified\n issue: https://x\n reason: r\n - test: identified\n issue: https://y\n reason: r\n" + .to_string(), + "duplicate waiver", + ), + ]; + for (yaml, expected) in cases { + let err = format!("{:#}", parse(&yaml).unwrap_err()); + assert!(err.contains(expected), "{expected}: {err}"); + } + assert!(parse("waivers:\n - test: x\n issu: y\n").is_err()); + } + + #[test] + fn repository_waivers_are_valid() { + let catalog = Catalog::load(&root()).unwrap(); + Waivers::load( + &root().join("tests/waivers.yaml"), + &catalog, + &environments(), + ) + .unwrap(); + } +} diff --git a/tests/suites/podman/Cargo.lock b/tests/suites/podman/Cargo.lock index 6c18cea843..d2bb9c9853 100644 --- a/tests/suites/podman/Cargo.lock +++ b/tests/suites/podman/Cargo.lock @@ -2009,11 +2009,20 @@ dependencies = [ name = "openshell-testkit-macros" version = "0.0.0" dependencies = [ + "openshell-testkit-spec", "proc-macro2", "quote", "syn 2.0.117", ] +[[package]] +name = "openshell-testkit-spec" +version = "0.0.0" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + [[package]] name = "openssl-probe" version = "0.2.1" diff --git a/tests/suites/providers/Cargo.lock b/tests/suites/providers/Cargo.lock index 15c543ace3..2ff5180087 100644 --- a/tests/suites/providers/Cargo.lock +++ b/tests/suites/providers/Cargo.lock @@ -2011,11 +2011,20 @@ dependencies = [ name = "openshell-testkit-macros" version = "0.0.0" dependencies = [ + "openshell-testkit-spec", "proc-macro2", "quote", "syn 2.0.117", ] +[[package]] +name = "openshell-testkit-spec" +version = "0.0.0" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + [[package]] name = "openssl-probe" version = "0.2.1" diff --git a/tests/suites/sandbox/Cargo.lock b/tests/suites/sandbox/Cargo.lock index f52e18b63b..173a3a6e72 100644 --- a/tests/suites/sandbox/Cargo.lock +++ b/tests/suites/sandbox/Cargo.lock @@ -2035,11 +2035,20 @@ dependencies = [ name = "openshell-testkit-macros" version = "0.0.0" dependencies = [ + "openshell-testkit-spec", "proc-macro2", "quote", "syn 2.0.117", ] +[[package]] +name = "openshell-testkit-spec" +version = "0.0.0" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + [[package]] name = "openssl-probe" version = "0.2.1" diff --git a/tests/suites/sandbox/README.md b/tests/suites/sandbox/README.md index 4b190cfc95..c23c0f7e6d 100644 --- a/tests/suites/sandbox/README.md +++ b/tests/suites/sandbox/README.md @@ -39,8 +39,9 @@ leave driver behavior alone. published docs. - Assert `phase` and `exit_code`. Don't assert condition reasons. - Check that something did not happen by reading state, not by sleeping. -- If a behavior is left to the driver, it needs a capability. Until testkit has - capabilities, keep that test out of this suite. +- If a behavior is left to the driver, declare it with + `#[testkit::test(requires = [...])]`. The runner supplies the environment's + capabilities, and the test fails if one is missing. - Use the CLI for upload, download, connect and forward. Rely on its exit code and `--output json`. - Add a testkit helper instead of calling raw gRPC from a test. diff --git a/tests/testkit/Cargo.lock b/tests/testkit/Cargo.lock index 1206cc7d68..c0b2515593 100644 --- a/tests/testkit/Cargo.lock +++ b/tests/testkit/Cargo.lock @@ -1990,6 +1990,7 @@ dependencies = [ "openshell-core", "openshell-sdk", "openshell-testkit-macros", + "openshell-testkit-spec", "rand 0.9.4", "serde", "serde_json", @@ -2001,11 +2002,20 @@ dependencies = [ name = "openshell-testkit-macros" version = "0.0.0" dependencies = [ + "openshell-testkit-spec", "proc-macro2", "quote", "syn 2.0.117", ] +[[package]] +name = "openshell-testkit-spec" +version = "0.0.0" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + [[package]] name = "openssl-probe" version = "0.2.1" diff --git a/tests/testkit/Cargo.toml b/tests/testkit/Cargo.toml index 17a5e5b052..468fefde14 100644 --- a/tests/testkit/Cargo.toml +++ b/tests/testkit/Cargo.toml @@ -5,4 +5,4 @@ # by path. [workspace] resolver = "2" -members = ["testkit", "testkit-macros"] +members = ["testkit", "testkit-macros", "testkit-spec"] diff --git a/tests/testkit/README.md b/tests/testkit/README.md index 7c4a321617..4a78377a3d 100644 --- a/tests/testkit/README.md +++ b/tests/testkit/README.md @@ -9,6 +9,7 @@ This is a separate Cargo workspace with its own lockfile: |---|---| | `testkit` | Finds and connects to the gateway, creates and cleans up sandboxes, runs the CLI. Also builds `testkit-ready`. | | `testkit-macros` | The `#[testkit::test]` attribute. | +| `testkit-spec` | Parses `#[testkit::test]` functions. The macro and the test runner's catalog both use it, so tools can list tests, with their ID and requirements, without compiling them. | ## Writing a test @@ -36,6 +37,14 @@ async fn long_running_exec_delete(cx: &mut Context) -> testkit::Result { because a testkit test panics on any failure. To check that something is refused, assert the failure in the body, for example with `cx.cli([...]).await?.failure()?` and `testkit::ensure!`. +- **`id` is optional.** `#[testkit::test(id = "t-0123abcd")]` gives a test an + identity that survives renames and moves. Tools that need one, such as + waivers and result history, use it; without it the function name is the + identity. An ID is `t-` and eight lowercase letters or digits, unique across + all suites. A malformed ID's compile error suggests a fresh one. +- **`requires` is optional.** `#[testkit::test(requires = [service_exposure])]` + names the capabilities the environment must provide. See + [Capabilities](#capabilities). Cleanup runs after every test, whether it passes, fails, or panics. It only deletes sandboxes created through `cx.sandbox(...)`. Delete anything else a @@ -60,6 +69,47 @@ test creates, such as providers, in the test itself. - `cx.client()` for SDK calls testkit does not wrap. - `poll_until` for waiting on other state. +## Capabilities + +Some behavior depends on the environment, not on the gateway's public +contract: whether files survive stop and start (left to the compute driver), +whether a GPU is present, whether exposed services are reachable from the +test. A test that needs one declares it with `requires`: + +| Capability | The environment provides | +|---|---| +| `gpu` | A GPU for sandboxes | +| `persistent_workspace` | Workspace files that survive stop and start | +| `rootless` | A compute driver running without root | +| `service_exposure` | Exposed sandbox services reachable from the test | + +Capabilities describe the environment, so they are not read from the gateway or +from a file in the tests. The runner lists them in +`OPENSHELL_TEST_CAPABILITIES`, from the environment definition in +`tests/environments/`: + +```text +OPENSHELL_TEST_CAPABILITIES=compute_driver=docker,persistent_workspace,service_exposure +``` + +Entries are comma-separated. A bare name provides the capability, `name=false` +does not, and `name=value` records a descriptive value that a test can read with +`Capabilities::from_env()` and `get`, such as `compute_driver`. + +Only a test with `requires` reads the variable. Such a test fails, instead of +passing without testing anything, when the variable is unset or lacks a required +capability. The runner leaves these tests out of environments that lack the +capability, so a failure means the environment was misdescribed. Running such a +test by hand needs the variable: + +```shell +OPENSHELL_TEST_CAPABILITIES=persistent_workspace cargo nextest run ... +``` + +Tests without `requires`, which is every test today, never read it. To run only +the tests an environment supports by hand, exclude the others by name with a +nextest filter, for example `-E 'not test(=stopped_sandbox_keeps_workspace)'`. + ## Running against a gateway Tests find the gateway the same way the `openshell` CLI does: diff --git a/tests/testkit/testkit-macros/Cargo.toml b/tests/testkit/testkit-macros/Cargo.toml index d6e9cdf0de..1a6c1d839a 100644 --- a/tests/testkit/testkit-macros/Cargo.toml +++ b/tests/testkit/testkit-macros/Cargo.toml @@ -16,6 +16,7 @@ name = "testkit_macros" proc-macro = true [dependencies] +openshell-testkit-spec = { path = "../testkit-spec" } proc-macro2 = "1" quote = "1" syn = { version = "2", features = ["full"] } diff --git a/tests/testkit/testkit-macros/src/lib.rs b/tests/testkit/testkit-macros/src/lib.rs index fc3dbbdb68..9da41cc9c5 100644 --- a/tests/testkit/testkit-macros/src/lib.rs +++ b/tests/testkit/testkit-macros/src/lib.rs @@ -13,17 +13,28 @@ //! async fn expose_service(cx: &mut Context) -> testkit::Result { /* ... */ } //! ``` //! +//! Two arguments are optional: +//! +//! - `id = "t-0123abcd"` gives the test a stable identity that survives +//! renames and moves, for tools such as waivers and result history. +//! - `requires = [service_exposure, gpu]` names the capabilities the +//! environment must provide. The test runner leaves the test out of +//! environments that lack them; a run that reaches the test anyway fails +//! instead of passing without testing the behavior. +//! //! The attribute expands to a `#[tokio::test]` wrapper with the same name, so //! the nextest test name is the function name. The original function is nested -//! in the wrapper and passed to `testkit::run`. Other attributes, such as -//! `#[ignore]` or `#[cfg(...)]`, move to the wrapper. `#[should_panic]` is -//! rejected: a testkit test panics on any failure, so it would pass for the -//! wrong reason. Check the expected failure in the body instead. +//! in the wrapper and passed to `testkit::run`. Parsing is done by +//! `testkit-spec`, which the test runner's catalog also uses. Other +//! attributes, such as `#[ignore]` or `#[cfg(...)]`, move to the wrapper. +//! `#[should_panic]` is rejected: a testkit test panics on any failure, so it +//! would pass for the wrong reason. Check the expected failure in the body +//! instead. use proc_macro::TokenStream; use proc_macro2::TokenStream as TokenStream2; use quote::{format_ident, quote}; -use syn::{Attribute, Expr, ExprLit, ItemFn, Lit, LitStr, Meta}; +use syn::{Attribute, ItemFn, LitStr}; #[proc_macro_attribute] pub fn test(attr: TokenStream, item: TokenStream) -> TokenStream { @@ -34,24 +45,7 @@ pub fn test(attr: TokenStream, item: TokenStream) -> TokenStream { fn expand(attr: TokenStream2, item: TokenStream2) -> syn::Result { let mut body: ItemFn = syn::parse2(item)?; - if !attr.is_empty() { - return Err(syn::Error::new_spanned( - attr, - "`#[testkit::test]` takes no arguments", - )); - } - if body.sig.asyncness.is_none() { - return Err(syn::Error::new_spanned( - body.sig.fn_token, - "testkit tests must be `async fn`", - )); - } - if body.sig.inputs.len() != 1 { - return Err(syn::Error::new_spanned( - &body.sig.inputs, - "testkit tests take exactly one argument: `cx: &mut testkit::Context`", - )); - } + let spec = testkit_spec::parse(attr, &body)?; if let Some(attr) = body.attrs.iter().find(|attr| is_test_attr(attr)) { return Err(syn::Error::new_spanned( attr, @@ -70,17 +64,21 @@ fn expand(attr: TokenStream2, item: TokenStream2) -> syn::Result { `CliOutput::failure` and `testkit::ensure!`", )); } - let description = doc_comment(&body.attrs); - if description.is_empty() { - return Err(syn::Error::new( - body.sig.ident.span(), - "testkit tests need a doc comment describing the behavior they verify", - )); - } let name = body.sig.ident.clone(); let name_str = LitStr::new(&name.to_string(), name.span()); - let description = LitStr::new(&description, name.span()); + let description = LitStr::new(&spec.description, name.span()); + let id = match &spec.id { + Some(id) => { + let id = LitStr::new(id, name.span()); + quote! { ::core::option::Option::Some(#id) } + } + None => quote! { ::core::option::Option::None }, + }; + let requires = spec + .requires + .iter() + .map(|capability| format_ident!("{}", pascal_case(&capability.to_string()))); let vis = body.vis.clone(); let attrs = std::mem::take(&mut body.attrs); @@ -95,7 +93,9 @@ fn expand(attr: TokenStream2, item: TokenStream2) -> syn::Result { ::testkit::run( ::testkit::TestInfo { name: #name_str, + id: #id, description: #description, + requires: &[#(::testkit::Capability::#requires),*], }, body, ) @@ -112,39 +112,59 @@ fn is_test_attr(attr: &Attribute) -> bool { .is_some_and(|segment| segment.ident == "test") } -/// The doc comment, joined into one line. Empty if there is none. -fn doc_comment(attrs: &[Attribute]) -> String { - attrs - .iter() - .filter_map(|attr| match &attr.meta { - Meta::NameValue(meta) if meta.path.is_ident("doc") => match &meta.value { - Expr::Lit(ExprLit { - lit: Lit::Str(line), - .. - }) => Some(line.value().trim().to_string()), - _ => None, - }, - _ => None, +/// `service_exposure` as `ServiceExposure`, the name of the `Capability` +/// variant. +fn pascal_case(snake: &str) -> String { + snake + .split('_') + .map(|word| { + let mut chars = word.chars(); + chars + .next() + .map(|first| first.to_ascii_uppercase().to_string() + chars.as_str()) + .unwrap_or_default() }) - .filter(|line| !line.is_empty()) - .collect::>() - .join(" ") + .collect() } #[cfg(test)] mod tests { - use super::expand; + use super::{expand, pascal_case}; + + fn expand_str(attr: &str, item: &str) -> syn::Result { + expand(attr.parse().unwrap(), item.parse().unwrap()).map(|tokens| tokens.to_string()) + } + + const ITEM: &str = "/// Doc.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }"; + + #[test] + fn carries_id_and_requirements() { + let out = expand_str( + "id = \"t-0123abcd\", requires = [service_exposure, gpu]", + ITEM, + ) + .unwrap(); + assert!(out.contains("Some (\"t-0123abcd\")"), "{out}"); + assert!( + out.contains( + "requires : & [:: testkit :: Capability :: ServiceExposure , :: testkit :: Capability :: Gpu]" + ), + "{out}" + ); + } // A suite only compiles correct tests, so it never reaches these errors. #[test] fn rejects_misuse() { let ok_fn = "async fn t(cx: &mut Context) -> testkit::Result { Ok(()) }"; for (attr, item, error) in [ + ("id = \"nope\"", ITEM.to_string(), "test IDs are"), ( - "id = \"t-1\"", - format!("/// Doc.\n{ok_fn}"), - "takes no arguments", + "requires = [teleport]", + ITEM.to_string(), + "unknown capability", ), + ("speed = fast", ITEM.to_string(), "unknown argument"), ("", ok_fn.to_string(), "doc comment"), ( "", @@ -167,8 +187,14 @@ mod tests { "`#[should_panic]`", ), ] { - let err = expand(attr.parse().unwrap(), item.parse().unwrap()).unwrap_err(); + let err = expand_str(attr, &item).unwrap_err(); assert!(err.to_string().contains(error), "{item}: {err}"); } } + + #[test] + fn names_variants_in_pascal_case() { + assert_eq!(pascal_case("gpu"), "Gpu"); + assert_eq!(pascal_case("persistent_workspace"), "PersistentWorkspace"); + } } diff --git a/tests/testkit/testkit-spec/Cargo.toml b/tests/testkit/testkit-spec/Cargo.toml new file mode 100644 index 0000000000..640503c38a --- /dev/null +++ b/tests/testkit/testkit-spec/Cargo.toml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-testkit-spec" +description = "Parses #[testkit::test] functions for the macro and the test catalog" +version = "0.0.0" +edition = "2024" +rust-version = "1.94" +license = "Apache-2.0" +repository = "https://github.com/NVIDIA/OpenShell" +publish = false + +[lib] +name = "testkit_spec" + +[dependencies] +proc-macro2 = "1" +syn = { version = "2", features = ["full"] } diff --git a/tests/testkit/testkit-spec/src/lib.rs b/tests/testkit/testkit-spec/src/lib.rs new file mode 100644 index 0000000000..562949114c --- /dev/null +++ b/tests/testkit/testkit-spec/src/lib.rs @@ -0,0 +1,403 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The single definition of what a `#[testkit::test]` function declares. +//! +//! The `#[testkit::test]` macro and the test runner's catalog both parse +//! tests through this crate, so they cannot disagree about a test's ID, +//! name, description, or requirements. Tools can list tests with this crate +//! without compiling them. +//! +//! Adapted from the crate of the same name by Evan Lezar +//! (NVIDIA/OpenShell#4629). Unlike there, the ID is optional. + +use std::hash::{BuildHasher, Hasher}; + +use proc_macro2::TokenStream; +use syn::parse::{Parse, ParseStream}; +use syn::punctuated::Punctuated; +use syn::{Attribute, Expr, ExprLit, Ident, Item, ItemFn, Lit, LitStr, Meta, Token, bracketed}; + +/// Prefix of every test ID. +pub const ID_PREFIX: &str = "t-"; +/// Number of random characters after the prefix. +const ID_LEN: usize = 8; +const ID_ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + +/// The capabilities a test can require, by the names used in +/// `requires = [...]`, in the environment definitions, and in +/// `OPENSHELL_TEST_CAPABILITIES`. `testkit::Capability` has a variant for +/// each, and a test in `testkit` checks the two agree. +pub const CAPABILITIES: &[&str] = &[ + "gpu", + "persistent_workspace", + "rootless", + "service_exposure", +]; + +/// What a test declares. +#[derive(Clone, Debug)] +pub struct TestSpec { + /// The stable test ID from `id = "..."`, if the test has one. It never + /// changes, including when the test is renamed or moved to another suite + /// or family. Without one, the function name is the test's identity. + pub id: Option, + /// The function name, which is also the nextest test name. + pub name: Ident, + /// The doc comment, joined into one line. + pub description: String, + /// Capability names, as written in `requires = [...]`; each is in + /// [`CAPABILITIES`]. + pub requires: Vec, + /// Whether the function has `#[ignore]`. nextest skips such a test unless + /// run with `--run-ignored`, so the runner leaves it out of what it expects. + pub ignored: bool, +} + +/// Arguments accepted by `#[testkit::test(...)]`. +#[derive(Default)] +struct Args { + id: Option, + requires: Vec, +} + +impl Parse for Args { + fn parse(input: ParseStream<'_>) -> syn::Result { + let mut args = Self::default(); + while !input.is_empty() { + let key: Ident = input.parse()?; + input.parse::()?; + if key == "id" { + if args.id.is_some() { + return Err(syn::Error::new(key.span(), "duplicate argument `id`")); + } + args.id = Some(input.parse()?); + } else if key == "requires" { + let content; + bracketed!(content in input); + args.requires + .extend(Punctuated::::parse_terminated(&content)?); + } else { + return Err(syn::Error::new( + key.span(), + format!( + "unknown argument `{key}`; expected `id = \"...\"` or `requires = [...]`" + ), + )); + } + if !input.is_empty() { + input.parse::()?; + } + } + Ok(args) + } +} + +/// Parse a test from the attribute's arguments and the function it marks. +/// +/// # Errors +/// +/// Returns an error for a function that is not an `async fn` with exactly one +/// argument, has no doc comment, or whose `id` or `requires` arguments are +/// malformed. +pub fn parse(args: TokenStream, item: &ItemFn) -> syn::Result { + let args: Args = syn::parse2(args)?; + + if item.sig.asyncness.is_none() { + return Err(syn::Error::new_spanned( + item.sig.fn_token, + "testkit tests must be `async fn`", + )); + } + if item.sig.inputs.len() != 1 { + return Err(syn::Error::new_spanned( + &item.sig.inputs, + "testkit tests take exactly one argument: `cx: &mut testkit::Context`", + )); + } + + let description = doc_comment(&item.attrs); + if description.is_empty() { + return Err(syn::Error::new( + item.sig.ident.span(), + "testkit tests need a doc comment describing the behavior they verify", + )); + } + + let id = match args.id { + Some(id) if is_valid_id(&id.value()) => Some(id.value()), + Some(id) => { + return Err(syn::Error::new( + id.span(), + format!( + "test IDs are `{ID_PREFIX}` followed by {ID_LEN} lowercase letters or digits; for a new test use `id = \"{}\"`", + generate_id() + ), + )); + } + None => None, + }; + for capability in &args.requires { + if !CAPABILITIES.contains(&capability.to_string().as_str()) { + return Err(syn::Error::new( + capability.span(), + format!( + "unknown capability `{capability}`; expected one of: {}", + CAPABILITIES.join(", ") + ), + )); + } + } + + Ok(TestSpec { + id, + name: item.sig.ident.clone(), + description, + requires: args.requires, + ignored: item.attrs.iter().any(|attr| attr.path().is_ident("ignore")), + }) +} + +/// Whether `id` has the test ID form: the prefix and lowercase base-36 characters. +#[must_use] +pub fn is_valid_id(id: &str) -> bool { + id.strip_prefix(ID_PREFIX).is_some_and(|rest| { + rest.len() == ID_LEN + && rest + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) + }) +} + +/// A fresh random test ID, offered in the error for an invalid one, and for +/// tools that assign IDs. Uses the standard library's per-process random hash +/// keys, so this crate needs no RNG dependency. +#[must_use] +pub fn generate_id() -> String { + let mut hasher = std::collections::hash_map::RandomState::new().build_hasher(); + hasher.write_u128( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_nanos()), + ); + let mut value = hasher.finish(); + let mut id = String::from(ID_PREFIX); + for _ in 0..ID_LEN { + id.push(char::from(ID_ALPHABET[(value % 36) as usize])); + value /= 36; + } + id +} + +/// Find every `#[testkit::test]` function in a source file. +/// +/// # Errors +/// +/// Returns an error if the file does not parse, or a marked function is not a +/// valid test. +pub fn scan(source: &str) -> syn::Result> { + let file = syn::parse_file(source)?; + let mut tests = Vec::new(); + for item in &file.items { + let Item::Fn(item) = item else { + continue; + }; + let Some(attr) = item.attrs.iter().find(|attr| is_testkit_test(attr)) else { + continue; + }; + let args = match &attr.meta { + Meta::Path(_) => TokenStream::new(), + Meta::List(list) => list.tokens.clone(), + Meta::NameValue(meta) => { + return Err(syn::Error::new_spanned( + meta, + "expected `#[testkit::test]` or `#[testkit::test(...)]`", + )); + } + }; + tests.push(parse(args, item)?); + } + Ok(tests) +} + +fn is_testkit_test(attr: &Attribute) -> bool { + let segments: Vec<_> = attr.path().segments.iter().collect(); + segments.len() == 2 && segments[0].ident == "testkit" && segments[1].ident == "test" +} + +/// The doc comment, joined into one line. Empty if there is none. +#[must_use] +pub fn doc_comment(attrs: &[Attribute]) -> String { + attrs + .iter() + .filter_map(|attr| match &attr.meta { + Meta::NameValue(meta) if meta.path.is_ident("doc") => match &meta.value { + Expr::Lit(ExprLit { + lit: Lit::Str(line), + .. + }) => Some(line.value().trim().to_string()), + _ => None, + }, + _ => None, + }) + .filter(|line| !line.is_empty()) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_str(args: &str, item: &str) -> syn::Result { + parse(args.parse().unwrap(), &syn::parse_str(item).unwrap()) + } + + const ITEM: &str = "/// Doc.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }"; + + #[test] + fn parses_a_test_without_arguments() { + let spec = parse_str( + "", + "/// First line\n/// second line.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap(); + assert_eq!(spec.id, None); + assert_eq!(spec.name, "t"); + assert_eq!(spec.description, "First line second line."); + assert!(spec.requires.is_empty()); + } + + #[test] + fn records_an_ignored_test() { + let spec = parse_str("", "/// Doc.\n#[ignore = \"needs a GPU\"]\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }").unwrap(); + assert!(spec.ignored); + let spec = parse_str( + "", + "/// Doc.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap(); + assert!(!spec.ignored); + } + + #[test] + fn parses_id_and_requirements() { + let spec = parse_str( + "id = \"t-0123abcd\", requires = [service_exposure, gpu]", + ITEM, + ) + .unwrap(); + assert_eq!(spec.id.as_deref(), Some("t-0123abcd")); + assert_eq!(spec.requires, ["service_exposure", "gpu"]); + } + + #[test] + fn rejects_malformed_id() { + for id in [ + "t-0123ABCD", + "t-0123abc", + "x-0123abcd", + "0123abcd", + "t-0123abcde", + ] { + let err = parse_str(&format!("id = \"{id}\""), ITEM).unwrap_err(); + let message = err.to_string(); + assert!(message.contains("test IDs are"), "{id}: {message}"); + let suggested = message.split('"').nth(1).unwrap(); + assert!(is_valid_id(suggested), "{message}"); + } + } + + #[test] + fn rejects_duplicate_id_argument() { + let err = parse_str("id = \"t-0123abcd\", id = \"t-0123abce\"", ITEM).unwrap_err(); + assert!(err.to_string().contains("duplicate argument"), "{err}"); + } + + #[test] + fn rejects_unknown_capability() { + let err = parse_str("requires = [gpus]", ITEM).unwrap_err(); + let message = err.to_string(); + assert!(message.contains("unknown capability `gpus`"), "{message}"); + assert!(message.contains("service_exposure"), "{message}"); + } + + #[test] + fn generated_ids_are_valid_and_distinct() { + let a = generate_id(); + let b = generate_id(); + assert!(is_valid_id(&a), "{a}"); + assert_ne!(a, b); + } + + #[test] + fn rejects_missing_doc_comment() { + let err = parse_str( + "", + "async fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap_err(); + assert!(err.to_string().contains("doc comment"), "{err}"); + } + + #[test] + fn rejects_sync_fn() { + let err = parse_str( + "", + "/// Doc.\nfn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap_err(); + assert!(err.to_string().contains("async fn"), "{err}"); + } + + #[test] + fn rejects_wrong_arity() { + let err = + parse_str("", "/// Doc.\nasync fn t() -> testkit::Result { Ok(()) }").unwrap_err(); + assert!(err.to_string().contains("exactly one argument"), "{err}"); + } + + #[test] + fn rejects_unknown_argument() { + let err = parse_str("timeout = [x]", ITEM).unwrap_err(); + assert!(err.to_string().contains("unknown argument"), "{err}"); + } + + #[test] + fn scans_only_testkit_tests() { + let tests = scan( + " + use testkit::Context; + + /// Plain. + #[testkit::test] + async fn plain(cx: &mut Context) -> testkit::Result { Ok(()) } + + /// With an id. + #[testkit::test(id = \"t-00000001\")] + async fn identified(cx: &mut Context) -> testkit::Result { Ok(()) } + + /// Needs exposure. + #[testkit::test(id = \"t-00000002\", requires = [service_exposure])] + async fn exposed(cx: &mut Context) -> testkit::Result { Ok(()) } + + #[test] + fn not_a_testkit_test() {} + + async fn helper(cx: &mut Context) {} + ", + ) + .unwrap(); + let names: Vec<_> = tests.iter().map(|test| test.name.to_string()).collect(); + assert_eq!(names, ["plain", "identified", "exposed"]); + assert_eq!(tests[0].id, None); + assert_eq!(tests[1].id.as_deref(), Some("t-00000001")); + assert_eq!(tests[2].requires, ["service_exposure"]); + } + + #[test] + fn scan_reports_invalid_tests() { + let err = scan("#[testkit::test]\nasync fn t(cx: &mut Context) {}").unwrap_err(); + assert!(err.to_string().contains("doc comment"), "{err}"); + } +} diff --git a/tests/testkit/testkit/Cargo.toml b/tests/testkit/testkit/Cargo.toml index f33c63674a..d6b7b5f10b 100644 --- a/tests/testkit/testkit/Cargo.toml +++ b/tests/testkit/testkit/Cargo.toml @@ -25,3 +25,6 @@ serde = "1" serde_json = "1" tokio = { version = "1.43", features = ["macros", "process", "rt", "time"] } tonic = { version = "0.14", features = ["tls-native-roots"] } + +[dev-dependencies] +openshell-testkit-spec = { path = "../testkit-spec" } diff --git a/tests/testkit/testkit/src/capabilities.rs b/tests/testkit/testkit/src/capabilities.rs new file mode 100644 index 0000000000..0d4ec2a9df --- /dev/null +++ b/tests/testkit/testkit/src/capabilities.rs @@ -0,0 +1,202 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! What the environment under test provides. +//! +//! Capabilities are data about the environment, not about the gateway, so +//! they do not come from gateway resolution. The runner (or a developer) +//! lists them in `OPENSHELL_TEST_CAPABILITIES`: +//! +//! ```text +//! OPENSHELL_TEST_CAPABILITIES=compute_driver=docker,persistent_workspace,service_exposure +//! ``` +//! +//! Entries are comma-separated. `name` provides a capability, `name=false` +//! does not, and `name=value` records a descriptive value such as +//! `compute_driver=docker` that tests can read with [`Capabilities::get`]. +//! +//! Only a test that declares `#[testkit::test(requires = [...])]` reads +//! them. Such a test fails, rather than passing without testing anything, +//! when the environment does not provide what it requires. The runner leaves +//! those tests out of environments that lack the capability, so reaching one +//! there means the environment was misdescribed. +//! +//! Adapted from the capabilities of the target file in Evan Lezar's +//! NVIDIA/OpenShell#4629. + +use std::collections::BTreeMap; + +/// The variable listing the capabilities the environment provides. +pub const CAPABILITIES_ENV: &str = "OPENSHELL_TEST_CAPABILITIES"; + +/// A capability a test can require with `#[testkit::test(requires = [...])]`. +/// +/// Variants are the snake-case requirement names in CamelCase, so an unknown +/// requirement fails to compile. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Capability { + /// A GPU is available to sandboxes. + Gpu, + /// Files in the sandbox workspace survive stop and start. The docs leave + /// this to the compute driver. + PersistentWorkspace, + /// The compute driver runs without root privileges. + Rootless, + /// Exposed sandbox services are reachable from the test. + ServiceExposure, +} + +impl Capability { + /// Every capability. + pub const ALL: [Self; 4] = [ + Self::Gpu, + Self::PersistentWorkspace, + Self::Rootless, + Self::ServiceExposure, + ]; + + /// The name used in `requires = [...]`, environment definitions and + /// [`CAPABILITIES_ENV`]. + #[must_use] + pub const fn name(self) -> &'static str { + match self { + Self::Gpu => "gpu", + Self::PersistentWorkspace => "persistent_workspace", + Self::Rootless => "rootless", + Self::ServiceExposure => "service_exposure", + } + } +} + +/// The capabilities an environment provides, as listed in +/// [`CAPABILITIES_ENV`]. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Capabilities { + entries: BTreeMap, +} + +impl Capabilities { + /// Read [`CAPABILITIES_ENV`]. `None` when it is not set. + #[must_use] + pub fn from_env() -> Option { + std::env::var(CAPABILITIES_ENV) + .ok() + .map(|value| Self::parse(&value)) + } + + /// Parse the comma-separated form described in the module docs. + #[must_use] + pub fn parse(list: &str) -> Self { + let entries = list + .split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(|entry| match entry.split_once('=') { + Some((name, value)) => (name.trim().to_string(), value.trim().to_string()), + None => (entry.to_string(), "true".to_string()), + }) + .collect(); + Self { entries } + } + + /// Whether the environment provides `capability`. + #[must_use] + pub fn provides(&self, capability: Capability) -> bool { + self.entries + .get(capability.name()) + .is_some_and(|value| value != "false") + } + + /// The value listed for a capability, such as `docker` for + /// `compute_driver`. `true` for a capability listed without a value. + #[must_use] + pub fn get(&self, name: &str) -> Option<&str> { + self.entries.get(name).map(String::as_str) + } +} + +/// Check that the environment provides every capability in `requires`. +/// +/// The caller reads the environment only for a test that has requirements, so +/// a test without them never depends on it. `None` means it was not set. +pub(crate) fn check_requirements( + requires: &[Capability], + provided: Option<&Capabilities>, +) -> Result<(), String> { + if requires.is_empty() { + return Ok(()); + } + let names = requires + .iter() + .filter(|capability| !provided.is_some_and(|provided| provided.provides(**capability))) + .map(|capability| capability.name()) + .collect::>(); + if names.is_empty() { + return Ok(()); + } + let hint = if provided.is_none() { + format!(" ({CAPABILITIES_ENV} is not set)") + } else { + String::new() + }; + Err(format!( + "requires {}, which the environment does not provide{hint}", + names.join(", ") + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_flags_values_and_explicit_false() { + let capabilities = Capabilities::parse( + " compute_driver=docker, persistent_workspace ,gpu=false,,service_exposure=true", + ); + assert_eq!(capabilities.get("compute_driver"), Some("docker")); + assert!(capabilities.provides(Capability::PersistentWorkspace)); + assert!(capabilities.provides(Capability::ServiceExposure)); + assert!(!capabilities.provides(Capability::Gpu)); + assert!(!capabilities.provides(Capability::Rootless)); + assert_eq!(capabilities.get("arch"), None); + } + + #[test] + fn a_test_without_requirements_needs_nothing() { + assert_eq!(check_requirements(&[], None), Ok(())); + } + + #[test] + fn reports_missing_requirements() { + let provided = Capabilities::parse("gpu"); + let result = check_requirements( + &[Capability::Gpu, Capability::ServiceExposure], + Some(&provided), + ); + assert_eq!( + result, + Err("requires service_exposure, which the environment does not provide".to_string()) + ); + assert_eq!( + check_requirements(&[Capability::Gpu], Some(&provided)), + Ok(()) + ); + } + + #[test] + fn an_unset_environment_fails_a_test_that_requires_something() { + let error = check_requirements(&[Capability::Gpu], None).unwrap_err(); + assert!(error.contains(CAPABILITIES_ENV), "{error}"); + assert!(error.contains("gpu"), "{error}"); + } + + #[test] + fn variants_match_the_names_the_macro_accepts() { + let names: Vec<_> = Capability::ALL + .iter() + .map(|capability| capability.name()) + .collect(); + assert_eq!(names, testkit_spec::CAPABILITIES); + } +} diff --git a/tests/testkit/testkit/src/lib.rs b/tests/testkit/testkit/src/lib.rs index 168fdbe894..38ab6ebcac 100644 --- a/tests/testkit/testkit/src/lib.rs +++ b/tests/testkit/testkit/src/lib.rs @@ -20,8 +20,11 @@ //! ``` //! //! Tests find the gateway the same way the `openshell` CLI does (see -//! [`gateway`]). +//! [`gateway`]). A test that needs something only some environments provide +//! declares it with `#[testkit::test(requires = [...])]` (see +//! [`Capability`]); `id = "t-..."` optionally gives a test a stable identity. +mod capabilities; mod cli; mod context; mod error; @@ -29,6 +32,7 @@ pub mod gateway; mod poll; mod run; +pub use capabilities::{CAPABILITIES_ENV, Capabilities, Capability}; pub use cli::{BIN_ENV, CLI_TIMEOUT, Cli, CliOutput}; pub use context::{ Context, DELETE_TIMEOUT, EXEC_TIMEOUT, ExecOutput, READY_TIMEOUT, STOP_TIMEOUT, Sandbox, diff --git a/tests/testkit/testkit/src/run.rs b/tests/testkit/testkit/src/run.rs index 731ead0557..58bd1e541c 100644 --- a/tests/testkit/testkit/src/run.rs +++ b/tests/testkit/testkit/src/run.rs @@ -1,12 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Running one test: find the gateway, run, clean up. +//! Running one test: check requirements, find the gateway, run, clean up. use std::panic::AssertUnwindSafe; use futures::FutureExt; +use crate::capabilities::{Capabilities, Capability, check_requirements}; use crate::cli::Cli; use crate::context::Context; use crate::error::Result; @@ -17,13 +18,20 @@ use crate::gateway::Gateway; pub struct TestInfo { /// The function name, which is also the nextest test name. pub name: &'static str, + /// The stable test ID, if the test declares one. + pub id: Option<&'static str>, pub description: &'static str, + /// Capabilities the environment must provide. + pub requires: &'static [Capability], } /// Run a test body against the gateway found by [`Gateway::from_env`]. /// -/// Cleanup runs whether the body passes, fails, or panics. The test fails if -/// the body fails, or if cleanup fails after the body passed. +/// A test that declares requirements first checks that the environment +/// provides them (see [`Capabilities`]); one without requirements never reads +/// the environment's capabilities. Cleanup runs whether the body passes, +/// fails, or panics. The test fails if the body fails, or if cleanup fails +/// after the body passed. /// /// # Panics /// @@ -33,7 +41,20 @@ where F: AsyncFnOnce(&mut Context) -> Result, { let name = info.name; - eprintln!("[{name}] {}", info.description); + match info.id { + Some(id) => eprintln!("[{name}] {} ({id})", info.description), + None => eprintln!("[{name}] {}", info.description), + } + + // The runner excludes tests the environment can't support. Reaching one + // here means it didn't, so fail rather than report a pass for behavior + // that was never tested. + if !info.requires.is_empty() { + let provided = Capabilities::from_env(); + if let Err(problem) = check_requirements(info.requires, provided.as_ref()) { + panic!("[{name}] {problem}"); + } + } let gateway = Gateway::from_env().unwrap_or_else(|error| panic!("[{name}] {error}")); eprintln!("[{name}] {}", gateway.describe()); diff --git a/tests/testkit/testkit/tests/requirements.rs b/tests/testkit/testkit/tests/requirements.rs new file mode 100644 index 0000000000..13221566aa --- /dev/null +++ b/tests/testkit/testkit/tests/requirements.rs @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! A test that declares `requires` fails when the environment does not +//! provide the capability, before it looks for a gateway. +//! +//! `#[testkit::test]` rejects `#[should_panic]`, so these call `testkit::run` +//! directly and check the panic it raises. They need +//! `OPENSHELL_TEST_CAPABILITIES` to be unset or not to list the capabilities +//! below, which is the case wherever the testkit unit tests run. + +use std::any::Any; +use std::panic::AssertUnwindSafe; + +use futures::FutureExt as _; +use testkit::{Capability, Context, TestInfo}; + +/// The message of a panic raised by `testkit::run`. +fn message(payload: Box) -> String { + match payload.downcast::() { + Ok(message) => *message, + Err(payload) => payload + .downcast_ref::<&str>() + .map(|message| (*message).to_string()) + .unwrap_or_default(), + } +} + +async fn run_requiring(requires: &'static [Capability]) -> String { + let info = TestInfo { + name: "requires", + id: Some("t-0a1b2c3d"), + description: "Declares requirements the environment does not provide.", + requires, + }; + let outcome = AssertUnwindSafe(testkit::run(info, async |_cx: &mut Context| Ok(()))) + .catch_unwind() + .await; + message(outcome.expect_err("a test whose requirements are missing must fail")) +} + +#[tokio::test] +async fn requirement_not_provided_fails_the_test() { + let message = run_requiring(&[Capability::Gpu]).await; + assert!(message.contains("requires gpu"), "{message}"); +} + +#[tokio::test] +async fn every_missing_requirement_is_named() { + let message = + run_requiring(&[Capability::ServiceExposure, Capability::PersistentWorkspace]).await; + assert!( + message.contains("requires service_exposure, persistent_workspace"), + "{message}" + ); +} diff --git a/tests/tmachine/Cargo.lock b/tests/tmachine/Cargo.lock index 9116e42ee8..527613035f 100644 --- a/tests/tmachine/Cargo.lock +++ b/tests/tmachine/Cargo.lock @@ -579,6 +579,7 @@ dependencies = [ "blake3", "clap", "directories", + "libc", "serde", "serde-saphyr", "tempfile", diff --git a/tests/tmachine/Cargo.toml b/tests/tmachine/Cargo.toml index 46358ea4e0..3d6508cf42 100644 --- a/tests/tmachine/Cargo.toml +++ b/tests/tmachine/Cargo.toml @@ -15,9 +15,10 @@ anyhow = { version = "1.0.100", default-features = false, features = ["std"] } blake3 = { version = "1.8.7", default-features = false, features = ["std"] } clap = { version = "4.6.6", default-features = false, features = ["std", "derive", "help", "usage", "color"] } directories = { version = "6.0.0", default-features = false } +libc = { version = "0.2", default-features = false } serde = { version = "1.0.229", default-features = false, features = ["std", "derive"] } serde-saphyr = { version = "1.2.0", default-features = false, features = ["deserialize"] } tempfile = { version = "3.27.0", default-features = false } -tokio = { version = "1.53.1", default-features = false, features = ["macros", "rt", "process", "net", "io-util"] } +tokio = { version = "1.53.1", default-features = false, features = ["macros", "rt", "process", "net", "io-util", "signal", "time"] } [workspace] diff --git a/tests/tmachine/config.nix b/tests/tmachine/config.nix index 522afec29c..81806486de 100644 --- a/tests/tmachine/config.nix +++ b/tests/tmachine/config.nix @@ -4,6 +4,7 @@ { pkgs, toolchains, + rustToolchain, qemuPkgs ? pkgs, firmwarePkgs ? pkgs, }: @@ -242,9 +243,41 @@ let exec ${tmachine}/bin/tmachine --config ${config} "$@" ''; }; + + # Runs test plans with tests/runner (`nix run .#test-runner -- test --plan + # tests/plans/testkit.yaml`). It cross-builds guest test bundles and drives + # tmachine sessions, so it needs the toolchains and the VM tools. The runner + # itself is built by cargo on first use. By Evan Lezar (NVIDIA/OpenShell#4629). + testRunner = pkgs.writeShellApplication { + name = "test-runner"; + runtimeInputs = [ + qemu + pkgs.ansible + pkgs.cargo-nextest + pkgs.coreutils + pkgs.git + pkgs.gnutar + pkgs.sshpass + rustToolchain + ]; + runtimeEnv = + pkgs.lib.foldl' (env: toolchain: env // toolchain.env) { } (builtins.attrValues toolchains) + // { + TMACHINE_CONFIG = "${config}"; + } + // pkgs.lib.optionalAttrs isAarch64 { + TMACHINE_FIRMWARE_CODE = firmwarePkgs.OVMF.firmware; + TMACHINE_FIRMWARE_VARS = firmwarePkgs.OVMF.variables; + }; + text = '' + root=$(git rev-parse --show-toplevel) + export ANSIBLE_CONFIG="$root/tests/tmachine/ansible/ansible.cfg" + exec cargo run --locked --quiet --manifest-path "$root/tests/runner/Cargo.toml" --bin openshell-test -- "$@" + ''; + }; in { package = runner; unwrapped = tmachine; - inherit config; + inherit config testRunner; } diff --git a/tests/tmachine/src/ansible.rs b/tests/tmachine/src/ansible.rs index 868b763d5b..3301934954 100644 --- a/tests/tmachine/src/ansible.rs +++ b/tests/tmachine/src/ansible.rs @@ -4,14 +4,14 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, ensure}; use tokio::process::Command; pub fn requirements_path() -> PathBuf { PathBuf::from(std::env::var("ANSIBLE_CONFIG").unwrap()).with_file_name("requirements.yaml") } -pub async fn install_roles() { +pub async fn install_roles() -> Result<()> { let status = Command::new("ansible-galaxy") .arg("role") .arg("install") @@ -20,13 +20,18 @@ pub async fn install_roles() { .arg(requirements_path()) .status() .await - .unwrap(); + .context("run ansible-galaxy")?; - assert!(status.success()); + ensure!(status.success(), "ansible-galaxy exited with {status}"); + Ok(()) } pub async fn run(playbook: &Path, inputs: &BTreeMap) -> Result<()> { let mut command = Command::new("ansible-playbook"); + // The VM's SSH forward may not be the inventory's default port. + command + .arg("--extra-vars") + .arg(format!("ansible_port={}", crate::ssh_port())); for (name, path) in inputs { let value = match std::fs::canonicalize(path) { Ok(path) => path, @@ -42,8 +47,19 @@ pub async fn run(playbook: &Path, inputs: &BTreeMap) -> Result< .arg(format!("{name}={}", value.display())); } - let status = command.arg(playbook).status().await.unwrap(); + // Stop ansible-playbook if the caller is dropped, for example when the + // runner is cancelled. + command.kill_on_drop(true); + let status = command + .arg(playbook) + .status() + .await + .context("run ansible-playbook")?; - assert!(status.success()); + ensure!( + status.success(), + "ansible-playbook {} exited with {status}", + playbook.display() + ); Ok(()) } diff --git a/tests/tmachine/src/lib.rs b/tests/tmachine/src/lib.rs new file mode 100644 index 0000000000..716b903922 --- /dev/null +++ b/tests/tmachine/src/lib.rs @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Isolated QEMU test machines provisioned with Ansible. +//! +//! The `tmachine` binary drives one-shot `setup`, `install`, and `test` +//! commands. Callers that need to act on a running machine use [`Session`]. +//! A binary that starts machines should call [`cleanup_on_signals`] once, so a +//! `SIGTERM` does not leave QEMU running. +//! +//! The session API is by Evan Lezar (NVIDIA/OpenShell#4629). + +pub mod ansible; +pub mod config; +mod qemu; + +pub use qemu::{DEFAULT_SSH_PORT, Session, cleanup_on_signals, install, setup, ssh_port, test}; diff --git a/tests/tmachine/src/main.rs b/tests/tmachine/src/main.rs index 3c0d1a4eee..c69e3a29e6 100644 --- a/tests/tmachine/src/main.rs +++ b/tests/tmachine/src/main.rs @@ -5,11 +5,8 @@ use std::path::PathBuf; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; -use config::{Config, Environment, Installer, Machine}; - -mod ansible; -mod config; -mod qemu; +use tmachine::config::{Config, Environment, Installer, Machine}; +use tmachine::{install, setup, test}; #[derive(Parser)] #[command(name = "tmachine")] @@ -40,12 +37,14 @@ enum Command { #[tokio::main(flavor = "current_thread")] async fn main() -> Result<()> { let cli = Cli::parse(); + // A SIGTERM from CI cancellation must not leave QEMU holding the SSH port. + tmachine::cleanup_on_signals(); let config = Config::load(&cli.config)?; match cli.command { Command::Setup { environment } => { let (machine, environment) = find_environment(&config, &environment)?; - qemu::setup(&machine, &environment).await?; + setup(&machine, &environment).await?; } Command::Install { environment, @@ -53,7 +52,7 @@ async fn main() -> Result<()> { } => { let (machine, environment) = find_environment(&config, &environment)?; let installer = find_installer(&config, &installer)?; - qemu::install(&machine, &environment, &installer).await?; + install(&machine, &environment, &installer).await?; } Command::Test { environment, @@ -67,7 +66,7 @@ async fn main() -> Result<()> { .iter() .find(|candidate| candidate.name == testsuite) .with_context(|| format!("testsuite {testsuite:?} is not defined"))?; - qemu::test(&machine, &environment, &installer, testsuite).await?; + test(&machine, &environment, &installer, testsuite).await?; } } diff --git a/tests/tmachine/src/qemu/cleanup.rs b/tests/tmachine/src/qemu/cleanup.rs new file mode 100644 index 0000000000..08fd822570 --- /dev/null +++ b/tests/tmachine/src/qemu/cleanup.rs @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Keeping QEMU from outliving tmachine. +//! +//! Dropping a VM handle stops QEMU, but a destructor never runs when the +//! process is killed by a signal. Two mechanisms cover that: +//! +//! - On Linux, QEMU asks the kernel to kill it when its parent dies +//! (`PR_SET_PDEATHSIG`), whatever the reason, including `SIGKILL`. +//! - [`cleanup_on_signals`] kills every tracked QEMU on `SIGTERM`, `SIGINT` +//! and `SIGHUP` and exits, which also covers hosts without that kernel +//! feature. +//! +//! A leaked QEMU holds the SSH forward port, so the next run on that host +//! fails until someone kills it by hand. + +use std::sync::Mutex; + +use tokio::process::Command; +use tokio::signal::unix::{SignalKind, signal}; + +static LIVE: Mutex> = Mutex::new(Vec::new()); + +pub(super) fn track(pid: u32) { + if let Ok(mut live) = LIVE.lock() { + live.push(pid); + } +} + +pub(super) fn forget(pid: u32) { + if let Ok(mut live) = LIVE.lock() { + live.retain(|candidate| *candidate != pid); + } +} + +/// Kill every QEMU this process started and has not yet reaped. +fn kill_all() { + let pids = LIVE.lock().map(|live| live.clone()).unwrap_or_default(); + for pid in pids { + if let Ok(pid) = libc::pid_t::try_from(pid) { + // SAFETY: `kill` has no memory-safety preconditions. A recycled + // PID is not a concern: entries are removed when the VM handle + // that owns the child is dropped, and the child is reaped by it. + unsafe { libc::kill(pid, libc::SIGKILL) }; + } + } +} + +/// Make a child die when this process dies, on platforms that support it. +#[cfg(target_os = "linux")] +pub(super) fn die_with_parent(command: &mut Command) { + // SAFETY: the closure runs between fork and exec and calls only + // `prctl`, which is async-signal-safe. + unsafe { + command.pre_exec(|| { + // PR_SET_PDEATHSIG fires when the thread that spawned the child + // exits, so the runtime must keep its spawning thread alive: + // tmachine's binaries use a current-thread runtime. + if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } +} + +#[cfg(not(target_os = "linux"))] +pub(super) fn die_with_parent(_command: &mut Command) {} + +/// Kill every VM started through tmachine when the process receives +/// `SIGTERM`, `SIGINT` or `SIGHUP`, then exit with the conventional status. +/// +/// Call once from a binary's `main`, inside the Tokio runtime. +pub fn cleanup_on_signals() { + for (kind, number) in [ + (SignalKind::terminate(), libc::SIGTERM), + (SignalKind::interrupt(), libc::SIGINT), + (SignalKind::hangup(), libc::SIGHUP), + ] { + let Ok(mut stream) = signal(kind) else { + eprintln!("tmachine: could not install the signal {number} handler; VMs may leak"); + continue; + }; + tokio::spawn(async move { + stream.recv().await; + kill_all(); + std::process::exit(128 + number); + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tracks_and_forgets_pids() { + // Out of range for a `pid_t`, so a concurrent `kill_all` skips them. + let (kept, dropped) = (u32::MAX - 1, u32::MAX - 2); + track(kept); + track(dropped); + forget(dropped); + let live = LIVE.lock().unwrap().clone(); + assert!(live.contains(&kept)); + assert!(!live.contains(&dropped)); + forget(kept); + } + + #[tokio::test(flavor = "current_thread")] + async fn kill_all_stops_tracked_children_and_handlers_install() { + // Installing handlers must not change behavior until a signal arrives. + cleanup_on_signals(); + let child = Command::new("sleep") + .arg("60") + .kill_on_drop(true) + .spawn() + .unwrap(); + let pid = child.id().unwrap(); + track(pid); + kill_all(); + let mut child = child; + let status = child.wait().await.unwrap(); + assert!(!status.success()); + forget(pid); + } +} diff --git a/tests/tmachine/src/qemu/layer.rs b/tests/tmachine/src/qemu/layer.rs index 9b1273a836..2b7c7c173a 100644 --- a/tests/tmachine/src/qemu/layer.rs +++ b/tests/tmachine/src/qemu/layer.rs @@ -29,7 +29,7 @@ pub(super) async fn cached_layer( } if use_galaxy { - crate::ansible::install_roles().await; + crate::ansible::install_roles().await?; } let temporary_disk = disks_dir.join(format!("{hash}.tmp")); @@ -38,10 +38,12 @@ pub(super) async fn cached_layer( } let image = QemuImage::create(base_image, temporary_disk.clone()).await; - let vm = QemuVm::start(&image).await; + let vm = QemuVm::start(&image).await?; run_playbooks(playbooks, inputs).await?; - vm.shutdown().await; - vm.wait().await; + // A layer is only cached after a clean power-off, so failing here is an + // error rather than something to log and move past. + vm.shutdown().await.context("power off the VM")?; + vm.wait().await?; std::fs::rename(temporary_disk, &disk).unwrap(); Ok(disk) diff --git a/tests/tmachine/src/qemu/mod.rs b/tests/tmachine/src/qemu/mod.rs index c13ebbf56b..4dac339bc4 100644 --- a/tests/tmachine/src/qemu/mod.rs +++ b/tests/tmachine/src/qemu/mod.rs @@ -2,13 +2,18 @@ // SPDX-License-Identifier: Apache-2.0 mod ansible_hash; +mod cleanup; mod img; mod install; mod layer; +mod session; mod setup; mod test; mod vm; +pub use cleanup::cleanup_on_signals; pub use install::install; +pub use session::Session; pub use setup::setup; pub use test::test; +pub use vm::{DEFAULT_SSH_PORT, ssh_port}; diff --git a/tests/tmachine/src/qemu/session.rs b/tests/tmachine/src/qemu/session.rs new file mode 100644 index 0000000000..939b05ce80 --- /dev/null +++ b/tests/tmachine/src/qemu/session.rs @@ -0,0 +1,327 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! A running machine that callers act on over SSH. +//! +//! The session API is by Evan Lezar (NVIDIA/OpenShell#4629). Timeouts on +//! every SSH operation, the configurable SSH port, and a `down` that cannot +//! panic were added when it landed. + +use std::collections::BTreeMap; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::process::{ExitStatus, Output, Stdio}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, bail, ensure}; +use tempfile::{TempDir, tempdir}; +use tokio::process::Command; + +use crate::config::{Environment, Installer, Machine}; + +use super::img::QemuImage; +use super::install::install; +use super::layer::run_playbooks; +use super::vm::{QemuVm, ssh_port}; + +// The port is the QEMU forward in `vm.rs`; `ansible.rs` passes the same port +// to Ansible. +const SSH_HOST: &str = "127.0.0.1"; +const SSH_USER: &str = "tmachine"; +const SSH_PASSWORD: &str = "tmachine"; +// Password authentication only: keys from the caller's agent or ~/.ssh would +// otherwise use up the guest's authentication attempts first. The keep-alive +// options make SSH give up on a guest that stopped answering, after about a +// minute, instead of waiting forever. +const SSH_OPTIONS: &[&str] = &[ + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "LogLevel=ERROR", + "-o", + "ConnectTimeout=5", + "-o", + "ServerAliveInterval=15", + "-o", + "ServerAliveCountMax=4", + "-o", + "PreferredAuthentications=password", + "-o", + "PubkeyAuthentication=no", + "-o", + "IdentityAgent=none", +]; +const SSH_READY_TIMEOUT: Duration = Duration::from_secs(300); +const SSH_READY_INTERVAL: Duration = Duration::from_secs(2); +/// How long one probe for SSH readiness may take. +const SSH_PROBE_TIMEOUT: Duration = Duration::from_secs(30); + +/// The longest [`Session::exec`] and the file copies wait for a command. +/// Use [`Session::exec_timeout`] for commands that run longer, such as a +/// whole test suite. +pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(600); + +/// A machine booted from a throwaway overlay of the installed disk. +/// +/// The VM stops when the session is dropped, so a caller that fails or +/// panics cannot leak it. Call [`Session::down`] for a clean shutdown. +pub struct Session { + // Declared before `_dir` so the VM stops before its disk is removed. + vm: Option, + _image: QemuImage, + _dir: TempDir, +} + +impl Session { + /// Set up and install the environment (reusing cached disks), boot a VM + /// from a throwaway overlay, and wait until it accepts SSH. + pub async fn up( + machine: &Machine, + environment: &Environment, + installer: &Installer, + ) -> Result { + let install_disk = install(machine, environment, installer).await?; + let dir = tempdir().context("create session directory")?; + let image = QemuImage::create(&install_disk, dir.path().join("session.qcow2")).await; + let vm = QemuVm::start(&image).await?; + let mut session = Self { + vm: Some(vm), + _image: image, + _dir: dir, + }; + session.wait_for_ssh().await?; + Ok(session) + } + + async fn wait_for_ssh(&mut self) -> Result<()> { + let started = Instant::now(); + loop { + if let Some(status) = self.vm.as_mut().and_then(QemuVm::try_wait) { + bail!( + "QEMU exited with {status} before SSH was ready; is another tmachine VM using port {}?", + ssh_port() + ); + } + match self.exec_timeout(&["true"], SSH_PROBE_TIMEOUT).await { + Ok(output) if output.status.success() => return Ok(()), + Ok(_) | Err(_) if started.elapsed() < SSH_READY_TIMEOUT => { + tokio::time::sleep(SSH_READY_INTERVAL).await; + } + Ok(output) => bail!( + "SSH was not ready after {SSH_READY_TIMEOUT:?}: {}", + String::from_utf8_lossy(&output.stderr).trim() + ), + Err(error) => { + return Err( + error.context(format!("SSH was not ready after {SSH_READY_TIMEOUT:?}")) + ); + } + } + } + } + + /// Run Ansible playbooks against the machine. + pub async fn run_playbooks( + &self, + playbooks: &[PathBuf], + inputs: &BTreeMap, + ) -> Result<()> { + run_playbooks(playbooks, inputs).await + } + + /// Run a command in the machine as the `tmachine` user and capture its + /// output, giving up after [`DEFAULT_TIMEOUT`]. A non-zero exit is + /// returned in the output, not as an error. + pub async fn exec(&self, argv: &[&str]) -> Result { + self.exec_timeout(argv, DEFAULT_TIMEOUT).await + } + + /// Like [`Session::exec`], with an explicit timeout. On timeout the SSH + /// client is killed, which ends the remote command with it, and the + /// result is an error. + pub async fn exec_timeout(&self, argv: &[&str], timeout: Duration) -> Result { + ensure!(!argv.is_empty(), "exec needs a command"); + let remote = argv + .iter() + .map(|arg| shell_quote(arg)) + .collect::>() + .join(" "); + let mut command = ssh_command("ssh", &["-p", &ssh_port().to_string()]); + command + .arg(format!("{SSH_USER}@{SSH_HOST}")) + .arg("--") + .arg(remote) + .stdin(Stdio::null()); + output_within(command, timeout) + .await + .with_context(|| format!("run {argv:?} in tmachine VM")) + } + + /// Copy a local file or directory into the machine. + pub async fn copy_in(&self, src: &Path, dst: &str) -> Result<()> { + let remote = format!("{SSH_USER}@{SSH_HOST}:{dst}"); + scp(src.as_os_str().to_string_lossy().as_ref(), &remote) + .await + .with_context(|| format!("copy {} to {dst} in tmachine VM", src.display())) + } + + /// Copy a file or directory out of the machine. + pub async fn copy_out(&self, src: &str, dst: &Path) -> Result<()> { + let remote = format!("{SSH_USER}@{SSH_HOST}:{src}"); + scp(&remote, dst.as_os_str().to_string_lossy().as_ref()) + .await + .with_context(|| format!("copy {src} from tmachine VM to {}", dst.display())) + } + + /// Write the OpenShell gateway's journal to `dir/gateway.log`. + pub async fn collect_diagnostics(&self, dir: &Path) -> Result<()> { + std::fs::create_dir_all(dir) + .with_context(|| format!("create diagnostics directory {}", dir.display()))?; + let output = self + .exec(&[ + "sudo", + "-n", + "journalctl", + "--no-pager", + "--lines", + "2000", + "_SYSTEMD_UNIT=openshell-gateway.service", + "+", + "_SYSTEMD_USER_UNIT=openshell-gateway.service", + ]) + .await?; + let log = dir.join("gateway.log"); + let mut contents = output.stdout; + contents.extend_from_slice(&output.stderr); + std::fs::write(&log, contents).with_context(|| format!("write {}", log.display())) + } + + /// Open an interactive SSH shell in the machine. + pub async fn shell(&self) -> Result { + ssh_command("ssh", &["-tt", "-p", &ssh_port().to_string()]) + .arg(format!("{SSH_USER}@{SSH_HOST}")) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .await + .context("open SSH shell in tmachine VM") + } + + /// Power the machine off and wait for QEMU to exit. + /// + /// This never panics and never fails: teardown has no useful recovery, + /// so a guest that does not power off cleanly is killed and the problem + /// is logged to stderr. + pub async fn down(mut self) { + if let Some(vm) = self.vm.take() { + vm.power_off_or_kill().await; + } + } +} + +fn ssh_command(program: &str, args: &[&str]) -> Command { + let mut command = Command::new("sshpass"); + command + .env("SSHPASS", SSH_PASSWORD) + .arg("-e") + .arg(program) + .args(args) + .args(SSH_OPTIONS); + // Dropping the future that waits for the process, as a timeout does, stops it. + command.kill_on_drop(true); + command +} + +/// Run a command to completion and capture its output, or give up after +/// `timeout` and kill it. +async fn output_within(mut command: Command, timeout: Duration) -> Result { + bounded(timeout, command.output()).await?.context("run") +} + +/// Wait for `work`, or fail after `timeout`. +async fn bounded(timeout: Duration, work: impl Future) -> Result { + match tokio::time::timeout(timeout, work).await { + Ok(value) => Ok(value), + Err(_) => bail!("timed out after {timeout:?}"), + } +} + +async fn scp(src: &str, dst: &str) -> Result<()> { + let mut command = ssh_command("scp", &["-r", "-P", &ssh_port().to_string()]); + command.arg(src).arg(dst).stdin(Stdio::null()); + let output = output_within(command, DEFAULT_TIMEOUT) + .await + .context("run scp")?; + ensure!( + output.status.success(), + "scp exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + Ok(()) +} + +/// Quote an argument for the remote POSIX shell that SSH runs commands in. +fn shell_quote(arg: &str) -> String { + let safe = !arg.is_empty() + && arg + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%".contains(&b)); + if safe { + arg.to_string() + } else { + format!("'{}'", arg.replace('\'', r"'\''")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn leaves_plain_arguments_unquoted() { + assert_eq!(shell_quote("cargo-nextest"), "cargo-nextest"); + assert_eq!(shell_quote("/var/lib/x.tar.zst"), "/var/lib/x.tar.zst"); + assert_eq!(shell_quote("KEY=value"), "KEY=value"); + } + + #[test] + fn quotes_shell_metacharacters() { + assert_eq!( + shell_quote("binary(a) | binary(b)"), + "'binary(a) | binary(b)'" + ); + assert_eq!(shell_quote("$HOME"), "'$HOME'"); + assert_eq!(shell_quote(""), "''"); + } + + #[test] + fn escapes_single_quotes() { + assert_eq!(shell_quote("it's"), r"'it'\''s'"); + } + + #[tokio::test(flavor = "current_thread")] + async fn a_command_that_outlives_its_timeout_is_an_error_and_is_killed() { + let mut command = Command::new("sleep"); + command.arg("60").kill_on_drop(true); + let started = Instant::now(); + let error = output_within(command, Duration::from_millis(100)) + .await + .unwrap_err(); + assert!(format!("{error:#}").contains("timed out"), "{error:#}"); + assert!(started.elapsed() < Duration::from_secs(5)); + } + + #[tokio::test(flavor = "current_thread")] + async fn a_command_within_its_timeout_returns_its_output() { + let mut command = Command::new("echo"); + command.arg("hi"); + let output = output_within(command, Duration::from_secs(10)) + .await + .unwrap(); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "hi"); + } +} diff --git a/tests/tmachine/src/qemu/test.rs b/tests/tmachine/src/qemu/test.rs index ac8a8bdfc0..cd4cb95d48 100644 --- a/tests/tmachine/src/qemu/test.rs +++ b/tests/tmachine/src/qemu/test.rs @@ -1,18 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use std::process::Stdio; - -use anyhow::{Context, Result}; -use tempfile::tempdir; -use tokio::process::Command; +use anyhow::Result; use crate::config::{Environment, Installer, Machine, Testsuite}; -use super::img::QemuImage; -use super::install::install; -use super::layer::run_playbooks; -use super::vm::QemuVm; +use super::session::Session; pub async fn test( machine: &Machine, @@ -20,46 +13,20 @@ pub async fn test( installer: &Installer, testsuite: &Testsuite, ) -> Result<()> { - let install_disk = install(machine, environment, installer).await?; - let test_dir = tempdir().unwrap(); - let test_disk = test_dir.path().join("test.qcow2"); - let image = QemuImage::create(&install_disk, test_disk).await; - let vm = QemuVm::start(&image).await; - - run_playbooks(&testsuite.playbooks, &testsuite.inputs).await?; + let session = Session::up(machine, environment, installer).await?; + session + .run_playbooks(&testsuite.playbooks, &testsuite.inputs) + .await?; if testsuite.interactive { println!("Opening an SSH shell in the tmachine VM."); - let ssh_status = Command::new("sshpass") - .env("SSHPASS", "tmachine") - .args([ - "-e", - "ssh", - "-tt", - "-p", - "2222", - "-o", - "StrictHostKeyChecking=no", - "-o", - "UserKnownHostsFile=/dev/null", - "-o", - "LogLevel=ERROR", - "tmachine@127.0.0.1", - ]) - .stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .status() - .await - .context("open SSH shell in tmachine VM")?; - - vm.shutdown().await; - vm.wait().await; + let ssh_status = session.shell().await; + session.down().await; + let ssh_status = ssh_status?; anyhow::ensure!(ssh_status.success(), "SSH shell exited with {ssh_status}"); return Ok(()); } - vm.shutdown().await; - vm.wait().await; + session.down().await; Ok(()) } diff --git a/tests/tmachine/src/qemu/vm.rs b/tests/tmachine/src/qemu/vm.rs index 1e035632e2..379ebc6b94 100644 --- a/tests/tmachine/src/qemu/vm.rs +++ b/tests/tmachine/src/qemu/vm.rs @@ -3,82 +3,197 @@ #[cfg(target_arch = "aarch64")] use std::fs; +use std::net::{Ipv4Addr, TcpListener}; #[cfg(target_arch = "aarch64")] use std::os::unix::fs::PermissionsExt; use std::path::Path; use std::path::PathBuf; use std::process::Stdio; +use std::time::Duration; +use anyhow::{Context, Result, bail, ensure}; use tempfile::{TempDir, tempdir}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; use tokio::process::{Child, Command}; +use super::cleanup; use super::img::QemuImage; +/// The host port forwarded to the guest's SSH server unless +/// `TMACHINE_SSH_PORT` says otherwise. +pub const DEFAULT_SSH_PORT: u16 = 2222; + +/// How long a powered-off guest has to exit before QEMU is killed. +const POWER_OFF_TIMEOUT: Duration = Duration::from_secs(120); + +/// The host port forwarded to the guest's SSH server. +/// +/// Concurrent tmachine runs on one host need different ports; set +/// `TMACHINE_SSH_PORT` to run them side by side. +pub fn ssh_port() -> u16 { + parse_ssh_port(std::env::var("TMACHINE_SSH_PORT").ok().as_deref()) +} + +fn parse_ssh_port(value: Option<&str>) -> u16 { + value + .and_then(|value| value.trim().parse().ok()) + .filter(|port| *port != 0) + .unwrap_or(DEFAULT_SSH_PORT) +} + pub(super) struct QemuVm { child: Child, runtime_dir: TempDir, + pid: Option, +} + +impl Drop for QemuVm { + fn drop(&mut self) { + // `kill_on_drop` stops QEMU; stop tracking it for signal cleanup. + if let Some(pid) = self.pid.take() { + cleanup::forget(pid); + } + } } impl QemuVm { - pub(super) async fn start(image: &QemuImage) -> Self { - let runtime_dir = tempdir().unwrap(); + pub(super) async fn start(image: &QemuImage) -> Result { + let port = ssh_port(); + // QEMU reports a taken port only by exiting, which is slow to notice + // and hard to diagnose. + TcpListener::bind((Ipv4Addr::LOCALHOST, port)).with_context(|| { + format!( + "host port {port} is not available for the VM's SSH forward; is another tmachine VM running? Set TMACHINE_SSH_PORT to use another port" + ) + })?; + + let runtime_dir = tempdir().context("create VM runtime directory")?; let qmp_socket = runtime_dir.path().join("qmp.sock"); - assert!( + ensure!( qmp_socket.as_os_str().as_encoded_bytes().len() < 108, "QMP socket path is too long: {} (set TMPDIR to a shorter path)", qmp_socket.display() ); let firmware_vars = prepare_firmware(runtime_dir.path()); - let child = command(image, &qmp_socket, firmware_vars.as_deref()) + let child = command(image, &qmp_socket, firmware_vars.as_deref(), port) .spawn() - .unwrap(); + .context("start QEMU")?; + let pid = child.id(); + if let Some(pid) = pid { + cleanup::track(pid); + } - Self { child, runtime_dir } + Ok(Self { + child, + runtime_dir, + pid, + }) } - pub(super) async fn wait(mut self) { - let status = self.child.wait().await.unwrap(); - assert!(status.success()); + /// The exit status, if QEMU has already exited. + pub(super) fn try_wait(&mut self) -> Option { + self.child.try_wait().ok().flatten() } - pub(super) async fn shutdown(&self) { + /// Wait for QEMU to exit after a power-off request, and check it exited + /// cleanly. QEMU is killed if it does not exit in time. + pub(super) async fn wait(mut self) -> Result<()> { + match tokio::time::timeout(POWER_OFF_TIMEOUT, self.child.wait()).await { + Ok(status) => { + let status = status.context("wait for QEMU")?; + ensure!(status.success(), "QEMU exited with {status}"); + Ok(()) + } + Err(_) => { + self.kill().await; + bail!( + "QEMU did not exit within {POWER_OFF_TIMEOUT:?} of the power-off request; killed it" + ) + } + } + } + + /// Stop QEMU immediately and reap it. + pub(super) async fn kill(&mut self) { + let _ = self.child.kill().await; + } + + /// Ask the guest to power off over QMP. + pub(super) async fn shutdown(&self) -> Result<()> { let stream = UnixStream::connect(self.runtime_dir.path().join("qmp.sock")) .await - .unwrap(); + .context("connect to QMP")?; let (reader, mut writer) = stream.into_split(); let mut reader = BufReader::new(reader); let mut response = String::new(); - reader.read_line(&mut response).await.unwrap(); + reader + .read_line(&mut response) + .await + .context("read QMP greeting")?; writer .write_all(b"{\"execute\":\"qmp_capabilities\"}\r\n") .await - .unwrap(); + .context("negotiate QMP capabilities")?; response.clear(); - reader.read_line(&mut response).await.unwrap(); - assert!(response.contains("\"return\"")); + reader + .read_line(&mut response) + .await + .context("read QMP capabilities response")?; + ensure!( + response.contains("\"return\""), + "unexpected QMP response: {response}" + ); writer .write_all(b"{\"execute\":\"system_powerdown\"}\r\n") .await - .unwrap(); + .context("send system_powerdown")?; loop { response.clear(); - let bytes_read = reader.read_line(&mut response).await.unwrap(); - assert_ne!(bytes_read, 0); + let bytes_read = reader + .read_line(&mut response) + .await + .context("read system_powerdown response")?; + ensure!( + bytes_read != 0, + "QMP closed before system_powerdown returned" + ); if response.contains("\"return\"") { - break; + return Ok(()); + } + } + } + + /// Power the guest off and wait for QEMU to exit. Never panics: a guest + /// that can't be powered off cleanly is killed, and the problem is + /// logged. Use this when tearing down, where there is nothing left to do + /// about a failure. + pub(super) async fn power_off_or_kill(mut self) { + match self.shutdown().await { + Ok(()) => { + if let Err(error) = self.wait().await { + eprintln!("tmachine: {error:#}"); + } + } + Err(error) => { + eprintln!("tmachine: could not power off the VM cleanly, killing it: {error:#}"); + self.kill().await; } } } } -fn command(image: &QemuImage, qmp_socket: &Path, firmware_vars: Option<&Path>) -> Command { +fn command( + image: &QemuImage, + qmp_socket: &Path, + firmware_vars: Option<&Path>, + ssh_port: u16, +) -> Command { let qemu = if cfg!(target_arch = "aarch64") { "qemu-system-aarch64" } else { @@ -117,8 +232,10 @@ fn command(image: &QemuImage, qmp_socket: &Path, firmware_vars: Option<&Path>) - .arg(format!("unix:{},server=on,wait=off", qmp_socket.display())); if let Some(firmware_vars) = firmware_vars { - let firmware_code = option_env!("TMACHINE_FIRMWARE_CODE") - .expect("TMACHINE_FIRMWARE_CODE must be set for ARM builds"); + let firmware_code = firmware_path( + "TMACHINE_FIRMWARE_CODE", + option_env!("TMACHINE_FIRMWARE_CODE"), + ); command .arg("-drive") .arg(format!( @@ -142,7 +259,7 @@ fn command(image: &QemuImage, qmp_socket: &Path, firmware_vars: Option<&Path>) - .arg("-device") .arg("virtio-rng-pci") .arg("-netdev") - .arg("user,id=net0,hostfwd=tcp:127.0.0.1:2222-:22") + .arg(format!("user,id=net0,hostfwd=tcp:127.0.0.1:{ssh_port}-:22")) .arg("-device") .arg("virtio-net-pci,netdev=net0"); @@ -150,15 +267,30 @@ fn command(image: &QemuImage, qmp_socket: &Path, firmware_vars: Option<&Path>) - .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); + // Dropping the handle stops QEMU; the parent-death signal covers the + // cases where no destructor runs. command.kill_on_drop(true); + cleanup::die_with_parent(&mut command); command } +/// A firmware path from the runtime environment, falling back to the value +/// compiled in. The fallback lets library users built outside Nix (such as +/// the test runner) supply firmware at runtime. +fn firmware_path(name: &str, compiled: Option<&'static str>) -> String { + std::env::var(name) + .ok() + .or_else(|| compiled.map(str::to_string)) + .unwrap_or_else(|| panic!("{name} must be set for ARM builds")) +} + fn prepare_firmware(runtime_dir: &Path) -> Option { #[cfg(target_arch = "aarch64")] { - let template = option_env!("TMACHINE_FIRMWARE_VARS") - .expect("TMACHINE_FIRMWARE_VARS must be set for ARM builds"); + let template = firmware_path( + "TMACHINE_FIRMWARE_VARS", + option_env!("TMACHINE_FIRMWARE_VARS"), + ); let firmware_vars = runtime_dir.join("firmware-vars.fd"); fs::copy(template, &firmware_vars).unwrap(); fs::set_permissions(&firmware_vars, fs::Permissions::from_mode(0o600)).unwrap(); @@ -171,3 +303,18 @@ fn prepare_firmware(runtime_dir: &Path) -> Option { None } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ssh_port_defaults_and_overrides() { + assert_eq!(parse_ssh_port(None), DEFAULT_SSH_PORT); + assert_eq!(parse_ssh_port(Some("2300")), 2300); + assert_eq!(parse_ssh_port(Some(" 2301 ")), 2301); + assert_eq!(parse_ssh_port(Some("0")), DEFAULT_SSH_PORT); + assert_eq!(parse_ssh_port(Some("not-a-port")), DEFAULT_SSH_PORT); + assert_eq!(parse_ssh_port(Some("70000")), DEFAULT_SSH_PORT); + } +} diff --git a/tests/waivers.yaml b/tests/waivers.yaml new file mode 100644 index 0000000000..429adf2a59 --- /dev/null +++ b/tests/waivers.yaml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Known bugs that skip a test in some environments. See TESTING.md. +# +# waivers: +# - test: t-xxxxxxxx # the test's stable ID, or its function name if unique +# environments: { runtime: podman-rootless } # omit for all environments +# issue: https://github.com/NVIDIA/OpenShell/issues/NNNN +# reason: what fails and why +waivers: [] From 2353b2f9c886d11f907223322484d682c7cf9e01 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Wed, 21 Oct 2026 13:33:06 -0700 Subject: [PATCH 10/10] ci(tests): check lane test counts and Branch E2E against the plan The drift check now also covers Branch E2E, and compares each testsuite's `suite_expected_tests` in tests/tmachine/config.nix with the number of tests the plan expects, using tests/expected-matrix.json. Raising a count in one place without the other fails the check. Document why persistent_workspace_survives_stop_start doesn't declare `requires`: the tmachine playbooks don't pass capabilities yet. Signed-off-by: Matthew Grossman --- tasks/scripts/check_plan_workflow_matrix.py | 61 ++++++++++++++++++++- tests/suites/sandbox/README.md | 4 ++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/tasks/scripts/check_plan_workflow_matrix.py b/tasks/scripts/check_plan_workflow_matrix.py index 0a09060dea..643bec7a6d 100644 --- a/tasks/scripts/check_plan_workflow_matrix.py +++ b/tasks/scripts/check_plan_workflow_matrix.py @@ -13,7 +13,9 @@ `testsuite` by hand. tests/plans/testkit.yaml says which suites run in which environments, and tests/environments/*.yaml say how each environment is provisioned. This check derives the lane triples from the plan and fails when a -workflow matrix lists something different, so the two cannot drift apart. +workflow matrix lists something different, so the two cannot drift apart. It also +checks that each testsuite's `suite_expected_tests` in tests/tmachine/config.nix equals the +number of tests the plan expects, using tests/expected-matrix.json. Each plan entry's suite maps to a tmachine testsuite name below. """ @@ -21,6 +23,7 @@ from __future__ import annotations import json +import re import subprocess import sys from pathlib import Path @@ -28,9 +31,12 @@ import yaml PLAN = "tests/plans/testkit.yaml" +CONFIG = "tests/tmachine/config.nix" +EXPECTED = "tests/expected-matrix.json" WORKFLOWS = ( ".github/workflows/release-qualification.yml", ".github/workflows/release-dev.yml", + ".github/workflows/branch-e2e.yml", ) # (plan family, suite or None for the family default) -> tmachine testsuite. TESTSUITES = { @@ -60,7 +66,53 @@ def environments(base: Path) -> dict[str, tuple[str, str]]: return found +def selects(testsuite: str, test: str) -> bool: + """Whether a catalog entry `family/suite/test` belongs to a testsuite.""" + family, suite, _ = test.split("/", 2) + if testsuite == "oci-image": + return (family, suite) == ("sandbox", "oci-image") + if testsuite == "sandbox": + return family == "sandbox" and suite != "oci-image" + if testsuite == "provider-refresh": + return family == "providers" + return False + + +def configured_counts(base: Path) -> dict[str, int]: + """`suite_expected_tests` for each testsuite in tests/tmachine/config.nix.""" + text = (base / CONFIG).read_text() + counts = {} + for match in re.finditer(r'name = "([^"]+)";', text): + rest = text[match.end() :] + end = re.search(r'\n \{', rest) + block = rest[: end.start()] if end else rest + count = re.search(r'suite_expected_tests = "(\d+)"', block) + if count: + counts[match.group(1)] = int(count.group(1)) + return counts + + +def count_problems(base: Path) -> list[str]: + """Each lane's `suite_expected_tests` must equal what the plan expects.""" + tests = json.loads((base / EXPECTED).read_text())["plans"]["testkit"]["tests"] + configured = configured_counts(base) + problems = [] + for (environment, installer, testsuite), env_id in sorted(plan_lanes(base)): + expected = sum(selects(testsuite, test) for test in tests[env_id]) + if configured.get(testsuite) != expected: + problems.append( + f"error: {CONFIG} sets suite_expected_tests={configured.get(testsuite)} for " + f"{testsuite}, but {PLAN} expects {expected} tests in {environment}" + ) + return problems + + def from_plan(base: Path) -> set[tuple[str, str, str]]: + return {triple for triple, _ in plan_lanes(base)} + + +def plan_lanes(base: Path) -> set[tuple[tuple[str, str, str], str]]: + """Each lane as ((environment, installer, testsuite), plan environment id).""" envs = environments(base) plan = yaml.safe_load((base / PLAN).read_text()) triples = set() @@ -75,7 +127,7 @@ def from_plan(base: Path) -> set[tuple[str, str, str]]: raise SystemExit(f"error: no tmachine testsuite for {family} {suites}") for env_id in entry["environments"]: environment, installer = envs[env_id] - triples.add((environment, installer, name)) + triples.add(((environment, installer, name), env_id)) return triples @@ -107,7 +159,10 @@ def main() -> int: for triple in sorted(missing): print(f"error: {workflow} is missing {triple} from {PLAN}", file=sys.stderr) failed |= bool(extra or missing) - return 1 if failed else 0 + problems = count_problems(base) + for problem in problems: + print(problem, file=sys.stderr) + return 1 if failed or problems else 0 if __name__ == "__main__": diff --git a/tests/suites/sandbox/README.md b/tests/suites/sandbox/README.md index c23c0f7e6d..e9db836d2a 100644 --- a/tests/suites/sandbox/README.md +++ b/tests/suites/sandbox/README.md @@ -42,6 +42,10 @@ leave driver behavior alone. - If a behavior is left to the driver, declare it with `#[testkit::test(requires = [...])]`. The runner supplies the environment's capabilities, and the test fails if one is missing. + The tmachine playbooks and `mise run e2e:testkit` don't supply capabilities + yet, so a test with `requires` fails there. That is why + `persistent_workspace_survives_stop_start` doesn't declare + `persistent_workspace`; add it when the runner drives those lanes. - Use the CLI for upload, download, connect and forward. Rely on its exit code and `--output json`. - Add a testkit helper instead of calling raw gRPC from a test.