Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion nix/test-guest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ The root [`flake.nix`](../../flake.nix) exposes this directory as the `test-gues
| Fedora 44 | No | Yes | Yes | `.rpm` |
| Rocky Linux 9 | Yes | Yes | 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.

The Ubuntu 24.04 Podman configuration is available for runtime and packaging
checks, but its Podman 4 release does not provide the `pasta` rootless network
helper required by OpenShell sandbox callbacks. OpenShell Podman E2E runs use
Expand Down Expand Up @@ -117,7 +122,9 @@ Configurations are Ansible playbooks stored under `nix/test-guest/configuration/

Configurations run in the order provided on the command line. OpenShell packages and copied binaries are installed after all configurations succeed.

`--install` packages and `--copy` executables are applied by a dedicated per-run Ansible playbook. They are not stored in prepared VM cache entries.
`--install` packages, `--copy` executables, and `--copy-file` regular files are
applied by a dedicated per-run Ansible playbook. They are not stored in prepared
VM cache entries.

## Prepared VM cache

Expand Down Expand Up @@ -217,6 +224,29 @@ nix run .#test-guest -- \
-- openshell --version
```

## Reproduce Snap gateway startup

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.

```shell
nix run .#test-guest -- \
--distro ubuntu \
--with snapd \
--keep \
--copy-file ./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
```

`--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.


The destination must be an absolute guest path. Copied files are installed with mode `0755`.

## Runner options
Expand All @@ -226,6 +256,8 @@ The destination must be an absolute guest path. Copied files are installed with
--with NAME Apply docker, podman, or selinux; repeatable
--install PATH Install a .deb or .rpm package; repeatable
--copy SRC:DEST Copy an executable into the guest; repeatable
--copy-file SRC:DEST
Copy a regular file into the guest; repeatable
--ssh-port PORT Use a specific loopback SSH forwarding port
--forward-port HOST_PORT:GUEST_PORT
Forward a loopback host port to a guest port; repeatable
Expand Down
38 changes: 38 additions & 0 deletions nix/test-guest/configuration/snapd.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

- name: Configure snapd
hosts: test_vm
become: true
gather_facts: true

tasks:
- name: Validate snapd support
ansible.builtin.assert:
that:
- ansible_facts.distribution == "Ubuntu"
fail_msg: >-
snapd is currently configured only for Ubuntu test guests,
not {{ ansible_facts.distribution }}.

- name: Refresh Ubuntu package metadata
ansible.builtin.apt:
update_cache: true

- name: Install snapd
ansible.builtin.apt:
name: snapd
state: present
install_recommends: false

- name: Start snapd socket activation
ansible.builtin.systemd_service:
name: snapd.socket
enabled: true
state: started

- name: Wait for snapd seed
ansible.builtin.command:
cmd: snap wait system seed.loaded
changed_when: false
1 change: 1 addition & 0 deletions nix/test-guest/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ let
docker = ./configuration/docker.yml;
podman = ./configuration/podman.yml;
selinux = ./configuration/selinux.yml;
snapd = ./configuration/snapd.yml;
};

mkDistroProfile =
Expand Down
57 changes: 55 additions & 2 deletions nix/test-guest/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ Usage:

Options:
--distro NAME Base distro: ubuntu, centos, fedora, or rocky
--with NAME Apply a configuration; repeatable (docker, podman, selinux)
--with NAME Apply a configuration; repeatable (docker, podman, selinux, snapd)
--install PATH Install a .deb or .rpm package; repeatable
--copy SRC:DEST Copy an executable to an absolute guest path; repeatable
--copy-file SRC:DEST
Copy a regular file to an absolute guest path; repeatable
--ssh-port PORT Use a specific loopback SSH forwarding port
--forward-port HOST_PORT:GUEST_PORT
Forward a loopback host port to a guest port; repeatable
Expand Down Expand Up @@ -50,6 +52,7 @@ list=0
configurations=()
packages=()
copies=()
files=()
forward_ports=()
guest_command=()

Expand All @@ -75,6 +78,11 @@ while [ "$#" -gt 0 ]; do
copies+=("$2")
shift 2
;;
--copy-file)
require_value "$@"
files+=("$2")
shift 2
;;
--ssh-port)
require_value "$@"
requested_ssh_port=$2
Expand Down Expand Up @@ -251,6 +259,36 @@ for copy_spec in "${copies[@]}"; do
done
copies=("${resolved_copies[@]}")

resolved_files=()
for file_spec in "${files[@]}"; do
source_path=${file_spec%%:*}
destination=${file_spec#*:}
if [ "${source_path}" = "${file_spec}" ] ||
! source_path=$(realpath -- "${source_path}") ||
[ ! -f "${source_path}" ]; then
echo "invalid --copy-file source: ${file_spec}" >&2
exit 2
fi
case "${destination}" in
/*)
if [[ ${destination} == *"/../"* ]] || [[ ${destination} == */.. ]]; then
echo "--copy-file destination must not contain '..': ${destination}" >&2
exit 2
fi
if [[ ! ${destination} =~ ^/[A-Za-z0-9._+~/-]+$ ]]; then
echo "--copy-file destination contains unsupported characters: ${destination}" >&2
exit 2
fi
;;
*)
echo "--copy-file destination must be absolute: ${destination}" >&2
exit 2
;;
esac
resolved_files+=("${source_path}:${destination}")
done
files=("${resolved_files[@]}")

test_vm_cpu=host
ssh_wait_seconds=180
if [ "${TEST_GUEST_ACCELERATOR}" = kvm ] &&
Expand Down Expand Up @@ -583,7 +621,7 @@ else
echo "==> Reusing cached configuration: ${configurations[*]:-base image}"
fi

if [ "${#packages[@]}" -gt 0 ] || [ "${#copies[@]}" -gt 0 ]; then
if [ "${#packages[@]}" -gt 0 ] || [ "${#copies[@]}" -gt 0 ] || [ "${#files[@]}" -gt 0 ]; then
phase_started_at=${SECONDS}
artifact_staging_dir=/tmp/openshell-test-guest-artifacts-$$
ssh "${ssh_args[@]}" openshell@127.0.0.1 \
Expand Down Expand Up @@ -629,6 +667,21 @@ if [ "${#packages[@]}" -gt 0 ] || [ "${#copies[@]}" -gt 0 ]; then
artifact_index=$((artifact_index + 1))
done

artifact_index=0
for file_spec in "${files[@]}"; do
source_path=${file_spec%%:*}
destination=${file_spec#*:}
remote_path=${artifact_staging_dir}/file-${artifact_index}
echo "==> Copying file: ${destination}"
scp -q "${scp_args[@]}" \
"${source_path}" "openshell@127.0.0.1:${remote_path}"
printf -v install_command \
'sudo install -D -m 0644 -- %q %q' \
"${remote_path}" "${destination}"
ssh "${ssh_args[@]}" openshell@127.0.0.1 "${install_command}"
artifact_index=$((artifact_index + 1))
done

ssh "${ssh_args[@]}" openshell@127.0.0.1 \
"rm -rf -- '${artifact_staging_dir}'"
report_timing "artifact transfer" "${phase_started_at}"
Expand Down
115 changes: 115 additions & 0 deletions nix/test-guest/scripts/snap-gateway-repro.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#!/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"
Loading