diff --git a/.agents/skills/test-release-canary/SKILL.md b/.agents/skills/test-release-canary/SKILL.md index 7f279778c4..797211e6f0 100644 --- a/.agents/skills/test-release-canary/SKILL.md +++ b/.agents/skills/test-release-canary/SKILL.md @@ -28,9 +28,14 @@ The workflow sets `OPENSHELL_VERSION=dev`, so every `install.sh` job consumes th rolling dev release produced by the triggering workflow. Kubernetes pins the matching `0.0.0-dev` chart and `:dev` images. -The host-package jobs exercise fresh installs, not upgrades from a persisted -schema-v1 gateway config. Validate Homebrew and RPM exact-default migration with -the release-tooling and package lifecycle tests before relying on the canary. +The host-package jobs exercise fresh installs, not upgrades from persisted +gateway state. Release Dev and Release Tag additionally run tmachine Ubuntu DEB +and Fedora RPM upgrade lanes: they install the latest retained prerelease +packages and matching runtime images, upgrade to the candidate packages, verify +an existing sandbox survives, and create a new sandbox. Those lanes are part of +the release conformance matrix, not this canary. Validate Homebrew exact-default +migration with the release-tooling and package lifecycle tests before relying +on the canary. The canary does not install or import `@nvidia/openshell-sdk`. TypeScript SDK validation lives in the `TypeScript SDK` branch check, including a publish diff --git a/.github/workflows/branch-e2e.yml b/.github/workflows/branch-e2e.yml index 99c68b9793..7c2df02393 100644 --- a/.github/workflows/branch-e2e.yml +++ b/.github/workflows/branch-e2e.yml @@ -26,6 +26,7 @@ jobs: 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_integration_upgrades: ${{ steps.labels.outputs.run_integration_upgrades }} run_kubernetes_ha_e2e: ${{ steps.labels.outputs.run_kubernetes_ha_e2e }} run_kubernetes_credential_drivers_e2e: ${{ steps.labels.outputs.run_kubernetes_credential_drivers_e2e }} run_any_e2e: ${{ steps.labels.outputs.run_any_e2e }} @@ -47,6 +48,7 @@ jobs: push) run_core_e2e="$(jq -r 'index("test:e2e") != null' <<< "$LABELS_JSON")" run_gpu_e2e="$(jq -r 'index("test:e2e-gpu") != null' <<< "$LABELS_JSON")" + run_integration_upgrades="$(jq -r 'index("test:upgrade") != null' <<< "$LABELS_JSON")" run_kubernetes_ha_e2e=false run_kubernetes_credential_drivers_e2e=false ;; @@ -56,17 +58,19 @@ jobs: # and ejects the PR. HA stays off until stable. run_core_e2e=true run_gpu_e2e=true + run_integration_upgrades=false run_kubernetes_ha_e2e=false run_kubernetes_credential_drivers_e2e=false ;; *) run_core_e2e=true run_gpu_e2e=true + run_integration_upgrades=false run_kubernetes_ha_e2e=false run_kubernetes_credential_drivers_e2e=false ;; esac - if [ "$run_core_e2e" = "true" ] || [ "$run_gpu_e2e" = "true" ] || [ "$run_kubernetes_ha_e2e" = "true" ] || [ "$run_kubernetes_credential_drivers_e2e" = "true" ]; then + if [ "$run_core_e2e" = "true" ] || [ "$run_gpu_e2e" = "true" ] || [ "$run_integration_upgrades" = "true" ] || [ "$run_kubernetes_ha_e2e" = "true" ] || [ "$run_kubernetes_credential_drivers_e2e" = "true" ]; then run_any_e2e=true else run_any_e2e=false @@ -75,6 +79,7 @@ jobs: { echo "run_core_e2e=$run_core_e2e" echo "run_gpu_e2e=$run_gpu_e2e" + echo "run_integration_upgrades=$run_integration_upgrades" echo "run_kubernetes_ha_e2e=$run_kubernetes_ha_e2e" echo "run_kubernetes_credential_drivers_e2e=$run_kubernetes_credential_drivers_e2e" echo "run_any_e2e=$run_any_e2e" @@ -82,13 +87,16 @@ jobs: version: needs: [pr_metadata] - if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_any_e2e == 'true' + if: needs.pr_metadata.outputs.should_run == 'true' permissions: contents: read runs-on: ubuntu-latest timeout-minutes: 5 outputs: cargo: ${{ steps.version.outputs.cargo }} + deb: ${{ steps.version.outputs.deb }} + rpm_version: ${{ steps.version.outputs.rpm_version }} + rpm_release: ${{ steps.version.outputs.rpm_release }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -99,7 +107,15 @@ jobs: id: version run: | cargo="$(python3 tasks/scripts/release.py get-version --cargo)" - echo "cargo=$cargo" >> "$GITHUB_OUTPUT" + deb="$(python3 tasks/scripts/release.py get-version --dev --deb)" + rpm_version="$(python3 tasks/scripts/release.py get-version --dev --rpm-version)" + rpm_release="$(python3 tasks/scripts/release.py get-version --dev --rpm-release)" + { + echo "cargo=$cargo" + echo "deb=$deb" + echo "rpm_version=$rpm_version" + echo "rpm_release=$rpm_release" + } >> "$GITHUB_OUTPUT" build-binaries: needs: version @@ -182,8 +198,10 @@ jobs: cargo-version: ${{ needs.version.outputs.cargo }} build-vm-driver: + # The Debian package embeds this binary, so unconditional package builds + # require the VM driver artifact even when no E2E suite is selected. needs: [pr_metadata, version, build-binaries] - if: needs.pr_metadata.outputs.run_core_e2e == 'true' + if: needs.pr_metadata.outputs.should_run == 'true' permissions: contents: read uses: ./.github/workflows/build-vm-driver.yml @@ -209,6 +227,66 @@ jobs: packages: read uses: ./.github/workflows/prepare-integration-inputs.yml + build-deb: + name: Build Debian packages + needs: [pr_metadata, version, build-binaries, build-vm-driver] + if: needs.pr_metadata.outputs.should_run == 'true' + permissions: + actions: read + contents: read + packages: read + uses: ./.github/workflows/deb-package.yml + with: + deb-version: ${{ needs.version.outputs.deb }} + checkout-ref: ${{ github.sha }} + + build-rpm: + name: Build RPM packages + needs: [pr_metadata, version, build-binaries] + if: needs.pr_metadata.outputs.should_run == 'true' + permissions: + actions: read + contents: read + uses: ./.github/workflows/rpm-package.yml + with: + checkout-ref: ${{ github.sha }} + rpm-version: ${{ needs.version.outputs.rpm_version }} + rpm-release: ${{ needs.version.outputs.rpm_release }} + cargo-version: ${{ needs.version.outputs.cargo }} + + prepare-integration-upgrades: + name: Prepare upgrade qualification inputs + needs: [pr_metadata, build-binaries, build-images, build-deb, build-rpm] + if: needs.pr_metadata.outputs.run_integration_upgrades == 'true' + permissions: + actions: read + contents: read + packages: read + uses: ./.github/workflows/prepare-integration-inputs.yml + with: + deb-artifact-name: deb-linux-amd64 + rpm-artifact-name: rpm-linux-x86_64 + include-deb-upgrade-source: true + include-rpm-upgrade-source: true + + integration-upgrades: + name: Integration upgrades + needs: prepare-integration-upgrades + permissions: + actions: read + contents: read + packages: read + uses: ./.github/workflows/integration-runner.yml + with: + category: upgrades + source-sha: ${{ needs.prepare-integration-upgrades.outputs.source_sha }} + integration-inputs-artifact-id: ${{ needs.prepare-integration-upgrades.outputs.integration_inputs_artifact_id }} + test-matrix: >- + [ + {"environment":"ubuntu-docker-rootful","installer":"deb-upgrade-source","testsuite":"deb-upgrade"}, + {"environment":"fedora-podman-rootless","installer":"rpm-upgrade-source","testsuite":"rpm-upgrade"} + ] + # Run driver-independent conformance tests. conformance-integration: needs: prepare-integration diff --git a/.github/workflows/e2e-label-help.yml b/.github/workflows/e2e-label-help.yml index b412cdc6f2..d542b7d1a8 100644 --- a/.github/workflows/e2e-label-help.yml +++ b/.github/workflows/e2e-label-help.yml @@ -19,7 +19,7 @@ permissions: {} jobs: hint: name: Post next-step hint for E2E label - if: github.event.label.name == 'test:e2e' || github.event.label.name == 'test:e2e-gpu' || github.event.label.name == 'test:e2e-kubernetes' + if: github.event.label.name == 'test:e2e' || github.event.label.name == 'test:e2e-gpu' || github.event.label.name == 'test:e2e-kubernetes' || github.event.label.name == 'test:upgrade' runs-on: ubuntu-latest permissions: pull-requests: write @@ -50,6 +50,11 @@ jobs: build_summary="supervisor image" status_summary="The matching required CI gate status on this PR will flip green automatically once the run finishes." ;; + test:upgrade) + suite_summary="the Debian and RPM upgrade qualification" + build_summary="DEB and RPM packages, VM driver, sandbox image, and supervisor image" + status_summary="This is an optional proof-of-life suite; failures are visible in the workflow run but do not publish a required CI gate status." + ;; test:e2e-kubernetes) suite_summary="Kubernetes HA and credential-driver E2E" build_summary="gateway, sandbox, and supervisor images" diff --git a/.github/workflows/prepare-integration-inputs.yml b/.github/workflows/prepare-integration-inputs.yml index 1fb14e5835..22a5113391 100644 --- a/.github/workflows/prepare-integration-inputs.yml +++ b/.github/workflows/prepare-integration-inputs.yml @@ -16,6 +16,21 @@ on: required: false type: string default: "" + rpm-artifact-name: + description: RPM package artifact to include in the tmachine inputs + required: false + type: string + default: "" + include-deb-upgrade-source: + description: Include the latest retained prerelease DEB and runtime images as upgrade inputs + required: false + type: boolean + default: false + include-rpm-upgrade-source: + description: Include the latest retained prerelease RPMs and runtime images as upgrade inputs + required: false + type: boolean + default: false outputs: source_sha: description: Source revision of the candidate artifacts @@ -86,6 +101,199 @@ jobs: mv artifacts/packages/download/*.deb artifacts/packages/openshell.deb rmdir artifacts/packages/download + - name: Download RPM package artifact + if: inputs['rpm-artifact-name'] != '' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs['rpm-artifact-name'] }} + path: artifacts/packages/rpm/download + github-token: ${{ github.token }} + run-id: ${{ inputs['artifact-run-id'] || github.run_id }} + + - name: Stage RPM package inputs + if: inputs['rpm-artifact-name'] != '' + run: | + set -euo pipefail + download_dir=artifacts/packages/rpm/download + cli_rpm=$(find "$download_dir" -maxdepth 1 -type f -name 'openshell-[0-9]*.x86_64.rpm' -print -quit) + gateway_rpm=$(find "$download_dir" -maxdepth 1 -type f -name 'openshell-gateway-[0-9]*.x86_64.rpm' -print -quit) + if [[ -z "$cli_rpm" || -z "$gateway_rpm" ]]; then + echo "candidate artifact did not contain the expected x86_64 CLI and gateway RPMs" >&2 + exit 1 + fi + mv "$cli_rpm" artifacts/packages/rpm/openshell.rpm + mv "$gateway_rpm" artifacts/packages/rpm/openshell-gateway.rpm + rm -rf "$download_dir" + + - name: Resolve prerelease upgrade source + if: inputs['include-deb-upgrade-source'] || inputs['include-rpm-upgrade-source'] + id: upgrade-source + env: + GH_TOKEN: ${{ github.token }} + INCLUDE_DEB: ${{ inputs['include-deb-upgrade-source'] }} + INCLUDE_RPM: ${{ inputs['include-rpm-upgrade-source'] }} + run: | + set -euo pipefail + successful_run_ids=$(gh api --paginate \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/release-tag.yml/runs?status=success&per_page=100" \ + --jq '.workflow_runs[] | select(.status == "completed" and .conclusion == "success") | .id') + artifact_records=$(gh api --paginate \ + "repos/${GITHUB_REPOSITORY}/actions/artifacts?per_page=100" \ + --jq '.artifacts[] | select(.expired == false) | [.workflow_run.id, .name] | @tsv') + source_record=$(printf '%s\n--ARTIFACTS--\n%s\n' "$successful_run_ids" "$artifact_records" | awk -F '\t' \ + -v require_deb="$INCLUDE_DEB" -v require_rpm="$INCLUDE_RPM" ' + $0 == "--ARTIFACTS--" { + reading_artifacts = 1 + next + } + !reading_artifacts { + if ($1 ~ /^[0-9]+$/) successful_runs[$1] = 1 + next + } + $1 in successful_runs { + run_id = $1 + artifact = $2 + tag = "" + if (artifact ~ /^openshell-v[0-9]+\.[0-9]+\.[0-9]+-pre\.[1-9][0-9]*-linux-amd64-deb$/) { + tag = artifact + sub(/^openshell-/, "", tag) + sub(/-linux-amd64-deb$/, "", tag) + deb[run_id SUBSEP tag] = artifact + } else if (artifact ~ /^openshell-v[0-9]+\.[0-9]+\.[0-9]+-pre\.[1-9][0-9]*-linux-x86_64-rpm$/) { + tag = artifact + sub(/^openshell-/, "", tag) + sub(/-linux-x86_64-rpm$/, "", tag) + rpm[run_id SUBSEP tag] = artifact + } + if (tag != "") { + candidates[run_id SUBSEP tag] = tag + runs[run_id SUBSEP tag] = run_id + } + } + END { + for (key in candidates) { + if (require_deb == "true" && !(key in deb)) continue + if (require_rpm == "true" && !(key in rpm)) continue + tag = candidates[key] + run_id = runs[key] + deb_artifact = (key in deb) ? deb[key] : "-" + rpm_artifact = (key in rpm) ? rpm[key] : "-" + version = tag + sub(/^v/, "", version) + split(version, parts, "-pre\\.") + split(parts[1], core, "\\.") + sequence = parts[2] + 0 + if (!found || core[1] + 0 > major || + (core[1] + 0 == major && core[2] + 0 > minor) || + (core[1] + 0 == major && core[2] + 0 == minor && core[3] + 0 > patch) || + (core[1] + 0 == major && core[2] + 0 == minor && core[3] + 0 == patch && sequence > prerelease)) { + selected = tag + selected_run = run_id + selected_deb = deb_artifact + selected_rpm = rpm_artifact + major = core[1] + 0 + minor = core[2] + 0 + patch = core[3] + 0 + prerelease = sequence + found = 1 + } + } + if (found) { + printf "%s\t%s\t%s\t%s\n", selected, selected_run, selected_deb, selected_rpm + } + }') + if [[ -z "$source_record" ]]; then + echo "no successful retained prerelease contains all requested package artifacts" >&2 + exit 1 + fi + IFS=$'\t' read -r source_tag source_run_id deb_artifact rpm_artifact <<< "$source_record" + { + echo "tag=${source_tag}" + echo "run-id=${source_run_id}" + if [[ "$deb_artifact" != "-" ]]; then echo "deb-artifact=${deb_artifact}"; fi + if [[ "$rpm_artifact" != "-" ]]; then echo "rpm-artifact=${rpm_artifact}"; fi + } >> "$GITHUB_OUTPUT" + + - name: Download prerelease Debian upgrade source + if: inputs['include-deb-upgrade-source'] + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ steps.upgrade-source.outputs['deb-artifact'] }} + path: artifacts/upgrade/deb/source/download + github-token: ${{ github.token }} + run-id: ${{ steps.upgrade-source.outputs['run-id'] }} + + - name: Stage prerelease Debian upgrade source + if: inputs['include-deb-upgrade-source'] + run: | + set -euo pipefail + source_dir=artifacts/upgrade/deb/source + download_dir="${source_dir}/download" + deb_path=$(find "$download_dir" -maxdepth 1 -type f -name 'openshell_*_amd64.deb' -print -quit) + if [[ -z "$deb_path" || ! -f "${download_dir}/openshell-checksums-sha256.txt" ]]; then + echo "prerelease source artifact did not contain the expected Debian package and checksums" >&2 + exit 1 + fi + ( + cd "$download_dir" + grep -F " $(basename "$deb_path")" openshell-checksums-sha256.txt | sha256sum --check --status + ) + mv "$deb_path" "${source_dir}/openshell.deb" + mv "${download_dir}/openshell-checksums-sha256.txt" "${source_dir}/openshell-checksums-sha256.txt" + rmdir "$download_dir" + dpkg-deb --field "${source_dir}/openshell.deb" Version > "${source_dir}/version" + + # Packages do not embed OCI image payloads. Their gateway binaries pin + # this release tag, while tmachine consumes tarballs to keep guest setup + # independent of registry access. + - name: Export prerelease runtime images for upgrade sources + if: inputs['include-deb-upgrade-source'] || inputs['include-rpm-upgrade-source'] + env: + IMAGE_TAG: ${{ steps.upgrade-source.outputs.tag }} + run: | + set -euo pipefail + image_tag="${IMAGE_TAG#v}" + mkdir -p artifacts/upgrade/source-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/upgrade/source-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/upgrade/source-images/openshell-supervisor-tmachine.tar openshell/supervisor:tmachine + + - name: Download prerelease RPM upgrade source + if: inputs['include-rpm-upgrade-source'] + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ steps.upgrade-source.outputs['rpm-artifact'] }} + path: artifacts/upgrade/rpm/source/download + github-token: ${{ github.token }} + run-id: ${{ steps.upgrade-source.outputs['run-id'] }} + + - name: Stage prerelease RPM upgrade source + if: inputs['include-rpm-upgrade-source'] + run: | + set -euo pipefail + source_dir=artifacts/upgrade/rpm/source + download_dir="${source_dir}/download" + cli_rpm=$(find "$download_dir" -maxdepth 1 -type f -name 'openshell-[0-9]*.x86_64.rpm' -print -quit) + gateway_rpm=$(find "$download_dir" -maxdepth 1 -type f -name 'openshell-gateway-[0-9]*.x86_64.rpm' -print -quit) + if [[ -z "$cli_rpm" || -z "$gateway_rpm" || ! -f "${download_dir}/openshell-checksums-sha256.txt" ]]; then + echo "prerelease source artifact did not contain the expected RPMs and checksums" >&2 + exit 1 + fi + ( + cd "$download_dir" + grep -F " $(basename "$cli_rpm")" openshell-checksums-sha256.txt | sha256sum --check --status + grep -F " $(basename "$gateway_rpm")" openshell-checksums-sha256.txt | sha256sum --check --status + ) + mv "$cli_rpm" "${source_dir}/openshell.rpm" + mv "$gateway_rpm" "${source_dir}/openshell-gateway.rpm" + mv "${download_dir}/openshell-checksums-sha256.txt" "${source_dir}/openshell-checksums-sha256.txt" + rm -rf "$download_dir" + - name: Log in to GHCR run: echo "${{ github.token }}" | docker login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index c188ba3dc6..ab9cb2677e 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -102,7 +102,7 @@ jobs: checkout-ref: ${{ github.sha }} prepare-integration: - needs: [build-binaries, build-deb, build-images] + needs: [build-binaries, build-deb, build-images, build-rpm] permissions: actions: read contents: read @@ -110,6 +110,9 @@ jobs: uses: ./.github/workflows/prepare-integration-inputs.yml with: deb-artifact-name: deb-linux-amd64 + rpm-artifact-name: rpm-linux-x86_64 + include-deb-upgrade-source: true + include-rpm-upgrade-source: true conformance-integration: needs: prepare-integration @@ -122,6 +125,14 @@ jobs: 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":"deb","testsuite":"conformance"}, + {"environment":"ubuntu-docker-rootful","installer":"deb-upgrade-source","testsuite":"deb-upgrade"}, + {"environment":"fedora-podman-rootless","installer":"rpm-upgrade-source","testsuite":"rpm-upgrade"}, + {"environment":"fedora-podman-rootful","installer":"binaries","testsuite":"conformance"}, + {"environment":"fedora-podman-rootless","installer":"binaries","testsuite":"conformance"} + ] feature-specific-integration: needs: prepare-integration diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 10465d251d..f345bb014d 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -152,7 +152,7 @@ jobs: CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }} prepare-integration: - needs: [compute-versions, build-binaries, build-deb, build-images] + needs: [compute-versions, build-binaries, build-deb, build-images, build-rpm] permissions: actions: read contents: read @@ -160,6 +160,9 @@ jobs: uses: ./.github/workflows/prepare-integration-inputs.yml with: deb-artifact-name: deb-linux-amd64 + rpm-artifact-name: rpm-linux-x86_64 + include-deb-upgrade-source: true + include-rpm-upgrade-source: true conformance-integration: needs: prepare-integration @@ -172,6 +175,14 @@ jobs: 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":"deb","testsuite":"conformance"}, + {"environment":"ubuntu-docker-rootful","installer":"deb-upgrade-source","testsuite":"deb-upgrade"}, + {"environment":"fedora-podman-rootless","installer":"rpm-upgrade-source","testsuite":"rpm-upgrade"}, + {"environment":"fedora-podman-rootful","installer":"binaries","testsuite":"conformance"}, + {"environment":"fedora-podman-rootless","installer":"binaries","testsuite":"conformance"} + ] feature-specific-integration: needs: prepare-integration diff --git a/CI.md b/CI.md index 1324ca9d7f..8f5296d640 100644 --- a/CI.md +++ b/CI.md @@ -18,11 +18,19 @@ Windows checks are not required for merging and do not run in merge queues. Main and manual runs also build release binaries, with `continue-on-error: true` so Windows failures do not fail the workflow. -Three opt-in labels enable the long-running E2E suites: +Four opt-in labels enable the long-running E2E suites: + +Every approved `Branch E2E Checks` run builds the DEB and RPM packages, even +when no optional E2E label is present. The `test:upgrade` label controls only +the tmachine upgrade execution and its runtime-image preparation. - `test:e2e` runs the Docker, rootless Podman, Kubernetes, and VM E2E suites with both managed and standalone compute drivers in `Branch E2E Checks` - `test:e2e-gpu` runs GPU E2E in `Branch E2E Checks` +- `test:upgrade` runs tmachine Debian and RPM upgrade qualification in + `Branch E2E Checks`: the latest retained prerelease packages and matching + runtime images are installed first, then the PR's packages are installed and + both existing and new sandboxes are verified - `test:e2e-kubernetes` runs Kubernetes E2E with the HA Helm overlay (`replicaCount: 2` and bundled PostgreSQL) and the credential-driver suite (Kubernetes Secrets plus Vault) in `Branch E2E Checks` @@ -307,7 +315,7 @@ Flow: 1. Open the PR. copy-pr-bot mirrors it to `pull-request/` automatically. 2. The mirror push runs `Branch Checks` automatically. `Required CI Gates` keeps the PR blocked until the mirror exists, matches the PR head SHA, and the required push-based workflow succeeds. The first `Branch E2E Checks` run only resolves metadata and skips expensive jobs unless an E2E label is already set. -3. A maintainer applies `test:e2e`, `test:e2e-gpu`, and/or `test:e2e-kubernetes`. `E2E Label Help` posts a comment with a link to the existing gated workflow run. +3. A maintainer applies `test:e2e`, `test:e2e-gpu`, `test:upgrade`, and/or `test:e2e-kubernetes`. `E2E Label Help` posts a comment with a link to the existing gated workflow run. 4. The maintainer opens that link and clicks **Re-run all jobs**. This time `pr_metadata` sees the label and the build/E2E jobs run. 5. When the run finishes, the matching `OpenShell / ...` gate status flips to green automatically. 6. New commits push to the mirror automatically and re-trigger `Branch Checks` plus any labeled E2E jobs in `Branch E2E Checks`. @@ -346,7 +354,7 @@ its own stable result status. Merge-group runs use the `merge_group` event. The event is distinct from `pull_request` and `push`, and GitHub will not report required checks for queued PRs unless the workflows include it. In this repository: - `Branch Checks` runs the standard non-E2E gates on the merge-group SHA. -- `Branch E2E Checks` runs core E2E and GPU E2E for merge groups. Kubernetes HA E2E remains optional and label-driven on PRs. +- `Branch E2E Checks` runs core E2E and GPU E2E for merge groups. Debian and RPM upgrade qualification and Kubernetes HA E2E remain optional and label-driven on PRs. - `Helm Lint` runs for merge groups without the PR diff optimization, because the merge-group branch is the final integration state. - `Trivy Changes` compares the merge-group configuration with its base and rejects new High or Critical findings. - `Required CI Gates` posts the same `OpenShell / ...` statuses to the merge-group SHA and does not require a `pull-request/` mirror for merge-group events. @@ -372,7 +380,7 @@ The bot's full administrator documentation is internal to NVIDIA. The only comma | File | Role | |---|---| | `.github/workflows/branch-checks.yml` | Required non-E2E checks. Triggers on `push: pull-request/[0-9]+` for PR mirrors and `merge_group` for queued merges. | -| `.github/workflows/branch-e2e.yml` | Standard, GPU, Kubernetes HA, and Kubernetes credential-driver E2E. PR mirror pushes use `test:e2e`, `test:e2e-gpu`, and `test:e2e-kubernetes` labels; merge groups run core and GPU E2E. | +| `.github/workflows/branch-e2e.yml` | Standard, GPU, Debian and RPM upgrade, Kubernetes HA, and Kubernetes credential-driver E2E. PR mirror pushes use `test:e2e`, `test:e2e-gpu`, `test:upgrade`, and `test:e2e-kubernetes` labels; merge groups run core and GPU E2E. | | `.github/workflows/build-binaries.yml`, `build-vm-driver.yml` | Shared binary matrices used by branch and release workflows. The VM driver remains separate because its build consumes the runtime binaries. | | `.github/workflows/build-images.yml` | Builds and pushes multi-platform images, then uploads the same OCI images as workflow artifacts. | | `.github/workflows/package-release-binaries.yml` | Packages raw build artifacts into release tarballs without rebuilding them. | diff --git a/crates/openshell-driver-docker/src/isolation.rs b/crates/openshell-driver-docker/src/isolation.rs index d921b8c348..d4c46a566a 100644 --- a/crates/openshell-driver-docker/src/isolation.rs +++ b/crates/openshell-driver-docker/src/isolation.rs @@ -16,15 +16,27 @@ use openshell_isolation_interface::contract::{ }; use openshell_sandbox_backend::GPU_RESOURCE_CLAIM; use openshell_sandbox_backend::boundary_protocol::{ - BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, - SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, + BoundaryConfig, BoundaryListener, FenceWireFormat, GatewayVerificationKey, + SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, + fence_wire_format, }; -use serde::Serialize; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize)] +#[serde(tag = "backend", rename_all = "kebab-case", deny_unknown_fields)] +enum LegacyDriverFenceEvidence { + Docker { + container_id: String, + network_mode: String, + unexpected_networks: Vec, + }, +} #[derive(Serialize)] struct DockerOuterFenceEvidence<'a> { container_id: &'a str, - network_mode: &'static str, + network_mode: &'a str, unexpected_networks: &'a [String], } @@ -58,6 +70,127 @@ impl DockerOuterFenceEvidence<'_> { } } +fn decode_fence_compatible( + encoded: &[u8], + description: &str, +) -> Result<(T, FenceWireFormat), BackendError> { + let mut value: serde_json::Value = serde_json::from_slice(encoded) + .map_err(|error| BackendError::Descriptor(format!("decode {description}: {error}")))?; + let object = value.as_object_mut().ok_or_else(|| { + BackendError::Descriptor(format!("decode {description}: expected a JSON object")) + })?; + let format = fence_wire_format(object, description)?; + if format == FenceWireFormat::LegacyDriverFence { + let generation = object + .get("generation") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + BackendError::Descriptor(format!( + "decode {description}: legacy bootstrap generation is missing" + )) + })? + .to_string(); + let legacy: LegacyDriverFenceEvidence = serde_json::from_value( + object.remove("driver_fence").expect("checked above"), + ) + .map_err(|error| { + BackendError::Descriptor(format!("decode {description} legacy driver fence: {error}")) + })?; + let LegacyDriverFenceEvidence::Docker { + container_id, + network_mode, + unexpected_networks, + } = legacy; + let projection = DockerOuterFenceEvidence { + container_id: &container_id, + network_mode: &network_mode, + unexpected_networks: &unexpected_networks, + } + .project(&generation)?; + object.insert( + "outer_fence".to_string(), + serde_json::to_value(projection).map_err(|error| { + BackendError::Descriptor(format!("encode migrated Docker outer fence: {error}")) + })?, + ); + } + serde_json::from_value(value) + .map(|decoded| (decoded, format)) + .map_err(|error| BackendError::Descriptor(format!("decode {description}: {error}"))) +} + +pub fn decode_boundary_config_compatible( + encoded: &[u8], +) -> Result<(BoundaryConfig, FenceWireFormat), BackendError> { + decode_fence_compatible(encoded, "Docker boundary config") +} + +pub fn decode_runtime_descriptor_compatible( + encoded: &[u8], +) -> Result<(SandboxRuntimeDescriptor, FenceWireFormat), BackendError> { + decode_fence_compatible(encoded, "Docker runtime descriptor") +} + +fn encode_fence_compatible( + value: &T, + format: FenceWireFormat, + description: &str, +) -> Result, BackendError> { + let mut value = serde_json::to_value(value) + .map_err(|error| BackendError::Descriptor(format!("encode {description}: {error}")))?; + if format == FenceWireFormat::LegacyDriverFence { + let object = value.as_object_mut().ok_or_else(|| { + BackendError::Descriptor(format!("encode {description}: expected a JSON object")) + })?; + let container_id = object + .get("resource_claims") + .and_then(serde_json::Value::as_object) + .and_then(|claims| claims.get("docker.container_id")) + .and_then(serde_json::Value::as_str) + .filter(|container_id| !container_id.is_empty()) + .ok_or_else(|| { + BackendError::Descriptor(format!( + "encode {description}: Docker container resource claim is missing" + )) + })? + .to_string(); + if object.remove("outer_fence").is_none() { + return Err(BackendError::Descriptor(format!( + "encode {description}: outer fence projection is missing" + ))); + } + object.insert( + "driver_fence".to_string(), + serde_json::to_value(LegacyDriverFenceEvidence::Docker { + container_id, + network_mode: "none".to_string(), + unexpected_networks: Vec::new(), + }) + .map_err(|error| { + BackendError::Descriptor(format!( + "encode {description} legacy driver fence: {error}" + )) + })?, + ); + } + serde_json::to_vec(&value) + .map_err(|error| BackendError::Descriptor(format!("encode {description}: {error}"))) +} + +pub fn encode_boundary_config_compatible( + config: &BoundaryConfig, + format: FenceWireFormat, +) -> Result, BackendError> { + encode_fence_compatible(config, format, "Docker boundary config") +} + +pub fn encode_runtime_descriptor_compatible( + descriptor: &SandboxRuntimeDescriptor, + format: FenceWireFormat, +) -> Result, BackendError> { + encode_fence_compatible(descriptor, format, "Docker runtime descriptor") +} + /// Driver-owned inputs that bind one Docker container to one boundary. pub struct DockerBoundarySpec { pub boundary_id: String, @@ -143,37 +276,12 @@ impl DockerBoundarySpec { mod tests { use super::*; - #[test] - fn outer_fence_projection_rejects_each_missing_native_fact() { - let unexpected_networks = vec!["bridge".to_string()]; - for evidence in [ - DockerOuterFenceEvidence { - container_id: "", - network_mode: "none", - unexpected_networks: &[], - }, - DockerOuterFenceEvidence { - container_id: "container", - network_mode: "bridge", - unexpected_networks: &[], - }, - DockerOuterFenceEvidence { - container_id: "container", - network_mode: "none", - unexpected_networks: &unexpected_networks, - }, - ] { - assert!(evidence.project("generation-1").is_err()); - } - } - - #[test] - fn provisioning_binds_container_and_image_claims() { + fn provisioning() -> DockerBoundaryProvisioning { let session_id = openshell_core::SandboxSessionId::new(); let tls = openshell_sandbox_backend::boundary_protocol::generate_sandbox_tls_material(session_id) .unwrap(); - let provisioned = DockerBoundarySpec { + DockerBoundarySpec { boundary_id: "sandbox-1".to_string(), generation: "generation-1".to_string(), session_id, @@ -209,7 +317,36 @@ mod tests { child_env: HashMap::new(), } .provision() - .unwrap(); + .unwrap() + } + + #[test] + fn outer_fence_projection_rejects_each_missing_native_fact() { + let unexpected_networks = vec!["bridge".to_string()]; + for evidence in [ + DockerOuterFenceEvidence { + container_id: "", + network_mode: "none", + unexpected_networks: &[], + }, + DockerOuterFenceEvidence { + container_id: "container", + network_mode: "bridge", + unexpected_networks: &[], + }, + DockerOuterFenceEvidence { + container_id: "container", + network_mode: "none", + unexpected_networks: &unexpected_networks, + }, + ] { + assert!(evidence.project("generation-1").is_err()); + } + } + + #[test] + fn provisioning_binds_container_and_image_claims() { + let provisioned = provisioning(); assert_eq!( provisioned.boundary_config.resource_claims, @@ -235,4 +372,36 @@ mod tests { .is_ok() ); } + + #[test] + fn legacy_driver_fence_round_trips_through_current_projection() { + let provisioned = provisioning(); + let boundary = encode_boundary_config_compatible( + &provisioned.boundary_config, + FenceWireFormat::LegacyDriverFence, + ) + .unwrap(); + let descriptor = encode_runtime_descriptor_compatible( + &provisioned.runtime_descriptor, + FenceWireFormat::LegacyDriverFence, + ) + .unwrap(); + for encoded in [&boundary, &descriptor] { + let value: serde_json::Value = serde_json::from_slice(encoded).unwrap(); + assert!(value.get("outer_fence").is_none()); + assert_eq!(value["driver_fence"]["backend"], "docker"); + assert_eq!(value["driver_fence"]["container_id"], "sha256:container"); + } + let (decoded_boundary, boundary_format) = + decode_boundary_config_compatible(&boundary).unwrap(); + let (decoded_descriptor, descriptor_format) = + decode_runtime_descriptor_compatible(&descriptor).unwrap(); + assert_eq!(boundary_format, FenceWireFormat::LegacyDriverFence); + assert_eq!(descriptor_format, FenceWireFormat::LegacyDriverFence); + assert_eq!(decoded_boundary.outer_fence, decoded_descriptor.outer_fence); + assert_eq!( + decoded_boundary.outer_fence, + provisioned.boundary_config.outer_fence + ); + } } diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index c76c4c838b..d8deaad3af 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -60,7 +60,7 @@ use openshell_core::{ }; use openshell_isolation_interface::contract::ResolvedWorkloadIdentity; use openshell_sandbox_backend::boundary_protocol::{ - BoundaryConfig, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, + FenceWireFormat, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, generate_sandbox_tls_material, }; use opentelemetry::trace::TraceContextExt as _; @@ -4725,26 +4725,33 @@ async fn refresh_docker_boundary_authentication( ) -> Result<(), Status> { let authentication = decode_docker_launch_authentication(encoded_authentication)?; let directory = docker_boundary_state_dir_by_id(sandbox_id, config)?; - let mut boundary_config = serde_json::from_slice::( - &tokio::fs::read(directory.join(BOUNDARY_CONFIG_FILE)) - .await - .map_err(|error| { - Status::failed_precondition(format!( - "read Docker sandbox bootstrap for authentication rotation: {error}" - )) - })?, - ) - .map_err(|error| { - Status::failed_precondition(format!( - "decode Docker sandbox bootstrap for authentication rotation: {error}" - )) - })?; - let Some(mut runtime_descriptor) = read_docker_runtime_descriptor(sandbox_id, config).await? + let boundary_bytes = tokio::fs::read(directory.join(BOUNDARY_CONFIG_FILE)) + .await + .map_err(|error| { + Status::failed_precondition(format!( + "read Docker sandbox bootstrap for authentication rotation: {error}" + )) + })?; + let (mut boundary_config, boundary_format) = + isolation::decode_boundary_config_compatible(&boundary_bytes).map_err(|error| { + Status::failed_precondition(format!( + "decode Docker sandbox bootstrap for authentication rotation: {error}" + )) + })?; + let Some((mut runtime_descriptor, descriptor_format)) = + read_docker_runtime_descriptor_with_format(sandbox_id, config).await? else { return Err(Status::failed_precondition( "Docker sandbox runtime descriptor is missing during authentication rotation", )); }; + let wire_format = if boundary_format == FenceWireFormat::LegacyDriverFence + || descriptor_format == FenceWireFormat::LegacyDriverFence + { + FenceWireFormat::LegacyDriverFence + } else { + FenceWireFormat::OuterFence + }; let session_id = authentication.supervisor.session_id; let tls = generate_sandbox_tls_material(session_id) .map_err(|error| Status::internal(format!("rotate Docker boundary TLS: {error}")))?; @@ -4760,12 +4767,12 @@ async fn refresh_docker_boundary_authentication( server_name: tls.server_name, trust_anchor_pem: tls.trust_anchor_pem, }; - let encoded_boundary_config = boundary_config - .encode() - .map_err(|error| Status::internal(error.to_string()))?; - let descriptor = runtime_descriptor - .backend_descriptor() - .map_err(|error| Status::internal(error.to_string()))?; + let encoded_boundary_config = + isolation::encode_boundary_config_compatible(&boundary_config, wire_format) + .map_err(|error| Status::internal(error.to_string()))?; + let descriptor = + isolation::encode_runtime_descriptor_compatible(&runtime_descriptor, wire_format) + .map_err(|error| Status::internal(error.to_string()))?; let supervisor_auth = serde_json::to_vec(&authentication.supervisor) .map_err(|error| Status::internal(format!("encode Docker supervisor auth: {error}")))?; write_docker_boundary_file( @@ -4783,11 +4790,7 @@ async fn refresh_docker_boundary_authentication( tls.private_key_pem.as_bytes(), ) .await?; - write_docker_boundary_file( - &directory.join(RUNTIME_DESCRIPTOR_FILE), - &descriptor.payload, - ) - .await?; + write_docker_boundary_file(&directory.join(RUNTIME_DESCRIPTOR_FILE), &descriptor).await?; write_docker_boundary_file( &directory.join(SUPERVISOR_AUTH_BUNDLE_FILE), &supervisor_auth, @@ -4802,21 +4805,19 @@ async fn refresh_docker_supervisor_authentication( ) -> Result<(), Status> { let authentication = decode_docker_launch_authentication(encoded_authentication)?; let directory = docker_boundary_state_dir_by_id(sandbox_id, config)?; - let Some(runtime_descriptor) = read_docker_runtime_descriptor(sandbox_id, config).await? else { + let Some((runtime_descriptor, wire_format)) = + read_docker_runtime_descriptor_with_format(sandbox_id, config).await? + else { return Err(Status::failed_precondition( "Docker sandbox runtime descriptor is missing during supervisor authentication rotation", )); }; - let descriptor = runtime_descriptor - .backend_descriptor() - .map_err(|error| Status::internal(error.to_string()))?; + let descriptor = + isolation::encode_runtime_descriptor_compatible(&runtime_descriptor, wire_format) + .map_err(|error| Status::internal(error.to_string()))?; let supervisor_auth = serde_json::to_vec(&authentication.supervisor) .map_err(|error| Status::internal(format!("encode Docker supervisor auth: {error}")))?; - write_docker_boundary_file( - &directory.join(RUNTIME_DESCRIPTOR_FILE), - &descriptor.payload, - ) - .await?; + write_docker_boundary_file(&directory.join(RUNTIME_DESCRIPTOR_FILE), &descriptor).await?; write_docker_boundary_file( &directory.join(SUPERVISOR_AUTH_BUNDLE_FILE), &supervisor_auth, @@ -4828,6 +4829,15 @@ async fn read_docker_runtime_descriptor( sandbox_id: &str, config: &DockerDriverRuntimeConfig, ) -> Result, Status> { + read_docker_runtime_descriptor_with_format(sandbox_id, config) + .await + .map(|descriptor| descriptor.map(|(descriptor, _)| descriptor)) +} + +async fn read_docker_runtime_descriptor_with_format( + sandbox_id: &str, + config: &DockerDriverRuntimeConfig, +) -> Result, Status> { let path = docker_boundary_state_dir_by_id(sandbox_id, config)?.join(RUNTIME_DESCRIPTOR_FILE); let bytes = match tokio::fs::read(&path).await { Ok(bytes) => bytes, @@ -4839,12 +4849,14 @@ async fn read_docker_runtime_descriptor( ))); } }; - serde_json::from_slice(&bytes).map(Some).map_err(|error| { - Status::internal(format!( - "decode Docker runtime descriptor {}: {error}", - path.display() - )) - }) + isolation::decode_runtime_descriptor_compatible(&bytes) + .map(Some) + .map_err(|error| { + Status::internal(format!( + "decode Docker runtime descriptor {}: {error}", + path.display() + )) + }) } async fn stage_docker_supervisor_bundle( diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index 4493192b2e..e46735105c 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -1164,6 +1164,7 @@ impl PodmanComputeDriver { ), child_env, &launch_authentication, + openshell_sandbox_backend::boundary_protocol::FenceWireFormat::OuterFence, )?; self.client .copy_to_container( @@ -1472,6 +1473,13 @@ impl PodmanComputeDriver { } let container_id = container.id; info!(sandbox_id = %sandbox_id, container = %container_id, "Starting sandbox container"); + let archive = self + .client + .copy_from_container(&container_id, crate::isolation::BOOTSTRAP_PATH) + .await?; + let boundary_config = + extract_first_tar_entry(&archive).map_err(ComputeDriverError::Precondition)?; + let fence_wire_format = crate::isolation::fence_wire_format_from_slice(&boundary_config)?; // Fence delayed stop/die events from the previous container run before // issuing the start. Podman's event stream can deliver those events @@ -1507,6 +1515,7 @@ impl PodmanComputeDriver { crate::isolation::userns_preserves_host_groups(self.config.userns.as_deref()), restart_metadata.child_env, &launch_authentication, + fence_wire_format, )?; self.client .copy_to_container( @@ -2142,6 +2151,7 @@ mod tests { "lifecycle-start", vec![ StubResponse::new(StatusCode::OK, r#"[{"Id":"ctr-1","State":"stopped"}]"#), + bootstrap_archive_response(), StubResponse::new( StatusCode::OK, r#"{"Id":"ctr-1","Name":"sandbox","State":{"Status":"exited","Running":false,"FinishedAt":"2026-08-12T16:39:13Z"},"Config":{}}"#, @@ -2182,12 +2192,23 @@ mod tests { start_requests .lock() .expect("request log lock should not be poisoned")[1], - format!("GET {}", api_path("/libpod/containers/ctr-1/json")) + format!( + "GET {}", + api_path( + "/libpod/containers/ctr-1/archive?path=%2F.openshell%2Fchannel%2Fsandbox%2Fbootstrap.json" + ) + ) ); assert_eq!( start_requests .lock() .expect("request log lock should not be poisoned")[2], + format!("GET {}", api_path("/libpod/containers/ctr-1/json")) + ); + assert_eq!( + start_requests + .lock() + .expect("request log lock should not be poisoned")[3], format!( "POST {}", api_path("/libpod/containers/openshell-supervisor-sandbox-1/stop?timeout=10") @@ -2492,6 +2513,7 @@ mod tests { "trace-start", vec![ StubResponse::new(StatusCode::OK, r#"[{"Id":"ctr-1","State":"stopped"}]"#), + bootstrap_archive_response(), StubResponse::new( StatusCode::OK, r#"{"Id":"ctr-1","Name":"sandbox","State":{"Status":"exited","Running":false,"FinishedAt":"2026-08-12T16:39:13Z"},"Config":{}}"#, @@ -3383,6 +3405,19 @@ mod tests { ] } + fn bootstrap_archive_response() -> StubResponse { + let mut archive = tar::Builder::new(Vec::new()); + let bootstrap = br#"{"outer_fence":{}}"#; + let mut header = tar::Header::new_gnu(); + header.set_size(bootstrap.len() as u64); + header.set_mode(0o600); + header.set_cksum(); + archive + .append_data(&mut header, "bootstrap.json", bootstrap.as_slice()) + .unwrap(); + StubResponse::new(StatusCode::OK, archive.into_inner().unwrap()) + } + fn fence_response() -> StubResponse { #[derive(serde::Serialize)] #[serde(rename_all = "PascalCase")] diff --git a/crates/openshell-driver-podman/src/isolation.rs b/crates/openshell-driver-podman/src/isolation.rs index 421ae32757..b6104e8ac3 100644 --- a/crates/openshell-driver-podman/src/isolation.rs +++ b/crates/openshell-driver-podman/src/isolation.rs @@ -15,9 +15,9 @@ use openshell_isolation_interface::contract::{ }; use openshell_sandbox_backend::ALLOW_EXTRA_SUPPLEMENTARY_GROUPS_RESOURCE_CLAIM; use openshell_sandbox_backend::boundary_protocol::{ - BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, - SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, - generate_sandbox_tls_material, + BoundaryConfig, BoundaryListener, FenceWireFormat, GatewayVerificationKey, + SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, + fence_wire_format, generate_sandbox_tls_material, }; use serde::{Deserialize, Serialize}; @@ -30,6 +30,16 @@ pub const AUTH_BUNDLE_PATH: &str = "/.openshell/supervisor/auth.json"; pub const RESTART_METADATA_PATH: &str = "/.openshell/supervisor/restart-metadata.json"; const SOCKET_PATH: &str = "/.openshell/channel/sandbox/control.sock"; +#[derive(Serialize)] +#[serde(tag = "backend", rename_all = "kebab-case")] +enum LegacyDriverFenceEvidence { + Podman { + container_id: String, + network_mode: String, + unexpected_networks: Vec, + }, +} + #[derive(Serialize)] struct PodmanOuterFenceEvidence<'a> { container_id: &'a str, @@ -64,6 +74,40 @@ impl PodmanOuterFenceEvidence<'_> { } } +pub fn fence_wire_format_from_slice(encoded: &[u8]) -> Result { + let value: serde_json::Value = serde_json::from_slice(encoded).map_err(invalid)?; + let object = value + .as_object() + .ok_or_else(|| invalid("bootstrap payload must be a JSON object"))?; + fence_wire_format(object, "Podman bootstrap payload").map_err(invalid) +} + +fn encode_fence_compatible( + value: &T, + format: FenceWireFormat, + container_id: &str, +) -> Result, ComputeDriverError> { + let mut value = serde_json::to_value(value).map_err(invalid)?; + if format == FenceWireFormat::LegacyDriverFence { + let object = value + .as_object_mut() + .ok_or_else(|| invalid("bootstrap payload must be a JSON object"))?; + if object.remove("outer_fence").is_none() { + return Err(invalid("bootstrap outer fence projection is missing")); + } + object.insert( + "driver_fence".to_string(), + serde_json::to_value(LegacyDriverFenceEvidence::Podman { + container_id: container_id.to_string(), + network_mode: "none".to_string(), + unexpected_networks: Vec::new(), + }) + .map_err(invalid)?, + ); + } + serde_json::to_vec(&value).map_err(invalid) +} + pub fn supervisor_name(id: &str) -> String { format!("openshell-supervisor-{id}") } @@ -205,6 +249,7 @@ pub fn bootstrap_archives( allow_extra_supplementary_groups: bool, child_env: HashMap, launch_authentication: &openshell_core::jwt::SandboxLaunchAuthentication, + fence_wire_format: FenceWireFormat, ) -> Result { launch_authentication.validate().map_err(invalid)?; let session_id = launch_authentication.supervisor.session_id; @@ -290,7 +335,7 @@ pub fn bootstrap_archives( channel.directory("sandbox", 0o711, true)?; channel.file( "sandbox/bootstrap.json", - &serde_json::to_vec(&config).map_err(invalid)?, + &encode_fence_compatible(&config, fence_wire_format, container_id)?, )?; channel.file("sandbox/server.crt", tls.certificate_chain_pem.as_bytes())?; channel.file("sandbox/server.key", tls.private_key_pem.as_bytes())?; @@ -302,7 +347,7 @@ pub fn bootstrap_archives( supervisor.directory(".openshell/supervisor", 0o700, true)?; supervisor.file( RUNTIME_DESCRIPTOR_PATH, - &serde_json::to_vec(&runtime_descriptor).map_err(invalid)?, + &encode_fence_compatible(&runtime_descriptor, fence_wire_format, container_id)?, )?; supervisor.file( AUTH_BUNDLE_PATH, @@ -513,6 +558,7 @@ mod tests { false, child_env.clone(), &authentication, + FenceWireFormat::OuterFence, ) .unwrap(); let workload = files(&archives.channel); @@ -589,4 +635,49 @@ mod tests { assert!(!userns_preserves_host_groups(Some("private"))); assert!(!userns_preserves_host_groups(None)); } + + #[test] + fn legacy_archives_preserve_driver_fence_wire_format() { + let identity = ResolvedWorkloadIdentity::new( + 1000, + 1001, + vec![], + "image".into(), + "sha256:image".into(), + ) + .unwrap(); + let archives = bootstrap_archives( + "sandbox", + "container", + "generation-1", + &identity, + false, + HashMap::new(), + &authentication(), + FenceWireFormat::LegacyDriverFence, + ) + .unwrap(); + let workload = files(&archives.channel); + let supervisor = files(&archives.supervisor); + for encoded in [ + workload + .get(&PathBuf::from("sandbox/bootstrap.json")) + .unwrap(), + supervisor + .get(&PathBuf::from( + RUNTIME_DESCRIPTOR_PATH.trim_start_matches('/'), + )) + .unwrap(), + ] { + assert_eq!( + fence_wire_format_from_slice(encoded).unwrap(), + FenceWireFormat::LegacyDriverFence + ); + let value: serde_json::Value = serde_json::from_slice(encoded).unwrap(); + assert!(value.get("outer_fence").is_none()); + assert_eq!(value["driver_fence"]["backend"], "podman"); + assert_eq!(value["driver_fence"]["container_id"], "container"); + assert_eq!(value["driver_fence"]["network_mode"], "none"); + } + } } diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index f6ba71fed7..842ba00ed4 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -486,6 +486,7 @@ fn yaml_mcp_method( method.to_string() } +#[allow(deprecated)] fn to_proto(raw: PolicyFile) -> Result { for (policy_name, rule) in &raw.network_policies { for (endpoint_index, endpoint) in rule.endpoints.iter().enumerate() { @@ -532,6 +533,9 @@ fn to_proto(raw: PolicyFile) -> Result { }; NetworkEndpoint { host: e.host, + legacy_tls: String::new(), + legacy_enforcement: String::new(), + legacy_access: String::new(), path: e.path, port: normalized_ports.first().copied().unwrap_or(0), ports: normalized_ports, diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 01991d712e..54750686cc 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -1541,9 +1541,13 @@ fn discovery_to_proto(discovery: &DiscoveryProfile) -> ProviderProfileDiscovery } } +#[allow(deprecated)] fn endpoint_to_proto(endpoint: &EndpointProfile) -> NetworkEndpoint { NetworkEndpoint { host: endpoint.host.clone(), + legacy_tls: String::new(), + legacy_enforcement: String::new(), + legacy_access: String::new(), port: endpoint.port, protocol: endpoint.protocol.clone(), tls: network_tls_mode_from_str(&endpoint.tls).map_or(-1, |value| value as i32), diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index ba82e39726..e29eddeb0e 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -43,6 +43,32 @@ pub const STREAM_STDIN_CLOSED: u8 = 4; pub const STREAM_NETWORK_DECISION: u8 = 5; pub const MAX_STREAM_FRAME_BYTES: usize = 64 * 1024; +/// Fence field used by a persisted sandbox bootstrap document. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FenceWireFormat { + /// Backend-native evidence consumed by runtimes released before `outer_fence`. + LegacyDriverFence, + /// Backend-neutral guarantees consumed by current runtimes. + OuterFence, +} + +/// Require a bootstrap document to contain exactly one supported fence field. +pub fn fence_wire_format( + object: &serde_json::Map, + description: &str, +) -> Result { + match ( + object.contains_key("driver_fence"), + object.contains_key("outer_fence"), + ) { + (true, false) => Ok(FenceWireFormat::LegacyDriverFence), + (false, true) => Ok(FenceWireFormat::OuterFence), + _ => Err(BackendError::Descriptor(format!( + "decode {description}: expected exactly one of driver_fence or outer_fence" + ))), + } +} + /// Capability masks measured from `/proc//status` by the `OpenShell` /// co-located runtime. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -1384,6 +1410,28 @@ pub enum FrameError { mod tests { use super::*; + #[test] + fn fence_wire_format_requires_exactly_one_supported_field() { + let legacy = serde_json::json!({"driver_fence": {}}); + assert_eq!( + fence_wire_format(legacy.as_object().unwrap(), "test bootstrap").unwrap(), + FenceWireFormat::LegacyDriverFence + ); + + let current = serde_json::json!({"outer_fence": {}}); + assert_eq!( + fence_wire_format(current.as_object().unwrap(), "test bootstrap").unwrap(), + FenceWireFormat::OuterFence + ); + + for invalid in [ + serde_json::json!({}), + serde_json::json!({"driver_fence": {}, "outer_fence": {}}), + ] { + assert!(fence_wire_format(invalid.as_object().unwrap(), "test bootstrap").is_err()); + } + } + fn complete_audit_evidence() -> NativeLinuxSandboxAuditEvidence { NativeLinuxSandboxAuditEvidence { capabilities: CapabilityEvidence { diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 4d7c52853d..ee4102b176 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -2597,6 +2597,10 @@ async fn handle_get_sandbox_config_inner( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let req = request.into_inner(); + let legacy_supervisor = matches!( + &principal, + Principal::Sandbox(sandbox) if req.workspace_scope.is_none() && req.name == sandbox.sandbox_id + ); let workspace = match &principal { Principal::Sandbox(_) if req.workspace_scope.is_none() => "", _ => crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?, @@ -2609,7 +2613,35 @@ async fn handle_get_sandbox_config_inner( MinWorkspaceRole::User, ) .await?; - Ok(Response::new(load_sandbox_config(state, &sandbox).await?)) + let mut config = load_sandbox_config(state, &sandbox).await?; + if legacy_supervisor && let Some(policy) = config.policy.as_mut() { + populate_legacy_endpoint_modes(policy)?; + } + Ok(Response::new(config)) +} + +#[allow(deprecated)] +fn populate_legacy_endpoint_modes(policy: &mut ProtoSandboxPolicy) -> Result<(), Status> { + for rule in policy.network_policies.values_mut() { + for endpoint in &mut rule.endpoints { + endpoint.legacy_tls = openshell_policy::network_tls_mode_to_str(endpoint.tls) + .ok_or_else(|| Status::internal("effective policy contains an unknown TLS mode"))? + .to_string(); + endpoint.legacy_enforcement = + openshell_policy::network_enforcement_mode_to_str(endpoint.enforcement) + .ok_or_else(|| { + Status::internal("effective policy contains an unknown enforcement mode") + })? + .to_string(); + endpoint.legacy_access = + openshell_policy::network_access_preset_to_str(endpoint.access) + .ok_or_else(|| { + Status::internal("effective policy contains an unknown access preset") + })? + .to_string(); + } + } + Ok(()) } /// Resolve the same effective configuration for authenticated RPCs and trusted @@ -7523,6 +7555,45 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use tonic::Code; + #[derive(Clone, PartialEq, prost::Message)] + struct LegacyNetworkEndpoint { + #[prost(string, tag = "4")] + tls: String, + #[prost(string, tag = "5")] + enforcement: String, + #[prost(string, tag = "6")] + access: String, + } + + #[test] + fn legacy_endpoint_modes_remain_decodable_by_pre4_supervisors() { + use openshell_core::proto::{NetworkAccessPreset, NetworkEnforcementMode, NetworkTlsMode}; + + let mut policy = ProtoSandboxPolicy { + network_policies: HashMap::from([( + "api".to_string(), + NetworkPolicyRule { + endpoints: vec![NetworkEndpoint { + tls: NetworkTlsMode::Skip.into(), + enforcement: NetworkEnforcementMode::Enforce.into(), + access: NetworkAccessPreset::ReadWrite.into(), + ..Default::default() + }], + ..Default::default() + }, + )]), + ..Default::default() + }; + + populate_legacy_endpoint_modes(&mut policy).unwrap(); + let encoded = policy.network_policies["api"].endpoints[0].encode_to_vec(); + let legacy = LegacyNetworkEndpoint::decode(encoded.as_slice()).unwrap(); + + assert_eq!(legacy.tls, "skip"); + assert_eq!(legacy.enforcement, "enforce"); + assert_eq!(legacy.access, "read-write"); + } + /// Wrap a request with a user `Principal` so handler scope guards treat /// the test caller as a CLI user. Most handler tests exercise /// user-facing behavior and should not trip sandbox equality checks. diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 1428b766db..d85786edba 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -202,7 +202,11 @@ pub(super) async fn resolve_and_authorize_sandbox_name( .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .filter(|sandbox| { sandbox.metadata.as_ref().is_some_and(|metadata| { - metadata.name == sandbox_name + // Supervisors released before sandbox names became the + // canonical RPC reference send their authenticated sandbox + // UUID in the same protobuf field. The principal is already + // bound to that UUID, so accepting it cannot widen scope. + (metadata.name == sandbox_name || sandbox_principal.sandbox_id == sandbox_name) && (workspace.is_empty() || workspace == sandbox.object_workspace()) }) }); @@ -3832,6 +3836,7 @@ async fn run_exec_with_russh( #[cfg(test)] mod tests { use super::*; + use crate::auth::principal::{Principal, SandboxIdentitySource, SandboxPrincipal}; use crate::compute::NoopTestDriver; use crate::grpc::test_support::{ authed_request, test_server_state, test_server_state_with_compute_driver, @@ -4294,6 +4299,33 @@ mod tests { sandbox } + #[tokio::test] + async fn sandbox_principal_accepts_legacy_id_reference_for_its_own_sandbox() { + let state = test_server_state().await; + let sandbox = test_sandbox("legacy-reference", Vec::new()); + let sandbox_id = sandbox.object_id().to_string(); + state.store.put_message(&sandbox).await.unwrap(); + let principal = Principal::Sandbox(SandboxPrincipal { + sandbox_id: sandbox_id.clone(), + source: SandboxIdentitySource::BootstrapJwt { + issuer: "openshell-gateway:test".to_string(), + }, + trust_domain: Some("openshell".to_string()), + }); + + let resolved = resolve_and_authorize_sandbox_name( + &state, + &principal, + &sandbox_id, + "", + MinWorkspaceRole::User, + ) + .await + .unwrap(); + + assert_eq!(resolved.object_id(), sandbox_id); + } + fn test_workload_template(name: &str) -> SandboxWorkloadTemplate { SandboxWorkloadTemplate { metadata: Some(ObjectMeta { diff --git a/crates/openshell-server/src/persistence/legacy_time_wire.rs b/crates/openshell-server/src/persistence/legacy_time_wire.rs index d5f12bab1b..e4c6471815 100644 --- a/crates/openshell-server/src/persistence/legacy_time_wire.rs +++ b/crates/openshell-server/src/persistence/legacy_time_wire.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Compatibility rewriting for protobuf records written before time fields used WKTs. +//! Compatibility rewriting for protobuf records written by earlier releases. use prost::Message; use prost_reflect::{DescriptorPool, Kind, MessageDescriptor}; @@ -24,6 +24,14 @@ enum Conversion { DurationSeconds { new_tag: u32 }, DurationString { new_tag: u32 }, TimestampMap { new_tag: u32 }, + StringEnum { tag: u32, kind: LegacyEnum }, +} + +#[derive(Clone, Copy)] +enum LegacyEnum { + TlsMode, + EnforcementMode, + AccessPreset, } pub(super) fn migrate(object_type: &str, payload: &[u8]) -> PersistenceResult> { @@ -78,7 +86,6 @@ fn rewrite_message(descriptor: &MessageDescriptor, input: &[u8]) -> PersistenceR )?; } else if wire_type == 2 && let Some(field) = descriptor.get_field(field_number) - && !field.is_map() && let Kind::Message(child) = field.kind() { let rewritten = rewrite_message(&child, &input[payload_start..payload_end])?; @@ -163,10 +170,92 @@ fn rewrite_legacy_field( write_embedded(output, new_tag, &rewritten); } } + Conversion::StringEnum { tag, kind } => { + rewrite_string_enum(output, tag, kind, wire_type, payload)?; + } } Ok(()) } +fn rewrite_string_enum( + output: &mut Vec, + tag: u32, + kind: LegacyEnum, + wire_type: u8, + payload: &[u8], +) -> PersistenceResult<()> { + let value = match wire_type { + // Current records already encode these fields as enum varints. Rewrite + // them canonically so the migration remains idempotent. + 0 => { + let (value, consumed) = read_varint(payload)?; + if consumed != payload.len() { + return Err(PersistenceError::Decode( + "invalid network endpoint enum value".into(), + )); + } + value + } + // v0.1.0-pre.4 and earlier encoded the same field numbers as strings. + 2 => { + let value = std::str::from_utf8(payload).map_err(|error| { + PersistenceError::Decode(format!( + "legacy network endpoint mode is not UTF-8: {error}" + )) + })?; + legacy_enum_value(kind, value).ok_or_else(|| { + PersistenceError::Decode(format!( + "unsupported legacy network endpoint {} value '{value}'", + legacy_enum_name(kind) + )) + })? + } + _ => { + return Err(PersistenceError::Decode(format!( + "network endpoint {} field has wire type {wire_type}, expected varint or string", + legacy_enum_name(kind) + ))); + } + }; + + write_key(output, tag, 0); + write_varint(output, value); + Ok(()) +} + +fn legacy_enum_value(kind: LegacyEnum, value: &str) -> Option { + match kind { + LegacyEnum::TlsMode => match value { + "" => Some(0), + "skip" => Some(1), + "terminate" => Some(2), + "passthrough" => Some(3), + _ => None, + }, + LegacyEnum::EnforcementMode => match value { + "" => Some(0), + "enforce" => Some(1), + "audit" => Some(2), + _ => None, + }, + LegacyEnum::AccessPreset => match value { + "" => Some(0), + "read-only" => Some(1), + "read-write" => Some(2), + "full" => Some(3), + _ => None, + }, + } +} + +fn legacy_enum_name(kind: LegacyEnum) -> &'static str { + match kind { + LegacyEnum::TlsMode => "tls", + LegacyEnum::EnforcementMode => "enforcement", + LegacyEnum::AccessPreset => "access", + } +} + fn parse_legacy_duration(value: &str) -> PersistenceResult { let (number, millis_multiplier) = value .strip_suffix("ms") @@ -219,9 +308,10 @@ fn rewrite_timestamp_map_entry(input: &[u8]) -> PersistenceResult fn conversion(message: &str, field: u32) -> Option { use Conversion::{ - DurationSeconds as D, DurationString as DS, Timestamp as T, TimestampMap as M, - TimestampString as TS, + DurationSeconds as D, DurationString as DS, StringEnum as E, Timestamp as T, + TimestampMap as M, TimestampString as TS, }; + use LegacyEnum::{AccessPreset as Access, EnforcementMode as Enforcement, TlsMode as Tls}; match (message, field) { ("openshell.datamodel.v1.ObjectMeta", 3) => Some(T { new_tag: 103 }), ("openshell.datamodel.v1.ObjectMeta", 8) => Some(T { new_tag: 108 }), @@ -242,6 +332,15 @@ fn conversion(message: &str, field: u32) -> Option { Some(D { new_tag: 116 }) } ("openshell.sandbox.v1.MiddlewareBinding", 4) => Some(DS { new_tag: 104 }), + ("openshell.sandbox.v1.NetworkEndpoint", 4) => Some(E { tag: 27, kind: Tls }), + ("openshell.sandbox.v1.NetworkEndpoint", 5) => Some(E { + tag: 28, + kind: Enforcement, + }), + ("openshell.sandbox.v1.NetworkEndpoint", 6) => Some(E { + tag: 29, + kind: Access, + }), _ => None, } } @@ -324,7 +423,7 @@ fn require_wire_type(actual: u8, expected: u8) -> PersistenceResult<()> { Ok(()) } else { Err(PersistenceError::Decode(format!( - "legacy time field has wire type {actual}, expected {expected}" + "legacy field has wire type {actual}, expected {expected}" ))) } } @@ -336,7 +435,8 @@ mod tests { StoredProviderCredentialRefreshState, StoredProviderCredentialRefreshStateV2, }; use openshell_core::proto::{ - EndpointStatus, Provider, SandboxCondition, SandboxWorkloadTemplate, SshSession, + EndpointStatus, NetworkAccessPreset, NetworkEnforcementMode, NetworkTlsMode, Provider, + Sandbox, SandboxCondition, SandboxWorkloadTemplate, SshSession, }; use std::collections::HashMap; @@ -412,6 +512,94 @@ mod tests { metadata: Option, } + #[derive(Clone, PartialEq, Message)] + struct LegacySandbox { + #[prost(message, optional, tag = "2")] + spec: Option, + } + + #[derive(Clone, PartialEq, Message)] + struct LegacySandboxSpec { + #[prost(message, optional, tag = "7")] + policy: Option, + } + + #[derive(Clone, PartialEq, Message)] + struct LegacySandboxPolicy { + #[prost(map = "string, message", tag = "5")] + network_policies: HashMap, + } + + #[derive(Clone, PartialEq, Message)] + struct LegacyNetworkPolicyRule { + #[prost(message, repeated, tag = "2")] + endpoints: Vec, + } + + #[derive(Clone, PartialEq, Message)] + struct LegacyNetworkEndpoint { + #[prost(string, tag = "1")] + host: String, + #[prost(string, tag = "4")] + tls: String, + #[prost(string, tag = "5")] + enforcement: String, + #[prost(string, tag = "6")] + access: String, + } + + fn legacy_sandbox_with_endpoint(endpoint: LegacyNetworkEndpoint) -> LegacySandbox { + LegacySandbox { + spec: Some(LegacySandboxSpec { + policy: Some(LegacySandboxPolicy { + network_policies: HashMap::from([( + "api".into(), + LegacyNetworkPolicyRule { + endpoints: vec![endpoint], + }, + )]), + }), + }), + } + } + + #[test] + #[allow(deprecated)] + fn migrates_pre4_network_endpoint_strings_to_enums() { + let legacy = legacy_sandbox_with_endpoint(LegacyNetworkEndpoint { + host: "api.example.com".into(), + tls: "terminate".into(), + enforcement: "enforce".into(), + access: "read-write".into(), + }); + + let migrated = migrate("sandbox", &legacy.encode_to_vec()).unwrap(); + let sandbox = Sandbox::decode(migrated.as_slice()).unwrap(); + let endpoint = &sandbox.spec.unwrap().policy.unwrap().network_policies["api"].endpoints[0]; + + assert_eq!(endpoint.tls, NetworkTlsMode::Terminate as i32); + assert_eq!(endpoint.enforcement, NetworkEnforcementMode::Enforce as i32); + assert_eq!(endpoint.access, NetworkAccessPreset::ReadWrite as i32); + assert_eq!(migrate("sandbox", &migrated).unwrap(), migrated); + } + + #[test] + fn rejects_unknown_pre4_network_endpoint_strings() { + let legacy = legacy_sandbox_with_endpoint(LegacyNetworkEndpoint { + host: "api.example.com".into(), + tls: String::new(), + enforcement: "observe".into(), + access: String::new(), + }); + + let error = migrate("sandbox", &legacy.encode_to_vec()).unwrap_err(); + assert!( + error + .to_string() + .contains("unsupported legacy network endpoint enforcement value 'observe'") + ); + } + #[test] fn migrates_nested_metadata_and_timestamp_maps() { let legacy = LegacyProvider { diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index 196b7be1cd..8ef70d639b 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -125,7 +125,7 @@ mod tests { const PUBLIC_RPC_SCHEMA_SHA256: &str = "e9533b91b8ead17666c43b17ca5bca3bf84050346e4855e5e5a261cbb56a26bb"; const DURABLE_SCHEMA_SHA256: &str = - "9eeaa29dfba187bff69fb7bc4f9a13a0f1d7be3f7049a38c8f0e20ce77ec7d8b"; + "0430e14e8cc4ac5c41d7c6cdf41a5aafa7cb724b7d9026b593ddb9194ae58379"; const PUBLIC_DURABLE_OVERLAP_SHA256: &str = "a6e97fdde30c439ffaa03c2952a43033f8ea338fed6b1456ebe2d7d8af14e834"; // A persisted Sandbox without endpoint status retains its lifecycle fields; diff --git a/crates/openshell-supervisor-network/src/policy_local.rs b/crates/openshell-supervisor-network/src/policy_local.rs index 6a6c0f9d26..368a96f276 100644 --- a/crates/openshell-supervisor-network/src/policy_local.rs +++ b/crates/openshell-supervisor-network/src/policy_local.rs @@ -1120,6 +1120,7 @@ fn network_rule_from_json( }) } +#[allow(deprecated)] fn network_endpoint_from_json( endpoint: NetworkEndpointJson, ) -> std::result::Result { @@ -1192,6 +1193,9 @@ fn network_endpoint_from_json( Ok(NetworkEndpoint { host: endpoint.host, + legacy_tls: String::new(), + legacy_enforcement: String::new(), + legacy_access: String::new(), port, protocol: endpoint.protocol, tls: openshell_policy::network_tls_mode_from_str(&endpoint.tls) diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 1d837b7f0e..18cb605a85 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -148,13 +148,11 @@ message NetworkEndpoint { // Endpoint protocol. "tcp" and "" select L4-only handling; "rest", // "websocket", "graphql", "sql", "json-rpc", and "mcp" select L7 inspection. string protocol = 3; - // TLS handling. Unspecified enables automatic detection and termination. - NetworkTlsMode tls = 4; - // Enforcement mode. Unspecified preserves the audit default. - NetworkEnforcementMode enforcement = 5; - // Access preset shorthand. Unspecified means no preset. - // Mutually exclusive with rules. - NetworkAccessPreset access = 6; + // Deprecated string representation retained for supervisors released before + // endpoint security modes became typed enums. + string legacy_tls = 4 [deprecated = true]; + string legacy_enforcement = 5 [deprecated = true]; + string legacy_access = 6 [deprecated = true]; // Explicit L7 rules (mutually exclusive with access). repeated L7Rule rules = 7; // Allowed resolved IP addresses or CIDR ranges for this endpoint. @@ -230,6 +228,13 @@ message NetworkEndpoint { // Internal gateway-derived marker indicating that this endpoint belongs to // an attached credentialed provider. User-authored values are ignored. bool provider_credentialed = 26; + // TLS handling. Unspecified enables automatic detection and termination. + NetworkTlsMode tls = 27; + // Enforcement mode. Unspecified preserves the audit default. + NetworkEnforcementMode enforcement = 28; + // Access preset shorthand. Unspecified means no preset. + // Mutually exclusive with rules. + NetworkAccessPreset access = 29; } // MCP options are grouped so MCP-specific policy can grow without adding more diff --git a/sdk/go/proto/sandboxv1/sandbox.pb.go b/sdk/go/proto/sandboxv1/sandbox.pb.go index c38db28c2f..b1abc7d24f 100644 --- a/sdk/go/proto/sandboxv1/sandbox.pb.go +++ b/sdk/go/proto/sandboxv1/sandbox.pb.go @@ -828,13 +828,15 @@ type NetworkEndpoint struct { // Endpoint protocol. "tcp" and "" select L4-only handling; "rest", // "websocket", "graphql", "sql", "json-rpc", and "mcp" select L7 inspection. Protocol string `protobuf:"bytes,3,opt,name=protocol,proto3" json:"protocol,omitempty"` - // TLS handling. Unspecified enables automatic detection and termination. - Tls NetworkTlsMode `protobuf:"varint,4,opt,name=tls,proto3,enum=openshell.sandbox.v1.NetworkTlsMode" json:"tls,omitempty"` - // Enforcement mode. Unspecified preserves the audit default. - Enforcement NetworkEnforcementMode `protobuf:"varint,5,opt,name=enforcement,proto3,enum=openshell.sandbox.v1.NetworkEnforcementMode" json:"enforcement,omitempty"` - // Access preset shorthand. Unspecified means no preset. - // Mutually exclusive with rules. - Access NetworkAccessPreset `protobuf:"varint,6,opt,name=access,proto3,enum=openshell.sandbox.v1.NetworkAccessPreset" json:"access,omitempty"` + // Deprecated string representation retained for supervisors released before + // endpoint security modes became typed enums. + // + // Deprecated: Marked as deprecated in sandbox.proto. + LegacyTls string `protobuf:"bytes,4,opt,name=legacy_tls,json=legacyTls,proto3" json:"legacy_tls,omitempty"` + // Deprecated: Marked as deprecated in sandbox.proto. + LegacyEnforcement string `protobuf:"bytes,5,opt,name=legacy_enforcement,json=legacyEnforcement,proto3" json:"legacy_enforcement,omitempty"` + // Deprecated: Marked as deprecated in sandbox.proto. + LegacyAccess string `protobuf:"bytes,6,opt,name=legacy_access,json=legacyAccess,proto3" json:"legacy_access,omitempty"` // Explicit L7 rules (mutually exclusive with access). Rules []*L7Rule `protobuf:"bytes,7,rep,name=rules,proto3" json:"rules,omitempty"` // Allowed resolved IP addresses or CIDR ranges for this endpoint. @@ -911,8 +913,15 @@ type NetworkEndpoint struct { // Internal gateway-derived marker indicating that this endpoint belongs to // an attached credentialed provider. User-authored values are ignored. ProviderCredentialed bool `protobuf:"varint,26,opt,name=provider_credentialed,json=providerCredentialed,proto3" json:"provider_credentialed,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // TLS handling. Unspecified enables automatic detection and termination. + Tls NetworkTlsMode `protobuf:"varint,27,opt,name=tls,proto3,enum=openshell.sandbox.v1.NetworkTlsMode" json:"tls,omitempty"` + // Enforcement mode. Unspecified preserves the audit default. + Enforcement NetworkEnforcementMode `protobuf:"varint,28,opt,name=enforcement,proto3,enum=openshell.sandbox.v1.NetworkEnforcementMode" json:"enforcement,omitempty"` + // Access preset shorthand. Unspecified means no preset. + // Mutually exclusive with rules. + Access NetworkAccessPreset `protobuf:"varint,29,opt,name=access,proto3,enum=openshell.sandbox.v1.NetworkAccessPreset" json:"access,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *NetworkEndpoint) Reset() { @@ -966,25 +975,28 @@ func (x *NetworkEndpoint) GetProtocol() string { return "" } -func (x *NetworkEndpoint) GetTls() NetworkTlsMode { +// Deprecated: Marked as deprecated in sandbox.proto. +func (x *NetworkEndpoint) GetLegacyTls() string { if x != nil { - return x.Tls + return x.LegacyTls } - return NetworkTlsMode_NETWORK_TLS_MODE_UNSPECIFIED + return "" } -func (x *NetworkEndpoint) GetEnforcement() NetworkEnforcementMode { +// Deprecated: Marked as deprecated in sandbox.proto. +func (x *NetworkEndpoint) GetLegacyEnforcement() string { if x != nil { - return x.Enforcement + return x.LegacyEnforcement } - return NetworkEnforcementMode_NETWORK_ENFORCEMENT_MODE_UNSPECIFIED + return "" } -func (x *NetworkEndpoint) GetAccess() NetworkAccessPreset { +// Deprecated: Marked as deprecated in sandbox.proto. +func (x *NetworkEndpoint) GetLegacyAccess() string { if x != nil { - return x.Access + return x.LegacyAccess } - return NetworkAccessPreset_NETWORK_ACCESS_PRESET_UNSPECIFIED + return "" } func (x *NetworkEndpoint) GetRules() []*L7Rule { @@ -1127,6 +1139,27 @@ func (x *NetworkEndpoint) GetProviderCredentialed() bool { return false } +func (x *NetworkEndpoint) GetTls() NetworkTlsMode { + if x != nil { + return x.Tls + } + return NetworkTlsMode_NETWORK_TLS_MODE_UNSPECIFIED +} + +func (x *NetworkEndpoint) GetEnforcement() NetworkEnforcementMode { + if x != nil { + return x.Enforcement + } + return NetworkEnforcementMode_NETWORK_ENFORCEMENT_MODE_UNSPECIFIED +} + +func (x *NetworkEndpoint) GetAccess() NetworkAccessPreset { + if x != nil { + return x.Access + } + return NetworkAccessPreset_NETWORK_ACCESS_PRESET_UNSPECIFIED +} + // MCP options are grouped so MCP-specific policy can grow without adding more // top-level NetworkEndpoint fields. OpenShell owns the supported revision // profiles instead of treating dependency enums as the policy contract. @@ -2319,14 +2352,15 @@ const file_sandbox_proto_rawDesc = "" + "\ainclude\x18\x01 \x03(\tR\ainclude\x12\x18\n" + "\aexclude\x18\x02 \x03(\tR\aexclude\"6\n" + "\x18NetworkCredentialBinding\x12\x1a\n" + - "\bprovider\x18\x01 \x01(\tR\bprovider\"\xdb\v\n" + + "\bprovider\x18\x01 \x01(\tR\bprovider\"\xda\f\n" + "\x0fNetworkEndpoint\x12\x12\n" + "\x04host\x18\x01 \x01(\tR\x04host\x12\x12\n" + "\x04port\x18\x02 \x01(\rR\x04port\x12\x1a\n" + - "\bprotocol\x18\x03 \x01(\tR\bprotocol\x126\n" + - "\x03tls\x18\x04 \x01(\x0e2$.openshell.sandbox.v1.NetworkTlsModeR\x03tls\x12N\n" + - "\venforcement\x18\x05 \x01(\x0e2,.openshell.sandbox.v1.NetworkEnforcementModeR\venforcement\x12A\n" + - "\x06access\x18\x06 \x01(\x0e2).openshell.sandbox.v1.NetworkAccessPresetR\x06access\x122\n" + + "\bprotocol\x18\x03 \x01(\tR\bprotocol\x12!\n" + + "\n" + + "legacy_tls\x18\x04 \x01(\tB\x02\x18\x01R\tlegacyTls\x121\n" + + "\x12legacy_enforcement\x18\x05 \x01(\tB\x02\x18\x01R\x11legacyEnforcement\x12'\n" + + "\rlegacy_access\x18\x06 \x01(\tB\x02\x18\x01R\flegacyAccess\x122\n" + "\x05rules\x18\a \x03(\v2\x1c.openshell.sandbox.v1.L7RuleR\x05rules\x12\x1f\n" + "\vallowed_ips\x18\b \x03(\tR\n" + "allowedIps\x12\x14\n" + @@ -2349,7 +2383,10 @@ const file_sandbox_proto_rawDesc = "" + "\x03mcp\x18\x17 \x01(\v2 .openshell.sandbox.v1.McpOptionsR\x03mcp\x12]\n" + "\x12credential_binding\x18\x18 \x01(\v2..openshell.sandbox.v1.NetworkCredentialBindingR\x11credentialBinding\x12B\n" + "\x1dallow_uninspected_credentials\x18\x19 \x01(\bR\x1ballowUninspectedCredentials\x123\n" + - "\x15provider_credentialed\x18\x1a \x01(\bR\x14providerCredentialed\x1ar\n" + + "\x15provider_credentialed\x18\x1a \x01(\bR\x14providerCredentialed\x126\n" + + "\x03tls\x18\x1b \x01(\x0e2$.openshell.sandbox.v1.NetworkTlsModeR\x03tls\x12N\n" + + "\venforcement\x18\x1c \x01(\x0e2,.openshell.sandbox.v1.NetworkEnforcementModeR\venforcement\x12A\n" + + "\x06access\x18\x1d \x01(\x0e2).openshell.sandbox.v1.NetworkAccessPresetR\x06access\x1ar\n" + "\x1cGraphqlPersistedQueriesEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12<\n" + "\x05value\x18\x02 \x01(\v2&.openshell.sandbox.v1.GraphqlOperationR\x05value:\x028\x01\"\xd2\x01\n" + @@ -2544,14 +2581,14 @@ var file_sandbox_proto_depIdxs = []int32{ 20, // 6: openshell.sandbox.v1.NetworkPolicyRule.binaries:type_name -> openshell.sandbox.v1.NetworkBinary 37, // 7: openshell.sandbox.v1.NetworkMiddlewareConfig.config:type_name -> google.protobuf.Struct 11, // 8: openshell.sandbox.v1.NetworkMiddlewareConfig.endpoints:type_name -> openshell.sandbox.v1.MiddlewareEndpointSelector - 0, // 9: openshell.sandbox.v1.NetworkEndpoint.tls:type_name -> openshell.sandbox.v1.NetworkTlsMode - 1, // 10: openshell.sandbox.v1.NetworkEndpoint.enforcement:type_name -> openshell.sandbox.v1.NetworkEnforcementMode - 2, // 11: openshell.sandbox.v1.NetworkEndpoint.access:type_name -> openshell.sandbox.v1.NetworkAccessPreset - 17, // 12: openshell.sandbox.v1.NetworkEndpoint.rules:type_name -> openshell.sandbox.v1.L7Rule - 16, // 13: openshell.sandbox.v1.NetworkEndpoint.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule - 30, // 14: openshell.sandbox.v1.NetworkEndpoint.graphql_persisted_queries:type_name -> openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry - 14, // 15: openshell.sandbox.v1.NetworkEndpoint.mcp:type_name -> openshell.sandbox.v1.McpOptions - 12, // 16: openshell.sandbox.v1.NetworkEndpoint.credential_binding:type_name -> openshell.sandbox.v1.NetworkCredentialBinding + 17, // 9: openshell.sandbox.v1.NetworkEndpoint.rules:type_name -> openshell.sandbox.v1.L7Rule + 16, // 10: openshell.sandbox.v1.NetworkEndpoint.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule + 30, // 11: openshell.sandbox.v1.NetworkEndpoint.graphql_persisted_queries:type_name -> openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry + 14, // 12: openshell.sandbox.v1.NetworkEndpoint.mcp:type_name -> openshell.sandbox.v1.McpOptions + 12, // 13: openshell.sandbox.v1.NetworkEndpoint.credential_binding:type_name -> openshell.sandbox.v1.NetworkCredentialBinding + 0, // 14: openshell.sandbox.v1.NetworkEndpoint.tls:type_name -> openshell.sandbox.v1.NetworkTlsMode + 1, // 15: openshell.sandbox.v1.NetworkEndpoint.enforcement:type_name -> openshell.sandbox.v1.NetworkEnforcementMode + 2, // 16: openshell.sandbox.v1.NetworkEndpoint.access:type_name -> openshell.sandbox.v1.NetworkAccessPreset 31, // 17: openshell.sandbox.v1.L7DenyRule.query:type_name -> openshell.sandbox.v1.L7DenyRule.QueryEntry 32, // 18: openshell.sandbox.v1.L7DenyRule.params:type_name -> openshell.sandbox.v1.L7DenyRule.ParamsEntry 18, // 19: openshell.sandbox.v1.L7Rule.allow:type_name -> openshell.sandbox.v1.L7Allow diff --git a/tests/ansible/playbooks/openshell-deb-upgrade-source.yaml b/tests/ansible/playbooks/openshell-deb-upgrade-source.yaml new file mode 100644 index 0000000000..bd33ae5667 --- /dev/null +++ b/tests/ansible/playbooks/openshell-deb-upgrade-source.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +- name: Install prerelease OpenShell Debian upgrade source + ansible.builtin.import_playbook: openshell-deb.yaml + +- name: Record OpenShell Debian upgrade source + hosts: all + gather_facts: false + tasks: + - name: Copy upgrade source version metadata + become: true + ansible.builtin.copy: + src: "{{ openshell_upgrade_source_version }}" + dest: /var/lib/openshell-qualification/upgrade-source-version + owner: root + group: root + mode: "0644" diff --git a/tests/ansible/playbooks/openshell-rpm-upgrade-source.yaml b/tests/ansible/playbooks/openshell-rpm-upgrade-source.yaml new file mode 100644 index 0000000000..2171e23231 --- /dev/null +++ b/tests/ansible/playbooks/openshell-rpm-upgrade-source.yaml @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +- name: Install prerelease OpenShell RPM upgrade source + ansible.builtin.import_playbook: openshell-rpm.yaml + +- name: Record OpenShell RPM upgrade source + hosts: all + gather_facts: false + tasks: + - name: Record installed source package versions + become: true + ansible.builtin.shell: | + set -euo pipefail + rpm --query --queryformat '%{EPOCHNUM}:%{VERSION}-%{RELEASE}.%{ARCH}\n' openshell openshell-gateway \ + > /var/lib/openshell-qualification/upgrade-source-versions + args: + executable: /bin/bash + changed_when: true diff --git a/tests/ansible/playbooks/openshell-rpm.yaml b/tests/ansible/playbooks/openshell-rpm.yaml new file mode 100644 index 0000000000..c0fda8e1ad --- /dev/null +++ b/tests/ansible/playbooks/openshell-rpm.yaml @@ -0,0 +1,123 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +- name: Install OpenShell RPM packages + hosts: all + gather_facts: false + vars: + user_systemd_environment: + HOME: /home/tmachine + XDG_RUNTIME_DIR: /run/user/1000 + DBUS_SESSION_BUS_ADDRESS: unix:path=/run/user/1000/bus + tasks: + - name: Wait for SSH + ansible.builtin.wait_for_connection: + + - name: Copy OpenShell RPM packages + become: true + ansible.builtin.copy: + src: "{{ item.src }}" + dest: "/var/tmp/{{ item.name }}.rpm" + mode: "0644" + loop: + - { name: openshell, src: "{{ openshell_rpm }}" } + - { name: openshell-gateway, src: "{{ openshell_gateway_rpm }}" } + + - name: Install OpenShell RPM packages + become: true + ansible.builtin.dnf: + name: + - /var/tmp/openshell.rpm + - /var/tmp/openshell-gateway.rpm + state: present + disable_gpg_check: true + allow_downgrade: true + + - name: Copy OpenShell runtime images + become: true + ansible.builtin.copy: + src: "{{ item.src }}" + dest: "/var/tmp/{{ item.name }}.tar" + owner: tmachine + group: tmachine + mode: "0600" + loop: + - { name: openshell-sandbox, src: "{{ openshell_sandbox_image }}" } + - { name: openshell-supervisor, src: "{{ openshell_supervisor_image }}" } + + - name: Load OpenShell runtime images + become: true + become_user: tmachine + ansible.builtin.command: + argv: [podman, load, --input, "/var/tmp/{{ item }}.tar"] + loop: [openshell-sandbox, openshell-supervisor] + environment: "{{ user_systemd_environment }}" + + - name: Create OpenShell qualification configuration directory + become: true + ansible.builtin.file: + path: /var/lib/openshell-qualification + state: directory + owner: root + group: root + mode: "0755" + + - name: Configure OpenShell RPM runtime images for qualification + become: true + ansible.builtin.copy: + dest: /var/lib/openshell-qualification/gateway.toml + owner: root + group: root + mode: "0644" + content: | + [openshell] + version = 2 + + [openshell.gateway] + compute_driver = "podman" + + [openshell.drivers.podman] + socket_path = "/run/user/1000/podman/podman.sock" + sandbox_runtime_image = "docker.io/openshell/sandbox:tmachine" + supervisor_image = "docker.io/openshell/supervisor:tmachine" + health_check_interval_secs = 10 + + - name: Create OpenShell environment directory + ansible.builtin.file: + path: /home/tmachine/.config/openshell + state: directory + mode: "0700" + + - name: Select qualification gateway configuration + ansible.builtin.copy: + dest: /home/tmachine/.config/openshell/gateway.env + mode: "0600" + content: | + OPENSHELL_GATEWAY_CONFIG=/var/lib/openshell-qualification/gateway.toml + + - name: Start tmachine user manager + ansible.builtin.include_role: + name: tmachine_user_manager + + - name: Start packaged OpenShell gateway service + ansible.builtin.systemd_service: + name: openshell-gateway.service + scope: user + daemon_reload: true + enabled: true + state: started + environment: "{{ user_systemd_environment }}" + + - name: Wait for OpenShell gateway + ansible.builtin.wait_for: + host: 127.0.0.1 + port: 17670 + timeout: 60 + + - name: Register packaged OpenShell gateway + ansible.builtin.include_role: + name: openshell_client + vars: + openshell_client_gateway_endpoint: https://127.0.0.1:17670 + openshell_client_gateway_name: openshell diff --git a/tests/ansible/playbooks/upgrade/deb.yaml b/tests/ansible/playbooks/upgrade/deb.yaml new file mode 100644 index 0000000000..6a193b87da --- /dev/null +++ b/tests/ansible/playbooks/upgrade/deb.yaml @@ -0,0 +1,203 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +- name: Upgrade OpenShell Debian package while preserving state + hosts: all + gather_facts: false + vars: + upgrade_sandbox: upgrade-existing + post_upgrade_sandbox: upgrade-new + user_systemd_environment: + XDG_RUNTIME_DIR: /run/user/1000 + DBUS_SESSION_BUS_ADDRESS: unix:path=/run/user/1000/bus + tasks: + - name: Wait for SSH + ansible.builtin.wait_for_connection: + + - name: Exercise prerelease baseline and upgrade candidate + block: + - name: Read expected source package version + become: true + ansible.builtin.slurp: + src: /var/lib/openshell-qualification/upgrade-source-version + register: upgrade_source_version_file + + - name: Record expected source package version + ansible.builtin.set_fact: + upgrade_source_version: "{{ upgrade_source_version_file.content | b64decode | trim }}" + + - name: Read installed source package version + ansible.builtin.command: + argv: [dpkg-query, --show, "--showformat=${Version}", openshell] + register: installed_source_version + changed_when: false + + - name: Require exact prerelease source package + ansible.builtin.assert: + that: installed_source_version.stdout == upgrade_source_version + fail_msg: >- + Expected prerelease source {{ upgrade_source_version }}, found + {{ installed_source_version.stdout }}. + + - name: Check source gateway status + ansible.builtin.command: + argv: [openshell, status] + changed_when: false + + - name: Remove stale upgrade sandboxes + ansible.builtin.command: + argv: [openshell, sandbox, delete, "{{ item }}"] + loop: ["{{ upgrade_sandbox }}", "{{ post_upgrade_sandbox }}"] + failed_when: false + changed_when: false + + - name: Create source sandbox + ansible.builtin.command: + argv: [openshell, sandbox, create, --name, "{{ upgrade_sandbox }}", --detach] + + - name: Write marker in source sandbox + ansible.builtin.command: + argv: + - openshell + - sandbox + - exec + - --name + - "{{ upgrade_sandbox }}" + - --no-tty + - -- + - sh + - -c + - "printf 'survived-upgrade\\n' > /tmp/openshell-upgrade-marker" + + - name: Copy candidate Debian package + become: true + ansible.builtin.copy: + src: "{{ openshell_deb }}" + dest: /var/tmp/openshell-upgrade-target.deb + mode: "0644" + + - name: Copy candidate runtime images + become: true + ansible.builtin.copy: + src: "{{ item.src }}" + dest: "/var/tmp/{{ item.name }}.tar" + mode: "0644" + loop: + - { name: openshell-sandbox-upgrade-target, src: "{{ openshell_sandbox_image }}" } + - { name: openshell-supervisor-upgrade-target, src: "{{ openshell_supervisor_image }}" } + + - name: Load candidate runtime images + become: true + ansible.builtin.command: + argv: [docker, load, --input, "/var/tmp/{{ item }}.tar"] + loop: [openshell-sandbox-upgrade-target, openshell-supervisor-upgrade-target] + + - name: Install candidate Debian package with APT + become: true + ansible.builtin.apt: + deb: /var/tmp/openshell-upgrade-target.deb + allow_downgrade: true + + - name: Restart packaged OpenShell gateway service + ansible.builtin.systemd_service: + name: openshell-gateway.service + scope: user + daemon_reload: true + state: restarted + environment: "{{ user_systemd_environment }}" + + - name: Wait for upgraded OpenShell gateway + ansible.builtin.wait_for: + host: 127.0.0.1 + port: 17670 + timeout: 60 + + - name: Read installed target package version + ansible.builtin.command: + argv: [dpkg-query, --show, "--showformat=${Version}", openshell] + register: installed_target_version + changed_when: false + + - name: Require package version to change + ansible.builtin.assert: + that: installed_target_version.stdout != upgrade_source_version + fail_msg: >- + Candidate package did not replace prerelease source + {{ upgrade_source_version }}. + + - name: Wait for upgraded gateway status + ansible.builtin.command: + argv: [openshell, status] + register: upgraded_gateway_status + retries: 30 + delay: 1 + until: upgraded_gateway_status.rc == 0 + changed_when: false + + - name: Read marker from source sandbox after upgrade + ansible.builtin.command: + argv: + - openshell + - sandbox + - exec + - --name + - "{{ upgrade_sandbox }}" + - --no-tty + - -- + - cat + - /tmp/openshell-upgrade-marker + register: upgrade_marker + changed_when: false + + - name: Require source sandbox marker to survive + ansible.builtin.assert: + that: upgrade_marker.stdout | trim == 'survived-upgrade' + + - name: Create sandbox with upgraded gateway + ansible.builtin.command: + argv: [openshell, sandbox, create, --name, "{{ post_upgrade_sandbox }}", --detach] + + - name: Execute in sandbox created after upgrade + ansible.builtin.command: + argv: [openshell, sandbox, exec, --name, "{{ post_upgrade_sandbox }}", --no-tty, --, "true"] + + rescue: + - name: Collect Debian upgrade diagnostics + ansible.builtin.shell: | + set +e + dpkg-query --show --showformat='${Package} ${Version}\n' openshell + openshell --version + openshell status + openshell sandbox list --all-workspaces + systemctl --user status openshell-gateway.service --no-pager + journalctl --user -u openshell-gateway.service --no-pager -n 200 + sudo docker ps -a + for container in $(sudo docker ps -aq); do + echo "=== docker logs: ${container} ===" + sudo docker logs "${container}" 2>&1 + done + sudo docker images + sudo cat /var/lib/openshell-qualification/gateway.toml + args: + executable: /bin/bash + environment: "{{ user_systemd_environment }}" + register: upgrade_diagnostics + changed_when: false + failed_when: false + + - name: Show Debian upgrade diagnostics + ansible.builtin.debug: + var: upgrade_diagnostics.stdout_lines + + - name: Fail Debian upgrade test + ansible.builtin.fail: + msg: Ubuntu Debian package upgrade test failed. + + always: + - name: Delete upgrade test sandboxes + ansible.builtin.command: + argv: [openshell, sandbox, delete, "{{ item }}"] + loop: ["{{ post_upgrade_sandbox }}", "{{ upgrade_sandbox }}"] + failed_when: false + changed_when: false diff --git a/tests/ansible/playbooks/upgrade/rpm.yaml b/tests/ansible/playbooks/upgrade/rpm.yaml new file mode 100644 index 0000000000..35aca377e2 --- /dev/null +++ b/tests/ansible/playbooks/upgrade/rpm.yaml @@ -0,0 +1,246 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +- name: Upgrade OpenShell RPM packages while preserving state + hosts: all + gather_facts: false + vars: + upgrade_sandbox: upgrade-existing + post_upgrade_sandbox: upgrade-new + user_systemd_environment: + HOME: /home/tmachine + XDG_RUNTIME_DIR: /run/user/1000 + DBUS_SESSION_BUS_ADDRESS: unix:path=/run/user/1000/bus + tasks: + - name: Wait for SSH + ansible.builtin.wait_for_connection: + + - name: Exercise prerelease baseline and RPM upgrade candidate + block: + - name: Read expected source package versions + become: true + ansible.builtin.slurp: + src: /var/lib/openshell-qualification/upgrade-source-versions + register: upgrade_source_versions_file + + - name: Record expected source package versions + ansible.builtin.set_fact: + upgrade_source_versions: "{{ upgrade_source_versions_file.content | b64decode | trim }}" + + - name: Read installed source package versions + ansible.builtin.command: + argv: + - rpm + - --query + - --queryformat + - "%{EPOCHNUM}:%{VERSION}-%{RELEASE}.%{ARCH}\n" + - openshell + - openshell-gateway + register: installed_source_versions + changed_when: false + + - name: Require exact prerelease source packages + ansible.builtin.assert: + that: installed_source_versions.stdout | trim == upgrade_source_versions + fail_msg: >- + Expected prerelease source packages {{ upgrade_source_versions }}, + found {{ installed_source_versions.stdout | trim }}. + + - name: Check source gateway status + ansible.builtin.command: + argv: [openshell, status] + changed_when: false + + - name: Remove stale upgrade sandboxes + ansible.builtin.command: + argv: [openshell, sandbox, delete, "{{ item }}"] + loop: ["{{ upgrade_sandbox }}", "{{ post_upgrade_sandbox }}"] + failed_when: false + changed_when: false + + - name: Create source sandbox + ansible.builtin.command: + argv: [openshell, sandbox, create, --name, "{{ upgrade_sandbox }}", --detach] + + - name: Write marker in source sandbox + ansible.builtin.command: + argv: + - openshell + - sandbox + - exec + - --name + - "{{ upgrade_sandbox }}" + - --no-tty + - -- + - sh + - -c + - "printf 'survived-upgrade\\n' > /tmp/openshell-upgrade-marker" + + - name: Copy candidate RPM packages + become: true + ansible.builtin.copy: + src: "{{ item.src }}" + dest: "/var/tmp/{{ item.name }}.rpm" + mode: "0644" + loop: + - { name: openshell-upgrade-target, src: "{{ openshell_rpm }}" } + - { name: openshell-gateway-upgrade-target, src: "{{ openshell_gateway_rpm }}" } + + - name: Read candidate RPM package versions + ansible.builtin.command: + argv: + - rpm + - --query + - --package + - --queryformat + - "%{EPOCHNUM}:%{VERSION}-%{RELEASE}.%{ARCH}\n" + - /var/tmp/openshell-upgrade-target.rpm + - /var/tmp/openshell-gateway-upgrade-target.rpm + register: candidate_package_versions + changed_when: false + + - name: Require candidate RPM packages to replace source packages + ansible.builtin.assert: + that: candidate_package_versions.stdout | trim != upgrade_source_versions + fail_msg: Candidate RPM versions match the retained prerelease source. + + - name: Copy candidate runtime images + become: true + ansible.builtin.copy: + src: "{{ item.src }}" + dest: "/var/tmp/{{ item.name }}.tar" + owner: tmachine + group: tmachine + mode: "0600" + loop: + - { name: openshell-sandbox-upgrade-target, src: "{{ openshell_sandbox_image }}" } + - { name: openshell-supervisor-upgrade-target, src: "{{ openshell_supervisor_image }}" } + + - name: Load candidate runtime images + become: true + become_user: tmachine + ansible.builtin.command: + argv: [podman, load, --input, "/var/tmp/{{ item }}.tar"] + loop: [openshell-sandbox-upgrade-target, openshell-supervisor-upgrade-target] + environment: "{{ user_systemd_environment }}" + + - name: Install candidate RPM packages with DNF + become: true + ansible.builtin.dnf: + name: + - /var/tmp/openshell-upgrade-target.rpm + - /var/tmp/openshell-gateway-upgrade-target.rpm + state: present + disable_gpg_check: true + allow_downgrade: true + + - name: Restart packaged OpenShell gateway service + ansible.builtin.systemd_service: + name: openshell-gateway.service + scope: user + daemon_reload: true + state: restarted + environment: "{{ user_systemd_environment }}" + + - name: Wait for upgraded OpenShell gateway + ansible.builtin.wait_for: + host: 127.0.0.1 + port: 17670 + timeout: 60 + + - name: Read installed target package versions + ansible.builtin.command: + argv: + - rpm + - --query + - --queryformat + - "%{EPOCHNUM}:%{VERSION}-%{RELEASE}.%{ARCH}\n" + - openshell + - openshell-gateway + register: installed_target_versions + changed_when: false + + - name: Require exact candidate RPM packages + ansible.builtin.assert: + that: installed_target_versions.stdout | trim == candidate_package_versions.stdout | trim + fail_msg: >- + Candidate packages {{ candidate_package_versions.stdout | trim }} + did not replace source packages; installed + {{ installed_target_versions.stdout | trim }}. + + - name: Wait for upgraded gateway status + ansible.builtin.command: + argv: [openshell, status] + register: upgraded_gateway_status + retries: 30 + delay: 1 + until: upgraded_gateway_status.rc == 0 + changed_when: false + + - name: Read marker from source sandbox after upgrade + ansible.builtin.command: + argv: + - openshell + - sandbox + - exec + - --name + - "{{ upgrade_sandbox }}" + - --no-tty + - -- + - cat + - /tmp/openshell-upgrade-marker + register: upgrade_marker + changed_when: false + + - name: Require source sandbox marker to survive + ansible.builtin.assert: + that: upgrade_marker.stdout | trim == 'survived-upgrade' + + - name: Create sandbox with upgraded gateway + ansible.builtin.command: + argv: [openshell, sandbox, create, --name, "{{ post_upgrade_sandbox }}", --detach] + + - name: Execute in sandbox created after upgrade + ansible.builtin.command: + argv: [openshell, sandbox, exec, --name, "{{ post_upgrade_sandbox }}", --no-tty, --, "true"] + + rescue: + - name: Collect RPM upgrade diagnostics + ansible.builtin.shell: | + set +e + rpm --query --queryformat '%{NAME} %{EPOCHNUM}:%{VERSION}-%{RELEASE}.%{ARCH}\n' openshell openshell-gateway + openshell --version + openshell status + openshell sandbox list --all-workspaces + systemctl --user status openshell-gateway.service --no-pager + journalctl --user -u openshell-gateway.service --no-pager -n 200 + podman ps -a + for container in $(podman ps -aq); do + echo "=== podman logs: ${container} ===" + podman logs "${container}" 2>&1 + done + podman images + sudo cat /var/lib/openshell-qualification/gateway.toml + args: + executable: /bin/bash + environment: "{{ user_systemd_environment }}" + register: upgrade_diagnostics + changed_when: false + failed_when: false + + - name: Show RPM upgrade diagnostics + ansible.builtin.debug: + var: upgrade_diagnostics.stdout_lines + + - name: Fail RPM upgrade test + ansible.builtin.fail: + msg: Fedora RPM package upgrade test failed. + + always: + - name: Delete upgrade test sandboxes + ansible.builtin.command: + argv: [openshell, sandbox, delete, "{{ item }}"] + loop: ["{{ post_upgrade_sandbox }}", "{{ upgrade_sandbox }}"] + failed_when: false + changed_when: false diff --git a/tests/config.nix b/tests/config.nix index 4b5d5b7d96..1885c8a17e 100644 --- a/tests/config.nix +++ b/tests/config.nix @@ -102,6 +102,28 @@ let openshell_sandbox_image = "../artifacts/images/openshell-sandbox-tmachine.tar"; }; } + { + name = "deb-upgrade-source"; + use_galaxy = false; + playbooks = [ "ansible/playbooks/openshell-deb-upgrade-source.yaml" ]; + inputs = { + openshell_deb = "../artifacts/upgrade/deb/source/openshell.deb"; + openshell_upgrade_source_version = "../artifacts/upgrade/deb/source/version"; + openshell_supervisor_image = "../artifacts/upgrade/source-images/openshell-supervisor-tmachine.tar"; + openshell_sandbox_image = "../artifacts/upgrade/source-images/openshell-sandbox-tmachine.tar"; + }; + } + { + name = "rpm-upgrade-source"; + use_galaxy = false; + playbooks = [ "ansible/playbooks/openshell-rpm-upgrade-source.yaml" ]; + inputs = { + openshell_rpm = "../artifacts/upgrade/rpm/source/openshell.rpm"; + openshell_gateway_rpm = "../artifacts/upgrade/rpm/source/openshell-gateway.rpm"; + openshell_supervisor_image = "../artifacts/upgrade/source-images/openshell-supervisor-tmachine.tar"; + openshell_sandbox_image = "../artifacts/upgrade/source-images/openshell-sandbox-tmachine.tar"; + }; + } ]; testsuites = [ @@ -148,6 +170,25 @@ let openshell_podman_userns_private_config = "suites/drivers/podman/fixtures/userns-private.toml"; }; } + { + name = "deb-upgrade"; + playbooks = [ "ansible/playbooks/upgrade/deb.yaml" ]; + inputs = { + openshell_deb = "../artifacts/packages/openshell.deb"; + openshell_supervisor_image = "../artifacts/images/openshell-supervisor-tmachine.tar"; + openshell_sandbox_image = "../artifacts/images/openshell-sandbox-tmachine.tar"; + }; + } + { + name = "rpm-upgrade"; + playbooks = [ "ansible/playbooks/upgrade/rpm.yaml" ]; + inputs = { + openshell_rpm = "../artifacts/packages/rpm/openshell.rpm"; + openshell_gateway_rpm = "../artifacts/packages/rpm/openshell-gateway.rpm"; + openshell_supervisor_image = "../artifacts/images/openshell-supervisor-tmachine.tar"; + openshell_sandbox_image = "../artifacts/images/openshell-sandbox-tmachine.tar"; + }; + } ]; };