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:
Matthew Grossman
2026-10-08 12:04:51 +02:00
committed by Evan Lezar
parent 67adcf1a3c
commit d4edcd9707
13 changed files with 574 additions and 263 deletions
+4 -1
View File
@@ -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:
+4 -1
View File
@@ -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:
+4 -1
View File
@@ -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
View File
@@ -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:
-5
View File
@@ -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"
-253
View File
@@ -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
+11
View File
@@ -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
'';
+7
View File
@@ -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" ];
+9
View File
@@ -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"
+1 -1
View File
@@ -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]);
}
}