diff --git a/.agents/skills/watch-github-actions/SKILL.md b/.agents/skills/watch-github-actions/SKILL.md index d1f00347ad..c86c8688c3 100644 --- a/.agents/skills/watch-github-actions/SKILL.md +++ b/.agents/skills/watch-github-actions/SKILL.md @@ -127,6 +127,8 @@ gh run list --json databaseId,status,headBranch,url --jq '.[] | {id: .databaseId For tmachine Kubernetes conformance failures, inspect the `tmachine-diagnostics-*` artifact. The K3s report includes Agent Sandbox controller state, leader leases, events, and current and previous controller logs. The conformance playbook checks reconciliation after guest boot with a temporary scheduling-gated Sandbox probe before running tests. Distinguish a failed controller probe from a failed conformance scenario; gateway connectivity and controller Deployment availability do not prove reconciliation works. +For test plan lanes (`plan-cpu-tests` and other `e2e-suites.yml` callers), start with the runner's `Summary` block in the job log, which gives PASS, FAIL or ERROR per environment and family. Then inspect the `e2e-results--` artifact: `suites.json` lists what the runner selected, and `//` holds `junit.xml`, `nextest.log` and, after a failure, the guest's `diagnostics/`. + `Pre-release` temporarily omits Codex Security from its `Security Scan` job. `Release` uses the passing prerelease qualification result. Other scanners and qualification gates still run. Use the standalone diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index 218d3d2657..f5af915600 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -222,6 +222,9 @@ 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/conformance/Cargo.toml -p sandbox -- --check cargo-lockfiles: name: Cargo lockfiles @@ -239,7 +242,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 }}) @@ -279,6 +284,9 @@ 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/conformance/Cargo.toml -p sandbox --all-targets -- -D warnings rust-test: name: Rust test (${{ matrix.system }}) @@ -328,6 +336,8 @@ 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 rust-postgres-test: name: Rust PostgreSQL tests diff --git a/.github/workflows/branch-e2e.yml b/.github/workflows/branch-e2e.yml index db4c96b936..ca1a8f8b7f 100644 --- a/.github/workflows/branch-e2e.yml +++ b/.github/workflows/branch-e2e.yml @@ -303,6 +303,22 @@ jobs: {"environment":"fedora-podman-rootless","installer":"binaries","testsuite":"driver-podman"} ] + # Run the tests in tests/plans/cpu-tests.yaml. The plan decides which suites + # run in which environments, so adding coverage means editing the plan, not + # this workflow. Not yet in core-e2e-result.needs: it starts out advisory. + plan-cpu-tests: + needs: [pr_metadata, prepare-integration] + if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true' + permissions: + actions: read + contents: read + packages: read + uses: ./.github/workflows/e2e-suites.yml + with: + plan: cpu-tests + source-sha: ${{ needs.prepare-integration.outputs.source_sha }} + integration-inputs-artifact-id: ${{ needs.prepare-integration.outputs.integration_inputs_artifact_id }} + docker-e2e: needs: [pr_metadata, build-binaries, build-images] if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true' diff --git a/.github/workflows/e2e-suites.yml b/.github/workflows/e2e-suites.yml new file mode 100644 index 0000000000..0826b5cc73 --- /dev/null +++ b/.github/workflows/e2e-suites.yml @@ -0,0 +1,156 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Runs a test plan (tests/plans/.yaml) on CI. The plan decides which +# suites run in which environments; this workflow keeps no list of either. +# `openshell-test-runner test --emit-github-matrix` turns the plan into one job per +# environment, and each job runs the plan's tests in that environment with +# `openshell-test-runner test --env --strict`. +# +# Modeled on the E2E Suites workflow in NVIDIA/OpenShell#4629 by Evan Lezar. + +name: E2E Suites + +on: + workflow_call: + inputs: + plan: + description: Plan to run, the file stem of a plan in tests/plans/ + required: true + type: string + source-sha: + description: Source revision of the prepared candidate artifacts + required: true + type: string + integration-inputs-artifact-id: + description: Immutable ID of the prepared integration inputs artifact + required: true + type: string + +permissions: + actions: read + contents: read + packages: read + +# github.workflow is the caller's name here, so scope the group to this plan to +# avoid colliding with (and cancelling) the calling workflow's other jobs. +concurrency: + group: ${{ github.workflow }}-e2e-suites-${{ inputs.plan }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + plan: + name: Plan ${{ inputs.plan }} + runs-on: ubuntu-24.04 + timeout-minutes: 30 + outputs: + matrix: ${{ steps.matrix.outputs.matrix }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs['source-sha'] }} + persist-credentials: false + + - uses: ./.github/actions/setup-nix + with: + prepare-shell: "true" + + - name: Generate the job matrix from the plan + id: matrix + shell: nix develop -c bash -euo pipefail {0} + env: + PLAN: ${{ inputs.plan }} + run: | + matrix=$(cargo run --locked --quiet \ + --manifest-path tests/runner/Cargo.toml --bin openshell-test-runner -- \ + test --plan "tests/plans/${PLAN}.yaml" --emit-github-matrix) + echo "$matrix" + echo "matrix=${matrix}" >> "$GITHUB_OUTPUT" + + suites: + name: E2E suites (${{ matrix.plan }}, ${{ matrix.environment }}) + needs: plan + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 90 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs['source-sha'] }} + persist-credentials: false + + - name: Enable KVM access + run: | + set -euo pipefail + if [[ ! -c /dev/kvm ]]; then + echo "::error::The runner did not expose /dev/kvm" + exit 1 + fi + sudo chmod 0666 /dev/kvm + exec 3<>/dev/kvm + exec 3>&- + + - uses: ./.github/actions/setup-nix + + # integration-runner.yml skips the disk cache for ubuntu-k3s and doesn't say + # why, so the k3s environments skip it here too until that is understood. + - name: Cache tmachine disks + if: ${{ !contains(matrix.environment, '.k3s.') }} + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: ~/.cache/tmachine + # 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 environment and runner. + key: tmachine-${{ matrix.environment }}-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('tests/tmachine/ansible/**', 'tests/tmachine/images.nix') }} + restore-keys: | + tmachine-${{ matrix.environment }}-${{ runner.os }}-${{ runner.arch }}- + + - name: Download integration inputs + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ inputs['integration-inputs-artifact-id'] }} + path: artifacts + + - name: Run the plan in this environment + env: + PLAN: ${{ matrix.plan }} + ENVIRONMENT: ${{ matrix.environment }} + run: | + # Retry dependency preparation, then run the plan once. The runner + # builds the suites for the guest, so it needs no prepared archives. + # --strict fails the job instead of skipping an environment or a + # selection that would run no tests. + nix build --no-link .#openshell-test-runner || nix build --no-link .#openshell-test-runner + nix run .#openshell-test-runner -- test \ + --plan "tests/plans/${PLAN}.yaml" \ + --env "${ENVIRONMENT}" \ + --strict \ + --results artifacts/test-results + + # The results directory holds each family's JUnit report (junit.xml), + # the nextest log, and the guest's diagnostics when a run failed. + - name: Upload results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: e2e-results-${{ matrix.plan }}-${{ matrix.environment }} + path: artifacts/test-results + if-no-files-found: ignore + retention-days: 7 + + result: + name: E2E suites result (${{ inputs.plan }}) + needs: [plan, suites] + if: always() + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/check-job-results + with: + results: ${{ toJSON(needs) }} diff --git a/AGENTS.md b/AGENTS.md index d1652c2606..a84860b80d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,11 @@ 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/suites/` | E2E suites | One Cargo workspace per test family; `conformance/sandbox` is the first testkit suite | +| `tests/tmachine/` | Test machines | QEMU machines provisioned with Ansible, and the `Session` API that drives them | +| `tests/runner/` | Test runner | `openshell-test-runner`: expands plans into jobs, runs them in tmachine environments, checks JUnit results, emits the CI matrix | +| `tests/plans/`, `tests/environments/` | Test plans | Plans pair suite families with environments; environments say what each provides | | `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..7d21c76ac1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -345,6 +345,10 @@ 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/suites/` | End-to-end suites, one Cargo workspace per family (`conformance/sandbox` runs through a plan) | +| `tests/tmachine/` | QEMU test machines provisioned with Ansible, and the `Session` API that drives them | +| `tests/runner/` | `openshell-test-runner`: 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 | | `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 e6754df317..746d444dd5 100644 --- a/TESTING.md +++ b/TESTING.md @@ -17,6 +17,11 @@ 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/conformance/ # Testkit suites (the `sandbox` crate) beside the older CLI conformance crate +tests/tmachine/ # QEMU test machines, Ansible playbooks, and the Session API +tests/runner/ # openshell-test-runner: runs test plans in environments +tests/plans/ # Test plans: which suites run in which environments +tests/environments/ # Environment definitions ``` ## Rust Tests @@ -140,6 +145,87 @@ by `OPENSHELL_BIN`. See [tests/testkit/README.md](tests/testkit/README.md) for how to write and run tests. +Suites live under `tests/suites//`. Each family is a Cargo workspace +with its own lockfile, and a family name is what a plan's `type` selects. +`tests/suites/conformance/sandbox` is the first testkit suite; it has one smoke +test. Run it against a local Docker gateway with: + +```shell +mise run e2e:testkit +``` + +That task runs `e2e/with-docker-gateway.sh`, which starts a gateway and exports +`OPENSHELL_GATEWAY` and `OPENSHELL_BIN`, so the suite runs the same way the +runner runs it in a guest. + +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. + +### Test plans and the runner (`tests/runner/`) + +`openshell-test-runner` 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. A plan selects which suites run in which environments. The plan is the only list of +what runs where: CI reads it, so no workflow or playbook repeats it. + +- **Plans** (`tests/plans/*.yaml`) pair a family (a workspace under + `tests/suites/`, such as `conformance`) and optionally some of its suites with + environment IDs. See [tests/plans/README.md](tests/plans/README.md). A `conformance` entry must + select the same suites in every environment; the runner refuses a plan that + doesn't. +- **Environments** (`tests/environments/*.yaml`) name the tmachine environment and + installer that provision them and the gateway name the installer registers. The + runner points the tests at that gateway and the installed CLI with + `OPENSHELL_GATEWAY` and `OPENSHELL_BIN`. +- **Test IDs** are optional: `#[testkit::test(id = "t-0123abcd")]` gives a test + an identity that survives renames. There is no per-environment skipping. A test + that cannot pass everywhere belongs in a different family, and a flaky or + broken one is marked `#[ignore = ""]`, which drops it everywhere. +- **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, or skipped, fails the job. An `#[ignore]` test + is left out of what a lane expects, so it can't fail the lane on its own. That + is the only way to take a broken or flaky test out of a lane, and it does so in every + environment: give the reason as a link to the tracking issue, + `#[ignore = "https://github.com/NVIDIA/OpenShell/issues/"]`. +- **Strict mode** (`--strict`, or `OPENSHELL_TEST_STRICT=true`) is for CI. It fails + instead of skipping an environment the host can't provision, a family + with no tests, or a plan that selects nothing. A family whose selected tests + are all ignored has no tests, so strict mode fails it: the plan pairs that + environment with suites that have nothing to run. 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 .#openshell-test-runner -- test --plan tests/plans/cpu-tests.yaml --dry-run +nix run .#openshell-test-runner -- test --plan tests/plans/cpu-tests.yaml --strict +``` + +`nix run .#openshell-test-runner` cross-builds the suites for the guest, boots +each environment through tmachine (this needs KVM), runs nextest in the guest +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 installer inputs, such +as the Debian package and the images, come from `artifacts/`, built by +`nix run .#build-artifacts` or downloaded by CI. + +**CI runs the plan.** `.github/workflows/e2e-suites.yml` takes a plan name. It +runs `openshell-test-runner test --plan tests/plans/.yaml --emit-github-matrix`, +which prints the `{"include": [...]}` a workflow reads with `fromJSON`: one job +per environment with the runner label that can boot it. Each job then runs +`nix run .#openshell-test-runner -- test --plan ... --env --strict` and +uploads its JUnit reports and logs. Branch E2E calls the workflow for `cpu-tests` when the +`test:e2e` label is set. To change what CI runs, edit the plan. + +The older conformance, feature and driver lanes in `.github/workflows/` are still +written by hand and run through the tmachine testsuites in +`tests/tmachine/config.nix`. They move to plans as their suites move to testkit. + ### Python E2E (`e2e/python/`) `mise run e2e:python` builds `openshell/e2e-python:dev` from diff --git a/flake.nix b/flake.nix index 7fac354cac..60ae691dc5 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; + openshell-test-runner = testMachines.testRunner; }; devShells = { diff --git a/tasks/rust.toml b/tasks/rust.toml index 682e478fca..cc9e7f661f 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 @@ -34,6 +37,9 @@ 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/conformance/Cargo.toml -p sandbox --all-targets -- -D warnings", ] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 lint native" hide = true @@ -47,6 +53,9 @@ 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/conformance/Cargo.toml -p sandbox", ] hide = true @@ -59,6 +68,9 @@ 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/conformance/Cargo.toml -p sandbox -- --check", ] hide = true diff --git a/tasks/scripts/check_cargo_lockfile_alignment.py b/tasks/scripts/check_cargo_lockfile_alignment.py new file mode 100644 index 0000000000..29e82588af --- /dev/null +++ b/tasks/scripts/check_cargo_lockfile_alignment.py @@ -0,0 +1,172 @@ +#!/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/conformance", +) + + +@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") == [] diff --git a/tasks/test.toml b/tasks/test.toml index a5a2a03928..7e7de8b13f 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -23,8 +23,13 @@ depends = [ "test:docs-website", "test:docs-nav", "test:maintainer-approval", + "test:cargo-lockfile-alignment", ] +["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-website"] description = "Test the docs-website sync script" # --no-project skips installing the OpenShell package; --with supplies the test @@ -120,6 +125,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 @@ -152,6 +159,10 @@ hide = true description = "Run Rust CLI e2e tests against a Docker-backed gateway" run = "e2e/rust/e2e-docker.sh" +["e2e:testkit"] +description = "Run the testkit suites in tests/suites/conformance against a Docker-backed gateway" +run = "e2e/with-docker-gateway.sh cargo test --locked --manifest-path tests/suites/conformance/Cargo.toml -p sandbox -- --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" diff --git a/tests/environments/docker.yaml b/tests/environments/docker.yaml new file mode 100644 index 0000000000..dfa617b5d8 --- /dev/null +++ b/tests/environments/docker.yaml @@ -0,0 +1,24 @@ +# 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`. +# +# Only amd64 is defined: CI has no arm64 runner with KVM for tmachine yet. To add +# arm64, copy the entry with `arch: arm64` and a `ubuntu2404-arm64` ID, then list +# it in a plan. The CI matrix picks the `ubuntu-24.04-arm` runner for it (see +# runner_label in tests/runner/src/github.rs); the calling workflow must also +# build arm64 inputs. +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 diff --git a/tests/environments/k3s.yaml b/tests/environments/k3s.yaml new file mode 100644 index 0000000000..87ea66462c --- /dev/null +++ b/tests/environments/k3s.yaml @@ -0,0 +1,21 @@ +# 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 and for adding arm64. 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 + - 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 diff --git a/tests/environments/podman.yaml b/tests/environments/podman.yaml new file mode 100644 index 0000000000..458b9e3701 --- /dev/null +++ b/tests/environments/podman.yaml @@ -0,0 +1,20 @@ +# 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 and for adding arm64. +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 + - 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 diff --git a/tests/plans/README.md b/tests/plans/README.md new file mode 100644 index 0000000000..f0326bd4eb --- /dev/null +++ b/tests/plans/README.md @@ -0,0 +1,77 @@ +# Test plans + +A plan lists which test suites run in which environments. CI and local runs read +the same file, and nothing else repeats it: a workflow does not carry a copy of +the lane list, and a suite does not carry a playbook that says where it runs. + +```yaml +matrix: + - type: conformance # a family: a workspace under tests/suites/ + suites: [smoke] # optional; omit to run every suite in the family + environments: + - tmachine.ubuntu2404-amd64.docker-rootful.deb # IDs from tests/environments/ +``` + +A `conformance` entry must select the same suites in every environment: the +runner refuses a plan that doesn't, so conformance means the same thing +everywhere. A test that can't pass everywhere belongs in another family. + +A family is a Cargo workspace directly under `tests/suites/` with at least one +testkit suite. A suite is a test file in a crate's `tests/` directory containing +`#[testkit::test]` functions. Its name is the file name without `.rs`, with `_` +replaced by `-`: `tests/file_transfer.rs` is the suite `file-transfer`. + +| Plan | Runs | +|---|---| +| `cpu-tests.yaml` | The `conformance` family on rootful Docker (Debian package), rootful and rootless Podman (RPM), and k3s and HA k3s, all amd64 | + +## How a plan runs on CI + +Branch E2E calls `.github/workflows/e2e-suites.yml` with the plan name when the +`test:e2e` label is set. + +1. A planning job runs + `openshell-test-runner test --plan tests/plans/.yaml --emit-github-matrix`. It + prints one job per environment, with the GitHub runner label that can boot it. +2. Each job downloads the integration inputs the tmachine lanes use (the Debian + and RPM packages and the images), then runs + `nix run .#openshell-test-runner -- test --plan tests/plans/.yaml --env --strict`. +3. The runner builds the family's tests for the guest, boots the environment + with tmachine, and runs nextest in it with `OPENSHELL_GATEWAY` set to the + gateway the installer registered and `OPENSHELL_BIN` set to the installed CLI. It checks the JUnit + report against the tests it selected: a missing or skipped test fails the job. +4. The job uploads the JUnit reports and logs, and an aggregator job reports the + result. + +Adding an environment or a suite to a plan changes what CI runs with no workflow +change. Arm64 is not defined yet: it needs a runner that can boot guests with KVM and +arm64 build inputs. `tests/environments/docker.yaml` says how to add it. + +## Running a plan locally + +Print what a plan would run, without building or booting anything: + +```shell +nix run .#openshell-test-runner -- test --plan tests/plans/cpu-tests.yaml --dry-run +``` + +Running it for real boots QEMU guests, so it needs KVM and the installer inputs in +`artifacts/` (`nix run .#build-artifacts`). The runner skips an environment the +host cannot boot unless you pass `--strict`, which is what CI does. + +When debugging, `--env ` and `--suite ` narrow the run to part of the +plan, and `--keep` leaves each VM running after its tests until you press +Enter, so you can inspect it: + +```shell +nix run .#openshell-test-runner -- test --plan tests/plans/cpu-tests.yaml \ + --env tmachine.ubuntu2404-amd64.docker-rootful.deb --suite smoke --keep +``` + +To run a suite against a gateway on your machine, without a guest, use the +Docker gateway wrapper. It exports `OPENSHELL_GATEWAY` and `OPENSHELL_BIN`, which +is how the runner points tests at the system under test: + +```shell +mise run e2e:testkit +``` diff --git a/tests/plans/cpu-tests.yaml b/tests/plans/cpu-tests.yaml new file mode 100644 index 0000000000..ee2a67f6aa --- /dev/null +++ b/tests/plans/cpu-tests.yaml @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Tests that need no GPU. `type` names a family under tests/suites/ (a Cargo +# workspace of testkit suites). Omit `suites` to run every suite in the family, +# or list suite names (a suite is a file under /tests/) to run only +# those. `environments` are IDs from tests/environments/. +# +# CI runs this plan from .github/workflows/e2e-suites.yml, one job per +# environment listed here. To add coverage, add an environment to an entry or an +# entry for another family; no workflow change is needed. +# +# Only amd64 is listed: Branch E2E has no arm64 runner with KVM for tmachine +# yet. See tests/environments/docker.yaml for how to add arm64. +matrix: + - type: conformance + environments: + - tmachine.ubuntu2404-amd64.docker-rootful.deb + - tmachine.fedora44-amd64.podman-rootful.rpm + - tmachine.fedora44-amd64.podman-rootless.rpm + - tmachine.ubuntu2404-amd64.k3s.k3s + - tmachine.ubuntu2404-amd64.k3s.k3s-ha 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..fac628a0e6 --- /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-runner" +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..2fceecc02d --- /dev/null +++ b/tests/runner/src/catalog.rs @@ -0,0 +1,423 @@ +// 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 suite whose tests are all `#[ignore]`d is kept with no +//! tests, so selecting it reports that nothing would run. +//! +//! 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, +} + +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(", ") + ) + }) + } +} + +#[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 { + // Cargo expands globs in `members`; the catalog doesn't, and would + // otherwise find no suites in them without saying so. + if member.contains(['*', '?', '[']) { + bail!( + "{}: list workspace members explicitly; the test catalog doesn't expand {member:?}", + workspace.join("Cargo.toml").display() + ); + } + 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; + } + let specs = testkit_spec::scan(&source) + .map_err(|error| anyhow::anyhow!("{}: {error}", file.display()))?; + if specs.is_empty() { + continue; + } + // An `#[ignore]` test only runs with `--run-ignored`, so it is not part + // of what a lane expects. A suite whose tests are all ignored stays in + // the catalog with no tests, so a plan that selects it is told that + // nothing would run rather than that the suite doesn't exist. + let specs: Vec<_> = specs.into_iter().filter(|spec| !spec.ignored).collect(); + 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, + }) + .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(), + } + } + + 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_smoke_suite() { + let catalog = Catalog::load(&repo_root()).unwrap(); + let family = catalog.family("conformance").unwrap(); + let suite = family.suite("smoke").unwrap(); + assert_eq!(suite.binary, "smoke"); + assert_eq!(suite.package, "sandbox"); + assert!( + suite + .tests + .iter() + .all(|test| !test.name.is_empty() && !test.description.is_empty()) + ); + assert!(!suite.tests.is_empty()); + } + + #[test] + fn glob_workspace_members_are_refused() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("Cargo.toml"), + "[workspace]\nmembers = [\"suites/*\"]\n", + ) + .unwrap(); + let err = Family::load("conformance", dir.path()).unwrap_err(); + assert!( + err.to_string() + .contains("list workspace members explicitly"), + "{err}" + ); + } + + #[test] + fn ignored_tests_are_not_expected() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("tests")).unwrap(); + std::fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"pkg\"\nversion = \"0.0.0\"\n", + ) + .unwrap(); + std::fs::write( + dir.path().join("tests/s.rs"), + "/// Runs.\n#[testkit::test]\nasync fn runs(cx: &mut Context) -> testkit::Result { Ok(()) }\n\n/// Needs a flag.\n#[ignore]\n#[testkit::test]\nasync fn ignored(cx: &mut Context) -> testkit::Result { Ok(()) }\n", + ) + .unwrap(); + let suites = member_suites(dir.path()).unwrap(); + let names: Vec<_> = suites[0].tests.iter().map(|test| &test.name[..]).collect(); + assert_eq!(names, ["runs"]); + } + + #[test] + fn suites_with_every_test_ignored_have_no_tests() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("tests")).unwrap(); + std::fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"pkg\"\nversion = \"0.0.0\"\n", + ) + .unwrap(); + std::fs::write( + dir.path().join("tests/all_ignored.rs"), + "/// Broken.\n#[ignore = \"https://example.com/1\"]\n#[testkit::test]\nasync fn broken(cx: &mut Context) -> testkit::Result { Ok(()) }\n", + ) + .unwrap(); + let suites = member_suites(dir.path()).unwrap(); + assert_eq!(suites.len(), 1); + assert_eq!(suites[0].suite, "all-ignored"); + assert!(suites[0].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(); + } + + #[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("conformance"), "{err}"); + let err = catalog + .family("conformance") + .unwrap() + .suite("smok") + .unwrap_err(); + assert!(err.to_string().contains("smoke"), "{err}"); + } +} diff --git a/tests/runner/src/environments.rs b/tests/runner/src/environments.rs new file mode 100644 index 0000000000..8813eb704e --- /dev/null +++ b/tests/runner/src/environments.rs @@ -0,0 +1,298 @@ +// 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, and the +//! name the installer registers the gateway under. +//! +//! 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, +} + +#[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, +} + +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 + ), + } + Ok(()) + } + + /// 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, + } + } +} + +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 } +"; + + 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(), + }) + ); + } + + #[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 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..9ce0511d2b --- /dev/null +++ b/tests/runner/src/execute.rs @@ -0,0 +1,466 @@ +// 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`. 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::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, +} + +/// 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, + }; + + 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. +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 +} + +/// 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_and_the_cli() { + 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", + ) + .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", + ] + ); + + 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..d3ad21f9de --- /dev/null +++ b/tests/runner/src/github.rs @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The CI matrix, generated from a plan. +//! +//! A workflow runs one job per entry of +//! `openshell-test-runner test --plan --emit-github-matrix`, so which +//! environments run is decided by the plan, not by a list copied into a +//! workflow. +//! Nothing checks the output in: the plan is the only copy. +//! +//! Adapted from the matrix generation in Evan Lezar's NVIDIA/OpenShell#4629. + +use anyhow::{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; + +/// 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-runner test --env`. + pub environment: String, + /// The GitHub runner label that can boot the environment. + pub runner: String, +} + +/// The strategy matrix a workflow reads with `fromJSON`. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] +pub struct GithubMatrix { + pub include: Vec, +} + +/// 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 list one job per environment, +/// 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 whose suites have no tests: a CI lane that +/// would pass without testing anything. +pub fn github_matrix( + plan_name: &str, + plan: &Plan, + catalog: &Catalog, + environments: &Environments, +) -> Result { + let jobs = jobs::expand(plan, catalog, environments, None, &Narrowing::default())?; + if jobs.is_empty() { + bail!("plan {plan_name} selects no tests"); + } + + let mut matrix = GithubMatrix::default(); + for job in &jobs { + let id = &job.environment.id; + if let Some(family) = job.families.iter().find(|family| !family.has_tests()) { + bail!( + "plan {plan_name} selects {id} / {}, but its suites have no tests", + family.family + ); + } + matrix.include.push(GithubJob { + plan: plan_name.to_string(), + environment: id.clone(), + runner: runner_label(job.environment.machine.arch).to_string(), + }); + } + matrix + .include + .sort_by(|a, b| a.environment.cmp(&b.environment)); + Ok(matrix) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::path::PathBuf; + + 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() -> 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" + )); + } + 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 suite = Suite { + suite: "smoke".to_string(), + binary: "smoke".to_string(), + package: "pkg".to_string(), + tests: vec![Test { + id: None, + name: "only".to_string(), + description: "d".to_string(), + }], + }; + Catalog { + families: BTreeMap::from([( + "conformance".to_string(), + Family { + name: "conformance".to_string(), + workspace: PathBuf::new(), + suites: BTreeMap::from([("smoke".to_string(), suite)]), + }, + )]), + } + } + + fn plan() -> Plan { + Plan::parse(&format!( + "matrix:\n - type: conformance\n environments: [{ARM64}, {AMD64}]\n" + )) + .unwrap() + } + + fn generate() -> Result { + github_matrix("p", &plan(), &catalog(), &environments()) + } + + #[test] + fn generates_a_job_per_environment_with_its_runner() { + let matrix = generate().unwrap(); + assert_eq!( + matrix.include, + [ + GithubJob { + plan: "p".to_string(), + environment: AMD64.to_string(), + runner: "ubuntu-24.04".to_string(), + }, + GithubJob { + plan: "p".to_string(), + environment: ARM64.to_string(), + runner: "ubuntu-24.04-arm".to_string(), + }, + ] + ); + } + + #[test] + fn serializes_as_the_include_list_a_workflow_reads() { + let json = serde_json::to_value(generate().unwrap()).unwrap(); + assert_eq!(json["include"][0]["environment"], AMD64); + assert_eq!(json["include"][0]["runner"], "ubuntu-24.04"); + } +} diff --git a/tests/runner/src/jobs.rs b/tests/runner/src/jobs.rs new file mode 100644 index 0000000000..a9f82d1f2c --- /dev/null +++ b/tests/runner/src/jobs.rs @@ -0,0 +1,529 @@ +// 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. A suite runs every +//! test in it; `#[ignore]`d tests are left out of the catalog. + +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::{Result, bail}; + +use crate::catalog::{Catalog, Suite}; +use crate::environments::{Arch, Catalog as Environments, Environment}; +use crate::plan::Plan; + +/// 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, +} + +/// 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, + 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()); + } + } + } + } + + check_conformance_is_uniform(&selected)?; + + 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"))) + .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) +} + +/// The family every driver and install must pass. A plan can't run part of it +/// somewhere, or conformance would mean different things in different places. +const CONFORMANCE: &str = "conformance"; + +/// Every environment the plan runs conformance in must select the same suites. +fn check_conformance_is_uniform( + selected: &BTreeMap>>, +) -> Result<()> { + let mut by_suites: BTreeMap, Vec<&str>> = BTreeMap::new(); + for (id, families) in selected { + if let Some(suites) = families.get(CONFORMANCE) { + by_suites + .entry(suites.iter().map(String::as_str).collect()) + .or_default() + .push(id); + } + } + if by_suites.len() > 1 { + let groups: Vec = by_suites + .iter() + .map(|(suites, ids)| { + format!( + "{} select {}", + ids.join(", "), + suites.iter().copied().collect::>().join(", ") + ) + }) + .collect(); + bail!( + "the plan selects different {CONFORMANCE} suites in different environments, so \ + {CONFORMANCE} would not mean the same thing everywhere; use the same suites \ + everywhere, or move the suite that can't run everywhere to another family:\n {}", + groups.join("\n ") + ); + } + Ok(()) +} + +/// What strict mode (CI) refuses to run: nothing selected at all, an +/// environment this host can't provision, and a family with no tests. 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} / {}: the selected suites have no tests, so nothing would run", + family.family + )); + } + } + } + problems +} + +fn suite_job(suite: &Suite) -> SuiteJob { + SuiteJob { + suite: suite.clone(), + run: suite.tests.iter().map(|test| test.name.clone()).collect(), + } +} + +/// 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. + format!( + "binary_id(={}::{})", + suite.suite.package, suite.suite.binary + ) + }) + .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]| Suite { + suite: name.to_string(), + binary: name.replace('-', "_"), + package: format!("pkg-{name}"), + tests: tests + .iter() + .enumerate() + .map(|(index, name)| Test { + id: Some(format!("t-{index:08}")), + name: (*name).to_string(), + description: "d".to_string(), + }) + .collect(), + }; + let family = Family { + name: "conformance".to_string(), + workspace: PathBuf::new(), + suites: [ + suite("sandbox-lifecycle", &["long_running", "expose"]), + 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" + ); + } + 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(), + Some(Arch::Amd64), + narrowing, + ) + } + + #[test] + fn a_suite_runs_every_test_in_it() { + 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", "expose"]); + assert_eq!( + jobs[0].families[0].filterset(), + "binary_id(=pkg-sandbox-lifecycle::sandbox_lifecycle)" + ); + } + + #[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] + 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: [sandbox-lifecycle]\n environments: [{AMD64}, {ARM64}]\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 conformance_must_select_the_same_suites_in_every_environment() { + let err = expand_plan( + &format!( + "matrix:\n - type: conformance\n environments: [{AMD64}]\n - type: conformance\n suites: [providers]\n environments: [{ARM64}]\n" + ), + &Narrowing::default(), + ) + .unwrap_err(); + let message = err.to_string(); + assert!( + message.contains("different conformance suites"), + "{message}" + ); + assert!( + message.contains(AMD64) && message.contains(ARM64), + "{message}" + ); + + // The same suites listed differently are the same selection. + expand_plan( + &format!( + "matrix:\n - type: conformance\n suites: [providers, sandbox-lifecycle]\n environments: [{AMD64}]\n - type: conformance\n environments: [{ARM64}]\n" + ), + &Narrowing::default(), + ) + .unwrap(); + } + + #[test] + fn expected_tests_are_the_ones_in_the_suite() { + let jobs = expand_plan( + &format!("matrix:\n - type: conformance\n suites: [providers]\n environments: [{AMD64}]\n"), + &Narrowing::default(), + ) + .unwrap(); + let family = &jobs[0].families[0]; + assert_eq!( + family.expected_tests().into_iter().collect::>(), + [( + "pkg-providers::providers".to_string(), + "create_provider".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()] + ); + } +} diff --git a/tests/runner/src/lib.rs b/tests/runner/src/lib.rs new file mode 100644 index 0000000000..25a3bfa9fa --- /dev/null +++ b/tests/runner/src/lib.rs @@ -0,0 +1,15 @@ +// 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 plan; +pub mod report; diff --git a/tests/runner/src/main.rs b/tests/runner/src/main.rs new file mode 100644 index 0000000000..b7a75aeaa7 --- /dev/null +++ b/tests/runner/src/main.rs @@ -0,0 +1,276 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! `openshell-test-runner`: run `OpenShell` test plans. +//! +//! Run it through `nix run .#openshell-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::plan::Plan; +use openshell_test_runner::report; + +#[derive(Parser)] +#[command(name = "openshell-test-runner", 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/cpu-tests.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", + value_parser = clap::builder::BoolishValueParser::new(), + default_value_t = false, + num_args = 0..=1, + default_missing_value = "true" + )] + 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, + }, +} + +#[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"))?; + + match cli.command { + Command::Test { + plan, + dry_run, + emit_github_matrix, + strict, + keep, + environments, + suites, + results, + } => { + 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 matrix = github::github_matrix(name, &plan, &catalog, &environments_catalog)?; + println!("{}", serde_json::to_string(&matrix)?); + return Ok(ExitCode::SUCCESS); + } + + let host = Arch::host(); + let narrowing = Narrowing { + environments, + suites, + }; + let jobs = jobs::expand(&plan, &catalog, &environments_catalog, 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)); + 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 { + if suite.run.is_empty() { + eprintln!(" {}: no tests to run", suite.suite.suite); + } else { + eprintln!(" {}: {}", suite.suite.suite, suite.run.join(", ")); + } + } + } + } +} + +/// 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 .#openshell-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/plan.rs b/tests/runner/src/plan.rs new file mode 100644 index 0000000000..1ae1d43056 --- /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: [smoke] # 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: [smoke]\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(&["smoke".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..54244401dc --- /dev/null +++ b/tests/runner/src/report.rs @@ -0,0 +1,467 @@ +// 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, +} + +#[derive(Debug)] +pub enum Status { + Passed { + tests: u32, + }, + Failed { + tests: u32, + failures: u32, + }, + /// The environment can't be provisioned on this host. + Unsupported(String), + /// The selected suites have no tests. + 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 the selected suites have no tests".to_string(), + Status::Error(error) => format!("ERROR {error}"), + }; + let _ = writeln!( + out, + " {} / {}: {status}", + 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 }, + }, + Outcome { + environment: "env-b".to_string(), + family: "conformance".to_string(), + status: Status::Unsupported("arch".to_string()), + }, + ]); + assert!( + text.contains("env-a / conformance: PASS 2 passed"), + "{text}" + ); + assert!( + text.contains("env-b / conformance: UNSUPPORTED arch"), + "{text}" + ); + } + + /// A report written by the nextest version the guest installs + /// (`ansible/playbooks/nextest.yaml`) with the runner's profile, for a + /// suite with a passing, a failing and an `#[ignore]` test. nextest leaves + /// the ignored test out of the report entirely. + const NEXTEST_REPORT: &str = include_str!("../testdata/nextest-0.9.143-junit.xml"); + + #[test] + fn verifies_a_real_nextest_report() { + let expected: BTreeSet<_> = [ + ("sandbox::smoke", "create_exec_delete"), + ("sandbox::smoke", "fails"), + ] + .into_iter() + .map(|(classname, name)| (classname.to_string(), name.to_string())) + .collect(); + let verification = verify_junit(NEXTEST_REPORT, &expected); + assert_eq!(verification.tests, 2); + assert_eq!(verification.failed, ["sandbox::smoke fails"]); + assert!(verification.missing.is_empty(), "{verification:?}"); + assert!(verification.unexpected.is_empty(), "{verification:?}"); + } + + #[test] + fn annotates_a_real_nextest_report() { + let ids = BTreeMap::from([( + ("sandbox::smoke".to_string(), "fails".to_string()), + "t-0123abcd".to_string(), + )]); + let annotated = annotate_junit(NEXTEST_REPORT, &ids); + assert!( + annotated.contains( + r#"classname="sandbox::smoke" timestamp="2026-10-22T20:54:54.894-07:00" time="0.026">"# + ), + "{annotated}" + ); + // Annotating doesn't change what the report says. + assert_eq!(parse_cases(&annotated), parse_cases(NEXTEST_REPORT)); + } +} diff --git a/tests/runner/testdata/nextest-0.9.143-junit.xml b/tests/runner/testdata/nextest-0.9.143-junit.xml new file mode 100644 index 0000000000..f0542e08a2 --- /dev/null +++ b/tests/runner/testdata/nextest-0.9.143-junit.xml @@ -0,0 +1,33 @@ + + + + + + thread 'fails' (63231268) panicked at sandbox/tests/smoke.rs:7:5: +assertion `left == right` failed: sandbox phase + left: 1 + right: 2 +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + +running 1 test +some stdout +test fails ... FAILED + +failures: + +failures: + fails + +test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s + + + +thread 'fails' (63231268) panicked at sandbox/tests/smoke.rs:7:5: +assertion `left == right` failed: sandbox phase + left: 1 + right: 2 +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + + + + diff --git a/tests/runner/tests/strict.rs b/tests/runner/tests/strict.rs new file mode 100644 index 0000000000..eec27fc151 --- /dev/null +++ b/tests/runner/tests/strict.rs @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Strict mode reaches the plan check from both the flag and the environment +//! variable CI can set. The cpu-tests plan only has an amd64 environment, so +//! on any other host strict mode must refuse it and a plain run must not. + +use std::path::Path; +use std::process::{Command, Output}; + +fn dry_run(strict_flag: Option<&str>, strict_env: Option<&str>) -> Output { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let mut command = Command::new(env!("CARGO_BIN_EXE_openshell-test-runner")); + command + .current_dir(&root) + .args(["test", "--plan", "tests/plans/cpu-tests.yaml", "--dry-run"]) + .env_remove("OPENSHELL_TEST_STRICT"); + if let Some(flag) = strict_flag { + command.arg(flag); + } + if let Some(value) = strict_env { + command.env("OPENSHELL_TEST_STRICT", value); + } + command.output().expect("run openshell-test-runner") +} + +fn assert_strict(output: &Output, strict: bool) { + let stderr = String::from_utf8_lossy(&output.stderr); + let refused = strict && !cfg!(target_arch = "x86_64"); + assert_eq!(output.status.success(), !refused, "{stderr}"); + assert_eq!( + stderr.contains("strict mode refuses this plan"), + refused, + "{stderr}" + ); +} + +#[test] +fn strict_mode_is_off_by_default() { + assert_strict(&dry_run(None, None), false); + assert_strict(&dry_run(None, Some("false")), false); + assert_strict(&dry_run(Some("--strict=false"), None), false); +} + +#[test] +fn strict_mode_is_on_from_the_flag_or_the_environment() { + assert_strict(&dry_run(Some("--strict"), None), true); + assert_strict(&dry_run(None, Some("true")), true); +} diff --git a/tests/suites/conformance/Cargo.lock b/tests/suites/conformance/Cargo.lock index 89d8c0d392..b6f46bf9d4 100644 --- a/tests/suites/conformance/Cargo.lock +++ b/tests/suites/conformance/Cargo.lock @@ -2,11 +2,330 @@ # 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" @@ -14,424 +333,3974 @@ 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" +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-conformance" +version = "0.0.0" +dependencies = [ + "rand 0.9.4", + "serde", + "serde_json", + "tempfile", + "tokio", +] + +[[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-conformance-cli" +version = "0.0.0" +dependencies = [ + "openshell-conformance", + "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", +] + +[[package]] +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" +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.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +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 = "sandbox" +version = "0.0.0" +dependencies = [ + "openshell-testkit", + "serde_json", + "tempfile", + "tokio", +] + +[[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 = "errno" -version = "0.3.14" +name = "want" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" dependencies = [ - "libc", - "windows-sys", + "try-lock", ] [[package]] -name = "fastrand" -version = "2.5.0" +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "getrandom" -version = "0.3.4" +name = "wasip2" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", + "wit-bindgen 0.57.1", ] [[package]] -name = "itoa" -version = "1.0.18" +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 = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] [[package]] -name = "libc" -version = "0.2.189" +name = "wasm-bindgen" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] [[package]] -name = "linux-raw-sys" -version = "0.12.1" +name = "wasm-bindgen-futures" +version = "0.4.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] [[package]] -name = "lock_api" -version = "0.4.14" +name = "wasm-bindgen-macro" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ - "scopeguard", + "quote", + "wasm-bindgen-macro-support", ] [[package]] -name = "memchr" -version = "2.8.3" +name = "wasm-bindgen-macro-support" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] [[package]] -name = "mio" -version = "1.2.3" +name = "wasm-bindgen-shared" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ - "libc", - "wasi", - "windows-sys", + "unicode-ident", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] [[package]] -name = "openshell-conformance" -version = "0.0.0" +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ - "rand", - "serde", - "serde_json", - "tempfile", - "tokio", + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", ] [[package]] -name = "openshell-test-conformance-cli" -version = "0.0.0" +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "openshell-conformance", - "tokio", + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", ] [[package]] -name = "parking_lot" -version = "0.12.5" +name = "web-sys" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ - "lock_api", - "parking_lot_core", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "parking_lot_core" -version = "0.9.12" +name = "webpki-roots" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", + "rustls-pki-types", ] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "whoami" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" [[package]] -name = "ppv-lite86" -version = "0.2.21" +name = "windows-core" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "zerocopy", + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "windows-implement" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ - "unicode-ident", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "quote" -version = "1.0.47" +name = "windows-interface" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "r-efi" -version = "5.3.0" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "rand" -version = "0.9.5" +name = "windows-result" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "rand_chacha", - "rand_core", + "windows-link", ] [[package]] -name = "rand_chacha" -version = "0.9.0" +name = "windows-strings" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "ppv-lite86", - "rand_core", + "windows-link", ] [[package]] -name = "rand_core" -version = "0.9.5" +name = "windows-sys" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "getrandom", + "windows-targets", ] [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "bitflags", + "windows-link", ] [[package]] -name = "rustix" -version = "1.1.5" +name = "windows-targets" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", + "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 = "scopeguard" -version = "1.2.0" +name = "windows_aarch64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] -name = "serde" -version = "1.0.229" +name = "windows_aarch64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +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 = [ - "serde_core", - "serde_derive", + "wit-bindgen-rust-macro", ] [[package]] -name = "serde_core" -version = "1.0.229" +name = "wit-bindgen" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ - "serde_derive", + "anyhow", + "heck", + "wit-parser", ] [[package]] -name = "serde_derive" -version = "1.0.229" +name = "wit-bindgen-rust" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +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 3.0.5", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] -name = "serde_json" -version = "1.0.151" +name = "wit-component" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ - "itoa", - "memchr", + "anyhow", + "bitflags", + "indexmap", + "log", "serde", - "serde_core", - "zmij", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", ] [[package]] -name = "signal-hook-registry" -version = "1.4.8" +name = "wit-parser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ - "errno", - "libc", + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", ] [[package]] -name = "smallvec" -version = "1.16.1" +name = "writeable" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] -name = "socket2" -version = "0.6.5" +name = "x509-parser" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" dependencies = [ - "libc", - "windows-sys", + "asn1-rs", + "aws-lc-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.20", + "time", ] [[package]] -name = "syn" -version = "2.0.119" +name = "yasna" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "bit-vec", + "time", ] [[package]] -name = "syn" -version = "3.0.5" +name = "yoke" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "stable_deref_trait", + "yoke-derive", + "zerofrom", ] [[package]] -name = "tempfile" -version = "3.27.0" +name = "yoke-derive" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ - "fastrand", - "getrandom", - "once_cell", - "rustix", - "windows-sys", + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", ] [[package]] -name = "tokio" -version = "1.53.1" +name = "zerocopy" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys", + "zerocopy-derive", ] [[package]] -name = "tokio-macros" -version = "2.7.2" +name = "zerocopy-derive" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 2.0.117", ] [[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" +name = "zerofrom" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] [[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" +name = "zerofrom-derive" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ - "wit-bindgen", + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", ] [[package]] -name = "windows-link" -version = "0.2.1" +name = "zeroize" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] -name = "windows-sys" -version = "0.61.2" +name = "zerotrie" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ - "windows-link", + "displaydoc", + "yoke", + "zerofrom", ] [[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" +name = "zerovec" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ - "zerocopy-derive", + "yoke", + "zerofrom", + "zerovec-derive", ] [[package]] -name = "zerocopy-derive" -version = "0.8.57" +name = "zerovec-derive" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "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/conformance/Cargo.toml b/tests/suites/conformance/Cargo.toml index 1c6b001096..8dad4ea1ff 100644 --- a/tests/suites/conformance/Cargo.toml +++ b/tests/suites/conformance/Cargo.toml @@ -3,4 +3,4 @@ [workspace] resolver = "2" -members = ["cli"] +members = ["cli", "sandbox"] diff --git a/tests/suites/conformance/sandbox/Cargo.toml b/tests/suites/conformance/sandbox/Cargo.toml new file mode 100644 index 0000000000..d213c6b23a --- /dev/null +++ b/tests/suites/conformance/sandbox/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 = "sandbox" +description = "Conformance suite: sandbox behavior on every compute driver" +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", features = ["time"] } diff --git a/tests/suites/conformance/sandbox/README.md b/tests/suites/conformance/sandbox/README.md new file mode 100644 index 0000000000..a39eb1f5f8 --- /dev/null +++ b/tests/suites/conformance/sandbox/README.md @@ -0,0 +1,64 @@ +# Sandbox conformance suite + +The `sandbox` crate holds testkit suites for sandbox behavior that every compute +driver must provide. It is a member of the `conformance` workspace, which a plan +selects with `type: conformance`. Each file in `tests/` is one suite, named after +the file with `_` replaced by `-` (`file_transfer.rs` is `file-transfer`). + +| Suite | Covers | +|---|---| +| `smoke` | Create a sandbox, run `echo` in it, delete it | +| `lifecycle` | SDK: long-running and run-to-completion sandboxes, the default command, restart of a stopped sandbox, deleting a stopped sandbox | +| `cli` | The same lifecycle through the `openshell` CLI: status, create, get, list, exec, stop, start, delete | +| `persistent-workspace` | Files in `/sandbox` survive stop and start | +| `file-transfer` | `sandbox upload` and `download`: round trips, `.gitignore` filtering, workspace-escape and option-looking names | + +`smoke` is the smallest end-to-end check that a plan can run a real test through +the runner on CI. The other suites are ports of the old `conformance/cli` +scenarios and of Evan Lezar's suites in NVIDIA/OpenShell#4629 (credited in each +file). `file-transfer` runs `git` on the machine that runs the tests, so a test +environment needs it installed (`tests/tmachine/ansible/playbooks/test-tools.yaml` +does that for the tmachine environments). + +## Running it + +Against a local Docker gateway: + +```shell +mise run e2e:testkit +``` + +Against any gateway the CLI can reach, with the testkit environment variables +described in [tests/testkit/README.md](../../../testkit/README.md): + +```shell +OPENSHELL_GATEWAY= cargo nextest run --locked \ + --manifest-path tests/suites/conformance/Cargo.toml -p sandbox --test-threads 1 +``` + +On CI, `tests/plans/cpu-tests.yaml` runs it through +[the plan runner](../../../plans/README.md). + +## Adding a test + +Add a `#[testkit::test]` function to a suite, or a new file in `tests/` for a new +area. No playbook, test archive or workflow entry is needed: the runner discovers +tests from source and the plan selects them. + +## What a test may assert + +These tests run on every compute driver, so they check the public contract and +leave driver behavior alone. + +- Assert only what the public contract promises: the proto comments and the + published docs. +- Assert `phase` and `exit_code`. Don't assert condition reasons. +- Check that something did not happen by reading state, not by sleeping. +- Don't test behavior that only some drivers have. A test that can't pass on + every environment isn't conformance; put it in `tests/suites/features` or + `tests/suites/drivers`. +- 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. + +Guidance adapted from NVIDIA/OpenShell#4629 by Evan Lezar. diff --git a/tests/suites/conformance/sandbox/tests/cli.rs b/tests/suites/conformance/sandbox/tests/cli.rs new file mode 100644 index 0000000000..d26eed9bb5 --- /dev/null +++ b/tests/suites/conformance/sandbox/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/conformance/sandbox/tests/file_transfer.rs b/tests/suites/conformance/sandbox/tests/file_transfer.rs new file mode 100644 index 0000000000..5bd24563c0 --- /dev/null +++ b/tests/suites/conformance/sandbox/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/conformance/sandbox/tests/lifecycle.rs b/tests/suites/conformance/sandbox/tests/lifecycle.rs new file mode 100644 index 0000000000..02e4f6f117 --- /dev/null +++ b/tests/suites/conformance/sandbox/tests/lifecycle.rs @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Sandbox lifecycle suite. +//! +//! 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. +#[testkit::test] +async fn long_running_exec_delete(cx: &mut Context) -> testkit::Result { + let sandbox = cx + .sandbox("lr") + .command(["sleep", "infinity"]) + .create() + .await?; + sandbox.wait_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, + "exec exited with {}; stderr: {}", + output.exit_code, + output.stderr + ); + testkit::ensure!( + output.stdout == format!("{marker}\n"), + "exec stdout was {:?}; expected {:?}", + output.stdout, + format!("{marker}\n") + ); + + 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/conformance/sandbox/tests/persistent_workspace.rs b/tests/suites/conformance/sandbox/tests/persistent_workspace.rs new file mode 100644 index 0000000000..101ea6d6ff --- /dev/null +++ b/tests/suites/conformance/sandbox/tests/persistent_workspace.rs @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Lifecycle tests for a compute driver whose workspace is persistent. +//! +//! Adapted from the `sandbox-lifecycle` suite in NVIDIA/OpenShell#4629 by Evan +//! Lezar. Docker, Podman, Kubernetes, and MicroVM keep `/sandbox` across stop +//! and start, and so must every conformant driver. The Microsoft MXC driver +//! cannot start a stopped sandbox yet, so it is not conformant until it can. + +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/conformance/sandbox/tests/smoke.rs b/tests/suites/conformance/sandbox/tests/smoke.rs new file mode 100644 index 0000000000..4134d9aa16 --- /dev/null +++ b/tests/suites/conformance/sandbox/tests/smoke.rs @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Sandbox smoke suite: the smallest end-to-end check that a plan can run a +//! testkit test against a real gateway. +//! +//! Adapted from the `sandbox-lifecycle` suite in NVIDIA/OpenShell#4629 by +//! Evan Lezar. + +use testkit::Context; + +/// A sandbox becomes ready, runs a command with exec, and is gone from get and +/// list after delete. +#[testkit::test] +async fn create_exec_delete(cx: &mut Context) -> testkit::Result { + let sandbox = cx + .sandbox("smoke") + .command(["sleep", "infinity"]) + .create() + .await?; + sandbox.wait_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, + "exec exited with {}; stderr: {}", + output.exit_code, + output.stderr + ); + testkit::ensure!( + output.stdout == format!("{marker}\n"), + "exec stdout was {:?}; expected {:?}", + output.stdout, + format!("{marker}\n") + ); + + sandbox.delete().await?; + cx.expect_absent(&sandbox).await +} diff --git a/tests/testkit/Cargo.lock b/tests/testkit/Cargo.lock index e36d9c7b09..3f1a0b11de 100644 --- a/tests/testkit/Cargo.lock +++ b/tests/testkit/Cargo.lock @@ -2000,11 +2000,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" @@ -2630,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", @@ -3876,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/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 fd6897013d..3dcfb54798 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 a test runner's catalog can both use it, so tools can list tests, with their ID and description, without compiling them. | ## Writing a test @@ -31,11 +32,26 @@ async fn long_running_exec_delete(cx: &mut Context) -> testkit::Result { - **The doc comment is required.** It is the test's description. - **The function name is the nextest test name.** +- **Keep tests at the top level of the file.** `testkit_spec::scan`, which + lists tests from source for tools such as a test runner's catalog, doesn't + look inside `mod` blocks yet, so it refuses a test there. +- **No `#[cfg]` or `#[cfg_attr]`** on a test, or `#![cfg]` on a file with + tests. Listing from source can't tell whether a test is compiled, so a + runner would expect a test that never runs. A test that only applies to some + environments belongs in a `features` or `drivers` suite. +- **No other tests in the file.** A file with `#[testkit::test]` functions + can't also have `#[test]` or `#[tokio::test]` functions. nextest would run + them, but a runner listing tests from source wouldn't expect them. - **Other attributes apply to the test.** For example, `#[ignore = "reason"]` skips it unless run with `--run-ignored`. `#[should_panic]` is rejected, 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 + 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. Cleanup runs after every test, whether it passes, fails, or panics. It only deletes sandboxes created through `cx.sandbox(...)`. Delete anything else a @@ -48,6 +64,10 @@ 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(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. - `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. @@ -89,6 +109,12 @@ time or the CLI does not report it connected. ephemeral gateway and export `OPENSHELL_GATEWAY` and `OPENSHELL_BIN`, so a suite runs under them unchanged. +`mise run e2e:testkit` does this for the suites in `tests/suites/conformance`. +On CI the test runner (`tests/runner/`) does the same for a guest: it sets +`OPENSHELL_GATEWAY` to the gateway the installer registered, `OPENSHELL_BIN` to +the installed CLI, then runs the plan's suites with nextest. See [TESTING.md](../../TESTING.md) and +[tests/plans/README.md](../plans/README.md). + ## Checks ```shell 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..4810efd8df 100644 --- a/tests/testkit/testkit-macros/src/lib.rs +++ b/tests/testkit/testkit-macros/src/lib.rs @@ -13,17 +13,22 @@ //! async fn expose_service(cx: &mut Context) -> testkit::Result { /* ... */ } //! ``` //! +//! `id = "t-0123abcd"` is optional. It gives the test a stable identity that +//! survives renames and moves, for tools such as result history. +//! //! 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 a test runner's catalog can also use. 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 +39,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 +58,17 @@ 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 vis = body.vis.clone(); let attrs = std::mem::take(&mut body.attrs); @@ -95,6 +83,7 @@ fn expand(attr: TokenStream2, item: TokenStream2) -> syn::Result { ::testkit::run( ::testkit::TestInfo { name: #name_str, + id: #id, description: #description, }, body, @@ -112,39 +101,29 @@ 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, - }) - .filter(|line| !line.is_empty()) - .collect::>() - .join(" ") -} - #[cfg(test)] mod tests { use super::expand; + 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_the_id() { + let out = expand_str("id = \"t-0123abcd\"", ITEM).unwrap(); + assert!(out.contains("Some (\"t-0123abcd\")"), "{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 = \"t-1\"", - format!("/// Doc.\n{ok_fn}"), - "takes no arguments", - ), + ("id = \"nope\"", ITEM.to_string(), "test IDs are"), + ("speed = fast", ITEM.to_string(), "unknown argument"), ("", ok_fn.to_string(), "doc comment"), ( "", @@ -167,7 +146,7 @@ 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}"); } } 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..981b3b91a1 --- /dev/null +++ b/tests/testkit/testkit-spec/src/lib.rs @@ -0,0 +1,486 @@ +// 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 parses tests through this crate. A test +//! runner's catalog that does the same cannot disagree with the macro about a +//! test's ID, name, or description. 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::{Attribute, Expr, ExprLit, Ident, Item, ItemFn, ItemMod, Lit, LitStr, Meta, Token}; + +/// 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"; + +/// 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, + /// Whether the function has `#[ignore]`. nextest skips such a test unless + /// run with `--run-ignored`, so a runner can leave it out of what it expects. + pub ignored: bool, +} + +/// Arguments accepted by `#[testkit::test(...)]`. +#[derive(Default)] +struct Args { + id: Option, +} + +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 { + return Err(syn::Error::new( + key.span(), + format!("unknown argument `{key}`; expected `id = \"...\"`"), + )); + } + 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, has a `#[cfg]` or `#[cfg_attr]` attribute, or +/// whose `id` argument is malformed. +pub fn parse(args: TokenStream, item: &ItemFn) -> syn::Result { + let args: Args = syn::parse2(args)?; + + if let Some(attr) = item.attrs.iter().find(|attr| is_conditional(attr)) { + return Err(syn::Error::new_spanned(attr, CONDITIONAL_TEST)); + } + + 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, + }; + Ok(TestSpec { + id, + name: item.sig.ident.clone(), + description, + 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, a marked function is not a +/// valid test, a marked function is inside an inline `mod`, or the file has +/// tests and either an inner `#![cfg]` or `#![cfg_attr]` attribute or another +/// kind of test such as `#[test]` or `#[tokio::test]`. +pub fn scan(source: &str) -> syn::Result> { + let file = syn::parse_file(source)?; + let mut tests = Vec::new(); + for item in &file.items { + if let Item::Mod(module) = item { + reject_nested_tests(module)?; + } + 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)?); + } + if !tests.is_empty() + && let Some(attr) = file.attrs.iter().find(|attr| is_conditional(attr)) + { + return Err(syn::Error::new_spanned(attr, CONDITIONAL_TEST)); + } + if !tests.is_empty() + && let Some(name) = find_other_test(&file.items) + { + return Err(syn::Error::new_spanned( + name, + "a file with `#[testkit::test]` functions can't have other tests: nextest runs \ + them, but tools that list tests from source don't expect them. Make it a \ + `#[testkit::test]`, or move it to a separate test file", + )); + } + Ok(tests) +} + +/// The first function, at any depth, marked as a test by something other +/// than `#[testkit::test]`, such as `#[test]` or `#[tokio::test]`. +fn find_other_test(items: &[Item]) -> Option<&syn::Ident> { + items.iter().find_map(|item| match item { + Item::Fn(item) => item + .attrs + .iter() + .any(|attr| !is_testkit_test(attr) && is_test_attribute(attr)) + .then_some(&item.sig.ident), + Item::Mod(module) => module + .content + .as_ref() + .and_then(|(_, items)| find_other_test(items)), + _ => None, + }) +} + +/// Whether an attribute marks a test, judged by its last path segment: +/// `#[test]`, `#[tokio::test]`, `#[rstest]`, and so on. +fn is_test_attribute(attr: &Attribute) -> bool { + attr.path() + .segments + .last() + .is_some_and(|segment| segment.ident == "test" || segment.ident == "rstest") +} + +/// Why conditional compilation is refused. [`scan`] reads source without +/// compiling it, so it can't tell whether a `cfg` keeps a test in the guest +/// build, and a runner would expect a test that never runs. +const CONDITIONAL_TEST: &str = "testkit tests can't use `#[cfg]` or `#[cfg_attr]`: tests are \ + listed from source, which can't tell whether they are compiled. Put a test that only \ + applies to some environments in a `features` or `drivers` suite, or use \ + `#[ignore = \"\"]` to skip it everywhere"; + +fn is_conditional(attr: &Attribute) -> bool { + attr.path().is_ident("cfg") || attr.path().is_ident("cfg_attr") +} + +/// Refuse tests inside inline modules, so a tool listing tests with [`scan`] +/// cannot silently miss tests that nextest runs. Refusing is only the simplest +/// option for now; `scan` could look inside modules instead if suites want to +/// group tests that way. +fn reject_nested_tests(module: &ItemMod) -> syn::Result<()> { + for item in module.content.iter().flat_map(|(_, items)| items) { + match item { + Item::Fn(item) if item.attrs.iter().any(is_testkit_test) => { + return Err(syn::Error::new_spanned( + &item.sig.ident, + "`#[testkit::test]` functions must be at the top level of the file, \ + not inside a `mod`", + )); + } + Item::Mod(module) => reject_nested_tests(module)?, + _ => {} + } + } + Ok(()) +} + +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."); + } + + #[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_an_id() { + let spec = parse_str("id = \"t-0123abcd\"", ITEM).unwrap(); + assert_eq!(spec.id.as_deref(), Some("t-0123abcd")); + } + + #[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 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(()) } + + async fn helper(cx: &mut Context) {} + ", + ) + .unwrap(); + let names: Vec<_> = tests.iter().map(|test| test.name.to_string()).collect(); + assert_eq!(names, ["plain", "identified"]); + assert_eq!(tests[0].id, None); + assert_eq!(tests[1].id.as_deref(), Some("t-00000001")); + } + + #[test] + fn scan_rejects_other_tests_alongside_testkit_tests() { + for other in [ + "#[test]\nfn other() {}", + "#[tokio::test]\nasync fn other() {}", + "#[cfg(test)]\nmod tests {\n#[test]\nfn other() {}\n}", + ] { + let err = scan(&format!( + "/// Doc.\n#[testkit::test]\nasync fn t(cx: &mut Context) -> testkit::Result {{ Ok(()) }}\n{other}" + )) + .unwrap_err(); + assert!( + err.to_string().contains("can't have other tests"), + "{other}: {err}" + ); + } + // A file without testkit tests is not a testkit suite, so it is left alone. + assert!(scan("#[test]\nfn other() {}").unwrap().is_empty()); + } + + #[test] + fn scan_rejects_tests_in_modules() { + let err = scan( + "mod outer { mod inner {\n/// Doc.\n#[testkit::test]\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }\n} }", + ) + .unwrap_err(); + assert!(err.to_string().contains("top level"), "{err}"); + } + + #[test] + fn rejects_conditional_tests() { + for attr in [ + "#[cfg(any())]", + "#[cfg(target_os = \"linux\")]", + "#[cfg_attr(target_os = \"linux\", ignore)]", + ] { + let source = format!( + "/// Doc.\n{attr}\n#[testkit::test]\nasync fn t(cx: &mut Context) -> testkit::Result {{ Ok(()) }}" + ); + let err = scan(&source).unwrap_err(); + assert!(err.to_string().contains("`#[cfg]`"), "{attr}: {err}"); + } + } + + #[test] + fn scan_rejects_conditional_files_with_tests() { + let err = scan( + "#![cfg(target_os = \"linux\")]\n/// Doc.\n#[testkit::test]\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap_err(); + assert!(err.to_string().contains("`#[cfg]`"), "{err}"); + assert!( + scan("#![cfg(target_os = \"linux\")]\nfn helper() {}") + .unwrap() + .is_empty() + ); + } + + #[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/src/context.rs b/tests/testkit/testkit/src/context.rs index 24e3f0a37a..9d4b4a0ff5 100644 --- a/tests/testkit/testkit/src/context.rs +++ b/tests/testkit/testkit/src/context.rs @@ -101,6 +101,31 @@ 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, + }) + } + /// 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/testkit/testkit/src/lib.rs b/tests/testkit/testkit/src/lib.rs index 168fdbe894..d870044329 100644 --- a/tests/testkit/testkit/src/lib.rs +++ b/tests/testkit/testkit/src/lib.rs @@ -20,7 +20,7 @@ //! ``` //! //! Tests find the gateway the same way the `openshell` CLI does (see -//! [`gateway`]). +//! [`gateway`]). `id = "t-..."` optionally gives a test a stable identity. mod cli; mod context; diff --git a/tests/testkit/testkit/src/run.rs b/tests/testkit/testkit/src/run.rs index 731ead0557..ab808d7333 100644 --- a/tests/testkit/testkit/src/run.rs +++ b/tests/testkit/testkit/src/run.rs @@ -17,6 +17,8 @@ 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, } @@ -33,7 +35,10 @@ 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), + } let gateway = Gateway::from_env().unwrap_or_else(|error| panic!("[{name}] {error}")); eprintln!("[{name}] {}", gateway.describe()); 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/ansible/playbooks/conformance/cli.yaml b/tests/tmachine/ansible/playbooks/conformance/cli.yaml index e841a11442..071672167d 100644 --- a/tests/tmachine/ansible/playbooks/conformance/cli.yaml +++ b/tests/tmachine/ansible/playbooks/conformance/cli.yaml @@ -83,7 +83,7 @@ when: gateway_status is succeeded and conformance_k3s.stat.exists block: - name: Verify Agent Sandbox reconciliation - ansible.builtin.include_tasks: k3s-ready.yaml + ansible.builtin.include_tasks: ../k3s-controller-ready.yaml rescue: - name: Record controller readiness failure ansible.builtin.set_fact: diff --git a/tests/tmachine/ansible/playbooks/conformance/k3s-ready.yaml b/tests/tmachine/ansible/playbooks/k3s-controller-ready.yaml similarity index 100% rename from tests/tmachine/ansible/playbooks/conformance/k3s-ready.yaml rename to tests/tmachine/ansible/playbooks/k3s-controller-ready.yaml diff --git a/tests/tmachine/ansible/playbooks/openshell-k3s.yaml b/tests/tmachine/ansible/playbooks/openshell-k3s.yaml index e39a9f3523..aca8ec958f 100644 --- a/tests/tmachine/ansible/playbooks/openshell-k3s.yaml +++ b/tests/tmachine/ansible/playbooks/openshell-k3s.yaml @@ -167,6 +167,11 @@ - k3s_gateway_address.stdout is match('^[0-9.]+:[0-9]+$') fail_msg: "Unexpected gateway Service address: {{ k3s_gateway_address.stdout }}" + # Deployment readiness doesn't establish that the controller reconciles + # Sandboxes, so the first test would otherwise race it. Tests don't wait. + - name: Verify Agent Sandbox reconciliation + ansible.builtin.include_tasks: k3s-controller-ready.yaml + - name: Register OpenShell gateway for test client hosts: all gather_facts: false diff --git a/tests/tmachine/ansible/playbooks/test-tools.yaml b/tests/tmachine/ansible/playbooks/test-tools.yaml new file mode 100644 index 0000000000..394810f8f2 --- /dev/null +++ b/tests/tmachine/ansible/playbooks/test-tools.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +# Tools the testkit suites run on the machine itself, next to cargo-nextest. +# The file-transfer suite builds a Git repository there to check that upload +# honors .gitignore. +- name: Install test tools + hosts: all + become: true + gather_facts: false + tasks: + - name: Wait for SSH + ansible.builtin.wait_for_connection: + + - name: Install Git + ansible.builtin.package: + name: git + state: present diff --git a/tests/tmachine/config.nix b/tests/tmachine/config.nix index 8f048e16af..98d0c41519 100644 --- a/tests/tmachine/config.nix +++ b/tests/tmachine/config.nix @@ -4,6 +4,7 @@ { pkgs, toolchains, + rustToolchain, qemuPkgs ? pkgs, firmwarePkgs ? pkgs, }: @@ -47,6 +48,7 @@ let use_galaxy = true; playbooks = [ "ansible/playbooks/nextest.yaml" + "ansible/playbooks/test-tools.yaml" "ansible/playbooks/docker.yaml" ]; }; @@ -58,6 +60,7 @@ let use_galaxy = false; playbooks = [ "ansible/playbooks/nextest.yaml" + "ansible/playbooks/test-tools.yaml" "ansible/playbooks/k3s.yaml" ]; }; @@ -69,6 +72,7 @@ let use_galaxy = false; playbooks = [ "ansible/playbooks/nextest.yaml" + "ansible/playbooks/test-tools.yaml" "ansible/playbooks/selinux.yaml" "ansible/playbooks/podman-rootful.yaml" ]; @@ -81,6 +85,7 @@ let use_galaxy = false; playbooks = [ "ansible/playbooks/nextest.yaml" + "ansible/playbooks/test-tools.yaml" "ansible/playbooks/selinux.yaml" "ansible/playbooks/podman-rootless.yaml" ]; @@ -232,9 +237,42 @@ let exec ${tmachine}/bin/tmachine --config ${config} "$@" ''; }; + + # Runs test plans with tests/runner + # (`nix run .#openshell-test-runner -- test --plan tests/plans/cpu-tests.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 = "openshell-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-runner -- "$@" + ''; + }; 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..e6ec03650d 100644 --- a/tests/tmachine/src/qemu/layer.rs +++ b/tests/tmachine/src/qemu/layer.rs @@ -10,7 +10,7 @@ use blake3::Hasher; use directories::ProjectDirs; use super::img::QemuImage; -use super::vm::QemuVm; +use super::vm::{LAYER_POWER_OFF_TIMEOUT, QemuVm}; pub(super) async fn cached_layer( base_image: &Path, @@ -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(LAYER_POWER_OFF_TIMEOUT).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..37fb72cd9c --- /dev/null +++ b/tests/tmachine/src/qemu/session.rs @@ -0,0 +1,328 @@ +// 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 local + /// `sshpass` process is killed and the result is an error. The remote + /// command is not guaranteed to stop with it (there is no TTY to hang up), + /// so treat the machine as unusable after a timeout and shut it down. + 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..1ba16b3e49 100644 --- a/tests/tmachine/src/qemu/vm.rs +++ b/tests/tmachine/src/qemu/vm.rs @@ -3,82 +3,219 @@ #[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 the QMP power-off request has to be acknowledged. A guest or QEMU +/// that stops answering must not hang teardown. +const QMP_TIMEOUT: Duration = Duration::from_secs(10); + +/// How long a powered-off guest has to exit at teardown before QEMU is +/// killed. +const POWER_OFF_TIMEOUT: Duration = Duration::from_secs(120); + +/// How long a guest being saved as a cached layer has to power off. A heavily +/// provisioned guest (Docker, k3s) can take minutes to stop its services, and +/// a layer that is killed instead is not cached, so this is much longer than +/// [`POWER_OFF_TIMEOUT`]. It still bounds a guest that never stops. +pub(super) const LAYER_POWER_OFF_TIMEOUT: Duration = Duration::from_secs(600); + +/// 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 { + let value = std::env::var("TMACHINE_SSH_PORT").ok(); + let port = parse_ssh_port(value.as_deref()); + if let Some(value) = value.as_deref().filter(|_| port == DEFAULT_SSH_PORT) + && value.trim() != DEFAULT_SSH_PORT.to_string() + { + eprintln!( + "tmachine: ignoring TMACHINE_SSH_PORT={value:?}, which is not a port from 1 to 65535; using {DEFAULT_SSH_PORT}" + ); + } + port +} + +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); + } + + Ok(Self { + child, + runtime_dir, + pid, + }) + } + + /// The exit status, if QEMU has already exited. + pub(super) fn try_wait(&mut self) -> Option { + self.child.try_wait().ok().flatten() + } - Self { child, runtime_dir } + /// Wait for QEMU to exit after a power-off request, and check it exited + /// cleanly. QEMU is killed if it does not exit within `timeout`. + pub(super) async fn wait(mut self, timeout: Duration) -> Result<()> { + match tokio::time::timeout(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 {timeout:?} of the power-off request; killed it") + } + } } - pub(super) async fn wait(mut self) { - let status = self.child.wait().await.unwrap(); - assert!(status.success()); + /// Stop QEMU immediately and reap it. + pub(super) async fn kill(&mut self) { + let _ = self.child.kill().await; } - pub(super) async fn shutdown(&self) { + /// 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) { + let shutdown = match tokio::time::timeout(QMP_TIMEOUT, self.shutdown()).await { + Ok(result) => result, + Err(_) => Err(anyhow::anyhow!("QMP did not answer within {QMP_TIMEOUT:?}")), + }; + match shutdown { + Ok(()) => { + if let Err(error) = self.wait(POWER_OFF_TIMEOUT).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 +254,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 +281,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 +289,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 +325,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); + } +}