From c1fcdeb12496ae01b73f13215c67bfa135040a43 Mon Sep 17 00:00:00 2001 From: Greg Curtis Date: Fri, 26 Apr 2024 16:39:54 -0400 Subject: [PATCH] internal/nix: support older nix versions in DaemonVersion To check if nix daemon is running, Devbox runs: nix store info --json --store daemon This fails on older versions of Nix for a couple reasons: - Before Nix 2.19.0, `nix store info` was `nix store ping`. - Before Nix 2.14.0, the `--json` flag wasn't supported. Check for both of these versions when constructing the `nix store` command so it works for Nix versions 2.12 - 2.21+. Also add a `nix.VersionInfo.AtLeast` method and constants to make checking for supported major Nix versions easier. --- internal/devbox/providers/nixcache/setup.go | 5 ++ internal/devpkg/narinfo_cache.go | 5 +- internal/nix/install.go | 16 ++--- internal/nix/nix.go | 74 +++++++++++-------- internal/nix/nix_test.go | 80 ++++++++++++++------- internal/nix/store.go | 43 +++++++++-- internal/nix/upgrade.go | 3 +- internal/telemetry/segment.go | 6 +- internal/telemetry/telemetry.go | 6 +- 9 files changed, 160 insertions(+), 78 deletions(-) diff --git a/internal/devbox/providers/nixcache/setup.go b/internal/devbox/providers/nixcache/setup.go index 5b4df698235..19a396ae8e7 100644 --- a/internal/devbox/providers/nixcache/setup.go +++ b/internal/devbox/providers/nixcache/setup.go @@ -11,6 +11,7 @@ import ( "path/filepath" "time" + "go.jetpack.io/devbox/internal/debug" "go.jetpack.io/devbox/internal/envir" "go.jetpack.io/devbox/internal/nix" "go.jetpack.io/devbox/internal/redact" @@ -32,12 +33,14 @@ func (n *nixSetupTask) NeedsRun(ctx context.Context, lastRun setup.RunInfo) bool } trusted, _ := cfg.IsUserTrusted(ctx, n.username) if trusted { + debug.Log("nixcache: skipping setup task nixcache-setup-nix: user %s is already trusted", n.username) return false } if _, err := nix.DaemonVersion(ctx); err != nil { // This looks like a single-user install, so no need to // configure the daemon. + debug.Log("nixcache: skipping setup task nixcache-setup-nix: error connecting to nix daemon, assuming single-user install: %v", err) return false } return true @@ -65,12 +68,14 @@ type awsSetupTask struct { func (a *awsSetupTask) NeedsRun(ctx context.Context, lastRun setup.RunInfo) bool { // This task only needs to run once. if !lastRun.Time.IsZero() { + debug.Log("nixcache: skipping setup task nixcache-setup-aws: setup was already run at %s", lastRun.Time) return false } // No need to configure the daemon if this looks like a single-user // install. if _, err := nix.DaemonVersion(ctx); err != nil { + debug.Log("nixcache: skipping setup task nixcache-setup-aws: error connecting to nix daemon, assuming single-user install: %v", err) return false } return true diff --git a/internal/devpkg/narinfo_cache.go b/internal/devpkg/narinfo_cache.go index 47f654f5a3e..1b420a44dd3 100644 --- a/internal/devpkg/narinfo_cache.go +++ b/internal/devpkg/narinfo_cache.go @@ -12,7 +12,6 @@ import ( "go.jetpack.io/devbox/internal/boxcli/featureflag" "go.jetpack.io/devbox/internal/lock" "go.jetpack.io/devbox/internal/nix" - "go.jetpack.io/devbox/internal/vercheck" "golang.org/x/sync/errgroup" ) @@ -206,8 +205,8 @@ func (p *Package) sysInfoIfExists() (*lock.SystemInfo, error) { return nil, err } - // enable for nix >= 2.17 - if vercheck.SemverCompare(version, "2.17.0") < 0 { + // disable for nix < 2.17 + if !version.AtLeast(nix.Version2_17) { return nil, err } diff --git a/internal/nix/install.go b/internal/nix/install.go index 1b45dfcff29..62ad33909de 100644 --- a/internal/nix/install.go +++ b/internal/nix/install.go @@ -20,14 +20,11 @@ import ( "go.jetpack.io/devbox/internal/build" "go.jetpack.io/devbox/internal/cmdutil" "go.jetpack.io/devbox/internal/fileutil" + "go.jetpack.io/devbox/internal/redact" "go.jetpack.io/devbox/internal/ux" - "go.jetpack.io/devbox/internal/vercheck" ) -const ( - minNixVersion = "2.12.0" - rootError = "warning: installing Nix as root is not supported by this script!" -) +const rootError = "warning: installing Nix as root is not supported by this script!" // Install runs the install script for Nix. daemon has 3 states // nil is unset. false is --no-daemon. true is --daemon. @@ -111,19 +108,20 @@ func EnsureNixInstalled(writer io.Writer, withDaemonFunc func() *bool) (err erro if err != nil { return } - version := "" + + var version VersionInfo version, err = Version() if err != nil { - err = fmt.Errorf("failed to get nix version: %w", err) + err = redact.Errorf("nix: ensure install: get version: %w", err) return } // ensure minimum nix version installed - if vercheck.SemverCompare(version, minNixVersion) < 0 { + if !version.AtLeast(MinVersion) { err = usererr.New( "Devbox requires nix of version >= %s. Your version is %s. "+ "Please upgrade nix and try again.\n", - minNixVersion, + MinVersion, version, ) return diff --git a/internal/nix/nix.go b/internal/nix/nix.go index 3abb8aa02f4..8168ea4fd21 100644 --- a/internal/nix/nix.go +++ b/internal/nix/nix.go @@ -22,6 +22,7 @@ import ( "go.jetpack.io/devbox/internal/boxcli/featureflag" "go.jetpack.io/devbox/internal/boxcli/usererr" "go.jetpack.io/devbox/internal/redact" + "golang.org/x/mod/semver" "go.jetpack.io/devbox/internal/debug" ) @@ -174,6 +175,22 @@ func SystemIsLinux() bool { return strings.Contains(System(), "linux") } +// All major Nix versions supported by Devbox. +const ( + Version2_12 = "2.12.0" + Version2_13 = "2.13.0" + Version2_14 = "2.14.0" + Version2_15 = "2.15.0" + Version2_16 = "2.16.0" + Version2_17 = "2.17.0" + Version2_18 = "2.18.0" + Version2_19 = "2.19.0" + Version2_20 = "2.20.0" + Version2_21 = "2.21.0" + + MinVersion = Version2_12 +) + // VersionInfo contains information about a Nix installation. type VersionInfo struct { // Name is the executed program name (the first element of argv). @@ -210,12 +227,9 @@ type VersionInfo struct { // DataDir is the path to the Nix data directory, usually somewhere // within the Nix store. This field is empty for Nix versions <= 2.12. DataDir string - - // raw is the raw nix --version --debug output. - raw string } -func parseVersionInfo(data []byte) VersionInfo { +func parseVersionInfo(data []byte) (VersionInfo, error) { // Example nix --version --debug output from Nix versions 2.12 to 2.21. // Version 2.12 omits the data directory, but they're otherwise // identical. @@ -232,13 +246,17 @@ func parseVersionInfo(data []byte) VersionInfo { // State directory: /nix/var/nix // Data directory: /nix/store/m0ns07v8by0458yp6k30rfq1rs3kaz6g-nix-2.21.2/share - info := VersionInfo{raw: string(data)} - if len(info.raw) == 0 { - return info + info := VersionInfo{} + if len(data) == 0 { + return info, redact.Errorf("empty nix --version output") } - lines := strings.Split(info.raw, "\n") - info.Name, info.Version, _ = strings.Cut(lines[0], " (Nix) ") + lines := strings.Split(string(data), "\n") + found := false + info.Name, info.Version, found = strings.Cut(lines[0], " (Nix) ") + if !found { + return info, redact.Errorf("parse nix version: %s", redact.Safe(lines[0])) + } for _, line := range lines { name, value, found := strings.Cut(line, ": ") if !found { @@ -264,18 +282,21 @@ func parseVersionInfo(data []byte) VersionInfo { info.DataDir = value } } - return info + return info, nil } -func (v VersionInfo) version() (string, error) { - if v.Version == "" { - firstLine, _, _ := strings.Cut(v.raw, "\n") - if strings.TrimSpace(firstLine) == "" { - firstLine = "empty nix --version output" - } - return "", redact.Errorf("parse nix version: %s", redact.Safe(firstLine)) +// AtLeast returns true if v.Version is >= version per semantic versioning. It +// always returns false if v.Version is empty or invalid, such as when the +// current Nix version cannot be parsed. It panics if version is an invalid +// semver. +func (v VersionInfo) AtLeast(version string) bool { + if !strings.HasPrefix(version, "v") { + version = "v" + version } - return v.Version, nil + if !semver.IsValid(version) { + panic(fmt.Sprintf("nix.atLeast: invalid version %q", version[1:])) + } + return semver.Compare("v"+v.Version, version) >= 0 } // version is the cached output of `nix --version --debug`. @@ -292,28 +313,23 @@ func runNixVersion() (VersionInfo, error) { cmd := exec.CommandContext(ctx, "nix", "--version", "--debug") out, err := cmd.Output() if err != nil { - if errors.Is(err, context.DeadlineExceeded) { - return VersionInfo{}, redact.Errorf("nix command: %s: timed out while reading output", redact.Safe(cmd)) - } - var exitErr *exec.ExitError if errors.As(err, &exitErr) && len(exitErr.Stderr) != 0 { return VersionInfo{}, redact.Errorf("nix command: %s: %q: %v", redact.Safe(cmd), exitErr.Stderr, err) } + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return VersionInfo{}, redact.Errorf("nix command: %s: timed out while reading output: %v", redact.Safe(cmd), err) + } return VersionInfo{}, redact.Errorf("nix command: %s: %v", redact.Safe(cmd), err) } debug.Log("nix --version --debug output:\n%s", out) - return parseVersionInfo(out), nil + return parseVersionInfo(out) } // Version returns the currently installed version of Nix. -func Version() (string, error) { - info, err := versionInfo() - if err != nil { - return "", err - } - return info.version() +func Version() (VersionInfo, error) { + return versionInfo() } var nixPlatforms = []string{ diff --git a/internal/nix/nix_test.go b/internal/nix/nix_test.go index 9c251923915..37f8ac8b4c6 100644 --- a/internal/nix/nix_test.go +++ b/internal/nix/nix_test.go @@ -82,7 +82,10 @@ State directory: /nix/var/nix Data directory: /nix/store/m0ns07v8by0458yp6k30rfq1rs3kaz6g-nix-2.21.2/share ` - info := parseVersionInfo([]byte(raw)) + info, err := parseVersionInfo([]byte(raw)) + if err != nil { + t.Error("got parse error:", err) + } if got, want := info.Name, "nix"; got != want { t.Errorf("got Name = %q, want %q", got, want) } @@ -116,7 +119,10 @@ Data directory: /nix/store/m0ns07v8by0458yp6k30rfq1rs3kaz6g-nix-2.21.2/share } func TestParseVersionInfoShort(t *testing.T) { - info := parseVersionInfo([]byte("nix (Nix) 2.21.2")) + info, err := parseVersionInfo([]byte("nix (Nix) 2.21.2")) + if err != nil { + t.Error("got parse error:", err) + } if got, want := info.Name, "nix"; got != want { t.Errorf("got Name = %q, want %q", got, want) } @@ -125,33 +131,59 @@ func TestParseVersionInfoShort(t *testing.T) { } } -func TestParseVersionInfoEmpty(t *testing.T) { - // Don't panic. - parseVersionInfo(nil) - parseVersionInfo([]byte{}) -} - -func TestVersionInfoVersion(t *testing.T) { - t.Run("Valid", func(t *testing.T) { - info := VersionInfo{Version: "2.21.0"} - got, err := info.version() - if err != nil { - t.Error("got error:", err) - } - if got != info.Version { - t.Fatalf("got version %q, want %q", got, info.Version) +func TestParseVersionInfoError(t *testing.T) { + t.Run("NilOutput", func(t *testing.T) { + _, err := parseVersionInfo(nil) + if err == nil { + t.Error("want non-nil error") } }) - - t.Run("Empty", func(t *testing.T) { - info := VersionInfo{} - if _, err := info.version(); err == nil { + t.Run("EmptyOutput", func(t *testing.T) { + _, err := parseVersionInfo([]byte{}) + if err == nil { t.Error("want non-nil error") } - - info.raw = "nix output without a version" - if _, err := info.version(); err == nil { + }) + t.Run("MissingVersionOutput", func(t *testing.T) { + _, err := parseVersionInfo([]byte("nix output without a version")) + if err == nil { t.Error("want non-nil error") } }) } + +func TestVersionInfoAtLeast(t *testing.T) { + info := VersionInfo{} + if info.AtLeast(Version2_12) { + t.Errorf("got empty current version >= %s", Version2_12) + } + + info.Version = Version2_13 + if !info.AtLeast(Version2_12) { + t.Errorf("got %s < %s", info.Version, Version2_12) + } + if !info.AtLeast(Version2_13) { + t.Errorf("got %s < %s", info.Version, Version2_13) + } + if info.AtLeast(Version2_14) { + t.Errorf("got %s >= %s", info.Version, Version2_14) + } + + t.Run("ArgEmptyPanic", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("want panic for empty version") + } + }() + info.AtLeast("") + }) + t.Run("ArgInvalidPanic", func(t *testing.T) { + v := "notasemver" + defer func() { + if r := recover(); r == nil { + t.Errorf("want panic for invalid version %q", v) + } + }() + info.AtLeast(v) + }) +} diff --git a/internal/nix/store.go b/internal/nix/store.go index 301b465b1b7..0da338b8e32 100644 --- a/internal/nix/store.go +++ b/internal/nix/store.go @@ -120,7 +120,22 @@ func (e *DaemonError) Redact() string { // DaemonVersion returns the version of the currently running Nix daemon. func DaemonVersion(ctx context.Context) (string, error) { - cmd := commandContext(ctx, "store", "info", "--json", "--store", "daemon") + // We only need the version to decide which CLI flags to use. We can + // ignore the error because an empty version assumes nix.MinVersion. + cliVersion, _ := Version() + + storeCmd := "ping" + if cliVersion.AtLeast(Version2_19) { + // "nix store ping" is deprecated as of 2.19 in favor of + // "nix store info". + storeCmd = "info" + } + canJSON := cliVersion.AtLeast(Version2_14) + + cmd := commandContext(ctx, "store", storeCmd, "--store", "daemon") + if canJSON { + cmd.Args = append(cmd.Args, "--json") + } out, err := cmd.Output() // ExitError means the command ran, but couldn't connect. @@ -139,9 +154,27 @@ func DaemonVersion(ctx context.Context) (string, error) { return "", redact.Errorf("command %s: %s", redact.Safe(cmd), err) } - info := struct{ Version string }{} - if err := json.Unmarshal(out, &info); err != nil { - return "", redact.Errorf("%s: unmarshal JSON output: %s", redact.Safe(cmd.String()), err) + if len(out) == 0 { + return "", redact.Errorf("command %s: empty output", redact.Safe(cmd), err) + } + if canJSON { + info := struct{ Version string }{} + if err := json.Unmarshal(out, &info); err != nil { + return "", redact.Errorf("command %s: unmarshal JSON output: %s", redact.Safe(cmd), err) + } + return info.Version, nil + } + + // Example output: + // + // Store URL: daemon + // Version: 2.21.1 + lines := strings.Split(string(out), "\n") + for _, line := range lines { + name, value, found := strings.Cut(line, ": ") + if found && name == "Version" { + return value, nil + } } - return info.Version, nil + return "", redact.Errorf("parse nix daemon version: %s", redact.Safe(lines[0])) } diff --git a/internal/nix/upgrade.go b/internal/nix/upgrade.go index e26a8c55fee..7e894fb87cc 100644 --- a/internal/nix/upgrade.go +++ b/internal/nix/upgrade.go @@ -9,7 +9,6 @@ import ( "go.jetpack.io/devbox/internal/redact" "go.jetpack.io/devbox/internal/ux" - "go.jetpack.io/devbox/internal/vercheck" ) func ProfileUpgrade(ProfileDir, indexOrName string) error { @@ -34,7 +33,7 @@ func FlakeUpdate(ProfileDir string) error { } ux.Finfo(os.Stderr, "Running \"nix flake update\"\n") cmd := exec.Command("nix", "flake", "update") - if vercheck.SemverCompare(version, "2.19.0") >= 0 { + if version.AtLeast(Version2_19) { cmd.Args = append(cmd.Args, "--flake") } cmd.Args = append(cmd.Args, ProfileDir) diff --git a/internal/telemetry/segment.go b/internal/telemetry/segment.go index c96894c1cdc..ce169364372 100644 --- a/internal/telemetry/segment.go +++ b/internal/telemetry/segment.go @@ -34,9 +34,9 @@ func initSegmentClient() bool { } func newTrackMessage(name string, meta Metadata) *segment.Track { - nixVersion, err := nix.Version() - if err != nil { - nixVersion = "unknown" + nixVersion := "unknown" + if v, err := nix.Version(); err == nil { + nixVersion = v.Version } dur := time.Since(procStartTime) diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index e6d10b02e2e..f03df58b441 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -141,9 +141,9 @@ func Error(err error, meta Metadata) { return } - nixVersion, err := nix.Version() - if err != nil { - nixVersion = "unknown" + nixVersion := "unknown" + if v, err := nix.Version(); err == nil { + nixVersion = v.Version } event := &sentry.Event{