Skip to content

Commit ca61ee3

Browse files
authored
feat(test-guest): add snap lifecycle reproduction harness (#2865)
* feat(test-guest): add snap lifecycle reproduction harness Signed-off-by: Evan Lezar <elezar@nvidia.com> * fix(test-guest): preserve cache seal executability Signed-off-by: Evan Lezar <elezar@nvidia.com> --------- Signed-off-by: Evan Lezar <elezar@nvidia.com>
1 parent 6e43a8b commit ca61ee3

6 files changed

Lines changed: 217 additions & 9 deletions

File tree

nix/test-guest/README.md

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ The root [`flake.nix`](../../flake.nix) exposes this directory as the `test-gues
6363
| Fedora 44 | No | Yes | Yes | `.rpm` |
6464
| Rocky Linux 9 | Yes | Yes | Yes | `.rpm` |
6565

66+
The `snapd` configuration is available for Ubuntu and prepares snapd for
67+
local Snap lifecycle experiments. It does not install Docker, because the Snap
68+
gateway reproduction uses the Docker **Snap** and its `docker:docker-daemon`
69+
interface rather than the host-package Docker configuration.
70+
6671
The Ubuntu 24.04 Podman configuration is available for runtime and packaging
6772
checks, but its Podman 4 release does not provide the `pasta` rootless network
6873
helper required by OpenShell sandbox callbacks. OpenShell Podman E2E runs use
@@ -121,9 +126,11 @@ nix run .#test-guest -- \
121126

122127
Configurations are Ansible playbooks stored under `nix/test-guest/configuration/`. Ansible runs on the host using the VM's ephemeral SSH key and loopback port. The guest does not install Ansible.
123128

124-
Configurations run in the order provided on the command line. OpenShell packages and copied binaries are installed after all configurations succeed.
129+
Configurations run in the order provided on the command line. OpenShell packages and copied files are installed after all configurations succeed.
125130

126-
`--install` packages and `--copy` executables are applied by a dedicated per-run Ansible playbook. They are not stored in prepared VM cache entries.
131+
`--install` packages and `--copy` files are applied by a dedicated per-run
132+
Ansible playbook. `--copy` preserves each source file's ordinary permission
133+
bits. They are not stored in prepared VM cache entries.
127134

128135
## Prepared VM cache
129136

@@ -212,9 +219,10 @@ For an x86_64 Linux guest, supply x86_64 binaries and use `package:deb:amd64`. T
212219

213220
`--install` is repeatable. Debian packages are accepted by Ubuntu; RPM packages are accepted by CentOS, Fedora, and Rocky Linux. This prototype can install an existing RPM but does not build one.
214221

215-
## Copy binaries directly
222+
## Copy files directly
216223

217-
Use `--copy SOURCE:DEST` to install an executable without creating a package:
224+
Use `--copy SOURCE:DEST` to copy a regular file without creating a package. The
225+
guest file preserves the source's ordinary permission bits:
218226

219227
```shell
220228
nix run .#test-guest -- \
@@ -223,6 +231,29 @@ nix run .#test-guest -- \
223231
-- openshell --version
224232
```
225233

234+
## Reproduce Snap gateway startup
235+
236+
The gateway Snap must be native to the guest architecture. Copy an existing
237+
Snap artifact and the reproduction script into a prepared Ubuntu guest, then
238+
run the script as root. It follows the Release Canary ordering exactly: install
239+
the Snap, connect Docker/log/system interfaces, and immediately query the
240+
gateway. On each failure it prints snapd and gateway journals.
241+
242+
```shell
243+
nix run .#test-guest -- \
244+
--distro ubuntu \
245+
--with snapd \
246+
--keep \
247+
--copy ./openshell_*.snap:/tmp/openshell.snap \
248+
--copy ./nix/test-guest/scripts/snap-gateway-repro.sh:/usr/local/bin/snap-gateway-repro \
249+
-- sudo /usr/local/bin/snap-gateway-repro /tmp/openshell.snap 10 30
250+
```
251+
252+
`--keep` retains the overlay and serial log when diagnosing a failure. The
253+
runner prints their location after shutdown. The final `30` accepts automatic
254+
recovery for up to 30 seconds; omit it to require the canary's immediate check.
255+
256+
226257
The destination must be an absolute guest path. Copied files are installed with mode `0755`.
227258

228259
## Runner options
@@ -231,7 +262,8 @@ The destination must be an absolute guest path. Copied files are installed with
231262
--distro NAME Base distro: ubuntu, centos, fedora, or rocky
232263
--with NAME Apply docker, podman, or selinux; repeatable
233264
--install PATH Install a .deb or .rpm package; repeatable
234-
--copy SRC:DEST Copy an executable into the guest; repeatable
265+
--copy SRC:DEST Copy a regular file into the guest, preserving its host mode;
266+
repeatable
235267
--ssh-port PORT Use a specific loopback SSH forwarding port
236268
--forward-port HOST_PORT:GUEST_PORT
237269
Forward a loopback host port to a guest port; repeatable

nix/test-guest/cache-seal.sh

100644100755
File mode changed.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
- name: Configure snapd
6+
hosts: test_vm
7+
become: true
8+
gather_facts: true
9+
10+
tasks:
11+
- name: Validate snapd support
12+
ansible.builtin.assert:
13+
that:
14+
- ansible_facts.distribution == "Ubuntu"
15+
fail_msg: >-
16+
snapd is currently configured only for Ubuntu test guests,
17+
not {{ ansible_facts.distribution }}.
18+
19+
- name: Refresh Ubuntu package metadata
20+
ansible.builtin.apt:
21+
update_cache: true
22+
23+
- name: Install snapd
24+
ansible.builtin.apt:
25+
name: snapd
26+
state: present
27+
install_recommends: false
28+
29+
- name: Start snapd socket activation
30+
ansible.builtin.systemd_service:
31+
name: snapd.socket
32+
enabled: true
33+
state: started
34+
35+
- name: Wait for snapd seed
36+
ansible.builtin.command:
37+
cmd: snap wait system seed.loaded
38+
changed_when: false

nix/test-guest/default.nix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ let
2828
docker = ./configuration/docker.yml;
2929
podman = ./configuration/podman.yml;
3030
selinux = ./configuration/selinux.yml;
31+
snapd = ./configuration/snapd.yml;
3132
};
3233

3334
mkDistroProfile =

nix/test-guest/run.sh

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ Usage:
1313
1414
Options:
1515
--distro NAME Base distro: ubuntu, centos, fedora, or rocky
16-
--with NAME Apply a configuration; repeatable (docker, podman, selinux)
16+
--with NAME Apply a configuration; repeatable (docker, podman, selinux, snapd)
1717
--install PATH Install a .deb or .rpm package; repeatable
18-
--copy SRC:DEST Copy an executable to an absolute guest path; repeatable
18+
--copy SRC:DEST Copy a regular file to an absolute guest path, preserving
19+
its host mode; repeatable
1920
--ssh-port PORT Use a specific loopback SSH forwarding port
2021
--forward-port HOST_PORT:GUEST_PORT
2122
Forward a loopback host port to a guest port; repeatable
@@ -43,6 +44,26 @@ require_value() {
4344
fi
4445
}
4546

47+
preserved_file_mode() {
48+
local source_path=$1
49+
local source_mode
50+
51+
if [ "$(uname -s)" = Darwin ]; then
52+
if ! source_mode=$(stat -f '%Lp' "${source_path}"); then
53+
echo "could not determine mode for --copy source: ${source_path}" >&2
54+
return 1
55+
fi
56+
elif ! source_mode=$(stat -c '%a' "${source_path}"); then
57+
echo "could not determine mode for --copy source: ${source_path}" >&2
58+
return 1
59+
fi
60+
if [[ ! ${source_mode} =~ ^[0-7]{3,4}$ ]]; then
61+
echo "could not determine mode for --copy source: ${source_path}" >&2
62+
return 1
63+
fi
64+
printf '%03o\n' "$((8#${source_mode} & 8#777))"
65+
}
66+
4667
distro=
4768
requested_ssh_port=
4869
keep=0
@@ -618,13 +639,14 @@ if [ "${#packages[@]}" -gt 0 ] || [ "${#copies[@]}" -gt 0 ]; then
618639
for copy_spec in "${copies[@]}"; do
619640
source_path=${copy_spec%%:*}
620641
destination=${copy_spec#*:}
642+
mode=$(preserved_file_mode "${source_path}") || exit 2
621643
remote_path=${artifact_staging_dir}/copy-${artifact_index}
622644
echo "==> Copying artifact: ${destination}"
623645
scp -q "${scp_args[@]}" \
624646
"${source_path}" "openshell@127.0.0.1:${remote_path}"
625647
printf -v install_command \
626-
'sudo install -D -m 0755 -- %q %q' \
627-
"${remote_path}" "${destination}"
648+
'sudo install -D -m %q -- %q %q' \
649+
"${mode}" "${remote_path}" "${destination}"
628650
ssh "${ssh_args[@]}" openshell@127.0.0.1 "${install_command}"
629651
artifact_index=$((artifact_index + 1))
630652
done
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
#!/usr/bin/env bash
2+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
# Reproduce the Release Canary Snap lifecycle: install the OpenShell Snap,
6+
# connect its interfaces after the daemon is started, then immediately use the
7+
# local gateway. Run this as root inside an Ubuntu guest prepared with --with snapd.
8+
9+
set -uo pipefail
10+
11+
usage() {
12+
cat <<'EOF'
13+
Usage: snap-gateway-repro.sh SNAP_FILE [ATTEMPTS] [READY_TIMEOUT_SECONDS]
14+
15+
Install SNAP_FILE repeatedly using the Release Canary interface ordering.
16+
ATTEMPTS defaults to 1. READY_TIMEOUT_SECONDS defaults to 0, preserving the
17+
canary's immediate readiness check. Set it to a positive value to wait for
18+
automatic gateway recovery after the immediate check fails. Every failed
19+
attempt prints service, connection, snap-change, journal, gateway-log, and
20+
listener diagnostics.
21+
EOF
22+
}
23+
24+
if [ "$#" -lt 1 ] || [ "$#" -gt 3 ]; then
25+
usage >&2
26+
exit 2
27+
fi
28+
29+
snap_file=$1
30+
attempts=${2:-1}
31+
ready_timeout=${3:-0}
32+
if [ ! -f "${snap_file}" ]; then
33+
echo "Snap file does not exist: ${snap_file}" >&2
34+
exit 2
35+
fi
36+
if [[ ! ${attempts} =~ ^[1-9][0-9]*$ ]]; then
37+
echo "ATTEMPTS must be a positive integer: ${attempts}" >&2
38+
exit 2
39+
fi
40+
if [[ ! ${ready_timeout} =~ ^[0-9]+$ ]]; then
41+
echo "READY_TIMEOUT_SECONDS must be a non-negative integer: ${ready_timeout}" >&2
42+
exit 2
43+
fi
44+
45+
diagnostics() {
46+
local attempt=$1
47+
echo "========== Snap diagnostics (attempt ${attempt}) ==========" >&2
48+
snap services openshell >&2 || true
49+
snap connections openshell >&2 || true
50+
snap changes >&2 || true
51+
systemctl status snap.openshell.gateway.service --no-pager >&2 || true
52+
journalctl -b -u snap.openshell.gateway.service --no-pager -n 300 >&2 || true
53+
journalctl -b -u snapd.service --no-pager -n 300 >&2 || true
54+
snap logs openshell.gateway -n=300 >&2 || true
55+
ss -ltnp '( sport = :17670 )' >&2 || true
56+
}
57+
58+
gateway_is_ready() {
59+
runuser -u openshell -- /snap/bin/openshell status >/dev/null 2>&1
60+
}
61+
62+
wait_for_gateway() {
63+
local deadline=$((SECONDS + ready_timeout))
64+
while [ "${SECONDS}" -lt "${deadline}" ]; do
65+
if gateway_is_ready; then
66+
return 0
67+
fi
68+
sleep 1
69+
done
70+
gateway_is_ready
71+
}
72+
73+
if ! snap list docker >/dev/null 2>&1; then
74+
echo "==> Installing Docker Snap"
75+
snap install docker
76+
fi
77+
78+
failures=0
79+
for attempt in $(seq 1 "${attempts}"); do
80+
echo "==> Snap gateway reproduction attempt ${attempt}/${attempts}"
81+
snap remove --purge openshell >/dev/null 2>&1 || true
82+
rm -rf /home/openshell/snap/openshell
83+
84+
if ! snap install "${snap_file}" --dangerous ||
85+
! snap connect openshell:docker docker:docker-daemon ||
86+
! snap connect openshell:log-observe ||
87+
! snap connect openshell:system-observe; then
88+
echo "OpenShell installation or interface connection failed" >&2
89+
diagnostics "${attempt}"
90+
failures=$((failures + 1))
91+
continue
92+
fi
93+
94+
# This deliberately does not wait for the listener. It mirrors the canary
95+
# and exposes a daemon that fails or races after late interface connections.
96+
if ! runuser -u openshell -- /snap/bin/openshell gateway add \
97+
http://127.0.0.1:17670 --local --name snap-docker ||
98+
! runuser -u openshell -- /snap/bin/openshell gateway select snap-docker ||
99+
! gateway_is_ready; then
100+
if [ "${ready_timeout}" -gt 0 ] && wait_for_gateway; then
101+
echo "Gateway recovered automatically within ${ready_timeout}s"
102+
continue
103+
fi
104+
echo "Gateway was not usable immediately after interface connection" >&2
105+
diagnostics "${attempt}"
106+
failures=$((failures + 1))
107+
fi
108+
done
109+
110+
if [ "${failures}" -gt 0 ]; then
111+
echo "${failures}/${attempts} attempt(s) failed" >&2
112+
exit 1
113+
fi
114+
115+
echo "All ${attempts} attempt(s) passed"

0 commit comments

Comments
 (0)