restic: a failed command reports the tail of its output, not only its exit status
Issue 1585: the backup before a nats move failed with only 'sh: exit status 1'. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
This commit is contained in:
@@ -61,25 +61,37 @@ func execRunner(ctx context.Context, name string, args ...string) (string, error
|
||||
cmd := exec.CommandContext(ctx, program, argv...)
|
||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
said := strings.TrimSpace(stderr.String())
|
||||
if said == "" {
|
||||
said = strings.TrimSpace(stdout.String())
|
||||
}
|
||||
if program == "sudo" && strings.HasPrefix(said, "sudo:") {
|
||||
return "", fmt.Errorf("%s needs root and the runtime's account may not run it without a prompt: %s", name, said)
|
||||
}
|
||||
lines := strings.Split(said, "\n")
|
||||
if len(lines) > 3 {
|
||||
lines = lines[len(lines)-3:]
|
||||
}
|
||||
if said == "" {
|
||||
return "", fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return "", errors.New(strings.Join(lines, " / "))
|
||||
return "", failure(program, name, err, stdout.String(), stderr.String())
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
// failureTail bounds what a failed command's output adds to the failure it reports: its last 2 KB.
|
||||
const failureTail = 2048
|
||||
|
||||
var secretLooking = regexp.MustCompile(`(?i)((?:pass(?:word|wd)?|secret|token|api[_-]?key)\s*[=:]\s*)\S+`)
|
||||
|
||||
// failure is the error of a command that exited badly: its last lines of stderr (stdout when stderr is
|
||||
// empty), bounded to failureTail bytes, with what looks like a secret blotted out, so the report of a
|
||||
// failed dump says why and not only that it exited. With nothing printed it keeps the exit status.
|
||||
func failure(program, name string, err error, stdout, stderr string) error {
|
||||
said := strings.TrimSpace(stderr)
|
||||
if said == "" {
|
||||
said = strings.TrimSpace(stdout)
|
||||
}
|
||||
if program == "sudo" && strings.HasPrefix(said, "sudo:") {
|
||||
return fmt.Errorf("%s needs root and the runtime's account may not run it without a prompt: %s", name, said)
|
||||
}
|
||||
if said == "" {
|
||||
return fmt.Errorf("%s: %w (it printed nothing)", name, err)
|
||||
}
|
||||
if len(said) > failureTail {
|
||||
said = "..." + said[len(said)-failureTail:]
|
||||
}
|
||||
said = secretLooking.ReplaceAllString(said, "${1}***")
|
||||
return fmt.Errorf("%s: %v: %s", name, err, strings.Join(strings.Split(said, "\n"), " / "))
|
||||
}
|
||||
|
||||
// Declared is what one module declared.
|
||||
type Declared struct {
|
||||
Module string `json:"module"`
|
||||
|
||||
@@ -333,3 +333,20 @@ func TestARestoreBesideAnotherAccountsDirectoryGoesToTheState(t *testing.T) {
|
||||
}
|
||||
_ = ran
|
||||
}
|
||||
|
||||
func TestAFailedCommandReportsTheTailOfWhatItPrintedWithSecretsBlotted(t *testing.T) {
|
||||
exit := errors.New("exit status 1")
|
||||
long := strings.Repeat("x", 5000) + "\npg_dump: error: password=hunter2 refused"
|
||||
for _, c := range []struct{ name, program, out, errOut, want, not string }{
|
||||
{"stderr", "sh", "", "pg_dump: connection refused\nsecond", "sh: exit status 1: pg_dump: connection refused / second", ""},
|
||||
{"stdout when stderr is empty", "sh", "dump failed\n", "", "sh: exit status 1: dump failed", ""},
|
||||
{"nothing printed", "sh", "", "", "sh: exit status 1 (it printed nothing)", ""},
|
||||
{"bounded and blotted", "sh", "", long, "password=*** refused", "hunter2"},
|
||||
{"sudo prompt", "sudo", "", "sudo: a password is required", "needs root", ""},
|
||||
} {
|
||||
got := failure(c.program, "sh", exit, c.out, c.errOut).Error()
|
||||
if !strings.Contains(got, c.want) || (c.not != "" && strings.Contains(got, c.not)) || len(got) > failureTail+100 {
|
||||
t.Errorf("%s: got %q", c.name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user