diff --git a/internal/nix/install.go b/internal/nix/install.go index d714b8b5646..cbc49c4c7bf 100644 --- a/internal/nix/install.go +++ b/internal/nix/install.go @@ -17,7 +17,6 @@ import ( "go.jetify.com/devbox/internal/boxcli/usererr" "go.jetify.com/devbox/internal/cmdutil" - "go.jetify.com/devbox/internal/fileutil" "go.jetify.com/devbox/nix" ) @@ -25,9 +24,33 @@ func BinaryInstalled() bool { return cmdutil.Exists("nix") } -func dirExistsAndIsNotEmpty(dir string) bool { - empty, err := fileutil.IsDirEmpty(dir) - return err == nil && !empty +// nonNixDirEntries are directory entries that can appear inside /nix without +// indicating an existing Nix installation. Container and VM users commonly +// mount an otherwise-empty volume at /nix, and many filesystems (for example +// ext4) create a lost+found directory at the root of such a mount. A /nix that +// contains only these entries should still be treated as a fresh install +// target rather than a broken installation. +var nonNixDirEntries = map[string]bool{ + "lost+found": true, + ".DS_Store": true, +} + +// nixDirIsInstalled reports whether dir looks like it already contains a Nix +// installation. It returns false when dir is missing, empty, or contains only +// filesystem cruft (see nonNixDirEntries) so that Devbox installs Nix into a +// freshly mounted /nix volume instead of reporting a broken installation. +// See https://github.com/jetify-com/devbox/issues/2601. +func nixDirIsInstalled(dir string) bool { + entries, err := os.ReadDir(dir) + if err != nil { + return false + } + for _, entry := range entries { + if !nonNixDirEntries[entry.Name()] { + return true + } + } + return false } var ensured = false @@ -58,7 +81,7 @@ func EnsureNixInstalled(ctx context.Context, writer io.Writer, withDaemonFunc fu if BinaryInstalled() { return nil } - if dirExistsAndIsNotEmpty("/nix") { + if nixDirIsInstalled("/nix") { if _, err = SourceProfile(); err != nil { return err } else if BinaryInstalled() { diff --git a/internal/nix/install_test.go b/internal/nix/install_test.go index 7e8250849c3..99c17d56e50 100644 --- a/internal/nix/install_test.go +++ b/internal/nix/install_test.go @@ -12,7 +12,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestDirExistsAndIsNotEmpty(t *testing.T) { +func TestNixDirIsInstalled(t *testing.T) { tests := []struct { name string setup func(string) error @@ -34,10 +34,9 @@ func TestDirExistsAndIsNotEmpty(t *testing.T) { expected: true, }, { - name: "directory with subdirectories", + name: "directory with nix store", setup: func(dir string) error { - subdir := filepath.Join(dir, "subdir") - return os.MkdirAll(subdir, 0o755) + return os.MkdirAll(filepath.Join(dir, "store"), 0o755) }, expected: true, }, @@ -49,6 +48,26 @@ func TestDirExistsAndIsNotEmpty(t *testing.T) { }, expected: true, }, + { + // Regression test for jetify-com/devbox#2601: mounting an empty + // /nix volume in Docker/Kubernetes often leaves a lost+found + // directory, which must not be mistaken for an existing install. + name: "directory with only lost+found", + setup: func(dir string) error { + return os.MkdirAll(filepath.Join(dir, "lost+found"), 0o755) + }, + expected: false, + }, + { + name: "directory with lost+found and nix store", + setup: func(dir string) error { + if err := os.MkdirAll(filepath.Join(dir, "lost+found"), 0o755); err != nil { + return err + } + return os.MkdirAll(filepath.Join(dir, "store"), 0o755) + }, + expected: true, + }, { name: "non-existent directory", setup: func(dir string) error { @@ -70,7 +89,7 @@ func TestDirExistsAndIsNotEmpty(t *testing.T) { } // Run the function - result := dirExistsAndIsNotEmpty(tempDir) + result := nixDirIsInstalled(tempDir) // Check results assert.Equal(t, curTest.expected, result)