mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-07 08:27:12 +00:00
Add arc ssh command for SSH access to Daytona sandboxes
Standalone subcommand that creates SSH credentials for a run's Daytona sandbox and connects directly (or prints the command with --print). Also parameterizes create_ssh_access TTL instead of hardcoding 60 min. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f27a5e187a
commit
4fe05e8a71
5 changed files with 166 additions and 5 deletions
|
|
@ -62,6 +62,8 @@ enum Command {
|
|||
Cp(arc_workflows::cli::cp::CpArgs),
|
||||
/// Get a preview URL for a port on a run's sandbox
|
||||
Preview(arc_workflows::cli::preview::PreviewArgs),
|
||||
/// SSH into a run's Daytona sandbox
|
||||
Ssh(arc_workflows::cli::ssh::SshArgs),
|
||||
/// List and test LLM models
|
||||
Model {
|
||||
#[command(subcommand)]
|
||||
|
|
@ -180,6 +182,7 @@ async fn main_inner() -> Result<()> {
|
|||
Command::Parse(_) => "parse",
|
||||
Command::Cp(_) => "cp",
|
||||
Command::Preview(_) => "preview",
|
||||
Command::Ssh(_) => "ssh",
|
||||
Command::Model { .. } => "model",
|
||||
#[cfg(feature = "server")]
|
||||
Command::Serve(_) => "serve",
|
||||
|
|
@ -355,6 +358,9 @@ async fn main_inner() -> Result<()> {
|
|||
Command::Preview(args) => {
|
||||
arc_workflows::cli::preview::preview_command(args).await?;
|
||||
}
|
||||
Command::Ssh(args) => {
|
||||
arc_workflows::cli::ssh::ssh_command(args).await?;
|
||||
}
|
||||
Command::Model { command } => {
|
||||
let cli_config = cli_config::load_cli_config(None)?;
|
||||
let resolved =
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ pub mod project_config;
|
|||
pub mod run;
|
||||
pub mod run_config;
|
||||
pub mod runs;
|
||||
pub mod ssh;
|
||||
pub mod validate;
|
||||
|
||||
use std::path::Path;
|
||||
|
|
|
|||
154
lib/crates/arc-workflows/src/cli/ssh.rs
Normal file
154
lib/crates/arc-workflows/src/cli/ssh.rs
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
use anyhow::{bail, Context, Result};
|
||||
use clap::Args;
|
||||
use tracing::info;
|
||||
|
||||
use crate::cli::runs::{default_runs_base, find_run_by_prefix};
|
||||
use crate::sandbox_record::SandboxRecord;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct SshArgs {
|
||||
/// Run ID or prefix
|
||||
pub run: String,
|
||||
/// SSH access expiry in minutes (default 60)
|
||||
#[arg(long, default_value = "60")]
|
||||
pub ttl: f64,
|
||||
/// Print the SSH command instead of connecting
|
||||
#[arg(long)]
|
||||
pub print: bool,
|
||||
}
|
||||
|
||||
fn validate_provider(record: &SandboxRecord) -> Result<()> {
|
||||
if record.provider != "daytona" {
|
||||
bail!(
|
||||
"SSH access is only supported for Daytona sandboxes (this run uses '{}')",
|
||||
record.provider
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_output(ssh_command: &str) -> String {
|
||||
format!("{ssh_command}\n")
|
||||
}
|
||||
|
||||
pub async fn ssh_command(args: SshArgs) -> Result<()> {
|
||||
let base = default_runs_base();
|
||||
let run_dir = find_run_by_prefix(&base, &args.run)?;
|
||||
let sandbox_json = run_dir.join("sandbox.json");
|
||||
let record = SandboxRecord::load(&sandbox_json).context(
|
||||
"Failed to load sandbox.json — was this run started with a recent version of arc?",
|
||||
)?;
|
||||
|
||||
validate_provider(&record)?;
|
||||
|
||||
let name = record
|
||||
.identifier
|
||||
.as_deref()
|
||||
.context("Daytona sandbox record missing identifier (sandbox name)")?;
|
||||
|
||||
info!(run_id = %args.run, ttl_minutes = args.ttl, "Creating SSH access");
|
||||
|
||||
let client = daytona_sdk::Client::new()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create Daytona client: {e}"))?;
|
||||
|
||||
let sdk_sandbox = client
|
||||
.get(name)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to reconnect to Daytona sandbox '{name}': {e}"))?;
|
||||
|
||||
let daytona = crate::daytona_sandbox::DaytonaSandbox::from_existing(client, sdk_sandbox);
|
||||
|
||||
let ssh_cmd = daytona
|
||||
.create_ssh_access(Some(args.ttl))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
if args.print {
|
||||
let output = format_output(&ssh_cmd);
|
||||
print!("{output}");
|
||||
} else {
|
||||
exec_ssh(&ssh_cmd)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn exec_ssh(ssh_cmd: &str) -> Result<()> {
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
let parts: Vec<&str> = ssh_cmd.split_whitespace().collect();
|
||||
if parts.is_empty() {
|
||||
bail!("Empty SSH command returned from Daytona");
|
||||
}
|
||||
let err = std::process::Command::new(parts[0])
|
||||
.args(&parts[1..])
|
||||
.exec();
|
||||
// exec() only returns on error
|
||||
Err(anyhow::anyhow!("Failed to exec SSH: {err}"))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn exec_ssh(ssh_cmd: &str) -> Result<()> {
|
||||
bail!("Direct SSH connection is only supported on Unix systems; use --print instead");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validate_provider_rejects_local() {
|
||||
let record = SandboxRecord {
|
||||
provider: "local".to_string(),
|
||||
working_directory: "/tmp".to_string(),
|
||||
identifier: None,
|
||||
host_working_directory: None,
|
||||
container_mount_point: None,
|
||||
data_host: None,
|
||||
};
|
||||
let err = validate_provider(&record).unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("SSH access is only supported for Daytona sandboxes"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_provider_rejects_docker() {
|
||||
let record = SandboxRecord {
|
||||
provider: "docker".to_string(),
|
||||
working_directory: "/workspace".to_string(),
|
||||
identifier: None,
|
||||
host_working_directory: None,
|
||||
container_mount_point: None,
|
||||
data_host: None,
|
||||
};
|
||||
let err = validate_provider(&record).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("this run uses 'docker'"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_provider_accepts_daytona() {
|
||||
let record = SandboxRecord {
|
||||
provider: "daytona".to_string(),
|
||||
working_directory: "/home/daytona/workspace".to_string(),
|
||||
identifier: Some("sandbox-abc".to_string()),
|
||||
host_working_directory: None,
|
||||
container_mount_point: None,
|
||||
data_host: None,
|
||||
};
|
||||
validate_provider(&record).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_output_produces_ssh_command_with_newline() {
|
||||
let output = format_output("ssh -p 2222 daytona@sandbox-123.daytona.work");
|
||||
assert_eq!(output, "ssh -p 2222 daytona@sandbox-123.daytona.work\n");
|
||||
}
|
||||
}
|
||||
|
|
@ -216,10 +216,10 @@ impl DaytonaSandbox {
|
|||
}
|
||||
|
||||
/// Create SSH access and return the connection command string.
|
||||
pub async fn create_ssh_access(&self) -> Result<String, String> {
|
||||
pub async fn create_ssh_access(&self, ttl_minutes: Option<f64>) -> Result<String, String> {
|
||||
let sandbox = self.sandbox()?;
|
||||
let dto = sandbox
|
||||
.create_ssh_access(Some(60.0))
|
||||
.create_ssh_access(ttl_minutes)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create SSH access: {e}"))?;
|
||||
Ok(dto.ssh_command)
|
||||
|
|
@ -800,7 +800,7 @@ impl Sandbox for DaytonaSandbox {
|
|||
}
|
||||
|
||||
async fn ssh_access_command(&self) -> Result<Option<String>, String> {
|
||||
self.create_ssh_access().await.map(Some)
|
||||
self.create_ssh_access(Some(60.0)).await.map(Some)
|
||||
}
|
||||
|
||||
fn origin_url(&self) -> Option<&str> {
|
||||
|
|
|
|||
|
|
@ -1345,7 +1345,7 @@ async fn daytona_ssh_access() {
|
|||
let env = create_env().await;
|
||||
env.initialize().await.unwrap();
|
||||
|
||||
let ssh_command = env.create_ssh_access().await.unwrap();
|
||||
let ssh_command = env.create_ssh_access(Some(60.0)).await.unwrap();
|
||||
assert!(!ssh_command.is_empty(), "ssh_command should not be empty");
|
||||
assert!(
|
||||
ssh_command.contains("ssh"),
|
||||
|
|
@ -1360,7 +1360,7 @@ async fn daytona_ssh_access() {
|
|||
async fn daytona_ssh_access_before_init_fails() {
|
||||
let env = create_env().await;
|
||||
|
||||
let result = env.create_ssh_access().await;
|
||||
let result = env.create_ssh_access(Some(60.0)).await;
|
||||
assert!(result.is_err(), "should fail before initialize()");
|
||||
assert!(
|
||||
result.unwrap_err().contains("not initialized"),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue