feat: add output pipeline
This commit is contained in:
@@ -130,7 +130,9 @@ commands не пустые для секций, которые присутст
|
||||
- жёлтый [Remote] ... Success!
|
||||
- `Deploy successful!` / красный блок ошибки с stderr.
|
||||
3. Тайминги каждого шага (Instant::now).
|
||||
4. Опционально: indicatif progress-bar на этапе передачи файлов.
|
||||
4. indicatif progress-bar решено не добавлять: статистика файлов/байт и тайминги
|
||||
уже дают достаточно информации, а прогресс-бар усложнил бы вывод при
|
||||
параллельных логах команд (ADR-002, минимум зависимостей).
|
||||
|
||||
Критерий приёмки: вывод визуально совпадает с примером UX из PRD;
|
||||
--dry-run печатает весь план всех трёх фаз без единого действия.
|
||||
|
||||
+152
@@ -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
@@ -3,19 +3,10 @@ use std::process::Command;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
pub fn run_commands(commands: &[String], workdir: &Path) -> Result<()> {
|
||||
for cmd in commands {
|
||||
run_command(cmd, workdir)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_command(cmd: &str, workdir: &Path) -> Result<()> {
|
||||
pub fn execute(cmd: &str, workdir: &Path) -> Result<()> {
|
||||
let mut command = shell_command(cmd);
|
||||
command.current_dir(workdir);
|
||||
|
||||
println!("[Local] running: {cmd} ...");
|
||||
|
||||
let status = command
|
||||
.status()
|
||||
.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}"),
|
||||
None => "terminated by signal".to_string(),
|
||||
};
|
||||
bail!("[Local] running: {cmd} ... FAILED ({code})");
|
||||
bail!("{cmd} FAILED ({code})");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -85,20 +76,24 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn successful_commands_pass_silently() {
|
||||
let cmds = vec![ok_cmd(), ok_cmd()];
|
||||
run_commands(&cmds, Path::new(".")).unwrap();
|
||||
execute(&ok_cmd(), Path::new(".")).unwrap();
|
||||
execute(&ok_cmd(), Path::new(".")).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fail_fast_stops_on_first_error_with_exit_code() {
|
||||
let workdir = tmpdir("failfast");
|
||||
let marker = workdir.join("first-ran.txt");
|
||||
let cmds = vec![
|
||||
let cmds = [
|
||||
marker_cmd("first-ran.txt"),
|
||||
fail_cmd(7),
|
||||
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!(marker.exists(), "first command did not run");
|
||||
assert!(
|
||||
@@ -111,7 +106,7 @@ mod tests {
|
||||
#[test]
|
||||
fn commands_run_in_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());
|
||||
let _ = fs::remove_dir_all(workdir);
|
||||
}
|
||||
@@ -120,7 +115,7 @@ mod tests {
|
||||
fn missing_workdir_is_contextual_error() {
|
||||
let missing = tmpdir("gone");
|
||||
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:#}");
|
||||
}
|
||||
}
|
||||
|
||||
+2
-62
@@ -1,5 +1,6 @@
|
||||
mod cli;
|
||||
mod config;
|
||||
mod deploy;
|
||||
mod local;
|
||||
mod ssh;
|
||||
mod sync;
|
||||
@@ -43,66 +44,5 @@ fn run(cli: &cli::Cli) -> Result<()> {
|
||||
.get(name)
|
||||
.with_context(|| format!("project '{name}' not found in {}", path.display()))?;
|
||||
|
||||
let target = match &project.user {
|
||||
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(())
|
||||
deploy::deploy(name, project, cli.dry_run)
|
||||
}
|
||||
|
||||
+2
-8
@@ -190,12 +190,7 @@ pub(crate) fn runtime() -> &'static tokio::runtime::Runtime {
|
||||
}
|
||||
|
||||
pub fn connect(resolved: &Resolved) -> Result<Session> {
|
||||
println!(
|
||||
"connecting to {}@{} (key {}) ...",
|
||||
resolved.user,
|
||||
format_host_port(&resolved.host, resolved.port),
|
||||
resolved.key_path.display()
|
||||
);
|
||||
println!("connecting...");
|
||||
runtime().block_on(async {
|
||||
let handler = ClientHandler {
|
||||
host: resolved.host.clone(),
|
||||
@@ -248,7 +243,6 @@ async fn authenticate(handle: &mut Handle<ClientHandler>, r: &Resolved) -> Resul
|
||||
|
||||
impl Session {
|
||||
pub fn exec(&mut self, cmd: &str) -> Result<()> {
|
||||
println!("[Remote] running: {cmd} ...");
|
||||
runtime().block_on(async {
|
||||
use std::io::Write;
|
||||
|
||||
@@ -286,7 +280,7 @@ impl Session {
|
||||
} else {
|
||||
format!("\nstderr:\n{stderr}")
|
||||
};
|
||||
bail!("[Remote] running: {cmd} ... FAILED (exit code {status}){tail}");
|
||||
bail!("{cmd} FAILED (exit code {status}){tail}");
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
|
||||
+2
-5
@@ -32,8 +32,7 @@ fn human_size(bytes: u64) -> String {
|
||||
}
|
||||
|
||||
pub fn upload(session: &mut Session, source: &Path, target: &str) -> Result<Stats> {
|
||||
println!("[SFTP] transferring {} -> {} ...", source.display(), target);
|
||||
let stats = runtime().block_on(async {
|
||||
runtime().block_on(async {
|
||||
if source.is_dir() {
|
||||
upload_dir(session, source, target).await
|
||||
} else if source.is_file() {
|
||||
@@ -41,9 +40,7 @@ pub fn upload(session: &mut Session, source: &Path, target: &str) -> Result<Stat
|
||||
} else {
|
||||
bail!("sync source '{}' does not exist", source.display());
|
||||
}
|
||||
})?;
|
||||
println!("[SFTP] ... Done! ({stats})");
|
||||
Ok(stats)
|
||||
})
|
||||
}
|
||||
|
||||
async fn open_sftp(session: &mut Session) -> Result<SftpSession> {
|
||||
|
||||
Reference in New Issue
Block a user