Skip to content

Commit 28b5152

Browse files
committed
feat(sandbox): support rootfs tar as --from source for VM driver
Accept flat rootfs tar archives (.tar, .tar.gz, .tgz) via the --from flag for VM-backed gateways. The CLI detects the archive extension, validates that the gateway uses the VM compute driver, and passes the tar path through driver_config. The VM driver copies the tar into its staging area and feeds it into the existing rootfs extraction and ext4 disk creation pipeline, skipping the container image pull/export steps. Closes #2175 Signed-off-by: Philippe Martin <phmartin@redhat.com>
1 parent 40d1b48 commit 28b5152

6 files changed

Lines changed: 509 additions & 53 deletions

File tree

‎crates/openshell-cli/src/main.rs‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1340,15 +1340,17 @@ enum SandboxCommands {
13401340
name: Option<String>,
13411341

13421342
/// Sandbox source: a community sandbox name (e.g., `ollama`), a path
1343-
/// to a Dockerfile or directory containing one, or a full container
1344-
/// image reference (e.g., `myregistry.com/img:tag`).
1343+
/// to a Dockerfile or directory containing one, a rootfs tar archive
1344+
/// (`.tar`, `.tar.gz`, or `.tgz`), or a full container image reference
1345+
/// (e.g., `myregistry.com/img:tag`).
13451346
///
13461347
/// Community names are resolved to
13471348
/// `ghcr.io/nvidia/openshell-community/sandboxes/<name>:latest`
13481349
/// (override the prefix with `OPENSHELL_COMMUNITY_REGISTRY`).
13491350
///
13501351
/// When given a Dockerfile or directory, the image is built into the
1351-
/// local Docker daemon before creating the sandbox.
1352+
/// local Docker daemon before creating the sandbox. When given a
1353+
/// rootfs tar, it is passed directly to the VM compute driver.
13521354
#[arg(long, value_hint = ValueHint::AnyPath)]
13531355
from: Option<String>,
13541356

‎crates/openshell-cli/src/run.rs‎

Lines changed: 228 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -40,21 +40,21 @@ use openshell_core::proto::{
4040
DeleteProviderProfileRequest, DeleteProviderRefreshRequest, DeleteProviderRequest,
4141
DeleteSandboxRequest, DeleteServiceRequest, DetachSandboxProviderRequest, ExecSandboxRequest,
4242
ExposeServiceRequest, GetCurrentUserRequest, GetDraftHistoryRequest, GetDraftPolicyRequest,
43-
GetGatewayConfigRequest, GetInferenceRouteRequest, GetProviderProfileRequest,
44-
GetProviderRefreshStatusRequest, GetProviderRequest, GetSandboxConfigRequest,
45-
GetSandboxConfigResponse, GetSandboxLogsRequest, GetSandboxPolicyStatusRequest,
46-
GetSandboxRequest, GetServiceRequest, GpuResourceRequirements, ImportProviderProfilesRequest,
47-
LintProviderProfilesRequest, ListProviderProfilesRequest, ListProvidersRequest,
48-
ListSandboxPoliciesRequest, ListSandboxProvidersRequest, ListSandboxesRequest,
49-
ListServicesRequest, PolicySource, PolicyStatus, Provider, ProviderCredentialRefreshStatus,
50-
ProviderCredentialRefreshStrategy, ProviderCredentialTokenGrantType, ProviderProfile,
51-
ProviderProfileDiagnostic, ProviderProfileImportItem, RejectDraftChunkRequest,
52-
ResourceRequirements, RevokeSshSessionRequest, RotateProviderCredentialRequest, Sandbox,
53-
SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate, ServiceEndpointResponse,
54-
SetInferenceRouteRequest, SettingScope, StartSandboxRequest, StopSandboxRequest,
55-
TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest,
56-
UpdateProviderProfilesRequest, UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event,
57-
setting_value, tcp_forward_init,
43+
GetGatewayConfigRequest, GetGatewayInfoRequest, GetInferenceRouteRequest,
44+
GetProviderProfileRequest, GetProviderRefreshStatusRequest, GetProviderRequest,
45+
GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxLogsRequest,
46+
GetSandboxPolicyStatusRequest, GetSandboxRequest, GetServiceRequest, GpuResourceRequirements,
47+
ImportProviderProfilesRequest, LintProviderProfilesRequest, ListProviderProfilesRequest,
48+
ListProvidersRequest, ListSandboxPoliciesRequest, ListSandboxProvidersRequest,
49+
ListSandboxesRequest, ListServicesRequest, PolicySource, PolicyStatus, Provider,
50+
ProviderCredentialRefreshStatus, ProviderCredentialRefreshStrategy,
51+
ProviderCredentialTokenGrantType, ProviderProfile, ProviderProfileDiagnostic,
52+
ProviderProfileImportItem, RejectDraftChunkRequest, ResourceRequirements,
53+
RevokeSshSessionRequest, RotateProviderCredentialRequest, Sandbox, SandboxPhase, SandboxPolicy,
54+
SandboxSpec, SandboxTemplate, ServiceEndpointResponse, SetInferenceRouteRequest, SettingScope,
55+
StartSandboxRequest, StopSandboxRequest, TcpForwardFrame, TcpForwardInit, TcpRelayTarget,
56+
UpdateConfigRequest, UpdateProviderProfilesRequest, UpdateProviderRequest, WatchSandboxRequest,
57+
exec_sandbox_event, setting_value, tcp_forward_init,
5858
};
5959
use openshell_core::settings;
6060
use openshell_core::{ObjectId, ObjectName, ObjectWorkspace};
@@ -473,22 +473,26 @@ pub async fn sandbox_create(
473473
let effective_tls = tls.clone();
474474

475475
// Resolve the --from flag into a container image reference, building from
476-
// a Dockerfile first if necessary.
477-
let image: Option<String> = match from {
476+
// a Dockerfile first if necessary, or a rootfs tar path for the VM driver.
477+
let (image, rootfs_tar_path): (Option<String>, Option<PathBuf>) = match from {
478478
Some(val) => {
479479
let resolved = resolve_from(val)?;
480480
match resolved {
481-
ResolvedSource::Image(img) => Some(img),
481+
ResolvedSource::Image(img) => (Some(img), None),
482482
ResolvedSource::Dockerfile {
483483
dockerfile,
484484
context,
485485
} => {
486486
let tag = build_from_dockerfile(&dockerfile, &context, gateway_name).await?;
487-
Some(tag)
487+
(Some(tag), None)
488+
}
489+
ResolvedSource::RootfsTar { path } => {
490+
validate_rootfs_tar_source(gateway_name, &mut client, &path).await?;
491+
(None, Some(path))
488492
}
489493
}
490494
}
491-
None => None,
495+
None => (None, None),
492496
};
493497
let inferred_provider = inferred_provider_type(command);
494498
let providers_v2_enabled =
@@ -513,11 +517,20 @@ pub async fn sandbox_create(
513517

514518
let policy = load_sandbox_policy(policy)?;
515519
let resource_limits = build_sandbox_resource_limits(cpu, memory)?;
516-
let driver_config = driver_config_json
520+
let mut driver_config = driver_config_json
517521
.map(parse_driver_config_json)
518522
.transpose()?;
519523

520-
let template = if image.is_some() || resource_limits.is_some() || driver_config.is_some() {
524+
if let Some(tar_path) = &rootfs_tar_path {
525+
let rootfs_config = rootfs_tar_driver_config(tar_path)?;
526+
driver_config = Some(merge_driver_config(driver_config, rootfs_config));
527+
}
528+
529+
let template = if image.is_some()
530+
|| resource_limits.is_some()
531+
|| driver_config.is_some()
532+
|| rootfs_tar_path.is_some()
533+
{
521534
Some(SandboxTemplate {
522535
image: image.unwrap_or_default(),
523536
resources: resource_limits,
@@ -1031,17 +1044,23 @@ enum ResolvedSource {
10311044
dockerfile: PathBuf,
10321045
context: PathBuf,
10331046
},
1047+
/// A flat rootfs tar archive (`.tar`, `.tar.gz`, `.tgz`) to pass directly
1048+
/// to the VM compute driver.
1049+
RootfsTar { path: PathBuf },
10341050
}
10351051

1036-
/// Classify the `--from` value into an image reference or a Dockerfile that
1037-
/// needs building.
1052+
/// Classify the `--from` value into an image reference, a Dockerfile that
1053+
/// needs building, or a rootfs tar to pass to the VM driver.
10381054
///
10391055
/// Resolution order:
1040-
/// 1. Existing file whose name contains "Dockerfile" → build from file.
1056+
/// 1. Existing file whose name contains "dockerfile" → build from Dockerfile.
10411057
/// 2. Existing directory that contains a `Dockerfile` → build from directory.
1042-
/// 3. Missing explicit local paths → local error, not image pull.
1043-
/// 4. Value contains `/`, `:`, or `.` → treat as a full image reference.
1044-
/// 5. Otherwise → community sandbox name, expanded via the registry prefix.
1058+
/// 3. Existing file with `.tar`, `.tar.gz`, or `.tgz` extension → rootfs tar archive.
1059+
/// 4. Other existing local paths → error.
1060+
/// 5. Non-existent path-like values (`./…`, `../…`, `/…`, `~/…`) → local
1061+
/// error, so they don't reach the gateway as broken image-pull requests.
1062+
/// 6. Value contains `/`, `:`, or `.` → treat as a full image reference.
1063+
/// 7. Otherwise → community sandbox name, expanded via the registry prefix.
10451064
fn resolve_from(value: &str) -> Result<ResolvedSource> {
10461065
let path = Path::new(value);
10471066

@@ -1062,9 +1081,17 @@ fn resolve_from(value: &str) -> Result<ResolvedSource> {
10621081
});
10631082
}
10641083

1084+
if filename_looks_like_rootfs_tar(path) {
1085+
let tar_path = path
1086+
.canonicalize()
1087+
.into_diagnostic()
1088+
.wrap_err_with(|| format!("failed to resolve path: {}", path.display()))?;
1089+
return Ok(ResolvedSource::RootfsTar { path: tar_path });
1090+
}
1091+
10651092
if value_looks_like_local_source(value) {
10661093
return Err(miette::miette!(
1067-
"local --from file is not a Dockerfile: {}",
1094+
"local --from file is not a Dockerfile or rootfs tar (.tar/.tar.gz/.tgz): {}",
10681095
path.display()
10691096
));
10701097
}
@@ -1103,7 +1130,7 @@ fn resolve_from(value: &str) -> Result<ResolvedSource> {
11031130
if value_looks_like_local_source(value) {
11041131
return Err(miette::miette!(
11051132
"local --from path does not exist: {}\n\
1106-
Use an existing Dockerfile, a directory containing Dockerfile, or a container image reference.",
1133+
Use an existing Dockerfile, directory containing Dockerfile, rootfs tar (.tar/.tar.gz/.tgz), or a container image reference.",
11071134
path.display()
11081135
));
11091136
}
@@ -1121,7 +1148,17 @@ fn filename_looks_like_dockerfile(path: &Path) -> bool {
11211148
.map(|n| n.to_string_lossy())
11221149
.unwrap_or_default();
11231150
let lower = name.to_lowercase();
1124-
lower.contains("dockerfile") || lower.ends_with(".dockerfile")
1151+
lower.contains("dockerfile")
1152+
}
1153+
1154+
#[allow(clippy::case_sensitive_file_extension_comparisons)] // already lowercased
1155+
fn filename_looks_like_rootfs_tar(path: &Path) -> bool {
1156+
let name = path
1157+
.file_name()
1158+
.map(|n| n.to_string_lossy())
1159+
.unwrap_or_default();
1160+
let lower = name.to_lowercase();
1161+
lower.ends_with(".tar.gz") || lower.ends_with(".tar") || lower.ends_with(".tgz")
11251162
}
11261163

11271164
fn value_looks_like_local_source(value: &str) -> bool {
@@ -1203,6 +1240,73 @@ async fn build_from_dockerfile(
12031240
Ok(tag)
12041241
}
12051242

1243+
/// Validate that a rootfs tar source is usable with the current gateway.
1244+
async fn validate_rootfs_tar_source(
1245+
gateway_name: &str,
1246+
client: &mut crate::tls::GrpcClient,
1247+
tar_path: &Path,
1248+
) -> Result<()> {
1249+
let metadata = get_gateway_metadata(gateway_name);
1250+
if !dockerfile_sources_supported_for_gateway(metadata.as_ref()) {
1251+
return Err(miette!(
1252+
"local rootfs tar sources are only supported for local gateways; gateway '{}' is remote",
1253+
gateway_name
1254+
));
1255+
}
1256+
1257+
let info = client
1258+
.get_gateway_info(GetGatewayInfoRequest {})
1259+
.await
1260+
.into_diagnostic()
1261+
.wrap_err("failed to query gateway compute driver")?
1262+
.into_inner();
1263+
1264+
let driver_name = info.compute_drivers.first().map_or("", |d| d.name.as_str());
1265+
1266+
if driver_name != "vm" {
1267+
return Err(miette!(
1268+
"rootfs tar sources are only supported by the VM compute driver, \
1269+
but gateway '{}' uses the '{}' driver",
1270+
gateway_name,
1271+
driver_name
1272+
));
1273+
}
1274+
1275+
eprintln!(
1276+
"Using rootfs tar {} for gateway '{}'",
1277+
tar_path.display().to_string().cyan(),
1278+
gateway_name,
1279+
);
1280+
eprintln!();
1281+
1282+
Ok(())
1283+
}
1284+
1285+
/// Build a `driver_config` struct carrying the rootfs tar path for the VM driver.
1286+
fn rootfs_tar_driver_config(tar_path: &Path) -> Result<prost_types::Struct> {
1287+
let fields = serde_json::Map::from_iter([(
1288+
"rootfs_tar_path".to_string(),
1289+
serde_json::Value::String(tar_path.to_string_lossy().into_owned()),
1290+
)]);
1291+
openshell_core::proto_struct::json_object_to_struct(fields)
1292+
.into_diagnostic()
1293+
.wrap_err("failed to encode rootfs_tar_path in driver_config")
1294+
}
1295+
1296+
/// Merge a rootfs tar config into an existing `driver_config`, if any.
1297+
fn merge_driver_config(
1298+
base: Option<prost_types::Struct>,
1299+
overlay: prost_types::Struct,
1300+
) -> prost_types::Struct {
1301+
match base {
1302+
Some(mut base) => {
1303+
base.fields.extend(overlay.fields);
1304+
base
1305+
}
1306+
None => overlay,
1307+
}
1308+
}
1309+
12061310
/// Load sandbox policy YAML.
12071311
///
12081312
/// Resolution order: `--policy` flag > `OPENSHELL_SANDBOX_POLICY` env var.
@@ -7950,8 +8054,8 @@ mod tests {
79508054
.expect("failed to canonicalize context")
79518055
);
79528056
}
7953-
super::ResolvedSource::Image(image) => {
7954-
panic!("expected Dockerfile source, got image {image}");
8057+
other => {
8058+
panic!("expected Dockerfile source, got {other:?}");
79558059
}
79568060
}
79578061
}
@@ -7976,12 +8080,101 @@ mod tests {
79768080

79778081
match resolve_from(image_ref).expect("expected image source") {
79788082
super::ResolvedSource::Image(image) => assert_eq!(image, image_ref),
7979-
super::ResolvedSource::Dockerfile { .. } => {
7980-
panic!("expected image ref, got Dockerfile source");
8083+
other => {
8084+
panic!("expected image ref, got {other:?}");
8085+
}
8086+
}
8087+
}
8088+
8089+
#[test]
8090+
fn resolve_from_classifies_tar_archive() {
8091+
let temp = tempfile::tempdir().expect("failed to create tempdir");
8092+
let archive = temp.path().join("rootfs.tar");
8093+
fs::write(&archive, b"fake tar content").expect("failed to write archive");
8094+
8095+
match resolve_from(archive.to_str().expect("temp path is not UTF-8"))
8096+
.expect("expected RootfsTar source")
8097+
{
8098+
super::ResolvedSource::RootfsTar { path } => {
8099+
assert_eq!(
8100+
path,
8101+
archive
8102+
.canonicalize()
8103+
.expect("failed to canonicalize archive")
8104+
);
79818105
}
8106+
other => panic!("expected RootfsTar source, got {other:?}"),
79828107
}
79838108
}
79848109

8110+
#[test]
8111+
fn resolve_from_classifies_tar_gz_archive() {
8112+
let temp = tempfile::tempdir().expect("failed to create tempdir");
8113+
let archive = temp.path().join("rootfs.tar.gz");
8114+
fs::write(&archive, b"fake tar.gz content").expect("failed to write archive");
8115+
8116+
match resolve_from(archive.to_str().expect("temp path is not UTF-8"))
8117+
.expect("expected RootfsTar source")
8118+
{
8119+
super::ResolvedSource::RootfsTar { path } => {
8120+
assert_eq!(
8121+
path,
8122+
archive
8123+
.canonicalize()
8124+
.expect("failed to canonicalize archive")
8125+
);
8126+
}
8127+
other => panic!("expected RootfsTar source, got {other:?}"),
8128+
}
8129+
}
8130+
8131+
#[test]
8132+
fn resolve_from_classifies_tgz_archive() {
8133+
let temp = tempfile::tempdir().expect("failed to create tempdir");
8134+
let archive = temp.path().join("rootfs.tgz");
8135+
fs::write(&archive, b"fake tgz content").expect("failed to write archive");
8136+
8137+
match resolve_from(archive.to_str().expect("temp path is not UTF-8"))
8138+
.expect("expected RootfsTar source")
8139+
{
8140+
super::ResolvedSource::RootfsTar { path } => {
8141+
assert_eq!(
8142+
path,
8143+
archive
8144+
.canonicalize()
8145+
.expect("failed to canonicalize archive")
8146+
);
8147+
}
8148+
other => panic!("expected RootfsTar source, got {other:?}"),
8149+
}
8150+
}
8151+
8152+
#[test]
8153+
fn resolve_from_rejects_missing_tar_archive() {
8154+
let temp = tempfile::tempdir().expect("failed to create tempdir");
8155+
let missing = temp.path().join("missing.tar");
8156+
8157+
let err = resolve_from(missing.to_str().expect("temp path is not UTF-8"))
8158+
.expect_err("expected missing archive to be rejected");
8159+
8160+
assert!(
8161+
err.to_string().contains("local --from path does not exist"),
8162+
"unexpected error: {err}"
8163+
);
8164+
}
8165+
8166+
#[test]
8167+
fn filename_looks_like_rootfs_tar_detects_extensions() {
8168+
use super::filename_looks_like_rootfs_tar;
8169+
assert!(filename_looks_like_rootfs_tar(Path::new("rootfs.tar")));
8170+
assert!(filename_looks_like_rootfs_tar(Path::new("rootfs.tar.gz")));
8171+
assert!(filename_looks_like_rootfs_tar(Path::new("rootfs.tgz")));
8172+
assert!(filename_looks_like_rootfs_tar(Path::new("IMAGE.TAR")));
8173+
assert!(filename_looks_like_rootfs_tar(Path::new("my-image.TAR.GZ")));
8174+
assert!(!filename_looks_like_rootfs_tar(Path::new("Dockerfile")));
8175+
assert!(!filename_looks_like_rootfs_tar(Path::new("image.zip")));
8176+
}
8177+
79858178
#[test]
79868179
fn dockerfile_sources_are_rejected_for_remote_gateways() {
79878180
let metadata = GatewayMetadata {

0 commit comments

Comments
 (0)