Skip to content

Commit 5f9bf9c

Browse files
authored
test(e2e): run rootless podman on ubuntu host (#2119)
* test(e2e): run rootless podman on ubuntu host Signed-off-by: Evan Lezar <elezar@nvidia.com> * test(e2e): probe rootless capability behavior Signed-off-by: Evan Lezar <elezar@nvidia.com> * test(e2e): make capability probe observational Signed-off-by: Evan Lezar <elezar@nvidia.com> --------- Signed-off-by: Evan Lezar <elezar@nvidia.com>
1 parent 43bb030 commit 5f9bf9c

3 files changed

Lines changed: 229 additions & 53 deletions

File tree

.github/workflows/e2e-test.yml

Lines changed: 113 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,6 @@ jobs:
3737
- suite: rust-docker
3838
cmd: "mise run --no-deps --skip-deps e2e:rust"
3939
apt_packages: "openssh-client"
40-
- suite: rust-podman
41-
cmd: "mise run --no-deps --skip-deps e2e:podman"
42-
apt_packages: "openssh-client podman"
43-
- suite: rust-podman-rootless
44-
cmd: "mise run --no-deps --skip-deps e2e:podman:rootless"
45-
apt_packages: "openssh-client podman uidmap"
46-
rootless: true
4740
- suite: mcp
4841
cmd: "mise run --no-deps --skip-deps e2e:mcp"
4942
apt_packages: ""
@@ -89,28 +82,6 @@ jobs:
8982
- name: Log in to GHCR with Docker
9083
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
9184

92-
- name: Log in to GHCR with Podman
93-
if: startsWith(matrix.suite, 'rust-podman')
94-
run: echo "${{ secrets.GITHUB_TOKEN }}" | podman login ghcr.io -u "${{ github.actor }}" --password-stdin
95-
96-
- name: Set up rootless Podman user
97-
if: matrix.rootless
98-
run: |
99-
useradd -m openshell-test
100-
echo "openshell-test:100000:65536" >> /etc/subuid
101-
echo "openshell-test:100000:65536" >> /etc/subgid
102-
mkdir -p "/run/user/$(id -u openshell-test)"
103-
chown openshell-test: "/run/user/$(id -u openshell-test)"
104-
chmod 700 "/run/user/$(id -u openshell-test)"
105-
chown -R openshell-test: .
106-
mkdir -p /home/openshell-test/.cache/mise /home/openshell-test/.cargo /home/openshell-test/.local/state/mise
107-
chown -R openshell-test: /home/openshell-test/.cache /home/openshell-test/.cargo /home/openshell-test/.local
108-
install -m 0755 "$(command -v mise)" /usr/local/bin/mise
109-
chmod a+x /root /root/.local /root/.local/bin
110-
for dir in /root/.cargo /root/.rustup /root/.local/share/mise /opt/mise; do
111-
[ -d "$dir" ] && chmod -R a+rX "$dir"
112-
done
113-
11485
- name: Install Python dependencies and generate protobuf stubs
11586
if: matrix.suite == 'python'
11687
run: uv sync --frozen && mise run --no-deps python:proto
@@ -119,27 +90,118 @@ jobs:
11990
env:
12091
OPENSHELL_SUPERVISOR_IMAGE: ${{ format('ghcr.io/nvidia/openshell/supervisor:{0}', inputs.image-tag) }}
12192
OPENSHELL_MCP_CONFORMANCE_CLIENT_IMAGE: ${{ format('openshell-mcp-conformance-client:{0}', inputs.image-tag) }}
122-
E2E_CMD: ${{ matrix.cmd }}
93+
run: ${{ matrix.cmd }}
94+
95+
e2e-podman-rootless:
96+
name: E2E (rust-podman-rootless, ${{ matrix.runner }})
97+
# Run directly on the Ubuntu host so the test observes the host's AppArmor
98+
# and unprivileged-user-namespace policy. A privileged job container masks
99+
# the restrictions that production rootless Podman installations enforce.
100+
runs-on: ${{ matrix.runner }}
101+
timeout-minutes: 30
102+
strategy:
103+
fail-fast: false
104+
matrix:
105+
include:
106+
# Ubuntu 24.04 matches the environment reported in #2069 and ships
107+
# Podman 4.x. The probe records whether AppArmor blocks the drop.
108+
- runner: ubuntu-24.04
109+
podman_major: "4"
110+
# Ubuntu 26.04 provides the supported Podman 5.x coverage for
111+
# comparison with the Ubuntu 24.04 environment.
112+
- runner: ubuntu-26.04
113+
podman_major: "5"
114+
env:
115+
IMAGE_TAG: ${{ inputs.image-tag }}
116+
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
117+
OPENSHELL_REGISTRY: ghcr.io/nvidia/openshell
118+
OPENSHELL_REGISTRY_HOST: ghcr.io
119+
OPENSHELL_REGISTRY_NAMESPACE: nvidia/openshell
120+
OPENSHELL_REGISTRY_USERNAME: ${{ github.actor }}
121+
OPENSHELL_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
122+
OPENSHELL_SUPERVISOR_IMAGE: ${{ format('ghcr.io/nvidia/openshell/supervisor:{0}', inputs.image-tag) }}
123+
steps:
124+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
125+
with:
126+
ref: ${{ inputs['checkout-ref'] || github.sha }}
127+
persist-credentials: false
128+
129+
- name: Install mise
130+
run: |
131+
curl https://mise.run | MISE_VERSION=v2026.4.25 sh
132+
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
133+
echo "$HOME/.local/share/mise/shims" >> "$GITHUB_PATH"
134+
135+
- name: Install tools
136+
run: mise install --locked
137+
138+
- name: Install Podman and build dependencies
139+
run: |
140+
sudo apt-get update
141+
sudo apt-get install -y --no-install-recommends \
142+
build-essential \
143+
clang \
144+
fuse-overlayfs \
145+
libssl-dev \
146+
libz3-dev \
147+
openssh-client \
148+
passt \
149+
pkg-config \
150+
podman \
151+
slirp4netns \
152+
uidmap
153+
154+
- name: Configure rootless Podman
123155
run: |
124-
if [ "${{ matrix.rootless }}" = "true" ]; then
125-
TESTUID="$(id -u openshell-test)"
126-
runuser -u openshell-test -- env \
127-
XDG_RUNTIME_DIR="/run/user/${TESTUID}" \
128-
HOME="/home/openshell-test" \
129-
PATH="/usr/local/bin:/root/.cargo/bin:/opt/mise/shims:/root/.local/bin:${PATH}" \
130-
CARGO_HOME="/home/openshell-test/.cargo" \
131-
RUSTUP_HOME="/root/.rustup" \
132-
MISE_DATA_DIR="/opt/mise" \
133-
MISE_CACHE_DIR="/home/openshell-test/.cache/mise" \
134-
MISE_STATE_DIR="/home/openshell-test/.local/state/mise" \
135-
OPENSHELL_SUPERVISOR_IMAGE="${OPENSHELL_SUPERVISOR_IMAGE}" \
136-
OPENSHELL_REGISTRY="${OPENSHELL_REGISTRY}" \
137-
OPENSHELL_REGISTRY_HOST="${OPENSHELL_REGISTRY_HOST}" \
138-
OPENSHELL_REGISTRY_USERNAME="${OPENSHELL_REGISTRY_USERNAME}" \
139-
OPENSHELL_REGISTRY_PASSWORD="${OPENSHELL_REGISTRY_PASSWORD}" \
140-
IMAGE_TAG="${IMAGE_TAG}" \
141-
MISE_GITHUB_TOKEN="${MISE_GITHUB_TOKEN}" \
142-
bash -c "${E2E_CMD}"
143-
else
144-
${E2E_CMD}
156+
set -euo pipefail
157+
if ! grep -q "^${USER}:" /etc/subuid; then
158+
sudo usermod --add-subuids 100000-165535 "$USER"
145159
fi
160+
if ! grep -q "^${USER}:" /etc/subgid; then
161+
sudo usermod --add-subgids 100000-165535 "$USER"
162+
fi
163+
runtime_dir="/run/user/$(id -u)"
164+
sudo install -d -m 0700 -o "$(id -u)" -g "$(id -g)" "$runtime_dir"
165+
echo "XDG_RUNTIME_DIR=$runtime_dir" >> "$GITHUB_ENV"
166+
167+
- name: Verify rootless Podman environment
168+
run: |
169+
set -euo pipefail
170+
podman_version="$(podman version --format '{{.Client.Version}}')"
171+
case "$podman_version" in
172+
"${{ matrix.podman_major }}".*) ;;
173+
*) echo "ERROR: expected Podman ${{ matrix.podman_major }}.x, found $podman_version" >&2; exit 1 ;;
174+
esac
175+
test "$(podman info --format '{{.Host.Security.Rootless}}')" = "true"
176+
test "$(sudo sysctl -n kernel.apparmor_restrict_unprivileged_userns)" = "1"
177+
echo "=== host ==="
178+
uname -a
179+
echo "=== AppArmor ==="
180+
cat /proc/self/attr/current
181+
sudo aa-status || true
182+
echo "=== Podman ==="
183+
podman version
184+
podman info --debug
185+
186+
- name: Probe rootless capability bounding set
187+
run: |
188+
set -euo pipefail
189+
probe="$RUNNER_TEMP/openshell-capbset-probe"
190+
cc -static -O2 -Wall -Wextra -Werror \
191+
e2e/support/capbset-probe.c \
192+
-o "$probe"
193+
podman run --rm \
194+
--cap-add=SETPCAP \
195+
--volume "$probe:/openshell-capbset-probe:ro" \
196+
docker.io/library/alpine:3.22 \
197+
/openshell-capbset-probe
198+
199+
- name: Log in to GHCR with Podman
200+
run: echo "${{ secrets.GITHUB_TOKEN }}" | podman login ghcr.io -u "${{ github.actor }}" --password-stdin
201+
202+
- name: Run rootless Podman E2E
203+
run: mise run --no-deps --skip-deps e2e:podman:rootless
204+
205+
- name: Print AppArmor denials
206+
if: always()
207+
run: sudo dmesg | grep -E 'apparmor=.*DENIED|profile="unprivileged_userns"' | tail -100 || true

e2e/rust/e2e-podman-rootless.sh

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@
1010

1111
set -euo pipefail
1212

13-
if podman info --format '{{.Host.Security.Rootless}}' 2>/dev/null | grep -q false; then
14-
echo "ERROR: podman is not running rootless; this test requires rootless mode" >&2
13+
rootless="$(podman info --format '{{.Host.Security.Rootless}}' 2>/dev/null || true)"
14+
if [ "${rootless}" != "true" ]; then
15+
echo "ERROR: podman is not running rootless; expected true, got '${rootless:-<empty>}'" >&2
1516
exit 2
1617
fi
1718

e2e/support/capbset-probe.c

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Verify the capability-bounding-set condition behind issue #2069 from
5+
// inside a rootless Podman container.
6+
7+
#include <errno.h>
8+
#include <linux/capability.h>
9+
#include <stdio.h>
10+
#include <stdlib.h>
11+
#include <string.h>
12+
#include <sys/prctl.h>
13+
14+
static unsigned long long status_capability(const char *field) {
15+
FILE *status = fopen("/proc/self/status", "r");
16+
if (status == NULL) {
17+
perror("fopen(/proc/self/status)");
18+
exit(EXIT_FAILURE);
19+
}
20+
21+
char line[256];
22+
unsigned long long value = 0;
23+
int found = 0;
24+
while (fgets(line, sizeof(line), status) != NULL) {
25+
char name[32];
26+
unsigned long long candidate;
27+
if (sscanf(line, "%31[^:]:%llx", name, &candidate) == 2 &&
28+
strcmp(name, field) == 0) {
29+
value = candidate;
30+
found = 1;
31+
break;
32+
}
33+
}
34+
fclose(status);
35+
36+
if (!found) {
37+
fprintf(stderr, "missing %s in /proc/self/status\n", field);
38+
exit(EXIT_FAILURE);
39+
}
40+
return value;
41+
}
42+
43+
static void print_apparmor_profile(void) {
44+
FILE *profile = fopen("/proc/self/attr/current", "r");
45+
if (profile == NULL) {
46+
perror("fopen(/proc/self/attr/current)");
47+
return;
48+
}
49+
50+
char line[256];
51+
if (fgets(line, sizeof(line), profile) != NULL) {
52+
printf("apparmor_profile=%s", line);
53+
if (strchr(line, '\n') == NULL) {
54+
putchar('\n');
55+
}
56+
}
57+
fclose(profile);
58+
}
59+
60+
int main(int argc, char **argv) {
61+
if (argc != 1) {
62+
fprintf(stderr, "usage: %s\n", argv[0]);
63+
return EXIT_FAILURE;
64+
}
65+
66+
const unsigned long long setpcap_mask = 1ULL << CAP_SETPCAP;
67+
const unsigned long long cap_bnd_before = status_capability("CapBnd");
68+
const unsigned long long cap_eff_before = status_capability("CapEff");
69+
const int setpcap_before = prctl(PR_CAPBSET_READ, CAP_SETPCAP, 0, 0, 0);
70+
if (setpcap_before == -1) {
71+
perror("prctl(PR_CAPBSET_READ) before drop");
72+
return EXIT_FAILURE;
73+
}
74+
75+
print_apparmor_profile();
76+
printf("cap_bnd_before=%016llx\n", cap_bnd_before);
77+
printf("cap_eff_before=%016llx\n", cap_eff_before);
78+
printf("setpcap_bounding_before=%d\n", setpcap_before);
79+
80+
if (cap_bnd_before == 0 || (cap_bnd_before & setpcap_mask) == 0 ||
81+
(cap_eff_before & setpcap_mask) == 0 || setpcap_before != 1) {
82+
fprintf(stderr, "CAP_SETPCAP must be effective and present in a non-empty bounding set\n");
83+
return EXIT_FAILURE;
84+
}
85+
86+
errno = 0;
87+
const int drop_result = prctl(PR_CAPBSET_DROP, CAP_SETPCAP, 0, 0, 0);
88+
const int drop_errno = errno;
89+
const unsigned long long cap_bnd_after = status_capability("CapBnd");
90+
const int setpcap_after = prctl(PR_CAPBSET_READ, CAP_SETPCAP, 0, 0, 0);
91+
92+
printf("drop_result=%d\n", drop_result);
93+
printf("drop_errno=%d (%s)\n", drop_errno, strerror(drop_errno));
94+
printf("cap_bnd_after=%016llx\n", cap_bnd_after);
95+
printf("setpcap_bounding_after=%d\n", setpcap_after);
96+
97+
if (drop_result == 0) {
98+
if (setpcap_after != 0 || (cap_bnd_after & setpcap_mask) != 0) {
99+
fprintf(stderr, "CAP_SETPCAP remained in the bounding set after a successful drop\n");
100+
return EXIT_FAILURE;
101+
}
102+
} else if (drop_errno == EPERM) {
103+
if (setpcap_after != 1 || (cap_bnd_after & setpcap_mask) == 0) {
104+
fprintf(stderr, "CAP_SETPCAP changed in the bounding set after EPERM\n");
105+
return EXIT_FAILURE;
106+
}
107+
} else {
108+
fprintf(stderr, "unexpected PR_CAPBSET_DROP result\n");
109+
return EXIT_FAILURE;
110+
}
111+
112+
return EXIT_SUCCESS;
113+
}

0 commit comments

Comments
 (0)