mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-16 23:43:10 +00:00
488 lines
17 KiB
Rust
488 lines
17 KiB
Rust
use std::time::Instant;
|
|
|
|
use sha2::{Digest, Sha256};
|
|
|
|
use fabro_devcontainer::DevcontainerSpec;
|
|
|
|
use crate::event::{Event, EventEmitter};
|
|
use fabro_agent::sandbox::Sandbox;
|
|
use fabro_sandbox::daytona::{DaytonaSnapshotConfig, DockerfileSource};
|
|
use futures::future::try_join_all;
|
|
|
|
/// Compute a deterministic snapshot name from Dockerfile content.
|
|
pub fn snapshot_name_for_dockerfile(dockerfile: &str) -> String {
|
|
let hash = Sha256::digest(dockerfile.as_bytes());
|
|
let hex = hex::encode(hash);
|
|
format!("devcontainer-{}", &hex[..12])
|
|
}
|
|
|
|
/// Map a `DevcontainerSpec` to a `DaytonaSnapshotConfig`.
|
|
pub fn devcontainer_to_snapshot_config(dc: &DevcontainerSpec) -> DaytonaSnapshotConfig {
|
|
DaytonaSnapshotConfig {
|
|
name: snapshot_name_for_dockerfile(&dc.dockerfile),
|
|
dockerfile: Some(DockerfileSource::Inline(dc.dockerfile.clone())),
|
|
cpu: None,
|
|
memory: None,
|
|
disk: None,
|
|
}
|
|
}
|
|
|
|
/// Run a set of devcontainer lifecycle commands inside a sandbox.
|
|
///
|
|
/// Follows the same pattern as setup commands in `run.rs`.
|
|
pub async fn run_devcontainer_lifecycle(
|
|
sandbox: &dyn Sandbox,
|
|
emitter: &EventEmitter,
|
|
phase: &str,
|
|
commands: &[fabro_devcontainer::Command],
|
|
timeout_ms: u64,
|
|
) -> anyhow::Result<()> {
|
|
if commands.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
emitter.emit(&Event::DevcontainerLifecycleStarted {
|
|
phase: phase.to_string(),
|
|
command_count: commands.len(),
|
|
});
|
|
let phase_start = Instant::now();
|
|
|
|
for (index, cmd) in commands.iter().enumerate() {
|
|
match cmd {
|
|
fabro_devcontainer::Command::Shell(s) => {
|
|
run_single_lifecycle_command(
|
|
sandbox,
|
|
emitter,
|
|
phase,
|
|
&format!("sh -c {}", shlex::try_quote(s).unwrap_or_else(|_| s.into())),
|
|
index,
|
|
timeout_ms,
|
|
)
|
|
.await?;
|
|
}
|
|
fabro_devcontainer::Command::Args(args) => {
|
|
let joined = args
|
|
.iter()
|
|
.map(|a| shlex::try_quote(a).unwrap_or_else(|_| a.into()).to_string())
|
|
.collect::<Vec<_>>()
|
|
.join(" ");
|
|
run_single_lifecycle_command(sandbox, emitter, phase, &joined, index, timeout_ms)
|
|
.await?;
|
|
}
|
|
fabro_devcontainer::Command::Parallel(map) => {
|
|
let futs: Vec<_> = map
|
|
.iter()
|
|
.map(|(name, cmd_str)| {
|
|
let command = format!(
|
|
"sh -c {}",
|
|
shlex::try_quote(cmd_str).unwrap_or_else(|_| cmd_str.into())
|
|
);
|
|
let phase = phase.to_string();
|
|
let name = name.clone();
|
|
async move {
|
|
let cmd_start = Instant::now();
|
|
emitter.emit(&Event::DevcontainerLifecycleCommandStarted {
|
|
phase: phase.clone(),
|
|
command: name.clone(),
|
|
index,
|
|
});
|
|
let result = sandbox
|
|
.exec_command(&command, timeout_ms, None, None, None)
|
|
.await
|
|
.map_err(|e| {
|
|
anyhow::anyhow!(
|
|
"Devcontainer {phase} parallel command '{name}' failed: {e}"
|
|
)
|
|
})?;
|
|
let cmd_duration = crate::millis_u64(cmd_start.elapsed());
|
|
if result.exit_code != 0 {
|
|
emitter.emit(
|
|
&Event::DevcontainerLifecycleFailed {
|
|
phase: phase.clone(),
|
|
command: name.clone(),
|
|
index,
|
|
exit_code: result.exit_code,
|
|
stderr: result.stderr.clone(),
|
|
},
|
|
);
|
|
anyhow::bail!(
|
|
"Devcontainer {phase} parallel command '{name}' failed (exit code {}): {}",
|
|
result.exit_code,
|
|
result.stderr,
|
|
);
|
|
}
|
|
emitter.emit(
|
|
&Event::DevcontainerLifecycleCommandCompleted {
|
|
phase: phase.clone(),
|
|
command: name.clone(),
|
|
index,
|
|
exit_code: result.exit_code,
|
|
duration_ms: cmd_duration,
|
|
},
|
|
);
|
|
Ok(())
|
|
}
|
|
})
|
|
.collect();
|
|
try_join_all(futs).await?;
|
|
}
|
|
}
|
|
}
|
|
|
|
let phase_duration = crate::millis_u64(phase_start.elapsed());
|
|
emitter.emit(&Event::DevcontainerLifecycleCompleted {
|
|
phase: phase.to_string(),
|
|
duration_ms: phase_duration,
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_single_lifecycle_command(
|
|
sandbox: &dyn Sandbox,
|
|
emitter: &EventEmitter,
|
|
phase: &str,
|
|
command: &str,
|
|
index: usize,
|
|
timeout_ms: u64,
|
|
) -> anyhow::Result<()> {
|
|
emitter.emit(&Event::DevcontainerLifecycleCommandStarted {
|
|
phase: phase.to_string(),
|
|
command: command.to_string(),
|
|
index,
|
|
});
|
|
let cmd_start = Instant::now();
|
|
let result = sandbox
|
|
.exec_command(command, timeout_ms, None, None, None)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Devcontainer {phase} command failed: {e}"))?;
|
|
let cmd_duration = crate::millis_u64(cmd_start.elapsed());
|
|
if result.exit_code != 0 {
|
|
emitter.emit(&Event::DevcontainerLifecycleFailed {
|
|
phase: phase.to_string(),
|
|
command: command.to_string(),
|
|
index,
|
|
exit_code: result.exit_code,
|
|
stderr: result.stderr.clone(),
|
|
});
|
|
anyhow::bail!(
|
|
"Devcontainer {phase} command failed (exit code {}): {command}\n{}",
|
|
result.exit_code,
|
|
result.stderr,
|
|
);
|
|
}
|
|
emitter.emit(&Event::DevcontainerLifecycleCommandCompleted {
|
|
phase: phase.to_string(),
|
|
command: command.to_string(),
|
|
index,
|
|
exit_code: result.exit_code,
|
|
duration_ms: cmd_duration,
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use async_trait::async_trait;
|
|
use fabro_agent::sandbox::{ExecResult, GrepOptions, Sandbox};
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, Mutex};
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
/// Simple test sandbox that records commands and returns a fixed exit code.
|
|
struct TestSandbox {
|
|
commands: Mutex<Vec<String>>,
|
|
exit_code: i32,
|
|
}
|
|
|
|
impl TestSandbox {
|
|
fn new() -> Self {
|
|
Self {
|
|
commands: Mutex::new(Vec::new()),
|
|
exit_code: 0,
|
|
}
|
|
}
|
|
|
|
fn with_exit_code(exit_code: i32) -> Self {
|
|
Self {
|
|
commands: Mutex::new(Vec::new()),
|
|
exit_code,
|
|
}
|
|
}
|
|
|
|
fn captured_commands(&self) -> Vec<String> {
|
|
self.commands.lock().unwrap().clone()
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Sandbox for TestSandbox {
|
|
async fn read_file(
|
|
&self,
|
|
_path: &str,
|
|
_offset: Option<usize>,
|
|
_limit: Option<usize>,
|
|
) -> Result<String, String> {
|
|
Ok(String::new())
|
|
}
|
|
async fn write_file(&self, _path: &str, _content: &str) -> Result<(), String> {
|
|
Ok(())
|
|
}
|
|
async fn delete_file(&self, _path: &str) -> Result<(), String> {
|
|
Ok(())
|
|
}
|
|
async fn file_exists(&self, _path: &str) -> Result<bool, String> {
|
|
Ok(false)
|
|
}
|
|
async fn list_directory(
|
|
&self,
|
|
_path: &str,
|
|
_depth: Option<usize>,
|
|
) -> Result<Vec<fabro_agent::sandbox::DirEntry>, String> {
|
|
Ok(vec![])
|
|
}
|
|
async fn exec_command(
|
|
&self,
|
|
command: &str,
|
|
_timeout_ms: u64,
|
|
_working_dir: Option<&str>,
|
|
_env_vars: Option<&std::collections::HashMap<String, String>>,
|
|
_cancel_token: Option<CancellationToken>,
|
|
) -> Result<ExecResult, String> {
|
|
self.commands.lock().unwrap().push(command.to_string());
|
|
Ok(ExecResult {
|
|
stdout: String::new(),
|
|
stderr: if self.exit_code != 0 {
|
|
"command failed".to_string()
|
|
} else {
|
|
String::new()
|
|
},
|
|
exit_code: self.exit_code,
|
|
timed_out: false,
|
|
duration_ms: 10,
|
|
})
|
|
}
|
|
async fn grep(
|
|
&self,
|
|
_pattern: &str,
|
|
_path: &str,
|
|
_options: &GrepOptions,
|
|
) -> Result<Vec<String>, String> {
|
|
Ok(vec![])
|
|
}
|
|
async fn glob(&self, _pattern: &str, _path: Option<&str>) -> Result<Vec<String>, String> {
|
|
Ok(vec![])
|
|
}
|
|
async fn download_file_to_local(
|
|
&self,
|
|
_remote_path: &str,
|
|
_local_path: &std::path::Path,
|
|
) -> Result<(), String> {
|
|
Ok(())
|
|
}
|
|
async fn upload_file_from_local(
|
|
&self,
|
|
_local_path: &std::path::Path,
|
|
_remote_path: &str,
|
|
) -> Result<(), String> {
|
|
Ok(())
|
|
}
|
|
async fn initialize(&self) -> Result<(), String> {
|
|
Ok(())
|
|
}
|
|
async fn cleanup(&self) -> Result<(), String> {
|
|
Ok(())
|
|
}
|
|
fn working_directory(&self) -> &str {
|
|
"/work"
|
|
}
|
|
fn platform(&self) -> &str {
|
|
"linux"
|
|
}
|
|
fn os_version(&self) -> String {
|
|
"Linux 6.1.0".into()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn snapshot_name_is_deterministic() {
|
|
let dockerfile = "FROM ubuntu:22.04\nRUN apt-get update";
|
|
let name1 = snapshot_name_for_dockerfile(dockerfile);
|
|
let name2 = snapshot_name_for_dockerfile(dockerfile);
|
|
assert_eq!(name1, name2);
|
|
}
|
|
|
|
#[test]
|
|
fn snapshot_name_differs_for_different_dockerfiles() {
|
|
let name1 = snapshot_name_for_dockerfile("FROM ubuntu:22.04");
|
|
let name2 = snapshot_name_for_dockerfile("FROM rust:1.85");
|
|
assert_ne!(name1, name2);
|
|
}
|
|
|
|
#[test]
|
|
fn snapshot_name_has_prefix() {
|
|
let name = snapshot_name_for_dockerfile("FROM ubuntu:22.04");
|
|
assert!(name.starts_with("devcontainer-"), "name: {name}");
|
|
// prefix + 12 hex chars
|
|
assert_eq!(name.len(), "devcontainer-".len() + 12);
|
|
}
|
|
|
|
#[test]
|
|
fn maps_dockerfile_to_inline() {
|
|
let dc = test_devcontainer_config("FROM rust:1.85\nRUN cargo install sccache");
|
|
let snapshot = devcontainer_to_snapshot_config(&dc);
|
|
assert_eq!(
|
|
snapshot.dockerfile,
|
|
Some(DockerfileSource::Inline(dc.dockerfile.clone()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn snapshot_name_from_dockerfile_hash() {
|
|
let dc = test_devcontainer_config("FROM ubuntu:22.04");
|
|
let snapshot = devcontainer_to_snapshot_config(&dc);
|
|
let expected = snapshot_name_for_dockerfile(&dc.dockerfile);
|
|
assert_eq!(snapshot.name, expected);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn shell_command_executed() {
|
|
let sandbox = TestSandbox::new();
|
|
let emitter = EventEmitter::default();
|
|
let commands = vec![fabro_devcontainer::Command::Shell("echo hi".to_string())];
|
|
run_devcontainer_lifecycle(&sandbox, &emitter, "on_create", &commands, 300_000)
|
|
.await
|
|
.unwrap();
|
|
let captured = sandbox.captured_commands();
|
|
assert_eq!(captured.len(), 1);
|
|
assert!(captured[0].contains("echo hi"), "command: {}", captured[0]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn args_command_joins() {
|
|
let sandbox = TestSandbox::new();
|
|
let emitter = EventEmitter::default();
|
|
let commands = vec![fabro_devcontainer::Command::Args(vec![
|
|
"echo".to_string(),
|
|
"hi".to_string(),
|
|
])];
|
|
run_devcontainer_lifecycle(&sandbox, &emitter, "on_create", &commands, 300_000)
|
|
.await
|
|
.unwrap();
|
|
let captured = sandbox.captured_commands();
|
|
assert_eq!(captured.len(), 1);
|
|
assert!(
|
|
captured[0].contains("echo") && captured[0].contains("hi"),
|
|
"command: {}",
|
|
captured[0]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn emits_started_and_completed_events() {
|
|
let emitter = EventEmitter::default();
|
|
let events = Arc::new(Mutex::new(Vec::<fabro_types::RunEvent>::new()));
|
|
let events_clone = Arc::clone(&events);
|
|
emitter.on_event(move |event| {
|
|
events_clone.lock().unwrap().push(event.clone());
|
|
});
|
|
let sandbox = TestSandbox::new();
|
|
let commands = vec![fabro_devcontainer::Command::Shell("echo hi".to_string())];
|
|
run_devcontainer_lifecycle(&sandbox, &emitter, "on_create", &commands, 300_000)
|
|
.await
|
|
.unwrap();
|
|
let events = events.lock().unwrap();
|
|
assert_eq!(events[0].event_name(), "devcontainer.lifecycle.started");
|
|
assert_eq!(events[0].properties()["phase"], "on_create");
|
|
assert_eq!(events[0].properties()["command_count"], 1);
|
|
|
|
assert_eq!(
|
|
events[1].event_name(),
|
|
"devcontainer.lifecycle.command.started"
|
|
);
|
|
assert_eq!(events[1].properties()["phase"], "on_create");
|
|
assert_eq!(events[1].properties()["index"], 0);
|
|
|
|
assert_eq!(
|
|
events[2].event_name(),
|
|
"devcontainer.lifecycle.command.completed"
|
|
);
|
|
assert_eq!(events[2].properties()["phase"], "on_create");
|
|
assert_eq!(events[2].properties()["index"], 0);
|
|
assert_eq!(events[2].properties()["exit_code"], 0);
|
|
|
|
assert_eq!(events[3].event_name(), "devcontainer.lifecycle.completed");
|
|
assert_eq!(events[3].properties()["phase"], "on_create");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn failed_command_emits_failed_and_returns_error() {
|
|
let emitter = EventEmitter::default();
|
|
let events = Arc::new(Mutex::new(Vec::<fabro_types::RunEvent>::new()));
|
|
let events_clone = Arc::clone(&events);
|
|
emitter.on_event(move |event| {
|
|
events_clone.lock().unwrap().push(event.clone());
|
|
});
|
|
let sandbox = TestSandbox::with_exit_code(1);
|
|
let commands = vec![fabro_devcontainer::Command::Shell("false".to_string())];
|
|
let result =
|
|
run_devcontainer_lifecycle(&sandbox, &emitter, "on_create", &commands, 300_000).await;
|
|
assert!(result.is_err());
|
|
let events = events.lock().unwrap();
|
|
assert!(events.iter().any(|event| {
|
|
event.event_name() == "devcontainer.lifecycle.failed"
|
|
&& event.properties()["phase"] == "on_create"
|
|
&& event.properties()["exit_code"] == 1
|
|
}));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn empty_commands_is_noop() {
|
|
let emitter = EventEmitter::default();
|
|
let events = Arc::new(Mutex::new(Vec::new()));
|
|
let events_clone = Arc::clone(&events);
|
|
emitter.on_event(move |event| {
|
|
events_clone.lock().unwrap().push(event.clone());
|
|
});
|
|
let sandbox = TestSandbox::new();
|
|
run_devcontainer_lifecycle(&sandbox, &emitter, "on_create", &[], 300_000)
|
|
.await
|
|
.unwrap();
|
|
assert!(events.lock().unwrap().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn parallel_commands_run() {
|
|
let sandbox = TestSandbox::new();
|
|
let emitter = EventEmitter::default();
|
|
let mut map = HashMap::new();
|
|
map.insert("install".to_string(), "npm install".to_string());
|
|
map.insert("build".to_string(), "npm run build".to_string());
|
|
let commands = vec![fabro_devcontainer::Command::Parallel(map)];
|
|
run_devcontainer_lifecycle(&sandbox, &emitter, "post_create", &commands, 300_000)
|
|
.await
|
|
.unwrap();
|
|
let captured = sandbox.captured_commands();
|
|
assert_eq!(captured.len(), 2);
|
|
}
|
|
|
|
fn test_devcontainer_config(dockerfile: &str) -> DevcontainerSpec {
|
|
DevcontainerSpec {
|
|
dockerfile: dockerfile.to_string(),
|
|
build_context: std::path::PathBuf::from("."),
|
|
build_args: HashMap::new(),
|
|
build_target: None,
|
|
initialize_commands: vec![],
|
|
on_create_commands: vec![],
|
|
post_create_commands: vec![],
|
|
post_start_commands: vec![],
|
|
environment: HashMap::new(),
|
|
container_env: HashMap::new(),
|
|
remote_user: None,
|
|
workspace_folder: "/workspaces/test".to_string(),
|
|
forwarded_ports: vec![],
|
|
compose_files: vec![],
|
|
compose_service: None,
|
|
}
|
|
}
|
|
}
|