Skip to content

Commit 8cdc3c4

Browse files
ericksoacvgithub-actions[bot]
authored
test(e2e): execute native runtime qualification (#9232)
## Summary Implement the canonical 24-case native Podman qualification matrix and preserve exact installer, runtime, lifecycle, recovery, GPU, and cleanup receipts for the existing B4-F consumer. Candidate execution stays credential-free and unprivileged with Docker unavailable; production Podman selection remains disabled and B4-H documentation remains out of scope. Commit `1fb09c49f9718f87570d55d90f05581d7375746c` remediated PRA-1 in code: the secret-bearing native-runtime controller runs only from trusted `main`, requires the workflow SHA to equal the PR-recorded base SHA and differ from the candidate SHA, and rejects candidate-workflow authority in both the trusted planner and the candidate row parser. Protected run `31984240689`, attempt `1`, at historical commit `504fcf718a8ece560c021c5ed4656851ef419e84` executed every case assignable to a runner already authorized for NemoClaw: 15/15 passed with zero failures, including all six AMD64/ARM64 CPU cases and all nine AMD64 RTX PRO 6000 GPU cases. The nine ARM64-GPU jobs received no runner group or runner identity because NemoClaw has no authorized non-Spark ARM64-GPU runner group. This PR does not claim 24/24 at latest PR commit `54a3c3b548d86035b752e499b227719f3eb961c2`. Repository administrator authorization ([administrator decision](#9232 (comment))) accepts deferring those nine executions and the complete aggregate receipt to mandatory pre-activation testing on the physical DGX Spark or a proper ARM64 NVIDIA-GPU runner. The [exact run receipt](#9232 (comment)) records every successful job, runner, artifact, digest, and deferred job identity. Production Podman must remain unselectable and unadvertised until that pre-activation qualification passes. This exception does not authorize merge. ## Related Issue Closes #9144 Related #7744 ## Changes - Add the live 3-agent x 2-architecture x 4-inference-path executor, including real GPU-backed turns, focused lifecycle/recovery interactions, rootful-denial coverage for the rootless-only provider, and exact cleanup. - Harden installer and per-case receipt handling against symlinks, unexpected files, credential material, content races, and candidate processes surviving into trusted evidence handling. - Bind protected host cleanup to a root-owned run/attempt marker carrying the account's exact UID/GID, reject pre-existing account or group authorization, verify subordinate-ID revocation, and grant Ubuntu user namespaces only to the pinned qualification Podman binary and the root-owned, mode-0555 run copy of its source-built `pasta` helper through ephemeral AppArmor profiles. - Start and verify the ephemeral account's systemd user manager/runtime-directory units for rootless Aardvark, restrict restarted user-unit authority to root-owned OS paths, prove the user-bus socket type/non-symlink/owner and account-side resolution, and stop both units before identity cleanup. - Build the compatible `pasta` helper from an official passt.top commit snapshot pinned by SHA and archive SHA-256, include it in the checksummed Podman toolchain, and verify the installed run copy by root ownership, mode, and matching digest. - Require native rootless overlay storage on the reviewed Ubuntu hosts, reject any external overlay mount program, and isolate Netavark to a root-owned per-run `nftables` configuration before candidate execution. - Materialize exact architecture-specific GPU probe and inference images in a runner-only preparation step, delete its registry authentication file before candidate dependencies or execution, and bind NIM/vLLM to a pinned public model revision whose exact root-owned files are revalidated by the unprivileged candidate. - Prove failed provider-network cleanup with the pinned Podman `network exists` postcondition and report the original validation error together with any unproven cleanup outcome. - Remove run-owned volume snapshots on both success and failure; rethrow the original case failure when cleanup succeeds, or aggregate it with separately collected cleanup failures when cleanup cannot be proven. - Add an aggregate validation job that rejects incomplete or mixed cohorts and feeds the exact 24-case envelope through the canonical B4-F consumer. - Align the live full-uninstall proof with the secure retirement contract by requiring both empty NemoClaw portable configuration directories to be absent. - Exercise nested YAML credential sanitization and scalar-document rejection through behavior tests so the existing credential boundary remains above its merged-coverage security floor. - Keep the Podman workload profile dormant and make no production provider registration, selection, advertising, support-documentation, NVIDIA/OpenShell, or Kubernetes/Docker behavior changes. ## Type of Change - [ ] Code change (feature, bug fix, or refactor) - [x] Code change with doc updates - [ ] Doc only (prose changes, no code sample modifications) - [ ] Doc only (includes code sample changes) ## Quality Gates - [x] Tests added or updated for changed behavior - [ ] Existing tests cover changed behavior — justification: - [ ] Tests not applicable — justification: - [x] Docs updated for user-facing behavior changes - [ ] Docs not applicable — justification: - [x] Sensitive paths changed (security, policy, credentials, preflight, onboarding, inference, runner, sandbox, or messaging) - [x] Sensitive-path review completed or maintainer-approved waiver recorded — reviewer/approval link/justification: Independent nine-category security review passed for latest PR commit `54a3c3b54`. The review confirmed that trusted-main authority, `NVIDIA_API_KEY` handling, candidate credential-free execution, cleanup, authorization, digest checks, and artifact boundaries remain unchanged or clearer. The final delta changes workflow labels, errors, guidance, and matching tests without weakening an enforcing control. All 28 review conversations remain resolved; approval is a separate gate. - [x] Non-success, skipped, or missing CI check accepted by maintainer — check name, approval link, and follow-up issue: the administrator [decision](#9232 (comment)) limits the exception to nine unavailable ARM64 GPU executions and the complete aggregate before activation. Those combinations remain mandatory on suitable ARM64 NVIDIA GPU hardware before B4-H activation. No required merge check or executed-case receipt is waived. ## Documentation Writer Review - [x] Documentation writer subagent reviewed the completed changes - Result: `docs-updated` - Evidence: Independent Pi CLI review passed for the complete 34-file diff at latest PR commit `54a3c3b54`. The guidance now states the trusted-main controller identities, exact dispatch SHA constraints, GitHub Actions repository-variable locations, `NVIDIA_API_KEY` lifetime and non-revocation, credential-free candidate execution, successful and failed case-artifact behavior, selector boundaries, retention periods, and empty-only portable configuration cleanup. Changed documentation includes `docs/manage-sandboxes/uninstall-nemoclaw.mdx`, `test/e2e/README.md`, and `test/e2e/docs/README.md`. Focused e2e-support validation passed 116 tests, `npm run docs` passed with 0 errors and 2 existing Fern warnings, and normal hooks passed. - Agent: Pi CLI <!-- docs-review-head-sha: 54a3c3b --> <!-- docs-review-agents-blob-sha: b9fb6a9 --> ## DGX Station Hardware Evidence - [ ] Tested on DGX Station - Tested commit: - Station profile/scenario: - Result: - Supporting evidence: ## Verification - [x] PR description includes a `Signed-off-by:` line and every commit appears as `Verified` in GitHub - [x] Normal `pre-commit`, `commit-msg`, and `pre-push` hooks passed, or `npm run validate:pr` passed after refreshing `origin/main` when hooks were skipped or unavailable - [x] Targeted behavior tests pass for the current change set, or tests are marked not applicable above — At latest PR commit `54a3c3b54`, focused e2e-support validation passed 116 workflow-boundary tests and `src/lib/security/credential-filter.test.ts` passed 47 tests. `npm run docs` passed, the independent security review passed all nine categories, and normal hooks passed. Earlier protected qualification evidence remains tied to its recorded historical commit. - [x] Applicable broad gate passed: all five required checks have passing or expected skipped conclusions at latest PR commit `54a3c3b548d86035b752e499b227719f3eb961c2`. Full CI run `32021563119` and codebase growth guardrail run `32021562210` passed. Protected run `31984240689` remains historical functional evidence for commit `504fcf718a8ece560c021c5ed4656851ef419e84`. - [x] Quality Gates section completed with required justifications or waivers - [x] No secrets, API keys, or credentials committed - [ ] `npm run docs` builds without warnings (doc changes only) - [x] Doc pages follow the [style guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md) (doc changes only) - [ ] New doc pages include SPDX header and frontmatter (new pages only) --- Signed-off-by: Aaron Erickson <aerickson@nvidia.com> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Protected Qualification** - Adds a 24-case rootless Podman qualification matrix covering inference, GPU validation, onboarding, lifecycle recovery, and cleanup. - Validates credential-free execution, pinned runtime components, isolated runners, and complete evidence before publishing a 30-day aggregate report. - Strengthens authorization and commit identity checks for candidate qualification runs. - **Product Boundary** - Production Podman selection remains unchanged and is not presented as a supported runtime. - **Bug Fixes** - Improves safe cleanup of portable configuration directories during uninstall and recovery. - **Tests** - Expands coverage for qualification workflows, evidence integrity, security boundaries, lifecycle behavior, and artifact aggregation. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Aaron Erickson <aerickson@nvidia.com> Co-authored-by: Carlos Villela <cvillela@nvidia.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 588bb6d commit 8cdc3c4

34 files changed

Lines changed: 7092 additions & 234 deletions

.github/workflows/e2e.yaml

Lines changed: 1363 additions & 46 deletions
Large diffs are not rendered by default.

docs/manage-sandboxes/uninstall-nemoclaw.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,8 @@ It skips the generic Docker availability probe and all generic Docker container,
180180
It does not remove the user's Podman installation, storage, networks, or unrelated containers.
181181
It does not disable a user-managed socket.
182182
On every successful portable cleanup, the final retirement operation removes the exact portable lifecycle receipts, their matching `sandboxes.json` rows, and `~/.config/nemoclaw/portable/containers.conf`, regardless of `--destroy-user-data`.
183+
After NemoClaw removes `containers.conf`, it removes `~/.config/nemoclaw/portable/` and `~/.config/nemoclaw/` only when each directory is empty.
184+
NemoClaw preserves either directory when it contains an unrelated entry.
183185
Other preserved user data follows the normal `--destroy-user-data` behavior.
184186
After NemoClaw releases the locks, later uninstall-plan cleanup never recursively revisits the canonical receipt, sandbox registry, or `~/.config/nemoclaw/portable/containers.conf` paths.
185187
This preserves any new lifecycle generation published after lock release.

scripts/checks/run-native-runtime-installer-qualification.sh

Lines changed: 64 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ verify_checkout() {
9898
*) fail "$label has an unexpected origin repository." ;;
9999
esac
100100
assert_checkout_has_no_git_credentials "$checkout" "$label"
101+
printf '%s\n' "$revision"
101102
}
102103

103104
verify_committed_file() {
@@ -191,14 +192,51 @@ assert_docker_unavailable() {
191192
[[ ! -S "$socket_path" ]] \
192193
|| fail "A Docker socket exists during the ${phase} check."
193194
done < <(docker_socket_paths)
195+
196+
printf '%s\n' \
197+
'{"dockerCommandGuarded":true,"dockerEnvironmentVariablesUnset":true,"dockerServiceInactive":true,"dockerSocketUnitInactive":true,"dockerdProcessNameAbsent":true,"defaultSocketPathsAbsent":true}'
194198
}
195199

196-
run_native_runtime_installer_qualification() {
197-
candidate_checkout=""
198-
candidate_sha=""
199-
expected_installer_sha256=""
200-
expected_architecture=""
201-
artifact_dir_input=""
200+
run_native_runtime_installer_qualification() (
201+
local candidate_checkout=""
202+
local candidate_sha=""
203+
local expected_installer_sha256=""
204+
local expected_architecture=""
205+
local artifact_dir_input=""
206+
local artifact_parent=""
207+
local artifact_name=""
208+
local artifact_dir=""
209+
local runner_architecture=""
210+
local candidate_installer=""
211+
local candidate_setup_script=""
212+
local qualification_root=""
213+
local qualification_home=""
214+
local qualification_tmp=""
215+
local docker_guard_dir=""
216+
local managed_payload_root=""
217+
local verified_script_dir=""
218+
local verified_installer=""
219+
local verified_setup_script=""
220+
local installed_checkout=""
221+
local receipt_stage=""
222+
local docker_guard=""
223+
local docker_guard_sha256=""
224+
local candidate_status=0
225+
local verified_candidate_revision=""
226+
local installed_revision=""
227+
local pre_execution_docker_posture=""
228+
local post_execution_docker_posture=""
229+
230+
cleanup() {
231+
if [[ -n "$receipt_stage" && -d "$receipt_stage" && ! -L "$receipt_stage" ]]; then
232+
rm -rf -- "$receipt_stage"
233+
fi
234+
if [[ -n "$qualification_root" && -d "$qualification_root" && ! -L "$qualification_root" ]]; then
235+
rm -rf -- "$qualification_root"
236+
fi
237+
}
238+
trap cleanup EXIT
239+
202240
while [[ "$#" -gt 0 ]]; do
203241
case "$1" in
204242
--candidate-checkout)
@@ -272,7 +310,9 @@ run_native_runtime_installer_qualification() {
272310

273311
candidate_installer="${candidate_checkout}/scripts/install.sh"
274312
candidate_setup_script="${candidate_checkout}/scripts/setup-jetson.sh"
275-
verify_checkout "$candidate_checkout" "$candidate_sha" "The candidate checkout"
313+
verified_candidate_revision="$(
314+
verify_checkout "$candidate_checkout" "$candidate_sha" "The candidate checkout"
315+
)"
276316
verify_installer \
277317
"$candidate_checkout" \
278318
"$candidate_sha" \
@@ -303,16 +343,6 @@ run_native_runtime_installer_qualification() {
303343
"$managed_payload_root" \
304344
"$verified_script_dir"
305345

306-
cleanup() {
307-
if [[ -n "${receipt_stage:-}" && -d "$receipt_stage" && ! -L "$receipt_stage" ]]; then
308-
rm -rf -- "$receipt_stage"
309-
fi
310-
if [[ -n "${qualification_root:-}" && -d "$qualification_root" && ! -L "$qualification_root" ]]; then
311-
rm -rf -- "$qualification_root"
312-
fi
313-
}
314-
trap cleanup EXIT
315-
316346
cp -- "$candidate_installer" "$verified_installer"
317347
cp -- "$candidate_setup_script" "$verified_setup_script"
318348
chmod 500 "$verified_installer" "$verified_setup_script"
@@ -336,9 +366,10 @@ run_native_runtime_installer_qualification() {
336366
PATH="${docker_guard_dir}:${PATH}"
337367
export PATH
338368

339-
assert_docker_unavailable "pre-execution" "$docker_guard" "$docker_guard_sha256"
369+
pre_execution_docker_posture="$(
370+
assert_docker_unavailable "pre-execution" "$docker_guard" "$docker_guard_sha256"
371+
)"
340372

341-
candidate_status=0
342373
# The child shell expands positional parameters inside this literal program.
343374
# shellcheck disable=SC2016
344375
env -i \
@@ -366,11 +397,15 @@ run_native_runtime_installer_qualification() {
366397
install_nemoclaw_before_onboarding
367398
' _ "$verified_installer" "$verified_script_dir" || candidate_status=$?
368399

369-
assert_docker_unavailable "post-execution" "$docker_guard" "$docker_guard_sha256"
400+
post_execution_docker_posture="$(
401+
assert_docker_unavailable "post-execution" "$docker_guard" "$docker_guard_sha256"
402+
)"
370403
[[ "$candidate_status" -eq 0 ]] \
371404
|| fail "The candidate installer phase executor exited with status ${candidate_status}."
372405

373-
verify_checkout "$installed_checkout" "$candidate_sha" "The installed checkout"
406+
installed_revision="$(
407+
verify_checkout "$installed_checkout" "$candidate_sha" "The installed checkout"
408+
)"
374409
verify_installer \
375410
"$installed_checkout" \
376411
"$candidate_sha" \
@@ -382,16 +417,16 @@ run_native_runtime_installer_qualification() {
382417
"$expected_installer_sha256" "$candidate_sha" "$runner_architecture" \
383418
>"${receipt_stage}/invocation.json"
384419
printf '{"receiptVersion":1,"repository":"%s","revision":"%s","installerSha256":"%s"}\n' \
385-
"$CANONICAL_REPOSITORY" "$candidate_sha" "$expected_installer_sha256" \
420+
"$CANONICAL_REPOSITORY" "$verified_candidate_revision" "$expected_installer_sha256" \
386421
>"${receipt_stage}/candidate-source.json"
387422
printf '{"receiptVersion":1,"repository":"%s","requestedRevision":"%s","installedRevision":"%s","installMode":"managed","installerSha256":"%s"}\n' \
388-
"$CANONICAL_REPOSITORY" "$candidate_sha" "$candidate_sha" "$expected_installer_sha256" \
423+
"$CANONICAL_REPOSITORY" "$candidate_sha" "$installed_revision" "$expected_installer_sha256" \
389424
>"${receipt_stage}/installed-source.json"
390425
printf '{"receiptVersion":1,"requested":"%s","runner":"%s"}\n' \
391426
"$expected_architecture" "$runner_architecture" \
392427
>"${receipt_stage}/architecture.json"
393-
printf '%s\n' \
394-
'{"receiptVersion":1,"preExecution":{"dockerCommandGuarded":true,"dockerEnvironmentVariablesUnset":true,"dockerServiceInactive":true,"dockerSocketUnitInactive":true,"dockerdProcessNameAbsent":true,"defaultSocketPathsAbsent":true},"postExecution":{"dockerCommandGuarded":true,"dockerEnvironmentVariablesUnset":true,"dockerServiceInactive":true,"dockerSocketUnitInactive":true,"dockerdProcessNameAbsent":true,"defaultSocketPathsAbsent":true}}' \
428+
printf '{"receiptVersion":1,"preExecution":%s,"postExecution":%s}\n' \
429+
"$pre_execution_docker_posture" "$post_execution_docker_posture" \
395430
>"${receipt_stage}/docker-absence.json"
396431

397432
bounded_file "${receipt_stage}/installer.sh" "$MAX_INSTALLER_BYTES"
@@ -409,7 +444,10 @@ run_native_runtime_installer_qualification() {
409444
receipt_stage=""
410445

411446
printf 'Native runtime installer qualification receipts: %s\n' "$artifact_dir"
412-
}
447+
cleanup
448+
trap - EXIT
449+
unset -f cleanup
450+
)
413451

414452
if [[ "${BASH_SOURCE[0]:-}" == "$0" ]]; then
415453
run_native_runtime_installer_qualification "$@"

src/lib/onboard/runtime-provider/native-qualification-authority.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
export const NATIVE_RUNTIME_QUALIFICATION_PROTECTED_REPOSITORY = "NVIDIA/NemoClaw";
1111
/** The trusted collector is separate and rejects evidence emitted by its own workflow. */
1212
export const NATIVE_RUNTIME_QUALIFICATION_PRODUCER_WORKFLOW =
13-
".github/workflows/native-runtime-qualification.yaml";
13+
".github/workflows/e2e.yaml";
1414

1515
export interface NativeRuntimeQualificationProtectedRun {
1616
readonly repository: string;

src/lib/onboard/runtime-provider/podman-preflight.test.ts

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ const INFO = JSON.stringify({
1919
arch: "amd64",
2020
os: "linux",
2121
cgroupVersion: "v2",
22+
idMappings: {
23+
uidmap: [
24+
{ container_id: 0, host_id: 1000, size: 1 },
25+
{ container_id: 1, host_id: 100000, size: 65536 },
26+
],
27+
gidmap: [
28+
{ container_id: 0, host_id: 1000, size: 1 },
29+
{ container_id: 1, host_id: 100000, size: 65536 },
30+
],
31+
},
2232
networkBackend: "netavark",
2333
security: { rootless: true },
2434
discoveredDevices: [
@@ -33,7 +43,6 @@ function engine(
3343
readonly info?: string;
3444
readonly serverVersion?: string;
3545
readonly version?: string;
36-
readonly idMap?: string;
3746
} = {},
3847
): ContainerEngine {
3948
const capture = vi.fn((args: readonly string[]) => {
@@ -55,10 +64,7 @@ function engine(
5564
});
5665
const captureHost = vi.fn((args: readonly string[]) => ({
5766
status: 0,
58-
stdout:
59-
args[0] === "--version"
60-
? (overrides.version ?? "podman version 5.6.2\n")
61-
: (overrides.idMap ?? "0 1000 1\n1 100000 65536\n"),
67+
stdout: args[0] === "--version" ? (overrides.version ?? "podman version 5.6.2\n") : "",
6268
stderr: "",
6369
}));
6470
return {
@@ -98,14 +104,7 @@ describe("Podman host preflight", () => {
98104
expect(runtime.capture).toHaveBeenCalledWith(["info", "--format", "json"], 15_000);
99105
expect(runtime.capture).toHaveBeenCalledWith(["version", "--format", "json"], 10_000);
100106
expect(runtime.captureHost).toHaveBeenCalledWith(["--version"], 10_000);
101-
expect(runtime.captureHost).toHaveBeenCalledWith(
102-
["unshare", "cat", "/proc/self/uid_map"],
103-
10_000,
104-
);
105-
expect(runtime.captureHost).toHaveBeenCalledWith(
106-
["unshare", "cat", "/proc/self/gid_map"],
107-
10_000,
108-
);
107+
expect(runtime.captureHost).toHaveBeenCalledTimes(1);
109108
});
110109

111110
it("keeps the CPU receipt server version canonical while preserving exact inference authority", () => {
@@ -196,12 +195,41 @@ describe("Podman host preflight", () => {
196195
});
197196

198197
it("rejects missing subordinate user mappings", () => {
198+
const info = JSON.stringify({
199+
...JSON.parse(INFO),
200+
host: {
201+
...JSON.parse(INFO).host,
202+
idMappings: {
203+
...JSON.parse(INFO).host.idMappings,
204+
uidmap: [{ container_id: 0, host_id: 1000, size: 1 }],
205+
},
206+
},
207+
});
208+
expect(() =>
209+
qualifyPodmanHost(engine({ info }), {
210+
platform: "linux",
211+
architecture: "x64",
212+
}),
213+
).toThrow("subordinate UID range for the API service user");
214+
});
215+
216+
it("rejects malformed API-service ID mappings", () => {
217+
const info = JSON.stringify({
218+
...JSON.parse(INFO),
219+
host: {
220+
...JSON.parse(INFO).host,
221+
idMappings: {
222+
...JSON.parse(INFO).host.idMappings,
223+
gidmap: [{ container_id: 0, host_id: 1000, size: "65536" }],
224+
},
225+
},
226+
});
199227
expect(() =>
200-
qualifyPodmanHost(engine({ idMap: "0 1000 1\n" }), {
228+
qualifyPodmanHost(engine({ info }), {
201229
platform: "linux",
202230
architecture: "x64",
203231
}),
204-
).toThrow("subordinate UID range");
232+
).toThrow("Podman API returned malformed gidmap");
205233
});
206234

207235
it("fails before commands for another engine scope or unsupported host platform", () => {

src/lib/onboard/runtime-provider/podman-preflight.ts

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -177,25 +177,42 @@ function normalizeArchitecture(value: string): "amd64" | "arm64" | null {
177177
return null;
178178
}
179179

180-
function hasSubordinateIdMapping(output: string): boolean {
181-
return output
182-
.trim()
183-
.split(/\r?\n/u)
184-
.some((line) => {
185-
const values = line.trim().split(/\s+/u).map(Number);
186-
return values.length === 3 && values.every(Number.isFinite) && (values[2] ?? 0) > 1;
187-
});
188-
}
189-
190-
function requireSubordinateIdMappings(engine: ContainerEngine): void {
191-
for (const mapping of ["uid_map", "gid_map"] as const) {
192-
const result = requireSuccessful(
193-
`${mapping} inspection`,
194-
engine.captureHost(["unshare", "cat", `/proc/self/${mapping}`], 10_000),
180+
function requireSubordinateIdMappings(host: unknown): void {
181+
// Bind this check to the same rootless API service as the rest of the
182+
// preflight. A local `podman unshare` can resolve different storage and user
183+
// authority than an explicitly bound service endpoint.
184+
const mappings = field(host, "idMappings", "IDMappings");
185+
for (const mapping of ["uidmap", "gidmap"] as const) {
186+
const entries = field(
187+
mappings,
188+
mapping,
189+
mapping === "uidmap" ? "UIDMap" : "GIDMap",
195190
);
196-
if (!hasSubordinateIdMapping(result.stdout)) {
191+
if (!Array.isArray(entries) || entries.length === 0 || entries.length > 1_024) {
192+
throw new PodmanHostPreflightError(`the Podman API returned malformed ${mapping}`);
193+
}
194+
let hasSubordinateRange = false;
195+
for (const value of entries) {
196+
const entry = record(value);
197+
const containerId = field(entry, "container_id", "containerID", "ContainerID");
198+
const hostId = field(entry, "host_id", "hostID", "HostID");
199+
const size = field(entry, "size", "Size");
200+
if (
201+
!entry ||
202+
!Number.isSafeInteger(containerId) ||
203+
!Number.isSafeInteger(hostId) ||
204+
!Number.isSafeInteger(size) ||
205+
(containerId as number) < 0 ||
206+
(hostId as number) < 0 ||
207+
(size as number) <= 0
208+
) {
209+
throw new PodmanHostPreflightError(`the Podman API returned malformed ${mapping}`);
210+
}
211+
if ((size as number) > 1) hasSubordinateRange = true;
212+
}
213+
if (!hasSubordinateRange) {
197214
throw new PodmanHostPreflightError(
198-
`rootless Podman requires a subordinate ${mapping === "uid_map" ? "UID" : "GID"} range for the current user`,
215+
`rootless Podman requires a subordinate ${mapping === "uidmap" ? "UID" : "GID"} range for the API service user`,
199216
);
200217
}
201218
}
@@ -437,7 +454,7 @@ export function qualifyPodmanHost(
437454
`the Podman service architecture '${normalizedArchitecture}' does not match host '${expectedArchitecture}'`,
438455
);
439456
}
440-
requireSubordinateIdMappings(engine);
457+
requireSubordinateIdMappings(host);
441458

442459
return Object.freeze({
443460
providerId: "podman",

src/lib/onboard/runtime-provider/podman.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,16 @@ function hostDoctorEngine(authorityId = AUTHORITY_ID): PodmanContainerEngine {
128128
arch: "amd64",
129129
os: "linux",
130130
cgroupVersion: "v2",
131+
idMappings: {
132+
uidmap: [
133+
{ container_id: 0, host_id: 1000, size: 1 },
134+
{ container_id: 1, host_id: 100000, size: 65536 },
135+
],
136+
gidmap: [
137+
{ container_id: 0, host_id: 1000, size: 1 },
138+
{ container_id: 1, host_id: 100000, size: 65536 },
139+
],
140+
},
131141
networkBackend: "netavark",
132142
security: { rootless: true },
133143
},
@@ -140,7 +150,7 @@ function hostDoctorEngine(authorityId = AUTHORITY_ID): PodmanContainerEngine {
140150
}),
141151
captureHost: vi.fn((args: readonly string[]) => ({
142152
status: 0,
143-
stdout: args[0] === "--version" ? "podman version 5.6.2\n" : "0 1000 1\n1 100000 65536\n",
153+
stdout: args[0] === "--version" ? "podman version 5.6.2\n" : "",
144154
stderr: "",
145155
})),
146156
};

0 commit comments

Comments
 (0)