feat: add output pipeline

This commit is contained in:
2026-08-23 05:54:38 +05:00
parent 27cf814c39
commit eeb5aa9e84
6 changed files with 173 additions and 93 deletions
+3 -1
View File
@@ -130,7 +130,9 @@ commands не пустые для секций, которые присутст
- жёлтый [Remote] ... Success! - жёлтый [Remote] ... Success!
- `Deploy successful!` / красный блок ошибки с stderr. - `Deploy successful!` / красный блок ошибки с stderr.
3. Тайминги каждого шага (Instant::now). 3. Тайминги каждого шага (Instant::now).
4. Опционально: indicatif progress-bar на этапе передачи файлов. 4. indicatif progress-bar решено не добавлять: статистика файлов/байт и тайминги
уже дают достаточно информации, а прогресс-бар усложнил бы вывод при
параллельных логах команд (ADR-002, минимум зависимостей).
Критерий приёмки: вывод визуально совпадает с примером UX из PRD; Критерий приёмки: вывод визуально совпадает с примером UX из PRD;
--dry-run печатает весь план всех трёх фаз без единого действия. --dry-run печатает весь план всех трёх фаз без единого действия.
+152
View File
@@ -0,0 +1,152 @@
use std::time::{Duration, Instant};
use anyhow::Result;
use colored::Colorize;
use crate::config::Project;
use crate::{local, ssh, sync};
pub fn deploy(name: &str, project: &Project, dry_run: bool) -> Result<()> {
println!("deploying '{name}'");
let local_cmds = project
.local
.as_ref()
.map_or([].as_slice(), |c| c.commands.as_slice());
let remote_cmds = project
.remote
.as_ref()
.map_or([].as_slice(), |c| c.commands.as_slice());
if dry_run {
print_plan(project, local_cmds, remote_cmds);
return Ok(());
}
if local_cmds.is_empty() && project.sync.is_empty() && remote_cmds.is_empty() {
println!("nothing to do: no local commands, sync rules or remote commands");
return Ok(());
}
let total = Instant::now();
for cmd in local_cmds {
let step = Instant::now();
if let Err(err) = local::execute(cmd, &project.workdir) {
fail_line("🟢 [Local]", &format!("running: {cmd}..."), err.to_string());
return Err(err);
}
success_line(
"🟢 [Local]",
&format!("running: {cmd}..."),
"Success!",
step.elapsed(),
);
}
if !project.sync.is_empty() || !remote_cmds.is_empty() {
let mut session = connect(project)?;
for rule in &project.sync {
let step = Instant::now();
let target = rule.target.to_string_lossy().to_string();
let result = sync::upload(&mut session, &rule.source, &target);
let label = format!("transferring {} to {}...", rule.source.display(), target);
match result {
Ok(stats) => success_line(
"🔵 [SFTP]",
&label,
&format!("Done! ({stats})"),
step.elapsed(),
),
Err(err) => {
fail_line("🔵 [SFTP]", &label, err.to_string());
return Err(err);
}
}
}
for cmd in remote_cmds {
let step = Instant::now();
if let Err(err) = session.exec(cmd) {
fail_line(
"🟡 [Remote]",
&format!("running: {cmd}..."),
err.to_string(),
);
return Err(err);
}
success_line(
"🟡 [Remote]",
&format!("running: {cmd}..."),
"Success!",
step.elapsed(),
);
}
}
println!(
"🎉 {} (total {})",
"Deploy successful!".green().bold(),
fmt_dur(total.elapsed())
);
Ok(())
}
fn print_plan(project: &Project, local_cmds: &[String], remote_cmds: &[String]) {
println!("dry-run plan:");
for cmd in local_cmds {
println!(" 🟢 [Local] {cmd}");
}
for rule in &project.sync {
println!(
" 🔵 [SFTP] {} -> {}",
rule.source.display(),
rule.target.display()
);
}
for cmd in remote_cmds {
println!(" 🟡 [Remote] {cmd}");
}
println!("{}", "nothing executed".dimmed());
}
fn connect(project: &Project) -> Result<ssh::Session> {
let target = ssh::Target {
host_alias: project.host.clone(),
user: project.user.clone(),
port: project.port,
key_path: project.key_path.clone(),
};
let resolved = ssh::resolve(&target)?;
ssh::connect(&resolved)
}
fn success_line(tag: &str, action: &str, status: &str, took: Duration) {
println!(
"{} {} {} ({})",
tag.green().bold(),
action,
status.green(),
fmt_dur(took).dimmed()
);
}
fn fail_line(tag: &str, action: &str, message: String) {
println!(
"{} {} {}",
tag.green().bold(),
action,
"FAILED".red().bold()
);
eprintln!("{}", message.red());
}
fn fmt_dur(d: Duration) -> String {
let ms = d.as_millis();
if ms < 1000 {
format!("{ms}ms")
} else {
format!("{:.2}s", d.as_secs_f64())
}
}
+12 -17
View File
@@ -3,19 +3,10 @@ use std::process::Command;
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
pub fn run_commands(commands: &[String], workdir: &Path) -> Result<()> { pub fn execute(cmd: &str, workdir: &Path) -> Result<()> {
for cmd in commands {
run_command(cmd, workdir)?;
}
Ok(())
}
fn run_command(cmd: &str, workdir: &Path) -> Result<()> {
let mut command = shell_command(cmd); let mut command = shell_command(cmd);
command.current_dir(workdir); command.current_dir(workdir);
println!("[Local] running: {cmd} ...");
let status = command let status = command
.status() .status()
.with_context(|| format!("failed to spawn '{cmd}' in {}", workdir.display()))?; .with_context(|| format!("failed to spawn '{cmd}' in {}", workdir.display()))?;
@@ -25,7 +16,7 @@ fn run_command(cmd: &str, workdir: &Path) -> Result<()> {
Some(code) => format!("exit code {code}"), Some(code) => format!("exit code {code}"),
None => "terminated by signal".to_string(), None => "terminated by signal".to_string(),
}; };
bail!("[Local] running: {cmd} ... FAILED ({code})"); bail!("{cmd} FAILED ({code})");
} }
Ok(()) Ok(())
} }
@@ -85,20 +76,24 @@ mod tests {
#[test] #[test]
fn successful_commands_pass_silently() { fn successful_commands_pass_silently() {
let cmds = vec![ok_cmd(), ok_cmd()]; execute(&ok_cmd(), Path::new(".")).unwrap();
run_commands(&cmds, Path::new(".")).unwrap(); execute(&ok_cmd(), Path::new(".")).unwrap();
} }
#[test] #[test]
fn fail_fast_stops_on_first_error_with_exit_code() { fn fail_fast_stops_on_first_error_with_exit_code() {
let workdir = tmpdir("failfast"); let workdir = tmpdir("failfast");
let marker = workdir.join("first-ran.txt"); let marker = workdir.join("first-ran.txt");
let cmds = vec![ let cmds = [
marker_cmd("first-ran.txt"), marker_cmd("first-ran.txt"),
fail_cmd(7), fail_cmd(7),
marker_cmd("never.txt"), marker_cmd("never.txt"),
]; ];
let err = run_commands(&cmds, &workdir).unwrap_err(); let err = cmds
.iter()
.map(|cmd| execute(cmd, &workdir))
.collect::<Result<Vec<_>>>()
.unwrap_err();
assert!(err.to_string().contains("exit code 7"), "{err:#}"); assert!(err.to_string().contains("exit code 7"), "{err:#}");
assert!(marker.exists(), "first command did not run"); assert!(marker.exists(), "first command did not run");
assert!( assert!(
@@ -111,7 +106,7 @@ mod tests {
#[test] #[test]
fn commands_run_in_workdir() { fn commands_run_in_workdir() {
let workdir = tmpdir("workdir"); let workdir = tmpdir("workdir");
run_commands(&[marker_cmd("in-workdir.txt")], &workdir).unwrap(); execute(&marker_cmd("in-workdir.txt"), &workdir).unwrap();
assert!(workdir.join("in-workdir.txt").exists()); assert!(workdir.join("in-workdir.txt").exists());
let _ = fs::remove_dir_all(workdir); let _ = fs::remove_dir_all(workdir);
} }
@@ -120,7 +115,7 @@ mod tests {
fn missing_workdir_is_contextual_error() { fn missing_workdir_is_contextual_error() {
let missing = tmpdir("gone"); let missing = tmpdir("gone");
fs::remove_dir(&missing).unwrap(); fs::remove_dir(&missing).unwrap();
let err = run_commands(&[ok_cmd()], &missing).unwrap_err(); let err = execute(&ok_cmd(), &missing).unwrap_err();
assert!(err.to_string().contains("failed to spawn"), "{err:#}"); assert!(err.to_string().contains("failed to spawn"), "{err:#}");
} }
} }
+2 -62
View File
@@ -1,5 +1,6 @@
mod cli; mod cli;
mod config; mod config;
mod deploy;
mod local; mod local;
mod ssh; mod ssh;
mod sync; mod sync;
@@ -43,66 +44,5 @@ fn run(cli: &cli::Cli) -> Result<()> {
.get(name) .get(name)
.with_context(|| format!("project '{name}' not found in {}", path.display()))?; .with_context(|| format!("project '{name}' not found in {}", path.display()))?;
let target = match &project.user { deploy::deploy(name, project, cli.dry_run)
Some(user) => format!("{user}@{}:{}", project.host, project.port.unwrap_or(22)),
None => format!("{}:{}", project.host, project.port.unwrap_or(22)),
};
println!(
"deploying '{name}' to {target}: {} local cmd(s), {} sync rule(s), {} remote cmd(s)",
project.local.as_ref().map_or(0, |c| c.commands.len()),
project.sync.len(),
project.remote.as_ref().map_or(0, |c| c.commands.len()),
);
let local_cmds = project
.local
.as_ref()
.map_or([].as_slice(), |c| c.commands.as_slice());
let remote_cmds = project
.remote
.as_ref()
.map_or([].as_slice(), |c| c.commands.as_slice());
if cli.dry_run {
println!("dry-run plan:");
for cmd in local_cmds {
println!(" local : {cmd}");
}
for rule in &project.sync {
println!(
" sync : {} -> {}",
rule.source.display(),
rule.target.display()
);
}
for cmd in remote_cmds {
println!(" remote: {cmd}");
}
} else {
local::run_commands(local_cmds, &project.workdir)?;
println!("[Local] done: {} cmd(s) succeeded", local_cmds.len());
let needs_session = !project.sync.is_empty() || !remote_cmds.is_empty();
let mut session = None;
if needs_session {
let target = ssh::Target {
host_alias: project.host.clone(),
user: project.user.clone(),
port: project.port,
key_path: project.key_path.clone(),
};
let resolved = ssh::resolve(&target)?;
session = Some(ssh::connect(&resolved)?);
}
if let Some(session) = session.as_mut() {
for rule in &project.sync {
sync::upload(session, &rule.source, &rule.target.to_string_lossy())?;
}
for cmd in remote_cmds {
session.exec(cmd)?;
}
println!("[Remote] done: {} cmd(s) succeeded", remote_cmds.len());
}
}
Ok(())
} }
+2 -8
View File
@@ -190,12 +190,7 @@ pub(crate) fn runtime() -> &'static tokio::runtime::Runtime {
} }
pub fn connect(resolved: &Resolved) -> Result<Session> { pub fn connect(resolved: &Resolved) -> Result<Session> {
println!( println!("connecting...");
"connecting to {}@{} (key {}) ...",
resolved.user,
format_host_port(&resolved.host, resolved.port),
resolved.key_path.display()
);
runtime().block_on(async { runtime().block_on(async {
let handler = ClientHandler { let handler = ClientHandler {
host: resolved.host.clone(), host: resolved.host.clone(),
@@ -248,7 +243,6 @@ async fn authenticate(handle: &mut Handle<ClientHandler>, r: &Resolved) -> Resul
impl Session { impl Session {
pub fn exec(&mut self, cmd: &str) -> Result<()> { pub fn exec(&mut self, cmd: &str) -> Result<()> {
println!("[Remote] running: {cmd} ...");
runtime().block_on(async { runtime().block_on(async {
use std::io::Write; use std::io::Write;
@@ -286,7 +280,7 @@ impl Session {
} else { } else {
format!("\nstderr:\n{stderr}") format!("\nstderr:\n{stderr}")
}; };
bail!("[Remote] running: {cmd} ... FAILED (exit code {status}){tail}"); bail!("{cmd} FAILED (exit code {status}){tail}");
} }
Ok(()) Ok(())
}) })
+2 -5
View File
@@ -32,8 +32,7 @@ fn human_size(bytes: u64) -> String {
} }
pub fn upload(session: &mut Session, source: &Path, target: &str) -> Result<Stats> { pub fn upload(session: &mut Session, source: &Path, target: &str) -> Result<Stats> {
println!("[SFTP] transferring {} -> {} ...", source.display(), target); runtime().block_on(async {
let stats = runtime().block_on(async {
if source.is_dir() { if source.is_dir() {
upload_dir(session, source, target).await upload_dir(session, source, target).await
} else if source.is_file() { } else if source.is_file() {
@@ -41,9 +40,7 @@ pub fn upload(session: &mut Session, source: &Path, target: &str) -> Result<Stat
} else { } else {
bail!("sync source '{}' does not exist", source.display()); bail!("sync source '{}' does not exist", source.display());
} }
})?; })
println!("[SFTP] ... Done! ({stats})");
Ok(stats)
} }
async fn open_sftp(session: &mut Session) -> Result<SftpSession> { async fn open_sftp(session: &mut Session) -> Result<SftpSession> {