Skip to content

Commit 3082a9a

Browse files
authored
fix(cli): plan sandbox uploads before provisioning (#4193)
Reject bad uploads before create. Share the upload transfer helper. Signed-off-by: Eric Curtin <eric.curtin@docker.com>
1 parent 0acb8d7 commit 3082a9a

5 files changed

Lines changed: 104 additions & 75 deletions

File tree

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

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3414,14 +3414,6 @@ async fn run_async() -> Result<()> {
34143414
})
34153415
.collect();
34163416

3417-
// Validate all local paths before creating the sandbox so failures are
3418-
// fast and have no side effects.
3419-
for (local, _, _) in &upload_specs {
3420-
if std::fs::symlink_metadata(local).is_err() {
3421-
return Err(miette::miette!("local path does not exist: {}", local));
3422-
}
3423-
}
3424-
34253417
let editor = editor.map(Into::into);
34263418
let forward = forward
34273419
.map(|s| openshell_core::forward::ForwardSpec::parse(&s))

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

Lines changed: 58 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,12 @@ pub async fn sandbox_create(
542542
return Err(miette::miette!("--expose port must be in 1..=65535"));
543543
}
544544

545+
// Plan every upload before provisioning so a rejected one leaves no sandbox.
546+
let upload_plans = uploads
547+
.iter()
548+
.map(|(local_path, _, git_ignore)| sandbox_upload_plan(Path::new(local_path), *git_ignore))
549+
.collect::<Result<Vec<_>>>()?;
550+
545551
// Check port availability *before* creating the sandbox so we don't
546552
// leave an orphaned sandbox behind when the forward would fail.
547553
if let Some(ref spec) = forward {
@@ -1033,7 +1039,9 @@ pub async fn sandbox_create(
10331039
drop(client);
10341040

10351041
let upload_count = uploads.len();
1036-
for (idx, (local_path, sandbox_path, git_ignore)) in uploads.iter().enumerate() {
1042+
for (idx, ((local_path, sandbox_path, _), upload_plan)) in
1043+
uploads.iter().zip(upload_plans).enumerate()
1044+
{
10371045
let dest = sandbox_path.as_deref();
10381046
let dest_display = dest.unwrap_or("~");
10391047
if upload_count > 1 {
@@ -1049,38 +1057,21 @@ pub async fn sandbox_create(
10491057
"\u{2022}".dimmed(),
10501058
);
10511059
}
1052-
let local = Path::new(local_path);
1053-
let upload_plan = sandbox_upload_plan(local, *git_ignore).wrap_err_with(|| {
1060+
sandbox_upload_planned(
1061+
upload_plan,
1062+
&effective_server,
1063+
&sandbox_name,
1064+
Path::new(local_path),
1065+
dest,
1066+
&effective_tls,
1067+
workspace,
1068+
)
1069+
.await
1070+
.wrap_err_with(|| {
10541071
format!(
10551072
"Sandbox '{sandbox_name}' was created and still exists.\nRetry the upload with 'openshell sandbox upload', or remove the sandbox with 'openshell sandbox delete'",
10561073
)
10571074
})?;
1058-
match upload_plan {
1059-
SandboxUploadPlan::GitAware { base_dir, files } => {
1060-
sandbox_sync_up_files(
1061-
&effective_server,
1062-
&sandbox_name,
1063-
&base_dir,
1064-
&files,
1065-
local,
1066-
dest,
1067-
&effective_tls,
1068-
workspace,
1069-
)
1070-
.await?;
1071-
}
1072-
SandboxUploadPlan::Regular => {
1073-
sandbox_sync_up(
1074-
&effective_server,
1075-
&sandbox_name,
1076-
local,
1077-
dest,
1078-
&effective_tls,
1079-
workspace,
1080-
)
1081-
.await?;
1082-
}
1083-
}
10841075
eprintln!(" {} Files uploaded", "\u{2713}".green().bold());
10851076
}
10861077

@@ -5012,28 +5003,16 @@ fn git_filtered_upload_plan(local_path: &Path) -> Result<SandboxUploadPlan> {
50125003
Ok(SandboxUploadPlan::GitAware { base_dir, files })
50135004
}
50145005

5015-
/// Upload a local path to a sandbox.
5016-
///
5017-
/// Symlink sources, including dangling links, bypass Git-aware filtering so
5018-
/// the tar upload preserves the link instead of dereferencing its target.
5019-
pub async fn sandbox_upload(
5006+
async fn sandbox_upload_planned(
5007+
plan: SandboxUploadPlan,
50205008
server: &str,
50215009
name: &str,
50225010
local_path: &Path,
50235011
sandbox_path: Option<&str>,
5024-
git_ignore: bool,
50255012
tls: &TlsOptions,
50265013
workspace: &str,
50275014
) -> Result<()> {
5028-
let upload_plan = sandbox_upload_plan(local_path, git_ignore)?;
5029-
let dest_display = sandbox_path.unwrap_or("~");
5030-
eprintln!(
5031-
"Uploading {} -> sandbox:{}",
5032-
local_path.display(),
5033-
dest_display
5034-
);
5035-
5036-
match upload_plan {
5015+
match plan {
50375016
SandboxUploadPlan::GitAware { base_dir, files } => {
50385017
sandbox_sync_up_files(
50395018
server,
@@ -5045,12 +5024,45 @@ pub async fn sandbox_upload(
50455024
tls,
50465025
workspace,
50475026
)
5048-
.await?;
5027+
.await
50495028
}
50505029
SandboxUploadPlan::Regular => {
5051-
sandbox_sync_up(server, name, local_path, sandbox_path, tls, workspace).await?;
5030+
sandbox_sync_up(server, name, local_path, sandbox_path, tls, workspace).await
50525031
}
50535032
}
5033+
}
5034+
5035+
/// Upload a local path to a sandbox.
5036+
///
5037+
/// Symlink sources, including dangling links, bypass Git-aware filtering so
5038+
/// the tar upload preserves the link instead of dereferencing its target.
5039+
pub async fn sandbox_upload(
5040+
server: &str,
5041+
name: &str,
5042+
local_path: &Path,
5043+
sandbox_path: Option<&str>,
5044+
git_ignore: bool,
5045+
tls: &TlsOptions,
5046+
workspace: &str,
5047+
) -> Result<()> {
5048+
let upload_plan = sandbox_upload_plan(local_path, git_ignore)?;
5049+
let dest_display = sandbox_path.unwrap_or("~");
5050+
eprintln!(
5051+
"Uploading {} -> sandbox:{}",
5052+
local_path.display(),
5053+
dest_display
5054+
);
5055+
5056+
sandbox_upload_planned(
5057+
upload_plan,
5058+
server,
5059+
name,
5060+
local_path,
5061+
sandbox_path,
5062+
tls,
5063+
workspace,
5064+
)
5065+
.await?;
50545066

50555067
eprintln!("{} Upload complete", "✓".green().bold());
50565068
Ok(())

‎crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs‎

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3172,12 +3172,14 @@ async fn run_cli_sandbox_create(
31723172
}
31733173

31743174
#[tokio::test]
3175-
async fn sandbox_create_upload_stops_before_ssh_when_git_filtering_fails_or_is_empty() {
3175+
async fn sandbox_create_upload_is_rejected_before_provisioning_when_planning_fails() {
31763176
let server = run_server().await;
31773177
let source = tempfile::tempdir().unwrap();
31783178
fs::create_dir(source.path().join("runs")).unwrap();
31793179
fs::write(source.path().join("runs/marker.txt"), "dummy content").unwrap();
31803180
fs::write(source.path().join(".gitignore"), "runs/\n").unwrap();
3181+
let plain = tempfile::tempdir().unwrap();
3182+
fs::write(plain.path().join("ok.txt"), "ok").unwrap();
31813183

31823184
// A broken repository must not be mistaken for a non-repository source.
31833185
fs::create_dir(source.path().join(".git")).unwrap();
@@ -3188,12 +3190,7 @@ async fn sandbox_create_upload_stops_before_ssh_when_git_filtering_fails_or_is_e
31883190
assert!(!result.status.success(), "{stderr}");
31893191
assert!(stderr.contains("Git filtering failed"), "{stderr}");
31903192
assert!(stderr.contains("--no-git-ignore"), "{stderr}");
3191-
assert!(
3192-
stderr.contains("Sandbox 'upload-no-repository' was created and still exists"),
3193-
"{stderr}",
3194-
);
3195-
assert!(stderr.contains("openshell sandbox upload"), "{stderr}");
3196-
assert!(stderr.contains("openshell sandbox delete"), "{stderr}");
3193+
assert!(!stderr.contains("was created"), "{stderr}");
31973194

31983195
fs::remove_dir(source.path().join(".git")).unwrap();
31993196
assert!(
@@ -3213,21 +3210,41 @@ async fn sandbox_create_upload_stops_before_ssh_when_git_filtering_fails_or_is_e
32133210
"{stderr}"
32143211
);
32153212
assert!(stderr.contains("--no-git-ignore"), "{stderr}");
3216-
assert!(
3217-
stderr.contains("Sandbox 'upload-empty-selection' was created and still exists"),
3218-
"{stderr}",
3213+
assert!(!stderr.contains("was created"), "{stderr}");
3214+
3215+
// A valid earlier upload must not get through when a later one is rejected.
3216+
let args = [
3217+
"--detach",
3218+
"--upload",
3219+
plain.path().to_str().unwrap(),
3220+
"--upload",
3221+
path.to_str().unwrap(),
3222+
];
3223+
let result = run_cli_sandbox_create(&server, "upload-later-rejected", &args).await;
3224+
let stderr = String::from_utf8_lossy(&result.stderr);
3225+
assert!(!result.status.success(), "{stderr}");
3226+
assert!(stderr.contains("filtering selected no files"), "{stderr}");
3227+
3228+
let missing = plain.path().join("missing");
3229+
let args = ["--detach", "--upload", missing.to_str().unwrap()];
3230+
let result = run_cli_sandbox_create(&server, "upload-missing", &args).await;
3231+
let stderr = String::from_utf8_lossy(&result.stderr);
3232+
assert!(!result.status.success(), "{stderr}");
3233+
assert!(stderr.contains("local path does not exist"), "{stderr}");
3234+
3235+
assert_eq!(
3236+
create_requests(&server).await.len(),
3237+
0,
3238+
"a rejected upload plan must not provision a sandbox",
32193239
);
3220-
// Upload rejection intentionally leaves the provisioned sandbox available
3221-
// for an explicit retry; it does not roll back sandbox creation.
3222-
assert_eq!(create_requests(&server).await.len(), 2);
32233240
assert_eq!(
32243241
server
32253242
.openshell
32263243
.state
32273244
.ssh_session_requests
32283245
.load(Ordering::SeqCst),
32293246
0,
3230-
"a rejected creation-time upload must not open an SSH session",
3247+
"a rejected upload plan must not open an SSH session",
32313248
);
32323249
}
32333250

@@ -3251,6 +3268,13 @@ async fn sandbox_create_upload_warns_and_reaches_ssh_outside_git_repository() {
32513268
"{stderr}"
32523269
);
32533270
assert!(!stderr.contains("Git filtering failed"), "{stderr}");
3271+
// A transfer failure after provisioning keeps the sandbox and says so.
3272+
assert!(
3273+
stderr.contains("Sandbox 'upload-non-repository' was created and still exists"),
3274+
"{stderr}"
3275+
);
3276+
assert!(stderr.contains("openshell sandbox upload"), "{stderr}");
3277+
assert!(stderr.contains("openshell sandbox delete"), "{stderr}");
32543278
assert_eq!(create_requests(&server).await.len(), 1);
32553279
assert!(
32563280
server

‎docs/how-it-works/sandboxes/overview.mdx‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -894,12 +894,13 @@ repository stops the upload. Pass `--no-git-ignore` to intentionally upload
894894
without filtering. These rules apply to both `sandbox upload` and
895895
`sandbox create --upload`.
896896
897-
`sandbox create --upload` provisions the sandbox before checking Git filtering.
898-
If filtering rejects an upload, the command exits with an error, but the sandbox
899-
remains running. Earlier uploads in the same command may already have completed.
900-
Use `openshell sandbox upload` to retry against the existing sandbox, or
901-
`openshell sandbox delete` to remove it. Add `--no-git-ignore` to the retry only
902-
if you intend to upload without filtering.
897+
`sandbox create --upload` checks every upload locally before it provisions the
898+
sandbox. If any upload is rejected, including a missing path or a Git filtering
899+
failure, the command exits with an error and creates no sandbox. If a transfer
900+
fails after provisioning, the sandbox remains running and earlier uploads may
901+
have completed. Use `openshell sandbox upload` to retry against the existing
902+
sandbox, or `openshell sandbox delete` to remove it. Add `--no-git-ignore` to the
903+
retry only if you intend to upload without filtering.
903904
904905
Uploads preserve symlinks, including dangling links, instead of dereferencing
905906
their targets. A symlink source bypasses Git filtering so the link itself is

‎skills/openshell-cli/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,7 @@ openshell sandbox download my-sandbox output ./local-output
400400

401401
Uploads inside a Git work tree honor `.gitignore` by default and stop if Git filtering fails or selects no files. Sources confirmed to be outside a Git work tree upload without filtering, with a warning that `.gitignore` rules are not applied. Git must be available to determine whether filtering applies; broken or inaccessible repositories stop the upload. Add `--no-git-ignore` for an intentional unfiltered upload. This also applies to `sandbox create --upload`.
402402

403-
If `sandbox create --upload` rejects an upload, the sandbox remains running and earlier uploads may have completed. Retry with `sandbox upload` against that sandbox, or remove it with `sandbox delete`.
403+
`sandbox create --upload` checks every upload locally before provisioning and creates no sandbox if one is rejected. If a transfer fails after provisioning, the sandbox remains running and earlier uploads may have completed. Retry with `sandbox upload` against that sandbox, or remove it with `sandbox delete`.
404404

405405
Uploads preserve symlinks, including dangling symlinks, instead of dereferencing their targets. A symlink source bypasses Git-aware filtering so the link itself is archived.
406406

0 commit comments

Comments
 (0)