diff --git a/.github/workflows/build-snap.yml b/.github/workflows/build-snap.yml new file mode 100644 index 0000000000..f51cf79601 --- /dev/null +++ b/.github/workflows/build-snap.yml @@ -0,0 +1,178 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Build Snap + +on: + workflow_call: + inputs: + checkout-ref: + required: true + type: string + arch: + description: "Snap package architecture" + required: true + type: string + runner: + description: "GitHub Actions runner label" + required: true + type: string + cli-artifact-name: + required: true + type: string + gateway-artifact-name: + required: true + type: string + supervisor-artifact-name: + required: true + type: string + upload-channel: + required: false + type: string + default: "" + description: "Snap Store channel to upload to; leave empty to skip upload" + + secrets: + publish-credentials: + required: false + description: "Snap Store credentials (SNAPCRAFT_STORE_CREDENTIALS)" +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + build-snap: + name: Build Snap (Linux ${{ inputs.arch }}) + runs-on: ${{ inputs.runner }} + timeout-minutes: 60 + environment: ${{ inputs.upload-channel != '' && inputs.upload-channel || '' }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.checkout-ref }} + fetch-depth: 0 + + - name: Install snapd + run: | + set -euo pipefail + if ! command -v snapd >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y snapd + fi + sudo systemctl enable --now snapd.socket + sudo systemctl start snapd + sudo snap wait system seed.loaded + + - name: Install LXD + run: | + set -euo pipefail + sudo snap install lxd + sudo usermod -aG lxd "$USER" + sudo lxd waitready + sudo lxd init --auto + sudo iptables -P FORWARD ACCEPT + sudo chgrp lxd /var/snap/lxd/common/lxd/unix.socket + sudo chmod 660 /var/snap/lxd/common/lxd/unix.socket + + - name: Install snapcraft + run: sudo snap install snapcraft --classic + + - name: Download prebuilt CLI binary + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.cli-artifact-name }} + path: prebuilt/cli + + - name: Download prebuilt gateway binary + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.gateway-artifact-name }} + path: prebuilt/gateway + + - name: Download prebuilt sandbox binary + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.supervisor-artifact-name }} + path: prebuilt/sandbox + + - name: Prepare snap build directory + run: | + set -euo pipefail + find_binary() { + find "$1" -type f -name "$2" -print -quit + } + cli="$(find_binary prebuilt/cli openshell)" + gateway="$(find_binary prebuilt/gateway openshell-gateway)" + sandbox="$(find_binary prebuilt/sandbox openshell-sandbox)" + test -n "$cli" && test -n "$gateway" && test -n "$sandbox" + + mkdir -p snap/prebuilt/meta/gui + install -m 0755 "$cli" snap/prebuilt/openshell + install -m 0755 "$gateway" snap/prebuilt/openshell-gateway + install -m 0755 "$sandbox" snap/prebuilt/openshell-sandbox + install -m 0755 tasks/scripts/snap-gateway-wrapper.sh snap/prebuilt/openshell-gateway-wrapper + cp LICENSE README.md snap/prebuilt/ + cp snap/local/term.desktop snap/prebuilt/meta/gui/term.desktop + cp snap/local/icon.png snap/prebuilt/meta/gui/icon.png + python3 tasks/scripts/release.py get-version --snap > snap/prebuilt/version + + - name: Build snap + run: | + set -euo pipefail + runtime_dir="/run/user/$(id -u)" + sudo install -d -m 0700 -o "$(id -u)" -g "$(id -g)" "$runtime_dir" + export XDG_RUNTIME_DIR="$runtime_dir" + sg lxd -c "XDG_RUNTIME_DIR=${runtime_dir} snapcraft pack -v" + + - name: Upload snapcraft logs on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: snapcraft-logs-${{ inputs.arch }} + path: "~/.local/state/snapcraft/log/snapcraft-*.log" + retention-days: 7 + + - name: Capture Snap filename + id: capture + run: | + set -euo pipefail + snap_file=$(find . -maxdepth 1 -type f -name '*.snap' -printf '%f\n' | head -1) + if [ -z "$snap_file" ]; then + echo "::error::No .snap file found after snapcraft pack" + exit 1 + fi + echo "snap-file=$snap_file" >> "$GITHUB_OUTPUT" + echo "Built Snap: $snap_file" + + - name: Upload snap artifact (${{ inputs.arch }}) + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: snap-linux-${{ inputs.arch }} + path: | + ${{ steps.capture.outputs.snap-file }} + *.comp + retention-days: 5 + + - name: Upload Snap to Snap Store + if: inputs.upload-channel != '' + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.publish-credentials }} + INPUTS_UPLOAD_CHANNEL: ${{ inputs.upload-channel }} + run: | + set -euo pipefail + snap_file="${{ steps.capture.outputs.snap-file }}" + snap_name="${snap_file%.snap}" + snap_name="${snap_name%%_*}" + + component_args=() + shopt -s nullglob + for component in "${snap_name}"+*.comp; do + echo "Adding component: $component" + component_args+=(--component "$component") + done + + echo "Uploading $snap_file to ${INPUTS_UPLOAD_CHANNEL}" + snapcraft upload --release "${INPUTS_UPLOAD_CHANNEL}" "$snap_file" "${component_args[@]}" diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 775eb9f83e..3721cb80e2 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -4,7 +4,16 @@ name: Conformance on: - workflow_dispatch: {} + workflow_dispatch: + inputs: + target_arch: + description: "Architecture for package artifacts" + required: false + default: amd64 + type: choice + options: + - amd64 + - arm64 permissions: {} @@ -26,6 +35,55 @@ jobs: - id: gate uses: ./.github/actions/pr-gate + target: + name: Resolve target architecture + needs: pr_metadata + if: needs.pr_metadata.outputs.should_run == 'true' + runs-on: ubuntu-latest + outputs: + arch: ${{ steps.target.outputs.arch }} + build_runner: ${{ steps.target.outputs.build_runner }} + test_runner: ${{ steps.target.outputs.test_runner }} + rpm_arch: ${{ steps.target.outputs.rpm_arch }} + musl_triple: ${{ steps.target.outputs.musl_triple }} + gnu_triple: ${{ steps.target.outputs.gnu_triple }} + musl_dev_shell: ${{ steps.target.outputs.musl_dev_shell }} + gnu_dev_shell: ${{ steps.target.outputs.gnu_dev_shell }} + gateway_interpreter: ${{ steps.target.outputs.gateway_interpreter }} + steps: + - id: target + shell: bash + run: | + set -euo pipefail + case "${{ inputs.target_arch }}" in + amd64) + cat >> "$GITHUB_OUTPUT" <<'EOF' + arch=amd64 + build_runner=linux-amd64-cpu8 + test_runner=ubuntu-24.04 + rpm_arch=x86_64 + musl_triple=x86_64-unknown-linux-musl + gnu_triple=x86_64-unknown-linux-gnu + musl_dev_shell=.#devShells.x86_64-linux.musl + gnu_dev_shell=.#devShells.x86_64-linux.glibc-2-28 + gateway_interpreter=/lib64/ld-linux-x86-64.so.2 + EOF + ;; + arm64) + cat >> "$GITHUB_OUTPUT" <<'EOF' + arch=arm64 + build_runner=linux-arm64-cpu8 + test_runner=ubuntu-24.04-arm + rpm_arch=aarch64 + musl_triple=aarch64-unknown-linux-musl + gnu_triple=aarch64-unknown-linux-gnu + musl_dev_shell=.#devShells.aarch64-linux.musl + gnu_dev_shell=.#devShells.aarch64-linux.glibc-2-28 + gateway_interpreter=/lib/ld-linux-aarch64.so.1 + EOF + ;; + esac + version: needs: pr_metadata if: needs.pr_metadata.outputs.should_run == 'true' @@ -55,71 +113,101 @@ jobs: } >> "$GITHUB_OUTPUT" build-cli: - needs: version + needs: [version, target] permissions: contents: read uses: ./.github/workflows/build-binaries.yml with: package: openshell-cli binary: openshell - triple: x86_64-unknown-linux-musl - runner: linux-amd64-cpu8 - dev-shell: .#devShells.x86_64-linux.musl + triple: ${{ needs.target.outputs.musl_triple }} + runner: ${{ needs.target.outputs.build_runner }} + dev-shell: ${{ needs.target.outputs.musl_dev_shell }} cargo-version: ${{ needs.version.outputs.cargo }} checkout-ref: ${{ github.sha }} secrets: inherit build-conformance: - needs: version + needs: [version, target] permissions: contents: read uses: ./.github/workflows/build-binaries.yml with: package: openshell-conformance-cli binary: openshell-conformance - triple: x86_64-unknown-linux-musl - runner: linux-amd64-cpu8 - dev-shell: .#devShells.x86_64-linux.musl + triple: ${{ needs.target.outputs.musl_triple }} + runner: ${{ needs.target.outputs.build_runner }} + dev-shell: ${{ needs.target.outputs.musl_dev_shell }} cargo-version: ${{ needs.version.outputs.cargo }} checkout-ref: ${{ github.sha }} secrets: inherit build-gateway: - needs: version + needs: [version, target] permissions: contents: read uses: ./.github/workflows/build-binaries.yml with: package: openshell-gateway binary: openshell-gateway - triple: x86_64-unknown-linux-gnu - runner: linux-amd64-cpu8 - dev-shell: .#devShells.x86_64-linux.glibc-2-28 + triple: ${{ needs.target.outputs.gnu_triple }} + runner: ${{ needs.target.outputs.build_runner }} + dev-shell: ${{ needs.target.outputs.gnu_dev_shell }} cargo-version: ${{ needs.version.outputs.cargo }} image-tag: dev - interpreter: /lib64/ld-linux-x86-64.so.2 + interpreter: ${{ needs.target.outputs.gateway_interpreter }} checkout-ref: ${{ github.sha }} secrets: inherit + build-sandbox: + needs: [version, target] + permissions: + contents: read + uses: ./.github/workflows/build-binaries.yml + with: + package: openshell-sandbox + binary: openshell-sandbox + triple: ${{ needs.target.outputs.musl_triple }} + runner: ${{ needs.target.outputs.build_runner }} + dev-shell: ${{ needs.target.outputs.musl_dev_shell }} + cargo-version: ${{ needs.version.outputs.cargo }} + checkout-ref: ${{ github.sha }} + secrets: inherit + + build-snap: + needs: [build-cli, build-gateway, build-sandbox, target] + permissions: + actions: read + contents: read + packages: read + uses: ./.github/workflows/build-snap.yml + with: + checkout-ref: ${{ github.sha }} + arch: ${{ needs.target.outputs.arch }} + runner: ${{ needs.target.outputs.build_runner }} + cli-artifact-name: openshell-${{ needs.target.outputs.musl_triple }} + gateway-artifact-name: openshell-gateway-${{ needs.target.outputs.gnu_triple }} + supervisor-artifact-name: openshell-sandbox-${{ needs.target.outputs.musl_triple }} + build-rpm: - needs: [version, build-cli, build-gateway] + needs: [version, build-cli, build-gateway, target] permissions: contents: read uses: ./.github/workflows/build-rpm.yml with: checkout-ref: ${{ github.sha }} - arch: x86_64 - runner: linux-amd64-cpu8 - cli-target: x86_64-unknown-linux-musl - gateway-target: x86_64-unknown-linux-gnu + arch: ${{ needs.target.outputs.rpm_arch }} + runner: ${{ needs.target.outputs.build_runner }} + cli-target: ${{ needs.target.outputs.musl_triple }} + gateway-target: ${{ needs.target.outputs.gnu_triple }} cargo-version: ${{ needs.version.outputs.cargo }} rpm-version: ${{ needs.version.outputs.rpm_version }} rpm-release: ${{ needs.version.outputs.rpm_release }} fedora: name: Fedora with Rootless Podman - needs: [build-conformance, build-rpm] - runs-on: ubuntu-24.04 + needs: [build-conformance, build-rpm, target] + runs-on: ${{ needs.target.outputs.test_runner }} timeout-minutes: 45 permissions: actions: read @@ -145,13 +233,13 @@ jobs: - name: Download RPM artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: rpm-linux-x86_64 + name: rpm-linux-${{ needs.target.outputs.rpm_arch }} path: rpm-input - name: Download conformance CLI uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: openshell-conformance-x86_64-unknown-linux-musl + name: openshell-conformance-${{ needs.target.outputs.musl_triple }} path: conformance-input - name: Run RPM gateway continuity conformance @@ -181,3 +269,56 @@ jobs: --provision gateway-rootless-podman \ --provision openshell-rpm-gateway-upgrade \ -- /tmp/openshell-conformance run --plan /tmp/conformance-plan.toml + + ubuntu-snap: + name: Ubuntu Snap with Docker Snap (${{ needs.target.outputs.arch }}) + needs: [build-snap, target] + runs-on: ${{ needs.target.outputs.test_runner }} + timeout-minutes: 45 + permissions: + actions: read + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Enable KVM access + run: | + set -euo pipefail + if [[ ! -c /dev/kvm ]]; then + echo "::error::The runner did not expose /dev/kvm" + exit 1 + fi + sudo chmod 0666 /dev/kvm + exec 3<>/dev/kvm + exec 3>&- + + - uses: ./.github/actions/setup-nix + + - name: Download Snap artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: snap-linux-${{ needs.target.outputs.arch }} + path: snap-input + + - name: Install Snap and verify gateway status + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + snaps=(snap-input/*.snap) + if [[ ${#snaps[@]} -ne 1 ]]; then + echo "expected one Snap artifact" >&2 + printf 'Snap artifacts:\n' >&2 + printf ' %s\n' snap-input/* >&2 + exit 1 + fi + + OPENSHELL_TEST_GUEST_CACHE_DISABLE=1 nix run .#test-guest -- \ + --distro ubuntu-24-04 \ + --with snapd \ + --with docker-snap \ + --copy "${snaps[0]}:/var/lib/openshell-conformance/candidate/openshell.snap" \ + --provision openshell-snap \ + -- true diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index 18b5f33a2e..834331605f 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -256,7 +256,6 @@ jobs: with: checkout-ref: ${{ github.sha }} upload-channel: latest/edge - github-environment: latest/edge secrets: publish-credentials: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index b3c45c97fb..6015e5f6ae 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -310,9 +310,7 @@ jobs: uses: ./.github/workflows/snap-package.yml with: checkout-ref: ${{ inputs.tag || github.ref }} - upload-channel: ${{ needs.compute-versions.outputs.is_prerelease == 'true' && 'latest/edge' || 'latest/stable' }} - github-environment: ${{ needs.compute-versions.outputs.is_prerelease == 'true' && 'latest/edge' || 'latest/stable' }} - publish: ${{ needs.compute-versions.outputs.is_prerelease != 'true' }} + upload-channel: ${{ needs.compute-versions.outputs.is_prerelease != 'true' && 'latest/stable' || '' }} secrets: publish-credentials: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} diff --git a/.github/workflows/snap-package.yml b/.github/workflows/snap-package.yml index 7eb78d3a27..7454abed8b 100644 --- a/.github/workflows/snap-package.yml +++ b/.github/workflows/snap-package.yml @@ -10,22 +10,14 @@ on: required: true type: string upload-channel: - required: true + required: false type: string + default: "" description: "Snap Store channel to upload to (e.g., latest/edge, latest/candidate, latest/stable)" - github-environment: - required: true - type: string - description: "GitHub deployment environment for approval gates (e.g., latest/edge, latest/stable)" - publish: - required: false - type: boolean - default: true - description: "Whether to upload the built snap to the Snap Store" secrets: publish-credentials: - required: true + required: false description: "Snap Store credentials (SNAPCRAFT_STORE_CREDENTIALS)" permissions: @@ -47,141 +39,14 @@ jobs: - arch: arm64 rust_arch: aarch64 runner: linux-arm64-cpu8 - runs-on: ${{ matrix.runner }} - timeout-minutes: 60 - environment: ${{ inputs.github-environment }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs.checkout-ref }} - fetch-depth: 0 - - - name: Install snapd - run: | - set -euo pipefail - if ! command -v snapd >/dev/null 2>&1; then - sudo apt-get update - sudo apt-get install -y snapd - fi - sudo systemctl enable --now snapd.socket - sudo systemctl start snapd - sudo snap wait system seed.loaded - - - name: Install LXD - run: | - set -euo pipefail - sudo snap install lxd - sudo usermod -aG lxd "$USER" - sudo lxd waitready - sudo lxd init --auto - sudo iptables -P FORWARD ACCEPT - sudo chgrp lxd /var/snap/lxd/common/lxd/unix.socket - sudo chmod 660 /var/snap/lxd/common/lxd/unix.socket - - - name: Install snapcraft - run: | - set -euo pipefail - sudo snap install snapcraft --classic - - - name: Download prebuilt CLI binary - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: openshell-${{ matrix.rust_arch }}-unknown-linux-musl - path: prebuilt/cli - - - name: Download prebuilt gateway binary - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: openshell-gateway-${{ matrix.rust_arch }}-unknown-linux-gnu - path: prebuilt/gateway - - - name: Download prebuilt sandbox binary - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: openshell-sandbox-${{ matrix.rust_arch }}-unknown-linux-musl - path: prebuilt/sandbox - - - name: Configure prebuilt binaries - run: | - set -euo pipefail - chmod +x prebuilt/cli/openshell - chmod +x prebuilt/gateway/openshell-gateway - chmod +x prebuilt/sandbox/openshell-sandbox - ls -laR prebuilt/ - - - name: Prepare snap build directory - run: | - set -euo pipefail - mkdir -p snap/prebuilt - - cp prebuilt/cli/openshell snap/prebuilt/openshell - cp prebuilt/gateway/openshell-gateway snap/prebuilt/openshell-gateway - cp prebuilt/sandbox/openshell-sandbox snap/prebuilt/openshell-sandbox - - cp tasks/scripts/snap-gateway-wrapper.sh snap/prebuilt/openshell-gateway-wrapper - cp LICENSE snap/prebuilt/ - cp README.md snap/prebuilt/ - - mkdir -p snap/prebuilt/meta/gui - cp snap/local/term.desktop snap/prebuilt/meta/gui/term.desktop - cp snap/local/icon.png snap/prebuilt/meta/gui/icon.png - - python3 tasks/scripts/release.py get-version --snap > snap/prebuilt/version - - - name: Build snap - run: | - set -euo pipefail - runtime_dir="/run/user/$(id -u)" - sudo install -d -m 0700 -o "$(id -u)" -g "$(id -g)" "$runtime_dir" - export XDG_RUNTIME_DIR="$runtime_dir" - sg lxd -c "XDG_RUNTIME_DIR=${runtime_dir} snapcraft pack -v" - - - name: Upload snapcraft logs on failure - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: snapcraft-logs - path: "~/.local/state/snapcraft/log/snapcraft-*.log" - retention-days: 7 - - - name: Capture snap filename - id: capture - run: | - set -euo pipefail - SNAP_FILE=$(ls -1 *.snap 2>/dev/null | head -1) - if [ -z "$SNAP_FILE" ]; then - echo "ERROR: No .snap file found after snapcraft pack" - exit 1 - fi - echo "snap-file=${SNAP_FILE}" >> $GITHUB_OUTPUT - echo "Built snap: ${SNAP_FILE}" - - - name: Upload snap artifact (${{ matrix.arch }}) - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: snap-linux-${{ matrix.arch }} - path: | - ${{ steps.capture.outputs.snap-file }} - *.comp - retention-days: 5 - - - name: Upload snap to Snap Store - if: inputs.publish - env: - SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.publish-credentials }} - INPUTS_UPLOAD_CHANNEL: ${{ inputs.upload-channel }} - run: | - set -euo pipefail - SNAP_FILE="${{ steps.capture.outputs.snap-file }}" - SNAP_NAME="${SNAP_FILE%.snap}" - SNAP_NAME="${SNAP_NAME%%_*}" - - COMPONENT_ARGS=() - shopt -s nullglob - for comp in "${SNAP_NAME}"+*.comp; do - echo "Adding component: $comp" - COMPONENT_ARGS+=(--component "$comp") - done - - echo "Uploading $SNAP_FILE to ${INPUTS_UPLOAD_CHANNEL}" - snapcraft upload --release "${INPUTS_UPLOAD_CHANNEL}" "$SNAP_FILE" "${COMPONENT_ARGS[@]}" + uses: ./.github/workflows/build-snap.yml + with: + checkout-ref: ${{ inputs.checkout-ref }} + arch: ${{ matrix.arch }} + runner: ${{ matrix.runner }} + cli-artifact-name: openshell-${{ matrix.rust_arch }}-unknown-linux-musl + gateway-artifact-name: openshell-gateway-${{ matrix.rust_arch }}-unknown-linux-gnu + supervisor-artifact-name: openshell-sandbox-${{ matrix.rust_arch }}-unknown-linux-musl + upload-channel: ${{ inputs.upload-channel }} + secrets: + publish-credentials: ${{ secrets.publish-credentials }} diff --git a/architecture/build.md b/architecture/build.md index 972f847bb9..b5b12df6dc 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -263,6 +263,12 @@ restart, while the scenario remains responsible for black-box sandbox continuity checks. The plan exposes opaque executable paths and timeouts rather than driver or package-manager configuration; target setup owns those details. +The conformance workflow also builds a branch Snap for an Ubuntu QEMU guest. +The guest installs snapd and Docker Snap, installs the unsigned candidate, +connects its required interfaces, registers the local gateway, and verifies +`openshell status`. Sandbox smoke conformance remains separate from this +package-installation check. + ## Python Wheel Packaging The generated protobuf/gRPC stubs under `python/openshell/_proto/` are gitignored diff --git a/nix/test-guest/README.md b/nix/test-guest/README.md index 1865f9a82f..d16a18de2e 100644 --- a/nix/test-guest/README.md +++ b/nix/test-guest/README.md @@ -70,18 +70,18 @@ The root [`flake.nix`](../../flake.nix) exposes this directory as the `test-gues ## Supported configurations -| Distro | Docker | Rootless Podman | SELinux | Package format | -| --- | --- | --- | --- | --- | -| Ubuntu 24.04 | Yes | No | No | `.deb` | -| Ubuntu 26.04 | Yes | Yes | No | `.deb` | -| CentOS Stream 10 | No | No | Yes | `.rpm` | -| Fedora 44 | No | Yes | Yes | `.rpm` | -| Rocky Linux 9 | Yes | No | Yes | `.rpm` | +| Distro | Docker package | Docker Snap | Rootless Podman | SELinux | Package format | +| --- | --- | --- | --- | --- | --- | +| Ubuntu 24.04 | Yes | Yes | No | No | `.deb` | +| Ubuntu 26.04 | Yes | Yes | Yes | No | `.deb` | +| CentOS Stream 10 | No | No | No | Yes | `.rpm` | +| Fedora 44 | No | No | Yes | Yes | `.rpm` | +| Rocky Linux 9 | Yes | No | No | Yes | `.rpm` | The `snapd` configuration is available for Ubuntu and prepares snapd for -local Snap lifecycle experiments. It does not install Docker, because the Snap -gateway reproduction uses the Docker **Snap** and its `docker:docker-daemon` -interface rather than the host-package Docker configuration. +local Snap lifecycle experiments. Add `docker-snap` after `snapd` to install +the Docker **Snap** and model the `docker:docker-daemon` interface used by the +OpenShell Snap. This is separate from the host-package Docker configuration. `podman-rootless` configures the explicit rootless Podman guest setup used by OpenShell tests. It supports Fedora and Ubuntu 26.04 or later. Ubuntu adds the @@ -331,27 +331,26 @@ nix run .#test-guest -- \ -- openshell --version ``` -## Reproduce Snap gateway startup +## Verify Snap gateway status -The gateway Snap must be native to the guest architecture. Copy an existing -Snap artifact and the reproduction script into a prepared Ubuntu guest, then -run the script as root. It follows the Release Canary ordering exactly: install -the Snap, connect Docker/log/system interfaces, and immediately query the -gateway. On each failure it prints snapd and gateway journals. +The candidate Snap must be native to the guest architecture. The `openshell-snap` +provisioner installs it, connects the Docker/log/system interfaces, registers the +local gateway, and waits for `openshell status`, matching the Release Canary +validation flow. ```shell nix run .#test-guest -- \ --distro ubuntu-24-04 \ --with snapd \ + --with docker-snap \ --keep \ - --copy ./openshell_*.snap:/tmp/openshell.snap \ - --copy ./nix/test-guest/scripts/snap-gateway-repro.sh:/usr/local/bin/snap-gateway-repro \ - -- sudo /usr/local/bin/snap-gateway-repro /tmp/openshell.snap 10 30 + --copy ./openshell_*.snap:/var/lib/openshell-conformance/candidate/openshell.snap \ + --provision openshell-snap \ + -- true ``` `--keep` retains the overlay and serial log when diagnosing a failure. The -runner prints their location after shutdown. The final `30` accepts automatic -recovery for up to 30 seconds; omit it to require the canary's immediate check. +runner prints their location after shutdown. The destination must be an absolute guest path. Use bare octal permission bits @@ -361,7 +360,8 @@ from `000` through `777` for explicit modes. ```text --distro NAME Base distro: ubuntu-24-04, ubuntu-26-04, centos, fedora, or rocky ---with NAME Apply docker, podman-rootless, selinux, or snapd; repeatable +--with NAME Apply docker, docker-snap, podman-rootless, selinux, or snapd; repeatable +--provision NAME Apply a post-artifact system provisioner; repeatable --install PATH Install a .deb or .rpm package; repeatable --copy SRC:DEST[:MODE] Copy a regular file into the guest; use MODE when provided, diff --git a/nix/test-guest/cache.sh b/nix/test-guest/cache.sh index f29a9b3492..29a614e993 100644 --- a/nix/test-guest/cache.sh +++ b/nix/test-guest/cache.sh @@ -13,7 +13,7 @@ Usage: Options: --distro NAME Base distro: ubuntu-24-04, ubuntu-26-04, centos, fedora, or rocky - --with NAME Apply a configuration; repeatable (docker, podman-rootless, selinux, snapd) + --with NAME Apply a configuration; repeatable (docker, docker-snap, podman-rootless, selinux, snapd) --repository REF OCI repository without a tag --digest DIGEST Trusted OCI manifest digest required for pulls --cache-dir PATH Override the local prepared-disk cache directory @@ -380,6 +380,7 @@ build_local() { for configuration in "${configurations[@]}"; do case "${configuration}" in docker) validation+='; docker info >/dev/null' ;; + docker-snap) validation+='; /snap/bin/docker info >/dev/null' ;; podman-rootless) validation+='; podman info >/dev/null' ;; selinux) validation+='; test "$(getenforce)" = Enforcing' ;; esac diff --git a/nix/test-guest/configuration/docker-snap.yml b/nix/test-guest/configuration/docker-snap.yml new file mode 100644 index 0000000000..679ce1e451 --- /dev/null +++ b/nix/test-guest/configuration/docker-snap.yml @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +# Docker delivered by Snap is deliberately separate from the host-package +# Docker configuration. It models the interface expected by the OpenShell Snap. +- name: Configure Docker Snap + hosts: test_vm + become: true + gather_facts: true + + tasks: + - name: Validate Docker Snap support + ansible.builtin.assert: + that: + - ansible_facts.distribution == "Ubuntu" + fail_msg: >- + Docker Snap is currently configured only for Ubuntu test guests, + not {{ ansible_facts.distribution }}. + + - name: Verify snapd is available + ansible.builtin.command: + cmd: snap wait system seed.loaded + changed_when: false + + - name: Check whether Docker Snap is installed + ansible.builtin.command: + cmd: snap list docker + register: docker_snap + changed_when: false + failed_when: false + + - name: Install Docker Snap + ansible.builtin.command: + cmd: snap install docker + when: docker_snap.rc != 0 + + - name: Wait for Docker Snap API + ansible.builtin.command: + cmd: /snap/bin/docker info + register: docker_snap_info + changed_when: false + retries: 30 + delay: 1 + until: docker_snap_info.rc == 0 diff --git a/nix/test-guest/configuration/snapd.yml b/nix/test-guest/configuration/snapd.yml index c89b81a2aa..a0a12f7a97 100644 --- a/nix/test-guest/configuration/snapd.yml +++ b/nix/test-guest/configuration/snapd.yml @@ -23,9 +23,19 @@ - name: Install snapd ansible.builtin.apt: name: snapd - state: present + state: latest install_recommends: false + - name: Report installed and candidate snapd packages + ansible.builtin.command: + cmd: apt-cache policy snapd + register: snapd_package_policy + changed_when: false + + - name: Display installed and candidate snapd packages + ansible.builtin.debug: + var: snapd_package_policy.stdout_lines + - name: Start snapd socket activation ansible.builtin.systemd_service: name: snapd.socket @@ -36,3 +46,13 @@ ansible.builtin.command: cmd: snap wait system seed.loaded changed_when: false + + - name: Query running snapd version + ansible.builtin.command: + cmd: snap version + register: snapd_version + changed_when: false + + - name: Display running snapd version + ansible.builtin.debug: + var: snapd_version.stdout_lines diff --git a/nix/test-guest/default.nix b/nix/test-guest/default.nix index e3b19a5d83..3c3ef7c78c 100644 --- a/nix/test-guest/default.nix +++ b/nix/test-guest/default.nix @@ -30,6 +30,7 @@ let podman-rootless = ./configuration/podman-rootless.yml; selinux = ./configuration/selinux.yml; snapd = ./configuration/snapd.yml; + docker-snap = ./configuration/docker-snap.yml; }; configurationTasks = [ @@ -46,6 +47,7 @@ let "gateway-rootless-podman" "openshell-rpm-gateway-reinstall" "openshell-rpm-gateway-upgrade" + "openshell-snap" ]; mkDistroProfile = diff --git a/nix/test-guest/provisioners/roles/openshell-snap/defaults/main.yml b/nix/test-guest/provisioners/roles/openshell-snap/defaults/main.yml new file mode 100644 index 0000000000..7fb5641c68 --- /dev/null +++ b/nix/test-guest/provisioners/roles/openshell-snap/defaults/main.yml @@ -0,0 +1,8 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +openshell_snap_package: /var/lib/openshell-conformance/candidate/openshell.snap +openshell_snap_user: openshell +openshell_snap_gateway_service: snap.openshell.gateway.service +openshell_snap_ready_retries: 30 diff --git a/nix/test-guest/provisioners/roles/openshell-snap/tasks/interfaces.yml b/nix/test-guest/provisioners/roles/openshell-snap/tasks/interfaces.yml new file mode 100644 index 0000000000..455a3a7398 --- /dev/null +++ b/nix/test-guest/provisioners/roles/openshell-snap/tasks/interfaces.yml @@ -0,0 +1,18 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +- name: Connect Docker Snap interface + become: true + ansible.builtin.command: + cmd: snap connect openshell:docker docker:docker-daemon + +- name: Connect log-observe interface + become: true + ansible.builtin.command: + cmd: snap connect openshell:log-observe + +- name: Connect system-observe interface + become: true + ansible.builtin.command: + cmd: snap connect openshell:system-observe diff --git a/nix/test-guest/provisioners/roles/openshell-snap/tasks/main.yml b/nix/test-guest/provisioners/roles/openshell-snap/tasks/main.yml new file mode 100644 index 0000000000..14ede79bfe --- /dev/null +++ b/nix/test-guest/provisioners/roles/openshell-snap/tasks/main.yml @@ -0,0 +1,82 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +- name: Gather facts for OpenShell Snap installation + become: true + ansible.builtin.setup: + +- name: Validate OpenShell Snap installation support + become: true + ansible.builtin.assert: + that: + - ansible_facts.distribution == "Ubuntu" + fail_msg: >- + OpenShell Snap installation is currently supported only on Ubuntu test guests, + not {{ ansible_facts.distribution }}. + +- name: Verify the candidate OpenShell Snap is present + become: true + ansible.builtin.stat: + path: "{{ openshell_snap_package }}" + register: openshell_snap_package_stat + +- name: Assert the candidate OpenShell Snap is present + become: true + ansible.builtin.assert: + that: openshell_snap_package_stat.stat.exists + fail_msg: "Candidate OpenShell Snap is missing: {{ openshell_snap_package }}" + +- name: Remove any previously installed OpenShell Snap + become: true + ansible.builtin.command: + cmd: snap remove --purge openshell + changed_when: true + failed_when: false + +- name: Remove OpenShell Snap user state + become: true + ansible.builtin.file: + path: "/home/{{ openshell_snap_user }}/snap/openshell" + state: absent + +- name: Install candidate OpenShell Snap + become: true + ansible.builtin.command: + cmd: "snap install {{ openshell_snap_package }} --dangerous" + +- name: Connect OpenShell Snap interfaces + block: + - ansible.builtin.include_tasks: interfaces.yml + rescue: + - name: Gather OpenShell Snap gateway service status + become: true + ansible.builtin.command: + cmd: >- + systemctl status {{ openshell_snap_gateway_service }} --no-pager + register: openshell_snap_gateway_service_status + changed_when: false + failed_when: false + + - name: Gather OpenShell Snap gateway journal + become: true + ansible.builtin.command: + cmd: >- + journalctl --no-pager -u {{ openshell_snap_gateway_service }} -n 200 + register: openshell_snap_gateway_journal + changed_when: false + failed_when: false + + - name: Report failed OpenShell Snap interface connection + ansible.builtin.debug: + msg: + interface_connection_error: "{{ ansible_failed_result }}" + gateway_service_status: "{{ openshell_snap_gateway_service_status.stdout }}" + gateway_journal: "{{ openshell_snap_gateway_journal.stdout }}" + + - name: Fail after collecting OpenShell Snap diagnostics + ansible.builtin.fail: + msg: "OpenShell Snap interface connection failed; see diagnostics above." + +- name: Register OpenShell Snap gateway + ansible.builtin.import_tasks: register.yml diff --git a/nix/test-guest/provisioners/roles/openshell-snap/tasks/register.yml b/nix/test-guest/provisioners/roles/openshell-snap/tasks/register.yml new file mode 100644 index 0000000000..0e30a55d40 --- /dev/null +++ b/nix/test-guest/provisioners/roles/openshell-snap/tasks/register.yml @@ -0,0 +1,31 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +- name: Check OpenShell Snap CLI version + ansible.builtin.command: + cmd: openshell --version + changed_when: false + +- name: Check OpenShell Snap services + become: true + ansible.builtin.command: + cmd: snap services openshell + changed_when: false + +- name: Register local OpenShell Snap gateway + ansible.builtin.command: + cmd: openshell gateway add http://127.0.0.1:17670 --local --name snap-docker + +- name: Select local OpenShell Snap gateway + ansible.builtin.command: + cmd: openshell gateway select snap-docker + +- name: Wait for local OpenShell Snap gateway status + ansible.builtin.command: + cmd: openshell status + register: openshell_snap_gateway_status + changed_when: false + retries: "{{ openshell_snap_ready_retries }}" + delay: 1 + until: openshell_snap_gateway_status.rc == 0 diff --git a/nix/test-guest/scripts/snap-gateway-repro.sh b/nix/test-guest/scripts/snap-gateway-repro.sh deleted file mode 100755 index f3090aece2..0000000000 --- a/nix/test-guest/scripts/snap-gateway-repro.sh +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -# Reproduce the Release Canary Snap lifecycle: install the OpenShell Snap, -# connect its interfaces after the daemon is started, then immediately use the -# local gateway. Run this as root inside an Ubuntu guest prepared with --with snapd. - -set -uo pipefail - -usage() { - cat <<'EOF' -Usage: snap-gateway-repro.sh SNAP_FILE [ATTEMPTS] [READY_TIMEOUT_SECONDS] - -Install SNAP_FILE repeatedly using the Release Canary interface ordering. -ATTEMPTS defaults to 1. READY_TIMEOUT_SECONDS defaults to 0, preserving the -canary's immediate readiness check. Set it to a positive value to wait for -automatic gateway recovery after the immediate check fails. Every failed -attempt prints service, connection, snap-change, journal, gateway-log, and -listener diagnostics. -EOF -} - -if [ "$#" -lt 1 ] || [ "$#" -gt 3 ]; then - usage >&2 - exit 2 -fi - -snap_file=$1 -attempts=${2:-1} -ready_timeout=${3:-0} -if [ ! -f "${snap_file}" ]; then - echo "Snap file does not exist: ${snap_file}" >&2 - exit 2 -fi -if [[ ! ${attempts} =~ ^[1-9][0-9]*$ ]]; then - echo "ATTEMPTS must be a positive integer: ${attempts}" >&2 - exit 2 -fi -if [[ ! ${ready_timeout} =~ ^[0-9]+$ ]]; then - echo "READY_TIMEOUT_SECONDS must be a non-negative integer: ${ready_timeout}" >&2 - exit 2 -fi - -diagnostics() { - local attempt=$1 - echo "========== Snap diagnostics (attempt ${attempt}) ==========" >&2 - snap services openshell >&2 || true - snap connections openshell >&2 || true - snap changes >&2 || true - systemctl status snap.openshell.gateway.service --no-pager >&2 || true - journalctl -b -u snap.openshell.gateway.service --no-pager -n 300 >&2 || true - journalctl -b -u snapd.service --no-pager -n 300 >&2 || true - snap logs openshell.gateway -n=300 >&2 || true - ss -ltnp '( sport = :17670 )' >&2 || true -} - -gateway_is_ready() { - runuser -u openshell -- /snap/bin/openshell status >/dev/null 2>&1 -} - -wait_for_gateway() { - local deadline=$((SECONDS + ready_timeout)) - while [ "${SECONDS}" -lt "${deadline}" ]; do - if gateway_is_ready; then - return 0 - fi - sleep 1 - done - gateway_is_ready -} - -if ! snap list docker >/dev/null 2>&1; then - echo "==> Installing Docker Snap" - snap install docker -fi - -failures=0 -for attempt in $(seq 1 "${attempts}"); do - echo "==> Snap gateway reproduction attempt ${attempt}/${attempts}" - snap remove --purge openshell >/dev/null 2>&1 || true - rm -rf /home/openshell/snap/openshell - - if ! snap install "${snap_file}" --dangerous || - ! snap connect openshell:docker docker:docker-daemon || - ! snap connect openshell:log-observe || - ! snap connect openshell:system-observe; then - echo "OpenShell installation or interface connection failed" >&2 - diagnostics "${attempt}" - failures=$((failures + 1)) - continue - fi - - # This deliberately does not wait for the listener. It mirrors the canary - # and exposes a daemon that fails or races after late interface connections. - if ! runuser -u openshell -- /snap/bin/openshell gateway add \ - http://127.0.0.1:17670 --local --name snap-docker || - ! runuser -u openshell -- /snap/bin/openshell gateway select snap-docker || - ! gateway_is_ready; then - if [ "${ready_timeout}" -gt 0 ] && wait_for_gateway; then - echo "Gateway recovered automatically within ${ready_timeout}s" - continue - fi - echo "Gateway was not usable immediately after interface connection" >&2 - diagnostics "${attempt}" - failures=$((failures + 1)) - fi -done - -if [ "${failures}" -gt 0 ]; then - echo "${failures}/${attempts} attempt(s) failed" >&2 - exit 1 -fi - -echo "All ${attempts} attempt(s) passed" diff --git a/python/openshell/release_formula_test.py b/python/openshell/release_formula_test.py index 62c27115b5..3b3758a822 100644 --- a/python/openshell/release_formula_test.py +++ b/python/openshell/release_formula_test.py @@ -131,13 +131,15 @@ def test_snap_wrapper_uses_optional_gateway_config_without_generating_toml() -> assert 'exec "${SNAP}/bin/openshell-gateway" "$@"' in wrapper -def test_snap_docker_connect_hook_restarts_gateway() -> None: +def test_snap_docker_connect_hook_enables_and_starts_gateway() -> None: repo_root = Path(__file__).resolve().parents[2] hook = repo_root / "snap/hooks/connect-plug-docker" + snapcraft = (repo_root / "snapcraft.yaml").read_text(encoding="utf-8") assert hook.is_file() assert hook.stat().st_mode & stat.S_IXUSR - assert 'snapctl restart "${SNAP_INSTANCE_NAME}.gateway"' in hook.read_text( + assert "install-mode: disable" in snapcraft + assert 'snapctl start --enable "${SNAP_INSTANCE_NAME}.gateway"' in hook.read_text( encoding="utf-8" ) diff --git a/snap/hooks/connect-plug-docker b/snap/hooks/connect-plug-docker index cfd334b74c..d032b6b9db 100755 --- a/snap/hooks/connect-plug-docker +++ b/snap/hooks/connect-plug-docker @@ -2,11 +2,10 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# The gateway daemon can start when this plug is still disconnected. Restart it -# after Docker access becomes available so driver auto-detection runs with the -# socket exposed by docker:docker-daemon. This hook does not make normal gateway -# startup conditional on Docker; it runs only after an operator connects Docker. +# The gateway is disabled on installation so it cannot start before this plug is +# connected. Enable and start it after Docker access becomes available so driver +# auto-detection runs with the socket exposed by docker:docker-daemon. set -eu -snapctl restart "${SNAP_INSTANCE_NAME}.gateway" +snapctl start --enable "${SNAP_INSTANCE_NAME}.gateway" diff --git a/snapcraft.yaml b/snapcraft.yaml index f567c63c47..2aad65e45a 100644 --- a/snapcraft.yaml +++ b/snapcraft.yaml @@ -82,6 +82,10 @@ apps: gateway: command: bin/openshell-gateway-wrapper daemon: simple + # Do not start before the Docker interface is available. The + # connect-plug-docker hook enables and starts the gateway after Docker + # access is granted, including for store-managed auto-connections. + install-mode: disable # refresh-mode: endure prevents snapd from restarting the gateway daemon # during snap refreshes, which would kill active sandbox sessions. # Operators must manually restart the service after a refresh if needed.