chore: merge main into stage 1 configuration updates

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
This commit is contained in:
Piotr Mlocek
2026-09-18 09:32:46 -07:00
45 changed files with 3550 additions and 400 deletions
+12
View File
@@ -8,3 +8,15 @@ final-status-level = "fail"
failure-output = "immediate-final"
# Print slow test names after 30 seconds and terminate them after two minutes.
slow-timeout = { period = "30s", terminate-after = 4 }
[profile.e2e-kubernetes]
fail-fast = false
status-level = "slow"
final-status-level = "fail"
failure-output = "immediate-final"
# Flag slow tests after 60s and terminate them after five minutes.
slow-timeout = { period = "60s", terminate-after = 5 }
# Relative to the profile store dir (`e2e/rust/target/nextest/e2e-kubernetes/`).
[profile.e2e-kubernetes.junit]
path = "../../../../../results/e2e-kubernetes.xml"
+77 -13
View File
@@ -23,6 +23,7 @@ jobs:
pull-requests: read
outputs:
should_run: ${{ steps.gate.outputs.should_run }}
run_integration: ${{ steps.labels.outputs.run_core_e2e }}
run_core_e2e: ${{ steps.labels.outputs.run_core_e2e }}
run_gpu_e2e: ${{ steps.labels.outputs.run_gpu_e2e }}
run_kubernetes_ha_e2e: ${{ steps.labels.outputs.run_kubernetes_ha_e2e }}
@@ -199,6 +200,69 @@ jobs:
packages: write
uses: ./.github/workflows/build-images.yml
prepare-integration:
needs: [pr_metadata, build-binaries, build-images]
if: needs.pr_metadata.outputs.run_integration == 'true'
permissions:
actions: read
contents: read
packages: read
uses: ./.github/workflows/prepare-integration-inputs.yml
# Run driver-independent conformance tests.
conformance-integration:
needs: prepare-integration
permissions:
actions: read
contents: read
packages: read
uses: ./.github/workflows/integration-runner.yml
with:
category: conformance
source-sha: ${{ needs.prepare-integration.outputs.source_sha }}
integration-inputs-artifact-id: ${{ needs.prepare-integration.outputs.integration_inputs_artifact_id }}
test-matrix: >-
[
{"environment":"ubuntu-docker-rootful","installer":"binaries","testsuite":"conformance"},
{"environment":"fedora-podman-rootful","installer":"binaries","testsuite":"conformance"},
{"environment":"fedora-podman-rootless","installer":"binaries","testsuite":"conformance"}
]
# Run feature-specific integration tests:
feature-specific-integration:
needs: prepare-integration
permissions:
actions: read
contents: read
packages: read
uses: ./.github/workflows/integration-runner.yml
with:
category: feature-specific
source-sha: ${{ needs.prepare-integration.outputs.source_sha }}
integration-inputs-artifact-id: ${{ needs.prepare-integration.outputs.integration_inputs_artifact_id }}
test-matrix: >-
[
{"environment":"fedora-podman-rootful","installer":"binaries","testsuite":"provider-refresh"},
{"environment":"fedora-podman-rootless","installer":"binaries","testsuite":"provider-refresh"}
]
# Run driver-specific integration tests:
# TODO: This should be added as soon as we have driver-specific tests enabled in tmachine.
# driver-specific:
# needs: prepare-integration
# permissions:
# actions: read
# contents: read
# packages: read
# uses: ./.github/workflows/integration-runner.yml
# with:
# category: driver-specific
# source-sha: ${{ needs.prepare-integration.outputs.source_sha }}
# integration-inputs-artifact-id: ${{ needs.prepare-integration.outputs.integration_inputs_artifact_id }}
# test-matrix: >-
# [
# ]
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'
@@ -212,18 +276,6 @@ jobs:
runner: linux-arm64-cpu8
conformance-artifact-prefix: openshell-conformance
podman-e2e:
needs: [pr_metadata, build-binaries, build-images]
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-podman-test.yml
with:
image-tag: ${{ github.sha }}
conformance-artifact-prefix: openshell-conformance
vm-e2e:
needs: [pr_metadata, build-binaries, build-vm-driver]
if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true'
@@ -392,7 +444,19 @@ jobs:
core-e2e-result:
name: Core E2E result
needs: [pr_metadata, docker-e2e, podman-e2e, vm-e2e, docker-external-driver-e2e, podman-external-driver-e2e, vm-external-driver-e2e, kubernetes-e2e, kubernetes-external-driver-e2e, kubernetes-workspace-managed-e2e, kubernetes-workspace-operator-e2e]
needs:
- pr_metadata
- conformance-integration
- feature-specific-integration
- docker-e2e
- vm-e2e
- docker-external-driver-e2e
- podman-external-driver-e2e
- vm-external-driver-e2e
- kubernetes-e2e
- kubernetes-external-driver-e2e
- kubernetes-workspace-managed-e2e
- kubernetes-workspace-operator-e2e
if: always() && needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true'
runs-on: ubuntu-latest
steps:
-137
View File
@@ -1,137 +0,0 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
name: Conformance
on:
workflow_call:
inputs:
scenarios:
description: JSON array of tmachine scenarios to run
required: true
type: string
testsuites:
description: JSON array of tmachine test suites to run
required: true
type: string
permissions:
actions: read
contents: read
concurrency:
group: ${{ github.workflow }}-conformance-${{ github.ref }}
cancel-in-progress: true
jobs:
prepare:
name: Prepare conformance inputs
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ github.sha }}
- uses: ./.github/actions/setup-nix
- name: Download CLI artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: openshell-x86_64-unknown-linux-musl
path: artifacts/binaries/x86_64-unknown-linux-musl
- name: Download gateway artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: openshell-gateway-x86_64-unknown-linux-gnu
path: artifacts/binaries/x86_64-unknown-linux-gnu
- name: Download sandbox image artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: image-sandbox
path: image-input/sandbox
- name: Download supervisor image artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: image-supervisor
path: image-input/supervisor
- name: Convert runtime images
shell: nix develop .#testing -c bash -euo pipefail {0}
run: |
mkdir -p artifacts/images
skopeo copy \
--override-os linux \
--override-arch amd64 \
oci-archive:image-input/sandbox/images/sandbox.tar \
docker-archive:artifacts/images/openshell-sandbox-tmachine.tar:openshell/sandbox:tmachine
skopeo copy \
--override-os linux \
--override-arch amd64 \
oci-archive:image-input/supervisor/images/supervisor.tar \
docker-archive:artifacts/images/openshell-supervisor-tmachine.tar:openshell/supervisor:tmachine
- name: Build conformance test archive
run: nix run .#build-artifacts-test-archives
- name: Upload conformance inputs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: openshell-conformance-inputs
path: artifacts
compression-level: 0
retention-days: 5
if-no-files-found: error
conformance:
name: Conformance (${{ matrix.scenario }}, ${{ matrix.testsuite }})
needs: prepare
strategy:
fail-fast: false
matrix:
scenario: ${{ fromJSON(inputs.scenarios) }}
testsuite: ${{ fromJSON(inputs.testsuites) }}
runs-on: ubuntu-24.04
timeout-minutes: 90
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ github.sha }}
- 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
- name: Cache tmachine disks
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
with:
path: ~/.cache/tmachine
key: tmachine-${{ runner.os }}-${{ runner.arch }}
- name: Download conformance inputs
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: openshell-conformance-inputs
path: artifacts
- name: Run conformance
env:
SCENARIO: ${{ matrix.scenario }}
TESTSUITE: ${{ matrix.testsuite }}
run: nix run .#tmachine -- test "${SCENARIO}" "${TESTSUITE}"
+1 -3
View File
@@ -26,10 +26,8 @@ on:
type: string
default: ""
suite-matrix:
required: false
required: true
type: string
default: >-
[{"suite":"provider-refresh-keycloak","runner":"ubuntu-26.04","podman_major":"5","podman_package_version":"5.7.0+ds2-3build1","conmon_package_version":"2.1.13+ds1-2","cmd":"mise run --no-deps --skip-deps e2e:provider-refresh-keycloak"}]
permissions:
actions: read
+86
View File
@@ -0,0 +1,86 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
name: Integration Test Runner
on:
workflow_call:
inputs:
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
category:
description: Integration-test category, used for the job name and concurrency key
required: false
type: string
test-matrix:
description: JSON array of environment, installer, and testsuite combinations
required: false
type: string
default: >-
[
{"environment":"ubuntu-docker-rootful","installer":"binaries","testsuite":"conformance"},
{"environment":"fedora-podman-rootful","installer":"binaries","testsuite":"conformance"},
{"environment":"fedora-podman-rootless","installer":"binaries","testsuite":"conformance"}
]
permissions:
actions: read
contents: read
packages: read
concurrency:
group: ${{ github.workflow }}-integration-${{ inputs.category }}-${{ github.ref }}
cancel-in-progress: true
jobs:
integration:
name: Integration test (${{ inputs.category }}, ${{ matrix.environment }}, ${{ matrix.installer }}, ${{ matrix.testsuite }})
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(inputs['test-matrix']) }}
runs-on: ubuntu-24.04
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
- name: Cache tmachine disks
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
with:
path: ~/.cache/tmachine
key: tmachine-${{ 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 test suite
env:
ENVIRONMENT: ${{ matrix.environment }}
INSTALLER: ${{ matrix.installer }}
TESTSUITE: ${{ matrix.testsuite }}
run: nix run .#tmachine -- test "${ENVIRONMENT}" "${INSTALLER}" "${TESTSUITE}"
+48
View File
@@ -0,0 +1,48 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
name: Integration Tests
on:
workflow_dispatch:
inputs:
artifact-run-id:
description: Workflow run containing the candidate artifacts
required: false
type: string
default: ""
category:
description: Integration-test category, used for the job name and concurrency key
required: true
type: string
default: conformance
test-matrix:
description: JSON array of environment, installer, and testsuite combinations
required: true
type: string
default: >-
[
{"environment":"ubuntu-docker-rootful","installer":"binaries","testsuite":"conformance"},
{"environment":"fedora-podman-rootful","installer":"binaries","testsuite":"conformance"},
{"environment":"fedora-podman-rootless","installer":"binaries","testsuite":"conformance"}
]
permissions:
actions: read
contents: read
packages: read
jobs:
prepare:
uses: ./.github/workflows/prepare-integration-inputs.yml
with:
artifact-run-id: ${{ inputs['artifact-run-id'] }}
integration:
needs: prepare
uses: ./.github/workflows/integration-runner.yml
with:
category: ${{ inputs.category }}
source-sha: ${{ needs.prepare.outputs.source_sha }}
integration-inputs-artifact-id: ${{ needs.prepare.outputs.integration_inputs_artifact_id }}
test-matrix: ${{ inputs.test-matrix }}
@@ -0,0 +1,96 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
name: Prepare Integration Inputs
on:
workflow_call:
inputs:
artifact-run-id:
description: Workflow run containing the candidate artifacts
required: false
type: string
default: ""
outputs:
source_sha:
description: Source revision of the candidate artifacts
value: ${{ jobs.prepare.outputs.source_sha }}
integration_inputs_artifact_id:
description: Immutable ID of the prepared integration inputs artifact
value: ${{ jobs.prepare.outputs.integration_inputs_artifact_id }}
permissions:
actions: read
contents: read
packages: read
jobs:
prepare:
name: Prepare tmachine inputs
runs-on: ubuntu-24.04
timeout-minutes: 30
outputs:
source_sha: ${{ steps.artifact-run.outputs.source_sha }}
integration_inputs_artifact_id: ${{ steps.upload-integration-inputs.outputs.artifact-id }}
steps:
- name: Resolve artifact run
id: artifact-run
env:
ARTIFACT_RUN_ID: ${{ inputs['artifact-run-id'] || github.run_id }}
GH_TOKEN: ${{ github.token }}
run: |
head_sha=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${ARTIFACT_RUN_ID}" --jq .head_sha)
echo "source_sha=${head_sha}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ steps.artifact-run.outputs.source_sha }}
persist-credentials: false
- uses: ./.github/actions/setup-nix
- name: Download CLI artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: openshell-x86_64-unknown-linux-musl
path: artifacts/binaries/x86_64-unknown-linux-musl
github-token: ${{ github.token }}
run-id: ${{ inputs['artifact-run-id'] || github.run_id }}
- name: Download gateway artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: openshell-gateway-x86_64-unknown-linux-gnu
path: artifacts/binaries/x86_64-unknown-linux-gnu
github-token: ${{ github.token }}
run-id: ${{ inputs['artifact-run-id'] || github.run_id }}
- name: Log in to GHCR
run: echo "${{ github.token }}" | docker login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin
- name: Export runtime images
env:
IMAGE_TAG: ${{ steps.artifact-run.outputs.source_sha }}
run: |
mkdir -p artifacts/images
docker pull "ghcr.io/nvidia/openshell/sandbox:${IMAGE_TAG}"
docker tag "ghcr.io/nvidia/openshell/sandbox:${IMAGE_TAG}" openshell/sandbox:tmachine
docker save --output artifacts/images/openshell-sandbox-tmachine.tar openshell/sandbox:tmachine
docker pull "ghcr.io/nvidia/openshell/supervisor:${IMAGE_TAG}"
docker tag "ghcr.io/nvidia/openshell/supervisor:${IMAGE_TAG}" openshell/supervisor:tmachine
docker save --output artifacts/images/openshell-supervisor-tmachine.tar openshell/supervisor:tmachine
- name: Build test archives
run: nix run .#build-artifacts-test-archives
- name: Upload integration inputs
id: upload-integration-inputs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: openshell-tmachine-inputs
path: artifacts
compression-level: 0
retention-days: 5
if-no-files-found: error
+42 -17
View File
@@ -101,15 +101,42 @@ jobs:
with:
checkout-ref: ${{ github.sha }}
conformance:
prepare-integration:
needs: [build-binaries, build-images]
permissions:
actions: read
contents: read
uses: ./.github/workflows/conformance.yml
packages: read
uses: ./.github/workflows/prepare-integration-inputs.yml
conformance-integration:
needs: prepare-integration
permissions:
actions: read
contents: read
packages: read
uses: ./.github/workflows/integration-runner.yml
with:
scenarios: '["ubuntu-docker-rootful", "fedora-podman-rootful", "fedora-podman-rootless"]'
testsuites: '["conformance"]'
category: conformance
source-sha: ${{ needs.prepare-integration.outputs.source_sha }}
integration-inputs-artifact-id: ${{ needs.prepare-integration.outputs.integration_inputs_artifact_id }}
feature-specific-integration:
needs: prepare-integration
permissions:
actions: read
contents: read
packages: read
uses: ./.github/workflows/integration-runner.yml
with:
category: feature-specific
source-sha: ${{ needs.prepare-integration.outputs.source_sha }}
integration-inputs-artifact-id: ${{ needs.prepare-integration.outputs.integration_inputs_artifact_id }}
test-matrix: >-
[
{"environment":"fedora-podman-rootful","installer":"binaries","testsuite":"provider-refresh"},
{"environment":"fedora-podman-rootless","installer":"binaries","testsuite":"provider-refresh"}
]
docker-e2e:
needs: [build-binaries, build-images]
@@ -124,18 +151,6 @@ jobs:
runner: linux-arm64-cpu8
conformance-artifact-prefix: openshell-conformance
podman-e2e:
needs: [build-binaries, build-images]
permissions:
actions: read
contents: read
packages: read
uses: ./.github/workflows/e2e-podman-test.yml
with:
image-tag: ${{ github.sha }}
checkout-ref: ${{ github.sha }}
conformance-artifact-prefix: openshell-conformance
vm-e2e:
needs: [build-binaries, build-vm-driver]
permissions:
@@ -244,7 +259,17 @@ jobs:
# ---------------------------------------------------------------------------
release-dev:
name: Release Dev
needs: [compute-versions, package-binaries, build-python-wheel, conformance, docker-e2e, podman-e2e, vm-e2e, build-deb, build-rpm, build-snap]
needs:
- compute-versions
- package-binaries
- build-python-wheel
- conformance-integration
- feature-specific-integration
- docker-e2e
- vm-e2e
- build-deb
- build-rpm
- build-snap
runs-on: linux-amd64-cpu8
timeout-minutes: 10
permissions:
+48 -13
View File
@@ -129,6 +129,43 @@ jobs:
image-tag: ${{ needs.compute-versions.outputs.source_sha }}
checkout-ref: ${{ needs.compute-versions.outputs.source_sha }}
prepare-integration:
needs: [compute-versions, build-binaries, build-images]
permissions:
actions: read
contents: read
packages: read
uses: ./.github/workflows/prepare-integration-inputs.yml
conformance-integration:
needs: prepare-integration
permissions:
actions: read
contents: read
packages: read
uses: ./.github/workflows/integration-runner.yml
with:
category: conformance
source-sha: ${{ needs.prepare-integration.outputs.source_sha }}
integration-inputs-artifact-id: ${{ needs.prepare-integration.outputs.integration_inputs_artifact_id }}
feature-specific-integration:
needs: prepare-integration
permissions:
actions: read
contents: read
packages: read
uses: ./.github/workflows/integration-runner.yml
with:
category: feature-specific
source-sha: ${{ needs.prepare-integration.outputs.source_sha }}
integration-inputs-artifact-id: ${{ needs.prepare-integration.outputs.integration_inputs_artifact_id }}
test-matrix: >-
[
{"environment":"fedora-podman-rootful","installer":"binaries","testsuite":"provider-refresh"},
{"environment":"fedora-podman-rootless","installer":"binaries","testsuite":"provider-refresh"}
]
docker-e2e:
needs: [compute-versions, build-binaries, build-images]
permissions:
@@ -142,18 +179,6 @@ jobs:
runner: linux-arm64-cpu8
conformance-artifact-prefix: openshell-conformance
podman-e2e:
needs: [compute-versions, build-binaries, build-images]
permissions:
actions: read
contents: read
packages: read
uses: ./.github/workflows/e2e-podman-test.yml
with:
image-tag: ${{ needs.compute-versions.outputs.source_sha }}
checkout-ref: ${{ needs.compute-versions.outputs.source_sha }}
conformance-artifact-prefix: openshell-conformance
vm-e2e:
needs: [compute-versions, build-binaries, build-vm-driver]
permissions:
@@ -273,7 +298,17 @@ jobs:
# ---------------------------------------------------------------------------
release:
name: Release
needs: [compute-versions, package-binaries, build-python-wheel, docker-e2e, podman-e2e, vm-e2e, build-deb, build-rpm, build-snap]
needs:
- compute-versions
- package-binaries
- build-python-wheel
- conformance-integration
- feature-specific-integration
- docker-e2e
- vm-e2e
- build-deb
- build-rpm
- build-snap
runs-on: linux-amd64-cpu8
timeout-minutes: 10
permissions:
+3
View File
@@ -6,6 +6,9 @@
/target/
e2e/rust/target/
target/
# Test result artifacts (JUnit XML, etc.)
/results/
debug/
release/
+13 -3
View File
@@ -258,13 +258,23 @@ for explicit publication.
CLI conformance runs after target provisioning and operates only through the
configured OpenShell CLI. The smoke scenario verifies the black-box sandbox
lifecycle by creating, inspecting, executing in, and deleting a sandbox.
Feature suites use the same disposable guest but may provision isolated
dependencies after installation. The Keycloak provider-refresh suite starts a
guest-local Keycloak realm and verifies a successful OAuth refresh followed by
revocation and the gateway's reauthorization-required recovery state.
The `tests/tmachine` setup and installation caches include a digest of the
Tmachine environments define the guest machine and runtime setup, while named
installers define how OpenShell is installed. This keeps the runtime mode
independent from binary or package installation and lets multiple installers
reuse the same prepared setup disk. The test command is
`tmachine test <environment> <installer> <testsuite>`.
The `tests/tmachine` setup and install caches include a digest of the
entire directory containing `ANSIBLE_CONFIG`, including local roles, task
includes, templates, inventory, and requirements. The digest uses sorted
relative paths, file contents, and executable permissions; source symlinks
are unsupported. Both keys also retain the ordered playbook paths and contents,
their base disk contents, and whether Galaxy is enabled; installation keys
their base disk contents, and whether Galaxy is enabled; install keys
include named binary inputs. The top-level `.roles` directory is excluded:
Galaxy release pins in `requirements.yaml` are treated as immutable, including
any transitive dependency pins. Cache misses with Galaxy enabled reinstall
@@ -275,7 +285,7 @@ with musl, and the gateway and supervisor with GNU. Image assembly stages
the gateway, sandbox, and supervisor as separate binaries for their respective
Dockerfiles. The helpers stage binaries under `artifacts/binaries` so local and
CI builds expose the same inputs to tmachine and image assembly. The Ubuntu
Docker and Fedora Podman scenarios import both local runtime images and
Docker and Fedora Podman environments import both local runtime images and
configure the gateway to use them.
## Python Wheel Packaging
+13 -2
View File
@@ -15,7 +15,12 @@ pub type CliExecution<'a> =
Pin<Box<dyn Future<Output = Result<Output, CliExecutionError>> + Send + 'a>>;
pub trait CliExecutor: Send + Sync {
fn execute(&self, args: Vec<String>, command_timeout: Duration) -> CliExecution<'_>;
fn execute(
&self,
args: Vec<String>,
environment: Vec<(String, String)>,
command_timeout: Duration,
) -> CliExecution<'_>;
}
pub enum CliExecutionError {
@@ -34,11 +39,17 @@ impl ProcessCli {
}
impl CliExecutor for ProcessCli {
fn execute(&self, args: Vec<String>, command_timeout: Duration) -> CliExecution<'_> {
fn execute(
&self,
args: Vec<String>,
environment: Vec<(String, String)>,
command_timeout: Duration,
) -> CliExecution<'_> {
Box::pin(async move {
let mut process = tokio::process::Command::new(&self.binary);
process
.args(&args)
.envs(environment)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
+104 -18
View File
@@ -248,6 +248,8 @@ pub struct OpenShellRunner {
run_id: String,
scenario: String,
known_sandboxes: BTreeSet<String>,
known_providers: BTreeSet<String>,
known_provider_profiles: BTreeSet<String>,
finished: bool,
}
@@ -311,6 +313,8 @@ impl OpenShellRunner {
run_id: generate_run_id(),
scenario: scenario.to_string(),
known_sandboxes: BTreeSet::new(),
known_providers: BTreeSet::new(),
known_provider_profiles: BTreeSet::new(),
finished: false,
}
}
@@ -393,6 +397,16 @@ impl OpenShellRunner {
self.known_sandboxes.remove(name);
}
/// Register a provider name for cleanup.
pub fn track_provider(&mut self, name: &str) {
self.known_providers.insert(name.to_string());
}
/// Register a provider profile ID for cleanup.
pub fn track_provider_profile(&mut self, id: &str) {
self.known_provider_profiles.insert(id.to_string());
}
pub async fn poll_until<T, F>(
&mut self,
step: &str,
@@ -440,6 +454,7 @@ impl OpenShellRunner {
step: &str,
expectation: &str,
args: Vec<String>,
environment: Vec<(String, String)>,
command_timeout: Duration,
) -> Result<CommandResult, RunnerError> {
let context = self.context(step);
@@ -447,22 +462,22 @@ impl OpenShellRunner {
eprintln!("{context} running: {command}");
let started = Instant::now();
let output =
self.cli
.execute(args, command_timeout)
.await
.map_err(|error| match error {
CliExecutionError::Timeout => RunnerError::Timeout {
context: context.clone(),
command: command.clone(),
timeout: command_timeout,
},
CliExecutionError::Spawn(source) => RunnerError::Spawn {
context: context.clone(),
command: command.clone(),
source,
},
})?;
let output = self
.cli
.execute(args, environment, command_timeout)
.await
.map_err(|error| match error {
CliExecutionError::Timeout => RunnerError::Timeout {
context: context.clone(),
command: command.clone(),
timeout: command_timeout,
},
CliExecutionError::Spawn(source) => RunnerError::Spawn {
context: context.clone(),
command: command.clone(),
source,
},
})?;
let elapsed = started.elapsed();
eprintln!(
"{context} completed in {:.1?}: exit {}",
@@ -484,7 +499,10 @@ impl OpenShellRunner {
}
async fn cleanup(&self) -> Result<(), String> {
if self.known_sandboxes.is_empty() {
if self.known_sandboxes.is_empty()
&& self.known_providers.is_empty()
&& self.known_provider_profiles.is_empty()
{
return Ok(());
}
@@ -516,6 +534,56 @@ impl OpenShellRunner {
}
}
for name in self.known_providers.clone() {
let remaining = remaining_cleanup_time(cleanup_started);
if remaining.is_zero() {
failures.push(format!(
"{} cleanup budget expired before deleting provider '{name}'",
self.context("cleanup/delete-provider")
));
break;
}
match self
.step("cleanup/delete-provider")
.description(format!("provider '{name}' is deleted or already absent"))
.with_timeout(remaining)
.run(&["provider", "delete", &name])
.await
{
Ok(result) if result.success() || output_reports_not_found(&result) => {}
Ok(result) => failures.push(result.failure_diagnostic(&format!(
"provider '{name}' is deleted or already absent"
))),
Err(error) => failures.push(error.to_string()),
}
}
for id in self.known_provider_profiles.clone() {
let remaining = remaining_cleanup_time(cleanup_started);
if remaining.is_zero() {
failures.push(format!(
"{} cleanup budget expired before deleting provider profile '{id}'",
self.context("cleanup/delete-provider-profile")
));
break;
}
match self
.step("cleanup/delete-provider-profile")
.description(format!(
"provider profile '{id}' is deleted or already absent"
))
.with_timeout(remaining)
.run(&["provider", "profile", "delete", &id])
.await
{
Ok(result) if result.success() || output_reports_not_found(&result) => {}
Ok(result) => failures.push(result.failure_diagnostic(&format!(
"provider profile '{id}' is deleted or already absent"
))),
Err(error) => failures.push(error.to_string()),
}
}
if failures.is_empty() {
Ok(())
} else {
@@ -550,11 +618,24 @@ impl<'a> CommandStep<'a> {
impl OpenShellCommand<'_> {
pub async fn run(&self, args: &[&str]) -> Result<CommandResult, RunnerError> {
self.run_with_env(args, &[]).await
}
/// Run a command with environment values that are excluded from diagnostics.
pub async fn run_with_env(
&self,
args: &[&str],
environment: &[(&str, &str)],
) -> Result<CommandResult, RunnerError> {
self.runner
.run_strings(
&self.step,
&self.description,
args.iter().map(|arg| (*arg).to_string()).collect(),
environment
.iter()
.map(|(name, value)| ((*name).to_string(), (*value).to_string()))
.collect(),
self.timeout,
)
.await
@@ -724,7 +805,12 @@ mod tests {
}
impl CliExecutor for MockCli {
fn execute(&self, args: Vec<String>, _command_timeout: Duration) -> CliExecution<'_> {
fn execute(
&self,
args: Vec<String>,
_environment: Vec<(String, String)>,
_command_timeout: Duration,
) -> CliExecution<'_> {
let response = {
let mut state = self.state.lock().expect("lock mock CLI state");
state.invocations.push(args);
+8 -3
View File
@@ -34,6 +34,11 @@ COMMUNITY_SANDBOX_IMAGE="${COMMUNITY_SANDBOX_IMAGE:-ghcr.io/nvidia/openshell-com
UMOCI_VERSION="${UMOCI_VERSION:-v0.6.0}"
# ── libkrunfw upstream (commit-pinned) ─────────────────────────────────
# Repo: https://github.com/containers/libkrunfw
# Pinned: 2026-03-27 (main branch HEAD at time of pinning)
LIBKRUNFW_REF="${LIBKRUNFW_REF:-463f717bbdd916e1352a025b6fb2456e882b0b39}"
# Repo: https://github.com/libkrun/libkrunfw
# Pinned: v5.6.1 (2026-09-16)
LIBKRUNFW_REF="${LIBKRUNFW_REF:-617938cf2ae9a3a0e5696d23a5d6252cd7a1ef4f}"
# ── libkrun upstream (commit-pinned) ──────────────────────────────────
# Repo: https://github.com/libkrun/libkrun
# Pinned: v1.19.4 (2026-09-16)
LIBKRUN_REF="${LIBKRUN_REF:-728df8125077d0db44265f6e997c72b81b65c015}"
+120 -2
View File
@@ -2670,7 +2670,7 @@ fn prepare_startup_configuration(
"Effective configuration admission rejected"
));
}
if provider.readiness_reason != ProviderReadinessReason::Unspecified {
if !provider_environment_is_installable(provider.readiness_reason) {
return Err(miette::miette!(
"Provider credentials are not ready for installation"
));
@@ -2686,6 +2686,18 @@ fn prepare_startup_configuration(
Ok((engine, process_policy, credentials))
}
/// Whether the provider response contains a complete environment snapshot that
/// can be installed. Withheld and expired credentials are intentionally absent,
/// so those responses remain valid fail-closed snapshots.
fn provider_environment_is_installable(reason: ProviderReadinessReason) -> bool {
matches!(
reason,
ProviderReadinessReason::Unspecified
| ProviderReadinessReason::CredentialsWithheld
| ProviderReadinessReason::CredentialExpired
)
}
fn prepare_provider_environment(
provider: &openshell_core::grpc_client::ProviderEnvironmentResult,
) -> Result<ProviderCredentialState> {
@@ -4296,7 +4308,7 @@ async fn run_policy_poll_loop_with_client<C: PolicyGatewayClient>(
{
Ok(provider)
if EnvironmentIdentity::from_environment(&provider) == desired_identity
&& provider.readiness_reason == ProviderReadinessReason::Unspecified =>
&& provider_environment_is_installable(provider.readiness_reason) =>
{
provider
}
@@ -5562,6 +5574,49 @@ network_policies:
assert_eq!(credentials.revision(), 10);
}
#[test]
fn startup_configuration_accepts_fail_closed_provider_environment() {
let policy = proto_policy_fixture();
let mut snapshot = settings_poll_result(
Some(policy.clone()),
1,
openshell_core::proto::PolicySource::Sandbox,
);
snapshot.provider_env_revision = 10;
for reason in [
ProviderReadinessReason::CredentialsWithheld,
ProviderReadinessReason::CredentialExpired,
] {
let mut provider = startup_provider(10);
provider.readiness_reason = reason;
provider
.environment
.insert("PROJECT_ID".to_string(), "example-project".to_string());
provider
.non_secret_environment_keys
.push("PROJECT_ID".to_string());
let (_, _, credentials) = prepare_startup_configuration(&snapshot, &policy, &provider)
.expect("withheld credentials preserve fail-closed startup");
assert_eq!(credentials.revision(), 10);
assert!(credentials.snapshot().child_env.contains_key("PROJECT_ID"));
}
}
#[test]
fn startup_configuration_rejects_provider_installation_failure() {
let policy = proto_policy_fixture();
let snapshot = settings_poll_result(
Some(policy.clone()),
1,
openshell_core::proto::PolicySource::Sandbox,
);
let mut provider = startup_provider(0);
provider.readiness_reason = ProviderReadinessReason::CredentialInstallFailed;
assert!(prepare_startup_configuration(&snapshot, &policy, &provider).is_err());
}
#[test]
fn startup_configuration_revalidates_on_restart_and_accepts_repair() {
let policy = proto_policy_fixture();
@@ -6153,6 +6208,69 @@ network_policies:
let _ = task.await;
}
#[tokio::test]
async fn provider_poll_installs_fail_closed_environment_and_acknowledges_policy() {
let policy = proto_policy_fixture();
let initial = settings_poll_result(
Some(policy.clone()),
1,
openshell_core::proto::PolicySource::Sandbox,
);
let engine = Arc::new(OpaEngine::from_proto(&policy).unwrap());
let ctx = policy_poll_test_context(
engine,
LoadedPolicyOrigin::Gateway {
revision: Some(LoadedPolicyRevision::from_snapshot(&initial)),
has_last_valid_policy: true,
},
default_middleware_connector(),
);
let credentials = ctx.provider_credentials.clone();
let (policy_gateway, polls, mut reports) = scripted_policy_gateway();
let (requests, mut received) = tokio::sync::mpsc::unbounded_channel();
polls.send(initial).unwrap();
let task = tokio::spawn(run_policy_poll_loop_with_client(
ctx,
ScriptedProviderGateway {
policy: policy_gateway,
requests,
},
));
expect_policy_report(&mut reports, 1).await;
let mut changed = settings_poll_result(
Some(policy),
2,
openshell_core::proto::PolicySource::Sandbox,
);
changed.provider_env_revision = 1;
polls.send(changed).unwrap();
let response = timeout(Duration::from_secs(1), received.recv())
.await
.expect("provider refresh requested")
.expect("poll loop active");
let mut withheld = static_provider_environment(1, None);
withheld.policy_hash = "hash-v2".to_string();
withheld.readiness_reason = ProviderReadinessReason::CredentialsWithheld;
withheld
.environment
.insert("PROJECT_ID".to_string(), "example-project".to_string());
withheld
.non_secret_environment_keys
.push("PROJECT_ID".to_string());
assert!(response.send(Ok(withheld)).is_ok());
expect_policy_report(&mut reports, 2).await;
assert_eq!(credentials.revision(), 1);
assert!(
credentials.snapshot().child_env.contains_key("PROJECT_ID"),
"the reduced snapshot retains non-secret provider configuration"
);
task.abort();
let _ = task.await;
}
#[tokio::test]
async fn provider_poll_preserves_static_references_across_rotation_failure_and_detach() {
let engine = Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).unwrap());
-6
View File
@@ -35,7 +35,6 @@ e2e-kubernetes-workspace-operator = ["e2e-kubernetes"]
e2e-podman = ["e2e", "e2e-host-gateway", "e2e-local-container-driver"]
e2e-podman-gpu = ["e2e-podman", "e2e-gpu"]
e2e-oidc-pkce = []
e2e-provider-refresh-keycloak = []
e2e-vm = ["e2e", "e2e-host-gateway"]
[[test]]
@@ -48,11 +47,6 @@ name = "oidc_pkce"
path = "tests/oidc_pkce.rs"
required-features = ["e2e-oidc-pkce"]
[[test]]
name = "provider_refresh_keycloak"
path = "tests/provider_refresh_keycloak.rs"
required-features = ["e2e-provider-refresh-keycloak"]
[[test]]
name = "vm_overlay"
path = "tests/vm_overlay.rs"
+46 -11
View File
@@ -14,6 +14,9 @@
# via `server.hostGatewayIP`. Targeting a cluster where the test host is
# unreachable from pods? Set OPENSHELL_E2E_KUBERNETES_FEATURES=e2e to drop the
# alias-dependent tests entirely.
#
# Results: `run_suite` writes a JUnit + HTML report under `results/`. Set
# `OPENSHELL_E2E_REPORT_NAME` to name it per run when invoking this script repeatedly.
set -euo pipefail
@@ -24,6 +27,25 @@ source "${ROOT}/e2e/support/conformance.sh"
E2E_FEATURES="${OPENSHELL_E2E_KUBERNETES_FEATURES-e2e,e2e-host-gateway,e2e-kubernetes}"
# Fixed output path of the `e2e-kubernetes` nextest profile (`.config/nextest.toml`).
JUNIT_XML="${ROOT}/results/e2e-kubernetes.xml"
# Render a sibling HTML report from a JUnit XML. Best-effort: failures only warn.
render_html() {
local xml="${1:-${JUNIT_XML}}"
local title="${2:-e2e-kubernetes}"
[ -f "${xml}" ] || return 0
local html="${xml%.xml}.html"
if command -v xsltproc >/dev/null 2>&1; then
xsltproc --stringparam title "${title}" \
"${ROOT}/scripts/junit-to-html.xsl" "${xml}" >"${html}" \
&& echo "HTML report: ${html}" \
|| echo "WARNING: failed to render HTML report from ${xml}" >&2
else
echo "WARNING: xsltproc not found; skipping HTML report (${xml} still written)" >&2
fi
}
# Docker and Podman build their local gateway and CLI together in the shared
# gateway wrapper. Kubernetes consumes published gateway images, so only its
# local CLI needs to be built when CI has not supplied a prebuilt one.
@@ -50,9 +72,19 @@ run_conformance() {
e2e_run_openshell_conformance "Kubernetes"
}
# `OPENSHELL_E2E_REPORT_NAME` (default `e2e-kubernetes`) names the report
# `results/<name>.{xml,html}` and its heading, so repeated runs do not clobber.
run_suite() {
local name="${OPENSHELL_E2E_REPORT_NAME:-e2e-kubernetes}"
local report="${ROOT}/results/${name}.xml"
local status=0
"${ROOT}/e2e/with-kube-gateway.sh" \
bash "${BASH_SOURCE[0]}" "${RUN_WITH_GATEWAY_COMMAND}"
bash "${BASH_SOURCE[0]}" "${RUN_WITH_GATEWAY_COMMAND}" || status=$?
if [ "${report}" != "${JUNIT_XML}" ]; then
mv -f "${JUNIT_XML}" "${report}" 2>/dev/null || true
fi
render_html "${report}" "${name}"
return "${status}"
}
run_e2e() {
@@ -61,11 +93,13 @@ run_e2e() {
return 0
fi
cargo test --manifest-path "${ROOT}/e2e/rust/Cargo.toml" \
# Pin `--target-dir` so the profile `junit.path` resolves regardless of `CARGO_TARGET_DIR`.
cargo nextest run --profile e2e-kubernetes \
--config-file "${ROOT}/.config/nextest.toml" \
--target-dir "${ROOT}/e2e/rust/target" \
--manifest-path "${ROOT}/e2e/rust/Cargo.toml" \
--features "${E2E_FEATURES}" \
--no-fail-fast \
${test_filter[@]+"${test_filter[@]}"} \
-- --nocapture
${test_filter[@]+"${test_filter[@]}"}
}
if [ "${1:-}" = "${RUN_WITH_GATEWAY_COMMAND}" ]; then
@@ -73,12 +107,13 @@ if [ "${1:-}" = "${RUN_WITH_GATEWAY_COMMAND}" ]; then
exit 0
fi
# Credential-driver mode: run once per storage backend, each with its own report.
if [ "${OPENSHELL_E2E_CREDENTIAL_DRIVERS:-0}" = "1" ] \
&& [ -z "${OPENSHELL_E2E_CREDENTIAL_DRIVER:-}" ]; then
OPENSHELL_E2E_CREDENTIAL_DRIVER=kubernetes-secrets run_suite
OPENSHELL_E2E_CREDENTIAL_DRIVER=vault run_suite
exit 0
OPENSHELL_E2E_CREDENTIAL_DRIVER=kubernetes-secrets \
OPENSHELL_E2E_REPORT_NAME=e2e-kubernetes-secrets run_suite
OPENSHELL_E2E_CREDENTIAL_DRIVER=vault \
OPENSHELL_E2E_REPORT_NAME=e2e-kubernetes-vault run_suite
else
run_suite
fi
exec "${ROOT}/e2e/with-kube-gateway.sh" \
bash "${BASH_SOURCE[0]}" "${RUN_WITH_GATEWAY_COMMAND}"
+3 -1
View File
@@ -13,7 +13,7 @@ OpenShell uses [Fern](https://buildwithfern.com/) to validate, preview, and publ
| `fern/assets/` | Logos and other shared assets. |
| `fern/main.css` | Site-wide styles. |
In a normal source checkout, `fern/docs.yml` points the `latest` version at `docs/index.yml`. Release automation builds the multi-version configuration on the generated `docs-website` branch.
In a normal source checkout, `fern/docs.yml` points the `dev` version at `docs/index.yml`. Release automation builds the multi-version configuration on the generated `docs-website` branch and maps the source documentation to the channel being published.
## Local development
@@ -54,6 +54,8 @@ The sync and publish workflows share the `docs-website` concurrency group. This
The `dev` snapshot also owns the shared Fern configuration, components, assets, and CSS on `docs-website`. The `latest` snapshot copies its documentation and navigation but does not replace those shared files. This keeps the site configuration aligned with `main` while preserving the released content.
A `dev` sync copies the top-level `announcement` from the source `fern/docs.yml`. This announcement is the global fallback, and removing it from the source removes it from `docs-website`. Each snapshot sync copies the source version announcement only to the channel being updated. A version announcement overrides the global announcement for that version, so Release Dev cannot change the `latest` announcement and Release Tag cannot change the `dev` announcement.
## Manual maintenance and publishing
Maintainers can run `.github/workflows/sync-docs.yml` manually to add, refresh, or remove a historical version snapshot. The workflow preserves snapshots that were not selected. Production publishing is disabled by default for a manual sync.
+5 -4
View File
@@ -2,7 +2,7 @@
# SPDX-License-Identifier: Apache-2.0
# Fern site config — merged with skills/convert-to-fern/assets/theme/nvidia/docs-theme.yml pattern
# Production: https://docs.nvidia.com/openshell/ — preview + custom-domain share path prefix `openshell`.
# Internal MDX links use /latest/...; basepath-aware resolves them under /openshell/ on production.
# Source checkouts and pull request previews expose the current documentation at /dev.
instances:
- url: openshell.docs.buildwithfern.com/openshell
custom-domain: docs.nvidia.com/openshell
@@ -38,7 +38,7 @@ logo:
dark: ./assets/NVIDIA_dark.svg
light: ./assets/NVIDIA_light.svg
height: 20
href: /openshell/latest
href: /openshell
right-text: OpenShell
favicon: ./assets/NVIDIA_symbol.svg
@@ -56,9 +56,10 @@ experimental:
- ./components
versions:
- display-name: Latest
- display-name: Dev
path: ../docs/index.yml
slug: latest
slug: dev
availability: beta
announcement:
message: '<span style="display: block; padding: 0.375rem 0; text-align: left;"><strong>OpenShell 0.1.0 is coming soon.</strong> <a href="https://github.com/NVIDIA/OpenShell/milestone/10" target="_blank" rel="noreferrer">Track progress in the 0.1.0 milestone</a>, <a href="https://docs.nvidia.com/openshell/dev/index.html" target="_blank" rel="noreferrer">read the prerelease documentation</a>, or <a href="https://docs.nvidia.com/openshell/dev/about/installation#install-a-prerelease" target="_blank" rel="noreferrer">install a prerelease</a>.</span>'
+1 -1
View File
@@ -138,7 +138,7 @@
};
build-artifacts-test-archives = {
type = "app";
program = "${artifacts.conformanceCliArchive}/bin/build-openshell-conformance-test-archive";
program = "${artifacts.testArchives}/bin/build-artifacts-test-archives";
};
build-artifacts-helm = {
type = "app";
+5 -6
View File
@@ -136,11 +136,6 @@ checksum = "sha256:f73a5c39f96bb6ebb89cc7915cf182260d4cbf30765322c5e793d0fe8bd80
url = "https://github.com/mozilla/sccache/releases/download/v0.16.0/sccache-v0.16.0-aarch64-unknown-linux-musl.tar.gz"
url_api = "https://api.github.com/repos/mozilla/sccache/releases/assets/452060468"
[tools."github:mozilla/sccache"."platforms.linux-x64"]
checksum = "sha256:aec995a83ad3dff3d14b6314e08858b7b73d35ca85a5bcf3d3a9ec07dee35588"
url = "https://github.com/mozilla/sccache/releases/download/v0.16.0/sccache-v0.16.0-x86_64-unknown-linux-musl.tar.gz"
url_api = "https://api.github.com/repos/mozilla/sccache/releases/assets/452060682"
[tools."github:mozilla/sccache"."platforms.macos-arm64"]
checksum = "sha256:ded590cae2c72042c61178632906bef62d635fa20d45f8b22110a2241f430960"
url = "https://github.com/mozilla/sccache/releases/download/v0.16.0/sccache-v0.16.0-aarch64-apple-darwin.tar.gz"
@@ -169,9 +164,12 @@ url = "https://github.com/mozilla/sccache/releases/download/v0.16.0/sccache-v0.1
url_api = "https://api.github.com/repos/mozilla/sccache/releases/assets/452060682"
[[tools."github:nextest-rs/nextest"]]
version = "cargo-nextest-0.9.143"
version = "0.9.143"
backend = "github:nextest-rs/nextest"
[tools."github:nextest-rs/nextest".options]
version_prefix = "cargo-nextest-"
[tools."github:nextest-rs/nextest"."platforms.linux-arm64"]
checksum = "sha256:2a64b3566a92508550a7ab29c3e8db25472ca37730ecb4d22100b6aa440c2a68"
url = "https://github.com/nextest-rs/nextest/releases/download/cargo-nextest-0.9.143/cargo-nextest-0.9.143-aarch64-unknown-linux-gnu.tar.gz"
@@ -183,6 +181,7 @@ checksum = "sha256:66786b9abe23920d022a182d1416b1bbc8130dd4872a9553d76985a1708dc
url = "https://github.com/nextest-rs/nextest/releases/download/cargo-nextest-0.9.143/cargo-nextest-0.9.143-x86_64-unknown-linux-gnu.tar.gz"
url_api = "https://api.github.com/repos/nextest-rs/nextest/releases/assets/501882467"
provenance = "github-attestations"
provenance_verified = true
[tools."github:nextest-rs/nextest"."platforms.macos-arm64"]
checksum = "sha256:4830d430411148d17602a75cc880bfb4dc8dac153dea59a48a2ef4cc93577f07"
+1 -1
View File
@@ -44,8 +44,8 @@ k3d = { version = "5.8.3", os = ["macos"] }
"github:EmbarkStudios/cargo-deny" = { version = "0.20.2", version_prefix = "" }
zig = "0.14.1"
"github:rust-secure-code/cargo-auditable" = "0.7.5"
"github:nextest-rs/nextest" = { version = "0.9.143", version_prefix = "cargo-nextest-" }
"github:rust-cross/cargo-zigbuild" = "0.22.3"
"github:nextest-rs/nextest" = { version = "cargo-nextest-0.9.143", os = ["windows"] }
"npm:markdownlint-cli2" = "0.22.0"
[tools."github:mozilla/sccache"]
+172
View File
@@ -0,0 +1,172 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -->
<!-- SPDX-License-Identifier: Apache-2.0 -->
<!--
Convert a nextest JUnit report into a standalone HTML report (XSLT 1.0).
Usage:
xsltproc junit-to-html.xsl report.xml > report.html
xsltproc -stringparam title "my suite report" junit-to-html.xsl report.xml > report.html
Renders a summary plus a per-suite table with pass/fail/skip rows. The report
heading is the `title` parameter (default "Test report").
-->
<xsl:stylesheet version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="html" indent="yes" encoding="UTF-8"
doctype-system="about:legacy-compat"/>
<!-- Report heading; override via the xsltproc `title` string parameter. -->
<xsl:param name="title" select="'Test report'"/>
<xsl:template match="/testsuites">
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title><xsl:value-of select="$title"/></title>
<style>
:root {
--bg: #f6f7f9; --card: #fff; --ink: #1f2328; --muted: #656d76;
--border: #d7dbe0; --pass: #1a7f37; --fail: #cf222e; --skip: #9a6700;
--pass-bg: #eafbe7; --fail-bg: #fbecec; --skip-bg: #fdf6e3;
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
margin: 0; padding: 2rem clamp(1rem, 4vw, 3rem); color: var(--ink);
background: var(--bg); line-height: 1.45;
}
h1 { font-size: 1.5rem; margin: 0 0 1rem; }
h2 {
font-family: var(--mono); font-size: 0.95rem; font-weight: 600;
margin: 2rem 0 0; color: var(--ink);
}
h2 .count { color: var(--muted); font-weight: 400; }
.summary { display: flex; flex-wrap: wrap; gap: 0.75rem; margin: 0 0 0.5rem; }
.summary .card {
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
padding: 0.5rem 0.9rem; min-width: 5.5rem;
}
.summary .card .label {
display: block; font-size: 0.7rem; text-transform: uppercase;
letter-spacing: 0.04em; color: var(--muted);
}
.summary .card .value { font-size: 1.35rem; font-weight: 700; }
.card.c-fail .value { color: var(--fail); }
.card.c-skip .value { color: var(--skip); }
.card.c-fail.zero .value, .card.c-skip.zero .value { color: var(--muted); }
table {
border-collapse: collapse; width: 100%; margin-top: 0.6rem; table-layout: fixed;
background: var(--card); border: 1px solid var(--border);
border-radius: 8px; overflow: hidden;
}
th, td {
text-align: left; padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border); vertical-align: top;
}
tbody tr:last-child td { border-bottom: none; }
td:first-child { font-family: var(--mono); font-size: 0.85rem; overflow-wrap: anywhere; }
th {
position: sticky; top: 0; background: #eef1f4; z-index: 1;
font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--muted);
}
tbody tr:nth-child(even) { background: #fafbfc; }
tbody tr:hover { background: #eef4ff; }
tr.failed, tr.failed:nth-child(even) { background: var(--fail-bg); }
tr.skipped, tr.skipped:nth-child(even) { background: var(--skip-bg); }
td.time { font-family: var(--mono); font-size: 0.85rem; color: var(--muted); }
.badge {
display: inline-block; padding: 0.1rem 0.5rem; border-radius: 999px;
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.03em;
}
.status-pass { background: var(--pass-bg); color: var(--pass); }
.status-fail { background: var(--fail-bg); color: var(--fail); }
.status-skip { background: var(--skip-bg); color: var(--skip); }
pre {
margin: 0.4rem 0 0; padding: 0.5rem 0.6rem; white-space: pre-wrap;
color: var(--fail); background: #fff; border: 1px solid var(--border);
border-radius: 6px; font-size: 0.8rem;
}
</style>
</head>
<body>
<h1><xsl:value-of select="$title"/></h1>
<div class="summary">
<div class="card">
<span class="label">Total</span>
<span class="value"><xsl:value-of select="@tests"/></span>
</div>
<div class="card c-fail">
<xsl:if test="@failures = 0"><xsl:attribute name="class">card c-fail zero</xsl:attribute></xsl:if>
<span class="label">Failures</span>
<span class="value"><xsl:value-of select="@failures"/></span>
</div>
<div class="card c-fail">
<xsl:if test="@errors = 0"><xsl:attribute name="class">card c-fail zero</xsl:attribute></xsl:if>
<span class="label">Errors</span>
<span class="value"><xsl:value-of select="@errors"/></span>
</div>
<div class="card c-skip">
<xsl:if test="sum(testsuite/@skipped) = 0"><xsl:attribute name="class">card c-skip zero</xsl:attribute></xsl:if>
<span class="label">Skipped</span>
<span class="value"><xsl:value-of select="sum(testsuite/@skipped)"/></span>
</div>
<div class="card">
<span class="label">Time</span>
<span class="value"><xsl:value-of select="@time"/>s</span>
</div>
</div>
<xsl:for-each select="testsuite">
<h2><xsl:value-of select="@name"/>
<xsl:text> </xsl:text>
<span class="count">(<xsl:value-of select="count(testcase)"/> tests, <xsl:value-of select="@time"/>s)</span>
</h2>
<table>
<colgroup>
<col/>
<col style="width: 6rem;"/>
<col style="width: 6rem;"/>
</colgroup>
<thead>
<tr><th>Test</th><th>Status</th><th>Time</th></tr>
</thead>
<tbody>
<xsl:for-each select="testcase">
<xsl:choose>
<xsl:when test="failure or error">
<tr class="failed">
<td><xsl:value-of select="@name"/>
<pre><xsl:value-of select="failure | error"/></pre>
</td>
<td><span class="badge status-fail">FAIL</span></td>
<td class="time"><xsl:value-of select="@time"/>s</td>
</tr>
</xsl:when>
<xsl:when test="skipped">
<tr class="skipped">
<td><xsl:value-of select="@name"/></td>
<td><span class="badge status-skip">SKIP</span></td>
<td class="time"><xsl:value-of select="@time"/>s</td>
</tr>
</xsl:when>
<xsl:otherwise>
<tr>
<td><xsl:value-of select="@name"/></td>
<td><span class="badge status-pass">PASS</span></td>
<td class="time"><xsl:value-of select="@time"/>s</td>
</tr>
</xsl:otherwise>
</xsl:choose>
</xsl:for-each>
</tbody>
</table>
</xsl:for-each>
</body>
</html>
</xsl:template>
</xsl:stylesheet>
+41 -2
View File
@@ -36,6 +36,7 @@ class VersionEntry:
display_name: str
path: str
availability: str | None = None
announcement: YamlMapping | None = None
def parse_args() -> argparse.Namespace:
@@ -312,6 +313,7 @@ def parse_versions(raw_versions: object) -> list[VersionEntry]:
display_name = entry.get("display-name")
path = entry.get("path")
availability = entry.get("availability")
announcement = entry.get("announcement")
if (
isinstance(slug, str)
and isinstance(display_name, str)
@@ -325,6 +327,9 @@ def parse_versions(raw_versions: object) -> list[VersionEntry]:
availability=availability
if isinstance(availability, str)
else None,
announcement=cast("YamlMapping", announcement)
if isinstance(announcement, dict)
else None,
)
)
return entries
@@ -349,8 +354,8 @@ def ordered_entries(
return [by_slug[slug] for slug in order]
def render_versions(entries: list[VersionEntry]) -> list[dict[str, str]]:
rendered: list[dict[str, str]] = []
def render_versions(entries: list[VersionEntry]) -> list[YamlMapping]:
rendered: list[YamlMapping] = []
for entry in entries:
item = {
"display-name": entry.display_name,
@@ -359,10 +364,36 @@ def render_versions(entries: list[VersionEntry]) -> list[dict[str, str]]:
}
if entry.availability is not None:
item["availability"] = entry.availability
if entry.announcement is not None:
item["announcement"] = entry.announcement
rendered.append(item)
return rendered
def sync_global_announcement(source_docs_yml: Path, target_docs_yml: Path) -> None:
source_data = read_yaml(source_docs_yml)
source_announcement = source_data.get("announcement")
if source_announcement is not None and not isinstance(source_announcement, dict):
raise ValueError("docs.yml announcement must be a mapping")
target_data = read_yaml(target_docs_yml)
if source_announcement is None:
target_data.pop("announcement", None)
else:
target_data["announcement"] = source_announcement
write_yaml(target_docs_yml, target_data)
def source_version_announcement(docs_yml: Path, slug: str) -> YamlMapping | None:
entries = parse_versions(read_yaml(docs_yml).get("versions"))
for entry in entries:
if entry.slug == slug:
return entry.announcement
if len(entries) == 1:
return entries[0].announcement
return None
def component_dirs(fern_dir: Path) -> list[str]:
dirs: list[str] = []
preferred = ["pages-latest", "pages-dev"]
@@ -408,6 +439,7 @@ def write_snapshot(
copy_if_exists(
source_fern / "fern.config.json", target_fern / "fern.config.json"
)
sync_global_announcement(source_fern / "docs.yml", target_fern / "docs.yml")
versions_dir = target_fern / "versions"
versions_dir.mkdir(parents=True, exist_ok=True)
@@ -483,6 +515,9 @@ def sync_docs(args: argparse.Namespace) -> None:
display_name=slug,
path=f"./versions/{slug}.yml",
availability=stable_availability,
announcement=source_version_announcement(
source_fern / "docs.yml", slug
),
),
refresh_shared=False,
)
@@ -509,6 +544,9 @@ def sync_docs(args: argparse.Namespace) -> None:
display_name=display_override or f"Latest ({slug})",
path="./versions/latest.yml",
availability=stable_availability,
announcement=source_version_announcement(
source_fern / "docs.yml", "latest"
),
),
refresh_shared=False,
)
@@ -548,6 +586,7 @@ def sync_docs(args: argparse.Namespace) -> None:
display_name=display_name,
path=f"./versions/{slug}.yml",
availability=availability,
announcement=source_version_announcement(source_fern / "docs.yml", slug),
),
refresh_shared=channel == "dev",
)
+243 -2
View File
@@ -147,13 +147,14 @@ def test_resolve_availability() -> None:
sdw.resolve_availability("dev", "alpha")
def test_parse_and_render_versions_preserves_availability() -> None:
def test_parse_and_render_versions_preserves_version_settings() -> None:
raw_versions = [
{
"display-name": "v0.0.36",
"path": "./versions/v0.0.36.yml",
"slug": "v0.0.36",
"availability": "deprecated",
"announcement": {"message": "Upgrade to the latest version."},
}
]
@@ -165,11 +166,104 @@ def test_parse_and_render_versions_preserves_availability() -> None:
"v0.0.36",
"./versions/v0.0.36.yml",
"deprecated",
{"message": "Upgrade to the latest version."},
)
]
assert sdw.render_versions(entries) == raw_versions
def test_sync_global_announcement_applies_source_config(tmp_path: Path) -> None:
source_docs_yml = tmp_path / "source.yml"
target_docs_yml = tmp_path / "target.yml"
source_docs_yml.write_text(
yaml.safe_dump(
{
"announcement": {"message": "Current global announcement."},
"versions": [],
}
),
encoding="utf-8",
)
target_docs_yml.write_text(
yaml.safe_dump(
{
"announcement": {"message": "Stale global announcement."},
"versions": [
{
"display-name": "Latest (v0.0.116)",
"path": "./versions/latest.yml",
"slug": "latest",
},
{
"display-name": "Dev",
"path": "./versions/dev.yml",
"slug": "dev",
"announcement": {"message": "Development docs."},
},
],
}
),
encoding="utf-8",
)
sdw.sync_global_announcement(source_docs_yml, target_docs_yml)
target_data = read_yaml(target_docs_yml)
assert target_data["announcement"] == {"message": "Current global announcement."}
assert target_data["versions"] == [
{
"display-name": "Latest (v0.0.116)",
"path": "./versions/latest.yml",
"slug": "latest",
},
{
"display-name": "Dev",
"path": "./versions/dev.yml",
"slug": "dev",
"announcement": {"message": "Development docs."},
},
]
source_docs_yml.write_text(
yaml.safe_dump({"versions": []}),
encoding="utf-8",
)
sdw.sync_global_announcement(source_docs_yml, target_docs_yml)
target_data = read_yaml(target_docs_yml)
assert "announcement" not in target_data
versions = target_data["versions"]
assert "announcement" not in versions[0]
assert versions[1]["announcement"] == {"message": "Development docs."}
def test_source_version_announcement_maps_single_source_version_to_channel(
tmp_path: Path,
) -> None:
docs_yml = tmp_path / "docs.yml"
docs_yml.write_text(
yaml.safe_dump(
{
"versions": [
{
"display-name": "Dev",
"path": "../docs/index.yml",
"slug": "dev",
"announcement": {"message": "Snapshot announcement."},
}
]
}
),
encoding="utf-8",
)
expected = {"message": "Snapshot announcement."}
assert sdw.source_version_announcement(docs_yml, "latest") == expected
assert sdw.source_version_announcement(docs_yml, "dev") == expected
assert sdw.source_version_announcement(docs_yml, "v0.0.116") == expected
def test_ordered_entries_pins_latest_then_dev() -> None:
existing = [
sdw.VersionEntry("v0.0.36", "v0.0.36", "./versions/v0.0.36.yml"),
@@ -212,6 +306,8 @@ def _make_source_tree(root: Path) -> None:
encoding="utf-8",
)
fern = root / "fern"
fern.mkdir(parents=True)
(fern / "docs.yml").write_text(yaml.safe_dump({"versions": []}), encoding="utf-8")
(fern / "assets").mkdir(parents=True)
(fern / "assets" / "logo.svg").write_text("<svg/>", encoding="utf-8")
(fern / "components").mkdir(parents=True)
@@ -228,11 +324,39 @@ def _make_docs_website_tree(root: Path) -> None:
(fern / "docs.yml").write_text(yaml.safe_dump({"versions": []}), encoding="utf-8")
def test_sync_docs_creates_planned_latest_and_dev_selector(tmp_path: Path) -> None:
def test_sync_docs_scopes_version_announcements_to_updated_channel(
tmp_path: Path,
) -> None:
source = tmp_path / "source"
website = tmp_path / "docs-website"
_make_source_tree(source)
_make_docs_website_tree(website)
(source / "fern" / "docs.yml").write_text(
yaml.safe_dump(
{
"versions": [
{
"display-name": "Latest",
"path": "../docs/index.yml",
"slug": "latest",
"announcement": {
"message": "Version 0.0.116 is the final alpha release."
},
}
]
}
),
encoding="utf-8",
)
(website / "fern" / "docs.yml").write_text(
yaml.safe_dump(
{
"announcement": {"message": "Stale global announcement."},
"versions": [],
}
),
encoding="utf-8",
)
sdw.sync_docs(
Namespace(
@@ -248,6 +372,21 @@ def test_sync_docs_creates_planned_latest_and_dev_selector(tmp_path: Path) -> No
availability="",
)
)
(source / "fern" / "docs.yml").write_text(
yaml.safe_dump(
{
"versions": [
{
"display-name": "Dev",
"path": "../docs/index.yml",
"slug": "dev",
"announcement": {"message": "OpenShell 0.1.0 is coming soon."},
}
]
}
),
encoding="utf-8",
)
sdw.sync_docs(
Namespace(
operation="sync",
@@ -276,16 +415,53 @@ def test_sync_docs_creates_planned_latest_and_dev_selector(tmp_path: Path) -> No
"display-name": "Latest (v0.0.116)",
"path": "./versions/latest.yml",
"slug": "latest",
"announcement": {"message": "Version 0.0.116 is the final alpha release."},
},
{
"display-name": "Dev",
"path": "./versions/dev.yml",
"slug": "dev",
"availability": "beta",
"announcement": {"message": "OpenShell 0.1.0 is coming soon."},
},
]
assert "announcement" not in docs_yml
assert "./components" in docs_yml["experimental"]["mdx-components"]
(source / "fern" / "docs.yml").write_text(
yaml.safe_dump(
{
"versions": [
{
"display-name": "Dev",
"path": "../docs/index.yml",
"slug": "dev",
"announcement": {"message": "OpenShell 0.1.0 is released."},
}
]
}
),
encoding="utf-8",
)
sdw.sync_docs(
Namespace(
operation="sync",
source_root=source,
docs_website_root=website,
channel="latest",
source_ref="release-0.1.0-sha",
source_sha="release-0.1.0-sha",
release_version="0.1.0",
version_slug="",
display_name="Latest (v0.1.0)",
availability="",
)
)
versions = read_yaml(fern / "docs.yml")["versions"]
assert versions[0]["announcement"] == {"message": "OpenShell 0.1.0 is released."}
assert versions[1]["announcement"] == {"message": "OpenShell 0.1.0 is coming soon."}
def test_sync_docs_preserves_other_version_availability(tmp_path: Path) -> None:
source = tmp_path / "source"
@@ -341,6 +517,71 @@ def test_sync_docs_preserves_other_version_availability(tmp_path: Path) -> None:
]
def test_latest_sync_updates_legacy_snapshot_announcement(tmp_path: Path) -> None:
source = tmp_path / "source"
website = tmp_path / "docs-website"
_make_source_tree(source)
_make_docs_website_tree(website)
(source / "fern" / "docs.yml").write_text(
yaml.safe_dump(
{
"versions": [
{
"display-name": "Latest",
"path": "../docs/index.yml",
"slug": "latest",
"announcement": {
"message": "Version 0.0.116 is the final alpha release."
},
}
]
}
),
encoding="utf-8",
)
docs_yml_path = website / "fern" / "docs.yml"
docs_yml_path.write_text(
yaml.safe_dump(
{
"versions": [
{
"display-name": "Latest (v0.0.116)",
"path": "./versions/latest.yml",
"slug": "latest",
}
]
}
),
encoding="utf-8",
)
sdw.sync_docs(
Namespace(
operation="sync",
source_root=source,
docs_website_root=website,
channel="latest",
source_ref="docs/v0.0.116-announcement",
source_sha="announcement-sha",
release_version="0.0.116",
version_slug="",
display_name="Latest (v0.0.116)",
availability="",
)
)
latest = read_yaml(docs_yml_path)["versions"][0]
assert latest["announcement"] == {
"message": "Version 0.0.116 is the final alpha release."
}
snapshots = read_yaml(website / "fern" / sdw.SNAPSHOT_METADATA_FILE)["snapshots"]
assert snapshots["latest"] == {
"source-ref": "docs/v0.0.116-announcement",
"source-sha": "announcement-sha",
"version": "0.0.116",
}
def test_stable_sync_creates_immutable_version_and_promotes_latest(
tmp_path: Path,
) -> None:
+7 -2
View File
@@ -31,6 +31,10 @@
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
# Source pinned dependency versions.
source "${ROOT}/crates/openshell-driver-vm/runtime/pins.env" 2>/dev/null || true
BUILD_DIR="${ROOT}/target/libkrun-build"
OUTPUT_DIR="${BUILD_DIR}"
BREW_PREFIX="$(brew --prefix 2>/dev/null || echo /opt/homebrew)"
@@ -149,11 +153,12 @@ echo " Built: libkrunfw.${ABI_VERSION}.dylib ($(du -sh "${OUTPUT_DIR}/libkrun
# ── Clone libkrun ───────────────────────────────────────────────────────
LIBKRUN_REF="${LIBKRUN_REF:-e5922f6}"
# LIBKRUN_REF is sourced from pins.env; env-var override still works.
LIBKRUN_REF="${LIBKRUN_REF:-728df8125077d0db44265f6e997c72b81b65c015}"
if [ ! -d libkrun ]; then
echo "==> Cloning libkrun..."
git clone https://github.com/containers/libkrun.git
git clone https://github.com/libkrun/libkrun.git
fi
echo "==> Checking out ${LIBKRUN_REF}..."
+4 -8
View File
@@ -134,7 +134,7 @@ ensure_python3_with_pyelftools_for_libkrunfw
if [ ! -d libkrunfw ]; then
echo " Cloning libkrunfw (pinned: ${LIBKRUNFW_REF:-HEAD})..."
git clone https://github.com/containers/libkrunfw.git
git clone https://github.com/libkrun/libkrunfw.git
fi
cd libkrunfw
@@ -405,11 +405,12 @@ echo "==> Building libkrun..."
ensure_cargo_for_libkrun
ensure_libclang_for_libkrun
LIBKRUN_REF="${LIBKRUN_REF:-v1.17.4}"
# LIBKRUN_REF is sourced from pins.env (line 32); env-var override still works.
LIBKRUN_REF="${LIBKRUN_REF:-728df8125077d0db44265f6e997c72b81b65c015}"
if [ ! -d libkrun ]; then
echo " Cloning libkrun..."
git clone https://github.com/containers/libkrun.git
git clone https://github.com/libkrun/libkrun.git
fi
cd libkrun
@@ -420,11 +421,6 @@ if [ -n "${LIBKRUN_REF:-}" ]; then
git checkout "${LIBKRUN_REF}" 2>/dev/null || git checkout "origin/${LIBKRUN_REF}" 2>/dev/null || true
fi
if [ -f init/Makefile ] || grep -q 'init/init' Makefile 2>/dev/null; then
echo " Building init/init binary..."
make init/init
fi
echo " Building libkrun with NET=1 BLK=1..."
cargo build --release --features blk --features net --target-dir="$(pwd)/target"
-6
View File
@@ -146,12 +146,6 @@ run = [
"CONTAINER_RUNTIME=podman e2e/with-keycloak.sh env OPENSHELL_E2E_OIDC_GATEWAY=1 e2e/with-podman-gateway.sh cargo test --manifest-path e2e/rust/Cargo.toml --features e2e-oidc-pkce --test oidc_pkce",
]
["e2e:provider-refresh-keycloak"]
description = "Run provider refresh recovery e2e against Keycloak and a Podman gateway"
run = [
"CONTAINER_RUNTIME=podman e2e/with-keycloak.sh e2e/with-podman-gateway.sh cargo test --manifest-path e2e/rust/Cargo.toml --features e2e-provider-refresh-keycloak --test provider_refresh_keycloak -- --nocapture",
]
["e2e:oidc-pkce:docker"]
description = "Run Linux browser PKCE and RBAC e2e tests against Keycloak and a Docker gateway"
run = [
@@ -0,0 +1,100 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
---
- name: Run Keycloak provider refresh tests
hosts: all
gather_facts: false
roles:
- keycloak
tasks:
- name: Wait for SSH
ansible.builtin.wait_for_connection:
- name: Create Keycloak provider refresh test directory
become: true
ansible.builtin.file:
path: /var/lib/openshell-provider-refresh/tests
state: directory
owner: tmachine
group: tmachine
mode: "0700"
- name: Install Keycloak provider refresh test bundle
become: true
ansible.builtin.copy:
src: "{{ provider_refresh_keycloak_test_bundle }}"
dest: /var/lib/openshell-provider-refresh/tests/bundle.tar
owner: tmachine
group: tmachine
mode: "0600"
- name: Extract Keycloak provider refresh test bundle
become: true
ansible.builtin.unarchive:
src: /var/lib/openshell-provider-refresh/tests/bundle.tar
dest: /var/lib/openshell-provider-refresh/tests
owner: tmachine
group: tmachine
remote_src: true
- name: Check Keycloak provider refresh test archive
ansible.builtin.stat:
path: /var/lib/openshell-provider-refresh/tests/tests.tar.zst
register: provider_refresh_keycloak_test_archive
- name: Require Keycloak provider refresh test archive
ansible.builtin.assert:
that:
- provider_refresh_keycloak_test_archive.stat.isreg | default(false)
fail_msg: Keycloak provider refresh test bundle did not contain tests.tar.zst
- name: Run Keycloak provider refresh archive
ansible.builtin.command:
argv:
- cargo-nextest
- nextest
- run
- --archive-file
- /var/lib/openshell-provider-refresh/tests/tests.tar.zst
- --workspace-remap
- /var/lib/openshell-provider-refresh/tests
- --no-capture
environment:
OPENSHELL_BIN: /usr/local/bin/openshell
OPENSHELL_E2E_OIDC_ISSUER: http://127.0.0.1:8180/realms/openshell
OPENSHELL_E2E_OIDC_USERNAME: admin@test
OPENSHELL_E2E_OIDC_PASSWORD: admin
register: provider_refresh_result
changed_when: false
failed_when: false
- name: Show Keycloak provider refresh result
ansible.builtin.debug:
var: provider_refresh_result
when: provider_refresh_result.rc != 0
- name: Read OpenShell gateway logs
become: true
ansible.builtin.command:
argv:
- journalctl
- --unit
- openshell-gateway.service
- --no-pager
- --lines
- "500"
register: openshell_gateway_logs
changed_when: false
failed_when: false
when: provider_refresh_result.rc != 0
- name: Show OpenShell gateway logs
ansible.builtin.debug:
var: openshell_gateway_logs.stdout_lines
when: provider_refresh_result.rc != 0
- name: Require Keycloak provider refresh success
ansible.builtin.assert:
that: provider_refresh_result.rc == 0
fail_msg: Keycloak provider refresh test failed
+20
View File
@@ -0,0 +1,20 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
---
- name: Verify SELinux
hosts: all
gather_facts: false
tasks:
- name: Read the current SELinux mode
ansible.builtin.command:
cmd: getenforce
register: selinux_mode
changed_when: false
- name: Verify enforcing mode
ansible.builtin.assert:
that:
- selinux_mode.stdout == "Enforcing"
fail_msg: SELinux is not enforcing.
@@ -0,0 +1,15 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
---
# Keep these values aligned with scripts/keycloak-dev.sh. The role provisions
# an isolated guest fixture, while the script manages a developer-host fixture.
keycloak_container_name: openshell-keycloak
keycloak_image: quay.io/keycloak/keycloak:24.0
keycloak_port: 8180
keycloak_realm: openshell
keycloak_admin_username: admin
keycloak_admin_password: admin
keycloak_state_directory: /var/lib/openshell-keycloak
keycloak_health_retries: 45
keycloak_health_delay: 2
@@ -0,0 +1,126 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
---
- name: Wait for SSH
ansible.builtin.wait_for_connection:
- name: Detect tmachine container runtime
ansible.builtin.include_role:
name: tmachine_container_runtime
- name: Set Keycloak runtime values
ansible.builtin.set_fact:
keycloak_runtime_user: >-
{{ 'tmachine' if tmachine_container_runtime_is_rootless else 'root' }}
keycloak_runtime_home: >-
{{ '/home/tmachine' if tmachine_container_runtime_is_rootless else '/root' }}
keycloak_runtime_state_directory: >-
{{ '/home/tmachine/.local/share/openshell-keycloak'
if tmachine_container_runtime_is_rootless else keycloak_state_directory }}
- name: Require Keycloak container runtime
become: true
become_user: "{{ keycloak_runtime_user }}"
ansible.builtin.command:
argv:
- "{{ tmachine_container_runtime_name }}"
- --version
environment:
HOME: "{{ keycloak_runtime_home }}"
changed_when: false
- name: Create Keycloak state directory
become: true
ansible.builtin.file:
path: "{{ keycloak_runtime_state_directory }}"
state: directory
owner: "{{ keycloak_runtime_user }}"
group: "{{ keycloak_runtime_user }}"
mode: "0755"
- name: Install Keycloak realm fixture
become: true
ansible.builtin.copy:
src: "{{ keycloak_realm_file }}"
dest: "{{ keycloak_runtime_state_directory }}/realm.json"
owner: "{{ keycloak_runtime_user }}"
group: "{{ keycloak_runtime_user }}"
mode: "0644"
- name: Remove an existing Keycloak fixture
become: true
become_user: "{{ keycloak_runtime_user }}"
ansible.builtin.command:
argv:
- "{{ tmachine_container_runtime_name }}"
- rm
- --force
- "{{ keycloak_container_name }}"
environment:
HOME: "{{ keycloak_runtime_home }}"
changed_when: false
failed_when: false
- name: Start Keycloak fixture
become: true
become_user: "{{ keycloak_runtime_user }}"
ansible.builtin.command:
argv:
- "{{ tmachine_container_runtime_name }}"
- run
- --detach
- --name
- "{{ keycloak_container_name }}"
- --publish
- "127.0.0.1:{{ keycloak_port }}:8080"
- --env
- "KEYCLOAK_ADMIN={{ keycloak_admin_username }}"
- --env
- "KEYCLOAK_ADMIN_PASSWORD={{ keycloak_admin_password }}"
- --volume
- "{{ keycloak_runtime_state_directory }}/realm.json:/opt/keycloak/data/import/realm.json:ro,z"
- "{{ keycloak_image }}"
- start-dev
- --import-realm
environment:
HOME: "{{ keycloak_runtime_home }}"
changed_when: true
- name: Wait for Keycloak discovery endpoint
ansible.builtin.uri:
url: "http://127.0.0.1:{{ keycloak_port }}/realms/{{ keycloak_realm }}/.well-known/openid-configuration"
status_code: 200
return_content: false
register: keycloak_discovery
retries: "{{ keycloak_health_retries }}"
delay: "{{ keycloak_health_delay }}"
until: keycloak_discovery.status | default(0) == 200
failed_when: false
- name: Read Keycloak logs after failed health check
become: true
become_user: "{{ keycloak_runtime_user }}"
ansible.builtin.command:
argv:
- "{{ tmachine_container_runtime_name }}"
- logs
- --tail
- "30"
- "{{ keycloak_container_name }}"
environment:
HOME: "{{ keycloak_runtime_home }}"
register: keycloak_logs
changed_when: false
failed_when: false
when: keycloak_discovery.status | default(0) != 200
- name: Show Keycloak logs after failed health check
ansible.builtin.debug:
var: keycloak_logs.stdout_lines
when: keycloak_discovery.status | default(0) != 200
- name: Require Keycloak discovery endpoint
ansible.builtin.assert:
that: keycloak_discovery.status | default(0) == 200
fail_msg: Keycloak did not become healthy within the configured timeout
@@ -2,50 +2,21 @@
# SPDX-License-Identifier: Apache-2.0
---
- name: Resolve tmachine UID
ansible.builtin.command:
argv:
- id
- -u
- tmachine
changed_when: false
register: openshell_tmachine_uid
- name: Detect tmachine container runtime
ansible.builtin.include_role:
name: tmachine_container_runtime
- name: Check Docker socket
become: true
ansible.builtin.stat:
path: /var/run/docker.sock
register: openshell_docker_socket
- name: Check rootful Podman socket
become: true
ansible.builtin.stat:
path: /run/podman/podman.sock
register: openshell_rootful_podman_socket
- name: Check rootless Podman socket
become: true
ansible.builtin.stat:
path: "/run/user/{{ openshell_tmachine_uid.stdout }}/podman/podman.sock"
register: openshell_rootless_podman_socket
- name: Require exactly one container runtime socket
ansible.builtin.assert:
that:
- >-
(openshell_docker_socket.stat.exists | int)
+ (openshell_rootful_podman_socket.stat.exists | int)
+ (openshell_rootless_podman_socket.stat.exists | int) == 1
fail_msg: Expected exactly one Docker or Podman socket
- name: Select container runtime
- name: Set OpenShell gateway runtime values
ansible.builtin.set_fact:
openshell_gateway_driver: "{{ 'docker' if openshell_docker_socket.stat.exists else 'podman' }}"
openshell_gateway_user: "{{ 'root' if openshell_rootful_podman_socket.stat.exists else 'tmachine' }}"
openshell_gateway_home: "{{ '/root' if openshell_rootful_podman_socket.stat.exists else '/home/tmachine' }}"
openshell_gateway_uid: "{{ '0' if openshell_rootful_podman_socket.stat.exists else openshell_tmachine_uid.stdout }}"
openshell_gateway_bind_address: "{{ '127.0.0.1:17670' if openshell_docker_socket.stat.exists else '0.0.0.0:17670' }}"
openshell_runtime_socket: >-
{{ '/var/run/docker.sock' if openshell_docker_socket.stat.exists
else '/run/podman/podman.sock' if openshell_rootful_podman_socket.stat.exists
else '/run/user/' ~ openshell_tmachine_uid.stdout ~ '/podman/podman.sock' }}
openshell_gateway_driver: "{{ tmachine_container_runtime_name }}"
openshell_gateway_user: >-
{{ 'root' if tmachine_container_runtime_name == 'podman'
and not tmachine_container_runtime_is_rootless else 'tmachine' }}
openshell_gateway_home: >-
{{ '/root' if tmachine_container_runtime_name == 'podman'
and not tmachine_container_runtime_is_rootless else '/home/tmachine' }}
openshell_gateway_uid: >-
{{ '0' if tmachine_container_runtime_name == 'podman'
and not tmachine_container_runtime_is_rootless else tmachine_container_runtime_tmachine_uid.stdout }}
openshell_gateway_bind_address: "{{ '127.0.0.1:17670' if tmachine_container_runtime_name == 'docker' else '0.0.0.0:17670' }}"
openshell_runtime_socket: "{{ tmachine_container_runtime_socket }}"
@@ -0,0 +1,50 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
---
- name: Resolve tmachine UID
ansible.builtin.command:
argv:
- id
- -u
- tmachine
changed_when: false
register: tmachine_container_runtime_tmachine_uid
- name: Check Docker socket
become: true
ansible.builtin.stat:
path: /var/run/docker.sock
register: tmachine_container_runtime_docker_socket
- name: Check rootful Podman socket
become: true
ansible.builtin.stat:
path: /run/podman/podman.sock
register: tmachine_container_runtime_rootful_podman_socket
- name: Check rootless Podman socket
become: true
ansible.builtin.stat:
path: "/run/user/{{ tmachine_container_runtime_tmachine_uid.stdout }}/podman/podman.sock"
register: tmachine_container_runtime_rootless_podman_socket
- name: Require exactly one container runtime socket
ansible.builtin.assert:
that:
- >-
(tmachine_container_runtime_docker_socket.stat.exists | int)
+ (tmachine_container_runtime_rootful_podman_socket.stat.exists | int)
+ (tmachine_container_runtime_rootless_podman_socket.stat.exists | int) == 1
fail_msg: Expected exactly one Docker or Podman socket
- name: Select tmachine container runtime
ansible.builtin.set_fact:
tmachine_container_runtime_name: >-
{{ 'docker' if tmachine_container_runtime_docker_socket.stat.exists else 'podman' }}
tmachine_container_runtime_is_rootless: >-
{{ tmachine_container_runtime_rootless_podman_socket.stat.exists }}
tmachine_container_runtime_socket: >-
{{ '/var/run/docker.sock' if tmachine_container_runtime_docker_socket.stat.exists
else '/run/podman/podman.sock' if tmachine_container_runtime_rootful_podman_socket.stat.exists
else '/run/user/' ~ tmachine_container_runtime_tmachine_uid.stdout ~ '/podman/podman.sock' }}
+23 -3
View File
@@ -84,9 +84,17 @@ let
target = muslToolchain.target;
output = "artifacts/test-archives/${muslToolchain.target}/openshell-conformance-tests.tar";
};
providerRefreshKeycloakArchive = mkTestArchive {
name = "provider-refresh-keycloak";
workspacePath = "tests/suites/features";
manifestPath = "tests/suites/features/Cargo.toml";
package = "openshell-test-feature-provider-refresh-keycloak";
target = muslToolchain.target;
output = "artifacts/test-archives/${muslToolchain.target}/provider-refresh-keycloak-tests.tar";
};
in
rec {
inherit conformanceCliArchive;
inherit conformanceCliArchive providerRefreshKeycloakArchive;
binaries = pkgs.writeShellApplication {
name = "build-artifacts-binaries";
@@ -126,6 +134,18 @@ rec {
'';
};
testArchives = pkgs.writeShellApplication {
name = "build-artifacts-test-archives";
runtimeInputs = [
conformanceCliArchive
providerRefreshKeycloakArchive
];
text = ''
build-openshell-conformance-test-archive
build-provider-refresh-keycloak-test-archive
'';
};
images = pkgs.writeShellApplication {
name = "build-artifacts-images";
runtimeInputs = [
@@ -204,13 +224,13 @@ rec {
name = "build-artifacts";
runtimeInputs = [
binaries
conformanceCliArchive
testArchives
images
helm
];
text = ''
build-artifacts-binaries
build-openshell-conformance-test-archive
build-artifacts-test-archives
build-artifacts-images
build-artifacts-helm
'';
+27 -39
View File
@@ -31,7 +31,7 @@ let
}
];
scenarios = [
environments = [
{
name = "ubuntu-docker-rootful";
machine = "ubuntu";
@@ -42,19 +42,6 @@ let
"ansible/playbooks/docker.yaml"
];
};
install = {
use_galaxy = false;
playbooks = [
"ansible/playbooks/openshell.yaml"
"ansible/playbooks/gateway.yaml"
];
inputs = {
openshell_cli_binary = "../artifacts/binaries/${muslTarget}/openshell";
openshell_gateway_binary = "../artifacts/binaries/${gnuTarget}/openshell-gateway";
openshell_supervisor_image = "../artifacts/images/openshell-supervisor-tmachine.tar";
openshell_sandbox_image = "../artifacts/images/openshell-sandbox-tmachine.tar";
};
};
}
{
name = "fedora-podman-rootful";
@@ -63,22 +50,10 @@ let
use_galaxy = false;
playbooks = [
"ansible/playbooks/nextest.yaml"
"ansible/playbooks/selinux.yaml"
"ansible/playbooks/podman-rootful.yaml"
];
};
install = {
use_galaxy = false;
playbooks = [
"ansible/playbooks/openshell.yaml"
"ansible/playbooks/gateway.yaml"
];
inputs = {
openshell_cli_binary = "../artifacts/binaries/${muslTarget}/openshell";
openshell_gateway_binary = "../artifacts/binaries/${gnuTarget}/openshell-gateway";
openshell_supervisor_image = "../artifacts/images/openshell-supervisor-tmachine.tar";
openshell_sandbox_image = "../artifacts/images/openshell-sandbox-tmachine.tar";
};
};
}
{
name = "fedora-podman-rootless";
@@ -87,21 +62,26 @@ let
use_galaxy = false;
playbooks = [
"ansible/playbooks/nextest.yaml"
"ansible/playbooks/selinux.yaml"
"ansible/playbooks/podman-rootless.yaml"
];
};
install = {
use_galaxy = false;
playbooks = [
"ansible/playbooks/openshell.yaml"
"ansible/playbooks/gateway.yaml"
];
inputs = {
openshell_cli_binary = "../artifacts/binaries/${muslTarget}/openshell";
openshell_gateway_binary = "../artifacts/binaries/${gnuTarget}/openshell-gateway";
openshell_supervisor_image = "../artifacts/images/openshell-supervisor-tmachine.tar";
openshell_sandbox_image = "../artifacts/images/openshell-sandbox-tmachine.tar";
};
}
];
installers = [
{
name = "binaries";
use_galaxy = false;
playbooks = [
"ansible/playbooks/openshell.yaml"
"ansible/playbooks/gateway.yaml"
];
inputs = {
openshell_cli_binary = "../artifacts/binaries/${muslTarget}/openshell";
openshell_gateway_binary = "../artifacts/binaries/${gnuTarget}/openshell-gateway";
openshell_supervisor_image = "../artifacts/images/openshell-supervisor-tmachine.tar";
openshell_sandbox_image = "../artifacts/images/openshell-sandbox-tmachine.tar";
};
}
];
@@ -114,6 +94,14 @@ let
openshell_conformance_test_bundle = "../artifacts/test-archives/${muslTarget}/openshell-conformance-tests.tar";
};
}
{
name = "provider-refresh";
playbooks = [ "ansible/playbooks/features/provider-refresh/keycloak.yaml" ];
inputs = {
keycloak_realm_file = "../scripts/keycloak-realm.json";
provider_refresh_keycloak_test_bundle = "../artifacts/test-archives/${muslTarget}/provider-refresh-keycloak-tests.tar";
};
}
];
};
+1868
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
[workspace]
resolver = "2"
members = ["provider-refresh/keycloak"]
@@ -0,0 +1,14 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
[package]
name = "openshell-test-feature-provider-refresh-keycloak"
version = "0.0.0"
edition = "2024"
[dependencies]
openshell-conformance = { path = "../../../../../crates/openshell-conformance" }
openshell-e2e = { path = "../../../../../e2e/rust" }
serde_json = "1"
tempfile = "3"
tokio = { version = "1.43", features = ["macros", "process", "io-util", "rt"] }
@@ -3,7 +3,7 @@
#![cfg(target_os = "linux")]
//! End-to-end coverage for provider OAuth refresh recovery against Keycloak.
//! Provider OAuth refresh recovery against Keycloak.
//!
//! OpenShell itself uses the local gateway's mTLS authentication. Keycloak is
//! only the provider token issuer: the test refreshes a valid grant, revokes
+5 -4
View File
@@ -10,7 +10,8 @@ use serde::Deserialize;
#[derive(Clone, Deserialize)]
pub struct Config {
pub machines: Vec<Machine>,
pub scenarios: Vec<Scenario>,
pub environments: Vec<Environment>,
pub installers: Vec<Installer>,
pub testsuites: Vec<Testsuite>,
}
@@ -21,11 +22,10 @@ pub struct Machine {
}
#[derive(Clone, Deserialize)]
pub struct Scenario {
pub struct Environment {
pub name: String,
pub machine: String,
pub setup: Setup,
pub install: Install,
}
#[derive(Clone, Deserialize)]
@@ -35,7 +35,8 @@ pub struct Setup {
}
#[derive(Clone, Deserialize)]
pub struct Install {
pub struct Installer {
pub name: String,
pub use_galaxy: bool,
pub playbooks: Vec<PathBuf>,
pub inputs: BTreeMap<String, PathBuf>,
+46 -22
View File
@@ -5,7 +5,7 @@ use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use config::{Config, Machine, Scenario};
use config::{Config, Environment, Installer, Machine};
mod ansible;
mod config;
@@ -23,9 +23,18 @@ struct Cli {
#[derive(Subcommand)]
enum Command {
Setup { scenario: String },
Install { scenario: String },
Test { scenario: String, testsuite: String },
Setup {
environment: String,
},
Install {
environment: String,
installer: String,
},
Test {
environment: String,
installer: String,
testsuite: String,
},
}
#[tokio::main(flavor = "current_thread")]
@@ -34,49 +43,64 @@ async fn main() -> Result<()> {
let config = Config::load(&cli.config)?;
match cli.command {
Command::Setup { scenario } => {
let (machine, scenario) = find_scenario(&config, &scenario)?;
qemu::setup(&machine, &scenario).await?;
Command::Setup { environment } => {
let (machine, environment) = find_environment(&config, &environment)?;
qemu::setup(&machine, &environment).await?;
}
Command::Install { scenario } => {
let (machine, scenario) = find_scenario(&config, &scenario)?;
qemu::install(&machine, &scenario).await?;
Command::Install {
environment,
installer,
} => {
let (machine, environment) = find_environment(&config, &environment)?;
let installer = find_installer(&config, &installer)?;
qemu::install(&machine, &environment, &installer).await?;
}
Command::Test {
scenario,
environment,
installer,
testsuite,
} => {
let (machine, scenario) = find_scenario(&config, &scenario)?;
let (machine, environment) = find_environment(&config, &environment)?;
let installer = find_installer(&config, &installer)?;
let testsuite = config
.testsuites
.iter()
.find(|candidate| candidate.name == testsuite)
.with_context(|| format!("testsuite {testsuite:?} is not defined"))?;
qemu::test(&machine, &scenario, testsuite).await?;
qemu::test(&machine, &environment, &installer, testsuite).await?;
}
}
Ok(())
}
fn find_scenario(config: &Config, name: &str) -> Result<(Machine, Scenario)> {
let scenario = config
.scenarios
fn find_installer(config: &Config, name: &str) -> Result<Installer> {
config
.installers
.iter()
.find(|scenario| scenario.name == name)
.with_context(|| format!("scenario {name:?} is not defined"))?
.find(|installer| installer.name == name)
.with_context(|| format!("installer {name:?} is not defined"))
.cloned()
}
fn find_environment(config: &Config, name: &str) -> Result<(Machine, Environment)> {
let environment = config
.environments
.iter()
.find(|environment| environment.name == name)
.with_context(|| format!("environment {name:?} is not defined"))?
.clone();
let machine = config
.machines
.iter()
.find(|machine| machine.name == scenario.machine)
.find(|machine| machine.name == environment.machine)
.with_context(|| {
format!(
"machine {:?} referenced by scenario {:?} is not defined",
scenario.machine, scenario.name
"machine {:?} referenced by environment {:?} is not defined",
environment.machine, environment.name
)
})?
.clone();
Ok((machine, scenario))
Ok((machine, environment))
}
+16 -13
View File
@@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use blake3::Hasher;
use crate::config::{Machine, Scenario};
use crate::config::{Environment, Installer, Machine};
use super::ansible_hash::hash_sources;
use super::layer::{cached_layer, hash_file, hash_files, hash_inputs};
@@ -14,31 +14,34 @@ use super::setup::setup;
const INSTALL_CACHE_VERSION: &[u8] = b"tmachine-install-blake3-v1";
pub async fn install(machine: &Machine, scenario: &Scenario) -> Result<PathBuf> {
let setup_disk = setup(machine, scenario).await?;
if scenario.install.playbooks.is_empty() && scenario.install.inputs.is_empty() {
pub async fn install(
machine: &Machine,
environment: &Environment,
installer: &Installer,
) -> Result<PathBuf> {
let setup_disk = setup(machine, environment).await?;
if installer.playbooks.is_empty() && installer.inputs.is_empty() {
return Ok(setup_disk);
}
let hash = install_hash(&setup_disk, scenario)?;
let hash = install_hash(&setup_disk, installer)?;
cached_layer(
&setup_disk,
&hash,
scenario.install.use_galaxy,
&scenario.install.playbooks,
&scenario.install.inputs,
installer.use_galaxy,
&installer.playbooks,
&installer.inputs,
)
.await
}
fn install_hash(setup_disk: &Path, scenario: &Scenario) -> Result<String> {
fn install_hash(setup_disk: &Path, installer: &Installer) -> Result<String> {
let mut hasher = Hasher::new();
hasher.update(INSTALL_CACHE_VERSION);
hash_file(&mut hasher, setup_disk).context("failed to hash setup disk")?;
hasher.update(&[u8::from(scenario.install.use_galaxy)]);
hasher.update(&[u8::from(installer.use_galaxy)]);
hash_sources(&mut hasher).context("failed to hash Ansible sources")?;
hash_files(&mut hasher, &scenario.install.playbooks)
.context("failed to hash install playbooks")?;
hash_inputs(&mut hasher, &scenario.install.inputs)?;
hash_files(&mut hasher, &installer.playbooks).context("failed to hash install playbooks")?;
hash_inputs(&mut hasher, &installer.inputs)?;
Ok(hasher.finalize().to_hex().to_string())
}
+10 -9
View File
@@ -7,36 +7,37 @@ use std::path::PathBuf;
use anyhow::{Context, Result};
use blake3::Hasher;
use crate::config::{Machine, Scenario};
use crate::config::{Environment, Machine};
use super::ansible_hash::hash_sources;
use super::layer::{cached_layer, hash_file, hash_files};
const SETUP_CACHE_VERSION: &[u8] = b"tmachine-disk-blake3-v1";
pub async fn setup(machine: &Machine, scenario: &Scenario) -> Result<PathBuf> {
if scenario.setup.playbooks.is_empty() {
pub async fn setup(machine: &Machine, environment: &Environment) -> Result<PathBuf> {
if environment.setup.playbooks.is_empty() {
return Ok(machine.base_image.clone());
}
let hash = setup_hash(machine, scenario)?;
let hash = setup_hash(machine, environment)?;
cached_layer(
&machine.base_image,
&hash,
scenario.setup.use_galaxy,
&scenario.setup.playbooks,
environment.setup.use_galaxy,
&environment.setup.playbooks,
&BTreeMap::new(),
)
.await
}
fn setup_hash(machine: &Machine, scenario: &Scenario) -> Result<String> {
fn setup_hash(machine: &Machine, environment: &Environment) -> Result<String> {
let mut hasher = Hasher::new();
hasher.update(SETUP_CACHE_VERSION);
hash_file(&mut hasher, &machine.base_image)
.with_context(|| format!("failed to hash base image for machine {:?}", machine.name))?;
hasher.update(&[u8::from(scenario.setup.use_galaxy)]);
hasher.update(&[u8::from(environment.setup.use_galaxy)]);
hash_sources(&mut hasher).context("failed to hash Ansible sources")?;
hash_files(&mut hasher, &scenario.setup.playbooks).context("failed to hash setup playbooks")?;
hash_files(&mut hasher, &environment.setup.playbooks)
.context("failed to hash setup playbooks")?;
Ok(hasher.finalize().to_hex().to_string())
}
+8 -3
View File
@@ -3,7 +3,7 @@
use tempfile::tempdir;
use crate::config::{Machine, Scenario, Testsuite};
use crate::config::{Environment, Installer, Machine, Testsuite};
use anyhow::Result;
use super::img::QemuImage;
@@ -11,8 +11,13 @@ use super::install::install;
use super::layer::run_playbooks;
use super::vm::QemuVm;
pub async fn test(machine: &Machine, scenario: &Scenario, testsuite: &Testsuite) -> Result<()> {
let install_disk = install(machine, scenario).await?;
pub async fn test(
machine: &Machine,
environment: &Environment,
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;