diff --git a/internal/nix/build.go b/internal/nix/build.go index 922e78aad2e..f0da452d282 100644 --- a/internal/nix/build.go +++ b/internal/nix/build.go @@ -24,7 +24,7 @@ func Build(ctx context.Context, args *BuildArgs, installables ...string) error { FixInstallableArgs(installables) // --impure is required for allowUnfreeEnv/allowInsecureEnv to work. - cmd := command("build", "--impure") + cmd := Command("build", "--impure") cmd.Args = appendArgs(cmd.Args, args.Flags) cmd.Args = appendArgs(cmd.Args, installables) // Adding extra substituters only here to be conservative, but this could also diff --git a/internal/nix/cache.go b/internal/nix/cache.go index b549bd05c4e..6047ccc09e1 100644 --- a/internal/nix/cache.go +++ b/internal/nix/cache.go @@ -18,7 +18,7 @@ func CopyInstallableToCache( env []string, ) error { fmt.Fprintf(out, "Copying %s to %s\n", installable, to) - cmd := command( + cmd := Command( "copy", "--to", to, // --impure makes NIXPKGS_ALLOW_* environment variables work. "--impure", diff --git a/internal/nix/command.go b/internal/nix/command.go index 8231e5e11c5..453e9c870de 100644 --- a/internal/nix/command.go +++ b/internal/nix/command.go @@ -1,332 +1,19 @@ package nix -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "log/slog" - "os" - "os/exec" - "slices" - "strconv" - "strings" - "syscall" - "time" -) - -type cmd struct { - Args cmdArgs - Env []string - - Stdin io.Reader - Stdout io.Writer - Stderr io.Writer - - execCmd *exec.Cmd - err error - dur time.Duration - logger *slog.Logger -} - -func command(args ...any) *cmd { - cmd := &cmd{ - Args: append(cmdArgs{ - "nix", - "--extra-experimental-features", "ca-derivations", - "--option", "experimental-features", "nix-command flakes fetch-closure", - }, args...), - logger: slog.Default(), - } - return cmd -} - -func (c *cmd) CombinedOutput(ctx context.Context) ([]byte, error) { - cmd := c.initExecCommand(ctx) - c.logger.DebugContext(ctx, "nix command starting", "cmd", c) - - start := time.Now() - out, err := cmd.CombinedOutput() - c.dur = time.Since(start) - - c.err = c.error(ctx, err) - c.logger.DebugContext(ctx, "nix command exited", "cmd", c) - return out, c.err -} - -func (c *cmd) Output(ctx context.Context) ([]byte, error) { - cmd := c.initExecCommand(ctx) - c.logger.DebugContext(ctx, "nix command starting", "cmd", c) - - start := time.Now() - out, err := cmd.Output() - c.dur = time.Since(start) - - c.err = c.error(ctx, err) - c.logger.DebugContext(ctx, "nix command exited", "cmd", c) - return out, c.err -} - -func (c *cmd) Run(ctx context.Context) error { - cmd := c.initExecCommand(ctx) - c.logger.DebugContext(ctx, "nix command starting", "cmd", c) - - start := time.Now() - err := cmd.Run() - c.dur = time.Since(start) - - c.err = c.error(ctx, err) - c.logger.DebugContext(ctx, "nix command exited", "cmd", c) - return c.err -} - -func (c *cmd) LogValue() slog.Value { - attrs := []slog.Attr{ - slog.Any("args", c.Args), - } - if c.execCmd == nil { - return slog.GroupValue(attrs...) - } - attrs = append(attrs, slog.String("path", c.execCmd.Path)) - - var exitErr *exec.ExitError - if errors.As(c.err, &exitErr) { - stderr := c.stderrExcerpt(exitErr.Stderr) - if len(stderr) != 0 { - attrs = append(attrs, slog.String("stderr", stderr)) - } - } - if proc := c.execCmd.Process; proc != nil { - attrs = append(attrs, slog.Int("pid", proc.Pid)) - } - if procState := c.execCmd.ProcessState; procState != nil { - if procState.Exited() { - attrs = append(attrs, slog.Int("code", procState.ExitCode())) - } - if status, ok := procState.Sys().(syscall.WaitStatus); ok && status.Signaled() { - if status.Signaled() { - attrs = append(attrs, slog.String("signal", status.Signal().String())) - } - } - } - if c.dur != 0 { - attrs = append(attrs, slog.Duration("dur", c.dur)) - } - return slog.GroupValue(attrs...) -} - -func (c *cmd) String() string { - return c.Args.String() -} - -func (c *cmd) initExecCommand(ctx context.Context) *exec.Cmd { - if c.execCmd != nil { - return c.execCmd - } - - args := c.Args.StringSlice() - c.execCmd = exec.CommandContext(ctx, args[0], args[1:]...) - c.execCmd.Env = c.Env - c.execCmd.Stdin = c.Stdin - c.execCmd.Stdout = c.Stdout - c.execCmd.Stderr = c.Stderr - - c.execCmd.Cancel = func() error { - // Try to let Nix exit gracefully by sending an interrupt - // instead of the default behavior of killing it. - c.logger.DebugContext(ctx, "sending interrupt to nix process", slog.Group("cmd", - "args", c.Args, - "path", c.execCmd.Path, - "pid", c.execCmd.Process.Pid, - )) - err := c.execCmd.Process.Signal(os.Interrupt) - if errors.Is(err, os.ErrProcessDone) { - // Nix already exited; execCmd.Wait will use the exit - // code. - return err - } - if err != nil { - // We failed to send SIGINT, so kill the process - // instead. - // - // - If Nix already exited, Kill will return - // os.ErrProcessDone and execCmd.Wait will use - // the exit code. - // - Otherwise, execCmd.Wait will always return an - // error. - c.logger.ErrorContext(ctx, "error interrupting nix process, attempting to kill", - "err", err, slog.Group("cmd", - "args", c.Args, - "path", c.execCmd.Path, - "pid", c.execCmd.Process.Pid, - )) - return c.execCmd.Process.Kill() - } - - // We sent the SIGINT successfully. It's still possible for Nix - // to exit successfully, so return os.ErrProcessDone so that - // execCmd.Wait uses the exit code instead of ctx.Err. - return os.ErrProcessDone - } - // Kill Nix if it doesn't exit within 15 seconds of Devbox sending an - // interrupt. - c.execCmd.WaitDelay = 15 * time.Second - return c.execCmd -} - -func (c *cmd) error(ctx context.Context, err error) error { - if err == nil { - return nil - } - - cmdErr := &cmdError{err: err} - if errors.Is(err, exec.ErrNotFound) { - cmdErr.msg = fmt.Sprintf("nix: %s not found in $PATH", c.Args[0]) +func init() { + Default.ExtraArgs = Args{ + "--extra-experimental-features", "ca-derivations", + "--option", "experimental-features", "nix-command flakes fetch-closure", } - - switch { - case errors.Is(ctx.Err(), context.Canceled): - cmdErr.msg = "nix: command canceled" - case errors.Is(ctx.Err(), context.DeadlineExceeded): - cmdErr.msg = "nix: command timed out" - default: - cmdErr.msg = "nix: command error" - } - cmdErr.msg += ": " + c.String() - - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - if stderr := c.stderrExcerpt(exitErr.Stderr); len(stderr) != 0 { - cmdErr.msg += ": " + stderr - } - if exitErr.Exited() { - cmdErr.msg += fmt.Sprintf(": exit code %d", exitErr.ExitCode()) - return cmdErr - } - if stat, ok := exitErr.Sys().(syscall.WaitStatus); ok && stat.Signaled() { - cmdErr.msg += fmt.Sprintf(": exit due to signal %d (%[1]s)", stat.Signal()) - return cmdErr - } - } - - if !errors.Is(err, ctx.Err()) { - cmdErr.msg += ": " + err.Error() - } - return cmdErr -} - -func (*cmd) stderrExcerpt(stderr []byte) string { - stderr = bytes.TrimSpace(stderr) - if len(stderr) == 0 { - return "" - } - - lines := bytes.Split(stderr, []byte("\n")) - slices.Reverse(lines) - for _, line := range lines { - line = bytes.TrimSpace(line) - after, found := bytes.CutPrefix(line, []byte("error: ")) - if !found { - continue - } - after = bytes.TrimSpace(after) - if len(after) == 0 { - continue - } - stderr = after - break - - } - - excerpt := string(stderr) - if !strconv.CanBackquote(excerpt) { - quoted := strconv.Quote(excerpt) - excerpt = quoted[1 : len(quoted)-1] - } - return excerpt } -type cmdArgs []any - -func appendArgs[E any](args cmdArgs, new []E) cmdArgs { +func appendArgs[E any](args Args, new []E) Args { for _, elem := range new { args = append(args, elem) } return args } -func (c cmdArgs) StringSlice() []string { - s := make([]string, len(c)) - for i := range c { - s[i] = fmt.Sprint(c[i]) - } - return s -} - -func (c cmdArgs) String() string { - if len(c) == 0 { - return "" - } - - sb := &strings.Builder{} - c.writeQuoted(sb, fmt.Sprint(c[0])) - if len(c) == 1 { - return sb.String() - } - - for _, arg := range c[1:] { - sb.WriteByte(' ') - c.writeQuoted(sb, fmt.Sprint(arg)) - } - return sb.String() -} - -func (cmdArgs) writeQuoted(dst *strings.Builder, str string) { - needsQuote := strings.ContainsAny(str, ";\"'()$|&><` \t\r\n\\#{~*?[=") - if !needsQuote { - dst.WriteString(str) - return - } - - canSingleQuote := !strings.Contains(str, "'") - if canSingleQuote { - dst.WriteByte('\'') - dst.WriteString(str) - dst.WriteByte('\'') - return - } - - dst.WriteByte('"') - for _, r := range str { - switch r { - // Special characters inside double quotes: - // https://pubs.opengroup.org/onlinepubs/009604499/utilities/xcu_chap02.html#tag_02_02_03 - case '$', '`', '"', '\\': - dst.WriteRune('\\') - } - dst.WriteRune(r) - } - dst.WriteByte('"') -} - -type cmdError struct { - msg string - err error -} - -func (c *cmdError) Redact() string { - return c.Error() -} - -func (c *cmdError) Error() string { - return c.msg -} - -func (c *cmdError) Unwrap() error { - return c.err -} - func allowUnfreeEnv(curEnv []string) []string { return append(curEnv, "NIXPKGS_ALLOW_UNFREE=1") } diff --git a/internal/nix/config.go b/internal/nix/config.go index ba82c2be2c2..8c7f9a2bf93 100644 --- a/internal/nix/config.go +++ b/internal/nix/config.go @@ -38,7 +38,7 @@ type ConfigField[T any] struct { func CurrentConfig(ctx context.Context) (Config, error) { // `nix show-config` is deprecated in favor of `nix config show`, but we // want to remain compatible with older Nix versions. - cmd := command("show-config", "--json") + cmd := Command("show-config", "--json") out, err := cmd.Output(ctx) var exitErr *exec.ExitError if errors.As(err, &exitErr) && len(exitErr.Stderr) != 0 { diff --git a/internal/nix/eval.go b/internal/nix/eval.go index 4748be15ff1..81b827b833b 100644 --- a/internal/nix/eval.go +++ b/internal/nix/eval.go @@ -8,7 +8,7 @@ import ( ) func EvalPackageName(path string) (string, error) { - cmd := command("eval", "--raw", path+".name") + cmd := Command("eval", "--raw", path+".name") out, err := cmd.Output(context.TODO()) if err != nil { return "", err @@ -18,7 +18,7 @@ func EvalPackageName(path string) (string, error) { // PackageIsInsecure is a fun little nix eval that maybe works. func PackageIsInsecure(path string) bool { - cmd := command("eval", path+".meta.insecure") + cmd := Command("eval", path+".meta.insecure") out, err := cmd.Output(context.TODO()) if err != nil { // We can't know for sure, but probably not. @@ -33,7 +33,7 @@ func PackageIsInsecure(path string) bool { } func PackageKnownVulnerabilities(path string) []string { - cmd := command("eval", path+".meta.knownVulnerabilities") + cmd := Command("eval", path+".meta.knownVulnerabilities") out, err := cmd.Output(context.TODO()) if err != nil { // We can't know for sure, but probably not. @@ -51,7 +51,7 @@ func PackageKnownVulnerabilities(path string) []string { // nix eval --raw nixpkgs/9ef09e06806e79e32e30d17aee6879d69c011037#fuse3 // to determine if a package if a package can be installed in system. func Eval(path string) ([]byte, error) { - cmd := command("eval", "--raw", path) + cmd := Command("eval", "--raw", path) return cmd.CombinedOutput(context.TODO()) } diff --git a/internal/nix/flake.go b/internal/nix/flake.go index 252a5ec2002..480710728a2 100644 --- a/internal/nix/flake.go +++ b/internal/nix/flake.go @@ -16,7 +16,7 @@ type FlakeMetadata struct { } func ResolveFlake(ctx context.Context, ref flake.Ref) (FlakeMetadata, error) { - cmd := command("flake", "metadata", "--json", ref) + cmd := Command("flake", "metadata", "--json", ref) out, err := cmd.Output(ctx) if err != nil { return FlakeMetadata{}, err diff --git a/internal/nix/nix.go b/internal/nix/nix.go index 59987c1aadc..c323fefcf62 100644 --- a/internal/nix/nix.go +++ b/internal/nix/nix.go @@ -75,7 +75,7 @@ func (*NixInstance) PrintDevEnv(ctx context.Context, args *PrintDevEnvArgs) (*Pr ref := flake.Ref{Type: flake.TypePath, Path: flakeDirResolved} if len(data) == 0 { - cmd := command("print-dev-env", "--json") + cmd := Command("print-dev-env", "--json") if featureflag.ImpurePrintDevEnv.Enabled() { cmd.Args = append(cmd.Args, "--impure") } diff --git a/internal/nix/nixpkgs.go b/internal/nix/nixpkgs.go index aa9ae3ecec3..40d3e77469f 100644 --- a/internal/nix/nixpkgs.go +++ b/internal/nix/nixpkgs.go @@ -38,7 +38,7 @@ func EnsureNixpkgsPrefetched(w io.Writer, commit string) error { } fmt.Fprintf(w, "Ensuring nixpkgs registry is downloaded.\n") - cmd := command( + cmd := Command( "flake", "prefetch", FlakeNixpkgs(commit), ) @@ -72,7 +72,7 @@ func nixpkgsCommitFileContents() (map[string]string, error) { func saveToNixpkgsCommitFile(commit string, commitToLocation map[string]string) error { // Make a query to get the /nix/store path for this commit hash. - cmd := command("flake", "prefetch", "--json", + cmd := Command("flake", "prefetch", "--json", FlakeNixpkgs(commit), ) out, err := cmd.Output(context.TODO()) diff --git a/internal/nix/profiles.go b/internal/nix/profiles.go index 54e9adadf94..ce9f177d248 100644 --- a/internal/nix/profiles.go +++ b/internal/nix/profiles.go @@ -19,7 +19,7 @@ import ( ) func ProfileList(writer io.Writer, profilePath string, useJSON bool) (string, error) { - cmd := command("profile", "list", "--profile", profilePath) + cmd := Command("profile", "list", "--profile", profilePath) if useJSON { cmd.Args = append(cmd.Args, "--json") } @@ -41,7 +41,7 @@ var ErrPriorityConflict = errors.New("priority conflict") func ProfileInstall(ctx context.Context, args *ProfileInstallArgs) error { defer debug.FunctionTimer().End() - cmd := command( + cmd := Command( "profile", "install", "--profile", args.ProfilePath, "--offline", // makes it faster. Package is already in store @@ -71,7 +71,7 @@ func ProfileInstall(ctx context.Context, args *ProfileInstallArgs) error { // WARNING, don't use indexes, they are not supported by nix 2.20+ func ProfileRemove(profilePath string, packageNames ...string) error { defer debug.FunctionTimer().End() - cmd := command( + cmd := Command( "profile", "remove", "--profile", profilePath, "--impure", // for NIXPKGS_ALLOW_UNFREE diff --git a/internal/nix/search.go b/internal/nix/search.go index 523a394c79b..40823f38a02 100644 --- a/internal/nix/search.go +++ b/internal/nix/search.go @@ -98,7 +98,7 @@ func searchSystem(url, system string) (map[string]*PkgInfo, error) { } // The `^` is added to indicate we want to show all packages - cmd := command("search", url, "^" /*regex*/, "--json") + cmd := Command("search", url, "^" /*regex*/, "--json") if system != "" { cmd.Args = append(cmd.Args, "--system", system) } diff --git a/internal/nix/shim.go b/internal/nix/shim.go index 7961d2be8a5..f5438fa0905 100644 --- a/internal/nix/shim.go +++ b/internal/nix/shim.go @@ -29,6 +29,8 @@ const ( type ( Nix = nix.Nix + Cmd = nix.Cmd + Args = nix.Args Info = nix.Info Installer = nix.Installer ) @@ -36,6 +38,7 @@ type ( var Default = nix.Default func AtLeast(version string) bool { return nix.AtLeast(version) } +func Command(args ...any) *Cmd { return nix.Command(args...) } func SourceProfile() (sourced bool, err error) { return nix.SourceProfile() } func System() string { return nix.System() } func Version() string { return nix.Version() } diff --git a/internal/nix/store.go b/internal/nix/store.go index 0ccd726f61f..4152c4cad9d 100644 --- a/internal/nix/store.go +++ b/internal/nix/store.go @@ -17,7 +17,7 @@ import ( ) func StorePathFromHashPart(ctx context.Context, hash, storeAddr string) (string, error) { - cmd := command("store", "path-from-hash-part", "--store", storeAddr, hash) + cmd := Command("store", "path-from-hash-part", "--store", storeAddr, hash) resultBytes, err := cmd.Output(ctx) if err != nil { return "", err @@ -29,7 +29,7 @@ func StorePathsFromInstallable(ctx context.Context, installable string, allowIns defer debug.FunctionTimer().End() // --impure for NIXPKGS_ALLOW_UNFREE - cmd := command("path-info", FixInstallableArg(installable), "--json", "--impure") + cmd := Command("path-info", FixInstallableArg(installable), "--json", "--impure") cmd.Env = allowUnfreeEnv(os.Environ()) if allowInsecure { @@ -56,7 +56,7 @@ func StorePathsAreInStore(ctx context.Context, storePaths []string) (map[string] if len(storePaths) == 0 { return map[string]bool{}, nil } - cmd := command("path-info", "--offline", "--json") + cmd := Command("path-info", "--offline", "--json") cmd.Args = appendArgs(cmd.Args, storePaths) output, err := cmd.Output(ctx) if err != nil { @@ -138,7 +138,7 @@ func DaemonVersion(ctx context.Context) (string, error) { } canJSON := nix.AtLeast(nix.Version2_14) - cmd := command("store", storeCmd, "--store", "daemon") + cmd := Command("store", storeCmd, "--store", "daemon") if canJSON { cmd.Args = append(cmd.Args, "--json") } diff --git a/internal/nix/upgrade.go b/internal/nix/upgrade.go index ee6050eab0d..29650564787 100644 --- a/internal/nix/upgrade.go +++ b/internal/nix/upgrade.go @@ -12,7 +12,7 @@ import ( ) func ProfileUpgrade(ProfileDir, indexOrName string) error { - return command( + return Command( "profile", "upgrade", "--profile", ProfileDir, indexOrName, @@ -21,7 +21,7 @@ func ProfileUpgrade(ProfileDir, indexOrName string) error { func FlakeUpdate(ProfileDir string) error { ux.Finfof(os.Stderr, "Running \"nix flake update\"\n") - cmd := command("flake", "update") + cmd := Command("flake", "update") if nix.AtLeast(Version2_19) { cmd.Args = append(cmd.Args, "--flake") } diff --git a/nix/command.go b/nix/command.go new file mode 100644 index 00000000000..ab7cd4793f2 --- /dev/null +++ b/nix/command.go @@ -0,0 +1,376 @@ +package nix + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "runtime" + "slices" + "strconv" + "strings" + "syscall" + "time" +) + +// Cmd is an external command that invokes a [*Nix] executable. It provides +// improved error messages, graceful cancellation, and debug logging via +// [log/slog]. Although it's possible to initialize a Cmd directly, calling the +// [Command] function or [Nix.Command] method is more typical. +// +// Most methods and fields correspond to their [exec.Cmd] equivalent. See its +// documentation for more details. +type Cmd struct { + // Path is the absolute path to the nix executable. It is the only + // mandatory field and must not be empty. + Path string + + // Args are the command line arguments, including the command name in + // Args[0]. Run formats each argument using [fmt.Sprint] before passing + // them to Nix. + Args Args + + Env []string + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + + // Logger emits debug logs when the command starts and exits. If nil, it + // defaults to [slog.Default]. + Logger *slog.Logger + + execCmd *exec.Cmd + err error + dur time.Duration +} + +// Command creates an arbitrary Nix command that uses the Path, ExtraArgs, +// Logger and other defaults from n. +func (n *Nix) Command(args ...any) *Cmd { + cmd := &Cmd{ + Args: make(Args, 1, 1+len(n.ExtraArgs)+len(args)), + Logger: n.logger(), + } + cmd.Path, cmd.err = n.resolvePath() + + if n.Path == "" { + cmd.Args[0] = "nix" // resolved from $PATH + } else { + cmd.Args[0] = n.Path // explicitly set + } + cmd.Args = append(cmd.Args, n.ExtraArgs...) + cmd.Args = append(cmd.Args, args...) + return cmd +} + +func (c *Cmd) CombinedOutput(ctx context.Context) ([]byte, error) { + defer c.logRunFunc(ctx)() + + start := time.Now() + out, err := c.initExecCommand(ctx).CombinedOutput() + c.dur = time.Since(start) + + c.err = c.error(ctx, err) + return out, c.err +} + +func (c *Cmd) Output(ctx context.Context) ([]byte, error) { + defer c.logRunFunc(ctx)() + + start := time.Now() + out, err := c.initExecCommand(ctx).Output() + c.dur = time.Since(start) + + c.err = c.error(ctx, err) + return out, c.err +} + +func (c *Cmd) Run(ctx context.Context) error { + defer c.logRunFunc(ctx)() + + start := time.Now() + err := c.initExecCommand(ctx).Run() + c.dur = time.Since(start) + + c.err = c.error(ctx, err) + return c.err +} + +func (c *Cmd) LogValue() slog.Value { + attrs := []slog.Attr{ + slog.Any("args", c.Args), + } + if c.execCmd == nil { + return slog.GroupValue(attrs...) + } + attrs = append(attrs, slog.String("path", c.execCmd.Path)) + + var exitErr *exec.ExitError + if errors.As(c.err, &exitErr) { + stderr := c.stderrExcerpt(exitErr.Stderr) + if len(stderr) != 0 { + attrs = append(attrs, slog.String("stderr", stderr)) + } + } + if proc := c.execCmd.Process; proc != nil { + attrs = append(attrs, slog.Int("pid", proc.Pid)) + } + if procState := c.execCmd.ProcessState; procState != nil { + if procState.Exited() { + attrs = append(attrs, slog.Int("code", procState.ExitCode())) + } + if status, ok := procState.Sys().(syscall.WaitStatus); ok && status.Signaled() { + if status.Signaled() { + attrs = append(attrs, slog.String("signal", status.Signal().String())) + } + } + } + if c.dur != 0 { + attrs = append(attrs, slog.Duration("dur", c.dur)) + } + return slog.GroupValue(attrs...) +} + +// String returns c as a shell-quoted string. +func (c *Cmd) String() string { + return c.Args.String() +} + +func (c *Cmd) initExecCommand(ctx context.Context) *exec.Cmd { + if c.execCmd != nil { + return c.execCmd + } + + c.execCmd = exec.CommandContext(ctx, c.Path) + c.execCmd.Path = c.Path + c.execCmd.Args = c.Args.StringSlice() + c.execCmd.Env = c.Env + c.execCmd.Stdin = c.Stdin + c.execCmd.Stdout = c.Stdout + c.execCmd.Stderr = c.Stderr + + c.execCmd.Cancel = func() error { + // Try to let Nix exit gracefully by sending an interrupt + // instead of the default behavior of killing it. + c.logger().DebugContext(ctx, "sending interrupt to nix process", slog.Group("cmd", + "args", c.Args, + "path", c.execCmd.Path, + "pid", c.execCmd.Process.Pid, + )) + err := c.execCmd.Process.Signal(os.Interrupt) + if errors.Is(err, os.ErrProcessDone) { + // Nix already exited; execCmd.Wait will use the exit + // code. + return err + } + if err != nil { + // We failed to send SIGINT, so kill the process + // instead. + // + // - If Nix already exited, Kill will return + // os.ErrProcessDone and execCmd.Wait will use + // the exit code. + // - Otherwise, execCmd.Wait will always return an + // error. + c.logger().DebugContext(ctx, "error interrupting nix process, attempting to kill", + "err", err, slog.Group("cmd", + "args", c.Args, + "path", c.execCmd.Path, + "pid", c.execCmd.Process.Pid, + )) + return c.execCmd.Process.Kill() + } + + // We sent the SIGINT successfully. It's still possible for Nix + // to exit successfully, so return os.ErrProcessDone so that + // execCmd.Wait uses the exit code instead of ctx.Err. + return os.ErrProcessDone + } + // Kill Nix if it doesn't exit within 15 seconds of Devbox sending an + // interrupt. + c.execCmd.WaitDelay = 15 * time.Second + return c.execCmd +} + +func (c *Cmd) error(ctx context.Context, err error) error { + if err == nil { + return nil + } + + cmdErr := &cmdError{err: err} + if errors.Is(err, exec.ErrNotFound) { + cmdErr.msg = fmt.Sprintf("nix: %s not found in $PATH", c.Args[0]) + } + + switch { + case errors.Is(ctx.Err(), context.Canceled): + cmdErr.msg = "nix: command canceled" + case errors.Is(ctx.Err(), context.DeadlineExceeded): + cmdErr.msg = "nix: command timed out" + default: + cmdErr.msg = "nix: command error" + } + cmdErr.msg += ": " + c.String() + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + if stderr := c.stderrExcerpt(exitErr.Stderr); len(stderr) != 0 { + cmdErr.msg += ": " + stderr + } + if exitErr.Exited() { + cmdErr.msg += fmt.Sprintf(": exit code %d", exitErr.ExitCode()) + return cmdErr + } + if stat, ok := exitErr.Sys().(syscall.WaitStatus); ok && stat.Signaled() { + cmdErr.msg += fmt.Sprintf(": exit due to signal %d (%[1]s)", stat.Signal()) + return cmdErr + } + } + + if !errors.Is(err, ctx.Err()) { + cmdErr.msg += ": " + err.Error() + } + return cmdErr +} + +func (*Cmd) stderrExcerpt(stderr []byte) string { + stderr = bytes.TrimSpace(stderr) + if len(stderr) == 0 { + return "" + } + + lines := bytes.Split(stderr, []byte("\n")) + slices.Reverse(lines) + for _, line := range lines { + line = bytes.TrimSpace(line) + after, found := bytes.CutPrefix(line, []byte("error: ")) + if !found { + continue + } + after = bytes.TrimSpace(after) + if len(after) == 0 { + continue + } + stderr = after + break + + } + + excerpt := string(stderr) + if !strconv.CanBackquote(excerpt) { + quoted := strconv.Quote(excerpt) + excerpt = quoted[1 : len(quoted)-1] + } + return excerpt +} + +func (c *Cmd) logger() *slog.Logger { + if c.Logger == nil { + return slog.Default() + } + return c.Logger +} + +// logRunFunc logs the start and exit of c.execCmd. It adjusts the source +// attribute of the log record to point to the caller of c.CombinedOutput, +// c.Output, or c.Run. This assumes a specific stack depth, so do not call +// logRunFunc from other methods or functions. +func (c *Cmd) logRunFunc(ctx context.Context) func() { + logger := c.logger() + if !logger.Enabled(ctx, slog.LevelDebug) { + return func() {} + } + + var pcs [1]uintptr + runtime.Callers(3, pcs[:]) // skip Callers, logRunFunc, CombinedOutput/Output/Run + r := slog.NewRecord(time.Now(), slog.LevelDebug, "nix command starting", pcs[0]) + r.Add("cmd", c) + _ = logger.Handler().Handle(ctx, r) + + return func() { + r := slog.NewRecord(time.Now(), slog.LevelDebug, "nix command exited", pcs[0]) + r.Add("cmd", c) + _ = logger.Handler().Handle(ctx, r) + } +} + +// Args is a slice of [Cmd] arguments. +type Args []any + +// StringSlice formats each argument using [fmt.Sprint]. +func (a Args) StringSlice() []string { + s := make([]string, len(a)) + for i := range a { + s[i] = fmt.Sprint(a[i]) + } + return s +} + +// String returns the arguments as a shell command, quoting arguments with +// spaces. +func (a Args) String() string { + if len(a) == 0 { + return "" + } + + sb := &strings.Builder{} + a.writeQuoted(sb, fmt.Sprint(a[0])) + if len(a) == 1 { + return sb.String() + } + + for _, arg := range a[1:] { + sb.WriteByte(' ') + a.writeQuoted(sb, fmt.Sprint(arg)) + } + return sb.String() +} + +func (Args) writeQuoted(dst *strings.Builder, str string) { + needsQuote := strings.ContainsAny(str, ";\"'()$|&><` \t\r\n\\#{~*?[=") + if !needsQuote { + dst.WriteString(str) + return + } + + canSingleQuote := !strings.Contains(str, "'") + if canSingleQuote { + dst.WriteByte('\'') + dst.WriteString(str) + dst.WriteByte('\'') + return + } + + dst.WriteByte('"') + for _, r := range str { + switch r { + // Special characters inside double quotes: + // https://pubs.opengroup.org/onlinepubs/009604499/utilities/xcu_chap02.html#tag_02_02_03 + case '$', '`', '"', '\\': + dst.WriteRune('\\') + } + dst.WriteRune(r) + } + dst.WriteByte('"') +} + +type cmdError struct { + msg string + err error +} + +func (c *cmdError) Redact() string { + return c.Error() +} + +func (c *cmdError) Error() string { + return c.msg +} + +func (c *cmdError) Unwrap() error { + return c.err +} diff --git a/nix/nix.go b/nix/nix.go index 97fb8248d53..dbc9d1cc0d3 100644 --- a/nix/nix.go +++ b/nix/nix.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "os" "os/exec" "os/user" @@ -22,6 +23,12 @@ import ( // Default is the default Nix installation. var Default = &Nix{} +// Command creates an arbitrary command using the Nix executable found in $PATH. +// It's the same as calling [Nix.Command] on the default Nix installation. +func Command(args ...any) *Cmd { + return Default.Command(args...) +} + // System calls [Nix.System] on the default Nix installation. func System() string { return Default.System() @@ -48,9 +55,16 @@ type Nix struct { Path string lookPath atomic.Pointer[string] + // ExtraArgs are command line arguments to pass to every Nix command. + ExtraArgs Args + info Info infoErr error infoOnce sync.Once + + // Logger logs information at [slog.LevelDebug] about Nix command + // starts and exits. If nil, it defaults to [slog.Default]. + Logger *slog.Logger } // resolvePath resolves the path to the Nix executable. It returns n.Path if it @@ -92,13 +106,11 @@ func (n *Nix) resolvePath() (string, error) { return "", pathErr } -// command runs an arbitrary Nix command. -func (n *Nix) command(ctx context.Context, args ...string) (*exec.Cmd, error) { - path, err := n.resolvePath() - if err != nil { - return nil, err +func (n *Nix) logger() *slog.Logger { + if n.Logger == nil { + return slog.Default() } - return exec.CommandContext(ctx, path, args...), nil + return n.Logger } // System returns the system from [Nix.Info] or an empty string if there was an @@ -122,13 +134,13 @@ func (n *Nix) Info() (Info, error) { // Create the command first, which will catch any errors finding the Nix // executable outside of the once. This allows us to retry after // installing Nix. - cmd, err := n.command(context.Background(), "--version", "--debug") - if err != nil { - return Info{}, err + cmd := n.Command("--version", "--debug") + if cmd.err != nil { + return Info{}, cmd.err } n.infoOnce.Do(func() { - out, err := cmd.Output() + out, err := cmd.Output(context.Background()) if err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) && len(exitErr.Stderr) != 0 {