Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions internal/devbox/providers/nixcache/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 2 additions & 3 deletions internal/devpkg/narinfo_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
}

Expand Down
16 changes: 7 additions & 9 deletions internal/nix/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
74 changes: 45 additions & 29 deletions internal/nix/nix.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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.
Expand All @@ -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 {
Expand All @@ -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`.
Expand All @@ -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{
Expand Down
80 changes: 56 additions & 24 deletions internal/nix/nix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
})
}
43 changes: 38 additions & 5 deletions internal/nix/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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]))
}
Loading