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>
This commit is contained in:
Matthew Grossman
2026-10-07 11:34:48 +02:00
committed by Evan Lezar
parent 04a448cdbf
commit 48204ce978
13 changed files with 573 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":"binaries","testsuite":"oci-image"},
{"environment":"fedora-podman-rootful","installer":"binaries","testsuite":"oci-image"},
{"environment":"fedora-podman-rootless","installer":"binaries","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":"binaries","testsuite":"oci-image"},
{"environment":"fedora-podman-rootful","installer":"binaries","testsuite":"oci-image"},
{"environment":"fedora-podman-rootless","installer":"binaries","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":"binaries","testsuite":"oci-image"},
{"environment":"fedora-podman-rootful","installer":"binaries","testsuite":"oci-image"},
{"environment":"fedora-podman-rootless","installer":"binaries","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 artifacts. CI runs it on Docker rootful,
Podman rootful, and Podman rootless guests:
```shell
nix run .#tmachine -- test ubuntu-docker-rootful binaries 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,117 @@
# 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
- --unit
- openshell-gateway.service
- --no-pager
- --lines
- "500"
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]);
}
}