Skip to content

Commit 7e4e95d

Browse files
committed
Derive nix profile from flake: attempt 2
1 parent 5cbd4a9 commit 7e4e95d

File tree

11 files changed

+272
-251
lines changed

11 files changed

+272
-251
lines changed

internal/devbox/nixprofile.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package devbox
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/samber/lo"
9+
"go.jetpack.io/devbox/internal/nix"
10+
"go.jetpack.io/devbox/internal/nix/nixprofile"
11+
"go.jetpack.io/devbox/internal/ux"
12+
)
13+
14+
// syncNixProfileFromFlake ensures the nix profile has the packages from the buildInputs
15+
// from the devshell of the generated flake.
16+
//
17+
// It also removes any packages from the nix profile that are no longer in the buildInputs.
18+
func (d *Devbox) syncNixProfileFromFlake(ctx context.Context) error {
19+
// Get the computed Devbox environment from the generated flake
20+
env, err := d.computeEnv(ctx, true /*usePrintDevEnvCache*/)
21+
if err != nil {
22+
return err
23+
}
24+
25+
// Get the store-paths of the packages we want installed in the nix profile
26+
wantStorePaths := []string{}
27+
if env["buildInputs"] != "" {
28+
// env["buildInputs"] can be empty string if there are no packages in the project
29+
// if buildInputs is empty, then we don't want wantStorePaths to be an array with a single "" entry
30+
wantStorePaths = strings.Split(env["buildInputs"], " ")
31+
}
32+
33+
profilePath, err := d.profilePath()
34+
if err != nil {
35+
return err
36+
}
37+
38+
// Get the store-paths of the packages currently installed in the nix profile
39+
items, err := nixprofile.ProfileListItems(d.stderr, profilePath)
40+
if err != nil {
41+
return fmt.Errorf("nix profile list: %v", err)
42+
}
43+
gotStorePaths := make([]string, 0, len(items))
44+
for _, item := range items {
45+
gotStorePaths = append(gotStorePaths, item.StorePaths()...)
46+
}
47+
48+
// Diff the store paths and install/remove packages as needed
49+
remove, add := lo.Difference(gotStorePaths, wantStorePaths)
50+
if len(remove) > 0 {
51+
packagesToRemove := make([]string, 0, len(remove))
52+
for _, p := range remove {
53+
storePath := nix.NewStorePathParts(p)
54+
packagesToRemove = append(packagesToRemove, fmt.Sprintf("%s@%s", storePath.Name, storePath.Version))
55+
}
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+
}
61+
62+
if err := nix.ProfileRemove(profilePath, remove...); err != nil {
63+
return err
64+
}
65+
}
66+
if len(add) > 0 {
67+
total := len(add)
68+
for idx, addPath := range add {
69+
stepNum := idx + 1
70+
storePath := nix.NewStorePathParts(addPath)
71+
nameAndVersion := fmt.Sprintf("%s@%s", storePath.Name, storePath.Version)
72+
stepMsg := fmt.Sprintf("[%d/%d] %s", stepNum, total, nameAndVersion)
73+
74+
if err = nixprofile.ProfileInstall(ctx, &nixprofile.ProfileInstallArgs{
75+
CustomStepMessage: stepMsg,
76+
Installable: addPath,
77+
// Install in offline mode for speed. We know we should have all the files
78+
// locally in /nix/store since we have run `nix print-dev-env` prior to this.
79+
// Also avoids some "substituter not found for store-path" errors.
80+
Offline: true,
81+
PackageName: storePath.Name,
82+
ProfilePath: profilePath,
83+
Writer: d.stderr,
84+
}); err != nil {
85+
return fmt.Errorf("error installing package %s: %w", addPath, err)
86+
}
87+
}
88+
}
89+
return nil
90+
}

internal/devbox/packages.go

Lines changed: 88 additions & 170 deletions
Original file line numberDiff line numberDiff line change
@@ -273,14 +273,14 @@ func (d *Devbox) ensureStateIsUpToDate(ctx context.Context, mode installMode) er
273273

274274
recomputeState := mode == ensure || d.IsEnvEnabled()
275275
if recomputeState {
276-
if err := d.recomputeState(ctx, mode); err != nil {
276+
if err := d.recomputeState(ctx); err != nil {
277277
return err
278278
}
279279
} else {
280-
// TODO: in the next PR, we will only `nix build` the packages that are being
281-
// added or updated. For now, we continue to call recomputeState here.
282-
if err := d.recomputeState(ctx, mode); err != nil {
283-
return err
280+
if mode == install || mode == update {
281+
if err := d.installNixPackagesToStore(ctx); err != nil {
282+
return err
283+
}
284284
}
285285
}
286286

@@ -312,18 +312,14 @@ func (d *Devbox) ensureStateIsUpToDate(ctx context.Context, mode installMode) er
312312
// - devbox.lock file
313313
// - the generated flake
314314
// - the nix-profile
315-
func (d *Devbox) recomputeState(ctx context.Context, mode installMode) error {
315+
func (d *Devbox) recomputeState(ctx context.Context) error {
316316
// Create plugin directories first because packages might need them
317317
for _, pkg := range d.InstallablePackages() {
318318
if err := d.PluginManager().Create(pkg); err != nil {
319319
return err
320320
}
321321
}
322322

323-
if err := d.syncPackagesToProfile(ctx, mode); err != nil {
324-
return err
325-
}
326-
327323
if err := d.InstallRunXPackages(ctx); err != nil {
328324
return err
329325
}
@@ -336,10 +332,11 @@ func (d *Devbox) recomputeState(ctx context.Context, mode installMode) error {
336332
return err
337333
}
338334

339-
// Use the printDevEnvCache if we are adding or removing or updating any package,
340-
// AND we are not in the shellenv-enabled environment of the current devbox-project.
341-
usePrintDevEnvCache := mode != ensure && !d.IsEnvEnabled()
342-
if _, err := d.computeEnv(ctx, usePrintDevEnvCache); err != nil {
335+
if err := d.installNixPackagesToStore(ctx); err != nil {
336+
return err
337+
}
338+
339+
if err := d.syncNixProfileFromFlake(ctx); err != nil {
343340
return err
344341
}
345342

@@ -366,162 +363,6 @@ func (d *Devbox) profilePath() (string, error) {
366363
return absPath, errors.WithStack(os.MkdirAll(filepath.Dir(absPath), 0o755))
367364
}
368365

369-
// syncPackagesToProfile can ensure that all packages in devbox.json exist in the nix profile,
370-
// and no more. However, it may skip some steps depending on the `mode`.
371-
func (d *Devbox) syncPackagesToProfile(ctx context.Context, mode installMode) error {
372-
defer debug.FunctionTimer().End()
373-
defer trace.StartRegion(ctx, "syncPackagesToProfile").End()
374-
375-
// First, fetch the profile items from the nix-profile,
376-
// and get the installable packages
377-
profileDir, err := d.profilePath()
378-
if err != nil {
379-
return err
380-
}
381-
profileItems, err := nixprofile.ProfileListItems(d.stderr, profileDir)
382-
if err != nil {
383-
return err
384-
}
385-
packages, err := d.AllInstallablePackages()
386-
if err != nil {
387-
return err
388-
}
389-
390-
// Remove non-nix packages from the list
391-
packages = lo.Filter(packages, devpkg.IsNix)
392-
393-
if err := devpkg.FillNarInfoCache(ctx, packages...); err != nil {
394-
return err
395-
}
396-
397-
// Second, remove any packages from the nix-profile that are not in the config
398-
itemsToKeep := profileItems
399-
if mode != install {
400-
itemsToKeep, err = d.removeExtraItemsFromProfile(ctx, profileDir, profileItems, packages)
401-
if err != nil {
402-
return err
403-
}
404-
}
405-
406-
// we are done if mode is uninstall
407-
if mode == uninstall {
408-
return nil
409-
}
410-
411-
// Last, find the pending packages, and ensure they are added to the nix-profile
412-
// Important to maintain the order of packages as specified by
413-
// Devbox.InstallablePackages() (higher priority first).
414-
// We also run nix profile upgrade on any virtenv flakes. This is a bit of a
415-
// blunt approach, but ensured any plugin created flakes are up-to-date.
416-
pending := []*devpkg.Package{}
417-
for _, pkg := range packages {
418-
idx, err := nixprofile.ProfileListIndex(&nixprofile.ProfileListIndexArgs{
419-
Items: itemsToKeep,
420-
Lockfile: d.lockfile,
421-
Writer: d.stderr,
422-
Package: pkg,
423-
ProfileDir: profileDir,
424-
})
425-
if err != nil {
426-
if !errors.Is(err, nix.ErrPackageNotFound) {
427-
return err
428-
}
429-
pending = append(pending, pkg)
430-
} else if f, err := pkg.FlakeInstallable(); err == nil && d.pluginManager.PathIsInVirtenv(f.Ref.Path) {
431-
if err := nix.ProfileUpgrade(profileDir, idx); err != nil {
432-
return err
433-
}
434-
}
435-
}
436-
437-
return d.addPackagesToProfile(ctx, pending)
438-
}
439-
440-
func (d *Devbox) removeExtraItemsFromProfile(
441-
ctx context.Context,
442-
profileDir string,
443-
profileItems []*nixprofile.NixProfileListItem,
444-
packages []*devpkg.Package,
445-
) ([]*nixprofile.NixProfileListItem, error) {
446-
defer debug.FunctionTimer().End()
447-
defer trace.StartRegion(ctx, "removeExtraPackagesFromProfile").End()
448-
449-
itemsToKeep := []*nixprofile.NixProfileListItem{}
450-
extras := []*nixprofile.NixProfileListItem{}
451-
// Note: because devpkg.Package uses memoization when normalizing attribute paths (slow operation),
452-
// and since we're reusing the Package objects, this O(n*m) loop becomes O(n+m) wrt the slow operation.
453-
for _, item := range profileItems {
454-
found := false
455-
for _, pkg := range packages {
456-
if item.Matches(pkg, d.lockfile) {
457-
itemsToKeep = append(itemsToKeep, item)
458-
found = true
459-
break
460-
}
461-
}
462-
if !found {
463-
extras = append(extras, item)
464-
}
465-
}
466-
// Remove by index to avoid comparing nix.ProfileListItem <> nix.Inputs again.
467-
if err := nixprofile.ProfileRemoveItems(profileDir, extras); err != nil {
468-
return nil, err
469-
}
470-
return itemsToKeep, nil
471-
}
472-
473-
// addPackagesToProfile inspects the packages in devbox.json, checks which of them
474-
// are missing from the nix profile, and then installs each package individually into the
475-
// nix profile.
476-
func (d *Devbox) addPackagesToProfile(ctx context.Context, pkgs []*devpkg.Package) error {
477-
defer debug.FunctionTimer().End()
478-
defer trace.StartRegion(ctx, "addPackagesToProfile").End()
479-
480-
if len(pkgs) == 0 {
481-
return nil
482-
}
483-
484-
// If packages are in profile but nixpkgs has been purged, the experience
485-
// will be poor when we try to run print-dev-env. So we ensure nixpkgs is
486-
// prefetched for all relevant packages (those not in binary cache).
487-
if err := devpkg.EnsureNixpkgsPrefetched(ctx, d.stderr, pkgs); err != nil {
488-
return err
489-
}
490-
491-
var msg string
492-
if len(pkgs) == 1 {
493-
msg = fmt.Sprintf("Installing package: %s.", pkgs[0])
494-
} else {
495-
pkgNames := lo.Map(pkgs, func(p *devpkg.Package, _ int) string { return p.Raw })
496-
msg = fmt.Sprintf("Installing %d packages: %s.", len(pkgs), strings.Join(pkgNames, ", "))
497-
}
498-
fmt.Fprintf(d.stderr, "\n%s\n\n", msg)
499-
500-
profileDir, err := d.profilePath()
501-
if err != nil {
502-
return fmt.Errorf("error getting profile path: %w", err)
503-
}
504-
505-
total := len(pkgs)
506-
for idx, pkg := range pkgs {
507-
stepNum := idx + 1
508-
509-
stepMsg := fmt.Sprintf("[%d/%d] %s", stepNum, total, pkg)
510-
511-
if err = nixprofile.ProfileInstall(ctx, &nixprofile.ProfileInstallArgs{
512-
CustomStepMessage: stepMsg,
513-
Lockfile: d.lockfile,
514-
Package: pkg.Raw,
515-
ProfilePath: profileDir,
516-
Writer: d.stderr,
517-
}); err != nil {
518-
return fmt.Errorf("error installing package %s: %w", pkg, err)
519-
}
520-
}
521-
522-
return nil
523-
}
524-
525366
var resetCheckDone = false
526367

527368
// resetProfileDirForFlakes ensures the profileDir directory is cleared of old
@@ -571,3 +412,80 @@ func (d *Devbox) InstallRunXPackages(ctx context.Context) error {
571412
}
572413
return nil
573414
}
415+
416+
// installNixPackagesToStore will install all the packages in the nix store, if
417+
// mode is install or update, and we're not in a devbox environment.
418+
// This is done by running `nix build` on the flake. We do this so that the
419+
// packages will be available in the nix store when computing the devbox environment
420+
// and installing in the nix profile (even if offline).
421+
func (d *Devbox) installNixPackagesToStore(ctx context.Context) error {
422+
packages, err := d.packagesToInstallInProfile(ctx)
423+
if err != nil {
424+
return err
425+
}
426+
427+
for _, pkg := range packages {
428+
installable, err := pkg.Installable()
429+
if err != nil {
430+
return err
431+
}
432+
433+
ux.Finfo(d.stderr, "Installing: %s\n", pkg.Raw)
434+
// --no-link to avoid generating the result objects
435+
err = nix.Build(ctx, []string{"--no-link"}, installable)
436+
if err != nil {
437+
platform := nix.System()
438+
return usererr.New(
439+
"package %s cannot be installed on your platform %s.\n"+
440+
"If you know this package is incompatible with %[2]s, then "+
441+
"you could run `devbox add %[1]s --exclude-platform %[2]s` and re-try.\n"+
442+
"If you think this package should be compatible with %[2]s, then "+
443+
"it's possible this particular version is not available yet from the nix registry. "+
444+
"You could try `devbox add` with a different version for this package.\n",
445+
pkg.Raw,
446+
platform,
447+
)
448+
}
449+
}
450+
return err
451+
}
452+
453+
func (d *Devbox) packagesToInstallInProfile(ctx context.Context) ([]*devpkg.Package, error) {
454+
// First, fetch the profile items from the nix-profile,
455+
profileDir, err := d.profilePath()
456+
if err != nil {
457+
return nil, err
458+
}
459+
profileItems, err := nixprofile.ProfileListItems(d.stderr, profileDir)
460+
if err != nil {
461+
return nil, err
462+
}
463+
464+
// Second, get and prepare all the packages that must be installed in this project
465+
packages, err := d.AllInstallablePackages()
466+
if err != nil {
467+
return nil, err
468+
}
469+
packages = lo.Filter(packages, devpkg.IsNix) // Remove non-nix packages from the list
470+
if err := devpkg.FillNarInfoCache(ctx, packages...); err != nil {
471+
return nil, err
472+
}
473+
474+
// Third, compute which packages need to be installed
475+
packagesToInstall := []*devpkg.Package{}
476+
// Note: because devpkg.Package uses memoization when normalizing attribute paths (slow operation),
477+
// and since we're reusing the Package objects, this O(n*m) loop becomes O(n+m) wrt the slow operation.
478+
for _, pkg := range packages {
479+
found := false
480+
for _, item := range profileItems {
481+
if item.Matches(pkg, d.lockfile) {
482+
found = true
483+
break
484+
}
485+
}
486+
if !found {
487+
packagesToInstall = append(packagesToInstall, pkg)
488+
}
489+
}
490+
return packagesToInstall, nil
491+
}

0 commit comments

Comments
 (0)