Skip to content

[wip/poc] impl: use flake for profile packages #1588

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from
Closed
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
163 changes: 8 additions & 155 deletions internal/impl/packages.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import (
"github.com/samber/lo"
"go.jetpack.io/devbox/internal/devpkg"
"go.jetpack.io/devbox/internal/devpkg/pkgtype"
"go.jetpack.io/devbox/internal/nix/nixprofile"
"go.jetpack.io/devbox/internal/shellgen"

"go.jetpack.io/devbox/internal/boxcli/usererr"
Expand Down Expand Up @@ -240,10 +239,6 @@ func (d *Devbox) ensurePackagesAreInstalled(ctx context.Context, mode installMod
}
}

if err := d.syncPackagesToProfile(ctx, mode); err != nil {
return err
}

if err := d.InstallRunXPackages(ctx); err != nil {
return err
}
Expand All @@ -264,6 +259,14 @@ func (d *Devbox) ensurePackagesAreInstalled(ctx context.Context, mode installMod
return err
}

profile, err := d.profilePath()
if err != nil {
return err
}
if err := syncFlakeToProfile(ctx, d.flakeDir(), profile); err != nil {
return err
}

// Ensure we clean out packages that are no longer needed.
d.lockfile.Tidy()

Expand Down Expand Up @@ -301,156 +304,6 @@ func (d *Devbox) profilePath() (string, error) {
return absPath, errors.WithStack(os.MkdirAll(filepath.Dir(absPath), 0o755))
}

// syncPackagesToProfile can ensure that all packages in devbox.json exist in the nix profile,
// and no more. However, it may skip some steps depending on the `mode`.
func (d *Devbox) syncPackagesToProfile(ctx context.Context, mode installMode) error {
defer debug.FunctionTimer().End()
defer trace.StartRegion(ctx, "syncPackagesToProfile").End()

// First, fetch the profile items from the nix-profile,
// and get the installable packages
profileDir, err := d.profilePath()
if err != nil {
return err
}
profileItems, err := nixprofile.ProfileListItems(d.stderr, profileDir)
if err != nil {
return err
}
packages, err := d.AllInstallablePackages()
if err != nil {
return err
}

// Remove non-nix packages from the list
packages = lo.Filter(packages, devpkg.IsNix)

if err := devpkg.FillNarInfoCache(ctx, packages...); err != nil {
return err
}

// Second, remove any packages from the nix-profile that are not in the config
itemsToKeep := profileItems
if mode != install {
itemsToKeep, err = d.removeExtraItemsFromProfile(ctx, profileDir, profileItems, packages)
if err != nil {
return err
}
}

// we are done if mode is uninstall
if mode == uninstall {
return nil
}

// Last, find the pending packages, and ensure they are added to the nix-profile
// Important to maintain the order of packages as specified by
// Devbox.InstallablePackages() (higher priority first)
pending := []*devpkg.Package{}
for _, pkg := range packages {
_, err := nixprofile.ProfileListIndex(&nixprofile.ProfileListIndexArgs{
Items: itemsToKeep,
Lockfile: d.lockfile,
Writer: d.stderr,
Package: pkg,
ProfileDir: profileDir,
})
if err != nil {
if !errors.Is(err, nix.ErrPackageNotFound) {
return err
}
pending = append(pending, pkg)
}
}

return d.addPackagesToProfile(ctx, pending)
}

func (d *Devbox) removeExtraItemsFromProfile(
ctx context.Context,
profileDir string,
profileItems []*nixprofile.NixProfileListItem,
packages []*devpkg.Package,
) ([]*nixprofile.NixProfileListItem, error) {
defer debug.FunctionTimer().End()
defer trace.StartRegion(ctx, "removeExtraPackagesFromProfile").End()

itemsToKeep := []*nixprofile.NixProfileListItem{}
extras := []*nixprofile.NixProfileListItem{}
// Note: because devpkg.Package uses memoization when normalizing attribute paths (slow operation),
// and since we're reusing the Package objects, this O(n*m) loop becomes O(n+m) wrt the slow operation.
for _, item := range profileItems {
found := false
for _, pkg := range packages {
if item.Matches(pkg, d.lockfile) {
itemsToKeep = append(itemsToKeep, item)
found = true
break
}
}
if !found {
extras = append(extras, item)
}
}
// Remove by index to avoid comparing nix.ProfileListItem <> nix.Inputs again.
if err := nixprofile.ProfileRemoveItems(profileDir, extras); err != nil {
return nil, err
}
return itemsToKeep, nil
}

// addPackagesToProfile inspects the packages in devbox.json, checks which of them
// are missing from the nix profile, and then installs each package individually into the
// nix profile.
func (d *Devbox) addPackagesToProfile(ctx context.Context, pkgs []*devpkg.Package) error {
defer debug.FunctionTimer().End()
defer trace.StartRegion(ctx, "addPackagesToProfile").End()

if len(pkgs) == 0 {
return nil
}

// If packages are in profile but nixpkgs has been purged, the experience
// will be poor when we try to run print-dev-env. So we ensure nixpkgs is
// prefetched for all relevant packages (those not in binary cache).
if err := devpkg.EnsureNixpkgsPrefetched(ctx, d.stderr, pkgs); err != nil {
return err
}

var msg string
if len(pkgs) == 1 {
msg = fmt.Sprintf("Installing package: %s.", pkgs[0])
} else {
pkgNames := lo.Map(pkgs, func(p *devpkg.Package, _ int) string { return p.Raw })
msg = fmt.Sprintf("Installing %d packages: %s.", len(pkgs), strings.Join(pkgNames, ", "))
}
fmt.Fprintf(d.stderr, "\n%s\n\n", msg)

profileDir, err := d.profilePath()
if err != nil {
return fmt.Errorf("error getting profile path: %w", err)
}

total := len(pkgs)
for idx, pkg := range pkgs {
stepNum := idx + 1

stepMsg := fmt.Sprintf("[%d/%d] %s", stepNum, total, pkg)

if err = nixprofile.ProfileInstall(ctx, &nixprofile.ProfileInstallArgs{
CustomStepMessage: stepMsg,
Lockfile: d.lockfile,
Package: pkg.Raw,
ProfilePath: profileDir,
Writer: d.stderr,
}); err != nil {
return fmt.Errorf("error installing package %s: %w", pkg, err)
}
}

return nil
}

var resetCheckDone = false

// resetProfileDirForFlakes ensures the profileDir directory is cleared of old
Expand Down
88 changes: 88 additions & 0 deletions internal/impl/poc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package impl

import (
"context"
"encoding/json"
"fmt"
"os/exec"
"slices"

"go.jetpack.io/devbox/internal/nix"
)

func syncFlakeToProfile(ctx context.Context, flakePath, profilePath string) error {
cmd := exec.CommandContext(ctx, "nix", "eval", "--json", flakePath+"#devShells."+nix.System()+".default.buildInputs")
b, err := cmd.Output()
if err != nil {
return fmt.Errorf("nix eval devShells: %v", err)
}
storePaths := []string{}
if err := json.Unmarshal(b, &storePaths); err != nil {
return fmt.Errorf("unmarshal store paths: %s: %v", b, err)
}

listCmd := exec.CommandContext(ctx, "nix", "profile", "list", "--json", "--profile", profilePath)
b, err = listCmd.Output()
if err != nil {
return err
}
var profile struct {
Elements []struct {
StorePaths []string
}
}
if err := json.Unmarshal(b, &profile); err != nil {
return fmt.Errorf("unmarshal profile: %v", err)
}
got := make([]string, 0, len(profile.Elements))
for _, e := range profile.Elements {
got = append(got, e.StorePaths...)
}

add, remove := diffStorePaths(got, storePaths)
if len(remove) > 0 {
removeCmd := exec.CommandContext(ctx, "nix", "profile", "remove", "--profile", profilePath)
removeCmd.Args = append(removeCmd.Args, remove...)
if err := removeCmd.Run(); err != nil {
return err
}
}
if len(add) > 0 {
addCmd := exec.CommandContext(ctx, "nix", "profile", "install", "--profile", profilePath)
addCmd.Args = append(addCmd.Args, add...)
if err := addCmd.Run(); err != nil {
return err
}
}
return nil
}

func diffStorePaths(got, want []string) (add, remove []string) {
slices.Sort(got)
slices.Sort(want)

var g, w int
for {
if g >= len(got) {
add = append(add, want[w:]...)
break
}
if w >= len(want) {
remove = append(remove, got[g:]...)
break
}

switch {
case got[g] == want[w]:
g++
w++
case got[g] < want[w]:
remove = append(remove, got[g])
g++
case got[g] > want[w]:
add = append(add, want[w])
w++
}
}
return add, remove
}