Skip to content

Commit 3f0a69c

Browse files
committed
test(features): add the remaining CDI scenarios
Add the scenarios for read-only and read-write mounts, library parent derivation, a device node, and a unix socket, with one test per scenario in the cdi suite. Each test delegates to the scenario wrapper, which checks the workload against the expectations recorded in the spec. Add the fixture machinery the scenarios need: a pipe fixture type, a host device node that a systemd service creates and labels on every boot, and the echo socket, which a systemd service serves on every boot. The node has the SELinux type of /dev/null so that a rootless runtime, which bind-mounts it, can use it. Signed-off-by: Evan Lezar <elezar@nvidia.com>
1 parent f4004a2 commit 3f0a69c

4 files changed

Lines changed: 335 additions & 1 deletion

File tree

‎tests/suites/features/cdi/tests/injection.rs‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,70 @@ use testkit::Context;
1919
async fn mount_under_read_only_baseline(cx: &mut Context) -> testkit::Result {
2020
cdi::scenario(cx, "mount-under-read-only-baseline").await
2121
}
22+
23+
/// A read-only file mounted outside any directory the sandbox baseline
24+
/// grants. The spec's expectations record whether the workload can read it.
25+
#[testkit::test]
26+
async fn read_only_file(cx: &mut Context) -> testkit::Result {
27+
cdi::scenario(cx, "read-only-file").await
28+
}
29+
30+
/// A read-only directory mounted outside the baseline grants. The
31+
/// expectations record whether its marker is readable and whether files can be
32+
/// created in it.
33+
#[testkit::test]
34+
async fn read_only_directory(cx: &mut Context) -> testkit::Result {
35+
cdi::scenario(cx, "read-only-directory").await
36+
}
37+
38+
/// A mount named like a shared library, `libfixture.so.1`, under a directory of
39+
/// its own. The expectations record whether the library's parent directory is
40+
/// granted for the workload.
41+
#[testkit::test]
42+
async fn library_parent(cx: &mut Context) -> testkit::Result {
43+
cdi::scenario(cx, "library-parent").await
44+
}
45+
46+
/// A mount named like a shared library directly under `/`. Granting its parent
47+
/// would grant the whole root, so the expectations record how that is handled.
48+
#[testkit::test]
49+
async fn broad_library_parent(cx: &mut Context) -> testkit::Result {
50+
cdi::scenario(cx, "broad-library-parent").await
51+
}
52+
53+
/// A file the CDI spec mounts read-write. The expectations record whether the
54+
/// workload can read or write it.
55+
#[testkit::test]
56+
async fn writable_file(cx: &mut Context) -> testkit::Result {
57+
cdi::scenario(cx, "writable-file").await
58+
}
59+
60+
/// A directory the CDI spec mounts read-write. The expectations record whether
61+
/// the workload can create files in it.
62+
#[testkit::test]
63+
async fn writable_directory(cx: &mut Context) -> testkit::Result {
64+
cdi::scenario(cx, "writable-directory").await
65+
}
66+
67+
/// A bind mount whose options omit both `ro` and `rw`, so the access it grants
68+
/// is implicit. The expectations record how the workload can use it.
69+
#[testkit::test]
70+
async fn omitted_access_file(cx: &mut Context) -> testkit::Result {
71+
cdi::scenario(cx, "omitted-access-file").await
72+
}
73+
74+
/// A character device node injected from `/dev/null` with read-write
75+
/// permissions. The expectations record whether a non-root workload can write
76+
/// it.
77+
#[testkit::test]
78+
async fn device_node(cx: &mut Context) -> testkit::Result {
79+
cdi::scenario(cx, "device-node").await
80+
}
81+
82+
/// A unix socket mounted on its own, without the directory it lives in. The
83+
/// expectations record that a sibling file in the host directory stays
84+
/// invisible.
85+
#[testkit::test]
86+
async fn unix_socket(cx: &mut Context) -> testkit::Result {
87+
cdi::scenario(cx, "unix-socket").await
88+
}

‎tests/tmachine/ansible/roles/cdi_fixtures/tasks/scenarios.yaml‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,127 @@
4545
loop_control:
4646
label: "{{ item.path }}"
4747

48+
- name: Create scenario fixture named pipes
49+
become: true
50+
ansible.builtin.command:
51+
argv: [mkfifo, "--mode={{ item.mode }}", "{{ cdi_scenario_dir }}/{{ item.path }}"]
52+
creates: "{{ cdi_scenario_dir }}/{{ item.path }}"
53+
loop: "{{ cdi_scenario_fixtures | selectattr('type', 'eq', 'fifo') | list }}"
54+
loop_control:
55+
label: "{{ item.path }}"
56+
57+
- name: Set scenario fixture pipe ownership
58+
become: true
59+
ansible.builtin.file:
60+
path: "{{ cdi_scenario_dir }}/{{ item.path }}"
61+
owner: "{{ item.owner | default('root') }}"
62+
group: "{{ item.group | default('root') }}"
63+
mode: "{{ item.mode }}"
64+
loop: "{{ cdi_scenario_fixtures | selectattr('type', 'eq', 'fifo') | list }}"
65+
loop_control:
66+
label: "{{ item.path }}"
67+
68+
# A service that creates the socket, not a socket unit: on SELinux hosts systemd
69+
# labels a socket it creates from the policy's default for the path, which
70+
# denies /var/lib.
71+
# A process creates a file with its directory's type, so the socket the service
72+
# creates inherits this one, and does so again on every boot. On SELinux hosts
73+
# (Fedora) a container cannot stat a socket of the default /usr/local type. The
74+
# file module ignores the type where SELinux is not enabled.
75+
- name: Label the socket fixture directory for containers
76+
become: true
77+
ansible.builtin.file:
78+
path: "{{ cdi_scenario_dir }}/socket"
79+
state: directory
80+
setype: container_file_t
81+
82+
- name: Check for systemd-socket-activate
83+
ansible.builtin.stat:
84+
path: /usr/bin/systemd-socket-activate
85+
register: cdi_socket_activate
86+
87+
- name: Require systemd-socket-activate for the socket fixture
88+
ansible.builtin.assert:
89+
that: [cdi_socket_activate.stat.exists]
90+
fail_msg: systemd-socket-activate is not installed; the socket fixture needs it
91+
92+
- name: Install the socket fixture unit
93+
become: true
94+
ansible.builtin.template:
95+
src: openshell-cdi-echo.service.j2
96+
dest: /etc/systemd/system/openshell-cdi-echo.service
97+
owner: root
98+
group: root
99+
mode: "0644"
100+
101+
- name: Start the persistent socket fixture
102+
block:
103+
- name: Start the socket fixture service
104+
become: true
105+
ansible.builtin.systemd_service:
106+
name: openshell-cdi-echo.service
107+
daemon_reload: true
108+
enabled: true
109+
state: restarted
110+
111+
- name: Wait for the socket fixture
112+
become: true
113+
ansible.builtin.wait_for:
114+
path: "{{ cdi_scenario_dir }}/socket/echo.sock"
115+
state: present
116+
timeout: 15
117+
118+
rescue:
119+
- name: Collect socket fixture diagnostics
120+
become: true
121+
ansible.builtin.shell: |
122+
systemctl status openshell-cdi-echo.service --no-pager -l
123+
journalctl -u openshell-cdi-echo.service --no-pager -n 40
124+
ls -lZd {{ cdi_scenario_dir }}/socket
125+
(ausearch -m avc,user_avc -ts recent 2>&1 || true) | tail -20
126+
changed_when: false
127+
failed_when: false
128+
register: cdi_socket_diagnostics
129+
130+
- name: Report why the socket fixture did not start
131+
ansible.builtin.fail:
132+
msg: "openshell-cdi-echo.service did not start:\n{{ cdi_socket_diagnostics.stdout }}\n{{ cdi_socket_diagnostics.stderr }}"
133+
134+
# The device-node scenario injects /dev/tmachine-cdi-null, a copy of /dev/null,
135+
# from a host node at the same path. A rootless runtime cannot create nodes, so
136+
# it bind-mounts the host node, and the container sees the host node's SELinux
137+
# type: it needs the type of /dev/null, which containers may use. /dev is
138+
# recreated on every boot, so a service creates and labels the node each time.
139+
# The label is ignored where SELinux is not enabled.
140+
- name: Install the host device node unit
141+
become: true
142+
ansible.builtin.copy:
143+
dest: /etc/systemd/system/openshell-cdi-device.service
144+
content: |
145+
[Unit]
146+
Description=OpenShell tmachine CDI host device node fixture
147+
148+
[Service]
149+
Type=oneshot
150+
RemainAfterExit=yes
151+
ExecStart=/bin/rm -f /dev/tmachine-cdi-null
152+
ExecStart=/usr/bin/mknod --mode=0666 /dev/tmachine-cdi-null c 1 3
153+
ExecStart=-/usr/bin/chcon --type=null_device_t /dev/tmachine-cdi-null
154+
155+
[Install]
156+
WantedBy=multi-user.target
157+
owner: root
158+
group: root
159+
mode: "0644"
160+
161+
- name: Create the host device node
162+
become: true
163+
ansible.builtin.systemd_service:
164+
name: openshell-cdi-device.service
165+
daemon_reload: true
166+
enabled: true
167+
state: restarted
168+
48169
- name: Generate named CDI scenario devices
49170
become: true
50171
ansible.builtin.template:
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. #}
2+
{# SPDX-License-Identifier: Apache-2.0 #}
3+
[Unit]
4+
Description=OpenShell tmachine CDI Unix socket fixture
5+
After=local-fs.target
6+
7+
[Service]
8+
# The socket is created by this process, not by PID 1, and `cat` echoes each
9+
# connection. A zero umask makes the socket connectable by any user.
10+
UMask=0000
11+
ExecStartPre=/bin/rm -f {{ cdi_scenario_dir }}/socket/echo.sock
12+
ExecStart=/usr/bin/systemd-socket-activate --listen={{ cdi_scenario_dir }}/socket/echo.sock --accept /bin/cat
13+
Restart=always
14+
15+
[Install]
16+
WantedBy=multi-user.target

‎tests/tmachine/ansible/roles/cdi_fixtures/vars/scenarios.yaml‎

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
# Fixture `path` is relative to {{ cdi_scenario_dir }}. Types:
1111
# dir directory (parents must be declared first)
1212
# file regular file with `content`
13-
# Both accept `mode`, `owner` and `group`.
13+
# fifo named pipe
14+
# All accept `mode`, `owner` and `group`.
1415
#
1516
# `expect` records the behavior the CDI feature tests assert for the scenario,
1617
# and is rendered to {{ cdi_fixture_root }}/scenarios.json for them. It states
@@ -72,3 +73,132 @@ cdi_scenarios:
7273
checks:
7374
- {type: contents, path: /usr/share/tmachine-cdi/baseline-read-only-file, expect: tmachine-cdi-baseline-read-only-file}
7475
- {type: write, path: /usr/share/tmachine-cdi/baseline-read-only-file, expect: denied}
76+
77+
- name: read-only-file
78+
fixtures:
79+
- {path: read-only-file, type: file, content: "tmachine-cdi-read-only-file\n", mode: "0644"}
80+
edits:
81+
mounts:
82+
- hostPath: "{{ cdi_scenario_dir }}/read-only-file"
83+
containerPath: /tmachine-cdi/read-only/file
84+
options: [bind, ro]
85+
expect:
86+
checks:
87+
- {type: filetype, path: /tmachine-cdi/read-only/file, expect: file}
88+
- {type: read, path: /tmachine-cdi/read-only/file, expect: denied}
89+
- {type: write, path: /tmachine-cdi/read-only/file, expect: denied}
90+
91+
- name: read-only-directory
92+
fixtures:
93+
- {path: read-only-directory, type: dir, mode: "0755"}
94+
- {path: read-only-directory/marker, type: file, content: "tmachine-cdi-read-only-directory/marker\n", mode: "0644"}
95+
edits:
96+
mounts:
97+
- hostPath: "{{ cdi_scenario_dir }}/read-only-directory"
98+
containerPath: /tmachine-cdi/read-only/directory
99+
options: [bind, ro]
100+
expect:
101+
checks:
102+
- {type: filetype, path: /tmachine-cdi/read-only/directory, expect: directory}
103+
- {type: read, path: /tmachine-cdi/read-only/directory/marker, expect: denied}
104+
- {type: write, path: /tmachine-cdi/read-only/directory/marker, expect: denied}
105+
- {type: write, path: /tmachine-cdi/read-only/directory/new-file, expect: denied}
106+
107+
# Both library cases bind the same file; they differ in whether its parent
108+
# directory is narrow or the container root.
109+
110+
- name: library-parent
111+
fixtures:
112+
- {path: libfixture.so.1, type: file, content: "tmachine-cdi-libfixture.so.1\n", mode: "0644"}
113+
edits:
114+
mounts:
115+
- hostPath: "{{ cdi_scenario_dir }}/libfixture.so.1"
116+
containerPath: /tmachine-cdi/library/libfixture.so.1
117+
options: [bind, ro]
118+
expect:
119+
checks:
120+
- {type: filetype, path: /tmachine-cdi/library/libfixture.so.1, expect: file}
121+
- {type: read, path: /tmachine-cdi/library/libfixture.so.1, expect: denied}
122+
- {type: write, path: /tmachine-cdi/library/libfixture.so.1, expect: denied}
123+
124+
- name: broad-library-parent
125+
edits:
126+
mounts:
127+
- hostPath: "{{ cdi_scenario_dir }}/libfixture.so.1"
128+
containerPath: /libtmachinefixture.so.1
129+
options: [bind, ro]
130+
expect:
131+
checks:
132+
- {type: filetype, path: /libtmachinefixture.so.1, expect: file}
133+
- {type: read, path: /libtmachinefixture.so.1, expect: denied}
134+
- {type: write, path: /libtmachinefixture.so.1, expect: denied}
135+
136+
- name: writable-file
137+
fixtures:
138+
- {path: writable-file, type: file, content: "tmachine-cdi-original\n", mode: "0666"}
139+
edits:
140+
mounts:
141+
- hostPath: "{{ cdi_scenario_dir }}/writable-file"
142+
containerPath: /tmachine-cdi/writable/file
143+
options: [bind, rw]
144+
expect:
145+
checks:
146+
- {type: filetype, path: /tmachine-cdi/writable/file, expect: file}
147+
- {type: read, path: /tmachine-cdi/writable/file, expect: denied}
148+
- {type: write, path: /tmachine-cdi/writable/file, expect: denied}
149+
150+
- name: writable-directory
151+
fixtures:
152+
- {path: writable-directory, type: dir, mode: "0777"}
153+
edits:
154+
mounts:
155+
- hostPath: "{{ cdi_scenario_dir }}/writable-directory"
156+
containerPath: /tmachine-cdi/writable/directory
157+
options: [bind, rw]
158+
expect:
159+
checks:
160+
- {type: filetype, path: /tmachine-cdi/writable/directory, expect: directory}
161+
- {type: write, path: /tmachine-cdi/writable/directory/probe, expect: denied}
162+
163+
- name: omitted-access-file
164+
edits:
165+
mounts:
166+
- hostPath: "{{ cdi_scenario_dir }}/writable-file"
167+
containerPath: /tmachine-cdi/writable/omitted-access
168+
options: [bind]
169+
expect:
170+
checks:
171+
- {type: filetype, path: /tmachine-cdi/writable/omitted-access, expect: file}
172+
- {type: read, path: /tmachine-cdi/writable/omitted-access, expect: denied}
173+
- {type: write, path: /tmachine-cdi/writable/omitted-access, expect: denied}
174+
175+
- name: device-node
176+
edits:
177+
# The host node is at the same path, created by tasks/scenarios.yaml.
178+
deviceNodes:
179+
- path: /dev/tmachine-cdi-null
180+
permissions: rw
181+
expect:
182+
checks:
183+
- {type: filetype, path: /dev/tmachine-cdi-null, expect: character-device}
184+
- {type: non-root}
185+
- {type: write, path: /dev/tmachine-cdi-null, expect: denied}
186+
187+
# The echo socket is served by the systemd service installed in scenarios.yaml.
188+
# Its sibling file checks that only the socket path is exposed.
189+
190+
- name: unix-socket
191+
fixtures:
192+
- {path: socket, type: dir, mode: "0755"}
193+
- {path: socket/sibling, type: file, content: "tmachine-cdi-socket/sibling\n", mode: "0644"}
194+
edits:
195+
mounts:
196+
- hostPath: "{{ cdi_scenario_dir }}/socket/echo.sock"
197+
containerPath: /tmachine-cdi/socket/echo.sock
198+
options: [bind]
199+
expect:
200+
checks:
201+
- {type: filetype, path: /tmachine-cdi/socket/echo.sock, expect: socket}
202+
# Only the socket is mounted, so its sibling in the host directory is
203+
# not visible. Connecting to the socket is not checked yet.
204+
- {type: exists, path: /tmachine-cdi/socket/sibling, expect: false}

0 commit comments

Comments
 (0)