Skip to content

Commit 10c436f

Browse files
authored
[UX] Better dedup packages we nix build, and improve UX printouts (#1827)
## Summary We have two separate steps for "installing packages". 1. We use nix build to add to the nix store. 2. We later ensure the nix profile is updated to have items pointing to the nix store packages. For the first step, we should only run `nix build` if the package's outputs are not in the nix store. For this, we check `nix store ls <path>`. We get `<path>` from `nix path-info <installable>`. Previously, we were checking if the packages were in the `nix profile`, rather than directly querying the nix store. This would lead to re-running `nix build` for when we've done `devbox add` outside a devbox-environment, and then started a devbox-environment (example: doing `devbox add` followed by `devbox shell`). For the second step, we can remove the `[1/3] <package>` being removed print outs. This was already removed for packages being added. Reason: this step should be quick! We've already added the packages to the nix store via `nix build`. It also confuses users who are likely not aware of a `nix profile` concept, nor should we make them aware of it. **Alternatives:** Also tried `nix build --profile <path> <installable>` but this doesn't seem to return the installed package in `nix profile list`. Not sure why, but it doesmean its not useful for de-duplicating packages that remain to be installed. **Upside:** Ths store-path deduping is great. Works much faster. Re-doing `devbox add <package>` for a package already in `/nix/store` is much faster since we skip the `nix build` step entirely. As it should be. **Downside:** A user adding or removing packages directly from editing the `devbox.json` will not see any nice per-package-steps installation output. (Note, this was changed when adding nix-proflile-from-flake, but mentioning for completeness of analyzing the UX) ## How was it tested? `devbox add` a package already in store no longer shows the per-package installation steps `devbox shell` subsequently does not again show we are adding the package. `devbox add` a package not in store does show the per-package-steps installation output. Need to do more rigorous testing: - [ ] add a local flake - [ ] add a remote flake
1 parent 9411a3e commit 10c436f

File tree

5 files changed

+124
-9
lines changed

5 files changed

+124
-9
lines changed

internal/devbox/nixprofile.go

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import (
66
"strings"
77

88
"github.com/samber/lo"
9+
"go.jetpack.io/devbox/internal/debug"
910
"go.jetpack.io/devbox/internal/nix"
1011
"go.jetpack.io/devbox/internal/nix/nixprofile"
11-
"go.jetpack.io/devbox/internal/ux"
1212
)
1313

1414
// syncNixProfileFromFlake ensures the nix profile has the packages from the buildInputs
@@ -53,11 +53,7 @@ func (d *Devbox) syncNixProfileFromFlake(ctx context.Context) error {
5353
storePath := nix.NewStorePathParts(p)
5454
packagesToRemove = append(packagesToRemove, fmt.Sprintf("%s@%s", storePath.Name, storePath.Version))
5555
}
56-
if len(packagesToRemove) == 1 {
57-
ux.Finfo(d.stderr, "Removing %s\n", strings.Join(packagesToRemove, ", "))
58-
} else {
59-
ux.Finfo(d.stderr, "Removing packages: %s\n", strings.Join(packagesToRemove, ", "))
60-
}
56+
debug.Log("Removing packages from nix profile: %s\n", strings.Join(packagesToRemove, ", "))
6157

6258
if err := nix.ProfileRemove(profilePath, remove...); err != nil {
6359
return err

internal/devbox/packages.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,7 @@ func (d *Devbox) InstallRunXPackages(ctx context.Context) error {
422422
// packages will be available in the nix store when computing the devbox environment
423423
// and installing in the nix profile (even if offline).
424424
func (d *Devbox) installNixPackagesToStore(ctx context.Context) error {
425-
packages, err := d.packagesToInstallInProfile(ctx)
425+
packages, err := d.packagesToInstallInStore(ctx)
426426
if err != nil {
427427
return err
428428
}
@@ -489,7 +489,7 @@ func (d *Devbox) installNixPackagesToStore(ctx context.Context) error {
489489
return err
490490
}
491491

492-
func (d *Devbox) packagesToInstallInProfile(ctx context.Context) ([]*devpkg.Package, error) {
492+
func (d *Devbox) packagesToInstallInStore(ctx context.Context) ([]*devpkg.Package, error) {
493493
// First, fetch the profile items from the nix-profile,
494494
profileDir, err := d.profilePath()
495495
if err != nil {
@@ -511,7 +511,7 @@ func (d *Devbox) packagesToInstallInProfile(ctx context.Context) ([]*devpkg.Pack
511511
}
512512

513513
// Third, compute which packages need to be installed
514-
packagesToInstall := []*devpkg.Package{}
514+
packagesNotInProfile := []*devpkg.Package{}
515515
// Note: because devpkg.Package uses memoization when normalizing attribute paths (slow operation),
516516
// and since we're reusing the Package objects, this O(n*m) loop becomes O(n+m) wrt the slow operation.
517517
for _, pkg := range packages {
@@ -523,9 +523,29 @@ func (d *Devbox) packagesToInstallInProfile(ctx context.Context) ([]*devpkg.Pack
523523
}
524524
}
525525
if !found {
526+
packagesNotInProfile = append(packagesNotInProfile, pkg)
527+
}
528+
}
529+
530+
packagesToInstall := []*devpkg.Package{}
531+
for _, pkg := range packagesNotInProfile {
532+
installable, err := pkg.Installable()
533+
if err != nil {
534+
return nil, err
535+
}
536+
storePaths, err := nix.StorePathsFromInstallable(ctx, installable)
537+
if err != nil {
538+
return nil, err
539+
}
540+
isInStore, err := nix.StorePathsAreInStore(ctx, storePaths)
541+
if err != nil {
542+
return nil, err
543+
}
544+
if !isInStore {
526545
packagesToInstall = append(packagesToInstall, pkg)
527546
}
528547
}
548+
529549
return packagesToInstall, nil
530550
}
531551

internal/nix/store.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@ package nix
22

33
import (
44
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"os"
9+
"os/exec"
510
"strings"
11+
12+
"go.jetpack.io/devbox/internal/debug"
13+
"golang.org/x/exp/maps"
614
)
715

816
func StorePathFromHashPart(ctx context.Context, hash, storeAddr string) (string, error) {
@@ -13,3 +21,56 @@ func StorePathFromHashPart(ctx context.Context, hash, storeAddr string) (string,
1321
}
1422
return strings.TrimSpace(string(resultBytes)), nil
1523
}
24+
25+
func StorePathsFromInstallable(ctx context.Context, installable string) ([]string, error) {
26+
// --impure for NIXPKGS_ALLOW_UNFREE
27+
cmd := commandContext(ctx, "path-info", installable, "--json", "--impure")
28+
cmd.Env = allowUnfreeEnv(os.Environ())
29+
debug.Log("Running cmd %s", cmd)
30+
resultBytes, err := cmd.Output()
31+
if err != nil {
32+
return nil, err
33+
}
34+
return parseStorePathFromInstallableOutput(installable, resultBytes)
35+
}
36+
37+
// StorePathAreInStore returns true if the store path is in the store
38+
// It relies on `nix store ls` to check if the store path is in the store
39+
func StorePathsAreInStore(ctx context.Context, storePaths []string) (bool, error) {
40+
for _, storePath := range storePaths {
41+
cmd := commandContext(ctx, "store", "ls", storePath)
42+
debug.Log("Running cmd %s", cmd)
43+
if err := cmd.Run(); err != nil {
44+
if exitErr := (&exec.ExitError{}); errors.As(err, &exitErr) {
45+
return false, nil
46+
}
47+
return false, err
48+
}
49+
}
50+
return true, nil
51+
}
52+
53+
// parseStorePathFromInstallableOutput parses the output of `nix store path-from-installable --json`
54+
// This function is decomposed out of StorePathFromInstallable to make it testable.
55+
func parseStorePathFromInstallableOutput(installable string, output []byte) ([]string, error) {
56+
// Newer nix versions (like 2.20)
57+
var out1 map[string]any
58+
if err := json.Unmarshal(output, &out1); err == nil {
59+
return maps.Keys(out1), nil
60+
}
61+
62+
// Older nix versions (like 2.17)
63+
var out2 []struct {
64+
Path string `json:"path"`
65+
Valid bool `json:"valid"`
66+
}
67+
if err := json.Unmarshal(output, &out2); err == nil {
68+
res := []string{}
69+
for _, outValue := range out2 {
70+
res = append(res, outValue.Path)
71+
}
72+
return res, nil
73+
}
74+
75+
return nil, fmt.Errorf("failed to parse store path from installable (%s) output: %s", installable, output)
76+
}

internal/nix/store_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,38 @@
11
package nix
2+
3+
import (
4+
"slices"
5+
"testing"
6+
)
7+
8+
func TestParseStorePathFromInstallableOutput(t *testing.T) {
9+
testCases := []struct {
10+
name string
11+
input string
12+
expected []string
13+
}{
14+
{
15+
name: "go-basic-nix-2-20-1",
16+
// snipped the actual output for brevity. We mainly care about the first key in the JSON.
17+
input: `{"/nix/store/fgkl3qk8p5hnd07b0dhzfky3ys5gxjmq-go-1.22.0":{"deriver":"/nix/store/clr3bm8njqysvyw4r4x4xmldhz4knrff-go-1.22.0.drv"}}`,
18+
expected: []string{"/nix/store/fgkl3qk8p5hnd07b0dhzfky3ys5gxjmq-go-1.22.0"},
19+
},
20+
{
21+
name: "go-basic-nix-2-17-0",
22+
input: `[{"path":"/nix/store/fgkl3qk8p5hnd07b0dhzfky3ys5gxjmq-go-1.22.0","valid":false}]`,
23+
expected: []string{"/nix/store/fgkl3qk8p5hnd07b0dhzfky3ys5gxjmq-go-1.22.0"},
24+
},
25+
}
26+
27+
for _, tc := range testCases {
28+
t.Run(tc.name, func(t *testing.T) {
29+
actual, err := parseStorePathFromInstallableOutput(tc.name, []byte(tc.input))
30+
if err != nil {
31+
t.Errorf("Expected no error but got error: %s", err)
32+
}
33+
if !slices.Equal(tc.expected, actual) {
34+
t.Errorf("Expected store path %s but got %s", tc.expected, actual)
35+
}
36+
})
37+
}
38+
}

typos.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ extend-exclude=[
88
"**/testdata/**",
99
"internal/cachehash/hash_test.go",
1010
"internal/devpkg/package_test.go",
11+
"internal/nix/store_test.go",
1112
]

0 commit comments

Comments
 (0)