mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
588 lines
19 KiB
Rust
588 lines
19 KiB
Rust
#[cfg(feature = "daytona")]
|
|
mod daytona_streaming_live {
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::{Context, Result, ensure};
|
|
use fabro_sandbox::daytona::{DaytonaConfig, DaytonaSandbox};
|
|
use fabro_sandbox::{CommandOutputCallback, ExecStreamingResult, Sandbox};
|
|
use fabro_static::EnvVars;
|
|
use fabro_types::{CommandOutputStream, CommandTermination};
|
|
use tokio::sync::Mutex;
|
|
use tokio::time::{Instant, sleep};
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct CapturedChunk {
|
|
stream: CommandOutputStream,
|
|
text: String,
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
#[ignore = "requires live Daytona credentials and provisions a sandbox"]
|
|
async fn daytona_streaming_live_smoke() -> Result<()> {
|
|
ensure!(
|
|
daytona_api_key_present(),
|
|
"DAYTONA_API_KEY must be set to run this live smoke test"
|
|
);
|
|
|
|
let sandbox = Arc::new(
|
|
DaytonaSandbox::new(
|
|
DaytonaConfig {
|
|
skip_clone: true,
|
|
..Default::default()
|
|
},
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await?,
|
|
);
|
|
|
|
sandbox.initialize().await?;
|
|
|
|
let smoke_result = run_smoke(Arc::clone(&sandbox)).await;
|
|
let cleanup_result = sandbox.cleanup().await.context("clean up Daytona sandbox");
|
|
|
|
smoke_result?;
|
|
cleanup_result?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Both command paths must reach the same interpreter, so Bash-only syntax
|
|
/// that `sh` rejects has to behave identically through them. The two paths
|
|
/// build different requests — a direct process exec and a toolbox session —
|
|
/// so neither is evidence for the other.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
#[ignore = "requires live Daytona credentials and provisions a sandbox"]
|
|
async fn daytona_runs_bash_only_syntax_through_both_command_paths() -> Result<()> {
|
|
ensure!(
|
|
daytona_api_key_present(),
|
|
"DAYTONA_API_KEY must be set to run this live smoke test"
|
|
);
|
|
|
|
let sandbox = DaytonaSandbox::new(
|
|
DaytonaConfig {
|
|
skip_clone: true,
|
|
..Default::default()
|
|
},
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await?;
|
|
sandbox.initialize().await?;
|
|
|
|
// Arrays, `[[ ]]`, and `${arr[@]}` are Bash-only; `shopt -q
|
|
// login_shell` proves neither path ran under a login shell.
|
|
let command = "arr=(one two three); [[ ${#arr[@]} -eq 3 ]] || exit 1; \
|
|
shopt -q login_shell && exit 2; echo ${arr[1]}";
|
|
|
|
let checks: Result<()> = async {
|
|
let non_streaming = sandbox
|
|
.exec_command(command, 30_000, None, None, None)
|
|
.await?;
|
|
ensure_eq(
|
|
&non_streaming.exit_code,
|
|
&Some(0),
|
|
&format!("exec_command should run Bash-only syntax: {non_streaming:?}"),
|
|
)?;
|
|
ensure_contains(
|
|
&non_streaming.stdout,
|
|
"two",
|
|
"exec_command should report the Bash-only result",
|
|
)?;
|
|
|
|
let (streaming, _) = run_captured(&sandbox, command, 30_000, None).await?;
|
|
ensure_eq(
|
|
&streaming.result.exit_code,
|
|
&Some(0),
|
|
&format!("exec_command_streaming should run Bash-only syntax: {streaming:?}"),
|
|
)?;
|
|
ensure_contains(
|
|
&streaming.result.stdout,
|
|
"two",
|
|
"exec_command_streaming should report the Bash-only result",
|
|
)?;
|
|
|
|
let stdin = "first line\n$(touch /tmp/must-not-run)\nlast line";
|
|
let (stdin_result, _) = run_captured_with_stdin(
|
|
&sandbox,
|
|
"cat",
|
|
30_000,
|
|
None,
|
|
Some(stdin.as_bytes().to_vec()),
|
|
)
|
|
.await?;
|
|
ensure_eq(
|
|
&stdin_result.result.exit_code,
|
|
&Some(0),
|
|
"exec_command_streaming should close stdin with a successful EOF",
|
|
)?;
|
|
ensure_eq(
|
|
&stdin_result.result.stdout,
|
|
&stdin.to_string(),
|
|
"exec_command_streaming should preserve exact stdin bytes",
|
|
)?;
|
|
let stdin_cleanup = sandbox
|
|
.exec_command(
|
|
"test ! -e /tmp/must-not-run && \
|
|
! compgen -G '/tmp/fabro-command-stdin-*' >/dev/null",
|
|
30_000,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await?;
|
|
ensure!(
|
|
stdin_cleanup.is_success(),
|
|
"Daytona stdin data must stay inert and its temporary file must be deleted: {stdin_cleanup:?}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
.await;
|
|
|
|
let cleanup_result = sandbox.cleanup().await.context("clean up Daytona sandbox");
|
|
|
|
checks?;
|
|
cleanup_result?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
#[ignore = "requires live Daytona credentials and provisions a sandbox"]
|
|
async fn daytona_managed_labels_live_smoke() -> Result<()> {
|
|
ensure!(
|
|
daytona_api_key_present(),
|
|
"DAYTONA_API_KEY must be set to run this live smoke test"
|
|
);
|
|
|
|
let run_id: fabro_types::RunId = "01HY0000000000000000000000".parse().unwrap();
|
|
let sandbox = DaytonaSandbox::new(
|
|
DaytonaConfig {
|
|
skip_clone: true,
|
|
labels: Some(std::collections::HashMap::from([(
|
|
"team".to_string(),
|
|
"platform".to_string(),
|
|
)])),
|
|
..Default::default()
|
|
},
|
|
None,
|
|
Some(run_id),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await?;
|
|
|
|
sandbox.initialize().await?;
|
|
let labels = sandbox
|
|
.sandbox_handle()
|
|
.context("sandbox handle should be initialized")?
|
|
.labels
|
|
.clone();
|
|
let cleanup_result = sandbox.cleanup().await.context("clean up Daytona sandbox");
|
|
|
|
ensure_eq(
|
|
&labels.get("sh.fabro.managed").map(String::as_str),
|
|
&Some("true"),
|
|
"Daytona should accept and return the managed label",
|
|
)?;
|
|
ensure_eq(
|
|
&labels.get("sh.fabro.run_id").map(String::as_str),
|
|
&Some("01HY0000000000000000000000"),
|
|
"Daytona should accept and return the run id label",
|
|
)?;
|
|
ensure_eq(
|
|
&labels.get("team").map(String::as_str),
|
|
&Some("platform"),
|
|
"Daytona should preserve user labels",
|
|
)?;
|
|
cleanup_result?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
#[ignore = "requires live Daytona credentials and provisions a sandbox"]
|
|
async fn daytona_clone_layout_live_smoke() -> Result<()> {
|
|
ensure!(
|
|
daytona_api_key_present(),
|
|
"DAYTONA_API_KEY must be set to run this live smoke test"
|
|
);
|
|
|
|
let sandbox = DaytonaSandbox::new(
|
|
DaytonaConfig {
|
|
skip_clone: false,
|
|
..Default::default()
|
|
},
|
|
None,
|
|
None,
|
|
Some("https://github.com/brynary/rack-test".to_string()),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await?;
|
|
|
|
sandbox.initialize().await?;
|
|
ensure_eq(
|
|
&sandbox.working_directory(),
|
|
&"/home/daytona/workspace/rack-test",
|
|
"working directory should be the workspace symlink",
|
|
)?;
|
|
|
|
let result = sandbox
|
|
.exec_command(
|
|
"test -d /home/daytona/repos/brynary/rack-test/.git && \
|
|
test -L /home/daytona/workspace/rack-test && \
|
|
test \"$(readlink /home/daytona/workspace/rack-test)\" = /home/daytona/repos/brynary/rack-test && \
|
|
test \"$(git -C /home/daytona/repos/brynary/rack-test rev-parse HEAD)\" = \
|
|
\"$(git -C /home/daytona/workspace/rack-test rev-parse HEAD)\" && \
|
|
git rev-parse --is-inside-work-tree",
|
|
30_000,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await?;
|
|
let cleanup_result = sandbox.cleanup().await.context("clean up Daytona sandbox");
|
|
|
|
ensure!(
|
|
result.is_success(),
|
|
"layout verification failed: stdout={} stderr={}",
|
|
result.stdout,
|
|
result.stderr
|
|
);
|
|
ensure_contains(
|
|
&result.stdout,
|
|
"true",
|
|
"default cwd should be inside the work tree",
|
|
)?;
|
|
cleanup_result?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// Regression test for glob patterns that contain a path separator. Before
|
|
// the glob fix, Daytona ran `find <base> -name <pattern>`, and `find -name`
|
|
// matches only the basename and rejects patterns containing `/`. So
|
|
// `*/SKILL.md` and `**/SKILL.md` silently returned an empty list even though
|
|
// the files existed. Both `glob` calls below fail against that old
|
|
// implementation and pass once traversal (the Daytona filesystem API) and
|
|
// matching (host-side) are split.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
#[ignore = "requires live Daytona credentials and provisions a sandbox"]
|
|
async fn daytona_glob_matches_patterns_containing_a_path_separator() -> Result<()> {
|
|
ensure!(
|
|
daytona_api_key_present(),
|
|
"DAYTONA_API_KEY must be set to run this live glob test"
|
|
);
|
|
|
|
let sandbox = DaytonaSandbox::new(
|
|
DaytonaConfig {
|
|
skip_clone: true,
|
|
..Default::default()
|
|
},
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await?;
|
|
|
|
sandbox.initialize().await?;
|
|
|
|
let glob_result = run_glob_checks(&sandbox).await;
|
|
let cleanup_result = sandbox.cleanup().await.context("clean up Daytona sandbox");
|
|
|
|
glob_result?;
|
|
cleanup_result?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_glob_checks(sandbox: &DaytonaSandbox) -> Result<()> {
|
|
// Build a skills tree with a SKILL.md at the search root, one level
|
|
// below it, and two levels below it.
|
|
let seed = sandbox
|
|
.exec_command(
|
|
"mkdir -p skills/patch skills/nested/deeper && \
|
|
touch skills/SKILL.md skills/patch/SKILL.md skills/nested/deeper/SKILL.md",
|
|
30_000,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await?;
|
|
ensure!(
|
|
seed.is_success(),
|
|
"seeding the skills tree failed: stdout={} stderr={}",
|
|
seed.stdout,
|
|
seed.stderr
|
|
);
|
|
|
|
// `*/SKILL.md` matches exactly one path segment: only the file one level
|
|
// below the search directory, not the root file or the deeper one.
|
|
let one_level = sandbox.glob("*/SKILL.md", Some("skills")).await?;
|
|
ensure_eq(
|
|
&one_level.len(),
|
|
&1,
|
|
"`*/SKILL.md` should match exactly one level below the search dir",
|
|
)?;
|
|
ensure!(
|
|
one_level[0].ends_with("skills/patch/SKILL.md"),
|
|
"`*/SKILL.md` should match the one-level-deep file, got {one_level:?}"
|
|
);
|
|
|
|
// `**/SKILL.md` matches at any depth, including several levels down.
|
|
let recursive = sandbox.glob("**/SKILL.md", Some("skills")).await?;
|
|
ensure!(
|
|
recursive
|
|
.iter()
|
|
.any(|path| path.ends_with("skills/nested/deeper/SKILL.md")),
|
|
"`**/SKILL.md` should match files nested several levels deep, got {recursive:?}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_smoke(sandbox: Arc<DaytonaSandbox>) -> Result<()> {
|
|
let chunks = Arc::new(Mutex::new(Vec::new()));
|
|
let cancel_token = CancellationToken::new();
|
|
let callback = capture_callback(Arc::clone(&chunks));
|
|
let sandbox_for_exec = Arc::clone(&sandbox);
|
|
let cancel_for_exec = cancel_token.clone();
|
|
|
|
let live_exec = tokio::spawn(async move {
|
|
sandbox_for_exec
|
|
.exec_command_streaming(fabro_sandbox::ExecStreamingRequest {
|
|
timeout_ms: Some(60_000),
|
|
cancel_token: Some(cancel_for_exec),
|
|
output_callback: Some(callback),
|
|
..fabro_sandbox::ExecStreamingRequest::new(
|
|
"printf 'live-out\\n'; printf 'live-err\\n' >&2; sleep 30",
|
|
)
|
|
})
|
|
.await
|
|
});
|
|
|
|
let saw_live_stdout_and_stderr =
|
|
wait_for_chunks(&chunks, Duration::from_secs(20), |chunks| {
|
|
contains_chunk(chunks, CommandOutputStream::Stdout, "live-out")
|
|
&& contains_chunk(chunks, CommandOutputStream::Stderr, "live-err")
|
|
})
|
|
.await;
|
|
|
|
cancel_token.cancel();
|
|
|
|
let live_result = live_exec
|
|
.await
|
|
.context("join live cancel command task")?
|
|
.context("run live cancel command")?;
|
|
|
|
ensure!(
|
|
saw_live_stdout_and_stderr,
|
|
"expected live stdout and stderr chunks before cancellation, got {chunks:?}",
|
|
chunks = chunks.lock().await
|
|
);
|
|
ensure!(
|
|
live_result.live_streaming,
|
|
"expected Daytona command logs to stream before completion"
|
|
);
|
|
ensure!(
|
|
live_result.streams_separated,
|
|
"expected Daytona command logs to separate stdout and stderr"
|
|
);
|
|
ensure_eq(
|
|
&live_result.result.termination,
|
|
&CommandTermination::Cancelled,
|
|
"cancelled command should preserve cancellation termination",
|
|
)?;
|
|
ensure_contains(
|
|
&live_result.result.stdout,
|
|
"live-out",
|
|
"cancelled command stdout should preserve partial logs",
|
|
)?;
|
|
ensure_contains(
|
|
&live_result.result.stderr,
|
|
"live-err",
|
|
"cancelled command stderr should preserve partial logs",
|
|
)?;
|
|
|
|
let (nonzero, nonzero_chunks) = run_captured(
|
|
sandbox.as_ref(),
|
|
"printf 'exit-out\\n'; printf 'exit-err\\n' >&2; exit 7",
|
|
30_000,
|
|
None,
|
|
)
|
|
.await?;
|
|
ensure_eq(
|
|
&nonzero.result.exit_code,
|
|
&Some(7),
|
|
"nonzero command should preserve the Daytona exit code",
|
|
)?;
|
|
ensure_eq(
|
|
&nonzero.result.termination,
|
|
&CommandTermination::Exited,
|
|
"nonzero command should be represented as a completed process",
|
|
)?;
|
|
ensure_contains(
|
|
&nonzero.result.stdout,
|
|
"exit-out",
|
|
"nonzero command stdout should be captured",
|
|
)?;
|
|
ensure_contains(
|
|
&nonzero.result.stderr,
|
|
"exit-err",
|
|
"nonzero command stderr should be captured",
|
|
)?;
|
|
ensure!(
|
|
contains_chunk(&nonzero_chunks, CommandOutputStream::Stdout, "exit-out"),
|
|
"nonzero command should stream stdout chunks"
|
|
);
|
|
ensure!(
|
|
contains_chunk(&nonzero_chunks, CommandOutputStream::Stderr, "exit-err"),
|
|
"nonzero command should stream stderr chunks"
|
|
);
|
|
|
|
let (timed_out, _) = run_captured(
|
|
sandbox.as_ref(),
|
|
"printf 'timeout-out\\n'; printf 'timeout-err\\n' >&2; sleep 30",
|
|
1_500,
|
|
None,
|
|
)
|
|
.await?;
|
|
ensure_eq(
|
|
&timed_out.result.termination,
|
|
&CommandTermination::TimedOut,
|
|
"timed-out command should preserve timeout termination",
|
|
)?;
|
|
ensure_contains(
|
|
&timed_out.result.stdout,
|
|
"timeout-out",
|
|
"timed-out command stdout should preserve partial logs",
|
|
)?;
|
|
ensure_contains(
|
|
&timed_out.result.stderr,
|
|
"timeout-err",
|
|
"timed-out command stderr should preserve partial logs",
|
|
)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_captured(
|
|
sandbox: &DaytonaSandbox,
|
|
command: &str,
|
|
timeout_ms: u64,
|
|
cancel_token: Option<CancellationToken>,
|
|
) -> Result<(ExecStreamingResult, Vec<CapturedChunk>)> {
|
|
run_captured_with_stdin(sandbox, command, timeout_ms, cancel_token, None).await
|
|
}
|
|
|
|
async fn run_captured_with_stdin(
|
|
sandbox: &DaytonaSandbox,
|
|
command: &str,
|
|
timeout_ms: u64,
|
|
cancel_token: Option<CancellationToken>,
|
|
stdin: Option<Vec<u8>>,
|
|
) -> Result<(ExecStreamingResult, Vec<CapturedChunk>)> {
|
|
let chunks = Arc::new(Mutex::new(Vec::new()));
|
|
let callback = capture_callback(Arc::clone(&chunks));
|
|
let result = sandbox
|
|
.exec_command_streaming(fabro_sandbox::ExecStreamingRequest {
|
|
timeout_ms: Some(timeout_ms),
|
|
cancel_token,
|
|
stdin,
|
|
output_callback: Some(callback),
|
|
..fabro_sandbox::ExecStreamingRequest::new(command)
|
|
})
|
|
.await?;
|
|
let chunks = chunks.lock().await.clone();
|
|
|
|
Ok((result, chunks))
|
|
}
|
|
|
|
fn capture_callback(chunks: Arc<Mutex<Vec<CapturedChunk>>>) -> CommandOutputCallback {
|
|
Arc::new(move |stream, bytes| {
|
|
let chunks = Arc::clone(&chunks);
|
|
Box::pin(async move {
|
|
chunks.lock().await.push(CapturedChunk {
|
|
stream,
|
|
text: String::from_utf8_lossy(&bytes).into_owned(),
|
|
});
|
|
Ok(())
|
|
})
|
|
})
|
|
}
|
|
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "live smoke tests need a direct process-env preflight before provisioning Daytona"
|
|
)]
|
|
fn daytona_api_key_present() -> bool {
|
|
std::env::var_os(EnvVars::DAYTONA_API_KEY).is_some()
|
|
}
|
|
|
|
async fn wait_for_chunks(
|
|
chunks: &Arc<Mutex<Vec<CapturedChunk>>>,
|
|
timeout_after: Duration,
|
|
predicate: impl Fn(&[CapturedChunk]) -> bool,
|
|
) -> bool {
|
|
let deadline = Instant::now() + timeout_after;
|
|
loop {
|
|
if predicate(&chunks.lock().await) {
|
|
return true;
|
|
}
|
|
|
|
if Instant::now() >= deadline {
|
|
return false;
|
|
}
|
|
|
|
sleep(Duration::from_millis(100)).await;
|
|
}
|
|
}
|
|
|
|
fn contains_chunk(chunks: &[CapturedChunk], stream: CommandOutputStream, text: &str) -> bool {
|
|
chunks
|
|
.iter()
|
|
.any(|chunk| chunk.stream == stream && chunk.text.contains(text))
|
|
}
|
|
|
|
fn ensure_contains(value: &str, needle: &str, message: &str) -> Result<()> {
|
|
ensure!(
|
|
value.contains(needle),
|
|
"{message}: expected to find {needle:?} in {value:?}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_eq<T>(actual: &T, expected: &T, message: &str) -> Result<()>
|
|
where
|
|
T: std::fmt::Debug + PartialEq,
|
|
{
|
|
ensure!(
|
|
actual == expected,
|
|
"{message}: expected {expected:?}, got {actual:?}"
|
|
);
|
|
Ok(())
|
|
}
|
|
}
|