mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-22 06:19:47 +08:00
test(oci): share OCI image checks across Docker and Podman
Add an oci-image feature testsuite that runs against installed artifacts. It covers custom WORKDIR placement, image content and ownership, workspace writes from the main process and exec, file transfer including directory-merge upload, OCI user identity, the managed /sandbox fallback, and rejection of an unwritable WORKDIR. CI runs the suite on the Docker rootful, Podman rootful, and Podman rootless tmachine guests. Replace the Docker-only custom_image e2e with the shared suite. Signed-off-by: Matthew Grossman <mgrossman@nvidia.com> Signed-off-by: Evan Lezar <elezar@nvidia.com>
This commit is contained in:
@@ -275,7 +275,10 @@ jobs:
|
||||
test-matrix: >-
|
||||
[
|
||||
{"environment":"fedora-podman-rootful","installer":"rpm","testsuite":"provider-refresh"},
|
||||
{"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"provider-refresh"}
|
||||
{"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"provider-refresh"},
|
||||
{"environment":"ubuntu-docker-rootful","installer":"deb","testsuite":"oci-image"},
|
||||
{"environment":"fedora-podman-rootful","installer":"rpm","testsuite":"oci-image"},
|
||||
{"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"oci-image"}
|
||||
]
|
||||
|
||||
# Run driver-specific integration tests:
|
||||
|
||||
@@ -145,7 +145,10 @@ jobs:
|
||||
test-matrix: >-
|
||||
[
|
||||
{"environment":"fedora-podman-rootful","installer":"rpm","testsuite":"provider-refresh"},
|
||||
{"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"provider-refresh"}
|
||||
{"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"provider-refresh"},
|
||||
{"environment":"ubuntu-docker-rootful","installer":"deb","testsuite":"oci-image"},
|
||||
{"environment":"fedora-podman-rootful","installer":"rpm","testsuite":"oci-image"},
|
||||
{"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"oci-image"}
|
||||
]
|
||||
|
||||
docker-e2e:
|
||||
|
||||
@@ -196,7 +196,10 @@ jobs:
|
||||
test-matrix: >-
|
||||
[
|
||||
{"environment":"fedora-podman-rootful","installer":"rpm","testsuite":"provider-refresh"},
|
||||
{"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"provider-refresh"}
|
||||
{"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"provider-refresh"},
|
||||
{"environment":"ubuntu-docker-rootful","installer":"deb","testsuite":"oci-image"},
|
||||
{"environment":"fedora-podman-rootful","installer":"rpm","testsuite":"oci-image"},
|
||||
{"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"oci-image"}
|
||||
]
|
||||
|
||||
docker-e2e:
|
||||
|
||||
+18
-1
@@ -311,6 +311,23 @@ binary. `tests/artifacts.nix` keeps the follow-up exclusions explicit and uses
|
||||
the same filter for the generated inventory, so excluded binaries cannot appear
|
||||
as false passes or silently re-enter the archive.
|
||||
|
||||
The `oci-image` feature testsuite (`tests/suites/features/oci-image`) checks
|
||||
OCI image identity and working-directory behavior shared by the Docker and
|
||||
Podman drivers against installed deb packages on Ubuntu and rpm packages on
|
||||
Fedora. CI runs it on Docker rootful, Podman rootful, and Podman rootless guests:
|
||||
|
||||
```shell
|
||||
nix run .#tmachine -- test ubuntu-docker-rootful deb oci-image
|
||||
```
|
||||
|
||||
Run it against a local gateway by naming the command that builds images into
|
||||
the gateway's image store:
|
||||
|
||||
```shell
|
||||
OPENSHELL_TEST_CONTAINER_ENGINE=podman e2e/with-podman-gateway.sh \
|
||||
cargo test --manifest-path tests/suites/features/Cargo.toml -p openshell-test-feature-oci-image -- --test-threads 1
|
||||
```
|
||||
|
||||
Run the VM-backed Rust CLI e2e suite:
|
||||
|
||||
```shell
|
||||
@@ -490,7 +507,7 @@ cargo test --manifest-path e2e/rust/Cargo.toml --features e2e --test sync
|
||||
Run a single Docker-only test directly with cargo:
|
||||
|
||||
```shell
|
||||
cargo test --manifest-path e2e/rust/Cargo.toml --features e2e-docker --test custom_image
|
||||
cargo test --manifest-path e2e/rust/Cargo.toml --features e2e-docker --test docker_preflight
|
||||
```
|
||||
|
||||
The harness (`e2e/rust/src/harness/`) provides:
|
||||
|
||||
@@ -58,11 +58,6 @@ name = "vm_overlay"
|
||||
path = "tests/vm_overlay.rs"
|
||||
required-features = ["e2e-vm"]
|
||||
|
||||
[[test]]
|
||||
name = "custom_image"
|
||||
path = "tests/custom_image.rs"
|
||||
required-features = ["e2e-docker"]
|
||||
|
||||
[[test]]
|
||||
name = "service_bearer_passthrough"
|
||||
path = "tests/service_bearer_passthrough.rs"
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
#![cfg(feature = "e2e-local-container-driver")]
|
||||
|
||||
//! E2E test: build custom container images and run sandboxes with them.
|
||||
//!
|
||||
//! Prerequisites:
|
||||
//! - A running Docker- or Podman-backed openshell gateway
|
||||
//! - The matching container runtime running (for image builds)
|
||||
//! - The `openshell` binary (built automatically from the workspace)
|
||||
|
||||
use std::{fs, io::Write};
|
||||
|
||||
use openshell_e2e::harness::container::ImageGuard;
|
||||
use openshell_e2e::harness::output::strip_ansi;
|
||||
use openshell_e2e::harness::sandbox::SandboxGuard;
|
||||
use serial_test::serial;
|
||||
|
||||
const DOCKERFILE_CONTENT: &str = r#"FROM public.ecr.aws/docker/library/python:3.13-slim
|
||||
|
||||
# iproute2 is required for sandbox network namespace isolation.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends iproute2 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN groupadd -g 1235 appstaff && \
|
||||
useradd -m -u 1234 -g appstaff app
|
||||
|
||||
# The final image identity already owns the OCI working directory. Existing
|
||||
# root-owned content remains root-owned.
|
||||
WORKDIR /workspace/project
|
||||
RUN printf root-owned > root-owned.txt && chown app:appstaff .
|
||||
|
||||
# Write a marker file so we can verify this is our custom image.
|
||||
# Place under /etc (Landlock baseline read-only path) so the sandbox
|
||||
# can read it when filesystem restrictions are properly enforced.
|
||||
RUN echo "custom-image-e2e-marker" > /etc/marker.txt
|
||||
|
||||
USER app
|
||||
CMD ["sleep", "infinity"]
|
||||
"#;
|
||||
|
||||
const NUMERIC_DOCKERFILE_CONTENT: &str = r#"FROM public.ecr.aws/docker/library/python:3.13-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends iproute2 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
USER 2345:2346
|
||||
CMD ["sleep", "infinity"]
|
||||
"#;
|
||||
|
||||
const UNWRITABLE_WORKDIR_DOCKERFILE_CONTENT: &str = r#"FROM public.ecr.aws/docker/library/python:3.13-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends iproute2 \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& groupadd -g 3235 appstaff \
|
||||
&& useradd -m -u 3234 -g appstaff app
|
||||
|
||||
WORKDIR /workspace/project
|
||||
USER app
|
||||
CMD ["sleep", "infinity"]
|
||||
"#;
|
||||
|
||||
const MARKER: &str = "custom-image-e2e-marker";
|
||||
|
||||
/// A named OCI user can write through direct and SSH children when the image
|
||||
/// already grants that authority; existing content retains its ownership.
|
||||
#[tokio::test]
|
||||
#[serial(custom_image)]
|
||||
async fn sandbox_from_custom_image() {
|
||||
// Step 1: Write a temporary Dockerfile.
|
||||
let tmpdir = tempfile::tempdir().expect("create tmpdir");
|
||||
let dockerfile_path = tmpdir.path().join("Dockerfile");
|
||||
{
|
||||
let mut f = std::fs::File::create(&dockerfile_path).expect("create Dockerfile");
|
||||
f.write_all(DOCKERFILE_CONTENT.as_bytes())
|
||||
.expect("write Dockerfile");
|
||||
}
|
||||
|
||||
// Step 2: Build the image out-of-band and create a sandbox from it.
|
||||
// `--from` no longer builds local Dockerfiles itself (pre-0.1.0
|
||||
// breaking change); tests build explicitly and pass the resulting tag.
|
||||
let image = ImageGuard::build("custom-dockerfile", &dockerfile_path, tmpdir.path())
|
||||
.expect("build custom image with selected container engine");
|
||||
let mut guard = SandboxGuard::create_keep_with_args(
|
||||
&["--from", image.tag(), "--no-tty"],
|
||||
&[
|
||||
"sh",
|
||||
"-c",
|
||||
"set -eu; id -u; id -g; test \"$(pwd -P)\" = /workspace/project; \
|
||||
test \"$HOME\" = /workspace/project; test \"$(cat root-owned.txt)\" = root-owned; \
|
||||
test \"$(stat -c %u:%g .)\" = 1234:1235; \
|
||||
test \"$(stat -c %u:%g root-owned.txt)\" = 0:0; \
|
||||
touch direct-oci-user-write; cat /etc/marker.txt; echo Ready; sleep infinity",
|
||||
],
|
||||
"Ready",
|
||||
)
|
||||
.await
|
||||
.expect("sandbox create from custom image");
|
||||
|
||||
// Step 3: Verify the marker file content appears in the output.
|
||||
let clean_output = strip_ansi(&guard.create_output);
|
||||
assert!(
|
||||
clean_output.contains(MARKER),
|
||||
"expected marker '{MARKER}' in sandbox output:\n{clean_output}"
|
||||
);
|
||||
assert!(
|
||||
clean_output.contains("1234") && clean_output.contains("1235"),
|
||||
"expected named OCI identity 1234:1235 in sandbox output:\n{clean_output}"
|
||||
);
|
||||
|
||||
let ssh_output = guard
|
||||
.exec(&[
|
||||
"sh",
|
||||
"-c",
|
||||
"set -eu; test \"$(id -u):$(id -g)\" = 1234:1235; \
|
||||
test \"$(pwd -P)\" = /workspace/project; test \"$HOME\" = /workspace/project; \
|
||||
touch ssh-oci-user-write; echo ssh-write-ok",
|
||||
])
|
||||
.await
|
||||
.expect("SSH child should write to prepared workspace");
|
||||
assert!(
|
||||
ssh_output.contains("ssh-write-ok"),
|
||||
"expected SSH write marker:\n{ssh_output}"
|
||||
);
|
||||
|
||||
let transfer_source = tmpdir.path().join("workspace-transfer.txt");
|
||||
fs::write(&transfer_source, "workspace-transfer-ok").expect("write transfer fixture");
|
||||
guard
|
||||
.upload_to_workdir(
|
||||
transfer_source
|
||||
.to_str()
|
||||
.expect("transfer fixture path is UTF-8"),
|
||||
)
|
||||
.await
|
||||
.expect("upload should default to the OCI workspace");
|
||||
let transfer_download = tmpdir.path().join("workspace-transfer-downloaded.txt");
|
||||
guard
|
||||
.download(
|
||||
"workspace-transfer.txt",
|
||||
transfer_download
|
||||
.to_str()
|
||||
.expect("download destination path is UTF-8"),
|
||||
)
|
||||
.await
|
||||
.expect("download should resolve relative to the OCI workspace");
|
||||
assert_eq!(
|
||||
fs::read_to_string(transfer_download).expect("read downloaded transfer fixture"),
|
||||
"workspace-transfer-ok"
|
||||
);
|
||||
|
||||
guard
|
||||
.exec(&[
|
||||
"sh",
|
||||
"-c",
|
||||
"set -eu; mkdir -p merge-upload; \
|
||||
printf remote-conflict > merge-upload/conflict.txt; \
|
||||
printf remote-preserved > merge-upload/unrelated.txt",
|
||||
])
|
||||
.await
|
||||
.expect("seed existing remote upload directory");
|
||||
let merge_source = tmpdir.path().join("merge-upload");
|
||||
fs::create_dir(&merge_source).expect("create local upload directory");
|
||||
fs::write(merge_source.join("conflict.txt"), "local-conflict")
|
||||
.expect("write conflicting local upload file");
|
||||
fs::write(merge_source.join("added.txt"), "local-added")
|
||||
.expect("write added local upload file");
|
||||
guard
|
||||
.upload_to_workdir(merge_source.to_str().expect("merge upload path is UTF-8"))
|
||||
.await
|
||||
.expect("upload should merge into the existing remote directory");
|
||||
guard
|
||||
.exec(&[
|
||||
"sh",
|
||||
"-c",
|
||||
"set -eu; \
|
||||
test \"$(cat merge-upload/conflict.txt)\" = local-conflict; \
|
||||
test \"$(cat merge-upload/added.txt)\" = local-added; \
|
||||
test \"$(cat merge-upload/unrelated.txt)\" = remote-preserved",
|
||||
])
|
||||
.await
|
||||
.expect("upload should overwrite conflicts and preserve unrelated remote files");
|
||||
|
||||
// Explicit cleanup (also happens in Drop, but explicit is clearer in tests).
|
||||
guard.cleanup().await;
|
||||
}
|
||||
|
||||
/// A numeric OCI user/group pair works without passwd or group entries.
|
||||
/// The image intentionally has no pre-existing `/sandbox`.
|
||||
#[tokio::test]
|
||||
#[serial(custom_image)]
|
||||
async fn sandbox_from_passwd_less_numeric_oci_user() {
|
||||
let tmpdir = tempfile::tempdir().expect("create tmpdir");
|
||||
let dockerfile_path = tmpdir.path().join("Dockerfile");
|
||||
{
|
||||
let mut f = std::fs::File::create(&dockerfile_path).expect("create Dockerfile");
|
||||
f.write_all(NUMERIC_DOCKERFILE_CONTENT.as_bytes())
|
||||
.expect("write Dockerfile");
|
||||
}
|
||||
|
||||
let image = ImageGuard::build("passwd-less-numeric", &dockerfile_path, tmpdir.path())
|
||||
.expect("build numeric OCI image with selected container engine");
|
||||
let mut guard = SandboxGuard::create(&[
|
||||
"--from",
|
||||
image.tag(),
|
||||
"--",
|
||||
"sh",
|
||||
"-c",
|
||||
"set -eu; id -u; id -g; test \"$(pwd -P)\" = /sandbox; \
|
||||
test \"$HOME\" = /sandbox; touch numeric-oci-user-write",
|
||||
])
|
||||
.await
|
||||
.expect("sandbox create from numeric OCI Dockerfile");
|
||||
|
||||
let clean_output = strip_ansi(&guard.create_output);
|
||||
assert!(
|
||||
clean_output.contains("2345") && clean_output.contains("2346"),
|
||||
"expected numeric OCI identity 2345:2346 in sandbox output:\n{clean_output}"
|
||||
);
|
||||
|
||||
guard.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial(custom_image)]
|
||||
async fn sandbox_rejects_image_workdir_that_would_require_new_authority() {
|
||||
let tmpdir = tempfile::tempdir().expect("create tmpdir");
|
||||
let dockerfile_path = tmpdir.path().join("Dockerfile");
|
||||
fs::write(&dockerfile_path, UNWRITABLE_WORKDIR_DOCKERFILE_CONTENT).expect("write Dockerfile");
|
||||
let image = ImageGuard::build("unwritable-workdir", &dockerfile_path, tmpdir.path())
|
||||
.expect("build unwritable-workdir image with selected container engine");
|
||||
|
||||
let result = SandboxGuard::create_keep_with_args(
|
||||
&["--from", image.tag(), "--no-tty"],
|
||||
&["sh", "-c", "echo should-not-run"],
|
||||
"should-not-run",
|
||||
)
|
||||
.await;
|
||||
let error = match result {
|
||||
Ok(mut guard) => {
|
||||
guard.cleanup().await;
|
||||
panic!("root-owned workdir must not be made writable for the image user");
|
||||
}
|
||||
Err(error) => error,
|
||||
};
|
||||
let message = error.to_string();
|
||||
assert!(
|
||||
(message.contains("WorkspaceValidationFailed") && message.contains("WorkingDir"))
|
||||
|| message.contains("subsystem request failed")
|
||||
|| message.contains("image workspace validation failed"),
|
||||
"expected rejected image to fail provisioning, got: {message}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
---
|
||||
- name: Run OCI image feature tests
|
||||
hosts: all
|
||||
gather_facts: false
|
||||
vars:
|
||||
oci_image_test_root: /var/lib/openshell-oci-image/tests
|
||||
tasks:
|
||||
- name: Wait for SSH
|
||||
ansible.builtin.wait_for_connection:
|
||||
|
||||
- name: Detect tmachine container runtime
|
||||
ansible.builtin.include_role:
|
||||
name: tmachine_container_runtime
|
||||
|
||||
# Tests build images into the store the gateway reads. Rootful Podman's
|
||||
# store belongs to root, so the unprivileged test user builds through sudo.
|
||||
- name: Select the gateway's container engine command
|
||||
ansible.builtin.set_fact:
|
||||
oci_image_container_engine: >-
|
||||
{{ 'docker' if tmachine_container_runtime_name == 'docker'
|
||||
else 'podman' if tmachine_container_runtime_is_rootless
|
||||
else 'sudo -n podman' }}
|
||||
|
||||
- name: Create OCI image test directory
|
||||
become: true
|
||||
ansible.builtin.file:
|
||||
path: "{{ oci_image_test_root }}"
|
||||
state: directory
|
||||
owner: tmachine
|
||||
group: tmachine
|
||||
mode: "0700"
|
||||
|
||||
- name: Extract OCI image test bundle
|
||||
become: true
|
||||
ansible.builtin.unarchive:
|
||||
src: "{{ oci_image_test_bundle }}"
|
||||
dest: "{{ oci_image_test_root }}"
|
||||
owner: tmachine
|
||||
group: tmachine
|
||||
|
||||
- name: Check OCI image nextest archive
|
||||
ansible.builtin.stat:
|
||||
path: "{{ oci_image_test_root }}/tests.tar.zst"
|
||||
register: oci_image_archive
|
||||
|
||||
- name: Require OCI image nextest archive
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- oci_image_archive.stat.isreg | default(false)
|
||||
fail_msg: OCI image test bundle did not contain tests.tar.zst
|
||||
|
||||
- name: Resolve installed OpenShell CLI
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- command -v openshell
|
||||
register: openshell_cli
|
||||
changed_when: false
|
||||
|
||||
- name: Run OCI image archive
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- cargo-nextest
|
||||
- nextest
|
||||
- run
|
||||
- --archive-file
|
||||
- "{{ oci_image_test_root }}/tests.tar.zst"
|
||||
- --workspace-remap
|
||||
- "{{ oci_image_test_root }}"
|
||||
- --no-capture
|
||||
# The guest has 4 GiB; keep sandbox creates from competing for it.
|
||||
- --test-threads
|
||||
- "1"
|
||||
- --no-fail-fast
|
||||
environment:
|
||||
HOME: /home/tmachine
|
||||
OPENSHELL_BIN: "{{ openshell_cli.stdout }}"
|
||||
OPENSHELL_TEST_CONTAINER_ENGINE: "{{ oci_image_container_engine }}"
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ tmachine_container_runtime_tmachine_uid.stdout }}"
|
||||
register: oci_image_result
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Show OCI image diagnostics
|
||||
ansible.builtin.debug:
|
||||
var: oci_image_result
|
||||
when: oci_image_result.rc != 0
|
||||
|
||||
- name: Read OpenShell gateway logs
|
||||
become: true
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- journalctl
|
||||
- --no-pager
|
||||
- --lines
|
||||
- "500"
|
||||
- _SYSTEMD_UNIT=openshell-gateway.service
|
||||
- "+"
|
||||
- _SYSTEMD_USER_UNIT=openshell-gateway.service
|
||||
register: openshell_gateway_logs
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when: oci_image_result.rc != 0
|
||||
|
||||
- name: Show OpenShell gateway logs
|
||||
ansible.builtin.debug:
|
||||
var: openshell_gateway_logs.stdout_lines
|
||||
when: oci_image_result.rc != 0
|
||||
|
||||
- name: Require OCI image success
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- oci_image_result.rc == 0
|
||||
fail_msg: OCI image feature tests failed
|
||||
@@ -95,6 +95,14 @@ let
|
||||
target = muslToolchain.target;
|
||||
output = "artifacts/test-archives/${muslToolchain.target}/provider-refresh-keycloak-tests.tar";
|
||||
};
|
||||
ociImageArchive = mkTestArchive {
|
||||
name = "oci-image";
|
||||
workspacePath = "tests/suites/features";
|
||||
manifestPath = "tests/suites/features/Cargo.toml";
|
||||
package = "openshell-test-feature-oci-image";
|
||||
target = muslToolchain.target;
|
||||
output = "artifacts/test-archives/${muslToolchain.target}/oci-image-tests.tar";
|
||||
};
|
||||
|
||||
# Follow-up: migrate these wrapper-coupled tests once tmachine provides their
|
||||
# managed-gateway controls, SPIFFE fixtures, caller driver-config setting,
|
||||
@@ -198,6 +206,7 @@ rec {
|
||||
inherit
|
||||
conformanceCliArchive
|
||||
providerRefreshKeycloakArchive
|
||||
ociImageArchive
|
||||
podmanDriverArchive
|
||||
podmanE2eArchive
|
||||
podmanE2eCiTests
|
||||
@@ -246,12 +255,14 @@ rec {
|
||||
runtimeInputs = [
|
||||
conformanceCliArchive
|
||||
providerRefreshKeycloakArchive
|
||||
ociImageArchive
|
||||
podmanDriverArchive
|
||||
podmanE2eArchive
|
||||
];
|
||||
text = ''
|
||||
build-openshell-conformance-test-archive
|
||||
build-provider-refresh-keycloak-test-archive
|
||||
build-oci-image-test-archive
|
||||
build-podman-driver-test-archive
|
||||
build-podman-e2e-test-archive
|
||||
'';
|
||||
|
||||
@@ -168,6 +168,13 @@ let
|
||||
provider_refresh_keycloak_test_bundle = "../artifacts/test-archives/${muslTarget}/provider-refresh-keycloak-tests.tar";
|
||||
};
|
||||
}
|
||||
{
|
||||
name = "oci-image";
|
||||
playbooks = [ "ansible/playbooks/features/oci-image.yaml" ];
|
||||
inputs = {
|
||||
oci_image_test_bundle = "../artifacts/test-archives/${muslTarget}/oci-image-tests.tar";
|
||||
};
|
||||
}
|
||||
{
|
||||
name = "e2e-podman";
|
||||
playbooks = [ "ansible/playbooks/drivers/podman/e2e.yaml" ];
|
||||
|
||||
Generated
+9
@@ -919,6 +919,15 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openshell-test-feature-oci-image"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"openshell-conformance",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openshell-test-feature-provider-refresh-keycloak"
|
||||
version = "0.0.0"
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["provider-refresh/keycloak"]
|
||||
members = ["oci-image", "provider-refresh/keycloak"]
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
[package]
|
||||
name = "openshell-test-feature-oci-image"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
openshell-conformance = { path = "../../../../crates/openshell-conformance" }
|
||||
tempfile = "3"
|
||||
tokio = { version = "1.43", features = ["macros", "rt"] }
|
||||
@@ -0,0 +1,386 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! OCI image behavior shared by the Docker and Podman compute drivers.
|
||||
//!
|
||||
//! Each test builds a small image with the container engine that backs the
|
||||
//! target gateway, creates a sandbox from it through the candidate CLI, and
|
||||
//! checks the process identity, workspace, and image content seen by both the
|
||||
//! sandbox main process and `sandbox exec`.
|
||||
//!
|
||||
//! `OPENSHELL_BIN` names the candidate CLI. `OPENSHELL_TEST_CONTAINER_ENGINE`
|
||||
//! is the command that builds images into the gateway's image store, such as
|
||||
//! `docker`, `podman`, or `sudo -n podman`.
|
||||
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use openshell_conformance::OpenShellRunner;
|
||||
|
||||
const BASE_IMAGE: &str = "nvcr.io/nvidia/base/ubuntu:24.04";
|
||||
const ENGINE_ENV: &str = "OPENSHELL_TEST_CONTAINER_ENGINE";
|
||||
const CREATE_TIMEOUT: Duration = Duration::from_mins(10);
|
||||
const COMMAND_TIMEOUT: Duration = Duration::from_mins(2);
|
||||
|
||||
/// A named image user that owns its custom `WORKDIR`. Existing image content
|
||||
/// keeps its ownership.
|
||||
#[tokio::test]
|
||||
async fn custom_workdir_with_named_user() {
|
||||
run("oci-image/custom-workdir-named-user", async |runner| {
|
||||
let image = TestImage::build(
|
||||
"named-workdir",
|
||||
&format!(
|
||||
"FROM {BASE_IMAGE}
|
||||
RUN groupadd -g 1235 appstaff && useradd -m -u 1234 -g appstaff app
|
||||
WORKDIR /workspace/project
|
||||
RUN printf root-owned > root-owned.txt && chown app:appstaff .
|
||||
USER app
|
||||
"
|
||||
),
|
||||
)?;
|
||||
let checks = format!(
|
||||
"{} test \"$(stat -c %u:%g .)\" = 1234:1235;",
|
||||
workspace_checks("1234:1235", "/workspace/project", true)
|
||||
);
|
||||
let sandbox = create_sandbox(runner, "named", "nu", &image, &checks).await?;
|
||||
file_transfer_uses_workspace(runner, &sandbox).await
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// A numeric image user without passwd entries can reach a custom `WORKDIR`
|
||||
/// whose parent directories are private to that user.
|
||||
#[tokio::test]
|
||||
async fn custom_workdir_with_numeric_user_and_private_parents() {
|
||||
run("oci-image/custom-workdir-numeric-user", async |runner| {
|
||||
let image = TestImage::build(
|
||||
"numeric-workdir",
|
||||
&format!(
|
||||
"FROM {BASE_IMAGE}
|
||||
RUN mkdir -p /home/app/project && \\
|
||||
chown 2345:2346 /home/app /home/app/project && \\
|
||||
chmod 0700 /home/app /home/app/project
|
||||
WORKDIR /home/app/project
|
||||
RUN printf root-owned > root-owned.txt
|
||||
USER 2345:2346
|
||||
"
|
||||
),
|
||||
)?;
|
||||
let checks = workspace_checks("2345:2346", "/home/app/project", true);
|
||||
create_sandbox(runner, "numeric", "pu", &image, &checks)
|
||||
.await
|
||||
.map(drop)
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// An image without a `WORKDIR` uses the managed `/sandbox` workspace, owned
|
||||
/// by the image user, even when the image does not contain `/sandbox`.
|
||||
#[tokio::test]
|
||||
async fn default_workdir_uses_managed_workspace() {
|
||||
run("oci-image/default-workdir", async |runner| {
|
||||
let image = TestImage::build(
|
||||
"default-workdir",
|
||||
&format!("FROM {BASE_IMAGE}\nUSER 2345:2346\n"),
|
||||
)?;
|
||||
let checks = workspace_checks("2345:2346", "/sandbox", false);
|
||||
create_sandbox(runner, "default", "dw", &image, &checks)
|
||||
.await
|
||||
.map(drop)
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// OpenShell rejects a custom `WORKDIR` that the image user cannot write
|
||||
/// instead of granting the user new access to it.
|
||||
#[tokio::test]
|
||||
async fn unwritable_custom_workdir_is_rejected() {
|
||||
run("oci-image/unwritable-workdir", async |runner| {
|
||||
let image = TestImage::build(
|
||||
"unwritable-workdir",
|
||||
&format!(
|
||||
"FROM {BASE_IMAGE}
|
||||
RUN groupadd -g 3235 appstaff && useradd -m -u 3234 -g appstaff app
|
||||
WORKDIR /workspace/project
|
||||
USER app
|
||||
"
|
||||
),
|
||||
)?;
|
||||
let name = format!("oi-{}-uw", runner.id());
|
||||
runner.track_sandbox(&name);
|
||||
let create = runner
|
||||
.step("unwritable/create")
|
||||
.description("sandbox creation fails before the command runs")
|
||||
.with_timeout(CREATE_TIMEOUT)
|
||||
.run(&[
|
||||
"sandbox",
|
||||
"create",
|
||||
"--name",
|
||||
&name,
|
||||
"--from",
|
||||
&image.tag,
|
||||
"--no-tty",
|
||||
"--",
|
||||
"sh",
|
||||
"-c",
|
||||
"echo should-not-run",
|
||||
])
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
if create.success() || create.stdout().contains("should-not-run") {
|
||||
return Err(create.failure_diagnostic("sandbox creation fails before the command runs"));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn run(scenario: &str, test: impl AsyncFnOnce(&mut OpenShellRunner) -> Result<(), String>) {
|
||||
let mut runner =
|
||||
OpenShellRunner::from_env(scenario).expect("candidate openshell CLI is available");
|
||||
let result = async {
|
||||
runner.check_gateway_status().await?;
|
||||
test(&mut runner).await
|
||||
}
|
||||
.await;
|
||||
if let Err(error) = runner.finish(result).await {
|
||||
panic!("{scenario} failed:\n{error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Shell checks for the identity, working directory, and `HOME` of a sandbox
|
||||
/// child. With `image_file`, also check that root-owned image content is
|
||||
/// present and unchanged in the workspace.
|
||||
fn workspace_checks(identity: &str, workspace: &str, image_file: bool) -> String {
|
||||
let mut checks = format!(
|
||||
"test \"$(id -u):$(id -g)\" = {identity}; \
|
||||
test \"$(pwd -P)\" = {workspace}; \
|
||||
test \"$HOME\" = {workspace};"
|
||||
);
|
||||
if image_file {
|
||||
checks.push_str(
|
||||
" test \"$(cat root-owned.txt)\" = root-owned; \
|
||||
test \"$(stat -c %u:%g root-owned.txt)\" = 0:0;",
|
||||
);
|
||||
}
|
||||
checks
|
||||
}
|
||||
|
||||
/// Create a detached sandbox whose main process runs `checks` and writes to
|
||||
/// the workspace, then run the same checks and a write through `sandbox exec`.
|
||||
async fn create_sandbox(
|
||||
runner: &mut OpenShellRunner,
|
||||
suffix: &str,
|
||||
short: &str,
|
||||
image: &TestImage,
|
||||
checks: &str,
|
||||
) -> Result<String, String> {
|
||||
// Sandbox names are limited to 19 characters on some drivers.
|
||||
let name = format!("oi-{}-{short}", runner.id());
|
||||
runner.track_sandbox(&name);
|
||||
let main = format!(
|
||||
"(set -eu; {checks} touch main-write) >/tmp/oci-main.log 2>&1; \
|
||||
echo $? >/tmp/oci-main.status; exec sleep infinity"
|
||||
);
|
||||
runner
|
||||
.step(format!("{suffix}/create"))
|
||||
.description("sandbox starts from the test image")
|
||||
.with_timeout(CREATE_TIMEOUT)
|
||||
.run(&[
|
||||
"sandbox", "create", "--name", &name, "--from", &image.tag, "--detach", "--", "sh",
|
||||
"-c", &main,
|
||||
])
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
.require_success()?;
|
||||
|
||||
let exec = format!(
|
||||
"set -eu; i=0; \
|
||||
while [ ! -f /tmp/oci-main.status ]; do \
|
||||
i=$((i + 1)); [ \"$i\" -le 60 ] || {{ echo main process checks did not finish >&2; exit 1; }}; \
|
||||
sleep 1; \
|
||||
done; \
|
||||
if [ \"$(cat /tmp/oci-main.status)\" != 0 ]; then \
|
||||
echo main process checks failed: >&2; cat /tmp/oci-main.log >&2; exit 1; \
|
||||
fi; \
|
||||
test -f main-write; {checks} touch exec-write"
|
||||
);
|
||||
runner
|
||||
.step(format!("{suffix}/exec"))
|
||||
.description("main process and exec children see the image workspace")
|
||||
.with_timeout(COMMAND_TIMEOUT)
|
||||
.run(&[
|
||||
"sandbox", "exec", "--name", &name, "--no-tty", "--", "sh", "-c", &exec,
|
||||
])
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
.require_success()?;
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// Upload and download default to paths relative to the image workspace.
|
||||
async fn file_transfer_uses_workspace(
|
||||
runner: &OpenShellRunner,
|
||||
sandbox: &str,
|
||||
) -> Result<(), String> {
|
||||
let local = tempfile::tempdir().map_err(|error| format!("create temp dir: {error}"))?;
|
||||
let upload = local.path().join("oci-transfer.txt");
|
||||
std::fs::write(&upload, "oci-transfer-ok").map_err(|error| format!("write upload: {error}"))?;
|
||||
let upload = upload.to_str().ok_or("upload path is not UTF-8")?;
|
||||
runner
|
||||
.step("named/upload")
|
||||
.description("upload without a destination writes to the workspace")
|
||||
.with_timeout(COMMAND_TIMEOUT)
|
||||
.run(&["sandbox", "upload", sandbox, upload, "--no-git-ignore"])
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
.require_success()?;
|
||||
runner
|
||||
.step("named/uploaded")
|
||||
.description("uploaded file is in the workspace")
|
||||
.with_timeout(COMMAND_TIMEOUT)
|
||||
.run(&[
|
||||
"sandbox",
|
||||
"exec",
|
||||
"--name",
|
||||
sandbox,
|
||||
"--no-tty",
|
||||
"--",
|
||||
"sh",
|
||||
"-c",
|
||||
"test \"$(cat oci-transfer.txt)\" = oci-transfer-ok",
|
||||
])
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
.require_success()?;
|
||||
|
||||
let download = local.path().join("downloaded.txt");
|
||||
let download_path = download.to_str().ok_or("download path is not UTF-8")?;
|
||||
runner
|
||||
.step("named/download")
|
||||
.description("download resolves relative paths in the workspace")
|
||||
.with_timeout(COMMAND_TIMEOUT)
|
||||
.run(&[
|
||||
"sandbox",
|
||||
"download",
|
||||
sandbox,
|
||||
"oci-transfer.txt",
|
||||
download_path,
|
||||
])
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
.require_success()?;
|
||||
let downloaded =
|
||||
std::fs::read_to_string(&download).map_err(|error| format!("read download: {error}"))?;
|
||||
if downloaded != "oci-transfer-ok" {
|
||||
return Err(format!(
|
||||
"downloaded file has unexpected content: {downloaded:?}"
|
||||
));
|
||||
}
|
||||
|
||||
// A directory upload merges into an existing workspace directory.
|
||||
let merge = local.path().join("merge-upload");
|
||||
std::fs::create_dir(&merge).map_err(|error| format!("create merge dir: {error}"))?;
|
||||
std::fs::write(merge.join("conflict.txt"), "local-conflict")
|
||||
.and_then(|()| std::fs::write(merge.join("added.txt"), "local-added"))
|
||||
.map_err(|error| format!("write merge files: {error}"))?;
|
||||
let merge = merge.to_str().ok_or("merge path is not UTF-8")?;
|
||||
let seed = "mkdir merge-upload && printf remote-conflict > merge-upload/conflict.txt \
|
||||
&& printf remote-preserved > merge-upload/unrelated.txt";
|
||||
let verify = "test \"$(cat merge-upload/conflict.txt)\" = local-conflict \
|
||||
&& test \"$(cat merge-upload/added.txt)\" = local-added \
|
||||
&& test \"$(cat merge-upload/unrelated.txt)\" = remote-preserved";
|
||||
for (step, description, args) in [
|
||||
(
|
||||
"named/merge-seed",
|
||||
"seed an existing workspace directory",
|
||||
[
|
||||
"sandbox", "exec", "--name", sandbox, "--no-tty", "--", "sh", "-c", seed,
|
||||
]
|
||||
.as_slice(),
|
||||
),
|
||||
(
|
||||
"named/merge-upload",
|
||||
"directory upload merges into the existing directory",
|
||||
["sandbox", "upload", sandbox, merge, "--no-git-ignore"].as_slice(),
|
||||
),
|
||||
(
|
||||
"named/merged",
|
||||
"upload overwrites conflicts and keeps unrelated files",
|
||||
[
|
||||
"sandbox", "exec", "--name", sandbox, "--no-tty", "--", "sh", "-c", verify,
|
||||
]
|
||||
.as_slice(),
|
||||
),
|
||||
] {
|
||||
runner
|
||||
.step(step)
|
||||
.description(description)
|
||||
.with_timeout(COMMAND_TIMEOUT)
|
||||
.run(args)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
.require_success()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An image built into the gateway's image store and removed on drop.
|
||||
struct TestImage {
|
||||
engine: Vec<String>,
|
||||
tag: String,
|
||||
}
|
||||
|
||||
impl TestImage {
|
||||
fn build(name: &str, containerfile: &str) -> Result<Self, String> {
|
||||
let engine: Vec<String> = std::env::var(ENGINE_ENV)
|
||||
.map_err(|_| format!("{ENGINE_ENV} must name the gateway's container engine"))?
|
||||
.split_whitespace()
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
if engine.is_empty() {
|
||||
return Err(format!("{ENGINE_ENV} is empty"));
|
||||
}
|
||||
let context = tempfile::tempdir().map_err(|error| format!("create context: {error}"))?;
|
||||
let file = context.path().join("Containerfile");
|
||||
std::fs::write(&file, containerfile)
|
||||
.map_err(|error| format!("write Containerfile: {error}"))?;
|
||||
let image = Self {
|
||||
engine,
|
||||
tag: format!("localhost/openshell-test-oci-{name}:{}", std::process::id()),
|
||||
};
|
||||
image.engine_command(&[
|
||||
"build",
|
||||
"--file",
|
||||
file.to_str().ok_or("Containerfile path is not UTF-8")?,
|
||||
"--tag",
|
||||
&image.tag,
|
||||
context.path().to_str().ok_or("context path is not UTF-8")?,
|
||||
])?;
|
||||
Ok(image)
|
||||
}
|
||||
|
||||
fn engine_command(&self, args: &[&str]) -> Result<(), String> {
|
||||
let command = format!("{} {}", self.engine.join(" "), args.join(" "));
|
||||
let output = Command::new(&self.engine[0])
|
||||
.args(&self.engine[1..])
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|error| format!("failed to run {command}: {error}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"{command} failed ({}):\n{}{}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestImage {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.engine_command(&["image", "rm", "--force", &self.tag]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user