From 8280a82ef89c338dbfa144b90f4e57896bf9a0cf Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 02:11:43 +0200 Subject: [PATCH] Read getent's exit code, not its wording (novox/hq issue 213) A user that does not exist yet was matched as Go's 'exit status 2', while the host's runner says 'getent exited 2', so it read as a user database that did not answer: the controller's account was never created and the handover to its process stopped there. The runner keeps the exit underneath its words, and a caller asks the code. --- internal/apply/apply.go | 17 ++++++++++-- internal/system/lookup_exit_test.go | 40 +++++++++++++++++++++++++++++ internal/system/system.go | 33 ++++++++++++++++++++++-- 3 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 internal/system/lookup_exit_test.go diff --git a/internal/apply/apply.go b/internal/apply/apply.go index f20a8ec..33b8a3c 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -1458,6 +1458,15 @@ func remove(ctx context.Context, sys system.System, a store.Applied, run Runner, // undo is never reported as done; not fatal for a former target, which was never dropped by anyone. var errNoRemoval = errors.New("no way to remove") +// exited is a command that ran and exited non-zero: its words, and the exit itself. +type exited struct { + words string + exit *exec.ExitError +} + +func (e *exited) Error() string { return e.words } +func (e *exited) Unwrap() error { return e.exit } + // ExecRunner runs a real command, with stdin closed and output captured. func ExecRunner(ctx context.Context, name string, args ...string) (string, error) { cmd := exec.CommandContext(ctx, name, args...) @@ -1466,8 +1475,12 @@ func ExecRunner(ctx context.Context, name string, args ...string) (string, error if err != nil { var exit *exec.ExitError if errors.As(err, &exit) { - return string(out), fmt.Errorf("%s exited %d: %s", - name, exit.ExitCode(), strings.TrimSpace(string(exit.Stderr))) + // The words as they always were, and the exit underneath them, so a caller asks the + // code (system.ExitCode) rather than matching text that this line is free to reword. + return string(out), &exited{ + words: fmt.Sprintf("%s exited %d: %s", name, exit.ExitCode(), strings.TrimSpace(string(exit.Stderr))), + exit: exit, + } } return string(out), fmt.Errorf("%s: %w", name, err) } diff --git a/internal/system/lookup_exit_test.go b/internal/system/lookup_exit_test.go new file mode 100644 index 0000000..7311f06 --- /dev/null +++ b/internal/system/lookup_exit_test.go @@ -0,0 +1,40 @@ +package system + +import ( + "context" + "errors" + "os/exec" + "testing" +) + +// A user that does not exist yet is "absent", in the words the host's own runner uses +// ("getent exited 2"), not only Go's ("exit status 2"). Matched as text, the runner's wording read as +// a user database that did not answer, and the controller's account was never created. +func TestAMissingUserIsAbsentInTheRunnersWords(t *testing.T) { + for _, words := range []string{"getent exited 2: ", "exit status 2"} { + run := func(context.Context, string, ...string) (string, error) { return "", errors.New(words) } + _, found, err := LookUpUser(context.Background(), run, "nobody-here") + if err != nil || found { + t.Errorf("%q: found %v, err %v; want absent", words, found, err) + } + } + run := func(context.Context, string, ...string) (string, error) { return "", errors.New("getent exited 1: ") } + if _, _, err := LookUpUser(context.Background(), run, "x"); err == nil { + t.Error("a database that failed read as an answer") + } +} + +// The real command, through a real exit: getent's code for a key not found. +func TestAMissingUserIsAbsentFromTheRealGetent(t *testing.T) { + if _, err := exec.LookPath("getent"); err != nil { + t.Skip("no getent here") + } + run := func(ctx context.Context, name string, args ...string) (string, error) { + out, err := exec.CommandContext(ctx, name, args...).Output() + return string(out), err + } + _, found, err := LookUpUser(context.Background(), run, "mesh-no-such-user-0b1f") + if err != nil || found { + t.Errorf("found %v, err %v; want absent", found, err) + } +} diff --git a/internal/system/system.go b/internal/system/system.go index d8d36d2..3712197 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -19,6 +19,9 @@ import ( "context" "errors" "fmt" + "os/exec" + "regexp" + "strconv" "strings" "github.com/novox/mesh-host/internal/declaration" @@ -95,8 +98,10 @@ func LookUpUser(ctx context.Context, run Runner, name string) (Login, bool, erro out, err := run(ctx, "getent", "passwd", name) if err != nil { // getent's own convention: 2 means the key was not found, which is the only failure that - // means "no such user". - if strings.Contains(err.Error(), "exit status 2") { + // means "no such user". Asked of the exit code: matched as text, it looked for Go's wording + // ("exit status 2") while the host's runner says "getent exited 2", so a user that did not + // exist yet read as a database that did not answer, and no account was ever created. + if code, ok := ExitCode(err); ok && code == 2 { return Login{}, false, nil } return Login{}, false, fmt.Errorf( @@ -221,3 +226,27 @@ func For(name string) (System, error) { func All() []System { return []System{arch{}, alpine{}, android{}} } + +// ExitCode is the code a command exited with, when err says one: from the exit itself where the +// runner kept it, else from the words either runner shape uses ("exit status N", " exited N"). +func ExitCode(err error) (int, bool) { + if err == nil { + return 0, false + } + var exit *exec.ExitError + if errors.As(err, &exit) { + return exit.ExitCode(), true + } + if m := exitWords.FindStringSubmatch(err.Error()); len(m) == 3 { + for _, g := range m[1:] { + if g != "" { + if n, convErr := strconv.Atoi(g); convErr == nil { + return n, true + } + } + } + } + return 0, false +} + +var exitWords = regexp.MustCompile(`exit status (\d+)|exited (\d+)`) -- 2.54.0