fix: improve Docker clone reliability and log display

- Use bash with pipefail instead of sh to detect pg_dump failures in pipes
- Switch full clone from binary format (pg_dump -Fc | pg_restore) to plain
  text (pg_dump | psql) for reliable transfer through docker exec
- Add --no-owner --no-acl flags to avoid errors from missing roles
- Extract shared run_pipe_cmd helper with proper error handling
- Remove shell commands from progress events to prevent credential leaks
- Fix process log layout overflow with break-all and block-level details

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 19:41:59 +03:00
parent 1ce5f78de8
commit 20b00e55b0
2 changed files with 78 additions and 61 deletions

View File

@@ -400,6 +400,67 @@ fn pg_dump_shell_cmd(has_local: bool, pg_version: &str, extra_args: &str, source
}
}
async fn run_pipe_cmd(
app: &AppHandle,
clone_id: &str,
pipe_cmd: &str,
label: &str,
) -> TuskResult<std::process::Output> {
// Use bash with pipefail so pg_dump failures are not swallowed
let wrapped = format!("set -o pipefail; {}", pipe_cmd);
emit_progress(app, clone_id, "transfer", 50, label, None);
let output = Command::new("bash")
.args(["-c", &wrapped])
.output()
.await
.map_err(|e| docker_err(format!("{} failed to start: {}", label, e)))?;
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
// Always log stderr if present
if !stderr.is_empty() {
// Truncate for progress display (full log can be long)
let short = if stderr.len() > 500 {
format!("{}...", &stderr[..500])
} else {
stderr.clone()
};
emit_progress(app, clone_id, "transfer", 55, &format!("{}: stderr output", label), Some(&short));
}
// Count DDL statements in stdout for feedback
if !stdout.is_empty() {
let creates = stdout.lines()
.filter(|l| l.starts_with("CREATE") || l.starts_with("ALTER") || l.starts_with("SET"))
.count();
if creates > 0 {
emit_progress(app, clone_id, "transfer", 58, &format!("Applied {} SQL statements", creates), None);
}
}
if !output.status.success() {
let code = output.status.code().unwrap_or(-1);
emit_progress(
app, clone_id, "transfer", 55,
&format!("{} exited with code {}", label, code),
Some(&stderr),
);
// Only hard-fail on connection / fatal errors
if stderr.contains("FATAL") || stderr.contains("could not connect")
|| stderr.contains("No such file") || stderr.contains("password authentication failed")
|| stderr.contains("does not exist") || (stdout.is_empty() && stderr.is_empty())
{
return Err(docker_err(format!("{} failed (exit {}): {}", label, code, stderr)));
}
}
Ok(output)
}
async fn transfer_schema_only(
app: &AppHandle,
clone_id: &str,
@@ -410,43 +471,16 @@ async fn transfer_schema_only(
) -> TuskResult<()> {
let has_local = try_local_pg_dump().await;
let label = if has_local { "local pg_dump" } else { "Docker-based pg_dump" };
emit_progress(app, clone_id, "transfer", 50, &format!("Using {} for schema...", label), None);
emit_progress(app, clone_id, "transfer", 48, &format!("Using {} for schema...", label), None);
let dump_cmd = pg_dump_shell_cmd(has_local, pg_version, "--schema-only", source_url);
let dump_cmd = pg_dump_shell_cmd(has_local, pg_version, "--schema-only --no-owner --no-acl", source_url);
let escaped_db = shell_escape(database);
let pipe_cmd = format!(
"{} | docker exec -i '{}' psql -U postgres -d '{}'",
dump_cmd, shell_escape(container_name), escaped_db
);
let output = Command::new("sh")
.args(["-c", &pipe_cmd])
.output()
.await
.map_err(|e| docker_err(format!("Schema transfer failed: {}", e)))?;
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !output.status.success() {
// psql often emits non-fatal warnings; only fail on actual errors
if stderr.contains("FATAL") || stderr.contains("could not connect") || stderr.contains("No such file") {
emit_progress(app, clone_id, "error", 55, "Schema transfer failed", Some(&stderr));
return Err(docker_err(format!("Schema transfer failed: {}", stderr)));
}
}
// Log any output for debugging
if !stderr.is_empty() {
emit_progress(app, clone_id, "transfer", 55, "Schema transferred with warnings", Some(&stderr));
}
if !stdout.is_empty() {
// Count CREATE statements to give user feedback
let creates = stdout.lines().filter(|l| l.starts_with("CREATE") || l.starts_with("ALTER")).count();
if creates > 0 {
emit_progress(app, clone_id, "transfer", 58, &format!("Applied {} DDL statements", creates), None);
}
}
run_pipe_cmd(app, clone_id, &pipe_cmd, "Schema transfer").await?;
emit_progress(app, clone_id, "transfer", 60, "Schema transferred successfully", None);
Ok(())
@@ -462,34 +496,17 @@ async fn transfer_full_clone(
) -> TuskResult<()> {
let has_local = try_local_pg_dump().await;
let label = if has_local { "local pg_dump" } else { "Docker-based pg_dump" };
emit_progress(app, clone_id, "transfer", 50, &format!("Using {} for full clone...", label), None);
emit_progress(app, clone_id, "transfer", 48, &format!("Using {} for full clone...", label), None);
let dump_cmd = pg_dump_shell_cmd(has_local, pg_version, "-Fc", source_url);
// Use plain text format piped to psql (more reliable than -Fc | pg_restore through docker exec)
let dump_cmd = pg_dump_shell_cmd(has_local, pg_version, "--no-owner --no-acl", source_url);
let escaped_db = shell_escape(database);
let pipe_cmd = format!(
"{} | docker exec -i '{}' pg_restore -U postgres -d '{}' --no-owner",
"{} | docker exec -i '{}' psql -U postgres -d '{}'",
dump_cmd, shell_escape(container_name), escaped_db
);
let output = Command::new("sh")
.args(["-c", &pipe_cmd])
.output()
.await
.map_err(|e| docker_err(format!("Full clone failed: {}", e)))?;
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if !output.status.success() {
if stderr.contains("FATAL") || stderr.contains("could not connect") {
emit_progress(app, clone_id, "error", 55, "Full clone failed", Some(&stderr));
return Err(docker_err(format!("Full clone failed: {}", stderr)));
}
}
// pg_restore often emits warnings about ownership/permissions — log them
if !stderr.is_empty() {
emit_progress(app, clone_id, "transfer", 80, "Clone completed with warnings", Some(&stderr));
}
run_pipe_cmd(app, clone_id, &pipe_cmd, "Full clone").await?;
emit_progress(app, clone_id, "transfer", 85, "Full clone completed", None);
Ok(())
@@ -567,11 +584,11 @@ async fn transfer_sample_data(
};
let pipe_cmd = format!(
"{} | docker exec -i '{}' psql -U postgres -d '{}' -c \"{}\"",
"set -o pipefail; {} | docker exec -i '{}' psql -U postgres -d '{}' -c \"{}\"",
source_cmd, escaped_container, escaped_db, copy_in_sql
);
let output = Command::new("sh")
let output = Command::new("bash")
.args(["-c", &pipe_cmd])
.output()
.await;

View File

@@ -67,17 +67,17 @@ function ProcessLog({
Process Log ({entries.length})
</button>
{logOpen && (
<div className="mt-1.5 rounded-md bg-muted p-3 text-xs font-mono max-h-40 overflow-y-auto">
<div className="mt-1.5 rounded-md bg-muted p-3 text-xs font-mono max-h-40 overflow-auto">
{entries.map((entry, i) => (
<div key={i} className="flex gap-2 leading-5">
<span className="text-muted-foreground shrink-0 w-8 text-right">
<div key={i} className="leading-5 min-w-0">
<span className="text-muted-foreground">
{entry.percent}%
</span>
</span>{" "}
<span>{entry.message}</span>
{entry.detail && (
<span className="text-muted-foreground truncate">
{entry.detail}
</span>
<div className="text-muted-foreground break-all pl-6">
{entry.detail}
</div>
)}
</div>
))}