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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user