Skip to content

Commit e15b0d2

Browse files
committed
refactor: restructure codebase with modular command and utility organization
1 parent 62bbee8 commit e15b0d2

12 files changed

Lines changed: 1058 additions & 857 deletions

File tree

src/commands/download.rs

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
// Copyright 2025 Lablup Inc. and Jeongkyu Shin
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
use anyhow::{Context, Result};
16+
use owo_colors::OwoColorize;
17+
use std::path::Path;
18+
use tokio::fs;
19+
20+
use crate::executor::{self, ParallelExecutor};
21+
use crate::ssh::SshClient;
22+
use crate::ui::OutputFormatter;
23+
24+
use super::upload::FileTransferParams;
25+
26+
pub async fn download_file(
27+
params: FileTransferParams<'_>,
28+
source: &str,
29+
destination: &Path,
30+
) -> Result<()> {
31+
// Create destination directory if it doesn't exist
32+
if !destination.exists() {
33+
fs::create_dir_all(destination)
34+
.await
35+
.with_context(|| format!("Failed to create destination directory: {destination:?}"))?;
36+
}
37+
38+
let key_path_str = params.key_path.map(|p| p.to_string_lossy().to_string());
39+
let executor = ParallelExecutor::new_with_all_options(
40+
params.nodes.clone(),
41+
params.max_parallel,
42+
key_path_str.clone(),
43+
params.strict_mode,
44+
params.use_agent,
45+
params.use_password,
46+
);
47+
48+
// Check if source contains glob pattern
49+
let has_glob = source.contains('*') || source.contains('?') || source.contains('[');
50+
51+
// Check if source is a directory (for recursive download)
52+
let is_directory = if params.recursive && !has_glob {
53+
// Use a test command to check if source is a directory
54+
let test_cmd = format!("test -d '{source}' && echo 'dir' || echo 'file'");
55+
let test_results = executor.execute(&test_cmd).await?;
56+
test_results.iter().any(|r| {
57+
r.result
58+
.as_ref()
59+
.is_ok_and(|res| String::from_utf8_lossy(&res.output).trim() == "dir")
60+
})
61+
} else {
62+
false
63+
};
64+
65+
if is_directory {
66+
// Recursive directory download using SFTP
67+
println!(
68+
"\n{} {} {} {} from {} nodes {}\n",
69+
"▶".cyan(),
70+
"Recursively downloading directory".cyan().bold(),
71+
source.green(),
72+
"from".dimmed(),
73+
params.nodes.len().to_string().yellow(),
74+
"(SFTP)".dimmed()
75+
);
76+
77+
let mut total_success = 0;
78+
let mut total_failed = 0;
79+
80+
// Download the entire directory from each node
81+
for node in &params.nodes {
82+
let node_dir = destination.join(node.to_string());
83+
84+
println!(
85+
"\n{} {} {} {} {:?}",
86+
"▶".cyan(),
87+
"Downloading from".cyan(),
88+
node.to_string().bold(),
89+
"to".dimmed(),
90+
node_dir
91+
);
92+
93+
// Use the download_dir_from_node function directly
94+
let result = executor::download_dir_from_node(
95+
node.clone(),
96+
source,
97+
&node_dir,
98+
key_path_str.as_deref(),
99+
params.strict_mode,
100+
params.use_agent,
101+
params.use_password,
102+
)
103+
.await;
104+
105+
match result {
106+
Ok(_) => {
107+
println!(
108+
" {} {}",
109+
"●".green(),
110+
"Successfully downloaded directory".green()
111+
);
112+
total_success += 1;
113+
}
114+
Err(e) => {
115+
println!(
116+
" {} {} {}",
117+
"●".red(),
118+
"Failed to download directory:".red(),
119+
e.to_string().dimmed()
120+
);
121+
total_failed += 1;
122+
}
123+
}
124+
}
125+
126+
println!(
127+
"{}",
128+
OutputFormatter::format_summary(
129+
total_success + total_failed,
130+
total_success,
131+
total_failed
132+
)
133+
);
134+
135+
if total_failed > 0 {
136+
std::process::exit(1);
137+
}
138+
} else if has_glob {
139+
println!(
140+
"Resolving glob pattern '{}' on {} nodes...",
141+
source,
142+
params.nodes.len()
143+
);
144+
145+
// First, execute ls command with glob to find matching files on first node
146+
let test_node = params
147+
.nodes
148+
.first()
149+
.ok_or_else(|| anyhow::anyhow!("No nodes available"))?;
150+
let glob_command = format!("ls -1 {source} 2>/dev/null || true");
151+
152+
let mut test_client = SshClient::new(
153+
test_node.host.clone(),
154+
test_node.port,
155+
test_node.username.clone(),
156+
);
157+
158+
let glob_result = test_client
159+
.connect_and_execute_with_host_check(
160+
&glob_command,
161+
params.key_path,
162+
Some(params.strict_mode),
163+
params.use_agent,
164+
params.use_password,
165+
)
166+
.await?;
167+
168+
let remote_files: Vec<String> = String::from_utf8_lossy(&glob_result.output)
169+
.lines()
170+
.filter(|line| !line.is_empty())
171+
.map(|s| s.to_string())
172+
.collect();
173+
174+
if remote_files.is_empty() {
175+
anyhow::bail!("No files found matching pattern: {}", source);
176+
}
177+
178+
println!(
179+
"\n{} {} {} file(s) matching pattern:",
180+
"▶".cyan(),
181+
"Found".bold(),
182+
remote_files.len().to_string().yellow()
183+
);
184+
for file in &remote_files {
185+
println!(" {} {}", "•".dimmed(), file.cyan());
186+
}
187+
println!("{} {:?}\n", "Destination:".bold(), destination);
188+
189+
// Download each file
190+
let results = executor
191+
.download_files(remote_files.clone(), destination)
192+
.await?;
193+
194+
// Print results
195+
let mut total_success = 0;
196+
let mut total_failed = 0;
197+
198+
for result in &results {
199+
result.print_summary();
200+
if result.is_success() {
201+
total_success += 1;
202+
} else {
203+
total_failed += 1;
204+
}
205+
}
206+
207+
println!(
208+
"{}",
209+
OutputFormatter::format_summary(
210+
total_success + total_failed,
211+
total_success,
212+
total_failed
213+
)
214+
);
215+
216+
if total_failed > 0 {
217+
std::process::exit(1);
218+
}
219+
} else {
220+
// Single file download
221+
println!(
222+
"\n{} {} {} from {} nodes to {:?} {}\n",
223+
"▶".cyan(),
224+
"Downloading".cyan().bold(),
225+
source.green(),
226+
params.nodes.len().to_string().yellow(),
227+
destination,
228+
"(SFTP)".dimmed()
229+
);
230+
231+
let results = executor.download_file(source, destination).await?;
232+
233+
// Print results
234+
for result in &results {
235+
result.print_summary();
236+
}
237+
238+
// Print summary
239+
let success_count = results.iter().filter(|r| r.is_success()).count();
240+
let failed_count = results.len() - success_count;
241+
242+
println!(
243+
"{}",
244+
OutputFormatter::format_summary(
245+
success_count + failed_count,
246+
success_count,
247+
failed_count
248+
)
249+
);
250+
251+
if failed_count > 0 {
252+
std::process::exit(1);
253+
}
254+
}
255+
256+
Ok(())
257+
}

src/commands/exec.rs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Copyright 2025 Lablup Inc. and Jeongkyu Shin
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
use anyhow::Result;
16+
use std::path::Path;
17+
18+
use crate::executor::ParallelExecutor;
19+
use crate::node::Node;
20+
use crate::ssh::known_hosts::StrictHostKeyChecking;
21+
use crate::ui::OutputFormatter;
22+
use crate::utils::output::save_outputs_to_files;
23+
24+
pub struct ExecuteCommandParams<'a> {
25+
pub nodes: Vec<Node>,
26+
pub command: &'a str,
27+
pub max_parallel: usize,
28+
pub key_path: Option<&'a Path>,
29+
pub verbose: bool,
30+
pub strict_mode: StrictHostKeyChecking,
31+
pub use_agent: bool,
32+
pub use_password: bool,
33+
pub output_dir: Option<&'a Path>,
34+
}
35+
36+
pub async fn execute_command(params: ExecuteCommandParams<'_>) -> Result<()> {
37+
println!(
38+
"{}",
39+
OutputFormatter::format_command_header(params.command, params.nodes.len())
40+
);
41+
42+
let key_path = params.key_path.map(|p| p.to_string_lossy().to_string());
43+
let executor = ParallelExecutor::new_with_all_options(
44+
params.nodes,
45+
params.max_parallel,
46+
key_path,
47+
params.strict_mode,
48+
params.use_agent,
49+
params.use_password,
50+
);
51+
52+
let results = executor.execute(params.command).await?;
53+
54+
// Save outputs to files if output_dir is specified
55+
if let Some(dir) = params.output_dir {
56+
save_outputs_to_files(&results, dir, params.command).await?;
57+
}
58+
59+
// Print results
60+
for result in &results {
61+
result.print_output(params.verbose);
62+
}
63+
64+
// Print summary
65+
let success_count = results.iter().filter(|r| r.is_success()).count();
66+
let failed_count = results.len() - success_count;
67+
68+
println!(
69+
"{}",
70+
OutputFormatter::format_summary(results.len(), success_count, failed_count)
71+
);
72+
73+
if failed_count > 0 {
74+
std::process::exit(1);
75+
}
76+
77+
Ok(())
78+
}

src/commands/list.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Copyright 2025 Lablup Inc. and Jeongkyu Shin
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
use owo_colors::OwoColorize;
16+
17+
use crate::config::{Config, NodeConfig};
18+
19+
pub fn list_clusters(config: &Config) {
20+
if config.clusters.is_empty() {
21+
println!("{}", "No clusters configured".dimmed());
22+
return;
23+
}
24+
25+
println!("\n{} {}\n", "▶".cyan(), "Available clusters".bold());
26+
for (name, cluster) in &config.clusters {
27+
println!(
28+
" {} {} ({} {})",
29+
"●".blue(),
30+
name.bold(),
31+
cluster.nodes.len().to_string().yellow(),
32+
if cluster.nodes.len() == 1 {
33+
"node"
34+
} else {
35+
"nodes"
36+
}
37+
);
38+
for node_config in &cluster.nodes {
39+
let node_str = match node_config {
40+
NodeConfig::Simple(s) => s.clone(),
41+
NodeConfig::Detailed { host, .. } => host.clone(),
42+
};
43+
println!(" {} {}", "•".dimmed(), node_str.dimmed());
44+
}
45+
}
46+
println!();
47+
}

0 commit comments

Comments
 (0)