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.
This commit is contained in:
+15
-2
@@ -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.
|
// 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")
|
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.
|
// ExecRunner runs a real command, with stdin closed and output captured.
|
||||||
func ExecRunner(ctx context.Context, name string, args ...string) (string, error) {
|
func ExecRunner(ctx context.Context, name string, args ...string) (string, error) {
|
||||||
cmd := exec.CommandContext(ctx, name, args...)
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
@@ -1466,8 +1475,12 @@ func ExecRunner(ctx context.Context, name string, args ...string) (string, error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
var exit *exec.ExitError
|
var exit *exec.ExitError
|
||||||
if errors.As(err, &exit) {
|
if errors.As(err, &exit) {
|
||||||
return string(out), fmt.Errorf("%s exited %d: %s",
|
// The words as they always were, and the exit underneath them, so a caller asks the
|
||||||
name, exit.ExitCode(), strings.TrimSpace(string(exit.Stderr)))
|
// 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)
|
return string(out), fmt.Errorf("%s: %w", name, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/novox/mesh-host/internal/declaration"
|
"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)
|
out, err := run(ctx, "getent", "passwd", name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// getent's own convention: 2 means the key was not found, which is the only failure that
|
// getent's own convention: 2 means the key was not found, which is the only failure that
|
||||||
// means "no such user".
|
// means "no such user". Asked of the exit code: matched as text, it looked for Go's wording
|
||||||
if strings.Contains(err.Error(), "exit status 2") {
|
// ("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, nil
|
||||||
}
|
}
|
||||||
return Login{}, false, fmt.Errorf(
|
return Login{}, false, fmt.Errorf(
|
||||||
@@ -221,3 +226,27 @@ func For(name string) (System, error) {
|
|||||||
func All() []System {
|
func All() []System {
|
||||||
return []System{arch{}, alpine{}, android{}}
|
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", "<cmd> 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+)`)
|
||||||
|
|||||||
Reference in New Issue
Block a user