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(
|
async fn transfer_schema_only(
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
clone_id: &str,
|
clone_id: &str,
|
||||||
@@ -410,43 +471,16 @@ async fn transfer_schema_only(
|
|||||||
) -> TuskResult<()> {
|
) -> TuskResult<()> {
|
||||||
let has_local = try_local_pg_dump().await;
|
let has_local = try_local_pg_dump().await;
|
||||||
let label = if has_local { "local pg_dump" } else { "Docker-based pg_dump" };
|
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 escaped_db = shell_escape(database);
|
||||||
let pipe_cmd = format!(
|
let pipe_cmd = format!(
|
||||||
"{} | docker exec -i '{}' psql -U postgres -d '{}'",
|
"{} | docker exec -i '{}' psql -U postgres -d '{}'",
|
||||||
dump_cmd, shell_escape(container_name), escaped_db
|
dump_cmd, shell_escape(container_name), escaped_db
|
||||||
);
|
);
|
||||||
|
|
||||||
let output = Command::new("sh")
|
run_pipe_cmd(app, clone_id, &pipe_cmd, "Schema transfer").await?;
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
emit_progress(app, clone_id, "transfer", 60, "Schema transferred successfully", None);
|
emit_progress(app, clone_id, "transfer", 60, "Schema transferred successfully", None);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -462,34 +496,17 @@ async fn transfer_full_clone(
|
|||||||
) -> TuskResult<()> {
|
) -> TuskResult<()> {
|
||||||
let has_local = try_local_pg_dump().await;
|
let has_local = try_local_pg_dump().await;
|
||||||
let label = if has_local { "local pg_dump" } else { "Docker-based pg_dump" };
|
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 escaped_db = shell_escape(database);
|
||||||
let pipe_cmd = format!(
|
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
|
dump_cmd, shell_escape(container_name), escaped_db
|
||||||
);
|
);
|
||||||
|
|
||||||
let output = Command::new("sh")
|
run_pipe_cmd(app, clone_id, &pipe_cmd, "Full clone").await?;
|
||||||
.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));
|
|
||||||
}
|
|
||||||
|
|
||||||
emit_progress(app, clone_id, "transfer", 85, "Full clone completed", None);
|
emit_progress(app, clone_id, "transfer", 85, "Full clone completed", None);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -567,11 +584,11 @@ async fn transfer_sample_data(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let pipe_cmd = format!(
|
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
|
source_cmd, escaped_container, escaped_db, copy_in_sql
|
||||||
);
|
);
|
||||||
|
|
||||||
let output = Command::new("sh")
|
let output = Command::new("bash")
|
||||||
.args(["-c", &pipe_cmd])
|
.args(["-c", &pipe_cmd])
|
||||||
.output()
|
.output()
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
@@ -67,17 +67,17 @@ function ProcessLog({
|
|||||||
Process Log ({entries.length})
|
Process Log ({entries.length})
|
||||||
</button>
|
</button>
|
||||||
{logOpen && (
|
{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) => (
|
{entries.map((entry, i) => (
|
||||||
<div key={i} className="flex gap-2 leading-5">
|
<div key={i} className="leading-5 min-w-0">
|
||||||
<span className="text-muted-foreground shrink-0 w-8 text-right">
|
<span className="text-muted-foreground">
|
||||||
{entry.percent}%
|
{entry.percent}%
|
||||||
</span>
|
</span>{" "}
|
||||||
<span>{entry.message}</span>
|
<span>{entry.message}</span>
|
||||||
{entry.detail && (
|
{entry.detail && (
|
||||||
<span className="text-muted-foreground truncate">
|
<div className="text-muted-foreground break-all pl-6">
|
||||||
— {entry.detail}
|
{entry.detail}
|
||||||
</span>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user