Skip to content

Commit ccec98e

Browse files
authored
feat: add native SFTP directory operations and internalize SSH client (#5)
* refactor: internalize async-ssh2-tokio as tokio_client module with comprehensive test coverage * feat: add native SFTP directory upload/download operations and remove unused code * update: Distribution codesign * fix: resolve clippy warnings for CI compliance * fix: resolve additional clippy warnings in main.rs
1 parent 071f9a1 commit ccec98e

21 files changed

Lines changed: 1552 additions & 2095 deletions

.github/workflows/release.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ jobs:
113113
run: cargo build --release --target ${{ matrix.target }} --locked
114114

115115
# 7) macOS code signing
116-
- name: Import Developer ID certificate
116+
- name: Import Distribution certificate
117117
if: runner.os == 'macOS'
118118
uses: apple-actions/import-codesign-certs@v3
119119
with:
@@ -125,7 +125,7 @@ jobs:
125125
run: |
126126
BIN=target/${{ matrix.target }}/release/${{ matrix.artifact_name }}
127127
codesign --force --timestamp --options runtime \
128-
--sign "Developer ID Application" "$BIN"
128+
--sign "Distribution" "$BIN"
129129
130130
# 8) Package binaries
131131
- name: Package Linux binary (tar.gz)

Cargo.lock

Lines changed: 2 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
[package]
22
name = "bssh"
33
version = "0.3.0"
4-
edition = "2024"
5-
authors = ["Jeongkyu Shin"]
4+
authors = ["Jeongkyu Shin <inureyes@gmail.com>"]
65
description = "Parallel SSH command execution tool for cluster management"
76
license = "Apache-2.0"
7+
repository = "https://github.com/lablup/bssh"
8+
readme = "README.md"
9+
keywords = ["cli", "rust"]
10+
categories = ["command-line-utilities"]
11+
edition = "2024"
812

913
[dependencies]
1014
tokio = { version = "1", features = ["full"] }
11-
async-ssh2-tokio = "0.9"
15+
russh = "0.52.1"
16+
russh-sftp = "2.1.1"
1217
clap = { version = "4", features = ["derive", "env"] }
1318
anyhow = "1"
1419
thiserror = "2"

src/executor.rs

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -433,18 +433,58 @@ async fn upload_to_node(
433433

434434
let key_path = key_path.map(Path::new);
435435

436+
// Check if the local path is a directory
437+
if local_path.is_dir() {
438+
client
439+
.upload_dir(
440+
local_path,
441+
remote_path,
442+
key_path,
443+
Some(strict_mode),
444+
use_agent,
445+
)
446+
.await
447+
} else {
448+
client
449+
.upload_file(
450+
local_path,
451+
remote_path,
452+
key_path,
453+
Some(strict_mode),
454+
use_agent,
455+
)
456+
.await
457+
}
458+
}
459+
460+
async fn download_from_node(
461+
node: Node,
462+
remote_path: &str,
463+
local_path: &Path,
464+
key_path: Option<&str>,
465+
strict_mode: StrictHostKeyChecking,
466+
use_agent: bool,
467+
) -> Result<std::path::PathBuf> {
468+
let mut client = SshClient::new(node.host.clone(), node.port, node.username.clone());
469+
470+
let key_path = key_path.map(Path::new);
471+
472+
// This function handles both files and directories
473+
// The caller should check if it's a directory and use the appropriate method
436474
client
437-
.upload_file(
438-
local_path,
475+
.download_file(
439476
remote_path,
477+
local_path,
440478
key_path,
441479
Some(strict_mode),
442480
use_agent,
443481
)
444-
.await
482+
.await?;
483+
484+
Ok(local_path.to_path_buf())
445485
}
446486

447-
async fn download_from_node(
487+
pub async fn download_dir_from_node(
448488
node: Node,
449489
remote_path: &str,
450490
local_path: &Path,
@@ -457,7 +497,7 @@ async fn download_from_node(
457497
let key_path = key_path.map(Path::new);
458498

459499
client
460-
.download_file(
500+
.download_dir(
461501
remote_path,
462502
local_path,
463503
key_path,

src/main.rs

Lines changed: 49 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -500,14 +500,16 @@ async fn upload_file(
500500
for file in &files {
501501
let remote_path = if is_dir_destination {
502502
// If destination is a directory or multiple files
503-
if params.recursive && base_dir.is_some() {
504-
// Preserve directory structure for recursive uploads
505-
let relative_path = file.strip_prefix(base_dir.unwrap()).unwrap_or(file);
506-
let remote_relative = relative_path.to_string_lossy();
507-
508-
// Create remote directory structure if needed
509-
if let Some(parent) = relative_path.parent() {
510-
if !parent.as_os_str().is_empty() {
503+
if params.recursive {
504+
if let Some(base) = base_dir {
505+
// Preserve directory structure for recursive uploads
506+
let relative_path = file.strip_prefix(base).unwrap_or(file);
507+
let remote_relative = relative_path.to_string_lossy();
508+
509+
// Create remote directory structure if needed
510+
if let Some(parent) = relative_path.parent()
511+
&& !parent.as_os_str().is_empty()
512+
{
511513
let remote_dir = if destination.ends_with('/') {
512514
format!("{destination}{}", parent.display())
513515
} else {
@@ -517,12 +519,23 @@ async fn upload_file(
517519
let mkdir_cmd = format!("mkdir -p '{remote_dir}'");
518520
let _ = executor.execute(&mkdir_cmd).await;
519521
}
520-
}
521522

522-
if destination.ends_with('/') {
523-
format!("{destination}{remote_relative}")
523+
if destination.ends_with('/') {
524+
format!("{destination}{remote_relative}")
525+
} else {
526+
format!("{destination}/{remote_relative}")
527+
}
524528
} else {
525-
format!("{destination}/{remote_relative}")
529+
// No base dir, just use filename
530+
let filename = file
531+
.file_name()
532+
.ok_or_else(|| anyhow::anyhow!("Failed to get filename from {:?}", file))?
533+
.to_string_lossy();
534+
if destination.ends_with('/') {
535+
format!("{destination}{filename}")
536+
} else {
537+
format!("{destination}/{filename}")
538+
}
526539
}
527540
} else {
528541
// Non-recursive: just append filename
@@ -699,77 +712,40 @@ async fn download_file(
699712
};
700713

701714
if is_directory {
702-
// Recursive directory download
715+
// Recursive directory download using SFTP
703716
println!(
704-
"Recursively downloading directory {source} from {} nodes",
717+
"Recursively downloading directory {source} from {} nodes using SFTP",
705718
params.nodes.len()
706719
);
707720

708-
// Find all files in the directory recursively
709-
let find_cmd = format!("find '{source}' -type f 2>/dev/null || find '{source}' -type f");
710-
let find_results = executor.execute(&find_cmd).await?;
711-
712721
let mut total_success = 0;
713722
let mut total_failed = 0;
714723

715-
for (node_idx, result) in find_results.iter().enumerate() {
716-
if let Ok(cmd_result) = &result.result {
717-
let stdout = String::from_utf8_lossy(&cmd_result.output);
718-
let files: Vec<String> = stdout
719-
.lines()
720-
.filter(|line| !line.is_empty())
721-
.map(|s| s.to_string())
722-
.collect();
723-
724-
if files.is_empty() {
725-
println!("No files found in directory on {}", params.nodes[node_idx]);
726-
continue;
727-
}
728-
729-
println!(
730-
"\nDownloading {} files from {}",
731-
files.len(),
732-
params.nodes[node_idx]
733-
);
724+
// Download the entire directory from each node
725+
for node in &params.nodes {
726+
let node_dir = destination.join(node.to_string());
734727

735-
for remote_file in files {
736-
// Calculate relative path from source directory
737-
let relative_path = remote_file
738-
.strip_prefix(source)
739-
.unwrap_or(&remote_file)
740-
.trim_start_matches('/');
741-
742-
// Create local file path preserving directory structure
743-
let local_file = destination
744-
.join(params.nodes[node_idx].to_string())
745-
.join(relative_path);
746-
747-
// Create parent directory if needed
748-
if let Some(parent) = local_file.parent() {
749-
fs::create_dir_all(parent).await?;
750-
}
728+
println!("\nDownloading directory from {node} to {node_dir:?}");
751729

752-
// Download the file using the executor's download method
753-
let single_node = vec![params.nodes[node_idx].clone()];
754-
let single_executor = ParallelExecutor::new_with_strict_mode_and_agent(
755-
single_node,
756-
1,
757-
key_path_str.clone(),
758-
params.strict_mode,
759-
params.use_agent,
760-
);
761-
762-
let download_results = single_executor
763-
.download_file(&remote_file, &local_file)
764-
.await?;
730+
// Use the download_dir_from_node function directly
731+
let result = bssh::executor::download_dir_from_node(
732+
node.clone(),
733+
source,
734+
&node_dir,
735+
key_path_str.as_deref(),
736+
params.strict_mode,
737+
params.use_agent,
738+
)
739+
.await;
765740

766-
if download_results.iter().any(|r| r.is_success()) {
767-
println!(" ✓ Downloaded: {remote_file} -> {local_file:?}");
768-
total_success += 1;
769-
} else {
770-
println!(" ✗ Failed: {remote_file}");
771-
total_failed += 1;
772-
}
741+
match result {
742+
Ok(_) => {
743+
println!(" ✓ Successfully downloaded directory");
744+
total_success += 1;
745+
}
746+
Err(e) => {
747+
println!(" ✗ Failed to download directory: {e}");
748+
total_failed += 1;
773749
}
774750
}
775751
}

0 commit comments

Comments
 (0)