Skip to content

Commit c1fcdeb

Browse files
committed
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.
1 parent e6e039f commit c1fcdeb

File tree

9 files changed

+160
-78
lines changed

9 files changed

+160
-78
lines changed

internal/devbox/providers/nixcache/setup.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"path/filepath"
1212
"time"
1313

14+
"go.jetpack.io/devbox/internal/debug"
1415
"go.jetpack.io/devbox/internal/envir"
1516
"go.jetpack.io/devbox/internal/nix"
1617
"go.jetpack.io/devbox/internal/redact"
@@ -32,12 +33,14 @@ func (n *nixSetupTask) NeedsRun(ctx context.Context, lastRun setup.RunInfo) bool
3233
}
3334
trusted, _ := cfg.IsUserTrusted(ctx, n.username)
3435
if trusted {
36+
debug.Log("nixcache: skipping setup task nixcache-setup-nix: user %s is already trusted", n.username)
3537
return false
3638
}
3739

3840
if _, err := nix.DaemonVersion(ctx); err != nil {
3941
// This looks like a single-user install, so no need to
4042
// configure the daemon.
43+
debug.Log("nixcache: skipping setup task nixcache-setup-nix: error connecting to nix daemon, assuming single-user install: %v", err)
4144
return false
4245
}
4346
return true
@@ -65,12 +68,14 @@ type awsSetupTask struct {
6568
func (a *awsSetupTask) NeedsRun(ctx context.Context, lastRun setup.RunInfo) bool {
6669
// This task only needs to run once.
6770
if !lastRun.Time.IsZero() {
71+
debug.Log("nixcache: skipping setup task nixcache-setup-aws: setup was already run at %s", lastRun.Time)
6872
return false
6973
}
7074

7175
// No need to configure the daemon if this looks like a single-user
7276
// install.
7377
if _, err := nix.DaemonVersion(ctx); err != nil {
78+
debug.Log("nixcache: skipping setup task nixcache-setup-aws: error connecting to nix daemon, assuming single-user install: %v", err)
7479
return false
7580
}
7681
return true

internal/devpkg/narinfo_cache.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import (
1212
"go.jetpack.io/devbox/internal/boxcli/featureflag"
1313
"go.jetpack.io/devbox/internal/lock"
1414
"go.jetpack.io/devbox/internal/nix"
15-
"go.jetpack.io/devbox/internal/vercheck"
1615
"golang.org/x/sync/errgroup"
1716
)
1817

@@ -206,8 +205,8 @@ func (p *Package) sysInfoIfExists() (*lock.SystemInfo, error) {
206205
return nil, err
207206
}
208207

209-
// enable for nix >= 2.17
210-
if vercheck.SemverCompare(version, "2.17.0") < 0 {
208+
// disable for nix < 2.17
209+
if !version.AtLeast(nix.Version2_17) {
211210
return nil, err
212211
}
213212

internal/nix/install.go

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,11 @@ import (
2020
"go.jetpack.io/devbox/internal/build"
2121
"go.jetpack.io/devbox/internal/cmdutil"
2222
"go.jetpack.io/devbox/internal/fileutil"
23+
"go.jetpack.io/devbox/internal/redact"
2324
"go.jetpack.io/devbox/internal/ux"
24-
"go.jetpack.io/devbox/internal/vercheck"
2525
)
2626

27-
const (
28-
minNixVersion = "2.12.0"
29-
rootError = "warning: installing Nix as root is not supported by this script!"
30-
)
27+
const rootError = "warning: installing Nix as root is not supported by this script!"
3128

3229
// Install runs the install script for Nix. daemon has 3 states
3330
// nil is unset. false is --no-daemon. true is --daemon.
@@ -111,19 +108,20 @@ func EnsureNixInstalled(writer io.Writer, withDaemonFunc func() *bool) (err erro
111108
if err != nil {
112109
return
113110
}
114-
version := ""
111+
112+
var version VersionInfo
115113
version, err = Version()
116114
if err != nil {
117-
err = fmt.Errorf("failed to get nix version: %w", err)
115+
err = redact.Errorf("nix: ensure install: get version: %w", err)
118116
return
119117
}
120118

121119
// ensure minimum nix version installed
122-
if vercheck.SemverCompare(version, minNixVersion) < 0 {
120+
if !version.AtLeast(MinVersion) {
123121
err = usererr.New(
124122
"Devbox requires nix of version >= %s. Your version is %s. "+
125123
"Please upgrade nix and try again.\n",
126-
minNixVersion,
124+
MinVersion,
127125
version,
128126
)
129127
return

internal/nix/nix.go

Lines changed: 45 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"go.jetpack.io/devbox/internal/boxcli/featureflag"
2323
"go.jetpack.io/devbox/internal/boxcli/usererr"
2424
"go.jetpack.io/devbox/internal/redact"
25+
"golang.org/x/mod/semver"
2526

2627
"go.jetpack.io/devbox/internal/debug"
2728
)
@@ -174,6 +175,22 @@ func SystemIsLinux() bool {
174175
return strings.Contains(System(), "linux")
175176
}
176177

178+
// All major Nix versions supported by Devbox.
179+
const (
180+
Version2_12 = "2.12.0"
181+
Version2_13 = "2.13.0"
182+
Version2_14 = "2.14.0"
183+
Version2_15 = "2.15.0"
184+
Version2_16 = "2.16.0"
185+
Version2_17 = "2.17.0"
186+
Version2_18 = "2.18.0"
187+
Version2_19 = "2.19.0"
188+
Version2_20 = "2.20.0"
189+
Version2_21 = "2.21.0"
190+
191+
MinVersion = Version2_12
192+
)
193+
177194
// VersionInfo contains information about a Nix installation.
178195
type VersionInfo struct {
179196
// Name is the executed program name (the first element of argv).
@@ -210,12 +227,9 @@ type VersionInfo struct {
210227
// DataDir is the path to the Nix data directory, usually somewhere
211228
// within the Nix store. This field is empty for Nix versions <= 2.12.
212229
DataDir string
213-
214-
// raw is the raw nix --version --debug output.
215-
raw string
216230
}
217231

218-
func parseVersionInfo(data []byte) VersionInfo {
232+
func parseVersionInfo(data []byte) (VersionInfo, error) {
219233
// Example nix --version --debug output from Nix versions 2.12 to 2.21.
220234
// Version 2.12 omits the data directory, but they're otherwise
221235
// identical.
@@ -232,13 +246,17 @@ func parseVersionInfo(data []byte) VersionInfo {
232246
// State directory: /nix/var/nix
233247
// Data directory: /nix/store/m0ns07v8by0458yp6k30rfq1rs3kaz6g-nix-2.21.2/share
234248

235-
info := VersionInfo{raw: string(data)}
236-
if len(info.raw) == 0 {
237-
return info
249+
info := VersionInfo{}
250+
if len(data) == 0 {
251+
return info, redact.Errorf("empty nix --version output")
238252
}
239253

240-
lines := strings.Split(info.raw, "\n")
241-
info.Name, info.Version, _ = strings.Cut(lines[0], " (Nix) ")
254+
lines := strings.Split(string(data), "\n")
255+
found := false
256+
info.Name, info.Version, found = strings.Cut(lines[0], " (Nix) ")
257+
if !found {
258+
return info, redact.Errorf("parse nix version: %s", redact.Safe(lines[0]))
259+
}
242260
for _, line := range lines {
243261
name, value, found := strings.Cut(line, ": ")
244262
if !found {
@@ -264,18 +282,21 @@ func parseVersionInfo(data []byte) VersionInfo {
264282
info.DataDir = value
265283
}
266284
}
267-
return info
285+
return info, nil
268286
}
269287

270-
func (v VersionInfo) version() (string, error) {
271-
if v.Version == "" {
272-
firstLine, _, _ := strings.Cut(v.raw, "\n")
273-
if strings.TrimSpace(firstLine) == "" {
274-
firstLine = "empty nix --version output"
275-
}
276-
return "", redact.Errorf("parse nix version: %s", redact.Safe(firstLine))
288+
// AtLeast returns true if v.Version is >= version per semantic versioning. It
289+
// always returns false if v.Version is empty or invalid, such as when the
290+
// current Nix version cannot be parsed. It panics if version is an invalid
291+
// semver.
292+
func (v VersionInfo) AtLeast(version string) bool {
293+
if !strings.HasPrefix(version, "v") {
294+
version = "v" + version
277295
}
278-
return v.Version, nil
296+
if !semver.IsValid(version) {
297+
panic(fmt.Sprintf("nix.atLeast: invalid version %q", version[1:]))
298+
}
299+
return semver.Compare("v"+v.Version, version) >= 0
279300
}
280301

281302
// version is the cached output of `nix --version --debug`.
@@ -292,28 +313,23 @@ func runNixVersion() (VersionInfo, error) {
292313
cmd := exec.CommandContext(ctx, "nix", "--version", "--debug")
293314
out, err := cmd.Output()
294315
if err != nil {
295-
if errors.Is(err, context.DeadlineExceeded) {
296-
return VersionInfo{}, redact.Errorf("nix command: %s: timed out while reading output", redact.Safe(cmd))
297-
}
298-
299316
var exitErr *exec.ExitError
300317
if errors.As(err, &exitErr) && len(exitErr.Stderr) != 0 {
301318
return VersionInfo{}, redact.Errorf("nix command: %s: %q: %v", redact.Safe(cmd), exitErr.Stderr, err)
302319
}
320+
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
321+
return VersionInfo{}, redact.Errorf("nix command: %s: timed out while reading output: %v", redact.Safe(cmd), err)
322+
}
303323
return VersionInfo{}, redact.Errorf("nix command: %s: %v", redact.Safe(cmd), err)
304324
}
305325

306326
debug.Log("nix --version --debug output:\n%s", out)
307-
return parseVersionInfo(out), nil
327+
return parseVersionInfo(out)
308328
}
309329

310330
// Version returns the currently installed version of Nix.
311-
func Version() (string, error) {
312-
info, err := versionInfo()
313-
if err != nil {
314-
return "", err
315-
}
316-
return info.version()
331+
func Version() (VersionInfo, error) {
332+
return versionInfo()
317333
}
318334

319335
var nixPlatforms = []string{

internal/nix/nix_test.go

Lines changed: 56 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,10 @@ State directory: /nix/var/nix
8282
Data directory: /nix/store/m0ns07v8by0458yp6k30rfq1rs3kaz6g-nix-2.21.2/share
8383
`
8484

85-
info := parseVersionInfo([]byte(raw))
85+
info, err := parseVersionInfo([]byte(raw))
86+
if err != nil {
87+
t.Error("got parse error:", err)
88+
}
8689
if got, want := info.Name, "nix"; got != want {
8790
t.Errorf("got Name = %q, want %q", got, want)
8891
}
@@ -116,7 +119,10 @@ Data directory: /nix/store/m0ns07v8by0458yp6k30rfq1rs3kaz6g-nix-2.21.2/share
116119
}
117120

118121
func TestParseVersionInfoShort(t *testing.T) {
119-
info := parseVersionInfo([]byte("nix (Nix) 2.21.2"))
122+
info, err := parseVersionInfo([]byte("nix (Nix) 2.21.2"))
123+
if err != nil {
124+
t.Error("got parse error:", err)
125+
}
120126
if got, want := info.Name, "nix"; got != want {
121127
t.Errorf("got Name = %q, want %q", got, want)
122128
}
@@ -125,33 +131,59 @@ func TestParseVersionInfoShort(t *testing.T) {
125131
}
126132
}
127133

128-
func TestParseVersionInfoEmpty(t *testing.T) {
129-
// Don't panic.
130-
parseVersionInfo(nil)
131-
parseVersionInfo([]byte{})
132-
}
133-
134-
func TestVersionInfoVersion(t *testing.T) {
135-
t.Run("Valid", func(t *testing.T) {
136-
info := VersionInfo{Version: "2.21.0"}
137-
got, err := info.version()
138-
if err != nil {
139-
t.Error("got error:", err)
140-
}
141-
if got != info.Version {
142-
t.Fatalf("got version %q, want %q", got, info.Version)
134+
func TestParseVersionInfoError(t *testing.T) {
135+
t.Run("NilOutput", func(t *testing.T) {
136+
_, err := parseVersionInfo(nil)
137+
if err == nil {
138+
t.Error("want non-nil error")
143139
}
144140
})
145-
146-
t.Run("Empty", func(t *testing.T) {
147-
info := VersionInfo{}
148-
if _, err := info.version(); err == nil {
141+
t.Run("EmptyOutput", func(t *testing.T) {
142+
_, err := parseVersionInfo([]byte{})
143+
if err == nil {
149144
t.Error("want non-nil error")
150145
}
151-
152-
info.raw = "nix output without a version"
153-
if _, err := info.version(); err == nil {
146+
})
147+
t.Run("MissingVersionOutput", func(t *testing.T) {
148+
_, err := parseVersionInfo([]byte("nix output without a version"))
149+
if err == nil {
154150
t.Error("want non-nil error")
155151
}
156152
})
157153
}
154+
155+
func TestVersionInfoAtLeast(t *testing.T) {
156+
info := VersionInfo{}
157+
if info.AtLeast(Version2_12) {
158+
t.Errorf("got empty current version >= %s", Version2_12)
159+
}
160+
161+
info.Version = Version2_13
162+
if !info.AtLeast(Version2_12) {
163+
t.Errorf("got %s < %s", info.Version, Version2_12)
164+
}
165+
if !info.AtLeast(Version2_13) {
166+
t.Errorf("got %s < %s", info.Version, Version2_13)
167+
}
168+
if info.AtLeast(Version2_14) {
169+
t.Errorf("got %s >= %s", info.Version, Version2_14)
170+
}
171+
172+
t.Run("ArgEmptyPanic", func(t *testing.T) {
173+
defer func() {
174+
if r := recover(); r == nil {
175+
t.Error("want panic for empty version")
176+
}
177+
}()
178+
info.AtLeast("")
179+
})
180+
t.Run("ArgInvalidPanic", func(t *testing.T) {
181+
v := "notasemver"
182+
defer func() {
183+
if r := recover(); r == nil {
184+
t.Errorf("want panic for invalid version %q", v)
185+
}
186+
}()
187+
info.AtLeast(v)
188+
})
189+
}

internal/nix/store.go

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,22 @@ func (e *DaemonError) Redact() string {
120120

121121
// DaemonVersion returns the version of the currently running Nix daemon.
122122
func DaemonVersion(ctx context.Context) (string, error) {
123-
cmd := commandContext(ctx, "store", "info", "--json", "--store", "daemon")
123+
// We only need the version to decide which CLI flags to use. We can
124+
// ignore the error because an empty version assumes nix.MinVersion.
125+
cliVersion, _ := Version()
126+
127+
storeCmd := "ping"
128+
if cliVersion.AtLeast(Version2_19) {
129+
// "nix store ping" is deprecated as of 2.19 in favor of
130+
// "nix store info".
131+
storeCmd = "info"
132+
}
133+
canJSON := cliVersion.AtLeast(Version2_14)
134+
135+
cmd := commandContext(ctx, "store", storeCmd, "--store", "daemon")
136+
if canJSON {
137+
cmd.Args = append(cmd.Args, "--json")
138+
}
124139
out, err := cmd.Output()
125140

126141
// ExitError means the command ran, but couldn't connect.
@@ -139,9 +154,27 @@ func DaemonVersion(ctx context.Context) (string, error) {
139154
return "", redact.Errorf("command %s: %s", redact.Safe(cmd), err)
140155
}
141156

142-
info := struct{ Version string }{}
143-
if err := json.Unmarshal(out, &info); err != nil {
144-
return "", redact.Errorf("%s: unmarshal JSON output: %s", redact.Safe(cmd.String()), err)
157+
if len(out) == 0 {
158+
return "", redact.Errorf("command %s: empty output", redact.Safe(cmd), err)
159+
}
160+
if canJSON {
161+
info := struct{ Version string }{}
162+
if err := json.Unmarshal(out, &info); err != nil {
163+
return "", redact.Errorf("command %s: unmarshal JSON output: %s", redact.Safe(cmd), err)
164+
}
165+
return info.Version, nil
166+
}
167+
168+
// Example output:
169+
//
170+
// Store URL: daemon
171+
// Version: 2.21.1
172+
lines := strings.Split(string(out), "\n")
173+
for _, line := range lines {
174+
name, value, found := strings.Cut(line, ": ")
175+
if found && name == "Version" {
176+
return value, nil
177+
}
145178
}
146-
return info.Version, nil
179+
return "", redact.Errorf("parse nix daemon version: %s", redact.Safe(lines[0]))
147180
}

0 commit comments

Comments
 (0)